diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index f6782d3..36f20e0 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + blank_issues_enabled: false contact_links: - name: HCP Terraform and Terraform Enterprise Troubleshooting and Feature Requests diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml new file mode 100644 index 0000000..91901e3 --- /dev/null +++ b/.github/workflows/integration-test.yml @@ -0,0 +1,195 @@ +name: SDK Integration Tests + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: make dev-install + + - name: Run format check + run: make fmt-check + + - name: Run lint + run: make lint + + - name: Run unit tests + run: make type-check && .venv/bin/python -m pytest -v tests/ + + integration-tests: + name: Integration Tests (Example Files) + runs-on: ubuntu-latest + needs: unit-tests # only runs if unit tests pass + + env: + TFE_TOKEN: ${{ secrets.TFE_TOKEN }} + TFE_ORG: ${{ secrets.TFE_ORG }} + TFE_ADDRESS: ${{ secrets.TFE_ADDRESS }} + TFE_WORKSPACE_ID: ${{ secrets.TFE_WORKSPACE_ID }} + TFE_WORKSPACE_NAME: ${{ secrets.TFE_WORKSPACE_NAME }} + TFE_TEAM_ID: ${{ secrets.TFE_TEAM_ID }} + TFE_RUN_ID: ${{ secrets.TFE_RUN_ID }} + TFE_APPLY_ID: ${{ secrets.TFE_APPLY_ID }} + TFE_PLAN_ID: ${{ secrets.TFE_PLAN_ID }} + TFE_TASK_STAGE_ID: ${{ secrets.TFE_TASK_STAGE_ID }} + TFE_POLICY_SET_ID: ${{ secrets.TFE_POLICY_SET_ID }} + TFE_POLICY_NAME: ${{ secrets.TFE_POLICY_NAME }} + TFE_REG_PROV_NS: ${{ secrets.TFE_REG_PROV_NS }} + TFE_REG_PROV_NAME: ${{ secrets.TFE_REG_PROV_NAME }} + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + OAUTH_CLIENT_GITHUB_TOKEN: ${{ secrets.OAUTH_CLIENT_GITHUB_TOKEN }} + WEBHOOK_URL: ${{ secrets.WEBHOOK_URL }} + TEAMS_WEBHOOK_URL: ${{ secrets.TEAMS_WEBHOOK_URL }} + TEST_MEMBER_EMAIL: ${{ secrets.TEST_MEMBER_EMAIL }} + TEAM_MEMBERSHIP_ID: ${{secretS.TEAM_MEBERSHIP_ID}} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: make dev-install + + - name: agent.py + run: .venv/bin/python examples/agent.py + + - name: agent_pool.py + run: .venv/bin/python examples/agent_pool.py + + - name: apply.py + if: ${{ env.TFE_APPLY_ID != '' }} + run: .venv/bin/python examples/apply.py --apply-id "$TFE_APPLY_ID" + + - name: configuration_version.py + if: ${{ env.TFE_WORKSPACE_ID != '' }} + run: .venv/bin/python examples/configuration_version.py + + - name: notification_configuration.py + if: ${{ env.TFE_WORKSPACE_ID != '' || env.TFE_WORKSPACE_NAME != '' }} + run: .venv/bin/python examples/notification_configuration.py + + - name: oauth_client.py + run: .venv/bin/python examples/oauth_client.py + + - name: oauth_token.py + run: .venv/bin/python examples/oauth_token.py + + - name: org.py + run: .venv/bin/python examples/org.py + + - name: organization_membership.py + run: .venv/bin/python examples/organization_membership.py + + - name: plan.py + if: ${{ env.TFE_PLAN_ID != '' }} + run: .venv/bin/python examples/plan.py --plan-id "$TFE_PLAN_ID" + + - name: policy_check.py + if: ${{ env.TFE_RUN_ID != '' }} + run: .venv/bin/python examples/policy_check.py --run-id "$TFE_RUN_ID" + + - name: policy_evaluation.py + if: ${{ env.TFE_TASK_STAGE_ID != '' }} + run: .venv/bin/python examples/policy_evaluation.py --task-stage-id "$TFE_TASK_STAGE_ID" + + - name: policy_set_parameter.py + if: ${{ env.TFE_POLICY_SET_ID != '' }} + run: .venv/bin/python examples/policy_set_parameter.py --policy-set-id "$TFE_POLICY_SET_ID" + + - name: policy_set.py + run: .venv/bin/python examples/policy_set.py --org "$TFE_ORG" + + - name: policy.py + if: ${{ env.TFE_POLICY_NAME != '' }} + run: .venv/bin/python examples/policy.py --org "$TFE_ORG" --policy-name "$TFE_POLICY_NAME" + + - name: project.py + run: .venv/bin/python examples/project.py --list --organization "$TFE_ORG" + + - name: query_run.py + if: ${{ env.TFE_WORKSPACE_NAME != '' }} + run: .venv/bin/python examples/query_run.py + + - name: registry_module.py + run: .venv/bin/python examples/registry_module.py + + - name: registry_provider_version.py + if: ${{ env.TFE_REG_PROV_NS != '' && env.TFE_REG_PROV_NAME != '' }} + run: | + .venv/bin/python examples/registry_provider_version.py \ + --organization "$TFE_ORG" \ + --namespace "$TFE_REG_PROV_NS" \ + --name "$TFE_REG_PROV_NAME" + + - name: registry_provider.py + run: .venv/bin/python examples/registry_provider.py + + - name: reserved_tag_key.py + run: .venv/bin/python examples/reserved_tag_key.py + + - name: run_events.py + if: ${{ env.TFE_RUN_ID != '' }} + run: .venv/bin/python examples/run_events.py --run-id "$TFE_RUN_ID" + + - name: run_task.py + run: .venv/bin/python examples/run_task.py --org "$TFE_ORG" + + - name: run_trigger.py + run: .venv/bin/python examples/run_trigger.py --org "$TFE_ORG" + + - name: run.py + run: .venv/bin/python examples/run.py --organization "$TFE_ORG" + + - name: ssh_keys.py + if: ${{ env.SSH_PRIVATE_KEY != '' }} + run: .venv/bin/python examples/ssh_keys.py + + - name: state_versions.py + if: ${{ env.TFE_WORKSPACE_ID != '' && env.TFE_WORKSPACE_NAME != '' }} + run: | + .venv/bin/python examples/state_versions.py \ + --org "$TFE_ORG" \ + --workspace "$TFE_WORKSPACE_NAME" \ + --workspace-id "$TFE_WORKSPACE_ID" + + - name: variable_sets.py + run: .venv/bin/python examples/variable_sets.py + + - name: variables.py + if: ${{ env.TFE_WORKSPACE_ID != '' }} + run: .venv/bin/python examples/variables.py + + - name: workspace_resources.py + if: ${{ env.TFE_WORKSPACE_ID != '' }} + run: | + .venv/bin/python examples/workspace_resources.py \ + --list \ + --workspace-id "$TFE_WORKSPACE_ID" \ + --page-size 10 + + - name: workspace.py + run: .venv/bin/python examples/workspace.py --org "$TFE_ORG" --list diff --git a/.gitignore b/.gitignore index 17c23a0..e4dd404 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,4 @@ protos.bin # Local RSA keys .local/* + diff --git a/CHANGELOG.md b/CHANGELOG.md index 90eaf39..4db63b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,44 @@ # Unreleased +# v0.1.3 + +## Enhancements + +### Iterator Pattern Migration +* Migrated Run resource list operations to iterator pattern by @NimishaShrivastava-dev [#91](https://github.com/hashicorp/python-tfe/pull/91) +* Migrated Policy resource list operations to iterator pattern by @TanyaSingh369-svg [#92](https://github.com/hashicorp/python-tfe/pull/92) +* Migrated Policy Set resource list operations to iterator pattern by @TanyaSingh369-svg [#95](https://github.com/hashicorp/python-tfe/pull/95) +* Migrated Run Event resource list operations to iterator pattern by @NimishaShrivastava-dev [#97](https://github.com/hashicorp/python-tfe/pull/97) +* Migrated SSH Keys resource list operations to iterator pattern by @NimishaShrivastava-dev [#101](https://github.com/hashicorp/python-tfe/pull/101) +* Migrated Notification Configuration resource list operations to iterator pattern by @TanyaSingh369-svg [#109](https://github.com/hashicorp/python-tfe/pull/109) +* Migrated Variable Set list operations to iterator pattern by @isivaselvan [#113](https://github.com/hashicorp/python-tfe/pull/113) +* Migrated Variable Set Variables list operations to iterator pattern by @isivaselvan [#113](https://github.com/hashicorp/python-tfe/pull/113) +* Migrated State Version list operations to iterator pattern by @isivaselvan [#113](https://github.com/hashicorp/python-tfe/pull/113) +* Migrated State Version Output list operations to iterator pattern by @isivaselvan [#113](https://github.com/hashicorp/python-tfe/pull/113) +* Migrated Policy Check list operations to iterator pattern by @isivaselvan [#113](https://github.com/hashicorp/python-tfe/pull/113) +* Refreshed examples and unit tests to align with iterator pattern updates by @NimishaShrivastava-dev, @TanyaSingh369-svg, @isivaselvan [#91](https://github.com/hashicorp/python-tfe/pull/91) [#92](https://github.com/hashicorp/python-tfe/pull/92) [#95](https://github.com/hashicorp/python-tfe/pull/95) [#97](https://github.com/hashicorp/python-tfe/pull/97) [#101](https://github.com/hashicorp/python-tfe/pull/101) [#109](https://github.com/hashicorp/python-tfe/pull/109) [#113](https://github.com/hashicorp/python-tfe/pull/113) + +### Project and Workspace Management +* Updated Project create and update models, including Project model refinements by @isivaselvan [#120](https://github.com/hashicorp/python-tfe/pull/120) +* Updated Project endpoints for list-effective-tag-bindings and delete-tag-bindings by @isivaselvan [#120](https://github.com/hashicorp/python-tfe/pull/120) +* Refactored Workspace models to improve validation with Pydantic by @isivaselvan [#106](https://github.com/hashicorp/python-tfe/pull/106) + +## Breaking Change + +### List Method Behavior +* Standardized list methods across multiple resources to iterator-based behavior, replacing legacy list response patterns by @NimishaShrivastava-dev, @TanyaSingh369-svg, @isivaselvan [#91](https://github.com/hashicorp/python-tfe/pull/91) [#92](https://github.com/hashicorp/python-tfe/pull/92) [#95](https://github.com/hashicorp/python-tfe/pull/95) [#97](https://github.com/hashicorp/python-tfe/pull/97) [#101](https://github.com/hashicorp/python-tfe/pull/101) [#109](https://github.com/hashicorp/python-tfe/pull/109) [#113](https://github.com/hashicorp/python-tfe/pull/113) + +## Bug Fixes +* Fixed pagination parameter handling across iterator-based page traversal by @isivaselvan [#111](https://github.com/hashicorp/python-tfe/pull/111) +* Fixed state version and state version output model import/export registration by @isivaselvan [#105](https://github.com/hashicorp/python-tfe/pull/105) +* Fixed the tag based filtering of workspace in list operation by @isivaselvan [#106](https://github.com/hashicorp/python-tfe/pull/106) +* Fixed the project response of workspace relationship by @isivaselvan [#106](https://github.com/hashicorp/python-tfe/pull/106) +* Fixed configuration version examples and added terraform+cloud support for ConfigurationSource usage by @isivaselvan [#107](https://github.com/hashicorp/python-tfe/pull/107) +* Fixed configuration upload packaging flow (tarfile-based handling) by @isivaselvan [#107](https://github.com/hashicorp/python-tfe/pull/107) +* Updated agent pool workspace assign/remove operations to consistently return AgentPool objects by @KshitijaChoudhari [#110](https://github.com/hashicorp/python-tfe/pull/110) +* Updated Run relationships handling for improved model consistency by @ibm-richard [#119](https://github.com/hashicorp/python-tfe/pull/119) +* Updated additional Run Source attributes by @isivaselvan [#123](https://github.com/hashicorp/python-tfe/pull/123) + # v0.1.2 ## Features diff --git a/Makefile b/Makefile index 8eb5654..91d61b8 100644 --- a/Makefile +++ b/Makefile @@ -70,4 +70,4 @@ clean: find . -type d -name ".ruff_cache" -exec rm -rf {} + rm -rf build/ dist/ $(VENV) -all: clean dev-install fmt lint test +all: clean dev-install fmt lint test \ No newline at end of file diff --git a/bin/publish-pypi.sh b/bin/publish-pypi.sh index 9185a93..17270db 100755 --- a/bin/publish-pypi.sh +++ b/bin/publish-pypi.sh @@ -1,4 +1,7 @@ #!/usr/bin/env bash +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + set -euo pipefail diff --git a/examples/agent.py b/examples/agent.py index c80eb62..3f046c7 100644 --- a/examples/agent.py +++ b/examples/agent.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Simple Individual Agent operations example with the TFE Python SDK. This example demonstrates: diff --git a/examples/agent_pool.py b/examples/agent_pool.py index bbaf14e..bcb04ae 100644 --- a/examples/agent_pool.py +++ b/examples/agent_pool.py @@ -1,15 +1,19 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Simple Agent Pool operations example with the TFE Python SDK. This example demonstrates: 1. Agent Pool CRUD operations (Create, Read, Update, Delete) 2. Agent token creation and management -3. Using the organization SDK client +3. Workspace assignment using assign_to_workspaces and remove_from_workspaces 4. Proper error handling Make sure to set the following environment variables: - TFE_TOKEN: Your Terraform Cloud/Enterprise API token - TFE_ADDRESS: Your Terraform Cloud/Enterprise URL (optional, defaults to https://app.terraform.io) - TFE_ORG: Your organization name +- TFE_WORKSPACE_ID: A workspace ID for testing workspace assignment (optional) Usage: export TFE_TOKEN="your-token-here" @@ -24,8 +28,10 @@ from pytfe.errors import NotFound from pytfe.models import ( AgentPoolAllowedWorkspacePolicy, + AgentPoolAssignToWorkspacesOptions, AgentPoolCreateOptions, AgentPoolListOptions, + AgentPoolRemoveFromWorkspacesOptions, AgentPoolUpdateOptions, AgentTokenCreateOptions, ) @@ -37,6 +43,9 @@ def main(): token = os.environ.get("TFE_TOKEN") org = os.environ.get("TFE_ORG") address = os.environ.get("TFE_ADDRESS", "https://app.terraform.io") + workspace_id = os.environ.get( + "TFE_WORKSPACE_ID" + ) # optional, for workspace assignment if not token: print("TFE_TOKEN environment variable is required") @@ -96,7 +105,27 @@ def main(): updated_pool = client.agent_pools.update(new_pool.id, update_options) print(f"Updated agent pool name to: {updated_pool.name}") - # Example 5: Create an agent token + # Example 5: Workspace assignment + # assign_to_workspaces sends PATCH /agent-pools/:id with relationships.allowed-workspaces + # remove_from_workspaces sends PATCH /agent-pools/:id with relationships.excluded-workspaces + if workspace_id: + print("\n Assigning workspace to agent pool...") + updated_pool = client.agent_pools.assign_to_workspaces( + new_pool.id, + AgentPoolAssignToWorkspacesOptions(workspace_ids=[workspace_id]), + ) + print(f" Assigned workspace {workspace_id} to pool {updated_pool.name}") + + print("\n Removing workspace from agent pool...") + updated_pool = client.agent_pools.remove_from_workspaces( + new_pool.id, + AgentPoolRemoveFromWorkspacesOptions(workspace_ids=[workspace_id]), + ) + print(f" Removed workspace {workspace_id} from pool {updated_pool.name}") + else: + print("\n Skipping workspace assignment (set TFE_WORKSPACE_ID to test)") + + # Example 6: Create an agent token print("\n Creating agent token...") token_options = AgentTokenCreateOptions( description="SDK example token" # Optional description @@ -107,7 +136,7 @@ def main(): if agent_token.token: print(f" Token (first 10 chars): {agent_token.token[:10]}...") - # Example 6: List agent tokens + # Example 7: List agent tokens print("\n Listing agent tokens...") tokens = client.agent_tokens.list(new_pool.id) @@ -117,7 +146,7 @@ def main(): for token in token_list: print(f" - {token.description or 'No description'} (ID: {token.id})") - # Example 7: Clean up - delete the token and pool + # Example 8: Clean up - delete the token and pool print("\n Cleaning up...") client.agent_tokens.delete(agent_token.id) print("Deleted agent token") diff --git a/examples/apply.py b/examples/apply.py index 44fd443..ea72dfa 100644 --- a/examples/apply.py +++ b/examples/apply.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations import argparse diff --git a/examples/configuration_version.py b/examples/configuration_version.py index 87fa6d1..72d03bf 100644 --- a/examples/configuration_version.py +++ b/examples/configuration_version.py @@ -1,4 +1,7 @@ #!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """ Complete Configuration Version Testing Suite @@ -175,7 +178,7 @@ def main(): # Initialize the TFE client client = TFEClient(TFEConfig.from_env()) - workspace_id = "ws-zLgDCHFz9mBfri2Q" # Replace with your workspace ID + workspace_id = os.getenv("TFE_WORKSPACE_ID") # Replace with your workspace ID # Variables to store created resources for dependent tests created_cv_id = None @@ -277,29 +280,8 @@ def main(): print(f" - {filename} ({size} bytes)") try: - # Create tar.gz archive manually since go-slug isn't available - print("Creating tar.gz archive manually...") - - import tarfile - - # Create tar.gz archive in memory - archive_buffer = io.BytesIO() - with tarfile.open(fileobj=archive_buffer, mode="w:gz") as tar: - # Add all files from the temp directory - for filename in files: - filepath = os.path.join(temp_dir, filename) - tar.add(filepath, arcname=filename) - - archive_buffer.seek(0) - archive_bytes = archive_buffer.getvalue() - print(f"Created archive: {len(archive_bytes)} bytes") - - # Use the SDK's upload_tar_gzip method instead of direct HTTP calls - print("Uploading archive using SDK method...") - archive_buffer.seek(0) # Reset buffer position - client.configuration_versions.upload_tar_gzip( - new_cv.upload_url, archive_buffer - ) + print("Uploading Terraform configuration...") + client.configuration_versions.upload(new_cv.upload_url, temp_dir) print("Terraform configuration uploaded successfully!") # Wait and check status @@ -408,8 +390,6 @@ def main(): # ===================================================== # TEST 4: UPLOAD CONFIGURATION VERSION # ===================================================== - # Test 4: Upload function (requires go-slug) - # ===================================================== print("\n4. Testing upload() function:") try: # Create a fresh configuration version specifically for upload testing @@ -441,33 +421,19 @@ def main(): print(f"\n Uploading configuration to CV: {fresh_cv.id}") print(f"Upload URL: {upload_url[:60]}...") - try: - client.configuration_versions.upload(upload_url, temp_dir) - print("Configuration uploaded successfully!") + client.configuration_versions.upload(upload_url, temp_dir) + print("Configuration uploaded successfully!") - # Check status after upload - print("\n Checking status after upload:") - time.sleep(3) # Give TFE time to process - updated_cv = client.configuration_versions.read(fresh_cv.id) - print(f"Status after upload: {updated_cv.status}") + # Check status after upload + print("\n Checking status after upload:") + time.sleep(3) # Give TFE time to process + updated_cv = client.configuration_versions.read(fresh_cv.id) + print(f"Status after upload: {updated_cv.status}") - if updated_cv.status.value != "pending": - print("Status changed (upload processed)") - else: - print("Status still pending (may need more time)") - - except ImportError as e: - if "go-slug" in str(e): - print("go-slug package not available") - print("Install with: pip install go-slug") - print( - "Upload function exists but requires go-slug for packaging" - ) - print( - "Function correctly raises ImportError when go-slug unavailable" - ) - else: - raise + if updated_cv.status.value != "pending": + print("Status changed (upload processed)") + else: + print("Status still pending (may need more time)") except Exception as e: print(f"Error: {e}") @@ -868,7 +834,7 @@ def main(): "TEST 2: create() - Create new configuration versions with different options" ) print("TEST 3: read() - Read configuration version details and validate fields") - print("TEST 4: upload() - Upload Terraform configurations (requires go-slug)") + print("TEST 4: upload() - Upload Terraform configurations (stdlib tarfile)") print("TEST 5: download() - Download configuration version archives") print("TEST 6: archive() - Archive configuration versions") print("TEST 7: read_with_options() - Read with include options") diff --git a/examples/notification_configuration.py b/examples/notification_configuration.py index 789367f..178c130 100644 --- a/examples/notification_configuration.py +++ b/examples/notification_configuration.py @@ -1,4 +1,7 @@ #!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """ Example usage of Notification Configuration API @@ -32,12 +35,18 @@ def main(): print("=== Python TFE Notification Configuration Example ===\n") - # Resolve workspace and team from environment (fallback to demo placeholders) - workspace_id = os.getenv("TFE_WORKSPACE_ID", "ws-example123456789") - workspace_name = os.getenv("TFE_ORG", "your-workspace-name") + # Resolve organization and workspace from environment variables + org_name = os.environ["TFE_ORG"] + workspace_name = os.getenv("TFE_WORKSPACE_NAME") + workspace_id = os.getenv("TFE_WORKSPACE_ID") + if not workspace_id: + print(f"Looking up workspace '{workspace_name}' in org '{org_name}'...") + ws = client.workspaces.read(workspace_name, organization=org_name) + workspace_id = ws.id + print(f"Resolved workspace ID: {workspace_id}") print(f"Using workspace: {workspace_name} (ID: {workspace_id})") - team_id = os.getenv("TFE_TEAM_ID", "team-example123456789") + team_id = os.getenv("TFE_TEAM_ID") if team_id == "team-example123456789": print("Using fake team ID for demonstration (teams may require paid plan)") else: @@ -47,13 +56,13 @@ def main(): # ===== List notification configurations for workspace ===== print("1. Listing notification configurations for workspace...") try: - workspace_notifications = client.notification_configurations.list( - subscribable_id=workspace_id + workspace_notifications_list = list( + client.notification_configurations.list(subscribable_id=workspace_id) ) print( - f"Found {len(workspace_notifications.items)} notification configurations" + f"Found {len(workspace_notifications_list)} notification configurations" ) - for nc in workspace_notifications.items: + for nc in workspace_notifications_list: print(f"- {nc.name} (ID: {nc.id}, Enabled: {nc.enabled})") except Exception as e: print(f"Error listing workspace notifications: {e}") @@ -69,13 +78,15 @@ def main(): options = NotificationConfigurationListOptions( subscribable_choice=team_choice ) - team_notifications = client.notification_configurations.list( - subscribable_id=team_id, options=options + team_notifications_list = list( + client.notification_configurations.list( + subscribable_id=team_id, options=options + ) ) print( - f"Found {len(team_notifications.items)} team notification configurations" + f"Found {len(team_notifications_list)} team notification configurations" ) - for nc in team_notifications.items: + for nc in team_notifications_list: print(f"- {nc.name} (ID: {nc.id}, Enabled: {nc.enabled})") except Exception as e: error_msg = str(e).lower() @@ -93,16 +104,20 @@ def main(): workspace_choice = NotificationConfigurationSubscribableChoice( workspace={"id": workspace_id} ) - slack_url = os.getenv( + # Use GENERIC destination type with a URL that returns HTTP 200. + # SLACK/MICROSOFT_TEAMS destinations are auto-verified by HCP Terraform + # at creation time; a fake Slack URL returns 302 and causes the create + # call to fail immediately. GENERIC webhooks + httpbin always succeed. + webhook_url = os.getenv( "WEBHOOK_URL", - "https://hooks.slack.com/services/YOUR_SLACK_WORKSPACE/YOUR_CHANNEL/YOUR_WEBHOOK_TOKEN", + "https://httpbin.org/status/200", ) create_options = NotificationConfigurationCreateOptions( - destination_type=NotificationDestinationType.SLACK, + destination_type=NotificationDestinationType.GENERIC, enabled=True, - name="Python TFE Example Slack Notification", + name="Python TFE Example Generic Notification", subscribable_choice=workspace_choice, - url=slack_url, + url=webhook_url, triggers=[ NotificationTriggerType.COMPLETED, NotificationTriggerType.ERRORED, @@ -175,12 +190,17 @@ def main(): except Exception as e: error_msg = str(e).lower() - if "verification failed" in error_msg and "404" in error_msg: - print(" Webhook verification failed (expected with fake URL)") - print("The fake Slack URL returns 404 - this is normal for testing") - print("To test real verification, use a webhook from:") - print("webhook.site (instant test URL)") - print("Slack, Teams, or Discord webhook") + if "verification failed" in error_msg and ( + "404" in error_msg or "302" in error_msg + ): + print("Webhook verification failed (expected with fake URL)") + print( + "The URL returned a non-200 response - this is normal for testing" + ) + print("To test real verification, use a webhook from webhook.site,") + print( + "Slack, Teams, or Discord, or set WEBHOOK_URL=https://httpbin.org/status/200" + ) else: print(f" Error in workspace notification operations: {e}") diff --git a/examples/oauth_client.py b/examples/oauth_client.py index e9cf62a..1e39a1c 100644 --- a/examples/oauth_client.py +++ b/examples/oauth_client.py @@ -1,4 +1,7 @@ #!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """ Complete OAuth Client Testing Suite @@ -61,7 +64,7 @@ def main(): # Initialize the TFE client client = TFEClient(TFEConfig.from_env()) - organization_name = "aayush-test" # Replace with your organization + organization_name = os.getenv("TFE_ORG") # Variables to store created resources for dependent tests created_oauth_client = None diff --git a/examples/oauth_token.py b/examples/oauth_token.py index 4a31bfc..d7a8056 100644 --- a/examples/oauth_token.py +++ b/examples/oauth_token.py @@ -1,4 +1,7 @@ #!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """ Complete OAuth Token Testing Suite diff --git a/examples/org.py b/examples/org.py index 6538f8d..8f4497a 100644 --- a/examples/org.py +++ b/examples/org.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from pytfe import TFEClient, TFEConfig from pytfe.models import ( DataRetentionPolicyDeleteOlderSetOptions, diff --git a/examples/organization_membership.py b/examples/organization_membership.py index da6d45d..1b7c332 100644 --- a/examples/organization_membership.py +++ b/examples/organization_membership.py @@ -1,4 +1,7 @@ #!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """ Example and test script for organization membership list functionality. @@ -11,6 +14,7 @@ python examples/organization_membership.py """ +import os import sys from pytfe import TFEClient @@ -25,7 +29,8 @@ def main(): """Demonstrate organization membership list functionality.""" - organization_name = "aayush-test" + organization_name = os.getenv("TFE_ORG") + team_id = os.getenv("TFE_TEAM_ID") # Initialize the client (reads TFE_TOKEN and TFE_ADDRESS from environment) try: @@ -269,13 +274,13 @@ def main(): from pytfe.models import OrganizationMembershipCreateOptions, Team # Replace with a valid email for your organization - new_member_email = "sivaselvan.i@hashicorp.com" + new_member_email = os.getenv("TEST_MEMBER_EMAIL") # Create membership with teams (uncomment to use) from pytfe.models import OrganizationAccess team = Team( - id="team-dx24FR9xQUuwNTHA", + id=team_id, organization_access=OrganizationAccess(read_workspaces=True), ) # Replace with actual team ID create_options = OrganizationMembershipCreateOptions( @@ -297,7 +302,9 @@ def main(): try: from pytfe.errors import NotFound - membership_id = "ou-9mG77c6uE5GScg9k" # Replace with actual membership ID + membership_id = os.getenv( + "TFE_MEMBERSHIP_ID" + ) # Replace with actual membership ID print(f"Attempting to delete membership: {membership_id}") client.organization_memberships.delete(membership_id) diff --git a/examples/plan.py b/examples/plan.py index 41b1e77..8910bd0 100644 --- a/examples/plan.py +++ b/examples/plan.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations import argparse diff --git a/examples/policy.py b/examples/policy.py index 74352f4..ac665fd 100644 --- a/examples/policy.py +++ b/examples/policy.py @@ -1,4 +1,7 @@ #!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Policy management example for python-tfe SDK. This example demonstrates how to use the Policy API to: @@ -82,7 +85,6 @@ def main(): _print_header(f"Listing policies in organization: {args.org}") list_options = PolicyListOptions( - page_number=args.page, page_size=args.page_size, ) @@ -95,12 +97,8 @@ def main(): policy_list = client.policies.list(args.org, list_options) - print(f"Total policies: {policy_list.total_count}") - print(f"Page {policy_list.current_page} of {policy_list.total_pages}") - print() - existing_policy = None - for policy in policy_list.items: + for policy in policy_list: print( f"- {policy.id} | {policy.name} | kind={policy.kind} | enforcement={policy.enforcement_level}" ) diff --git a/examples/policy_check.py b/examples/policy_check.py index 67f2081..ffa77f3 100644 --- a/examples/policy_check.py +++ b/examples/policy_check.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations import argparse @@ -35,7 +38,6 @@ def main(): action="store_true", help="Get logs for the specified policy check", ) - parser.add_argument("--page", type=int, default=1) parser.add_argument("--page-size", type=int, default=20) args = parser.parse_args() @@ -50,21 +52,19 @@ def main(): _print_header(f"Listing policy checks for run: {args.run_id}") options = PolicyCheckListOptions( - page_number=args.page, page_size=args.page_size, ) try: - pc_list = client.policy_checks.list(args.run_id, options) + pc_list = list(client.policy_checks.list(args.run_id, options)) - print(f"Total policy checks: {pc_list.total_count}") - print(f"Page {pc_list.current_page} of {pc_list.total_pages}") + print(f"Total policy checks fetched: {len(pc_list)}") print() - if not pc_list.items: + if not pc_list: print("No policy checks found for this run.") else: - for pc in pc_list.items: + for pc in pc_list: print(f"- ID: {pc.id}") print(f"Status: {pc.status}") print(f"Scope: {pc.scope}") diff --git a/examples/policy_evaluation.py b/examples/policy_evaluation.py index d7cb2fd..7a6500b 100644 --- a/examples/policy_evaluation.py +++ b/examples/policy_evaluation.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations import argparse @@ -76,7 +79,9 @@ def main(): print(f"- Errored At: {pe.status_timestamp.errored_at}") if pe.policy_attachable: - print(f"Task Stage: {pe.task_stage.id} ({pe.task_stage.type})") + print( + f"Task Stage ID: {pe.policy_attachable.id} ({pe.policy_attachable.type})" + ) if pe.created_at: print(f"Created At: {pe.created_at}") diff --git a/examples/policy_set.py b/examples/policy_set.py index 1808d80..69ca88e 100644 --- a/examples/policy_set.py +++ b/examples/policy_set.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations import argparse @@ -163,16 +166,15 @@ def main(): ) try: - ps_list = client.policy_sets.list(args.org, list_options) + ps_list = list(client.policy_sets.list(args.org, list_options)) - print(f"Total policy sets: {ps_list.total_count}") - print(f"Page {ps_list.current_page} of {ps_list.total_pages}") + print(f"Total policy sets: {len(ps_list)}") print() - if not ps_list.items: + if not ps_list: print("No policy sets found for this organization.") else: - for ps in ps_list.items: + for ps in ps_list: print( f"- ID: {ps.id} | Name: {ps.name} | Kind: {ps.kind} | Global: {ps.Global}" ) diff --git a/examples/policy_set_parameter.py b/examples/policy_set_parameter.py index 9ffdf71..70e1f63 100644 --- a/examples/policy_set_parameter.py +++ b/examples/policy_set_parameter.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations import argparse diff --git a/examples/project.py b/examples/project.py index 7702b09..f6f500b 100644 --- a/examples/project.py +++ b/examples/project.py @@ -1,849 +1,313 @@ -""" -Comprehensive Integration Test for python-tfe Projects CRUD Operations - -This file tests all CRUD operations: -- List: Get all projects in an organization -- Create: Add new projects with validation -- Read: Get specific project details -- Update: Modify existing projects -- Delete: Remove projects - -Setup Instructions: -1. Create a test organization in HCP Terraform (https://app.terraform.io) -2. Generate an organization or user API token with appropriate permissions -3. Set environment variables: - export TFE_TOKEN="your-api-token-here" - export TFE_ORG="your-test-organization-name" -4. Run the tests: - pytest examples/project.py -v -s - -Important Notes: -- These tests make real API calls and create/delete actual resources -- Always use a dedicated test organization, never production -- Tests will fail if you don't have proper permissions -- Clean up is automatic, but verify resources are deleted after testing -""" +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 +from __future__ import annotations + +import argparse import os import uuid -import pytest - -from pytfe._http import HTTPTransport -from pytfe.config import TFEConfig -from pytfe.errors import NotFound +from pytfe import TFEClient, TFEConfig from pytfe.models import ( ProjectAddTagBindingsOptions, ProjectCreateOptions, ProjectListOptions, + ProjectSettingOverwrites, ProjectUpdateOptions, TagBinding, ) -from pytfe.resources.projects import Projects - - -@pytest.fixture -def integration_client(): - """Create a real Projects client for integration testing""" - token = os.environ.get("TFE_TOKEN") - org = os.environ.get("TFE_ORG") - - if not token: - pytest.skip( - "TFE_TOKEN environment variable is required. " - "Get your token from HCP Terraform: Settings API Tokens" - ) - - if not org: - pytest.skip( - "TFE_ORG environment variable is required. " - "Use your organization name from HCP Terraform URL" - ) - - print(f"\n Testing against organization: {org}") - print(f"Using token: {token[:10]}...") - - config = TFEConfig() - - try: - transport = HTTPTransport( - config.address, - token, - timeout=config.timeout, - verify_tls=config.verify_tls, - user_agent_suffix=None, - max_retries=3, - backoff_base=0.1, - backoff_cap=1.0, - backoff_jitter=True, - http2=False, - proxies=None, - ca_bundle=None, - ) - except Exception as e: - pytest.fail(f"Failed to create HTTP transport: {e}") - - return Projects(transport), org - - -def test_list_projects_integration(integration_client): - """Test LIST operation - Get all projects in organization - - This is the safest test to run first - it only reads data. - Tests: projects.list(organization, options) - """ - projects, org = integration_client - try: - # Test basic list without options - print("Testing LIST operation: basic list") - project_list = list(projects.list(org)) - print(f"Found {len(project_list)} projects in organization '{org}'") - assert isinstance(project_list, list) - - if project_list: - project = project_list[0] - assert hasattr(project, "id"), "Project should have an ID" - assert hasattr(project, "name"), "Project should have a name" - assert hasattr(project, "organization"), ( - "Project should have an organization" - ) - assert hasattr(project, "description"), "Project should have a description" - assert hasattr(project, "created_at"), "Project should have created_at" - assert hasattr(project, "updated_at"), "Project should have updated_at" - print(f"Example project: {project.name} (ID: {project.id})") - print(f"Created: {project.created_at}, Updated: {project.updated_at}") +def _print_header(title: str) -> None: + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def _org_display(project) -> str: + """Render organization safely for both string and object representations.""" + org = getattr(project, "organization", None) + if org is None: + return "" + if isinstance(org, str): + return org + return getattr(org, "id", str(org)) + + +def _parse_tag_pairs(tag_pairs: list[str] | None) -> list[TagBinding]: + """Convert --tag key=value args into TagBinding models.""" + if not tag_pairs: + return [] + + tags: list[TagBinding] = [] + for pair in tag_pairs: + if "=" in pair: + key, value = pair.split("=", 1) + key = key.strip() + value = value.strip() + if not key: + raise ValueError(f"Invalid tag format '{pair}'. Key is empty.") + tags.append(TagBinding(key=key, value=value)) else: - print("No projects found - this is normal for a new organization") - - # Test list with options - print("Testing LIST operation: with options") - list_options = ProjectListOptions(page_size=5) - project_list_with_options = list(projects.list(org, list_options)) - print(f"List with options returned {len(project_list_with_options)} projects") - - except Exception as e: - pytest.fail( - f"LIST operation failed. Check your TFE_TOKEN and TFE_ORG. Error: {e}" - ) - - -def test_create_project_integration(integration_client): - """Test CREATE operation - Add new projects + key = pair.strip() + if not key: + raise ValueError(f"Invalid tag format '{pair}'.") + tags.append(TagBinding(key=key, value=None)) + return tags - Tests: projects.create(organization, options) - Validates: ProjectCreateOptions with name and description - """ - projects, org = integration_client - unique_id = str(uuid.uuid4())[:8] - test_name = f"create-test-{unique_id}" - test_description = f"Integration test project created at {unique_id}" - project_id = None +def main() -> None: + parser = argparse.ArgumentParser(description="Projects demo for python-tfe SDK") - try: - # Test CREATE operation - print(f"Testing CREATE operation: {test_name}") - create_options = ProjectCreateOptions( - name=test_name, description=test_description - ) - created_project = projects.create(org, create_options) - - # Validate created project - assert created_project.name == test_name, ( - f"Expected name {test_name}, got {created_project.name}" - ) - assert created_project.description == test_description, ( - f"Expected description {test_description}, got {created_project.description}" - ) - assert created_project.organization == org, ( - f"Expected org {org}, got {created_project.organization}" - ) - assert created_project.id.startswith("prj-"), ( - f"Project ID should start with 'prj-', got {created_project.id}" - ) - assert created_project.workspace_count == 0, ( - "New project should have 0 workspaces" - ) - - project_id = created_project.id - print(f"CREATE successful: {project_id}") - print( - f"Project details: {created_project.name} - {created_project.description}" - ) - - except Exception as e: - pytest.fail(f"CREATE operation failed: {e}") - - finally: - # Clean up created project - if project_id: - try: - print(f"Cleaning up created project: {project_id}") - projects.delete(project_id) - print("Cleanup successful") - except Exception as e: - print(f"Warning: Failed to clean up project {project_id}: {e}") - - -def test_read_project_integration(integration_client): - """Test READ operation - Get specific project details - - Tests: projects.read(project_id, include) - Creates a project, reads it, then cleans up - """ - projects, org = integration_client - - unique_id = str(uuid.uuid4())[:8] - test_name = f"read-test-{unique_id}" - project_id = None - - try: - # Create a project to read - print(f"� Creating project for READ test: {test_name}") - create_options = ProjectCreateOptions( - name=test_name, description="Project for read test" - ) - created_project = projects.create(org, create_options) - project_id = created_project.id - - # Test READ operation - print(f"Testing READ operation: {project_id}") - read_project = projects.read(project_id) - - # Validate read project - assert read_project.id == project_id, ( - f"Expected ID {project_id}, got {read_project.id}" - ) - assert read_project.name == test_name, ( - f"Expected name {test_name}, got {read_project.name}" - ) - assert read_project.organization == org, ( - f"Expected org {org}, got {read_project.organization}" - ) - assert hasattr(read_project, "created_at"), "Project should have created_at" - assert hasattr(read_project, "updated_at"), "Project should have updated_at" - - print(f"READ successful: {read_project.name}") - print(f"Project created: {read_project.created_at}") - - # Note: Projects API doesn't support include parameters in the current API version - print("READ operation completed successfully") - - except Exception as e: - pytest.fail(f"READ operation failed: {e}") - - finally: - # Clean up created project - if project_id: - try: - print(f"Cleaning up read test project: {project_id}") - projects.delete(project_id) - print("Cleanup successful") - except Exception as e: - print(f"Warning: Failed to clean up project {project_id}: {e}") - - -def test_update_project_integration(integration_client): - """Test UPDATE operation - Modify existing projects - - Tests: projects.update(project_id, options) - Validates: ProjectUpdateOptions with name and description changes - """ - projects, org = integration_client - - unique_id = str(uuid.uuid4())[:8] - original_name = f"update-test-{unique_id}" - updated_name = f"updated-test-{unique_id}" - original_description = "Original description for update test" - updated_description = "Updated description for update test" - project_id = None - - try: - # Create a project to update - print(f"Creating project for UPDATE test: {original_name}") - create_options = ProjectCreateOptions( - name=original_name, description=original_description - ) - created_project = projects.create(org, create_options) - project_id = created_project.id + parser.add_argument( + "--address", + default=os.getenv("TFE_ADDRESS", "https://app.terraform.io"), + help="TFE/TFC address", + ) + parser.add_argument( + "--token", + default=os.getenv("TFE_TOKEN", ""), + help="TFE/TFC API token", + ) + parser.add_argument( + "--organization", + default=os.getenv("TFE_ORG", ""), + help="Organization name", + ) + parser.add_argument( + "--page-size", + type=int, + default=20, + help="Page size for project listing", + ) - # Test UPDATE operation - name only - print("Testing UPDATE operation: name only") - update_options = ProjectUpdateOptions(name=updated_name) - updated_project = projects.update(project_id, update_options) + parser.add_argument("--list", action="store_true", help="List projects") + parser.add_argument("--create", action="store_true", help="Create a project") + parser.add_argument("--read", action="store_true", help="Read a project") + parser.add_argument("--update", action="store_true", help="Update a project") + parser.add_argument("--delete", action="store_true", help="Delete a project") - assert updated_project.id == project_id, ( - f"Project ID should remain {project_id}" - ) - assert updated_project.name == updated_name, ( - f"Expected updated name {updated_name}, got {updated_project.name}" - ) - assert updated_project.description == original_description, ( - "Description should remain unchanged" - ) - print(f"UPDATE name successful: {updated_project.name}") + parser.add_argument( + "--list-tag-bindings", + action="store_true", + help="List project tag bindings", + ) + parser.add_argument( + "--list-effective-tag-bindings", + action="store_true", + help="List project effective tag bindings", + ) + parser.add_argument( + "--add-tag-bindings", + action="store_true", + help="Add/replace tag bindings on project", + ) + parser.add_argument( + "--delete-tag-bindings", + action="store_true", + help="Delete all tag bindings from project", + ) - # Test UPDATE operation - description only - print("Testing UPDATE operation: description only") - update_options = ProjectUpdateOptions(description=updated_description) - updated_project = projects.update(project_id, update_options) + parser.add_argument( + "--project-id", + help="Project ID for read/update/delete/tag operations", + ) + parser.add_argument("--name", help="Project name for create/update") + parser.add_argument("--description", help="Project description for create/update") + parser.add_argument( + "--tag", + action="append", + default=[], + help="Tag binding in key=value format (repeatable)", + ) + parser.add_argument( + "--create-random", + action="store_true", + help="Append a short random suffix to --name for create", + ) - assert updated_project.name == updated_name, "Name should remain unchanged" - assert updated_project.description == updated_description, ( - f"Expected updated description {updated_description}, got {updated_project.description}" - ) - print("UPDATE description successful") + args = parser.parse_args() - # Test UPDATE operation - both name and description - final_name = f"final-{unique_id}" - final_description = "Final description for update test" - print("Testing UPDATE operation: both name and description") - update_options = ProjectUpdateOptions( - name=final_name, description=final_description - ) - updated_project = projects.update(project_id, update_options) + if not args.token: + raise SystemExit("Error: --token or TFE_TOKEN is required") - assert updated_project.name == final_name, ( - f"Expected final name {final_name}, got {updated_project.name}" - ) - assert updated_project.description == final_description, ( - f"Expected final description {final_description}, got {updated_project.description}" - ) - print(f"UPDATE both fields successful: {updated_project.name}") + cfg = TFEConfig(address=args.address, token=args.token) + client = TFEClient(cfg) - except Exception as e: - pytest.fail(f"UPDATE operation failed: {e}") + has_org_op = args.list or args.create + has_project_op = ( + args.read + or args.update + or args.delete + or args.list_tag_bindings + or args.list_effective_tag_bindings + or args.add_tag_bindings + or args.delete_tag_bindings + ) - finally: - # Clean up created project - if project_id: - try: - print(f"Cleaning up update test project: {project_id}") - projects.delete(project_id) - print("Cleanup successful") - except Exception as e: - print(f"Warning: Failed to clean up project {project_id}: {e}") + if has_org_op and not args.organization: + raise SystemExit("Error: --organization or TFE_ORG is required") + if has_project_op and not args.project_id: + raise SystemExit("Error: --project-id is required for selected operation") -def test_delete_project_integration(integration_client): - """Test DELETE operation - Remove projects + # 1) List projects + if args.list: + _print_header(f"Listing projects for organization: {args.organization}") + list_options = ProjectListOptions(page_size=args.page_size) - Tests: projects.delete(project_id) - Creates a project, deletes it, verifies it's gone - """ - projects, org = integration_client + count = 0 + for project in client.projects.list(args.organization, list_options): + count += 1 + print(f"- {project.name} (ID: {project.id})") + print(f" Description: {project.description}") + print(f" Workspaces: {project.workspace_count}") + print(f" Default execution mode: {project.default_execution_mode}") + print( + f" Auto destroy activity duration: {project.auto_destroy_activity_duration}" + ) + print(f" Created at: {project.created_at}") + print(f" Updated at: {project.updated_at}") + print(f" Setting overwrites: {project.setting_overwrites}") + print(f" Default agent pool: {project.default_agent_pool}") + print(f" Organization: {_org_display(project)}") - unique_id = str(uuid.uuid4())[:8] - test_name = f"delete-test-{unique_id}" - project_id = None + print() - try: - # Create a project to delete - print(f"Creating project for DELETE test: {test_name}") - create_options = ProjectCreateOptions( - name=test_name, description="Project for delete test" - ) - created_project = projects.create(org, create_options) - project_id = created_project.id - print(f"Project created for deletion: {project_id}") - - # Verify project exists - print("Verifying project exists before deletion") - read_project = projects.read(project_id) - assert read_project.id == project_id - print(f"Project confirmed to exist: {read_project.name}") - - # Test DELETE operation - print(f"Testing DELETE operation: {project_id}") - projects.delete(project_id) - print("DELETE operation completed") - - # Verify project is deleted - print("Verifying project is deleted") - try: - projects.read(project_id) - pytest.fail("Project should not exist after deletion") - except Exception as e: - if "404" in str(e) or "not found" in str(e).lower(): - print("Project successfully deleted - confirmed by 404 error") - else: - raise e - - # Clear project_id since it's been deleted - project_id = None - - except Exception as e: - pytest.fail(f"DELETE operation failed: {e}") - - finally: - # Additional cleanup attempt (should be unnecessary) - if project_id: - try: - print(f"Additional cleanup attempt: {project_id}") - projects.delete(project_id) - except Exception: - pass # Project might already be deleted - - -def test_comprehensive_crud_integration(integration_client): - """Test all CRUD operations in sequence - - WARNING: This test creates and deletes real resources! - Tests complete workflow: CREATE READ UPDATE LIST DELETE - """ - projects, org = integration_client - - unique_id = str(uuid.uuid4())[:8] - test_name = f"comprehensive-{unique_id}" - updated_name = f"comprehensive-updated-{unique_id}" - test_description = f"Comprehensive CRUD test {unique_id}" - updated_description = f"Updated comprehensive CRUD test {unique_id}" - project_id = None - - try: - print(f"Starting comprehensive CRUD test: {test_name}") - - # 1. CREATE - print("1 CREATE: Creating project") - create_options = ProjectCreateOptions( - name=test_name, description=test_description - ) - created_project = projects.create(org, create_options) - project_id = created_project.id + if count == 0: + print("No projects found.") + else: + print(f"Total: {count} projects") - assert created_project.name == test_name - assert created_project.description == test_description - print(f"CREATE: {project_id}") + # 2) Create project + if args.create: + if not args.name: + raise SystemExit("Error: --name is required for create") - # 2. READ - print("2 READ: Reading created project") - read_project = projects.read(project_id) + name = args.name + if args.create_random: + name = f"{name}-{uuid.uuid4().hex[:8]}" - assert read_project.id == project_id - assert read_project.name == test_name - assert read_project.description == test_description - print(f"READ: {read_project.name}") + _print_header(f"Creating project: {name}") - # 3. UPDATE - print("3 UPDATE: Updating project") - update_options = ProjectUpdateOptions( - name=updated_name, description=updated_description - ) - updated_project = projects.update(project_id, update_options) - - assert updated_project.id == project_id - assert updated_project.name == updated_name - assert updated_project.description == updated_description - print(f"UPDATE: {updated_project.name}") - - # 4. LIST (verify updated project appears) - print("4 LIST: Verifying project appears in list") - project_list = list(projects.list(org)) - found_project = None - for p in project_list: - if p.id == project_id: - found_project = p - break - - assert found_project is not None, ( - f"Updated project {project_id} should appear in list" - ) - assert found_project.name == updated_name - print("LIST: Found updated project in list") - - # 5. DELETE - print("5 DELETE: Deleting project") - projects.delete(project_id) - print("DELETE: Project deleted") - - # 6. Verify deletion - print("6 VERIFY: Confirming deletion") - try: - projects.read(project_id) - pytest.fail("Project should not exist after deletion") - except Exception as e: - if "404" in str(e) or "not found" in str(e).lower(): - print("VERIFY: Deletion confirmed") - else: - raise e - - project_id = None # Clear since deleted - print("Comprehensive CRUD test completed successfully!") - - except Exception as e: - pytest.fail(f"Comprehensive CRUD test failed: {e}") - - finally: - if project_id: - try: - print(f"Final cleanup: {project_id}") - projects.delete(project_id) - except Exception: - pass - - -def test_validation_integration(integration_client): - """Test validation functions work with real API - - Tests all validation scenarios with actual API calls - """ - projects, org = integration_client - - print("Testing validation with real API calls") - - try: - # Test valid project creation - unique_id = str(uuid.uuid4())[:8] - valid_name = f"validation-test-{unique_id}" - - print(f"Testing valid project creation: {valid_name}") + tags = _parse_tag_pairs(args.tag) create_options = ProjectCreateOptions( - name=valid_name, description="Valid project" - ) - created_project = projects.create(org, create_options) - - assert created_project.name == valid_name - project_id = created_project.id - print(f"Valid project created successfully: {project_id}") - - # Test valid project update - updated_name = f"validation-updated-{unique_id}" - print(f"Testing valid project update: {updated_name}") - update_options = ProjectUpdateOptions(name=updated_name) - updated_project = projects.update(project_id, update_options) - - assert updated_project.name == updated_name - print("Valid project updated successfully") - - # Clean up - projects.delete(project_id) - print("Validation test cleanup completed") - - except Exception as e: - pytest.fail(f"Validation integration test failed: {e}") - - -def test_error_handling_integration(integration_client): - """Test error handling with real API calls - - Tests various error scenarios to ensure proper error handling - """ - projects, org = integration_client - - print("Testing error handling scenarios") - - # Test reading a non-existent project - print("Testing read non-existent project") - fake_project_id = "prj-nonexistent123456789" - try: - projects.read(fake_project_id) - pytest.fail("Should have raised an exception for non-existent project") - except Exception as e: - print(f"Correctly handled error for non-existent project: {type(e).__name__}") - assert "404" in str(e) or "not found" in str(e).lower() - - # Test updating a non-existent project - print("Testing update non-existent project") - try: - update_options = ProjectUpdateOptions(name="should-fail") - projects.update(fake_project_id, update_options) - pytest.fail("Should have raised an exception for non-existent project") - except Exception as e: + name=name, + description=args.description, + auto_destroy_activity_duration="14d", + default_execution_mode="remote", + default_agent_pool_id=None, + setting_overwrites=ProjectSettingOverwrites( + execution_mode=False, + agent_pool=False, + ), + tag_bindings=tags, + ) + + project = client.projects.create(args.organization, create_options) + print(f"Created project: {project.id}") + print(f"Name: {project.name}") + print(f"Description: {project.description}") + print(f"Workspaces: {project.workspace_count}") + print(f"Default execution mode: {project.default_execution_mode}") print( - f"Correctly handled update error for non-existent project: {type(e).__name__}" - ) - assert "404" in str(e) or "not found" in str(e).lower() - - # Test deleting a non-existent project - print("Testing delete non-existent project") - try: - projects.delete(fake_project_id) - pytest.fail("Should have raised an exception for non-existent project") - except Exception as e: + f"Auto destroy activity duration: {project.auto_destroy_activity_duration}" + ) + print(f"Created at: {project.created_at}") + print(f"Updated at: {project.updated_at}") + print(f"Setting overwrites: {project.setting_overwrites}") + print(f"Default agent pool: {project.default_agent_pool}") + print(f"Organization: {_org_display(project)}") + + # 3) Read project + if args.read: + _print_header(f"Reading project: {args.project_id}") + project = client.projects.read(args.project_id) + print(f"ID: {project.id}") + print(f"Name: {project.name}") + print(f"Description: {project.description}") + print(f"Organization: {_org_display(project)}") + print(f"Created at: {project.created_at}") + print(f"Updated at: {project.updated_at}") + print(f"Workspace count: {project.workspace_count}") + print(f"Default execution mode: {project.default_execution_mode}") print( - f"Correctly handled delete error for non-existent project: {type(e).__name__}" + f"Auto destroy activity duration: {project.auto_destroy_activity_duration}" ) - assert "404" in str(e) or "not found" in str(e).lower() - print("All error handling scenarios tested successfully") - - -def test_project_tag_bindings_integration(integration_client): - """ - Integration test for project tag binding operations - - Note: Project tag bindings may not be available in all HCP Terraform plans. - This test gracefully handles unavailable features while testing what's available. - """ - projects, org = integration_client - - unique_id = str(uuid.uuid4())[:8] - test_name = f"tag-test-{unique_id}" - test_description = f"Project for testing tag bindings - {unique_id}" - project_id = None - - try: - # Create a test project for tagging operations - print(f"Setting up test project for tagging: {test_name}") - create_options = ProjectCreateOptions( - name=test_name, description=test_description - ) - created_project = projects.create(org, create_options) - project_id = created_project.id - print(f"Created test project: {project_id}") - - # Test 1: List tag bindings (this should work) - print("Testing LIST_TAG_BINDINGS") - try: - initial_tag_bindings = projects.list_tag_bindings(project_id) - assert isinstance(initial_tag_bindings, list), "Should return a list" - print(f"list_tag_bindings works: {len(initial_tag_bindings)} bindings") - list_tag_bindings_available = True - except Exception as e: - print(f"list_tag_bindings not available: {e}") - list_tag_bindings_available = False - - # Test 2: List effective tag bindings - print("Testing LIST_EFFECTIVE_TAG_BINDINGS") - try: - effective_bindings = projects.list_effective_tag_bindings(project_id) - assert isinstance(effective_bindings, list), "Should return a list" - print( - f"list_effective_tag_bindings works: {len(effective_bindings)} bindings" + # 4) Update project + if args.update: + if args.name is None and args.description is None and not args.tag: + raise SystemExit( + "Error: provide at least one of --name, --description or --tag for update" ) - effective_tag_bindings_available = True - except Exception as e: - print(f"list_effective_tag_bindings not available: {e}") - print("This feature may require a higher HCP Terraform plan") - effective_tag_bindings_available = False - - # Test 3: Add tag bindings (if basic listing works) - if list_tag_bindings_available: - print("Testing ADD_TAG_BINDINGS") - try: - test_tags = [ - TagBinding(key="environment", value="testing"), - TagBinding(key="integration-test", value="true"), - ] - add_options = ProjectAddTagBindingsOptions(tag_bindings=test_tags) - added_bindings = projects.add_tag_bindings(project_id, add_options) - - assert isinstance(added_bindings, list), "Should return a list" - assert len(added_bindings) == len(test_tags), ( - "Should return all added tags" - ) - print(f"add_tag_bindings works: added {len(added_bindings)} bindings") - - # Verify tags were actually added - current_bindings = projects.list_tag_bindings(project_id) - added_keys = {binding.key for binding in current_bindings} - for tag in test_tags: - assert tag.key in added_keys, ( - f"Tag {tag.key} not found after adding" - ) - print(f"Verified tags added: {len(current_bindings)} total bindings") - - add_tag_bindings_available = True - - # Test 4: Delete tag bindings - print("Testing DELETE_TAG_BINDINGS") - try: - result = projects.delete_tag_bindings(project_id) - assert result is None, "Delete should return None" - - # Verify deletion - final_bindings = projects.list_tag_bindings(project_id) - print( - f"delete_tag_bindings works: {len(final_bindings)} bindings remain" - ) - delete_tag_bindings_available = True - except Exception as e: - print(f"delete_tag_bindings not available: {e}") - delete_tag_bindings_available = False - - except Exception as e: - print(f"add_tag_bindings not available: {e}") - print("This feature may require a higher HCP Terraform plan") - add_tag_bindings_available = False - delete_tag_bindings_available = False - else: - add_tag_bindings_available = False - delete_tag_bindings_available = False - - # Summary - print("\n Project Tag Bindings API Availability Summary:") - features = [ - ("list_tag_bindings", list_tag_bindings_available), - ("list_effective_tag_bindings", effective_tag_bindings_available), - ("add_tag_bindings", add_tag_bindings_available), - ("delete_tag_bindings", delete_tag_bindings_available), - ] - - for feature_name, available in features: - status = "Available" if available else " Not Available" - print(f"{feature_name}: {status}") - - available_count = sum(available for _, available in features) - print( - f"\n {available_count}/4 tag binding features are available in this HCP Terraform organization" - ) - if available_count == 4: - print("All project tag binding operations work perfectly!") - elif available_count > 0: - print("Partial functionality available - basic operations work!") - else: - print("Tag binding features may require a higher HCP Terraform plan") + _print_header(f"Updating project: {args.project_id}") - except Exception as e: - pytest.fail( - f"Project tag binding integration test failed unexpectedly. " - f"This may indicate a configuration or connectivity issue. Error: {e}" - ) + tags = _parse_tag_pairs(args.tag) + update_options = ProjectUpdateOptions( + name=args.name, + description=args.description, + tag_bindings=tags if tags else None, + ) + + updated = client.projects.update(args.project_id, update_options) + print("Project updated successfully") + print(f"ID: {updated.id}") + print(f"Name: {updated.name}") + print(f"Description: {updated.description}") + + # 5) Delete project + if args.delete: + _print_header(f"Deleting project: {args.project_id}") + client.projects.delete(args.project_id) + print("Project deleted successfully") + + # 6) List tag bindings + if args.list_tag_bindings: + _print_header(f"Listing tag bindings for project: {args.project_id}") + bindings = client.projects.list_tag_bindings(args.project_id) + + if not bindings: + print("No tag bindings found.") + else: + for tag in bindings: + print(f"- {tag.key}={tag.value}") + print(f"Total: {len(bindings)} tag bindings") - finally: - # Clean up: Delete the test project - if project_id: - try: - print(f"🧹 Cleaning up test project: {project_id}") - projects.delete(project_id) - print("Test project deleted successfully") - except Exception as cleanup_error: - print( - f" Warning: Failed to clean up test project {project_id}: {cleanup_error}" - ) - - -def test_project_tag_bindings_error_scenarios(integration_client): - """ - Test error handling for project tag binding operations - - Tests various error conditions: - - Invalid project IDs - - Empty tag binding lists - - Non-existent projects - """ - projects, org = integration_client - - print("Testing tag binding error scenarios") - - # Test invalid project ID validation - print("Testing invalid project ID scenarios") - - invalid_project_ids = ["", "x", "invalid-id", None] - - for invalid_id in invalid_project_ids: - if invalid_id is None: - continue # Skip None as it will cause different error - - try: - projects.list_tag_bindings(invalid_id) - pytest.fail( - f"Should have raised ValueError or NotFound for invalid project ID: {invalid_id}" - ) - except (ValueError, NotFound) as e: - print(f"Correctly rejected invalid project ID '{invalid_id}': {e}") - if isinstance(e, ValueError): - assert "Project ID is required and must be valid" in str(e) - - try: - projects.list_effective_tag_bindings(invalid_id) - pytest.fail( - f"Should have raised ValueError or NotFound for invalid project ID: {invalid_id}" - ) - except (ValueError, NotFound) as e: - print(f"Correctly rejected invalid project ID '{invalid_id}': {e}") + # 7) List effective tag bindings + if args.list_effective_tag_bindings: + _print_header(f"Listing effective tag bindings for project: {args.project_id}") + bindings = client.projects.list_effective_tag_bindings(args.project_id) - try: - projects.delete_tag_bindings(invalid_id) - pytest.fail( - f"Should have raised ValueError or NotFound for invalid project ID: {invalid_id}" - ) - except (ValueError, NotFound) as e: - print(f"Correctly rejected invalid project ID '{invalid_id}': {e}") - - # Test empty tag binding list - print("Testing empty tag binding list") - try: - fake_project_id = "prj-fakefakefake123" - empty_options = ProjectAddTagBindingsOptions(tag_bindings=[]) - projects.add_tag_bindings(fake_project_id, empty_options) - pytest.fail("Should have raised ValueError for empty tag binding list") - except ValueError as e: - print(f"Correctly rejected empty tag binding list: {e}") - assert "At least one tag binding is required" in str(e) - - # Test non-existent project operations - print("Testing operations on non-existent project") - fake_project_id = "prj-doesnotexist123" - - # These should raise HTTP errors (404) from the API - for operation_name, operation_func in [ - ("list_tag_bindings", lambda: projects.list_tag_bindings(fake_project_id)), - ( - "list_effective_tag_bindings", - lambda: projects.list_effective_tag_bindings(fake_project_id), - ), - ("delete_tag_bindings", lambda: projects.delete_tag_bindings(fake_project_id)), - ]: - try: - operation_func() - pytest.fail(f"{operation_name} should have failed for non-existent project") - except Exception as e: - print( - f"{operation_name} correctly failed for non-existent project: {type(e).__name__}" - ) - # Should be some kind of HTTP error (404, not found, etc.) - assert ( - "404" in str(e) - or "not found" in str(e).lower() - or "does not exist" in str(e).lower() + if not bindings: + print("No effective tag bindings found.") + else: + for tag in bindings: + print(f"- {tag.key}={tag.value}") + print(f"Total: {len(bindings)} effective tag bindings") + + # 8) Add tag bindings + if args.add_tag_bindings: + tags = _parse_tag_pairs(args.tag) + if not tags: + raise SystemExit( + "Error: at least one --tag key=value is required for --add-tag-bindings" ) - # Test add_tag_bindings on non-existent project - try: - test_tags = [TagBinding(key="test", value="value")] - add_options = ProjectAddTagBindingsOptions(tag_bindings=test_tags) - projects.add_tag_bindings(fake_project_id, add_options) - pytest.fail("add_tag_bindings should have failed for non-existent project") - except Exception as e: - print( - f"add_tag_bindings correctly failed for non-existent project: {type(e).__name__}" - ) - assert ( - "404" in str(e) - or "not found" in str(e).lower() - or "does not exist" in str(e).lower() - ) + _print_header(f"Adding tag bindings to project: {args.project_id}") + options = ProjectAddTagBindingsOptions(tag_bindings=tags) + updated_tags = client.projects.add_tag_bindings(args.project_id, options) + for tag in updated_tags: + print(f"- {tag.key}={tag.value}") + print(f"Total returned: {len(updated_tags)} tag bindings") - print("All tag binding error scenarios tested successfully") + # 9) Delete tag bindings + if args.delete_tag_bindings: + _print_header(f"Deleting all tag bindings from project: {args.project_id}") + client.projects.delete_tag_bindings(args.project_id) + print("Deleted all project tag bindings") if __name__ == "__main__": - """ - You can also run this file directly for quick testing: - - export TFE_TOKEN="your-token" - export TFE_ORG="your-org" - python examples/integration_test_example.py - """ - import sys - - token = os.environ.get("TFE_TOKEN") - org = os.environ.get("TFE_ORG") - - if not token or not org: - print("Please set TFE_TOKEN and TFE_ORG environment variables") - print("export TFE_TOKEN='your-hcp-terraform-token'") - print("export TFE_ORG='your-organization-name'") - sys.exit(1) - - print("Running integration tests directly...") - print( - " For full pytest features, use: pytest examples/integration_test_example.py -v -s" - ) - - # Simple direct execution - pytest.main([__file__, "-v", "-s"]) + main() diff --git a/examples/query_run.py b/examples/query_run.py index 66ee804..5019543 100644 --- a/examples/query_run.py +++ b/examples/query_run.py @@ -1,4 +1,7 @@ #!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """ Query Run Individual Function Tests @@ -34,8 +37,8 @@ def get_client_and_workspace(): """Initialize client and get workspace ID.""" client = TFEClient(TFEConfig.from_env()) - organization = os.getenv("TFE_ORG", "aayush-test") - workspace_name = "query-test" # Default workspace for testing + organization = os.getenv("TFE_ORG") + workspace_name = os.getenv("TFE_WORKSPACE_NAME") # Default workspace for testing # Get workspace workspace = client.workspaces.read(workspace_name, organization=organization) diff --git a/examples/registry_module.py b/examples/registry_module.py index cc53edf..bf8e7ab 100644 --- a/examples/registry_module.py +++ b/examples/registry_module.py @@ -1,4 +1,7 @@ #!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """ Complete Registry Module Testing Suite diff --git a/examples/registry_provider.py b/examples/registry_provider.py index d2b7c4b..e4de81d 100644 --- a/examples/registry_provider.py +++ b/examples/registry_provider.py @@ -1,4 +1,7 @@ #!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """ Registry Provider Individual Function Tests @@ -37,7 +40,7 @@ def get_client_and_org(): """Initialize client and get organization name.""" client = TFEClient() - organization_name = os.getenv("TFE_ORGANIZATION", "aayush-test") + organization_name = os.environ["TFE_ORG"] return client, organization_name diff --git a/examples/registry_provider_version.py b/examples/registry_provider_version.py index 4da1c83..f72a28a 100644 --- a/examples/registry_provider_version.py +++ b/examples/registry_provider_version.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations import argparse diff --git a/examples/reserved_tag_key.py b/examples/reserved_tag_key.py index 8e62b1a..2e7dccf 100644 --- a/examples/reserved_tag_key.py +++ b/examples/reserved_tag_key.py @@ -1,4 +1,7 @@ #!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Reserved Tag Keys Example Script. This script demonstrates how to use the Reserved Tag Keys API to: diff --git a/examples/run.py b/examples/run.py index d95b6e9..1a6735d 100644 --- a/examples/run.py +++ b/examples/run.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations import argparse @@ -12,6 +15,7 @@ RunListOptions, RunReadOptions, RunVariable, + Workspace, ) @@ -67,7 +71,8 @@ def main(): ) try: - run_list = client.runs.list(args.workspace_id, options) + print("running inside run list") + run_list = list(client.runs.list(args.workspace_id, options)) except Exception as e: print(f"Error listing runs: {e}") if args.organization: @@ -75,23 +80,22 @@ def main(): else: return - if "run_list" in locals(): - print(f"Total runs: {run_list.total_count}") - print(f"Page {run_list.current_page} of {run_list.total_pages}") + if "run_list" in locals() and run_list: + print(f"Total runs fetched: {len(run_list)}") print() - for run in run_list.items: + for run in run_list: print(f"- {run.id} | status={run.status} | created={run.created_at}") print(f"message: {run.message}") print(f"has_changes: {run.has_changes} | is_destroy: {run.is_destroy}") - if not run_list.items: + if not run_list: print("No runs found.") else: # 2) Read the most recent run with details _print_header("Reading most recent run details") - latest_run = run_list.items[0] + latest_run = run_list[0] read_options = RunReadOptions( include=[ RunIncludeOpt.RUN_PLAN, @@ -138,15 +142,11 @@ def main(): # Get workspace object - convert to the model type expected by run workspace_data = client.workspaces.read_by_id(args.workspace_id) - # Create the workspace object that run models expect - from pytfe.models.workspace import Workspace - workspace = Workspace( id=workspace_data.id, name=workspace_data.name, organization=workspace_data.organization, execution_mode=workspace_data.execution_mode, - project_id=workspace_data.project_id, tags=getattr(workspace_data, "tags", []), ) @@ -188,10 +188,12 @@ def main(): status="applied,planned,errored", ) - org_runs = client.runs.list_for_organization(args.organization, org_options) - print(f"Found {len(org_runs.items)} runs across organization") + org_runs = list( + client.runs.list_for_organization(args.organization, org_options) + ) + print(f"Found {len(org_runs)} runs across organization") - for run in org_runs.items[:3]: # Show first 3 + for run in org_runs[:3]: # Show first 3 print(f"- {run.id} | status={run.status}") if run.workspace: print(f"workspace: {run.workspace.name}") @@ -203,20 +205,19 @@ def main(): if args.run_actions and args.workspace_id: _print_header("Run Actions Demo (Safe Mode)") - # Get runs first if not already available - if "run_list" not in locals() or not run_list.items: - try: - options = RunListOptions(page_size=1) - run_list = client.runs.list(args.workspace_id, options) - except Exception as e: - print(f"Error getting runs for actions demo: {e}") - return + try: + options = RunListOptions(page_size=1) + run_list = list(client.runs.list(args.workspace_id, options)) + print(f"Fetched {len(run_list)} runs for actions demo") + except Exception as e: + print(f"Error getting runs for actions demo: {e}") + return - if not run_list.items: + if not run_list: print("No runs available for actions demo") return - demo_run = run_list.items[0] + demo_run = run_list[0] print(f"Demonstrating actions for run: {demo_run.id}") print(f"Current status: {demo_run.status}") diff --git a/examples/run_events.py b/examples/run_events.py index a648c5b..4f64040 100644 --- a/examples/run_events.py +++ b/examples/run_events.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Run Events Example for python-tfe SDK This example demonstrates how to work with run events using the python-tfe SDK. @@ -94,23 +97,19 @@ def main(): options = RunEventListOptions(include=include_opts if include_opts else None) try: - event_list = client.run_events.list(args.run_id, options) - - print(f"Total run events: {event_list.total_count or 'N/A'}") - if event_list.current_page and event_list.total_pages: - print(f"Page {event_list.current_page} of {event_list.total_pages}") - print() + event_count = 0 + for event in client.run_events.list(args.run_id, options): + print(f"Event ID: {event.id}") + print(f"Action: {event.action or 'N/A'}") + print(f"Description: {event.description or 'N/A'}") + print(f"Created At: {event.created_at or 'N/A'}") + print() + event_count += 1 - if not event_list.items: + if event_count == 0: print("No run events found for this run.") else: - for event in event_list.items: - print(f"Event ID: {event.id}") - print(f"Action: {event.action or 'N/A'}") - print(f"Description: {event.description or 'N/A'}") - print(f"Created At: {event.created_at or 'N/A'}") - - print() + print(f"Total run events listed: {event_count}") except Exception as e: print(f"Error listing run events: {e}") @@ -139,7 +138,6 @@ def main(): # 3) Summary _print_header("Summary") print(f"Successfully demonstrated run events for run: {args.run_id}") - print(f"Total events found: {event_list.total_count or 'N/A'}") if args.event_id: print(f"Successfully read specific event: {args.event_id}") return 0 diff --git a/examples/run_task.py b/examples/run_task.py index 8331941..f2e6959 100644 --- a/examples/run_task.py +++ b/examples/run_task.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """ Terraform Cloud/Enterprise Run Task Management Example @@ -103,14 +106,7 @@ def main(): f"Fetching run tasks from organization '{args.org}' (page {args.page}, size {args.page_size})..." ) # Get run tasks and convert to list safely - run_task_gen = client.run_tasks.list(args.org, options) - run_task_list = [] - count = 0 - for task in run_task_gen: - run_task_list.append(task) - count += 1 - if count >= args.page_size * 2: # Safety limit based on page size - break + run_task_list = list(client.run_tasks.list(args.org, options)) print(f"Found {len(run_task_list)} run tasks") print() diff --git a/examples/run_trigger.py b/examples/run_trigger.py index c6fed59..a07e400 100644 --- a/examples/run_trigger.py +++ b/examples/run_trigger.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """ Terraform Cloud/Enterprise Run Trigger Management Example @@ -32,7 +35,6 @@ import argparse import os -import time from pytfe import TFEClient, TFEConfig from pytfe.models import ( @@ -131,14 +133,9 @@ def main(): ) # Get run triggers and convert to list safely - run_trigger_gen = client.run_triggers.list(args.workspace_id, options) - run_trigger_list = [] - count = 0 - for trigger in run_trigger_gen: - run_trigger_list.append(trigger) - count += 1 - if count >= args.page_size * 2: # Safety limit based on page size - break + run_trigger_list = list( + client.run_triggers.list(args.workspace_id, options) + ) print(f"Found {len(run_trigger_list)} run triggers") print() @@ -168,8 +165,6 @@ def main(): # Create a workspace object for the source source_workspace = Workspace( id=args.source_workspace_id, - name=f"source-workspace-{int(time.time())}", - organization=args.org, ) create_options = RunTriggerCreateOptions(sourceable=source_workspace) diff --git a/examples/ssh_keys.py b/examples/ssh_keys.py index 743bd98..9448bbf 100644 --- a/examples/ssh_keys.py +++ b/examples/ssh_keys.py @@ -1,4 +1,7 @@ #!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """SSH Keys Example Script. This script demonstrates how to use the SSH Keys API to: @@ -99,10 +102,11 @@ def main(): try: # 1. List existing SSH keys print("\n1. Listing SSH keys...") - ssh_keys = client.ssh_keys.list(TFE_ORG) - print(f"Found {len(ssh_keys.items)} SSH keys:") - for key in ssh_keys.items: + ssh_keys_count = 0 + for key in client.ssh_keys.list(TFE_ORG): print(f"- ID: {key.id}, Name: {key.name}") + ssh_keys_count += 1 + print(f"Found {ssh_keys_count} SSH keys") # 2. Create a new SSH key print("\n2. Creating a new SSH key...") @@ -132,16 +136,17 @@ def main(): # 6. Verify deletion by listing again print("\n6. Verifying deletion...") - ssh_keys_after = client.ssh_keys.list(TFE_ORG) - print(f"SSH keys after deletion: {len(ssh_keys_after.items)}") + ssh_keys_after_count = sum(1 for _ in client.ssh_keys.list(TFE_ORG)) + print(f"SSH keys after deletion: {ssh_keys_after_count}") - # 7. Demonstrate pagination with options - print("\n7. Demonstrating pagination options...") + # 7. Demonstrate iterator with pagination options + print("\n7. Demonstrating iterator with pagination options...") list_options = SSHKeyListOptions(page_size=5, page_number=1) - paginated_keys = client.ssh_keys.list(TFE_ORG, list_options) - print(f"Page 1 with page size 5: {len(paginated_keys.items)} keys") - print(f"Total pages: {paginated_keys.total_pages}") - print(f"Total count: {paginated_keys.total_count}") + paginated_count = 0 + for key in client.ssh_keys.list(TFE_ORG, list_options): + paginated_count += 1 + print(f" - {key.name}") + print(f"Listed {paginated_count} keys with pagination options") print("\n SSH Keys API example completed successfully!") diff --git a/examples/state_versions.py b/examples/state_versions.py index 6d3f8b8..6118761 100644 --- a/examples/state_versions.py +++ b/examples/state_versions.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations import argparse @@ -33,7 +36,6 @@ def main(): parser.add_argument("--workspace-id", required=True, help="Workspace ID") parser.add_argument("--download", help="Path to save downloaded current state") parser.add_argument("--upload", help="Path to a .tfstate (or JSON state) to upload") - parser.add_argument("--page", type=int, default=1) parser.add_argument("--page-size", type=int, default=10) args = parser.parse_args() @@ -41,19 +43,16 @@ def main(): client = TFEClient(cfg) options = StateVersionListOptions( - page_number=args.page, page_size=args.page_size, organization=args.org, workspace=args.workspace, ) - sv_list = client.state_versions.list(options) - - print(f"Total state versions: {sv_list.total_count}") - print(f"Page {sv_list.current_page} of {sv_list.total_pages}") + sv_list = list(client.state_versions.list(options)) + print(f"Total state versions: {len(sv_list)}") print() - for sv in sv_list.items: + for sv in sv_list: print(f"- {sv.id} | status={sv.status} | created_at={sv.created_at}") # 1) List all state versions across org and workspace filters @@ -63,7 +62,7 @@ def main(): organization=args.org, workspace=args.workspace, page_size=args.page_size ) ) - for sv in all_sv.items: + for sv in all_sv: print(f"- {sv.id} | status={sv.status} | created_at={sv.created_at}") # 2) Read the current state version (with outputs included if you want) @@ -84,15 +83,30 @@ def main(): # 4) List outputs for the current state version (paged) _print_header("Listing outputs (current state version)") - outs = client.state_versions.list_outputs( - current.id, options=StateVersionOutputsListOptions(page_size=50) + outs = list( + client.state_versions.list_outputs( + current.id, options=StateVersionOutputsListOptions(page_size=50) + ) ) - if not outs.items: + if not outs: print("No outputs found.") - for o in outs.items: + for o in outs: # Sensitive outputs will have value = None print(f"- {o.name}: sensitive={o.sensitive} type={o.type} value={o.value}") + if args.workspace_id: + # 4b) List outputs for the current state version via workspace endpoint + _print_header("Listing outputs via workspace endpoint") + outs2 = list( + client.state_version_outputs.read_current( + args.workspace_id, options=StateVersionOutputsListOptions(page_size=50) + ) + ) + if not outs2: + print("No outputs found.") + for o in outs2: + print(f"- {o.name}: sensitive={o.sensitive} type={o.type} value={o.value}") + # 5) (Optional) Upload a new state file if args.upload: _print_header(f"Uploading new state from: {args.upload}") diff --git a/examples/variable_sets.py b/examples/variable_sets.py index 0b41084..258319a 100644 --- a/examples/variable_sets.py +++ b/examples/variable_sets.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Example demonstrating Variable Set operations with the TFE Python SDK. This example shows how to: @@ -64,7 +67,7 @@ def variable_set_example(): list_options = VariableSetListOptions( page_size=10, include=[VariableSetIncludeOpt.WORKSPACES] ) - variable_sets = client.variable_sets.list(org_name, list_options) + variable_sets = list(client.variable_sets.list(org_name, list_options)) print(f"Found {len(variable_sets)} existing variable sets") for vs in variable_sets[:3]: # Show first 3 @@ -92,6 +95,15 @@ def variable_set_example(): print(f"Priority: {new_variable_set.priority}") print() + print("Listing existing variable sets...") + list_options = VariableSetListOptions(page_size=10) + variable_sets = list(client.variable_sets.list(org_name, list_options)) + print(f"Found {len(variable_sets)} existing variable sets") + + for vs in variable_sets: # Show first 3 + print(f"- {vs.name} (ID: {vs.id}, Global: {vs.global_})") + print() + # 3. Create variables in the variable set print("3. Creating variables in the variable set...") @@ -147,12 +159,16 @@ def variable_set_example(): # 4. List variables in the variable set print("4. Listing variables in the variable set...") var_list_options = VariableSetVariableListOptions(page_size=50) - variables = client.variable_set_variables.list( - created_variable_set_id, var_list_options + variables = list( + client.variable_set_variables.list( + created_variable_set_id, var_list_options + ) ) print(f"Found {len(variables)} variables in the set:") - for var in variables: + for var in client.variable_set_variables.list( + created_variable_set_id, var_list_options + ): sensitive_note = " (sensitive)" if var.sensitive else "" hcl_note = " (HCL)" if var.hcl else "" print(f"- {var.key}: {var.category.value}{sensitive_note}{hcl_note}") @@ -212,10 +228,14 @@ def variable_set_example(): print("Successfully applied to workspace") # List variable sets for this workspace - workspace_varsets = client.variable_sets.list_for_workspace( + print(f"Listing variable sets for workspace: {first_workspace.name}") + workspace_varsets = 0 + for ws_varset in client.variable_sets.list_for_workspace( first_workspace.id - ) - print(f"Workspace now has {len(workspace_varsets)} variable sets") + ): + print(f"- {ws_varset.name} (ID: {ws_varset.id})") + workspace_varsets += 1 + print(f"Workspace now has {workspace_varsets} variable sets") # Remove from workspace remove_ws_options = VariableSetRemoveFromWorkspacesOptions( @@ -250,10 +270,14 @@ def variable_set_example(): print("Successfully applied to project") # List variable sets for this project - project_varsets = client.variable_sets.list_for_project( + print(f"Listing variable sets for project: {first_project.name}") + project_varsets = 0 + for proj_varset in client.variable_sets.list_for_project( first_project.id - ) - print(f"Project now has {len(project_varsets)} variable sets") + ): + print(f"- {proj_varset.name} (ID: {proj_varset.id})") + project_varsets += 1 + print(f"Project now has {project_varsets} variable sets") # Remove from project remove_proj_options = VariableSetRemoveFromProjectsOptions( diff --git a/examples/variables.py b/examples/variables.py index 0b3fe34..f5d5ca6 100644 --- a/examples/variables.py +++ b/examples/variables.py @@ -1,4 +1,7 @@ #!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """ Comprehensive example testing all variable functions in TFE workspace. Tests: list, list_all, create, read, update, and delete operations. @@ -22,7 +25,9 @@ def main(): client = TFEClient(TFEConfig.from_env()) # Replace this with your actual workspace ID - workspace_id = "ws-example123456789" # Get this from your TFE workspace + workspace_id = os.environ.get( + "TFE_WORKSPACE_ID" + ) # Get this from your TFE workspace print(f"Testing all variable operations in workspace: {workspace_id}") print("=" * 60) diff --git a/examples/workspace.py b/examples/workspace.py index 4dfb643..54f6871 100644 --- a/examples/workspace.py +++ b/examples/workspace.py @@ -1,15 +1,9 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """ Terraform Cloud/Enterprise Workspace Management Example -This comprehensive example demonstrates 38 workspace operations using the python-tfe SDK, -providing a complete command-line interface for managing TFE workspaces with advanced -operations including create, read, update, delete, lock/unlock, tag management, VCS -integration, SSH keys, remote state, data retention, and filtering capabilities. - -API Coverage: 38/38 workspace methods (100% coverage) -Testing Status: All operations tested and validated -Organization: Logically grouped into 16 sections for easy navigation - Prerequisites: - Set TFE_TOKEN environment variable with your Terraform Cloud API token - Ensure you have access to the target organization @@ -29,21 +23,17 @@ python examples/workspace.py --org my-org --create 3. Read Operations: - python examples/workspace.py --org my-org --workspace "my-workspace" - python examples/workspace.py --org my-org --workspace-id "ws-abc123xyz" - python examples/workspace.py --org my-org --workspace "my-workspace" --read-all + python examples/workspace.py --org my-org --workspace "my-workspace" --read + python examples/workspace.py --org my-org --workspace-id "ws-abc123xyz" --read 4. Update Operations: python examples/workspace.py --org my-org --workspace "my-workspace" --update - python examples/workspace.py --org my-org --workspace "my-workspace" --update-all + python examples/workspace.py --org my-org --workspace-id "ws-abc123xyz" --update 5. Lock Management: - python examples/workspace.py --org my-org --workspace "my-workspace" --lock - python examples/workspace.py --org my-org --workspace "my-workspace" --unlock - python examples/workspace.py --org my-org --workspace "my-workspace" --force-unlock - -6. Comprehensive Testing: - python examples/workspace.py --org my-org --workspace "my-workspace" --all-tests + python examples/workspace.py --org my-org --workspace-id "ws-abc123xyz" --lock + python examples/workspace.py --org my-org --workspace-id "ws-abc123xyz" --unlock + python examples/workspace.py --org my-org --workspace-id "ws-abc123xyz" --force-unlock """ from __future__ import annotations @@ -57,6 +47,7 @@ ExecutionMode, Tag, WorkspaceAddTagsOptions, + WorkspaceAssignSSHKeyOptions, WorkspaceCreateOptions, WorkspaceIncludeOpt, WorkspaceListOptions, @@ -86,10 +77,15 @@ def main(): parser.add_argument("--workspace-id", help="Workspace ID for ID-based operations") # Core CRUD Operations + parser.add_argument( + "--list", action="store_true", help="List workspaces in the organization" + ) parser.add_argument("--create", action="store_true", help="Create a new workspace") parser.add_argument("--delete", action="store_true", help="Delete the workspace") parser.add_argument( - "--safe-delete", action="store_true", help="Safely delete the workspace" + "--safe-delete", + action="store_true", + help="Safely delete the workspace, passed along with --delete", ) parser.add_argument( "--update", action="store_true", help="Update workspace settings" @@ -107,14 +103,7 @@ def main(): "--remove-vcs", action="store_true", help="Remove VCS connection" ) - # Method Testing Flags - parser.add_argument("--read-all", action="store_true", help="Test all read methods") - parser.add_argument( - "--update-all", action="store_true", help="Test all update methods" - ) - parser.add_argument( - "--delete-all", action="store_true", help="Test all delete methods" - ) + parser.add_argument("--read", action="store_true", help="Read workspace details") parser.add_argument( "--tag-ops", action="store_true", help="Test tag management operations" ) @@ -130,10 +119,8 @@ def main(): parser.add_argument( "--readme", action="store_true", help="Test readme functionality" ) - parser.add_argument("--all-tests", action="store_true", help="Run all method tests") # Listing and Filtering - parser.add_argument("--page", type=int, default=1, help="Page number for listing") parser.add_argument( "--page-size", type=int, default=10, help="Page size for listing" ) @@ -150,47 +137,47 @@ def main(): client = TFEClient(cfg) # 1) List workspaces in the organization - _print_header("Listing workspaces") - try: - # Create options for listing workspaces with pagination and filters - options = WorkspaceListOptions( - page_number=args.page, - page_size=args.page_size, - search=args.search, - tags=args.tags, - exclude_tags=args.exclude_tags, - wildcard_name=args.wildcard_name, - project_id=args.project_id, - ) - print( - f"Fetching workspaces from organization '{args.org}' (page {args.page}, size {args.page_size})..." - ) - - # Get workspaces and convert to list safely - workspace_gen = client.workspaces.list(args.org, options) - workspace_list = [] - count = 0 - for ws in workspace_gen: - workspace_list.append(ws) - count += 1 - if count >= args.page_size * 2: # Safety limit based on page size - break - - print(f"Found {len(workspace_list)} workspaces") - print() - - if not workspace_list: - print("No workspaces found in this organization.") - else: - for i, ws in enumerate(workspace_list, 1): - print(f"{i:2d}. {ws.name}") - print(f"ID: {ws.id}") - print(f"Execution Mode: {ws.execution_mode}") - print(f"Auto Apply: {ws.auto_apply}") - print() - except Exception as e: - print(f"Error listing workspaces: {e}") - return + if args.list: + _print_header("Listing workspaces") + try: + # Create options for listing workspaces with pagination and filters + options = WorkspaceListOptions( + page_size=args.page_size, + search=args.search, + tags=args.tags, + exclude_tags=args.exclude_tags, + wildcard_name=args.wildcard_name, + project_id=args.project_id, + ) + print( + f"Fetching workspaces from organization '{args.org}', size {args.page_size})..." + ) + + # Get workspaces and convert to list safely + workspace_gen = client.workspaces.list(args.org, options) + workspace_list = [] + count = 0 + for ws in workspace_gen: + workspace_list.append(ws) + count += 1 + if count >= args.page_size * 2: # Safety limit based on page size + break + + print(f"Found {len(workspace_list)} workspaces") + print() + + if not workspace_list: + print("No workspaces found in this organization.") + else: + for i, ws in enumerate(workspace_list, 1): + print(f"{i:2d}. {ws.name}") + print(f"ID: {ws.id}") + print(f"Execution Mode: {ws.execution_mode}") + print(f"Auto Apply: {ws.auto_apply}") + print() + except Exception as e: + print(f"Error listing workspaces: {e}") + return # 2) Create a new workspace if requested if args.create: @@ -233,9 +220,9 @@ def main(): print(f"Error creating workspace: {e}") return - # 3a) Read workspace details using read_with_options - if args.workspace: - _print_header("Read Operations - Testing all read methods") + # 3a) Read workspace details by name + if args.read and args.workspace: + _print_header("Read workspace by name") # Test read_with_options (enhanced read) try: @@ -261,33 +248,9 @@ def main(): except Exception as e: print(f"read_with_options error: {e}") - # Test basic read method (when testing all read methods) - if args.read_all or args.all_tests: - try: - print("Testing read() without options...") - workspace = client.workspaces.read( - args.workspace, organization=args.org - ) - print(f"read: {workspace.name} (ID: {workspace.id})") - print(f"Description: {workspace.description}") - print(f"Execution Mode: {workspace.execution_mode}") - except Exception as e: - print(f"read error: {e}") - - # 3b) Read workspace by ID methods (comprehensive testing) - if args.workspace_id and (args.read_all or args.all_tests): - if not args.workspace: # Only show header if not already shown above - _print_header("ID-based Read Operations") - - # Test read_by_id - try: - print("Testing read_by_id()...") - workspace = client.workspaces.read_by_id(args.workspace_id) - print(f"read_by_id: {workspace.name} (ID: {workspace.id})") - except Exception as e: - print(f"read_by_id error: {e}") - - # Test read_by_id_with_options + # 3b) Read workspace by ID + if args.read and args.workspace_id: + _print_header("Read workspace by ID") try: print("Testing read_by_id_with_options()...") options = WorkspaceReadOptions(include=[WorkspaceIncludeOpt.ORGANIZATION]) @@ -301,33 +264,32 @@ def main(): print(f"read_by_id_with_options error: {e}") # 4a) Update workspace by name - if args.update and args.workspace or args.update_all or args.all_tests: - if args.workspace: - _print_header("Update Operations - Testing all update methods") - - # Test standard update method - try: - print("Testing update() by name...") - update_options = WorkspaceUpdateOptions( - name=args.workspace, # Name is required - description=f"Updated workspace at {datetime.now()}", - auto_apply=True, - terraform_version="1.6.0", - ) - updated_workspace = client.workspaces.update( - args.workspace, update_options, organization=args.org - ) - print("update: Successfully updated workspace!") - print(f"Name: {updated_workspace.name}") - print(f"Description: {updated_workspace.description}") - print(f"Auto Apply: {updated_workspace.auto_apply}") - print(f"Terraform Version: {updated_workspace.terraform_version}") - print() - except Exception as e: - print(f"update error: {e}") + if args.update and args.workspace: + _print_header("Update workspace by name") + + # Test standard update method + try: + print("Testing update() by name...") + update_options = WorkspaceUpdateOptions( + name=args.workspace, # Name is required + description=f"Updated workspace at {datetime.now()}", + auto_apply=True, + terraform_version="1.6.0", + ) + updated_workspace = client.workspaces.update( + args.workspace, update_options, organization=args.org + ) + print("update: Successfully updated workspace!") + print(f"Name: {updated_workspace.name}") + print(f"Description: {updated_workspace.description}") + print(f"Auto Apply: {updated_workspace.auto_apply}") + print(f"Terraform Version: {updated_workspace.terraform_version}") + print() + except Exception as e: + print(f"update error: {e}") # 4b) Update workspace by ID - if args.workspace_id and (args.update_all or args.all_tests): + if args.update and args.workspace_id and not args.workspace: try: print("Testing update_by_id()...") # Get current workspace to preserve the name @@ -378,7 +340,7 @@ def main(): print(f"Error removing VCS connection: {e}") # 8) Demonstrate tag operations - if args.workspace_id: + if args.tag_ops and args.workspace_id: _print_header("Tag operations") # List existing tags @@ -399,8 +361,37 @@ def main(): except Exception as e: print(f"Error adding tags: {e}") + # Test remove_tags + try: + print("Testing remove_tags()...") + remove_options = WorkspaceRemoveTagsOptions(tags=[Tag(name="demo")]) + client.workspaces.remove_tags(args.workspace_id, remove_options) + print("remove_tags: Removed 'demo' tag") + except Exception as e: + print(f"remove_tags: {e}") + + # Test list_tag_bindings + try: + print("Testing list_tag_bindings()...") + bindings = list(client.workspaces.list_tag_bindings(args.workspace_id)) + print(f"list_tag_bindings: Found {len(bindings)} tag bindings") + except Exception as e: + print(f"list_tag_bindings error: {e}") + + # Test list_effective_tag_bindings + try: + print("Testing list_effective_tag_bindings()...") + effective_bindings = list( + client.workspaces.list_effective_tag_bindings(args.workspace_id) + ) + print( + f"list_effective_tag_bindings: Found {len(effective_bindings)} effective bindings" + ) + except Exception as e: + print(f"list_effective_tag_bindings error: {e}") + # 9) Demonstrate remote state consumer operations - if args.workspace_id: + if args.remote_state and args.workspace_id: _print_header("Remote state consumer operations") # List remote state consumers @@ -418,7 +409,7 @@ def main(): print(f"Error listing remote state consumers: {e}") # 10) Test force unlock - if (args.all_tests or args.force_unlock) and args.workspace_id: + if args.force_unlock and args.workspace_id: _print_header("Testing force unlock") try: print("Testing force_unlock()...") @@ -429,22 +420,23 @@ def main(): print("(Expected if workspace wasn't locked)") # 11) Test SSH key operations - if (args.all_tests or args.ssh_keys) and args.workspace_id: + if args.ssh_keys and args.workspace_id: _print_header("Testing SSH key operations") # First, list available SSH keys try: print("Listing available SSH keys...") - ssh_keys = client.ssh_keys.list(args.org) - if ssh_keys.items: - ssh_key = ssh_keys.items[0] + ssh_keys = list(client.ssh_keys.list(args.org)) + if ssh_keys: + ssh_key = ssh_keys[0] print(f"Found SSH key: {ssh_key.name} (ID: {ssh_key.id})") # Test assign SSH key try: print("Testing assign_ssh_key()...") + options = WorkspaceAssignSSHKeyOptions(ssh_key_id=ssh_key.id) workspace = client.workspaces.assign_ssh_key( - args.workspace_id, ssh_key.id + args.workspace_id, options ) print(f"assign_ssh_key: Assigned key to {workspace.name}") @@ -464,51 +456,8 @@ def main(): except Exception as e: print(f"SSH key listing error: {e}") - # 12) Test advanced tag operations - if (args.all_tests or args.tag_ops) and args.workspace_id: - _print_header("Testing advanced tag operations") - - try: - # Test remove_tags - print("Testing remove_tags()...") - remove_options = WorkspaceRemoveTagsOptions(tags=[Tag(name="demo")]) - client.workspaces.remove_tags(args.workspace_id, remove_options) - print("remove_tags: Removed 'demo' tag") - except Exception as e: - print(f"remove_tags: {e}") - - try: - # Test list_tag_bindings - print("Testing list_tag_bindings()...") - bindings = list(client.workspaces.list_tag_bindings(args.workspace_id)) - print(f"list_tag_bindings: Found {len(bindings)} tag bindings") - except Exception as e: - print(f"list_tag_bindings error: {e}") - - try: - # Test list_effective_tag_bindings - print("Testing list_effective_tag_bindings()...") - effective_bindings = list( - client.workspaces.list_effective_tag_bindings(args.workspace_id) - ) - print( - f"list_effective_tag_bindings: Found {len(effective_bindings)} effective bindings" - ) - except Exception as e: - print(f"list_effective_tag_bindings error: {e}") - - # 13) Test additional remote state operations - if (args.all_tests or args.remote_state) and args.workspace_id: - _print_header("Testing additional remote state operations") - - print("Available remote state methods:") - print("list_remote_state_consumers() - Already tested above") - print("add_remote_state_consumers() - Requires consumer workspace IDs") - print("update_remote_state_consumers() - Requires specific setup") - print("remove_remote_state_consumers() - Requires existing consumers") - # 14) Test data retention policies - if (args.all_tests or args.retention) and args.workspace_id: + if args.retention and args.workspace_id: _print_header("Testing data retention policies") try: @@ -528,15 +477,8 @@ def main(): except Exception as e: print(f"read_data_retention_policy_choice: {e}") - print("Available policy setting methods:") - print("set_data_retention_policy() - Set custom retention policy") - print("set_data_retention_policy_delete_older() - Delete older runs") - print("set_data_retention_policy_dont_delete() - Keep all runs") - print("delete_data_retention_policy() - Remove retention policy") - print("(Not executed to preserve workspace settings)") - # 15) Test readme functionality - if (args.all_tests or args.readme) and args.workspace_id: + if args.readme and args.workspace_id: _print_header("Testing readme functionality") try: diff --git a/examples/workspace_resources.py b/examples/workspace_resources.py index 15fbf1b..f71b7c9 100644 --- a/examples/workspace_resources.py +++ b/examples/workspace_resources.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Example script for working with workspace resources in Terraform Enterprise. This script demonstrates how to list resources within a workspace. diff --git a/pyproject.toml b/pyproject.toml index ad9117e..c8986fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pytfe" -version = "0.1.2" +version = "0.1.3" 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/__init__.py b/src/pytfe/__init__.py index bc7be16..68cea56 100644 --- a/src/pytfe/__init__.py +++ b/src/pytfe/__init__.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from . import errors, models from .client import TFEClient from .config import TFEConfig diff --git a/src/pytfe/_http.py b/src/pytfe/_http.py index 2c32012..4c25358 100644 --- a/src/pytfe/_http.py +++ b/src/pytfe/_http.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations import re diff --git a/src/pytfe/_jsonapi.py b/src/pytfe/_jsonapi.py index 3a12f60..7fafff9 100644 --- a/src/pytfe/_jsonapi.py +++ b/src/pytfe/_jsonapi.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from typing import Any diff --git a/src/pytfe/client.py b/src/pytfe/client.py index d1c8337..30b506b 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from ._http import HTTPTransport @@ -69,6 +72,7 @@ def __init__(self, config: TFEConfig | None = None): self.plans = Plans(self._transport) self.organizations = Organizations(self._transport) self.organization_memberships = OrganizationMemberships(self._transport) + self.projects = Projects(self._transport) self.variables = Variables(self._transport) self.variable_sets = VariableSets(self._transport) diff --git a/src/pytfe/config.py b/src/pytfe/config.py index e4c3da3..24c6177 100644 --- a/src/pytfe/config.py +++ b/src/pytfe/config.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations import os diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index 168d37b..e913f6d 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from typing import Any diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index 8524e6b..0f1435d 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations # ── Agent & Agent Pools ──────────────────────────────────────────────────────── @@ -106,7 +109,6 @@ PolicyActions, PolicyCheck, PolicyCheckIncludeOpt, - PolicyCheckList, PolicyCheckListOptions, PolicyPermissions, PolicyResult, @@ -154,6 +156,7 @@ ProjectAddTagBindingsOptions, ProjectCreateOptions, ProjectListOptions, + ProjectSettingOverwrites, ProjectUpdateOptions, ) @@ -287,10 +290,20 @@ from .ssh_key import ( SSHKey, SSHKeyCreateOptions, - SSHKeyList, SSHKeyListOptions, SSHKeyUpdateOptions, ) +from .state_version import ( + StateVersion, + StateVersionCreateOptions, + StateVersionCurrentOptions, + StateVersionListOptions, + StateVersionReadOptions, +) +from .state_version_output import ( + StateVersionOutput, + StateVersionOutputsListOptions, +) from .team import ( OrganizationAccess, Team, @@ -339,7 +352,6 @@ WorkspaceAssignSSHKeyOptions, WorkspaceCreateOptions, WorkspaceIncludeOpt, - WorkspaceList, WorkspaceListOptions, WorkspaceListRemoteStateConsumersOptions, WorkspaceLockOptions, @@ -382,7 +394,6 @@ # SSH keys "SSHKey", "SSHKeyCreateOptions", - "SSHKeyList", "SSHKeyListOptions", "SSHKeyUpdateOptions", # Reserved tag keys @@ -494,6 +505,7 @@ "ProjectCreateOptions", "ProjectListOptions", "ProjectUpdateOptions", + "ProjectSettingOverwrites", "DataRetentionPolicy", "DataRetentionPolicyChoice", "DataRetentionPolicyDeleteOlder", @@ -521,7 +533,6 @@ "WorkspaceAssignSSHKeyOptions", "WorkspaceCreateOptions", "WorkspaceIncludeOpt", - "WorkspaceList", "WorkspaceListOptions", "WorkspaceListRemoteStateConsumersOptions", "WorkspaceLockOptions", @@ -598,7 +609,6 @@ "PolicyResult", "PolicyStatusTimestamps", "PolicyCheckListOptions", - "PolicyCheckList", # Policy Evaluation "PolicyAttachable", "PolicyEvaluation", @@ -652,8 +662,16 @@ "VariableSetVariableCreateOptions", "VariableSetVariableListOptions", "VariableSetVariableUpdateOptions", + # State Versions + "StateVersion", + "StateVersionCreateOptions", + "StateVersionCurrentOptions", + "StateVersionListOptions", + "StateVersionReadOptions", + # State Version Outputs + "StateVersionOutput", + "StateVersionOutputsListOptions", ] # Rebuild models with forward references after all models are loaded PolicyCheck.model_rebuild() -PolicyCheckList.model_rebuild() diff --git a/src/pytfe/models/agent.py b/src/pytfe/models/agent.py index 48c3055..d0751ea 100644 --- a/src/pytfe/models/agent.py +++ b/src/pytfe/models/agent.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Agent and Agent Pool models for the Python TFE SDK. This module contains Pydantic models for Terraform Enterprise/Cloud agents and agent pools, @@ -82,6 +85,10 @@ class AgentPoolCreateOptions(BaseModel): organization_scoped: bool | None = None # Optional: Allowed workspace policy allowed_workspace_policy: AgentPoolAllowedWorkspacePolicy | None = None + # Optional: IDs of workspaces allowed to use this pool (sent as relationships.allowed-workspaces) + allowed_workspace_ids: list[str] = Field(default_factory=list) + # Optional: IDs of workspaces excluded from this pool (sent as relationships.excluded-workspaces) + excluded_workspace_ids: list[str] = Field(default_factory=list) class AgentPoolUpdateOptions(BaseModel): @@ -93,6 +100,10 @@ class AgentPoolUpdateOptions(BaseModel): organization_scoped: bool | None = None # Optional: Allowed workspace policy allowed_workspace_policy: AgentPoolAllowedWorkspacePolicy | None = None + # Optional: Full replacement list of workspace IDs allowed to use this pool + allowed_workspace_ids: list[str] = Field(default_factory=list) + # Optional: Full replacement list of workspace IDs excluded from this pool + excluded_workspace_ids: list[str] = Field(default_factory=list) class AgentPoolReadOptions(BaseModel): diff --git a/src/pytfe/models/apply.py b/src/pytfe/models/apply.py index abfa02b..c3f991c 100644 --- a/src/pytfe/models/apply.py +++ b/src/pytfe/models/apply.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from datetime import datetime diff --git a/src/pytfe/models/comment.py b/src/pytfe/models/comment.py index 6242f39..da2cd21 100644 --- a/src/pytfe/models/comment.py +++ b/src/pytfe/models/comment.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from pydantic import BaseModel, ConfigDict, Field diff --git a/src/pytfe/models/common.py b/src/pytfe/models/common.py index a6dd569..335bcc9 100644 --- a/src/pytfe/models/common.py +++ b/src/pytfe/models/common.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from typing import Any diff --git a/src/pytfe/models/configuration_version.py b/src/pytfe/models/configuration_version.py index 6bc613a..d45599e 100644 --- a/src/pytfe/models/configuration_version.py +++ b/src/pytfe/models/configuration_version.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from enum import Enum @@ -25,6 +28,7 @@ class ConfigurationSource(str, Enum): GITLAB = "gitlab" ADO = "ado" TERRAFORM = "terraform" + TERRAFORM_CLOUD = "terraform+cloud" class ConfigVerIncludeOpt(str, Enum): diff --git a/src/pytfe/models/cost_estimate.py b/src/pytfe/models/cost_estimate.py index 3c0f4e1..d1b6ff6 100644 --- a/src/pytfe/models/cost_estimate.py +++ b/src/pytfe/models/cost_estimate.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from datetime import datetime diff --git a/src/pytfe/models/data_retention_policy.py b/src/pytfe/models/data_retention_policy.py index 6b6349f..4671a21 100644 --- a/src/pytfe/models/data_retention_policy.py +++ b/src/pytfe/models/data_retention_policy.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from pydantic import BaseModel diff --git a/src/pytfe/models/notification_configuration.py b/src/pytfe/models/notification_configuration.py index 0632a1e..e1af877 100644 --- a/src/pytfe/models/notification_configuration.py +++ b/src/pytfe/models/notification_configuration.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """ Notification Configuration Models @@ -188,17 +191,14 @@ class NotificationConfigurationListOptions: """Represents the options for listing notification configurations.""" # Type annotations for instance attributes - page_number: int | None page_size: int | None subscribable_choice: NotificationConfigurationSubscribableChoice | None def __init__( self, - page_number: int | None = None, page_size: int | None = None, subscribable_choice: NotificationConfigurationSubscribableChoice | None = None, ): - self.page_number = page_number self.page_size = page_size self.subscribable_choice = subscribable_choice @@ -206,8 +206,6 @@ def to_dict(self) -> dict[str, Any]: """Convert to dictionary for API requests.""" params = {} - if self.page_number is not None: - params["page[number]"] = self.page_number if self.page_size is not None: params["page[size]"] = self.page_size diff --git a/src/pytfe/models/oauth_client.py b/src/pytfe/models/oauth_client.py index 4a74ee4..86ffbbf 100644 --- a/src/pytfe/models/oauth_client.py +++ b/src/pytfe/models/oauth_client.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from datetime import datetime diff --git a/src/pytfe/models/oauth_token.py b/src/pytfe/models/oauth_token.py index a70c20e..9cc78ca 100644 --- a/src/pytfe/models/oauth_token.py +++ b/src/pytfe/models/oauth_token.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from datetime import datetime diff --git a/src/pytfe/models/organization.py b/src/pytfe/models/organization.py index 21e40b2..4616d97 100644 --- a/src/pytfe/models/organization.py +++ b/src/pytfe/models/organization.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from datetime import datetime diff --git a/src/pytfe/models/organization_membership.py b/src/pytfe/models/organization_membership.py index a588e9c..a80cbcb 100644 --- a/src/pytfe/models/organization_membership.py +++ b/src/pytfe/models/organization_membership.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from enum import Enum diff --git a/src/pytfe/models/plan.py b/src/pytfe/models/plan.py index 2987a95..c543c25 100644 --- a/src/pytfe/models/plan.py +++ b/src/pytfe/models/plan.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from datetime import datetime diff --git a/src/pytfe/models/plan_export.py b/src/pytfe/models/plan_export.py index 0d5e45a..a00085b 100644 --- a/src/pytfe/models/plan_export.py +++ b/src/pytfe/models/plan_export.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from pydantic import BaseModel, ConfigDict diff --git a/src/pytfe/models/policy.py b/src/pytfe/models/policy.py index fb182d9..379cec1 100644 --- a/src/pytfe/models/policy.py +++ b/src/pytfe/models/policy.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from datetime import datetime @@ -38,7 +41,6 @@ class PolicyListOptions(BaseModel): search: str | None = Field(None, alias="search[name]") kind: PolicyKind | None = Field(None, alias="filter[kind]") - page_number: int | None = Field(None, alias="page[number]") page_size: int | None = Field(None, alias="page[size]") diff --git a/src/pytfe/models/policy_check.py b/src/pytfe/models/policy_check.py index 7b97274..70856e0 100644 --- a/src/pytfe/models/policy_check.py +++ b/src/pytfe/models/policy_check.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from datetime import datetime @@ -106,7 +109,6 @@ class PolicyCheckListOptions(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) include: list[PolicyCheckIncludeOpt] | None = Field(None, alias="include") - page_number: int | None = Field(None, alias="page[number]") page_size: int | None = Field(None, alias="page[size]") diff --git a/src/pytfe/models/policy_evaluation.py b/src/pytfe/models/policy_evaluation.py index 49ad257..4891338 100644 --- a/src/pytfe/models/policy_evaluation.py +++ b/src/pytfe/models/policy_evaluation.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from datetime import datetime diff --git a/src/pytfe/models/policy_set.py b/src/pytfe/models/policy_set.py index 60fccb3..6bd7ca0 100644 --- a/src/pytfe/models/policy_set.py +++ b/src/pytfe/models/policy_set.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from datetime import datetime diff --git a/src/pytfe/models/policy_set_outcome.py b/src/pytfe/models/policy_set_outcome.py index ffb9723..ea2e168 100644 --- a/src/pytfe/models/policy_set_outcome.py +++ b/src/pytfe/models/policy_set_outcome.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from pydantic import BaseModel, ConfigDict, Field diff --git a/src/pytfe/models/policy_set_parameter.py b/src/pytfe/models/policy_set_parameter.py index 01a88c2..ec1b881 100644 --- a/src/pytfe/models/policy_set_parameter.py +++ b/src/pytfe/models/policy_set_parameter.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from pydantic import BaseModel, ConfigDict, Field diff --git a/src/pytfe/models/policy_set_version.py b/src/pytfe/models/policy_set_version.py index 4114856..f56bfe8 100644 --- a/src/pytfe/models/policy_set_version.py +++ b/src/pytfe/models/policy_set_version.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from datetime import datetime diff --git a/src/pytfe/models/policy_types.py b/src/pytfe/models/policy_types.py index 5a5c6b5..fd9ea2f 100644 --- a/src/pytfe/models/policy_types.py +++ b/src/pytfe/models/policy_types.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from enum import Enum diff --git a/src/pytfe/models/project.py b/src/pytfe/models/project.py index 3f4b9c6..b67d3c9 100644 --- a/src/pytfe/models/project.py +++ b/src/pytfe/models/project.py @@ -1,19 +1,39 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field +from .agent import AgentPool from .common import TagBinding +from .organization import Organization class Project(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + id: str - name: str | None = None - description: str = "" - organization: str | None = None - created_at: str = "" - updated_at: str = "" - workspace_count: int = 0 - default_execution_mode: str = "remote" + name: str | None = Field(default=None, alias="name") + description: str | None = Field(default=None, alias="description") + created_at: str | None = Field(default=None, alias="created-at") + updated_at: str | None = Field(default=None, alias="updated-at") + workspace_count: int = Field(default=0, alias="workspace-count") + default_execution_mode: str = Field( + default="remote", alias="default-execution-mode" + ) + auto_destroy_activity_duration: str | None = Field( + default=None, alias="auto-destroy-activity-duration" + ) + setting_overwrites: ProjectSettingOverwrites | None = Field( + default=None, alias="setting-overwrites" + ) + + # relations + default_agent_pool: AgentPool | None = Field( + default=None, alias="default-agent-pool" + ) + organization: Organization | None = Field(default=None, alias="organization") class ProjectListOptions(BaseModel): @@ -26,29 +46,81 @@ class ProjectListOptions(BaseModel): # Optional: Include related resources include: list[str] | None = None # Pagination options - page_number: int | None = None page_size: int | None = None class ProjectCreateOptions(BaseModel): """Options for creating a project""" + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + # Required: A name to identify the project name: str # Optional: A description for the project - description: str | None = None + description: str | None = Field(default=None, alias="description") + auto_destroy_activity_duration: str | None = Field( + default=None, + alias="auto-destroy-activity-duration", + ) + default_execution_mode: str | None = Field( + default="remote", alias="default-execution-mode" + ) + # Optional: DefaultAgentPoolID default agent pool for workspaces in the project, + # required when DefaultExecutionMode is set to `agent` + default_agent_pool_id: str | None = Field( + default=None, + alias="default-agent-pool-id", + ) + setting_overwrites: ProjectSettingOverwrites | None = Field( + default=None, + alias="setting-overwrites", + ) + tag_bindings: list[TagBinding] | None = Field( + default_factory=list, alias="tag-bindings" + ) class ProjectUpdateOptions(BaseModel): """Options for updating a project""" + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + # Optional: A name to identify the project name: str | None = None # Optional: A description for the project - description: str | None = None + description: str | None = Field(default=None, alias="description") + auto_destroy_activity_duration: str | None = Field( + default=None, + alias="auto-destroy-activity-duration", + ) + default_execution_mode: str | None = Field( + default="remote", alias="default-execution-mode" + ) + # Optional: DefaultAgentPoolID default agent pool for workspaces in the project, + # required when DefaultExecutionMode is set to `agent` + default_agent_pool_id: str | None = Field( + default=None, + alias="default-agent-pool-id", + ) + setting_overwrites: ProjectSettingOverwrites | None = Field( + default=None, + alias="setting-overwrites", + ) + tag_bindings: list[TagBinding] | None = Field( + default_factory=list, alias="tag-bindings" + ) class ProjectAddTagBindingsOptions(BaseModel): """Options for adding tag bindings to a project""" tag_bindings: list[TagBinding] = Field(default_factory=list) + + +class ProjectSettingOverwrites(BaseModel): + """Options for overwriting project settings""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + execution_mode: bool | None = Field(alias="default-execution-mode") + agent_pool: bool | None = Field(alias="default-agent-pool") diff --git a/src/pytfe/models/query_run.py b/src/pytfe/models/query_run.py index cdcfe57..79c84be 100644 --- a/src/pytfe/models/query_run.py +++ b/src/pytfe/models/query_run.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from datetime import datetime diff --git a/src/pytfe/models/registry_module.py b/src/pytfe/models/registry_module.py index d7c39be..dc0953a 100644 --- a/src/pytfe/models/registry_module.py +++ b/src/pytfe/models/registry_module.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from enum import Enum diff --git a/src/pytfe/models/registry_provider.py b/src/pytfe/models/registry_provider.py index 3ac3140..2861aca 100644 --- a/src/pytfe/models/registry_provider.py +++ b/src/pytfe/models/registry_provider.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from datetime import datetime diff --git a/src/pytfe/models/registry_provider_version.py b/src/pytfe/models/registry_provider_version.py index 6c043d3..e587505 100644 --- a/src/pytfe/models/registry_provider_version.py +++ b/src/pytfe/models/registry_provider_version.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from datetime import datetime diff --git a/src/pytfe/models/reserved_tag_key.py b/src/pytfe/models/reserved_tag_key.py index c332742..54622e0 100644 --- a/src/pytfe/models/reserved_tag_key.py +++ b/src/pytfe/models/reserved_tag_key.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from datetime import datetime diff --git a/src/pytfe/models/run.py b/src/pytfe/models/run.py index 7ae158a..ab305a5 100644 --- a/src/pytfe/models/run.py +++ b/src/pytfe/models/run.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from datetime import datetime @@ -24,6 +27,10 @@ class RunSource(str, Enum): Run_Source_Configuration_Version = "tfe-configuration-version" Run_Source_UI = "tfe-ui" Run_Source_Terraform_Cloud = "terraform+cloud" + Run_Source_Terraform = "terraform" + Run_Source_Run_Trigger = "tfe-run-trigger" + Run_Source_Infra_Lifecycle = "tfe-infrastructure-lifecycle" + Run_Source_Module = "tfe-module" class RunStatus(str, Enum): diff --git a/src/pytfe/models/run_event.py b/src/pytfe/models/run_event.py index 419937d..bcf7b1b 100644 --- a/src/pytfe/models/run_event.py +++ b/src/pytfe/models/run_event.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from datetime import datetime diff --git a/src/pytfe/models/run_task.py b/src/pytfe/models/run_task.py index 8741162..11c2bea 100644 --- a/src/pytfe/models/run_task.py +++ b/src/pytfe/models/run_task.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from enum import Enum diff --git a/src/pytfe/models/run_trigger.py b/src/pytfe/models/run_trigger.py index b9a4e74..1abfc12 100644 --- a/src/pytfe/models/run_trigger.py +++ b/src/pytfe/models/run_trigger.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from datetime import datetime diff --git a/src/pytfe/models/ssh_key.py b/src/pytfe/models/ssh_key.py index cc853e4..4df3d0a 100644 --- a/src/pytfe/models/ssh_key.py +++ b/src/pytfe/models/ssh_key.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from pydantic import BaseModel, ConfigDict, Field diff --git a/src/pytfe/models/state_version.py b/src/pytfe/models/state_version.py index 018620c..4d8607d 100644 --- a/src/pytfe/models/state_version.py +++ b/src/pytfe/models/state_version.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from datetime import datetime @@ -78,7 +81,6 @@ class StateVersionListOptions(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) # Standard pagination + filters - page_number: int | None = Field(None, alias="page[number]") page_size: int | None = Field(None, alias="page[size]") organization: str | None = Field(None, alias="filter[organization][name]") workspace: str | None = Field(None, alias="filter[workspace][name]") diff --git a/src/pytfe/models/state_version_output.py b/src/pytfe/models/state_version_output.py index 1d69b27..a5fb57c 100644 --- a/src/pytfe/models/state_version_output.py +++ b/src/pytfe/models/state_version_output.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from typing import Any @@ -19,7 +22,6 @@ class StateVersionOutput(BaseModel): class StateVersionOutputsListOptions(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) - page_number: int | None = Field(None, alias="page[number]") page_size: int | None = Field(None, alias="page[size]") diff --git a/src/pytfe/models/task_stage.py b/src/pytfe/models/task_stage.py index 3f8b12f..54b9346 100644 --- a/src/pytfe/models/task_stage.py +++ b/src/pytfe/models/task_stage.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from pydantic import BaseModel, ConfigDict diff --git a/src/pytfe/models/team.py b/src/pytfe/models/team.py index c19b007..8b0b833 100644 --- a/src/pytfe/models/team.py +++ b/src/pytfe/models/team.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from typing import TYPE_CHECKING diff --git a/src/pytfe/models/user.py b/src/pytfe/models/user.py index 26b902e..bfa4335 100644 --- a/src/pytfe/models/user.py +++ b/src/pytfe/models/user.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from pydantic import BaseModel, ConfigDict, Field diff --git a/src/pytfe/models/variable.py b/src/pytfe/models/variable.py index e9a137a..8c40f05 100644 --- a/src/pytfe/models/variable.py +++ b/src/pytfe/models/variable.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from enum import Enum diff --git a/src/pytfe/models/variable_set.py b/src/pytfe/models/variable_set.py index 0daee06..02c413f 100644 --- a/src/pytfe/models/variable_set.py +++ b/src/pytfe/models/variable_set.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from datetime import datetime @@ -69,7 +72,6 @@ class VariableSetListOptions(BaseModel): """Options for listing variable sets.""" # Pagination options - page_number: int | None = None page_size: int | None = None include: list[VariableSetIncludeOpt] | None = None query: str | None = None # Filter by name diff --git a/src/pytfe/models/workspace.py b/src/pytfe/models/workspace.py index dca54b0..d24ab35 100644 --- a/src/pytfe/models/workspace.py +++ b/src/pytfe/models/workspace.py @@ -1,74 +1,179 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from datetime import datetime from enum import Enum -from typing import Any +from typing import TYPE_CHECKING, Any + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from ..errors import ( + InvalidNameError, + RequiredAgentModeError, + RequiredAgentPoolIDError, + RequiredNameError, + UnsupportedBothTagsRegexAndFileTriggersEnabledError, + UnsupportedBothTagsRegexAndTriggerPatternsError, + UnsupportedBothTagsRegexAndTriggerPrefixesError, + UnsupportedBothTriggerPatternsAndPrefixesError, + UnsupportedOperationsError, +) +from ..utils import has_tags_regex_defined, is_valid_workspace_name, valid_string +from .agent import AgentPool +from .common import EffectiveTagBinding, Tag, TagBinding +from .data_retention_policy import DataRetentionPolicyChoice +from .organization import ExecutionMode, Organization +from .project import Project -from pydantic import BaseModel, Field +if TYPE_CHECKING: + from .run import Run + + +# Helper classes that need to be defined before Workspace +class WorkspaceSource(str, Enum): + API = "tfe-api" + MODULE = "tfe-module" + UI = "tfe-ui" + TERRAFORM = "terraform" + + +class WorkspaceActions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + is_destroyable: bool = Field(default=False, alias="is-destroyable") -from .common import EffectiveTagBinding, Pagination, Tag, TagBinding -from .data_retention_policy import DataRetentionPolicy, DataRetentionPolicyChoice -from .organization import ExecutionMode -from .project import Project + +class WorkspacePermissions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + can_destroy: bool = Field(default=False, alias="can-destroy") + can_force_unlock: bool = Field(default=False, alias="can-force-unlock") + can_lock: bool = Field(default=False, alias="can-lock") + can_manage_run_tasks: bool = Field(default=False, alias="can-manage-run-tasks") + can_queue_apply: bool = Field(default=False, alias="can-queue-apply") + can_queue_destroy: bool = Field(default=False, alias="can-queue-destroy") + can_queue_run: bool = Field(default=False, alias="can-queue-run") + can_read_settings: bool = Field(default=False, alias="can-read-settings") + can_unlock: bool = Field(default=False, alias="can-unlock") + can_update: bool = Field(default=False, alias="can-update") + can_update_variable: bool = Field(default=False, alias="can-update-variable") + can_force_delete: bool | None = Field(default=None, alias="can-force-delete") + + +class WorkspaceSettingOverwrites(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + execution_mode: bool | None = Field(None, alias="execution-mode") + agent_pool: bool | None = Field(None, alias="agent-pool") + + +class WorkspaceOutputs(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str + name: str | None = Field(default=None, alias="name") + sensitive: bool = Field(default=False, alias="sensitive") + output_type: str | None = Field(default=None, alias="output-type") + value: Any | None = Field(default=None, alias="value") + + +class LockedByChoice(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + run: Any | None = None + user: Any | None = None + team: Any | None = None + + +class VCSRepo(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + branch: str | None = Field(default=None, alias="branch") + display_identifier: str | None = Field(default=None, alias="display-identifier") + identifier: str | None = Field(default=None, alias="identifier") + ingress_submodules: bool | None = Field(default=None, alias="ingress-submodules") + oauth_token_id: str | None = Field(default=None, alias="oauth-token-id") + tags_regex: str | None = Field(default=None, alias="tags-regex") + gha_installation_id: str | None = Field( + default=None, alias="github-app-installation-id" + ) + repository_http_url: str | None = Field(default=None, alias="repository-http-url") + service_provider: str | None = Field(default=None, alias="service-provider") + tags: bool | None = Field(default=None, alias="tags") + webhook_url: str | None = Field(default=None, alias="webhook-url") + tag_prefix: str | None = Field(default=None, alias="tag-prefix") + source_directory: str | None = Field(default=None, alias="source-directory") class Workspace(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + id: str - name: str | None = None - organization: str | None = None - execution_mode: ExecutionMode | None = None - project_id: str | None = None + name: str | None = Field(None, alias="name") # Core attributes - actions: WorkspaceActions | None = None - allow_destroy_plan: bool = False - assessments_enabled: bool = False - auto_apply: bool = False - auto_apply_run_trigger: bool = False - auto_destroy_at: datetime | None = None - auto_destroy_activity_duration: str | None = None - can_queue_destroy_plan: bool = False - created_at: datetime | None = None - description: str = "" - environment: str = "" - file_triggers_enabled: bool = False - global_remote_state: bool = False - inherits_project_auto_destroy: bool = False - locked: bool = False - migration_environment: str = "" - no_code_upgrade_available: bool = False - operations: bool = False - permissions: WorkspacePermissions | None = None - queue_all_runs: bool = False - speculative_enabled: bool = False - source: WorkspaceSource | None = None - source_name: str = "" - source_url: str = "" - structured_run_output_enabled: bool = False - terraform_version: str = "" - trigger_prefixes: list[str] = Field(default_factory=list) - trigger_patterns: list[str] = Field(default_factory=list) - vcs_repo: VCSRepo | None = None - working_directory: str = "" - updated_at: datetime | None = None - resource_count: int = 0 - apply_duration_average: float | None = None # in seconds - plan_duration_average: float | None = None # in seconds - policy_check_failures: int = 0 - run_failures: int = 0 - runs_count: int = 0 - tag_names: list[str] = Field(default_factory=list) - setting_overwrites: WorkspaceSettingOverwrites | None = None + actions: WorkspaceActions | None = Field(None, alias="actions") + allow_destroy_plan: bool | None = Field(None, alias="allow-destroy-plan") + assessments_enabled: bool | None = Field(None, alias="assessments-enabled") + auto_apply: bool | None = Field(None, alias="auto-apply") + auto_apply_run_trigger: bool | None = Field(None, alias="auto-apply-run-trigger") + auto_destroy_at: datetime | None = Field(None, alias="auto-destroy-at") + auto_destroy_activity_duration: str | None = Field( + None, alias="auto-destroy-activity-duration" + ) + can_queue_destroy_plan: bool | None = Field(None, alias="can-queue-destroy-plan") + created_at: datetime | None = Field(None, alias="created-at") + description: str | None = Field(None, alias="description") + environment: str | None = Field(None, alias="environment") + execution_mode: ExecutionMode | None = Field(None, alias="execution-mode") + file_triggers_enabled: bool | None = Field(None, alias="file-triggers-enabled") + global_remote_state: bool | None = Field(None, alias="global-remote-state") + inherits_project_auto_destroy: bool | None = Field( + None, alias="inherits-project-auto-destroy" + ) + locked: bool | None = Field(None, alias="locked") + migration_environment: str | None = Field(None, alias="migration-environment") + no_code_upgrade_available: bool | None = Field( + None, alias="no-code-upgrade-available" + ) + operations: bool | None = Field(None, alias="operations") + permissions: WorkspacePermissions | None = Field(None, alias="permissions") + queue_all_runs: bool | None = Field(None, alias="queue-all-runs") + speculative_enabled: bool | None = Field(None, alias="speculative-enabled") + source: WorkspaceSource | None = Field(None, alias="source") + source_name: str | None = Field(None, alias="source-name") + source_url: str | None = Field(None, alias="source-url") + structured_run_output_enabled: bool | None = Field( + None, alias="structured-run-output-enabled" + ) + terraform_version: str | None = Field(None, alias="terraform-version") + trigger_prefixes: list[str] = Field(default_factory=list, alias="trigger-prefixes") + trigger_patterns: list[str] = Field(default_factory=list, alias="trigger-patterns") + vcs_repo: VCSRepo | None = Field(None, alias="vcs-repo") + working_directory: str | None = Field(None, alias="working-directory") + updated_at: datetime | None = Field(None, alias="updated-at") + resource_count: int | None = Field(None, alias="resource-count") + apply_duration_average: float | None = Field(None, alias="apply-duration-average") + plan_duration_average: float | None = Field(None, alias="plan-duration-average") + policy_check_failures: int | None = Field(None, alias="policy-check-failures") + run_failures: int | None = Field(None, alias="run-failures") + runs_count: int | None = Field(None, alias="workspace-kpis-runs-count") + tag_names: list[str] = Field(default_factory=list, alias="tag-names") + setting_overwrites: WorkspaceSettingOverwrites | None = Field( + None, alias="setting-overwrites" + ) # Relations - agent_pool: Any | None = None # AgentPool object - current_run: Any | None = None # Run object + agent_pool: AgentPool | None = None # AgentPool object + current_run: Run | None = None # Run object current_state_version: Any | None = None # StateVersion object + organization: Organization | None = None project: Project | None = None ssh_key: Any | None = None # SSHKey object outputs: list[WorkspaceOutputs] = Field(default_factory=list) tags: list[Tag] = Field(default_factory=list) - # tags: list[Tag] = Field(default_factory=list) current_configuration_version: Any | None = None # ConfigurationVersion object locked_by: LockedByChoice | None = None variables: list[Any] = Field(default_factory=list) # Variable objects @@ -76,8 +181,9 @@ class Workspace(BaseModel): effective_tag_bindings: list[EffectiveTagBinding] = Field(default_factory=list) # Links - links: dict[str, Any] = Field(default_factory=dict) - data_retention_policy: DataRetentionPolicy | None = None + links: dict[str, Any] | None = Field(None, alias="links") + + data_retention_policy: Any | None = None # Legacy field, deprecated data_retention_policy_choice: DataRetentionPolicyChoice | None = None @@ -99,190 +205,258 @@ class WorkspaceIncludeOpt(str, Enum): PROJECT = "project" -class WorkspaceSource(str, Enum): - API = "tfe-api" - MODULE = "tfe-module" - UI = "tfe-ui" - TERRAFORM = "terraform" - - -class WorkspaceActions(BaseModel): - is_destroyable: bool = False - - -class WorkspacePermissions(BaseModel): - can_destroy: bool = False - can_force_unlock: bool = False - can_lock: bool = False - can_manage_run_tasks: bool = False - can_queue_apply: bool = False - can_queue_destroy: bool = False - can_queue_run: bool = False - can_read_settings: bool = False - can_unlock: bool = False - can_update: bool = False - can_update_variable: bool = False - can_force_delete: bool | None = None - - -class WorkspaceSettingOverwrites(BaseModel): - execution_mode: bool | None = None - agent_pool: bool | None = None - - -class WorkspaceOutputs(BaseModel): - id: str - name: str - sensitive: bool = False - output_type: str - value: Any | None = None - - -class LockedByChoice(BaseModel): - run: Any | None = None - user: Any | None = None - team: Any | None = None - - class WorkspaceListOptions(BaseModel): """Options for listing workspaces.""" - # Pagination options (from ListOptions) - page_number: int | None = None - page_size: int | None = None + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) - # Search and filter options - search: str | None = None # search[name] - partial workspace name - tags: str | None = None # search[tags] - comma-separated tag names - exclude_tags: str | None = ( - None # search[exclude-tags] - comma-separated tag names to exclude - ) - wildcard_name: str | None = None # search[wildcard-name] - substring matching - project_id: str | None = None # filter[project][id] - project ID filter - current_run_status: str | None = ( - None # filter[current-run][status] - run status filter - ) + page_size: int | None = Field(None, alias="page[size]") + search: str | None = Field(None, alias="search[name]") + tags: str | None = Field(None, alias="search[tags]") + exclude_tags: str | None = Field(None, alias="search[exclude-tags]") + wildcard_name: str | None = Field(None, alias="search[wildcard-name]") + project_id: str | None = Field(None, alias="filter[project][id]") + current_run_status: str | None = Field(None, alias="filter[current-run][status]") - # Tag binding filters (not URL encoded, handled specially) tag_bindings: list[TagBinding] = Field(default_factory=list) # Include related resources - include: list[WorkspaceIncludeOpt] = Field(default_factory=list) + include: list[WorkspaceIncludeOpt] | None = Field(None, alias="include") # Sorting options - sort: str | None = ( - None # "name" (default) or "current-run.created-at", prepend "-" to reverse - ) + sort: str | None = Field(None, alias="sort") class WorkspaceReadOptions(BaseModel): - include: list[WorkspaceIncludeOpt] = Field(default_factory=list) + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + include: list[WorkspaceIncludeOpt] | None = Field(None, alias="include") class WorkspaceCreateOptions(BaseModel): - name: str - type: str = "workspaces" - agent_pool_id: str | None = None - allow_destroy_plan: bool | None = None - assessments_enabled: bool | None = None - auto_apply: bool | None = None - auto_apply_run_trigger: bool | None = None - auto_destroy_at: datetime | None = None - auto_destroy_activity_duration: str | None = None - inherits_project_auto_destroy: bool | None = None - description: str | None = None - execution_mode: ExecutionMode | None = None - file_triggers_enabled: bool | None = None - global_remote_state: bool | None = None - migration_environment: str | None = None - operations: bool | None = None - queue_all_runs: bool | None = None - speculative_enabled: bool | None = None - source_name: str | None = None - source_url: str | None = None - structured_run_output_enabled: bool | None = None - terraform_version: str | None = None - trigger_prefixes: list[str] = Field(default_factory=list) - trigger_patterns: list[str] = Field(default_factory=list) - vcs_repo: VCSRepo | None = None - working_directory: str | None = None - hyok_enabled: bool | None = None - tags: list[Tag] = Field(default_factory=list) - setting_overwrites: WorkspaceSettingOverwrites | None = None - project: Project | None = None - tag_bindings: list[TagBinding] = Field(default_factory=list) + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + name: str = Field(alias="name") + type: str = Field(default="workspaces") + agent_pool_id: str | None = Field(None, alias="agent-pool-id") + allow_destroy_plan: bool | None = Field(None, alias="allow-destroy-plan") + assessments_enabled: bool | None = Field(None, alias="assessments-enabled") + auto_apply: bool | None = Field(None, alias="auto-apply") + auto_apply_run_trigger: bool | None = Field(None, alias="auto-apply-run-trigger") + auto_destroy_at: datetime | None = Field(None, alias="auto-destroy-at") + auto_destroy_activity_duration: str | None = Field( + None, alias="auto-destroy-activity-duration" + ) + inherits_project_auto_destroy: bool | None = Field( + None, alias="inherits-project-auto-destroy" + ) + description: str | None = Field(None, alias="description") + execution_mode: ExecutionMode | None = Field(None, alias="execution-mode") + file_triggers_enabled: bool | None = Field(None, alias="file-triggers-enabled") + global_remote_state: bool | None = Field(None, alias="global-remote-state") + migration_environment: str | None = Field(None, alias="migration-environment") + operations: bool | None = Field(None, alias="operations") + queue_all_runs: bool | None = Field(None, alias="queue-all-runs") + speculative_enabled: bool | None = Field(None, alias="speculative-enabled") + source_name: str | None = Field(None, alias="source-name") + source_url: str | None = Field(None, alias="source-url") + structured_run_output_enabled: bool | None = Field( + None, alias="structured-run-output-enabled" + ) + terraform_version: str | None = Field(None, alias="terraform-version") + trigger_prefixes: list[str] | None = Field(None, alias="trigger-prefixes") + trigger_patterns: list[str] | None = Field(None, alias="trigger-patterns") + vcs_repo: VCSRepoOptions | None = Field(None, alias="vcs-repo") + working_directory: str | None = Field(None, alias="working-directory") + hyok_enabled: bool | None = Field(None, alias="hyok-enabled") + setting_overwrites: WorkspaceSettingOverwrites | None = Field( + None, alias="setting-overwrites" + ) + project: Project | None = Field(None, alias="project") + tag_bindings: list[TagBinding] | None = Field(None, alias="tag-bindings") + + @model_validator(mode="after") + def valid(self) -> WorkspaceCreateOptions: + """ + Validate workspace create options for proper API usage. + Raises specific validation errors if validation fails. + """ + # Check required name + if not valid_string(self.name): + raise RequiredNameError() + + # Check name format + if not is_valid_workspace_name(self.name): + raise InvalidNameError() + + # Check operations and execution mode conflict + if self.operations is not None and self.execution_mode is not None: + raise UnsupportedOperationsError() + + # Check agent mode requirements + if self.agent_pool_id is not None and ( + self.execution_mode is None or self.execution_mode != "agent" + ): + raise RequiredAgentModeError() + + if ( + self.agent_pool_id is None + and self.execution_mode is not None + and self.execution_mode == "agent" + ): + raise RequiredAgentPoolIDError() + + # Check trigger patterns and prefixes conflict + if ( + self.trigger_prefixes + and len(self.trigger_prefixes) > 0 + and self.trigger_patterns + and len(self.trigger_patterns) > 0 + ): + raise UnsupportedBothTriggerPatternsAndPrefixesError() + + # Check tags regex conflicts + if has_tags_regex_defined(self.vcs_repo): + if self.trigger_patterns and len(self.trigger_patterns) > 0: + raise UnsupportedBothTagsRegexAndTriggerPatternsError() + + if self.trigger_prefixes and len(self.trigger_prefixes) > 0: + raise UnsupportedBothTagsRegexAndTriggerPrefixesError() + + if self.file_triggers_enabled is not None and self.file_triggers_enabled: + raise UnsupportedBothTagsRegexAndFileTriggersEnabledError() + + return self -class WorkspaceUpdateOptions(BaseModel): - name: str - type: str = "workspaces" - agent_pool_id: str | None = None - allow_destroy_plan: bool | None = None - assessments_enabled: bool | None = None - auto_apply: bool | None = None - auto_apply_run_trigger: bool | None = None - auto_destroy_at: datetime | None = None - auto_destroy_activity_duration: str | None = None - inherits_project_auto_destroy: bool | None = None - description: str | None = None - execution_mode: ExecutionMode | None = None - file_triggers_enabled: bool | None = None - global_remote_state: bool | None = None - operations: bool | None = None - queue_all_runs: bool | None = None - speculative_enabled: bool | None = None - structured_run_output_enabled: bool | None = None - terraform_version: str | None = None - trigger_prefixes: list[str] = Field(default_factory=list) - trigger_patterns: list[str] = Field(default_factory=list) - vcs_repo: VCSRepo | None = None - working_directory: str | None = None - hyok_enabled: bool | None = None - setting_overwrites: WorkspaceSettingOverwrites | None = None - project: Project | None = None - tag_bindings: list[TagBinding] = Field(default_factory=list) +class WorkspaceUpdateOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) -class WorkspaceList(BaseModel): - items: list[Workspace] = Field(default_factory=list) - pagination: Pagination | None = None + name: str | None = Field(None, alias="name") + type: str = "workspaces" + agent_pool_id: str | None = Field(None, alias="agent-pool-id") + allow_destroy_plan: bool | None = Field(None, alias="allow-destroy-plan") + assessments_enabled: bool | None = Field(None, alias="assessments-enabled") + auto_apply: bool | None = Field(None, alias="auto-apply") + auto_apply_run_trigger: bool | None = Field(None, alias="auto-apply-run-trigger") + auto_destroy_at: datetime | None = Field(None, alias="auto-destroy-at") + auto_destroy_activity_duration: str | None = Field( + None, alias="auto-destroy-activity-duration" + ) + inherits_project_auto_destroy: bool | None = Field( + None, alias="inherits-project-auto-destroy" + ) + description: str | None = Field(None, alias="description") + execution_mode: ExecutionMode | None = Field(None, alias="execution-mode") + file_triggers_enabled: bool | None = Field(None, alias="file-triggers-enabled") + global_remote_state: bool | None = Field(None, alias="global-remote-state") + operations: bool | None = Field(None, alias="operations") + queue_all_runs: bool | None = Field(None, alias="queue-all-runs") + speculative_enabled: bool | None = Field(None, alias="speculative-enabled") + structured_run_output_enabled: bool | None = Field( + None, alias="structured-run-output-enabled" + ) + terraform_version: str | None = Field(None, alias="terraform-version") + trigger_prefixes: list[str] | None = Field(None, alias="trigger-prefixes") + trigger_patterns: list[str] | None = Field(None, alias="trigger-patterns") + vcs_repo: VCSRepoOptions | None = Field(None, alias="vcs-repo") + working_directory: str | None = Field(None, alias="working-directory") + hyok_enabled: bool | None = Field(None, alias="hyok-enabled") + setting_overwrites: WorkspaceSettingOverwrites | None = Field( + None, alias="setting-overwrites" + ) + project: Project | None = Field(None, alias="project") + tag_bindings: list[TagBinding] | None = Field(None, alias="tag-bindings") + + @model_validator(mode="after") + def valid(self) -> WorkspaceUpdateOptions: + """ + Validate workspace update options for proper API usage. + Raises specific validation errors if validation fails. + """ + # Check name format if provided + if self.name is not None and not is_valid_workspace_name(self.name): + raise InvalidNameError() + + # Check operations and execution mode conflict + if self.operations is not None and self.execution_mode is not None: + raise UnsupportedOperationsError() + + # Check agent mode requirements + if ( + self.agent_pool_id is None + and self.execution_mode is not None + and self.execution_mode == "agent" + ): + raise RequiredAgentPoolIDError() + + # Check trigger patterns and prefixes conflict + if ( + self.trigger_prefixes + and len(self.trigger_prefixes) > 0 + and self.trigger_patterns + and len(self.trigger_patterns) > 0 + ): + raise UnsupportedBothTriggerPatternsAndPrefixesError() + + # Check tags regex conflicts + if has_tags_regex_defined(self.vcs_repo): + if self.trigger_patterns and len(self.trigger_patterns) > 0: + raise UnsupportedBothTagsRegexAndTriggerPatternsError() + + if self.trigger_prefixes and len(self.trigger_prefixes) > 0: + raise UnsupportedBothTagsRegexAndTriggerPrefixesError() + + if self.file_triggers_enabled is not None and self.file_triggers_enabled: + raise UnsupportedBothTagsRegexAndFileTriggersEnabledError() + + return self class WorkspaceRemoveVCSConnectionOptions(BaseModel): """Options for removing VCS connection from a workspace.""" + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + id: str - vcs_repo: VCSRepoOptions | None = None + vcs_repo: VCSRepoOptions = Field(alias="vcs-repo") class WorkspaceLockOptions(BaseModel): """Options for locking a workspace.""" + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + # Specifies the reason for locking the workspace. - reason: str + reason: str | None = Field(None, alias="reason") class WorkspaceAssignSSHKeyOptions(BaseModel): """Options for assigning an SSH key to a workspace.""" - ssh_key_id: str - type: str = "workspaces" + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + ssh_key_id: str = Field(alias="id") + type: str = Field(default="workspaces") class workspaceUnassignSSHKeyOptions(BaseModel): """Options for unassigning an SSH key from a workspace.""" + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + # Must be nil to unset the currently assigned SSH key. - ssh_key_id: str - type: str = "workspaces" + ssh_key_id: str = Field(alias="id") + type: str = Field(default="workspaces") class WorkspaceListRemoteStateConsumersOptions(BaseModel): """Options for listing remote state consumers of a workspace.""" - # Pagination options (from ListOptions) - page_number: int | None = None - page_size: int | None = None + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + page_size: int | None = Field(None, alias="page[size]") class WorkspaceAddRemoteStateConsumersOptions(BaseModel): @@ -306,10 +480,10 @@ class WorkspaceUpdateRemoteStateConsumersOptions(BaseModel): class WorkspaceTagListOptions(BaseModel): """Options for listing tags of a workspace.""" - # Pagination options (from ListOptions) - page_number: int | None = None - page_size: int | None = None - query: str | None = None + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + page_size: int | None = Field(None, alias="page[size]") + query: str | None = Field(None, alias="name") class WorkspaceAddTagsOptions(BaseModel): @@ -330,19 +504,27 @@ class WorkspaceAddTagBindingsOptions(BaseModel): tag_bindings: list[TagBinding] = Field(default_factory=list) -class VCSRepo(BaseModel): - branch: str | None = None - identifier: str | None = None - ingress_submodules: bool | None = None - oauth_token_id: str | None = None - tags_regex: str | None = None - gha_installation_id: str | None = None +class VCSRepoOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + branch: str | None = Field(None, alias="branch") + identifier: str | None = Field(None, alias="identifier") + ingress_submodules: bool | None = Field(None, alias="ingress-submodules") + oauth_token_id: str | None = Field(None, alias="oauth-token-id") + tags_regex: str | None = Field(None, alias="tags-regex") + gha_installation_id: str | None = Field(None, alias="github-app-installation-id") -class VCSRepoOptions(BaseModel): - branch: str | None = None - identifier: str | None = None - ingress_submodules: bool | None = None - oauth_token_id: str | None = None - tags_regex: str | None = None - gha_installation_id: str | None = None + +# Rebuild Workspace model after all dependencies are defined +def _rebuild_workspace_model() -> None: + """Rebuild Workspace model to resolve forward references.""" + try: + from .run import Run # noqa: F401 + + Workspace.model_rebuild() + except ImportError: + # Run model not yet available, will be rebuilt later + pass + + +_rebuild_workspace_model() diff --git a/src/pytfe/models/workspace_resource.py b/src/pytfe/models/workspace_resource.py index 78eaa40..e3b5deb 100644 --- a/src/pytfe/models/workspace_resource.py +++ b/src/pytfe/models/workspace_resource.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Workspace resources models for Terraform Enterprise.""" from pydantic import BaseModel diff --git a/src/pytfe/models/workspace_run_task.py b/src/pytfe/models/workspace_run_task.py index b5072a6..f29695e 100644 --- a/src/pytfe/models/workspace_run_task.py +++ b/src/pytfe/models/workspace_run_task.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from pydantic import BaseModel diff --git a/src/pytfe/resources/_base.py b/src/pytfe/resources/_base.py index 0d8bf10..a6e65dd 100644 --- a/src/pytfe/resources/_base.py +++ b/src/pytfe/resources/_base.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from collections.abc import Iterator @@ -16,7 +19,7 @@ def _list( page = 1 while True: p = dict(params or {}) - p.setdefault("page[number]", page) + p["page[number]"] = page p.setdefault("page[size]", 100) r = self.t.request("GET", path, params=p) diff --git a/src/pytfe/resources/admin/settings.py b/src/pytfe/resources/admin/settings.py index 8ee9407..0242f6a 100644 --- a/src/pytfe/resources/admin/settings.py +++ b/src/pytfe/resources/admin/settings.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from typing import Any diff --git a/src/pytfe/resources/agent_pools.py b/src/pytfe/resources/agent_pools.py index e0ff776..47ffc8b 100644 --- a/src/pytfe/resources/agent_pools.py +++ b/src/pytfe/resources/agent_pools.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Agent Pool resource implementation for the Python TFE SDK. This module provides the AgentPools service for managing Terraform Enterprise/Cloud @@ -203,7 +206,27 @@ def create(self, organization: str, options: AgentPoolCreateOptions) -> AgentPoo options.allowed_workspace_policy.value ) - payload = {"data": {"type": "agent-pools", "attributes": attributes}} + relationships: dict[str, Any] = {} + if options.allowed_workspace_ids: + relationships["allowed-workspaces"] = { + "data": [ + {"type": "workspaces", "id": ws_id} + for ws_id in options.allowed_workspace_ids + ] + } + if options.excluded_workspace_ids: + relationships["excluded-workspaces"] = { + "data": [ + {"type": "workspaces", "id": ws_id} + for ws_id in options.excluded_workspace_ids + ] + } + + payload: dict[str, Any] = { + "data": {"type": "agent-pools", "attributes": attributes} + } + if relationships: + payload["data"]["relationships"] = relationships response = self.t.request("POST", path, json_body=payload) data = response.json()["data"] @@ -320,13 +343,31 @@ def update(self, agent_pool_id: str, options: AgentPoolUpdateOptions) -> AgentPo options.allowed_workspace_policy.value ) - payload = { + relationships: dict[str, Any] = {} + if options.allowed_workspace_ids: + relationships["allowed-workspaces"] = { + "data": [ + {"type": "workspaces", "id": ws_id} + for ws_id in options.allowed_workspace_ids + ] + } + if options.excluded_workspace_ids: + relationships["excluded-workspaces"] = { + "data": [ + {"type": "workspaces", "id": ws_id} + for ws_id in options.excluded_workspace_ids + ] + } + + payload: dict[str, Any] = { "data": { "type": "agent-pools", "id": agent_pool_id, "attributes": attributes, } } + if relationships: + payload["data"]["relationships"] = relationships response = self.t.request("PATCH", path, json_body=payload) data = response.json()["data"] @@ -371,13 +412,20 @@ def delete(self, agent_pool_id: str) -> None: def assign_to_workspaces( self, agent_pool_id: str, options: AgentPoolAssignToWorkspacesOptions - ) -> None: - """Assign an agent pool to workspaces. + ) -> AgentPool: + """Assign an agent pool to workspaces by updating the allowed-workspaces + relationship via PATCH /agent-pools/:id. + + The provided workspace IDs become the new complete list of allowed + workspaces for this pool (full replacement, not append). Args: agent_pool_id: Agent pool ID options: Assignment options containing workspace IDs + Returns: + Updated AgentPool object + Raises: ValueError: If parameters are invalid TFEError: If API request fails @@ -388,26 +436,67 @@ def assign_to_workspaces( if not options.workspace_ids: raise ValueError("At least one workspace ID is required") - path = f"/api/v2/agent-pools/{agent_pool_id}/relationships/workspaces" - - # Create data payload with workspace references - workspace_data = [] for workspace_id in options.workspace_ids: if not valid_string_id(workspace_id): raise ValueError(f"Invalid workspace ID: {workspace_id}") - workspace_data.append({"type": "workspaces", "id": workspace_id}) - payload = {"data": workspace_data} - self.t.request("POST", path, json_body=payload) + path = f"/api/v2/agent-pools/{agent_pool_id}" + payload: dict[str, Any] = { + "data": { + "type": "agent-pools", + "id": agent_pool_id, + "attributes": {}, + "relationships": { + "allowed-workspaces": { + "data": [ + {"type": "workspaces", "id": ws_id} + for ws_id in options.workspace_ids + ] + } + }, + } + } + response = self.t.request("PATCH", path, json_body=payload) + data = response.json()["data"] + + # Extract agent pool data from response + attr = data.get("attributes", {}) or {} + agent_pool_data = { + "id": _safe_str(data.get("id")), + "name": _safe_str(attr.get("name")), + "created_at": attr.get("created-at"), + "organization_scoped": attr.get("organization-scoped"), + "allowed_workspace_policy": attr.get("allowed-workspace-policy"), + "agent_count": attr.get("agent-count", 0), + } + + return AgentPool( + id=_safe_str(agent_pool_data["id"]) or "", + name=_safe_str(agent_pool_data["name"]), + created_at=cast(Any, agent_pool_data["created_at"]), + organization_scoped=_safe_bool(agent_pool_data["organization_scoped"]), + allowed_workspace_policy=_safe_workspace_policy( + agent_pool_data["allowed_workspace_policy"] + ), + agent_count=_safe_int(agent_pool_data["agent_count"]), + ) def remove_from_workspaces( self, agent_pool_id: str, options: AgentPoolRemoveFromWorkspacesOptions - ) -> None: - """Remove an agent pool from workspaces. + ) -> AgentPool: + """Exclude workspaces from an agent pool by updating the excluded-workspaces + relationship via PATCH /agent-pools/:id. + + 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). Args: agent_pool_id: Agent pool ID - options: Removal options containing workspace IDs + options: Removal options containing workspace IDs to exclude + + Returns: + Updated AgentPool object Raises: ValueError: If parameters are invalid @@ -419,14 +508,47 @@ def remove_from_workspaces( if not options.workspace_ids: raise ValueError("At least one workspace ID is required") - path = f"/api/v2/agent-pools/{agent_pool_id}/relationships/workspaces" - - # Create data payload with workspace references - workspace_data = [] for workspace_id in options.workspace_ids: if not valid_string_id(workspace_id): raise ValueError(f"Invalid workspace ID: {workspace_id}") - workspace_data.append({"type": "workspaces", "id": workspace_id}) - payload = {"data": workspace_data} - self.t.request("DELETE", path, json_body=payload) + path = f"/api/v2/agent-pools/{agent_pool_id}" + payload: dict[str, Any] = { + "data": { + "type": "agent-pools", + "id": agent_pool_id, + "attributes": {}, + "relationships": { + "excluded-workspaces": { + "data": [ + {"type": "workspaces", "id": ws_id} + for ws_id in options.workspace_ids + ] + } + }, + } + } + response = self.t.request("PATCH", path, json_body=payload) + data = response.json()["data"] + + # Extract agent pool data from response + attr = data.get("attributes", {}) or {} + agent_pool_data = { + "id": _safe_str(data.get("id")), + "name": _safe_str(attr.get("name")), + "created_at": attr.get("created-at"), + "organization_scoped": attr.get("organization-scoped"), + "allowed_workspace_policy": attr.get("allowed-workspace-policy"), + "agent_count": attr.get("agent-count", 0), + } + + return AgentPool( + id=_safe_str(agent_pool_data["id"]) or "", + name=_safe_str(agent_pool_data["name"]), + created_at=cast(Any, agent_pool_data["created_at"]), + organization_scoped=_safe_bool(agent_pool_data["organization_scoped"]), + allowed_workspace_policy=_safe_workspace_policy( + agent_pool_data["allowed_workspace_policy"] + ), + agent_count=_safe_int(agent_pool_data["agent_count"]), + ) diff --git a/src/pytfe/resources/agents.py b/src/pytfe/resources/agents.py index adc1ad7..d876368 100644 --- a/src/pytfe/resources/agents.py +++ b/src/pytfe/resources/agents.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Agent resource implementation for the Python TFE SDK. This module provides the Agents service for managing individual Terraform Enterprise/Cloud diff --git a/src/pytfe/resources/apply.py b/src/pytfe/resources/apply.py index c13a464..621a818 100644 --- a/src/pytfe/resources/apply.py +++ b/src/pytfe/resources/apply.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from ..errors import InvalidApplyIDError diff --git a/src/pytfe/resources/configuration_version.py b/src/pytfe/resources/configuration_version.py index 50c5125..6a7d047 100644 --- a/src/pytfe/resources/configuration_version.py +++ b/src/pytfe/resources/configuration_version.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations import io diff --git a/src/pytfe/resources/notification_configuration.py b/src/pytfe/resources/notification_configuration.py index 4de32ea..1543d64 100644 --- a/src/pytfe/resources/notification_configuration.py +++ b/src/pytfe/resources/notification_configuration.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """ Notification Configuration Resources @@ -6,6 +9,7 @@ from __future__ import annotations +from collections.abc import Iterator from typing import Any from ..errors import ( @@ -15,7 +19,6 @@ from ..models.notification_configuration import ( NotificationConfiguration, NotificationConfigurationCreateOptions, - NotificationConfigurationList, NotificationConfigurationListOptions, NotificationConfigurationUpdateOptions, ) @@ -30,35 +33,21 @@ def list( self, subscribable_id: str, options: NotificationConfigurationListOptions | None = None, - ) -> NotificationConfigurationList: + ) -> Iterator[NotificationConfiguration]: """List all notification configurations associated with a workspace or team.""" if not valid_string_id(subscribable_id): raise InvalidOrgError("Invalid subscribable ID") # Determine URL based on subscribable choice if options and options.subscribable_choice and options.subscribable_choice.team: - url = f"/api/v2/teams/{subscribable_id}/notification-configurations" + path = f"/api/v2/teams/{subscribable_id}/notification-configurations" else: - url = f"/api/v2/workspaces/{subscribable_id}/notification-configurations" + path = f"/api/v2/workspaces/{subscribable_id}/notification-configurations" params = options.to_dict() if options else None - r = self.t.request("GET", url, params=params) - jd = r.json() - - items = [] - meta = jd.get("meta", {}) - pagination = meta.get("pagination", {}) - - for d in jd.get("data", []): - items.append(self._parse_notification_configuration(d)) - - return NotificationConfigurationList( - { - "data": [{"attributes": item.__dict__} for item in items], - "meta": {"pagination": pagination}, - } - ) + for d in self._list(path, params=params): + yield self._parse_notification_configuration(d) def create( self, subscribable_id: str, options: NotificationConfigurationCreateOptions diff --git a/src/pytfe/resources/oauth_client.py b/src/pytfe/resources/oauth_client.py index 2321785..86cfa90 100644 --- a/src/pytfe/resources/oauth_client.py +++ b/src/pytfe/resources/oauth_client.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from collections.abc import Iterator diff --git a/src/pytfe/resources/oauth_token.py b/src/pytfe/resources/oauth_token.py index 337fa02..a417fa8 100644 --- a/src/pytfe/resources/oauth_token.py +++ b/src/pytfe/resources/oauth_token.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from collections.abc import Iterator diff --git a/src/pytfe/resources/organization_membership.py b/src/pytfe/resources/organization_membership.py index 659608b..a697067 100644 --- a/src/pytfe/resources/organization_membership.py +++ b/src/pytfe/resources/organization_membership.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations import re diff --git a/src/pytfe/resources/organizations.py b/src/pytfe/resources/organizations.py index fe6f94c..f9ac4b1 100644 --- a/src/pytfe/resources/organizations.py +++ b/src/pytfe/resources/organizations.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from collections.abc import Iterator diff --git a/src/pytfe/resources/plan.py b/src/pytfe/resources/plan.py index 332f2c5..7f7d39a 100644 --- a/src/pytfe/resources/plan.py +++ b/src/pytfe/resources/plan.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from typing import Any diff --git a/src/pytfe/resources/policy.py b/src/pytfe/resources/policy.py index fb30ca0..4ab5003 100644 --- a/src/pytfe/resources/policy.py +++ b/src/pytfe/resources/policy.py @@ -1,5 +1,11 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations +from collections.abc import Iterator +from typing import Any + from ..errors import ( InvalidNameError, InvalidOrgError, @@ -11,7 +17,6 @@ from ..models.policy import ( Policy, PolicyCreateOptions, - PolicyList, PolicyListOptions, PolicyUpdateOptions, ) @@ -22,35 +27,28 @@ class Policies(_Service): def list( self, organization: str, options: PolicyListOptions | None = None - ) -> PolicyList: - """List all the policies of the given organization.""" + ) -> Iterator[Policy]: + """Iterate all the policies of the given organization.""" if not valid_string_id(organization): raise InvalidOrgError() - params = ( - options.model_dump(by_alias=True, exclude_none=True) if options else None - ) - r = self.t.request( - "GET", - f"/api/v2/organizations/{organization}/policies", - params=params, - ) - jd = r.json() - items = [] - meta = jd.get("meta", {}) - pagination = meta.get("pagination", {}) - for d in jd.get("data", []): - attrs = d.get("attributes", {}) - attrs["id"] = d.get("id") - attrs["organization"] = d.get("relationships", {}).get("organization", {}) - items.append(Policy.model_validate(attrs)) - return PolicyList( - items=items, - current_page=pagination.get("current-page"), - total_pages=pagination.get("total-pages"), - prev_page=pagination.get("prev-page"), - next_page=pagination.get("next-page"), - total_count=pagination.get("total-count"), - ) + + path = f"/api/v2/organizations/{organization}/policies" + params: dict[str, Any] = {} + + if options: + if getattr(options, "page_size", None): + params["page[size]"] = str(options.page_size) + + def _gen() -> Iterator[Policy]: + for item in self._list(path, params=params): + attrs = item.get("attributes", {}) + attrs["id"] = item.get("id") + attrs["organization"] = item.get("relationships", {}).get( + "organization", {} + ) + yield Policy.model_validate(attrs) + + return _gen() def create(self, organization: str, options: PolicyCreateOptions) -> Policy: """Create a new policy in the given organization.""" diff --git a/src/pytfe/resources/policy_check.py b/src/pytfe/resources/policy_check.py index affae77..d9ba222 100644 --- a/src/pytfe/resources/policy_check.py +++ b/src/pytfe/resources/policy_check.py @@ -1,6 +1,10 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations import time +from collections.abc import Iterator from ..errors import ( InvalidPolicyCheckIDError, @@ -8,7 +12,6 @@ ) from ..models.policy_check import ( PolicyCheck, - PolicyCheckList, PolicyCheckListOptions, PolicyStatus, ) @@ -24,35 +27,19 @@ class PolicyChecks(_Service): def list( self, run_id: str, options: PolicyCheckListOptions | None = None - ) -> PolicyCheckList: + ) -> Iterator[PolicyCheck]: """List all policy checks of the given run.""" if not valid_string_id(run_id): raise InvalidRunIDError() params = ( options.model_dump(by_alias=True, exclude_none=True) if options else None ) - r = self.t.request( - "GET", - f"/api/v2/runs/{run_id}/policy-checks", - params=params, - ) - jd = r.json() - items = [] - meta = jd.get("meta", {}) - pagination = meta.get("pagination", {}) - for d in jd.get("data", []): - attrs = d.get("attributes", {}) - attrs["id"] = d.get("id") - attrs["run"] = d.get("relationships", {}).get("run", {}) - items.append(PolicyCheck.model_validate(attrs)) - return PolicyCheckList( - items=items, - current_page=pagination.get("current-page"), - total_pages=pagination.get("total-pages"), - prev_page=pagination.get("prev-page"), - next_page=pagination.get("next-page"), - total_count=pagination.get("total-count"), - ) + path = f"/api/v2/runs/{run_id}/policy-checks" + for item in self._list(path, params=params): + attrs = item.get("attributes", {}) + attrs["id"] = item.get("id") + attrs["run"] = item.get("relationships", {}).get("run", {}).get("data") + yield PolicyCheck.model_validate(attrs) def read(self, policy_check_id: str) -> PolicyCheck: """Read a policy check by its ID.""" @@ -66,7 +53,7 @@ def read(self, policy_check_id: str) -> PolicyCheck: d = jd.get("data", {}) attrs = d.get("attributes", {}) attrs["id"] = d.get("id") - attrs["run"] = d.get("relationships", {}).get("run", {}) + attrs["run"] = d.get("relationships", {}).get("run", {}).get("data") return PolicyCheck.model_validate(attrs) def override(self, policy_check_id: str) -> PolicyCheck: @@ -81,7 +68,7 @@ def override(self, policy_check_id: str) -> PolicyCheck: d = jd.get("data", {}) attrs = d.get("attributes", {}) attrs["id"] = d.get("id") - attrs["run"] = d.get("relationships", {}).get("run", {}) + attrs["run"] = d.get("relationships", {}).get("run", {}).get("data") return PolicyCheck.model_validate(attrs) def logs(self, policy_check_id: str) -> str: diff --git a/src/pytfe/resources/policy_evaluation.py b/src/pytfe/resources/policy_evaluation.py index 2f911f6..184fb2c 100644 --- a/src/pytfe/resources/policy_evaluation.py +++ b/src/pytfe/resources/policy_evaluation.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from collections.abc import Iterator diff --git a/src/pytfe/resources/policy_set.py b/src/pytfe/resources/policy_set.py index f25e986..64d2ea0 100644 --- a/src/pytfe/resources/policy_set.py +++ b/src/pytfe/resources/policy_set.py @@ -1,5 +1,10 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations +from collections.abc import Iterator + from ..errors import ( InvalidNameError, InvalidOrgError, @@ -17,7 +22,6 @@ PolicySetAddWorkspaceExclusionsOptions, PolicySetAddWorkspacesOptions, PolicySetCreateOptions, - PolicySetList, PolicySetListOptions, PolicySetReadOptions, PolicySetRemovePoliciesOptions, @@ -38,47 +42,41 @@ class PolicySets(_Service): def list( self, organization: str, options: PolicySetListOptions | None = None - ) -> PolicySetList: - """List all the policy sets of the given organization.""" + ) -> Iterator[PolicySet]: + """Iterate all the policy sets of the given organization.""" if not valid_string_id(organization): raise InvalidOrgError() + + # Build params from options but do not pass page[number] — let _list handle pagination. params = options.model_dump(by_alias=True, exclude_none=True) if options else {} - r = self.t.request( - "GET", - f"/api/v2/organizations/{organization}/policy-sets", - params=params, - ) - jd = r.json() - items = [] - meta = jd.get("meta", {}) - pagination = meta.get("pagination", {}) - for d in jd.get("data", []): - attrs = d.get("attributes", {}) - attrs["id"] = d.get("id") - attrs["organization"] = d.get("relationships", {}).get("organization", {}) - attrs["workspace_exclusions"] = ( - d.get("relationships", {}) - .get("workspace-exclusions", {}) - .get("data", []) - ) - attrs["workspaces"] = ( - d.get("relationships", {}).get("workspaces", {}).get("data", []) - ) - attrs["projects"] = ( - d.get("relationships", {}).get("projects", {}).get("data", []) - ) - attrs["policies"] = ( - d.get("relationships", {}).get("policies", {}).get("data", []) - ) - items.append(PolicySet.model_validate(attrs)) - return PolicySetList( - items=items, - current_page=pagination.get("current-page"), - total_pages=pagination.get("total-pages"), - prev_page=pagination.get("prev-page"), - next_page=pagination.get("next-page"), - total_count=pagination.get("total-count"), - ) + params.pop("page[number]", None) + + path = f"/api/v2/organizations/{organization}/policy-sets" + + def _gen() -> Iterator[PolicySet]: + for d in self._list(path, params=params): + attrs = d.get("attributes", {}) + attrs["id"] = d.get("id") + attrs["organization"] = d.get("relationships", {}).get( + "organization", {} + ) + attrs["workspace_exclusions"] = ( + d.get("relationships", {}) + .get("workspace-exclusions", {}) + .get("data", []) + ) + attrs["workspaces"] = ( + d.get("relationships", {}).get("workspaces", {}).get("data", []) + ) + attrs["projects"] = ( + d.get("relationships", {}).get("projects", {}).get("data", []) + ) + attrs["policies"] = ( + d.get("relationships", {}).get("policies", {}).get("data", []) + ) + yield PolicySet.model_validate(attrs) + + return _gen() def create(self, organization: str, options: PolicySetCreateOptions) -> PolicySet: """Create a new policy set in the given organization.""" diff --git a/src/pytfe/resources/policy_set_outcome.py b/src/pytfe/resources/policy_set_outcome.py index 42389d3..d025ec5 100644 --- a/src/pytfe/resources/policy_set_outcome.py +++ b/src/pytfe/resources/policy_set_outcome.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from collections.abc import Iterator diff --git a/src/pytfe/resources/policy_set_parameter.py b/src/pytfe/resources/policy_set_parameter.py index 076579c..0f530b5 100644 --- a/src/pytfe/resources/policy_set_parameter.py +++ b/src/pytfe/resources/policy_set_parameter.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from collections.abc import Iterator diff --git a/src/pytfe/resources/policy_set_version.py b/src/pytfe/resources/policy_set_version.py index b7c4234..c7a82bf 100644 --- a/src/pytfe/resources/policy_set_version.py +++ b/src/pytfe/resources/policy_set_version.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from ..errors import ( diff --git a/src/pytfe/resources/projects.py b/src/pytfe/resources/projects.py index e64cb34..335b131 100644 --- a/src/pytfe/resources/projects.py +++ b/src/pytfe/resources/projects.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations import builtins @@ -5,10 +8,12 @@ from collections.abc import Iterator from typing import Any +from ..models.agent import AgentPool from ..models.common import ( EffectiveTagBinding, TagBinding, ) +from ..models.organization import Organization from ..models.project import ( Project, ProjectAddTagBindingsOptions, @@ -44,38 +49,56 @@ def valid_organization_name(org_name: str) -> bool: def validate_project_create_options( - organization: str, name: str, description: str | None = None + organization: str, options: ProjectCreateOptions ) -> None: """Validate project creation parameters""" if not valid_organization_name(organization): raise ValueError("Organization name is required and must be valid") - if not valid_string(name): + if not valid_string(options.name): raise ValueError("Project name is required") - if not valid_project_name(name): + if not valid_project_name(options.name): raise ValueError("Project name contains invalid characters or is too long") - if description is not None and not valid_string(description): + if options.description is not None and not valid_string(options.description): raise ValueError("Description must be a valid string") + if ( + options.default_execution_mode + and options.default_execution_mode == "agent" + and not options.default_agent_pool_id + ): + raise ValueError( + "Default agent pool is required when default execution mode is set to 'agent'" + ) + def validate_project_update_options( - project_id: str, name: str | None = None, description: str | None = None + project_id: str, options: ProjectUpdateOptions ) -> None: """Validate project update parameters""" if not valid_string_id(project_id): raise ValueError("Project ID is required") - if name is not None: - if not valid_string(name): + if options.name is not None: + if not valid_string(options.name): raise ValueError("Project name cannot be empty") - if not valid_project_name(name): + if not valid_project_name(options.name): raise ValueError("Project name contains invalid characters or is too long") - if description is not None and not valid_string(description): + if options.description is not None and not valid_string(options.description): raise ValueError("Description must be a valid string") + if ( + options.default_execution_mode + and options.default_execution_mode == "agent" + and not options.default_agent_pool_id + ): + raise ValueError( + "Default agent pool is required when default execution mode is set to 'agent'" + ) + def validate_project_list_options( organization: str, query: str | None = None, name: str | None = None @@ -118,8 +141,6 @@ def list( params["q"] = options.query if options.name: params["filter[names]"] = options.name - if options.page_number: - params["page[number]"] = options.page_number if options.page_size: params["page[size]"] = options.page_size @@ -130,51 +151,49 @@ def list( for item in items_iter: # Extract project data - attr = item.get("attributes", {}) or {} - project_data = { - "id": _safe_str(item.get("id")), - "name": _safe_str(attr.get("name")), - "description": _safe_str(attr.get("description")), - "organization": organization, - "created_at": _safe_str(attr.get("created-at")), - "updated_at": _safe_str(attr.get("updated-at")), - "workspace_count": attr.get("workspace-count", 0), - "default_execution_mode": _safe_str( - attr.get("default-execution-mode"), "remote" - ), - } - yield Project(**project_data) + yield self._project_from(item) def create(self, organization: str, options: ProjectCreateOptions) -> Project: """Create a new project in an organization""" # Validate inputs - validate_project_create_options(organization, options.name, options.description) + validate_project_create_options(organization, options) path = f"/api/v2/organizations/{organization}/projects" - attributes = {"name": options.name} - if options.description: - attributes["description"] = options.description - - payload = {"data": {"type": "projects", "attributes": attributes}} + attributes = options.model_dump( + by_alias=True, + exclude_none=True, + exclude={"tag_bindings", "setting_overwrites"}, + ) + if options.setting_overwrites: + attributes["setting-overwrites"] = options.setting_overwrites.model_dump( + by_alias=True, exclude_none=True + ) + if options.tag_bindings: + relationships = {} + data = [ + { + "type": "tag-bindings", + "attributes": tag_binding.model_dump( + by_alias=True, exclude_none=True + ), + } + for tag_binding in options.tag_bindings + ] + relationships["tag-bindings"] = {"data": data} + payload = { + "data": { + "type": "projects", + "attributes": attributes, + "relationships": relationships, + } + } + else: + payload = {"data": {"type": "projects", "attributes": attributes}} response = self.t.request("POST", path, json_body=payload) data = response.json()["data"] - # Extract project data - attr = data.get("attributes", {}) or {} - project_data = { - "id": _safe_str(data.get("id")), - "name": _safe_str(attr.get("name")), - "description": _safe_str(attr.get("description")), - "organization": organization, - "created_at": _safe_str(attr.get("created-at")), - "updated_at": _safe_str(attr.get("updated-at")), - "workspace_count": attr.get("workspace-count", 0), - "default_execution_mode": _safe_str( - attr.get("default-execution-mode"), "remote" - ), - } - return Project(**project_data) + return self._project_from(data) def read( self, project_id: str, include: builtins.list[str] | None = None @@ -196,67 +215,49 @@ def read( data = response.json()["data"] - # Extract organization from relationships - relationships = data.get("relationships", {}) - org_data = relationships.get("organization", {}).get("data", {}) - organization = _safe_str(org_data.get("id")) - - # Extract project data - attr = data.get("attributes", {}) or {} - project_data = { - "id": _safe_str(data.get("id")), - "name": _safe_str(attr.get("name")), - "description": _safe_str(attr.get("description")), - "organization": organization, - "created_at": _safe_str(attr.get("created-at")), - "updated_at": _safe_str(attr.get("updated-at")), - "workspace_count": attr.get("workspace-count", 0), - "default_execution_mode": _safe_str( - attr.get("default-execution-mode"), "remote" - ), - } - return Project(**project_data) + return self._project_from(data) def update(self, project_id: str, options: ProjectUpdateOptions) -> Project: """Update a project's name and/or description""" # Validate inputs - validate_project_update_options(project_id, options.name, options.description) + validate_project_update_options(project_id, options) path = f"/api/v2/projects/{project_id}" - attributes = {} - - if options.name is not None: - attributes["name"] = options.name - if options.description is not None: - attributes["description"] = options.description - - payload = { - "data": {"type": "projects", "id": project_id, "attributes": attributes} - } + attributes = options.model_dump( + by_alias=True, + exclude_none=True, + exclude={"tag_bindings", "setting_overwrites"}, + ) + if options.setting_overwrites: + attributes["setting-overwrites"] = options.setting_overwrites.model_dump( + by_alias=True, exclude_none=True + ) + if options.tag_bindings: + relationships = {} + data = [ + { + "type": "tag-bindings", + "attributes": tag_binding.model_dump( + by_alias=True, exclude_none=True + ), + } + for tag_binding in options.tag_bindings + ] + relationships["tag-bindings"] = {"data": data} + payload = { + "data": { + "type": "projects", + "attributes": attributes, + "relationships": relationships, + } + } + else: + payload = {"data": {"type": "projects", "attributes": attributes}} response = self.t.request("PATCH", path, json_body=payload) data = response.json()["data"] - # Extract organization from relationships - relationships = data.get("relationships", {}) - org_data = relationships.get("organization", {}).get("data", {}) - organization = _safe_str(org_data.get("id")) - - # Extract project data - attr = data.get("attributes", {}) or {} - project_data = { - "id": _safe_str(data.get("id")), - "name": _safe_str(attr.get("name")), - "description": _safe_str(attr.get("description")), - "organization": organization, - "created_at": _safe_str(attr.get("created-at")), - "updated_at": _safe_str(attr.get("updated-at")), - "workspace_count": attr.get("workspace-count", 0), - "default_execution_mode": _safe_str( - attr.get("default-execution-mode"), "remote" - ), - } - return Project(**project_data) + return self._project_from(data) def delete(self, project_id: str) -> None: """Delete a project""" @@ -297,7 +298,7 @@ def list_effective_tag_bindings( if not valid_string_id(project_id): raise ValueError("Project ID is required and must be valid") - path = f"/api/v2/projects/{project_id}/tag-bindings/effective" + path = f"/api/v2/projects/{project_id}/effective-tag-bindings" response = self.t.request("GET", path) data = response.json()["data"] @@ -374,5 +375,32 @@ def delete_tag_bindings(self, project_id: str) -> None: if not valid_string_id(project_id): raise ValueError("Project ID is required and must be valid") - path = f"/api/v2/projects/{project_id}/tag-bindings" - self.t.request("DELETE", path) + payload = { + "data": { + "type": "projects", + "relationships": {"tag-bindings": {"data": []}}, + } + } + + path = f"/api/v2/projects/{project_id}" + self.t.request("PATCH", path, json_body=payload) + + def _project_from(self, data: dict[str, Any]) -> Project: + """Helper method to create a Project object from API response data""" + attrs = data.get("attributes", {}) + attrs["id"] = data.get("id") + + relationships = data.get("relationships", {}) + org_data = relationships.get("organization", {}).get("data", {}) + organization = _safe_str(org_data.get("id")) if org_data else None + default_agent_pool_data = relationships.get("default-agent-pool", {}).get( + "data", {} + ) + attrs["organization"] = Organization(id=organization) if organization else None + attrs["default_agent_pool"] = ( + AgentPool(id=_safe_str(default_agent_pool_data.get("id"))) + if default_agent_pool_data + else None + ) + + return Project.model_validate(attrs) diff --git a/src/pytfe/resources/query_run.py b/src/pytfe/resources/query_run.py index a552e64..bc7feca 100644 --- a/src/pytfe/resources/query_run.py +++ b/src/pytfe/resources/query_run.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations import io diff --git a/src/pytfe/resources/registry_module.py b/src/pytfe/resources/registry_module.py index b65d4d8..651471f 100644 --- a/src/pytfe/resources/registry_module.py +++ b/src/pytfe/resources/registry_module.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations import io diff --git a/src/pytfe/resources/registry_provider.py b/src/pytfe/resources/registry_provider.py index 2511636..d4ae122 100644 --- a/src/pytfe/resources/registry_provider.py +++ b/src/pytfe/resources/registry_provider.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from collections.abc import Iterator diff --git a/src/pytfe/resources/registry_provider_version.py b/src/pytfe/resources/registry_provider_version.py index f2d4fb3..08735c4 100644 --- a/src/pytfe/resources/registry_provider_version.py +++ b/src/pytfe/resources/registry_provider_version.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from collections.abc import Iterator diff --git a/src/pytfe/resources/reserved_tag_key.py b/src/pytfe/resources/reserved_tag_key.py index 8eed7fa..6000452 100644 --- a/src/pytfe/resources/reserved_tag_key.py +++ b/src/pytfe/resources/reserved_tag_key.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from collections.abc import Iterator diff --git a/src/pytfe/resources/run.py b/src/pytfe/resources/run.py index 49efdbc..d827997 100644 --- a/src/pytfe/resources/run.py +++ b/src/pytfe/resources/run.py @@ -1,5 +1,9 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations +from collections.abc import Iterator from typing import Any from ..errors import ( @@ -9,81 +13,102 @@ RequiredWorkspaceError, TerraformVersionValidForPlanOnlyError, ) +from ..models.apply import Apply +from ..models.comment import Comment +from ..models.configuration_version import ConfigurationVersion +from ..models.cost_estimate import CostEstimate +from ..models.plan import Plan +from ..models.policy_check import PolicyCheck from ..models.run import ( - OrganizationRunList, Run, RunApplyOptions, RunCancelOptions, RunCreateOptions, RunDiscardOptions, RunForceCancelOptions, - RunList, RunListForOrganizationOptions, RunListOptions, RunReadOptions, ) +from ..models.run_event import RunEvent +from ..models.task_stage import TaskStage +from ..models.user import User +from ..models.workspace import Workspace from ..utils import _safe_str, valid_string, valid_string_id from ._base import _Service +def transform_relationships(relationships: dict) -> Any: + """ + Transform relationships dict to map relationship names to their model objects. + Single IDs become model instances, multiple IDs become lists of model instances. + """ + result = {} + + # Map relationship keys to their model constructors + model_map = { + "apply": Apply, + "configuration-version": ConfigurationVersion, + "cost-estimate": CostEstimate, + "created-by": User, + "confirmed-by": User, + "plan": Plan, + "workspace": Workspace, + "policy-checks": PolicyCheck, + "run-events": RunEvent, + "task-stages": TaskStage, + "comments": Comment, + } + + for key, value in relationships.items(): + data = value.get("data") + + if data is None: + continue + + model_class = model_map.get(key) + if not model_class: + # Unknown relationship type, skip it + continue + + if isinstance(data, list): + # Multiple entries - create list of model instances + result[key] = [model_class(id=item["id"]) for item in data if "id" in item] + elif isinstance(data, dict) and "id" in data: + # Single entry - create model instance + result[key] = model_class(id=data["id"]) + + return result + + class Runs(_Service): - def list(self, workspace_id: str, options: RunListOptions | None = None) -> RunList: + def list( + self, workspace_id: str, options: RunListOptions | None = None + ) -> Iterator[Run]: """List all the runs of the given workspace.""" if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() - params = ( - options.model_dump(by_alias=True, exclude_none=True) if options else None - ) - r = self.t.request( - "GET", - f"/api/v2/workspaces/{workspace_id}/runs", - params=params, - ) - jd = r.json() - items = [] - meta = jd.get("meta", {}) - pagination = meta.get("pagination", {}) - for d in jd.get("data", []): - attrs = d.get("attributes", {}) - attrs["id"] = d.get("id") - items.append(Run.model_validate(attrs)) - return RunList( - items=items, - current_page=pagination.get("current-page"), - total_pages=pagination.get("total-pages"), - prev_page=pagination.get("prev-page"), - next_page=pagination.get("next-page"), - total_count=pagination.get("total-count"), - ) + params = options.model_dump(by_alias=True) if options else {} + path = f"/api/v2/workspaces/{workspace_id}/runs" + for item in self._list(path, params=params): + attrs = item.get("attributes", {}) + attrs["id"] = item.get("id") + yield Run.model_validate(attrs) def list_for_organization( self, organization: str, options: RunListForOrganizationOptions | None = None - ) -> OrganizationRunList: + ) -> Iterator[Run]: """List all the runs of the given organization.""" if not valid_string_id(organization): raise InvalidOrgError() - params = ( - options.model_dump(by_alias=True, exclude_none=True) if options else None - ) - r = self.t.request( - "GET", - f"/api/v2/organizations/{organization}/runs", - params=params, - ) - jd = r.json() - items = [] - meta = jd.get("meta", {}) - pagination = meta.get("pagination", {}) - for d in jd.get("data", []): - attrs = d.get("attributes", {}) - attrs["id"] = d.get("id") - items.append(Run.model_validate(attrs)) - return OrganizationRunList( - items=items, - current_page=pagination.get("current-page"), - prev_page=pagination.get("prev-page"), - next_page=pagination.get("next-page"), - ) + path = f"/api/v2/organizations/{organization}/runs" + params = options.model_dump(by_alias=True, exclude_none=True) if options else {} + # meta = jd.get("meta", {}) + # pagination = meta.get("pagination", {}) + for item in self._list(path, params=params): + attrs = item.get("attributes", {}) + attrs["id"] = item.get("id") + yield Run.model_validate(attrs) def create(self, options: RunCreateOptions) -> Run: """Create a new run for the given workspace.""" @@ -125,10 +150,11 @@ def create(self, options: RunCreateOptions) -> Run: ) d = r.json().get("data", {}) attrs = d.get("attributes", {}) - return Run( - id=_safe_str(d.get("id")), - **{k.replace("-", "_"): v for k, v in attrs.items()}, - ) + relationships = transform_relationships(d.get("relationships", {})) + combined = { + k.replace("-", "_"): v for k, v in {**attrs, **relationships}.items() + } + return Run(id=_safe_str(d.get("id")), **combined) def read(self, run_id: str) -> Run: """Read a run by its ID.""" @@ -150,10 +176,11 @@ def read_with_options( ) d = r.json().get("data", {}) attrs = d.get("attributes", {}) - return Run( - id=_safe_str(d.get("id")), - **{k.replace("-", "_"): v for k, v in attrs.items()}, - ) + relationships = transform_relationships(d.get("relationships", {})) + combined = { + k.replace("-", "_"): v for k, v in {**attrs, **relationships}.items() + } + return Run(id=_safe_str(d.get("id")), **combined) def apply(self, run_id: str, options: RunApplyOptions | None = None) -> None: """Apply a run by its ID.""" diff --git a/src/pytfe/resources/run_event.py b/src/pytfe/resources/run_event.py index fb5479f..5c85c87 100644 --- a/src/pytfe/resources/run_event.py +++ b/src/pytfe/resources/run_event.py @@ -1,11 +1,14 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations +from collections.abc import Iterator from typing import Any from ..errors import InvalidRunEventIDError, InvalidRunIDError from ..models.run_event import ( RunEvent, - RunEventList, RunEventListOptions, RunEventReadOptions, ) @@ -16,34 +19,18 @@ class RunEvents(_Service): def list( self, run_id: str, options: RunEventListOptions | None = None - ) -> RunEventList: + ) -> Iterator[RunEvent]: """List all the run events of the given run.""" if not valid_string_id(run_id): raise InvalidRunIDError() params: dict[str, Any] = {} if options and options.include: params["include"] = ",".join(options.include) - r = self.t.request( - "GET", - f"/api/v2/runs/{run_id}/run-events", - params=params, - ) - jd = r.json() - items = [] - meta = jd.get("meta", {}) - pagination = meta.get("pagination", {}) - for d in jd.get("data", []): - attrs = d.get("attributes", {}) - attrs["id"] = d.get("id") - items.append(RunEvent.model_validate(attrs)) - return RunEventList( - items=items, - current_page=pagination.get("current-page"), - total_pages=pagination.get("total-pages"), - prev_page=pagination.get("prev-page"), - next_page=pagination.get("next-page"), - total_count=pagination.get("total-count"), - ) + path = f"/api/v2/runs/{run_id}/run-events" + for item in self._list(path, params=params): + attrs = item.get("attributes", {}) + attrs["id"] = item.get("id") + yield RunEvent.model_validate(attrs) def read(self, run_event_id: str) -> RunEvent: """Read a specific run event by its ID.""" diff --git a/src/pytfe/resources/run_task.py b/src/pytfe/resources/run_task.py index 853eab6..952ca65 100644 --- a/src/pytfe/resources/run_task.py +++ b/src/pytfe/resources/run_task.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from collections.abc import Iterator diff --git a/src/pytfe/resources/run_trigger.py b/src/pytfe/resources/run_trigger.py index 3a16d76..52d820d 100644 --- a/src/pytfe/resources/run_trigger.py +++ b/src/pytfe/resources/run_trigger.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations import builtins @@ -48,11 +51,11 @@ def _run_trigger_from(d: dict[str, Any], org: str | None = None) -> RunTrigger: sourceable_id = sourceable_rel["data"].get("id", "") # Create workspace objects with proper IDs - workspace = Workspace( - id=workspace_id, name=workspace_name_str, organization=org or "" + workspace = Workspace.model_validate( + {"id": workspace_id, "name": workspace_name_str, "organization": org} ) - sourceable = Workspace( - id=sourceable_id, name=sourceable_name_str, organization=org or "" + sourceable = Workspace.model_validate( + {"id": sourceable_id, "name": sourceable_name_str, "organization": org} ) sourceable_choice = SourceableChoice( workspace=sourceable diff --git a/src/pytfe/resources/ssh_keys.py b/src/pytfe/resources/ssh_keys.py index 429d5fe..1cbeef2 100644 --- a/src/pytfe/resources/ssh_keys.py +++ b/src/pytfe/resources/ssh_keys.py @@ -1,5 +1,9 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations +from collections.abc import Iterator from typing import Any from ..errors import ( @@ -9,7 +13,6 @@ from ..models.ssh_key import ( SSHKey, SSHKeyCreateOptions, - SSHKeyList, SSHKeyListOptions, SSHKeyUpdateOptions, ) @@ -22,37 +25,17 @@ class SSHKeys(_Service): def list( self, organization: str, options: SSHKeyListOptions | None = None - ) -> SSHKeyList: + ) -> Iterator[SSHKey]: """List SSH keys for the given organization.""" if not valid_string_id(organization): raise InvalidOrgError() - params = ( - options.model_dump(by_alias=True, exclude_none=True) if options else None - ) - - r = self.t.request( - "GET", - f"/api/v2/organizations/{organization}/ssh-keys", - params=params, - ) - - jd = r.json() - items = [] - meta = jd.get("meta", {}) - pagination = meta.get("pagination", {}) - - for d in jd.get("data", []): - items.append(self._parse_ssh_key(d)) - - return SSHKeyList( - items=items, - current_page=pagination.get("current-page"), - total_pages=pagination.get("total-pages"), - prev_page=pagination.get("prev-page"), - next_page=pagination.get("next-page"), - total_count=pagination.get("total-count"), - ) + params = options.model_dump(by_alias=True, exclude_none=True) if options else {} + path = f"/api/v2/organizations/{organization}/ssh-keys" + for item in self._list(path, params=params): + attrs = item.get("attributes", {}) + attrs["id"] = item.get("id") + yield SSHKey.model_validate(attrs) def create(self, organization: str, options: SSHKeyCreateOptions) -> SSHKey: """Create a new SSH key for the given organization.""" diff --git a/src/pytfe/resources/state_version_outputs.py b/src/pytfe/resources/state_version_outputs.py index 205073c..98786d6 100644 --- a/src/pytfe/resources/state_version_outputs.py +++ b/src/pytfe/resources/state_version_outputs.py @@ -1,10 +1,13 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations +from collections.abc import Iterator from typing import Any from ..models.state_version_output import ( StateVersionOutput, - StateVersionOutputsList, StateVersionOutputsListOptions, ) from ..utils import valid_string_id @@ -42,7 +45,7 @@ def read_current( self, workspace_id: str, options: StateVersionOutputsListOptions | None = None, - ) -> StateVersionOutputsList: + ) -> Iterator[StateVersionOutput]: """ Read outputs for the workspace's current state version. Note: sensitive outputs are returned with null values by the API. @@ -52,32 +55,13 @@ def read_current( params: dict[str, Any] = {} if options: - if options.page_number is not None: - params["page[number]"] = options.page_number if options.page_size is not None: params["page[size]"] = options.page_size + path = f"/api/v2/workspaces/{workspace_id}/current-state-version-outputs" - r = self.t.request( - "GET", - f"/api/v2/workspaces/{workspace_id}/current-state-version-outputs", - params=params, - ) - data = r.json() - - items: list[StateVersionOutput] = [] - for item in data.get("data", []): - attr = item.get("attributes", {}) or {} - items.append( - StateVersionOutput( - id=_safe_str(item.get("id")), - **{k.replace("-", "_"): v for k, v in attr.items()}, - ) + for d in self._list(path, params=params): + attr = d.get("attributes", {}) or {} + yield StateVersionOutput( + id=_safe_str(d.get("id")), + **{k.replace("-", "_"): v for k, v in attr.items()}, ) - - meta = data.get("meta", {}).get("pagination", {}) or {} - return StateVersionOutputsList( - items=items, - current_page=meta.get("current-page"), - total_pages=meta.get("total-pages"), - total_count=meta.get("total-count"), - ) diff --git a/src/pytfe/resources/state_versions.py b/src/pytfe/resources/state_versions.py index 29e1f5c..e98e13b 100644 --- a/src/pytfe/resources/state_versions.py +++ b/src/pytfe/resources/state_versions.py @@ -1,5 +1,9 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations +from collections.abc import Iterator from typing import Any from urllib.parse import urlencode @@ -10,13 +14,11 @@ StateVersion, StateVersionCreateOptions, StateVersionCurrentOptions, - StateVersionList, StateVersionListOptions, StateVersionReadOptions, ) from ..models.state_version_output import ( StateVersionOutput, - StateVersionOutputsList, StateVersionOutputsListOptions, ) from ..utils import looks_like_workspace_id, valid_string_id @@ -69,28 +71,19 @@ def _encode_query(params: dict[str, Any]) -> str: return "" return "?" + urlencode(clean, doseq=True) - def list(self, options: StateVersionListOptions | None = None) -> StateVersionList: + def list( + self, options: StateVersionListOptions | None = None + ) -> Iterator[StateVersion]: """ GET /state-versions Accepts filters for organization and workspace and standard pagination. """ params = options.model_dump(by_alias=True, exclude_none=True) if options else {} path = f"/api/v2/state-versions{self._encode_query(params)}" - r = self.t.request("GET", path) - jd = r.json() - # Expecting JSON:API list. Normalize to models. - items = [] - meta = jd.get("meta", {}) - for d in jd.get("data", []): + for d in self._list(path, params=params): attrs = d.get("attributes", {}) attrs["id"] = d.get("id") - items.append(StateVersion.model_validate(attrs)) - return StateVersionList( - items=items, - current_page=meta.get("pagination", {}).get("current-page"), - total_pages=meta.get("pagination", {}).get("total-pages"), - total_count=meta.get("pagination", {}).get("total-count"), - ) + yield StateVersion.model_validate(attrs) def read(self, state_version_id: str) -> StateVersion: """Read a state version by ID.""" @@ -263,40 +256,24 @@ def list_outputs( self, state_version_id: str, options: StateVersionOutputsListOptions | None = None, - ) -> StateVersionOutputsList: + ) -> Iterator[StateVersionOutput]: """List outputs for a given state version (paged).""" if not valid_string_id(state_version_id): raise ValueError("invalid state version id") params: dict[str, Any] = {} if options: - if options.page_number is not None: - params["page[number]"] = options.page_number if options.page_size is not None: params["page[size]"] = options.page_size - r = self.t.request( - "GET", f"/api/v2/state-versions/{state_version_id}/outputs", params=params - ) - data = r.json() - - items: list[StateVersionOutput] = [] - for item in data.get("data", []): - attr = item.get("attributes", {}) or {} - items.append( - StateVersionOutput( - id=_safe_str(item.get("id")), - **{k.replace("-", "_"): v for k, v in attr.items()}, - ) - ) + path = f"/api/v2/state-versions/{state_version_id}/outputs" - meta = data.get("meta", {}).get("pagination", {}) or {} - return StateVersionOutputsList( - items=items, - current_page=meta.get("current-page"), - total_pages=meta.get("total-pages"), - total_count=meta.get("total-count"), - ) + for d in self._list(path, params=params): + attr = d.get("attributes", {}) or {} + yield StateVersionOutput( + id=_safe_str(d.get("id")), + **{k.replace("-", "_"): v for k, v in attr.items()}, + ) # ---------------------------- # TFE-only backing data actions diff --git a/src/pytfe/resources/variable.py b/src/pytfe/resources/variable.py index 6eff36d..60ee924 100644 --- a/src/pytfe/resources/variable.py +++ b/src/pytfe/resources/variable.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations from collections.abc import Iterator diff --git a/src/pytfe/resources/variable_sets.py b/src/pytfe/resources/variable_sets.py index 4bf4353..49d5c78 100644 --- a/src/pytfe/resources/variable_sets.py +++ b/src/pytfe/resources/variable_sets.py @@ -1,6 +1,10 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Variable Set resource implementation for the Python TFE SDK.""" import builtins +from collections.abc import Iterator from typing import Any from .._http import HTTPTransport @@ -48,7 +52,7 @@ def list( self, organization: str, options: VariableSetListOptions | None = None, - ) -> list[VariableSet]: + ) -> Iterator[VariableSet]: """List all variable sets within an organization. Args: @@ -56,8 +60,7 @@ def list( options: Optional parameters for filtering and pagination Returns: - List of VariableSet objects - + Iterator of VariableSet objects within the organization Raises: ValueError: If organization name is invalid TFEError: If API request fails @@ -69,8 +72,6 @@ def list( params: dict[str, str] = {} if options: - if options.page_number: - params["page[number]"] = str(options.page_number) if options.page_size: params["page[size]"] = str(options.page_size) if options.query: @@ -78,16 +79,14 @@ def list( if options.include: params["include"] = ",".join([opt.value for opt in options.include]) - response = self.t.request("GET", path, params=params) - data = response.json() - - return self._parse_variable_sets_response(data) + for item in self._list(path, params=params): + yield self._parse_variable_set(item) def list_for_workspace( self, workspace_id: str, options: VariableSetListOptions | None = None, - ) -> builtins.list[VariableSet]: + ) -> Iterator[VariableSet]: """List variable sets associated with a workspace. Args: @@ -95,7 +94,7 @@ def list_for_workspace( options: Optional parameters for filtering and pagination Returns: - List of VariableSet objects associated with the workspace + Iterator of VariableSet objects associated with the workspace Raises: ValueError: If workspace_id is invalid @@ -108,8 +107,6 @@ def list_for_workspace( params: dict[str, str] = {} if options: - if options.page_number: - params["page[number]"] = str(options.page_number) if options.page_size: params["page[size]"] = str(options.page_size) if options.query: @@ -117,16 +114,14 @@ def list_for_workspace( if options.include: params["include"] = ",".join([opt.value for opt in options.include]) - response = self.t.request("GET", path, params=params) - data = response.json() - - return self._parse_variable_sets_response(data) + for item in self._list(path, params=params): + yield self._parse_variable_set(item) def list_for_project( self, project_id: str, options: VariableSetListOptions | None = None, - ) -> builtins.list[VariableSet]: + ) -> Iterator[VariableSet]: """List variable sets associated with a project. Args: @@ -134,7 +129,7 @@ def list_for_project( options: Optional parameters for filtering and pagination Returns: - List of VariableSet objects associated with the project + Iterator of VariableSet objects associated with the project Raises: ValueError: If project_id is invalid @@ -147,8 +142,6 @@ def list_for_project( params: dict[str, str] = {} if options: - if options.page_number: - params["page[number]"] = str(options.page_number) if options.page_size: params["page[size]"] = str(options.page_size) if options.query: @@ -156,10 +149,8 @@ def list_for_project( if options.include: params["include"] = ",".join([opt.value for opt in options.include]) - response = self.t.request("GET", path, params=params) - data = response.json() - - return self._parse_variable_sets_response(data) + for item in self._list(path, params=params): + yield self._parse_variable_set(item) def create( self, @@ -628,7 +619,6 @@ def _parse_variable_set(self, data: dict[str, Any]) -> VariableSet: { "id": ws["id"], "name": f"workspace-{ws['id']}", # Placeholder name - "organization": "placeholder-org", # Placeholder organization } ) parsed_data["workspaces"] = workspaces @@ -644,7 +634,6 @@ def _parse_variable_set(self, data: dict[str, Any]) -> VariableSet: { "id": proj["id"], "name": f"project-{proj['id']}", # Placeholder name - "organization": "placeholder-org", # Placeholder organization } ) parsed_data["projects"] = projects @@ -680,7 +669,6 @@ def _parse_variable_set(self, data: dict[str, Any]) -> VariableSet: "project": { "id": parent_data["id"], "name": f"project-{parent_data['id']}", - "organization": "placeholder-org", } } elif parent_data.get("type") == "organizations": @@ -714,7 +702,7 @@ def list( self, variable_set_id: str, options: VariableSetVariableListOptions | None = None, - ) -> list[VariableSetVariable]: + ) -> Iterator[VariableSetVariable]: """List all variables in a variable set. Args: @@ -722,7 +710,7 @@ def list( options: Optional parameters for pagination Returns: - List of VariableSetVariable objects + Iterator of VariableSetVariable objects Raises: ValueError: If variable_set_id is invalid @@ -735,19 +723,11 @@ def list( params: dict[str, str] = {} if options: - if options.page_number: - params["page[number]"] = str(options.page_number) if options.page_size: params["page[size]"] = str(options.page_size) - response = self.t.request("GET", path, params=params) - data = response.json() - - variables = [] - for item in data.get("data", []): - variables.append(self._parse_variable_set_variable(item)) - - return variables + for item in self._list(path, params=params): + yield self._parse_variable_set_variable(item) def create( self, diff --git a/src/pytfe/resources/workspace_resources.py b/src/pytfe/resources/workspace_resources.py index 617b5f7..6f5c1f9 100644 --- a/src/pytfe/resources/workspace_resources.py +++ b/src/pytfe/resources/workspace_resources.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Workspace resources service for Terraform Enterprise.""" from collections.abc import Iterator diff --git a/src/pytfe/resources/workspaces.py b/src/pytfe/resources/workspaces.py index 81b49f5..2bd2d9b 100644 --- a/src/pytfe/resources/workspaces.py +++ b/src/pytfe/resources/workspaces.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations import builtins @@ -29,6 +32,8 @@ DataRetentionPolicyDontDelete, DataRetentionPolicySetOptions, ) +from ..models.organization import Organization +from ..models.project import Project from ..models.workspace import ( ExecutionMode, LockedByChoice, @@ -48,19 +53,14 @@ WorkspaceReadOptions, WorkspaceRemoveRemoteStateConsumersOptions, WorkspaceRemoveTagsOptions, - WorkspaceRemoveVCSConnectionOptions, WorkspaceSettingOverwrites, - WorkspaceSource, WorkspaceTagListOptions, WorkspaceUpdateOptions, WorkspaceUpdateRemoteStateConsumersOptions, ) from ..utils import ( - _safe_str, valid_string, valid_string_id, - validate_workspace_create_options, - validate_workspace_update_options, ) from ._base import _Service @@ -73,190 +73,120 @@ def _em_safe(v: Any) -> ExecutionMode | None: return result if isinstance(result, ExecutionMode) else None -def _ws_from(d: dict[str, Any], org: str | None = None) -> Workspace: +def _ws_from(d: dict[str, Any]) -> Workspace: attr: dict[str, Any] = d.get("attributes", {}) or {} - - # Coerce to required string fields (empty string fallback keeps mypy happy) - id_str: str = _safe_str(d.get("id")) - name_str: str = _safe_str(attr.get("name")) - org_str: str = _safe_str(org if org is not None else attr.get("organization")) + relationships: dict[str, Any] = d.get("relationships", {}) or {} # Optional fields em: ExecutionMode | None = _em_safe(attr.get("execution-mode")) - proj_id: str | None = None - proj = attr.get("project") - if isinstance(proj, dict): - proj_id = proj.get("id") if isinstance(proj.get("id"), str) else None - - # Enhanced field mapping - tags_val = attr.get("tags", []) or [] - tags_list: builtins.list[Tag] = [] - if isinstance(tags_val, builtins.list): - for tag_item in tags_val: - if isinstance(tag_item, dict): - tags_list.append( - Tag(id=tag_item.get("id"), name=tag_item.get("name", "")) - ) - elif isinstance(tag_item, str): - tags_list.append(Tag(name=tag_item)) - - # Map additional attributes actions = None if attr.get("actions"): - actions = WorkspaceActions( - is_destroyable=attr["actions"].get("is-destroyable", False) - ) + actions = WorkspaceActions.model_validate(attr["actions"]) permissions = None if attr.get("permissions"): - perm_attr = attr["permissions"] - permissions = WorkspacePermissions( - can_destroy=perm_attr.get("can-destroy", False), - can_force_unlock=perm_attr.get("can-force-unlock", False), - can_lock=perm_attr.get("can-lock", False), - can_manage_run_tasks=perm_attr.get("can-manage-run-tasks", False), - can_queue_apply=perm_attr.get("can-queue-apply", False), - can_queue_destroy=perm_attr.get("can-queue-destroy", False), - can_queue_run=perm_attr.get("can-queue-run", False), - can_read_settings=perm_attr.get("can-read-settings", False), - can_unlock=perm_attr.get("can-unlock", False), - can_update=perm_attr.get("can-update", False), - can_update_variable=perm_attr.get("can-update-variable", False), - can_force_delete=perm_attr.get("can-force-delete"), - ) + permissions = WorkspacePermissions.model_validate(attr["permissions"]) setting_overwrites = None if attr.get("setting-overwrites"): - so_attr = attr["setting-overwrites"] - setting_overwrites = WorkspaceSettingOverwrites( - execution_mode=so_attr.get("execution-mode"), - agent_pool=so_attr.get("agent-pool"), + setting_overwrites = WorkspaceSettingOverwrites.model_validate( + attr["setting-overwrites"] ) # Map VCS repo vcs_repo = None if attr.get("vcs-repo"): - vcs_attr = attr["vcs-repo"] - vcs_repo = VCSRepo( - branch=vcs_attr.get("branch"), - identifier=vcs_attr.get("identifier"), - ingress_submodules=vcs_attr.get("ingress-submodules"), - oauth_token_id=vcs_attr.get("oauth-token-id"), - gha_installation_id=vcs_attr.get("github-app-installation-id"), - ) + vcs_repo = VCSRepo.model_validate(attr["vcs-repo"]) # Map locked_by choice locked_by = None - if d.get("relationships", {}).get("locked-by"): - lb_data = d["relationships"]["locked-by"]["data"] + if relationships.get("locked-by", {}).get("data"): + lb_data = relationships["locked-by"]["data"] if lb_data: - locked_by = LockedByChoice( - run=lb_data.get("run"), - user=lb_data.get("user"), - team=lb_data.get("team"), - ) + if lb_data.get("type") == "runs": + locked_by = LockedByChoice.model_validate({"run": lb_data.get("id")}) + elif lb_data.get("type") == "users": + locked_by = LockedByChoice.model_validate({"user": lb_data.get("id")}) + elif lb_data.get("type") == "teams": + locked_by = LockedByChoice.model_validate({"team": lb_data.get("id")}) # Map outputs outputs = [] - if d.get("relationships", {}).get("outputs"): - for output_data in d["relationships"]["outputs"].get("data", []): - outputs.append( - WorkspaceOutputs( - id=output_data.get("id", ""), - name=output_data.get("attributes", {}).get("name", ""), - sensitive=output_data.get("attributes", {}).get("sensitive", False), - output_type=output_data.get("attributes", {}).get( - "output-type", "" - ), - value=output_data.get("attributes", {}).get("value"), - ) - ) + if relationships.get("outputs", {}).get("data"): + for output_data in relationships["outputs"].get("data", []): + output_attrs = output_data.get("attributes", {}) + output_attrs["id"] = output_data.get("id", "") + outputs.append(WorkspaceOutputs.model_validate(output_attrs)) data_retention_policy_choice: DataRetentionPolicyChoice | None = None - if d.get("relationships", {}).get("data-retention-policy-choice"): - drp_data = d["relationships"]["data-retention-policy-choice"]["data"] + if relationships.get("data-retention-policy-choice", {}).get("data"): + drp_data = relationships["data-retention-policy-choice"]["data"] if drp_data: if drp_data.get("type") == "data-retention-policy-delete-olders": - data_retention_policy_choice = DataRetentionPolicyChoice( - data_retention_policy_delete_older=DataRetentionPolicyDeleteOlder( - id=drp_data.get("id"), - delete_older_than_n_days=drp_data.get("attributes", {}).get( - "delete-older-than-n-days", 0 - ), + data_retention_policy_delete_older = ( + DataRetentionPolicyDeleteOlder.model_validate( + { + "id": drp_data.get("id"), + "delete_older_than_n_days": drp_data.get( + "attributes", {} + ).get("delete-older-than-n-days", 0), + } ) ) + data_retention_policy_choice = DataRetentionPolicyChoice.model_validate( + { + "data_retention_policy_delete_older": data_retention_policy_delete_older + } + ) elif drp_data.get("type") == "data-retention-policy-dont-deletes": - data_retention_policy_choice = DataRetentionPolicyChoice( - data_retention_policy_dont_delete=DataRetentionPolicyDontDelete( - id=drp_data.get("id") + data_retention_policy_dont_delete = ( + DataRetentionPolicyDontDelete.model_validate( + {"id": drp_data.get("id")} ) ) + data_retention_policy_choice = DataRetentionPolicyChoice.model_validate( + { + "data_retention_policy_dont_delete": data_retention_policy_dont_delete + } + ) elif drp_data.get("type") == "data-retention-policies": # Legacy data retention policy - data_retention_policy_choice = DataRetentionPolicyChoice( - data_retention_policy=DataRetentionPolicy( - id=drp_data.get("id"), - delete_older_than_n_days=drp_data.get("attributes", {}).get( + data_retention_policy = DataRetentionPolicy.model_validate( + { + "id": drp_data.get("id"), + "delete_older_than_n_days": drp_data.get("attributes", {}).get( "delete-older-than-n-days", 0 ), - ) + } + ) + data_retention_policy_choice = DataRetentionPolicyChoice.model_validate( + {"data_retention_policy": data_retention_policy} ) - return Workspace( - id=id_str, - name=name_str, - organization=org_str, - execution_mode=em, - project_id=proj_id, - tags=tags_list, - # Core attributes - actions=actions, - allow_destroy_plan=attr.get("allow-destroy-plan", False), - assessments_enabled=attr.get("assessments-enabled", False), - auto_apply=attr.get("auto-apply", False), - auto_apply_run_trigger=attr.get("auto-apply-run-trigger", False), - auto_destroy_at=attr.get("auto-destroy-at"), - auto_destroy_activity_duration=attr.get("auto-destroy-activity-duration"), - can_queue_destroy_plan=attr.get("can-queue-destroy-plan", False), - created_at=attr.get("created-at"), - description=attr.get("description") or "", - environment=attr.get("environment", ""), - file_triggers_enabled=attr.get("file-triggers-enabled", False), - global_remote_state=attr.get("global-remote-state", False), - inherits_project_auto_destroy=attr.get("inherits-project-auto-destroy", False), - locked=attr.get("locked", False), - migration_environment=attr.get("migration-environment", ""), - no_code_upgrade_available=attr.get("no-code-upgrade-available", False), - operations=attr.get("operations", False), - permissions=permissions, - queue_all_runs=attr.get("queue-all-runs", False), - speculative_enabled=attr.get("speculative-enabled", False), - source=WorkspaceSource(attr.get("source")) if attr.get("source") else None, - source_name=attr.get("source-name") or "", - source_url=attr.get("source-url") or "", - structured_run_output_enabled=attr.get("structured-run-output-enabled", False), - terraform_version=attr.get("terraform-version") or "", - trigger_prefixes=attr.get("trigger-prefixes", []), - trigger_patterns=attr.get("trigger-patterns", []), - vcs_repo=vcs_repo, - working_directory=attr.get("working-directory") or "", - updated_at=attr.get("updated-at"), - resource_count=attr.get("resource-count", 0), - apply_duration_average=attr.get("apply-duration-average"), - plan_duration_average=attr.get("plan-duration-average"), - policy_check_failures=attr.get("policy-check-failures") or 0, - run_failures=attr.get("run-failures") or 0, - runs_count=attr.get("workspace-kpis-runs-count") or 0, - tag_names=attr.get("tag-names", []), - setting_overwrites=setting_overwrites, - # Relations - outputs=outputs, - locked_by=locked_by, - data_retention_policy_choice=data_retention_policy_choice - if data_retention_policy_choice - else None, - ) + attr["id"] = d.get("id") + attr["execution_mode"] = em + attr["actions"] = actions + attr["permissions"] = permissions + attr["setting_overwrites"] = setting_overwrites + attr["vcs-repo"] = vcs_repo + + # Add parsed relations + if relationships.get("organization", {}).get("data"): + attr["organization"] = Organization.model_validate( + {"id": relationships["organization"]["data"].get("id")} + ) + if relationships.get("project", {}).get("data"): + attr["project"] = Project.model_validate( + {"id": relationships["project"]["data"].get("id")} + ) + if relationships.get("ssh-key", {}).get("data"): + attr["ssh_key"] = relationships["ssh-key"]["data"].get("id") + attr["outputs"] = outputs + attr["locked_by"] = locked_by + attr["data_retention_policy_choice"] = data_retention_policy_choice + + return Workspace.model_validate(attr) class Workspaces(_Service): @@ -265,47 +195,32 @@ def list( organization: str, options: WorkspaceListOptions | None = None, ) -> Iterator[Workspace]: - # Validate parameters if not valid_string_id(organization): raise InvalidOrgError() - params: dict[str, Any] = {} + params = ( + options.model_dump( + by_alias=True, exclude_none=True, exclude={"tag_bindings"} + ) + if options + else {} + ) if options is not None: - # Use structured options - if options.search: - params["search[name]"] = options.search - if options.tags: - params["search[tags]"] = options.tags - if options.exclude_tags: - params["search[exclude-tags]"] = options.exclude_tags - if options.wildcard_name: - params["search[wildcard-name]"] = options.wildcard_name - if options.project_id: - params["filter[project][id]"] = options.project_id - if options.current_run_status: - params["filter[current-run][status]"] = options.current_run_status if options.include: params["include"] = ",".join([i.value for i in options.include]) - if options.sort: - params["sort"] = options.sort - if options.page_number: - params["page[number]"] = options.page_number - if options.page_size: - params["page[size]"] = options.page_size - - # Handle tag binding filters + if options.tag_bindings: for i, binding in enumerate(options.tag_bindings): if binding.key and binding.value: - params[f"search[tag-bindings][{i}][key]"] = binding.key - params[f"search[tag-bindings][{i}][value]"] = binding.value + params[f"filter[tagged][{i}][key]"] = binding.key + params[f"filter[tagged][{i}][value]"] = binding.value elif binding.key: - params[f"search[tag-bindings][{i}][key]"] = binding.key + params[f"filter[tagged][{i}][key]"] = binding.key path = f"/api/v2/organizations/{organization}/workspaces" for item in self._list(path, params=params): - yield _ws_from(item, organization) + yield _ws_from(item) def read(self, workspace: str, *, organization: str) -> Workspace: """Read workspace by organization and name.""" @@ -318,7 +233,6 @@ def read_with_options( *, organization: str, ) -> Workspace: - # Validate parameters if not valid_string_id(organization): raise InvalidOrgError() if not valid_string_id(workspace): @@ -333,7 +247,7 @@ def read_with_options( f"/api/v2/organizations/{organization}/workspaces/{workspace}", params=params, ) - ws = _ws_from(r.json()["data"], organization) + ws = _ws_from(r.json()["data"]) ws.data_retention_policy = ( ws.data_retention_policy_choice.convert_to_legacy_struct() if ws.data_retention_policy_choice @@ -348,7 +262,6 @@ def read_by_id(self, workspace_id: str) -> Workspace: def read_by_id_with_options( self, workspace_id: str, options: WorkspaceReadOptions | None = None ) -> Workspace: - # Validate parameters if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() @@ -357,7 +270,7 @@ def read_by_id_with_options( if options.include: params["include"] = ",".join([i.value for i in options.include]) r = self.t.request("GET", f"/api/v2/workspaces/{workspace_id}", params=params) - ws = _ws_from(r.json()["data"], None) + ws = _ws_from(r.json()["data"]) if ws.data_retention_policy_choice is not None: ws.data_retention_policy = ( ws.data_retention_policy_choice.convert_to_legacy_struct() @@ -370,184 +283,94 @@ def create( options: WorkspaceCreateOptions, ) -> Workspace: """Create a new workspace in the given organization.""" - # Validate parameters if not valid_string_id(organization): raise InvalidOrgError() - # Validate options before creating workspace - validate_workspace_create_options(options) - - body = self._build_workspace_payload(options, is_create=True) + body = self._build_workspace_payload(options) r = self.t.request( "POST", f"/api/v2/organizations/{organization}/workspaces", json_body=body ) - return _ws_from(r.json()["data"], organization) + return _ws_from(r.json()["data"]) - # Convenience methods for org+name operations def update( self, workspace: str, options: WorkspaceUpdateOptions, *, organization: str ) -> Workspace: """Update workspace by organization and name.""" - # Validate parameters if not valid_string_id(organization): raise InvalidOrgError() if not valid_string_id(workspace): raise InvalidWorkspaceValueError() - # Validate options before updating workspace - validate_workspace_update_options(options) - - body = self._build_workspace_payload(options, is_create=False) + body = self._build_workspace_payload(options) r = self.t.request( "PATCH", f"/api/v2/organizations/{organization}/workspaces/{workspace}", json_body=body, ) - return _ws_from(r.json()["data"], organization) + return _ws_from(r.json()["data"]) def update_by_id( self, workspace_id: str, options: WorkspaceUpdateOptions ) -> Workspace: """Update workspace by workspace ID.""" - # Validate parameters if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() - # Validate options before updating workspace - validate_workspace_update_options(options) - - body = self._build_workspace_payload(options, is_create=False) + body = self._build_workspace_payload(options) r = self.t.request( "PATCH", f"/api/v2/workspaces/{workspace_id}", json_body=body ) - return _ws_from(r.json()["data"], None) + return _ws_from(r.json()["data"]) def _build_workspace_payload( - self, - options: WorkspaceCreateOptions | WorkspaceUpdateOptions, - is_create: bool = False, + self, options: WorkspaceCreateOptions | WorkspaceUpdateOptions ) -> dict[str, Any]: """Build the workspace payload from options following API specification. Args: options: Either WorkspaceCreateOptions or WorkspaceUpdateOptions - is_create: True for create operations, False for update operations """ - body: dict[str, Any] = {"data": {"type": "workspaces", "attributes": {}}} - - # Add attributes from options - attrs = body["data"]["attributes"] - - # Required field for both create and update: name - attrs["name"] = options.name - - # Common optional attributes - if options.agent_pool_id is not None: - attrs["agent-pool-id"] = options.agent_pool_id - if options.allow_destroy_plan is not None: - attrs["allow-destroy-plan"] = options.allow_destroy_plan - if options.assessments_enabled is not None: - attrs["assessments-enabled"] = options.assessments_enabled - if options.auto_apply is not None: - attrs["auto-apply"] = options.auto_apply - if options.auto_apply_run_trigger is not None: - attrs["auto-apply-run-trigger"] = options.auto_apply_run_trigger - if options.auto_destroy_at is not None: - # Format datetime as ISO8601 string as expected by the API - attrs["auto-destroy-at"] = options.auto_destroy_at.isoformat() - if options.auto_destroy_activity_duration is not None: - attrs["auto-destroy-activity-duration"] = ( - options.auto_destroy_activity_duration - ) - if options.description is not None: - attrs["description"] = options.description - if options.execution_mode is not None: - # Accepts either an enum (with .value) or a string; fallback to the value itself if neither - attrs["execution-mode"] = getattr( - options.execution_mode, "value", options.execution_mode - ) - if options.file_triggers_enabled is not None: - attrs["file-triggers-enabled"] = options.file_triggers_enabled - if options.global_remote_state is not None: - attrs["global-remote-state"] = options.global_remote_state - if options.queue_all_runs is not None: - attrs["queue-all-runs"] = options.queue_all_runs - if options.speculative_enabled is not None: - attrs["speculative-enabled"] = options.speculative_enabled - if options.terraform_version is not None: - attrs["terraform-version"] = options.terraform_version - if options.trigger_patterns: - attrs["trigger-patterns"] = options.trigger_patterns - if options.trigger_prefixes: - attrs["trigger-prefixes"] = options.trigger_prefixes - if options.working_directory is not None: - attrs["working-directory"] = options.working_directory - if options.allow_destroy_plan is not None: - attrs["allow-destroy-plan"] = options.allow_destroy_plan - if options.assessments_enabled is not None: - attrs["assessments-enabled"] = options.assessments_enabled - - # Create-specific attributes - if ( - is_create - and hasattr(options, "source_name") - and options.source_name is not None - ): - attrs["source-name"] = options.source_name - if ( - is_create - and hasattr(options, "source_url") - and options.source_url is not None - ): - attrs["source-url"] = options.source_url - if ( - is_create - and hasattr(options, "structured_run_output_enabled") - and options.structured_run_output_enabled is not None - ): - attrs["structured-run-output-enabled"] = ( - options.structured_run_output_enabled + attrs = ( + ( + options.model_dump( + by_alias=True, + exclude_none=True, + exclude={ + "vcs_repo", + "setting_overwrites", + "project", + "tag_bindings", + }, + ) ) - if ( - is_create - and hasattr(options, "hyok_enabled") - and options.hyok_enabled is not None - ): - attrs["hyok-enabled"] = options.hyok_enabled + if options + else {} + ) # VCS repository configuration - if hasattr(options, "vcs_repo") and options.vcs_repo is not None: - vcs_data: dict[str, Any] = {} - if options.vcs_repo.oauth_token_id is not None: - vcs_data["oauth-token-id"] = options.vcs_repo.oauth_token_id - if options.vcs_repo.identifier is not None: - vcs_data["identifier"] = options.vcs_repo.identifier - if options.vcs_repo.branch is not None: - vcs_data["branch"] = options.vcs_repo.branch - if options.vcs_repo.ingress_submodules is not None: - vcs_data["ingress-submodules"] = options.vcs_repo.ingress_submodules - if options.vcs_repo.tags_regex is not None: - vcs_data["tags-regex"] = options.vcs_repo.tags_regex - if options.vcs_repo.gha_installation_id is not None: - vcs_data["github-app-installation-id"] = ( - options.vcs_repo.gha_installation_id - ) + if hasattr(options, "vcs_repo"): + vcs_data = ( + (options.vcs_repo.model_dump(by_alias=True, exclude_none=True)) + if options.vcs_repo + else {} + ) attrs["vcs-repo"] = vcs_data # Setting overwrites - if ( - hasattr(options, "setting_overwrites") - and options.setting_overwrites is not None - ): - setting_overwrites: dict[str, Any] = {} - if options.setting_overwrites.execution_mode is not None: - setting_overwrites["execution-mode"] = ( - options.setting_overwrites.execution_mode + if hasattr(options, "setting_overwrites"): + setting_overwrites = ( + ( + options.setting_overwrites.model_dump( + by_alias=True, exclude_none=True + ) ) - if options.setting_overwrites.agent_pool is not None: - setting_overwrites["agent-pool"] = options.setting_overwrites.agent_pool + if options.setting_overwrites + else {} + ) attrs["setting-overwrites"] = setting_overwrites + body = {"data": {"type": "workspaces", "attributes": attrs}} + # Add relationships relationships: dict[str, Any] = {} @@ -576,7 +399,6 @@ def _build_workspace_payload( def delete(self, workspace: str, *, organization: str) -> None: """Delete workspace by organization and workspace name.""" - # Validate parameters for proper API usage if not valid_string_id(organization): raise InvalidOrgError() if not valid_string_id(workspace): @@ -585,18 +407,18 @@ def delete(self, workspace: str, *, organization: str) -> None: self.t.request( "DELETE", f"/api/v2/organizations/{organization}/workspaces/{workspace}" ) + return None def delete_by_id(self, workspace_id: str) -> None: """Delete workspace by workspace ID.""" - # Validate parameters for proper API usage if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() self.t.request("DELETE", f"/api/v2/workspaces/{workspace_id}") + return None def safe_delete(self, workspace: str, *, organization: str) -> None: """Safely delete workspace by organization and name.""" - # Validate parameters for proper API usage if not valid_string_id(organization): raise InvalidOrgError() if not valid_string_id(workspace): @@ -606,14 +428,15 @@ def safe_delete(self, workspace: str, *, organization: str) -> None: "POST", f"/api/v2/organizations/{organization}/workspaces/{workspace}/actions/safe-delete", ) + return None def safe_delete_by_id(self, workspace_id: str) -> None: """Safely delete workspace by workspace ID.""" - # Validate parameters for proper API usage if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() self.t.request("POST", f"/api/v2/workspaces/{workspace_id}/actions/safe-delete") + return None def remove_vcs_connection( self, @@ -622,19 +445,15 @@ def remove_vcs_connection( organization: str | None = None, ) -> Workspace: """Remove VCS connection from workspace by organization and name.""" - # Validate parameters if not valid_string_id(organization): raise InvalidOrgError() if not valid_string_id(workspace): raise InvalidWorkspaceValueError() - # Create empty options with vcs_repo=None to remove VCS connection - options = WorkspaceRemoveVCSConnectionOptions(id="", vcs_repo=None) - body = { "data": { "type": "workspaces", - "attributes": {"vcs-repo": options.vcs_repo}, + "attributes": {"vcs-repo": None}, } } @@ -643,21 +462,17 @@ def remove_vcs_connection( f"/api/v2/organizations/{organization}/workspaces/{workspace}", json_body=body, ) - return _ws_from(r.json()["data"], organization) + 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.""" - # Validate parameters if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() - # Create empty options with vcs_repo=None to remove VCS connection - options = WorkspaceRemoveVCSConnectionOptions(id="", vcs_repo=None) - body = { "data": { "type": "workspaces", - "attributes": {"vcs-repo": options.vcs_repo}, + "attributes": {"vcs-repo": None}, } } @@ -666,11 +481,10 @@ def remove_vcs_connection_by_id(self, workspace_id: str) -> Workspace: f"/api/v2/workspaces/{workspace_id}", json_body=body, ) - return _ws_from(r.json()["data"], None) + return _ws_from(r.json()["data"]) def lock(self, workspace_id: str, options: WorkspaceLockOptions) -> Workspace: """Lock a workspace by workspace ID.""" - # Validate parameters if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() @@ -681,11 +495,10 @@ def lock(self, workspace_id: str, options: WorkspaceLockOptions) -> Workspace: f"/api/v2/workspaces/{workspace_id}/actions/lock", json_body=body, ) - return _ws_from(r.json()["data"], None) + return _ws_from(r.json()["data"]) def unlock(self, workspace_id: str) -> Workspace: """Unlock a workspace by workspace ID.""" - # Validate parameters if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() try: @@ -693,7 +506,7 @@ def unlock(self, workspace_id: str) -> Workspace: "POST", f"/api/v2/workspaces/{workspace_id}/actions/unlock", ) - return _ws_from(r.json()["data"], None) + return _ws_from(r.json()["data"]) except Exception as e: if "latest state version is still pending" in str(e): raise WorkspaceLockedStateVersionStillPending(str(e)) from e @@ -701,7 +514,6 @@ def unlock(self, workspace_id: str) -> Workspace: def force_unlock(self, workspace_id: str) -> Workspace: """Force unlock a workspace by workspace ID.""" - # Validate parameters if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() @@ -709,13 +521,12 @@ def force_unlock(self, workspace_id: str) -> Workspace: "POST", f"/api/v2/workspaces/{workspace_id}/actions/force-unlock", ) - return _ws_from(r.json()["data"], None) + return _ws_from(r.json()["data"]) def assign_ssh_key( self, workspace_id: str, options: WorkspaceAssignSSHKeyOptions ) -> Workspace: """Assign an SSH key to a workspace by workspace ID.""" - # Validate parameters if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() @@ -737,11 +548,10 @@ def assign_ssh_key( f"/api/v2/workspaces/{workspace_id}/relationships/ssh-key", json_body=body, ) - return _ws_from(r.json()["data"], None) + 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.""" - # Validate parameters if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() @@ -758,27 +568,22 @@ def unassign_ssh_key(self, workspace_id: str) -> Workspace: json_body=body, ) - return _ws_from(r.json()["data"], None) + return _ws_from(r.json()["data"]) def list_remote_state_consumers( - self, workspace_id: str, options: WorkspaceListRemoteStateConsumersOptions + self, + workspace_id: str, + options: WorkspaceListRemoteStateConsumersOptions | None = None, ) -> Iterator[Workspace]: """List remote state consumers of a workspace by workspace ID.""" - # Validate parameters if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() - params: dict[str, Any] = {} - if options is not None: - # Use structured options - if options.page_number: - params["page[number]"] = options.page_number - if options.page_size: - params["page[size]"] = options.page_size + params = options.model_dump(by_alias=True, exclude_none=True) if options else {} path = f"/api/v2/workspaces/{workspace_id}/relationships/remote-state-consumers" for item in self._list(path, params=params): - yield _ws_from(item, None) + yield _ws_from(item) def add_remote_state_consumers( self, workspace_id: str, options: WorkspaceAddRemoteStateConsumersOptions @@ -799,6 +604,7 @@ def add_remote_state_consumers( f"/api/v2/workspaces/{workspace_id}/relationships/remote-state-consumers", json_body=body, ) + return None def remove_remote_state_consumers( self, workspace_id: str, options: WorkspaceRemoveRemoteStateConsumersOptions @@ -818,6 +624,7 @@ def remove_remote_state_consumers( f"/api/v2/workspaces/{workspace_id}/relationships/remote-state-consumers", json_body=body, ) + return None def update_remote_state_consumers( self, workspace_id: str, options: WorkspaceUpdateRemoteStateConsumersOptions @@ -837,6 +644,7 @@ def update_remote_state_consumers( f"/api/v2/workspaces/{workspace_id}/relationships/remote-state-consumers", json_body=body, ) + return None def list_tags( self, workspace_id: str, options: WorkspaceTagListOptions | None = None @@ -844,14 +652,7 @@ def list_tags( if not valid_string_id(workspace_id): raise InvalidWorkspaceIDError() - params: dict[str, Any] = {} - if options is not None: - if options.query is not None: - params["name"] = options.query - if options.page_number is not None: - params["page[number]"] = options.page_number - if options.page_size is not None: - params["page[size]"] = options.page_size + params = options.model_dump(by_alias=True, exclude_none=True) if options else {} path = f"/api/v2/workspaces/{workspace_id}/relationships/tags" for item in self._list(path, params=params): @@ -879,6 +680,7 @@ def add_tags(self, workspace_id: str, options: WorkspaceAddTagsOptions) -> None: f"/api/v2/workspaces/{workspace_id}/relationships/tags", json_body=body, ) + return None def remove_tags( self, workspace_id: str, options: WorkspaceRemoveTagsOptions @@ -903,6 +705,7 @@ def remove_tags( f"/api/v2/workspaces/{workspace_id}/relationships/tags", json_body=body, ) + return None def list_tag_bindings(self, workspace_id: str) -> Iterator[TagBinding]: if not valid_string_id(workspace_id): @@ -980,6 +783,7 @@ def delete_all_tag_bindings(self, workspace_id: str) -> None: } } self.t.request("PATCH", f"/api/v2/workspaces/{workspace_id}", json_body=body) + return None def read_data_retention_policy( self, workspace_id: str @@ -1149,6 +953,7 @@ def delete_data_retention_policy(self, workspace_id: str) -> None: raise InvalidWorkspaceIDError() self.t.request("DELETE", self._data_retention_policy_link(workspace_id)) + return None def readme(self, workspace_id: str) -> str | None: """Get the README content of a workspace by its ID.""" diff --git a/src/pytfe/utils.py b/src/pytfe/utils.py index 02c43cc..4040c34 100644 --- a/src/pytfe/utils.py +++ b/src/pytfe/utils.py @@ -1,7 +1,12 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from __future__ import annotations import io +import os import re +import tarfile import time from collections.abc import Callable, Mapping from typing import TYPE_CHECKING, Any @@ -12,31 +17,10 @@ OAuthClientCreateOptions, OAuthClientRemoveProjectsOptions, ) + from .models.workspace import VCSRepoOptions from urllib.parse import urlparse -try: - import slug # type: ignore[import-not-found] -except ImportError: - slug = None - -from .errors import ( - InvalidNameError, - RequiredAgentModeError, - RequiredAgentPoolIDError, - RequiredNameError, - UnsupportedBothTagsRegexAndFileTriggersEnabledError, - UnsupportedBothTagsRegexAndTriggerPatternsError, - UnsupportedBothTagsRegexAndTriggerPrefixesError, - UnsupportedBothTriggerPatternsAndPrefixesError, - UnsupportedOperationsError, -) -from .models.workspace import ( - VCSRepo, - WorkspaceCreateOptions, - WorkspaceUpdateOptions, -) - _STRING_ID_PATTERN = re.compile(r"^[^/\s]+$") _WS_ID_RE = re.compile(r"^ws-[A-Za-z0-9]+$") _VERSION_PATTERN = re.compile( @@ -126,94 +110,11 @@ def is_valid_workspace_name(name: str | None) -> bool: return True -def has_tags_regex_defined(vcs_repo: VCSRepo | None) -> bool: +def has_tags_regex_defined(vcs_repo: VCSRepoOptions | None) -> bool: """Check if VCS repo has tags regex defined.""" return vcs_repo is not None and valid_string(vcs_repo.tags_regex) -def validate_workspace_create_options(options: WorkspaceCreateOptions) -> None: - """ - Validate workspace create options for proper API usage. - Raises specific validation errors if validation fails. - """ - # Check required name - if not valid_string(options.name): - raise RequiredNameError() - - # Check name format - if not is_valid_workspace_name(options.name): - raise InvalidNameError() - - # Check operations and execution mode conflict - if options.operations is not None and options.execution_mode is not None: - raise UnsupportedOperationsError() - - # Check agent mode requirements - if options.agent_pool_id is not None and ( - options.execution_mode is None or options.execution_mode != "agent" - ): - raise RequiredAgentModeError() - - if ( - options.agent_pool_id is None - and options.execution_mode is not None - and options.execution_mode == "agent" - ): - raise RequiredAgentPoolIDError() - - # Check trigger patterns and prefixes conflict - if len(options.trigger_prefixes) > 0 and len(options.trigger_patterns) > 0: - raise UnsupportedBothTriggerPatternsAndPrefixesError() - - # Check tags regex conflicts - if has_tags_regex_defined(options.vcs_repo): - if len(options.trigger_patterns) > 0: - raise UnsupportedBothTagsRegexAndTriggerPatternsError() - - if len(options.trigger_prefixes) > 0: - raise UnsupportedBothTagsRegexAndTriggerPrefixesError() - - if options.file_triggers_enabled is not None and options.file_triggers_enabled: - raise UnsupportedBothTagsRegexAndFileTriggersEnabledError() - - -def validate_workspace_update_options(options: WorkspaceUpdateOptions) -> None: - """ - Validate workspace update options for proper API usage. - Raises specific validation errors if validation fails. - """ - # Check name format if provided - if options.name is not None and not is_valid_workspace_name(options.name): - raise InvalidNameError() - - # Check operations and execution mode conflict - if options.operations is not None and options.execution_mode is not None: - raise UnsupportedOperationsError() - - # Check agent mode requirements - if ( - options.agent_pool_id is None - and options.execution_mode is not None - and options.execution_mode == "agent" - ): - raise RequiredAgentPoolIDError() - - # Check trigger patterns and prefixes conflict - if len(options.trigger_prefixes) > 0 and len(options.trigger_patterns) > 0: - raise UnsupportedBothTriggerPatternsAndPrefixesError() - - # Check tags regex conflicts - if has_tags_regex_defined(options.vcs_repo): - if len(options.trigger_patterns) > 0: - raise UnsupportedBothTagsRegexAndTriggerPatternsError() - - if len(options.trigger_prefixes) > 0: - raise UnsupportedBothTagsRegexAndTriggerPrefixesError() - - if options.file_triggers_enabled is not None and options.file_triggers_enabled: - raise UnsupportedBothTagsRegexAndFileTriggersEnabledError() - - def validate_oauth_client_create_options(options: OAuthClientCreateOptions) -> None: """ Validate OAuth client create options for proper API usage. @@ -366,26 +267,25 @@ def pack_contents(path: str) -> io.BytesIO: BytesIO buffer containing the tar.gz archive Raises: - ImportError: If go-slug is not available ValueError: If path is invalid """ - if slug is None: - raise ImportError( - "go-slug package is required for packing configuration files. " - "Install it with: pip install go-slug" + if not path or not os.path.isdir(path): + raise ValueError( + f"Failed to pack directory {path}: path must be an existing directory" ) body = io.BytesIO() - # Use go-slug to pack the configuration directory - # This handles .terraformignore and other Terraform-specific behaviors - packer = slug.Packer() - _, err = packer.pack(path, body) - - if err: - raise ValueError(f"Failed to pack directory {path}: {err}") + with tarfile.open(fileobj=body, mode="w:gz") as tar: + for root, _, files in os.walk(path): + rel_root = os.path.relpath(root, path) + for filename in files: + full_path = os.path.join(root, filename) + arcname = ( + filename if rel_root == "." else os.path.join(rel_root, filename) + ) + tar.add(full_path, arcname=arcname, recursive=False) - # Reset buffer position to beginning for reading body.seek(0) return body diff --git a/tests/__init__.py b/tests/__init__.py index 2cfc448..af1712d 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1,4 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Tests for the python-tfe package.""" diff --git a/tests/units/test_agent_pools.py b/tests/units/test_agent_pools.py index a113ac6..f797f61 100644 --- a/tests/units/test_agent_pools.py +++ b/tests/units/test_agent_pools.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Unit tests for agent pool operations. These tests mock the TFE API responses and focus on: @@ -6,6 +9,7 @@ 3. Agent token management 4. Request building and parameter handling 5. Response parsing and error handling +6. Workspace assignment (assign_to_workspaces / remove_from_workspaces bug fix) Run with: pytest tests/units/test_agent_pools.py -v @@ -19,8 +23,10 @@ from pytfe.models.agent import ( AgentPool, AgentPoolAllowedWorkspacePolicy, + AgentPoolAssignToWorkspacesOptions, AgentPoolCreateOptions, AgentPoolListOptions, + AgentPoolRemoveFromWorkspacesOptions, AgentPoolUpdateOptions, AgentTokenCreateOptions, ) @@ -85,6 +91,26 @@ def test_agent_pool_create_options(self): == AgentPoolAllowedWorkspacePolicy.SPECIFIC_WORKSPACES ) + def test_agent_pool_create_options_workspace_ids(self): + """Test AgentPoolCreateOptions with allowed/excluded workspace IDs (bug fix)""" + options = AgentPoolCreateOptions( + name="scoped-pool", + organization_scoped=False, + allowed_workspace_ids=["ws-aaa", "ws-bbb"], + excluded_workspace_ids=["ws-ccc"], + ) + assert options.allowed_workspace_ids == ["ws-aaa", "ws-bbb"] + assert options.excluded_workspace_ids == ["ws-ccc"] + + def test_agent_pool_update_options_workspace_ids(self): + """Test AgentPoolUpdateOptions with allowed/excluded workspace IDs (bug fix)""" + options = AgentPoolUpdateOptions( + allowed_workspace_ids=["ws-aaa"], + excluded_workspace_ids=["ws-bbb"], + ) + assert options.allowed_workspace_ids == ["ws-aaa"] + assert options.excluded_workspace_ids == ["ws-bbb"] + class TestAgentPoolOperations: """Test agent pool CRUD operations""" @@ -121,7 +147,6 @@ def test_list_agent_pools(self, agent_pools_service, mock_transport): } mock_transport.request.return_value.json.return_value = mock_response - agent_pools = list(agent_pools_service.list("test-org")) assert len(agent_pools) == 1 @@ -139,7 +164,6 @@ def test_list_agent_pools_with_options(self, agent_pools_service, mock_transport mock_transport.request.return_value.json.return_value = mock_response options = AgentPoolListOptions( - page_number=2, page_size=10, allowed_workspace_policy=AgentPoolAllowedWorkspacePolicy.ALL_WORKSPACES, ) @@ -150,7 +174,7 @@ def test_list_agent_pools_with_options(self, agent_pools_service, mock_transport mock_transport.request.assert_called_once() call_args = mock_transport.request.call_args params = call_args[1]["params"] - assert params["page[number]"] == 2 + assert params["page[number]"] == 1 assert params["page[size]"] == 10 assert params["filter[allowed_workspace_policy]"] == "all-workspaces" @@ -171,7 +195,6 @@ def test_create_agent_pool(self, agent_pools_service, mock_transport): } mock_transport.request.return_value.json.return_value = mock_response - options = AgentPoolCreateOptions( name="new-pool", organization_scoped=True, @@ -207,7 +230,6 @@ def test_read_agent_pool(self, agent_pools_service, mock_transport): } mock_transport.request.return_value.json.return_value = mock_response - agent_pool = agent_pools_service.read("apool-123456789abcdef0") assert agent_pool.id == "apool-123456789abcdef0" @@ -238,9 +260,7 @@ def test_update_agent_pool(self, agent_pools_service, mock_transport): } mock_transport.request.return_value.json.return_value = mock_response - options = AgentPoolUpdateOptions(name="updated-pool", organization_scoped=False) - agent_pool = agent_pools_service.update("apool-123456789abcdef0", options) assert agent_pool.id == "apool-123456789abcdef0" @@ -263,6 +283,96 @@ def test_delete_agent_pool(self, agent_pools_service, mock_transport): assert call_args[0][0] == "DELETE" assert "agent-pools/apool-123456789abcdef0" in call_args[0][1] + def test_assign_to_workspaces(self, agent_pools_service, mock_transport): + """assign_to_workspaces must PATCH /agent-pools/:id with relationships.allowed-workspaces. + + Previously (broken): POST /agent-pools/:id/relationships/workspaces -> 404 + Fixed: PATCH /agent-pools/:id with relationships.allowed-workspaces body + """ + pool_id = "apool-123456789abcdef0" + ws_id = "ws-aaaaaaaaaaaaaaa1" + + mock_response = { + "data": { + "id": pool_id, + "type": "agent-pools", + "attributes": { + "name": "test-pool", + "created-at": "2023-01-01T00:00:00Z", + "organization-scoped": True, + "allowed-workspace-policy": "all-workspaces", + "agent-count": 0, + }, + } + } + mock_transport.request.return_value.json.return_value = mock_response + + agent_pool = agent_pools_service.assign_to_workspaces( + pool_id, + AgentPoolAssignToWorkspacesOptions(workspace_ids=[ws_id]), + ) + + assert agent_pool.id == pool_id + assert agent_pool.name == "test-pool" + + call_args = mock_transport.request.call_args + # Must be PATCH, not POST + assert call_args[0][0] == "PATCH" + # Must target the pool URL, not a /relationships/workspaces sub-resource + assert call_args[0][1] == f"/api/v2/agent-pools/{pool_id}" + # Payload must use relationships.allowed-workspaces + body = call_args[1]["json_body"]["data"] + assert body["type"] == "agent-pools" + assert body["id"] == pool_id + ws_data = body["relationships"]["allowed-workspaces"]["data"] + assert ws_data[0]["id"] == ws_id + assert ws_data[0]["type"] == "workspaces" + + def test_remove_from_workspaces(self, agent_pools_service, mock_transport): + """remove_from_workspaces must PATCH /agent-pools/:id with relationships.excluded-workspaces. + + Previously (broken): DELETE /agent-pools/:id/relationships/workspaces -> 404 + Fixed: PATCH /agent-pools/:id with relationships.excluded-workspaces body + """ + pool_id = "apool-123456789abcdef0" + ws_id = "ws-aaaaaaaaaaaaaaa1" + + mock_response = { + "data": { + "id": pool_id, + "type": "agent-pools", + "attributes": { + "name": "test-pool", + "created-at": "2023-01-01T00:00:00Z", + "organization-scoped": True, + "allowed-workspace-policy": "all-workspaces", + "agent-count": 0, + }, + } + } + mock_transport.request.return_value.json.return_value = mock_response + + agent_pool = agent_pools_service.remove_from_workspaces( + pool_id, + AgentPoolRemoveFromWorkspacesOptions(workspace_ids=[ws_id]), + ) + + assert agent_pool.id == pool_id + assert agent_pool.name == "test-pool" + + call_args = mock_transport.request.call_args + # Must be PATCH, not DELETE + assert call_args[0][0] == "PATCH" + # Must target the pool URL, not a /relationships/workspaces sub-resource + assert call_args[0][1] == f"/api/v2/agent-pools/{pool_id}" + # Payload must use relationships.excluded-workspaces + body = call_args[1]["json_body"]["data"] + assert body["type"] == "agent-pools" + assert body["id"] == pool_id + ws_data = body["relationships"]["excluded-workspaces"]["data"] + assert ws_data[0]["id"] == ws_id + assert ws_data[0]["type"] == "workspaces" + class TestAgentTokenOperations: """Test agent token operations""" @@ -297,7 +407,6 @@ def test_list_agent_tokens(self, agent_tokens_service, mock_transport): } mock_transport.request.return_value.json.return_value = mock_response - tokens = list(agent_tokens_service.list("apool-123456789abcdef0")) assert len(tokens) == 1 @@ -328,7 +437,6 @@ def test_create_agent_token(self, agent_tokens_service, mock_transport): } mock_transport.request.return_value.json.return_value = mock_response - options = AgentTokenCreateOptions(description="New token") token = agent_tokens_service.create("apool-123456789abcdef0", options) @@ -361,7 +469,6 @@ def test_read_agent_token(self, agent_tokens_service, mock_transport): } mock_transport.request.return_value.json.return_value = mock_response - token = agent_tokens_service.read("at-123456789abcdef0") assert token.id == "at-123456789abcdef0" diff --git a/tests/units/test_agents.py b/tests/units/test_agents.py index 94e9b33..69e40ae 100644 --- a/tests/units/test_agents.py +++ b/tests/units/test_agents.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Unit tests for individual agent operations. These tests mock the TFE API responses and focus on: diff --git a/tests/units/test_apply.py b/tests/units/test_apply.py index 62f7509..77f9600 100644 --- a/tests/units/test_apply.py +++ b/tests/units/test_apply.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Test cases for Apply resources.""" from __future__ import annotations diff --git a/tests/units/test_configuration_version.py b/tests/units/test_configuration_version.py index f4f0fa2..d8d442b 100644 --- a/tests/units/test_configuration_version.py +++ b/tests/units/test_configuration_version.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """ Comprehensive unit tests for configuration version operations in the Python TFE SDK. @@ -17,7 +20,7 @@ """ import io -from unittest.mock import Mock, patch +from unittest.mock import Mock import pytest @@ -164,7 +167,7 @@ def test_list_with_options( workspace_id = "ws-YnyXLq9fy38afEeb" options = ConfigurationVersionListOptions( - include=[ConfigVerIncludeOpt.INGRESS_ATTRIBUTES], page_size=5, page_number=1 + include=[ConfigVerIncludeOpt.INGRESS_ATTRIBUTES], page_size=5 ) list(configuration_versions_service.list(workspace_id, options)) @@ -173,7 +176,7 @@ def test_list_with_options( expected_params = { "include": "ingress_attributes", "page[size]": "5", - "page[number]": "1", + "page[number]": 1, } mock_transport.request.assert_called_with( "GET", @@ -343,35 +346,33 @@ def test_read_with_options_no_ingress( class TestConfigurationVersionsUpload: """Test configuration versions upload functionality.""" - def test_upload_missing_slug(self, configuration_versions_service): - """Test upload when go-slug is not available.""" + def test_upload_packs_with_tar(self, configuration_versions_service, tmp_path): + """Test upload works by packing a directory to tar.gz with stdlib.""" upload_url = "https://example.com/upload" - directory_path = "/tmp/test" + directory_path = tmp_path + (directory_path / "main.tf").write_text('resource "null_resource" "test" {}') + + mock_response = Mock() + mock_response.status_code = 200 + configuration_versions_service.t._sync.put.return_value = mock_response - with patch("src.pytfe.utils.slug", None): - with pytest.raises(ImportError, match="go-slug package is required"): - configuration_versions_service.upload(upload_url, directory_path) + configuration_versions_service.upload(upload_url, str(directory_path)) - @patch("src.pytfe.utils.slug") - def test_upload_success(self, mock_slug, configuration_versions_service): - """Test successful upload.""" - # Mock slug.pack - mock_packer = Mock() - mock_packer.pack.return_value = (None, None) # (size, error) - mock_slug.Packer.return_value = mock_packer + configuration_versions_service.t._sync.put.assert_called_once() + def test_upload_success(self, configuration_versions_service, tmp_path): + """Test successful upload.""" upload_url = "https://example.com/upload" - directory_path = "/tmp/test" + directory_path = tmp_path + (directory_path / "main.tf").write_text('resource "null_resource" "test" {}') # Mock transport's underlying httpx client instead of direct httpx mock_response = Mock() mock_response.status_code = 200 configuration_versions_service.t._sync.put.return_value = mock_response - configuration_versions_service.upload(upload_url, directory_path) - - # Verify slug.pack was called - mock_packer.pack.assert_called_once() + configuration_versions_service.upload(upload_url, str(directory_path)) + configuration_versions_service.t._sync.put.assert_called_once() class TestConfigurationVersionsUploadTarGzip: diff --git a/tests/units/test_notification_configuration.py b/tests/units/test_notification_configuration.py index 034d161..a9204bf 100644 --- a/tests/units/test_notification_configuration.py +++ b/tests/units/test_notification_configuration.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """ Unit tests for Notification Configuration API. @@ -73,20 +76,28 @@ def test_list_workspace_notifications(self): # Test list operation workspace_id = "ws-123456789" - result = self.notifications.list(workspace_id) + result_iter = self.notifications.list(workspace_id) + items = list(result_iter) - # Verify API call - self.mock_transport.request.assert_called_once_with( - "GET", - f"/api/v2/workspaces/{workspace_id}/notification-configurations", - params=None, + # Verify API call (occurs when iterator is consumed) + self.mock_transport.request.assert_called_once() + call_args = self.mock_transport.request.call_args + assert call_args[0][0] == "GET" + assert ( + call_args[0][1] + == f"/api/v2/workspaces/{workspace_id}/notification-configurations" ) + params = call_args[1].get("params") + assert isinstance(params, dict) + assert "page[number]" in params and "page[size]" in params + assert params["page[number]"] == 1 + assert params["page[size]"] == 100 # Verify result - assert isinstance(result, NotificationConfigurationList) - assert len(result.items) == 1 - assert result.items[0].id == "nc-123456789" - assert result.items[0].name == "Test Notification" + assert len(items) == 1 + assert isinstance(items[0], NotificationConfiguration) + assert items[0].id == "nc-123456789" + assert items[0].name == "Test Notification" def test_list_team_notifications(self): """Test listing notification configurations for a team.""" @@ -105,16 +116,23 @@ def test_list_team_notifications(self): team_choice = NotificationConfigurationSubscribableChoice(team={"id": team_id}) options = NotificationConfigurationListOptions(subscribable_choice=team_choice) - result = self.notifications.list(team_id, options) + result_iter = self.notifications.list(team_id, options) + items = list(result_iter) - # Verify API call - self.mock_transport.request.assert_called_once_with( - "GET", f"/api/v2/teams/{team_id}/notification-configurations", params={} - ) + # Verify API call (occurs when iterator is consumed) + self.mock_transport.request.assert_called_once() + call_args = self.mock_transport.request.call_args + assert call_args[0][0] == "GET" + assert call_args[0][1] == f"/api/v2/teams/{team_id}/notification-configurations" + params = call_args[1].get("params") + assert isinstance(params, dict) + assert "page[number]" in params and "page[size]" in params + assert params["page[number]"] == 1 + assert params["page[size]"] == 100 # Verify result - assert isinstance(result, NotificationConfigurationList) - assert len(result.items) == 1 + assert len(items) == 1 + assert isinstance(items[0], NotificationConfiguration) def test_list_with_pagination(self): """Test listing with pagination options.""" @@ -130,21 +148,29 @@ def test_list_with_pagination(self): # Test with pagination workspace_id = "ws-123456789" - options = NotificationConfigurationListOptions(page_number=2, page_size=50) + options = NotificationConfigurationListOptions(page_size=50) - self.notifications.list(workspace_id, options) + result_iter = self.notifications.list(workspace_id, options) + _ = list(result_iter) - # Verify API call with pagination - self.mock_transport.request.assert_called_once_with( - "GET", - f"/api/v2/workspaces/{workspace_id}/notification-configurations", - params={"page[number]": 2, "page[size]": 50}, + # page_size from options is respected by _list(); page[number] is controlled by _list() + self.mock_transport.request.assert_called_once() + call_args = self.mock_transport.request.call_args + assert call_args[0][0] == "GET" + assert ( + call_args[0][1] + == f"/api/v2/workspaces/{workspace_id}/notification-configurations" ) + params = call_args[1].get("params") + assert isinstance(params, dict) + assert "page[number]" in params and "page[size]" in params + assert params["page[number]"] == 1 + assert params["page[size]"] == 50 def test_list_invalid_id(self): """Test list with invalid subscribable ID.""" with pytest.raises(InvalidOrgError): - self.notifications.list("") + list(self.notifications.list("")) def test_create_workspace_notification(self): """Test creating a notification configuration for a workspace.""" @@ -619,10 +645,10 @@ def test_notification_configuration_list(self): def test_list_options_to_dict(self): """Test list options conversion to dictionary.""" - options = NotificationConfigurationListOptions(page_number=2, page_size=50) + options = NotificationConfigurationListOptions(page_size=50) result = options.to_dict() - assert result == {"page[number]": 2, "page[size]": 50} + assert result == {"page[size]": 50} def test_create_options_to_dict(self): """Test create options conversion to dictionary.""" diff --git a/tests/units/test_oauth_client.py b/tests/units/test_oauth_client.py index 00e4ff3..e0db5ae 100644 --- a/tests/units/test_oauth_client.py +++ b/tests/units/test_oauth_client.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """ Comprehensive unit tests for OAuth client operations in the Python TFE SDK. diff --git a/tests/units/test_oauth_token.py b/tests/units/test_oauth_token.py index b60ee9b..0939f42 100644 --- a/tests/units/test_oauth_token.py +++ b/tests/units/test_oauth_token.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """ Comprehensive unit tests for OAuth token operations in the Python TFE SDK. diff --git a/tests/units/test_organization_membership.py b/tests/units/test_organization_membership.py index 11888d5..c848fb7 100644 --- a/tests/units/test_organization_membership.py +++ b/tests/units/test_organization_membership.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """ Comprehensive unit tests for organization membership operations in the Python TFE SDK. diff --git a/tests/units/test_plan.py b/tests/units/test_plan.py index a8d5146..36c3f7e 100644 --- a/tests/units/test_plan.py +++ b/tests/units/test_plan.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Unit tests for the plan module.""" from unittest.mock import Mock, patch diff --git a/tests/units/test_policy.py b/tests/units/test_policy.py index af4790c..e94a38b 100644 --- a/tests/units/test_policy.py +++ b/tests/units/test_policy.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Unit tests for the policy module.""" from unittest.mock import Mock, patch @@ -14,7 +17,6 @@ EnforcementLevel, Policy, PolicyCreateOptions, - PolicyList, PolicyUpdateOptions, ) from pytfe.models.policy_set import PolicyKind @@ -84,22 +86,16 @@ def test_list_policies_success_without_options( mock_response.json.return_value = mock_response_data mock_transport.request.return_value = mock_response - result = policies_service.list("org-123") + result_iter = policies_service.list("org-123") + items = list(result_iter) - mock_transport.request.assert_called_once_with( - "GET", "/api/v2/organizations/org-123/policies", params=None - ) + assert mock_transport.request.called - assert isinstance(result, PolicyList) - assert len(result.items) == 1 - assert result.items[0].id == "pol-123" - assert result.items[0].name == "test-policy" - assert result.items[0].kind == PolicyKind.SENTINEL - assert ( - result.items[0].enforcement_level == EnforcementLevel.ENFORCEMENT_ADVISORY - ) - assert result.current_page == 1 - assert result.total_count == 1 + assert len(items) == 1 + assert items[0].id == "pol-123" + assert items[0].name == "test-policy" + assert items[0].kind == PolicyKind.SENTINEL + assert items[0].enforcement_level == EnforcementLevel.ENFORCEMENT_ADVISORY def test_create_policy_validations(self, policies_service): """Test create method validations.""" diff --git a/tests/units/test_policy_check.py b/tests/units/test_policy_check.py new file mode 100644 index 0000000..07642cb --- /dev/null +++ b/tests/units/test_policy_check.py @@ -0,0 +1,284 @@ +"""Unit tests for the policy_check module.""" + +from unittest.mock import Mock, patch + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import InvalidPolicyCheckIDError, InvalidRunIDError +from pytfe.models.policy_check import ( + PolicyCheck, + PolicyCheckIncludeOpt, + PolicyCheckListOptions, + PolicyStatus, +) +from pytfe.resources.policy_check import PolicyChecks + + +class TestPolicyChecks: + """Test the PolicyChecks service class.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock HTTPTransport.""" + return Mock(spec=HTTPTransport) + + @pytest.fixture + def policy_checks_service(self, mock_transport): + """Create a PolicyChecks service with mocked transport.""" + return PolicyChecks(mock_transport) + + def test_list_policy_checks_validation(self, policy_checks_service): + """Test list() with invalid run ID.""" + with pytest.raises(InvalidRunIDError): + list(policy_checks_service.list("")) + + def test_list_policy_checks_iterator(self, policy_checks_service): + """Test list() returns an iterator of PolicyCheck models.""" + # Mock items match the raw data objects yielded by _list() as per the + # List Policy Checks API response: + # https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policy-checks#list-policy-checks + mock_items = [ + { + "id": "polchk-9VYRc9bpfJEsnwum", + "type": "policy-checks", + "attributes": { + "result": { + "result": False, + "passed": 0, + "total-failed": 1, + "hard-failed": 0, + "soft-failed": 1, + "advisory-failed": 0, + "duration-ms": 0, + "sentinel": None, + }, + "scope": "organization", + "status": "soft_failed", + "status-timestamps": { + "queued-at": "2017-11-29T20:02:17+00:00", + "soft-failed-at": "2017-11-29T20:02:20+00:00", + }, + "actions": {"is-overridable": True}, + "permissions": {"can-override": False}, + }, + "relationships": { + "run": {"data": {"id": "run-veDoQbv6xh6TbnJD", "type": "runs"}} + }, + "links": { + "output": "/api/v2/policy-checks/polchk-9VYRc9bpfJEsnwum/output" + }, + }, + { + "id": "polchk-passed456", + "type": "policy-checks", + "attributes": { + "result": { + "result": True, + "passed": 3, + "total-failed": 0, + "hard-failed": 0, + "soft-failed": 0, + "advisory-failed": 0, + "duration-ms": 120, + "sentinel": None, + }, + "scope": "workspace", + "status": "passed", + "status-timestamps": { + "queued-at": "2017-11-29T20:02:17+00:00", + "passed-at": "2017-11-29T20:02:19+00:00", + }, + "actions": {"is-overridable": False}, + "permissions": {"can-override": False}, + }, + "relationships": { + "run": {"data": {"id": "run-veDoQbv6xh6TbnJD", "type": "runs"}} + }, + "links": {"output": "/api/v2/policy-checks/polchk-passed456/output"}, + }, + ] + + with patch.object(policy_checks_service, "_list") as mock_list: + mock_list.return_value = mock_items + + options = PolicyCheckListOptions( + page_size=25, + include=[PolicyCheckIncludeOpt.POLICY_CHECK_RUN], + ) + result = list(policy_checks_service.list("run-1", options)) + + mock_list.assert_called_once() + call_args = mock_list.call_args + assert call_args[0][0] == "/api/v2/runs/run-1/policy-checks" + params = call_args[1]["params"] + assert params["page[size]"] == 25 + + assert len(result) == 2 + assert all(isinstance(item, PolicyCheck) for item in result) + + pc0 = result[0] + assert pc0.id == "polchk-9VYRc9bpfJEsnwum" + assert pc0.status == PolicyStatus.POLICY_SOFT_FAILED + assert pc0.scope.value == "organization" + assert pc0.result is not None + assert pc0.result.soft_failed == 1 + assert pc0.result.passed == 0 + assert pc0.result.total_failed == 1 + assert pc0.actions.is_overridable is True + assert pc0.permissions.can_override is False + assert pc0.status_timestamps.queued_at is not None + assert pc0.status_timestamps.soft_failed_at is not None + + pc1 = result[1] + assert pc1.id == "polchk-passed456" + assert pc1.status == PolicyStatus.POLICY_PASSES + assert pc1.scope.value == "workspace" + assert pc1.result.passed == 3 + assert pc1.result.total_failed == 0 + + def test_read_policy_check(self, policy_checks_service, mock_transport): + """Test read() for a policy check. + + Mock matches the Show Policy Check API response: + https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policy-checks#show-policy-check + """ + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "id": "polchk-9VYRc9bpfJEsnwum", + "type": "policy-checks", + "attributes": { + "result": { + "result": False, + "passed": 0, + "total-failed": 1, + "hard-failed": 0, + "soft-failed": 1, + "advisory-failed": 0, + "duration-ms": 0, + "sentinel": None, + }, + "scope": "organization", + "status": "soft_failed", + "status-timestamps": { + "queued-at": "2017-11-29T20:02:17+00:00", + "soft-failed-at": "2017-11-29T20:02:20+00:00", + }, + "actions": {"is-overridable": True}, + "permissions": {"can-override": False}, + }, + "relationships": { + "run": {"data": {"id": "run-veDoQbv6xh6TbnJD", "type": "runs"}} + }, + "links": { + "output": "/api/v2/policy-checks/polchk-9VYRc9bpfJEsnwum/output" + }, + } + } + mock_transport.request.return_value = mock_response + + result = policy_checks_service.read("polchk-9VYRc9bpfJEsnwum") + + mock_transport.request.assert_called_once_with( + "GET", "/api/v2/policy-checks/polchk-9VYRc9bpfJEsnwum" + ) + assert result.id == "polchk-9VYRc9bpfJEsnwum" + assert result.status == PolicyStatus.POLICY_SOFT_FAILED + assert result.scope.value == "organization" + assert result.result is not None + assert result.result.soft_failed == 1 + assert result.result.total_failed == 1 + assert result.result.passed == 0 + assert result.result.result is False + assert result.actions.is_overridable is True + assert result.permissions.can_override is False + assert result.status_timestamps.queued_at is not None + assert result.status_timestamps.soft_failed_at is not None + + def test_override_policy_check(self, policy_checks_service, mock_transport): + """Test override() for a policy check. + + Mock matches the Override Policy API response: + https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policy-checks#override-policy + """ + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "id": "polchk-EasPB4Srx5NAiWAU", + "type": "policy-checks", + "attributes": { + "result": { + "result": False, + "passed": 0, + "total-failed": 1, + "hard-failed": 0, + "soft-failed": 1, + "advisory-failed": 0, + "duration-ms": 0, + "sentinel": None, + }, + "scope": "organization", + "status": "overridden", + "status-timestamps": { + "queued-at": "2017-11-29T20:13:37+00:00", + "soft-failed-at": "2017-11-29T20:13:40+00:00", + "overridden-at": "2017-11-29T20:14:11+00:00", + }, + "actions": {"is-overridable": True}, + "permissions": {"can-override": False}, + }, + "links": { + "output": "/api/v2/policy-checks/polchk-EasPB4Srx5NAiWAU/output" + }, + } + } + mock_transport.request.return_value = mock_response + + result = policy_checks_service.override("polchk-EasPB4Srx5NAiWAU") + + mock_transport.request.assert_called_once_with( + "POST", "/api/v2/policy-checks/polchk-EasPB4Srx5NAiWAU/actions/override" + ) + assert result.id == "polchk-EasPB4Srx5NAiWAU" + assert result.status == PolicyStatus.POLICY_OVERRIDDEN + assert result.scope.value == "organization" + assert result.result.soft_failed == 1 + assert result.actions.is_overridable is True + assert result.status_timestamps.soft_failed_at is not None + assert result.status_timestamps.queued_at is not None + + def test_logs_invalid_id(self, policy_checks_service): + """Test logs() with invalid policy check ID.""" + with pytest.raises(InvalidPolicyCheckIDError): + policy_checks_service.logs("") + + def test_logs_waits_until_ready(self, policy_checks_service, mock_transport): + """Test logs() polling until policy status is no longer pending/queued.""" + pending_pc = PolicyCheck(id="pc-1", status=PolicyStatus.POLICY_PENDING) + passed_pc = PolicyCheck(id="pc-1", status=PolicyStatus.POLICY_PASSES) + + with ( + patch.object(policy_checks_service, "read") as mock_read, + patch("pytfe.resources.policy_check.time.sleep") as mock_sleep, + ): + mock_read.side_effect = [pending_pc, passed_pc] + mock_response = Mock() + mock_response.text = "policy output" + mock_transport.request.return_value = mock_response + + logs = policy_checks_service.logs("pc-1") + + assert logs == "policy output" + assert mock_read.call_count == 2 + mock_sleep.assert_called_once_with(0.5) + mock_transport.request.assert_called_once_with( + "GET", "/api/v2/policy-checks/pc-1/output" + ) + + +def test_policy_check_list_options_has_no_page_number(): + """Ensure iterator-style list options no longer expose page_number.""" + options = PolicyCheckListOptions(page_size=10) + dumped = options.model_dump(by_alias=True, exclude_none=True) + assert dumped == {"page[size]": 10} diff --git a/tests/units/test_policy_evaluation.py b/tests/units/test_policy_evaluation.py index 820496b..ea41ee0 100644 --- a/tests/units/test_policy_evaluation.py +++ b/tests/units/test_policy_evaluation.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Unit tests for the policy evaluation module.""" from unittest.mock import Mock diff --git a/tests/units/test_policy_set.py b/tests/units/test_policy_set.py new file mode 100644 index 0000000..91229c4 --- /dev/null +++ b/tests/units/test_policy_set.py @@ -0,0 +1,327 @@ +"""Unit tests for the PolicySets resource.""" + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import ( + InvalidNameError, + InvalidOrgError, + InvalidPolicySetIDError, + RequiredNameError, +) +from pytfe.models.policy_set import ( + PolicySet, + PolicySetCreateOptions, + PolicySetListOptions, + PolicySetReadOptions, + PolicySetUpdateOptions, +) +from pytfe.models.policy_types import PolicyKind +from pytfe.resources.policy_set import PolicySets + + +class TestPolicySets: + """Test the PolicySets service class.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock HTTPTransport.""" + return Mock(spec=HTTPTransport) + + @pytest.fixture + def service(self, mock_transport): + """Create a PolicySets service with mocked transport.""" + return PolicySets(mock_transport) + + # ────────────────────────────────────────────────────────────────────────── + # Helpers + # ────────────────────────────────────────────────────────────────────────── + + @staticmethod + def _policy_set_data( + ps_id: str = "ps-abc123", + name: str = "example-policy-set", + kind: str = "sentinel", + ) -> dict: + """Minimal JSON:API policy-set dict as returned by the API.""" + return { + "id": ps_id, + "type": "policy-sets", + "attributes": { + "name": name, + "description": "A test policy set", + "kind": kind, + "global": False, + "overridable": False, + "agent-enabled": False, + "policy-count": 0, + "workspace-count": 0, + "project-count": 0, + "policy-tool-version": None, + "policies-path": None, + "created-at": "2024-01-01T00:00:00Z", + "updated-at": "2024-01-01T00:00:00Z", + }, + "relationships": { + "organization": {"data": {"id": "org-test", "type": "organizations"}}, + "workspaces": {"data": []}, + "projects": {"data": []}, + "policies": {"data": []}, + "workspace-exclusions": {"data": []}, + }, + } + + # ────────────────────────────────────────────────────────────────────────── + # list() + # ────────────────────────────────────────────────────────────────────────── + + def test_list_invalid_org_empty_string(self, service): + """list() raises InvalidOrgError for an empty organization.""" + with pytest.raises(InvalidOrgError): + list(service.list("")) + + def test_list_invalid_org_none(self, service): + """list() raises InvalidOrgError for None organization.""" + with pytest.raises(InvalidOrgError): + list(service.list(None)) + + def test_list_returns_iterator_of_policy_sets(self, service): + """list() returns an iterator that yields PolicySet objects.""" + raw = [ + self._policy_set_data("ps-1", "ps-one"), + self._policy_set_data("ps-2", "ps-two"), + ] + service._list = Mock(return_value=raw) + + result = list(service.list("my-org")) + + assert len(result) == 2 + assert all(isinstance(ps, PolicySet) for ps in result) + assert result[0].id == "ps-1" + assert result[0].name == "ps-one" + assert result[1].id == "ps-2" + assert result[1].name == "ps-two" + + def test_list_hits_correct_endpoint(self, service): + """list() calls _list with the correct path.""" + service._list = Mock(return_value=[]) + + list(service.list("my-org")) + + service._list.assert_called_once() + call_path = service._list.call_args[0][0] + assert call_path == "/api/v2/organizations/my-org/policy-sets" + + def test_list_with_search_option_passes_param(self, service): + """list() with a search option passes the correct params to _list.""" + service._list = Mock(return_value=[]) + options = PolicySetListOptions(search="my-prefix") + + list(service.list("my-org", options)) + + service._list.assert_called_once() + call_kwargs = service._list.call_args[1] + assert call_kwargs.get("params", {}).get("search[name]") == "my-prefix" + + def test_list_with_kind_filter(self, service): + """list() with a kind filter passes filter[kind] param.""" + service._list = Mock(return_value=[]) + options = PolicySetListOptions(kind=PolicyKind.OPA) + + list(service.list("my-org", options)) + + service._list.assert_called_once() + params = service._list.call_args[1].get("params", {}) + assert params.get("filter[kind]") == PolicyKind.OPA + + def test_list_page_number_stripped_from_params(self, service): + """list() strips page[number] from params so _list handles pagination.""" + service._list = Mock(return_value=[]) + options = PolicySetListOptions(page_number=3, page_size=20) + + list(service.list("my-org", options)) + + params = service._list.call_args[1].get("params", {}) + assert "page[number]" not in params + assert params.get("page[size]") == 20 + + # ────────────────────────────────────────────────────────────────────────── + # read() + # ────────────────────────────────────────────────────────────────────────── + + def test_read_invalid_id(self, service): + """read() raises InvalidPolicySetIDError for an invalid ID.""" + with pytest.raises(InvalidPolicySetIDError): + service.read("") + + with pytest.raises(InvalidPolicySetIDError): + service.read(None) + + def test_read_hits_correct_endpoint(self, service, mock_transport): + """read() calls GET /api/v2/policy-sets/{id}.""" + mock_response = Mock() + mock_response.json.return_value = {"data": self._policy_set_data("ps-abc123")} + mock_transport.request.return_value = mock_response + + service.read("ps-abc123") + + mock_transport.request.assert_called_once_with( + "GET", + "/api/v2/policy-sets/ps-abc123", + params=None, + ) + + def test_read_returns_policy_set(self, service, mock_transport): + """read() parses and returns a PolicySet model.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": self._policy_set_data("ps-abc123", "my-ps", "sentinel") + } + mock_transport.request.return_value = mock_response + + result = service.read("ps-abc123") + + assert isinstance(result, PolicySet) + assert result.id == "ps-abc123" + assert result.name == "my-ps" + assert result.kind == PolicyKind.SENTINEL + + def test_read_with_options_passes_include_param(self, service, mock_transport): + """read_with_options() passes include param when provided.""" + from pytfe.models.policy_set import PolicySetIncludeOpt + + mock_response = Mock() + mock_response.json.return_value = {"data": self._policy_set_data("ps-xyz")} + mock_transport.request.return_value = mock_response + + options = PolicySetReadOptions( + include=[PolicySetIncludeOpt.POLICY_SET_POLICIES] + ) + service.read_with_options("ps-xyz", options) + + call_kwargs = mock_transport.request.call_args[1] + assert call_kwargs.get("params") is not None + + # ────────────────────────────────────────────────────────────────────────── + # create() + # ────────────────────────────────────────────────────────────────────────── + + def test_create_invalid_org(self, service): + """create() raises InvalidOrgError for an invalid organization.""" + options = PolicySetCreateOptions(name="valid-name") + with pytest.raises(InvalidOrgError): + service.create("", options) + + def test_create_missing_name(self, service): + """create() raises RequiredNameError when name is empty.""" + options = PolicySetCreateOptions(name="") + with pytest.raises((RequiredNameError, InvalidNameError)): + service.create("my-org", options) + + def test_create_success(self, service, mock_transport): + """create() POSTs to the correct endpoint and returns a PolicySet.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": self._policy_set_data("ps-new", "new-policy-set") + } + mock_transport.request.return_value = mock_response + + options = PolicySetCreateOptions(name="new-policy-set") + result = service.create("my-org", options) + + mock_transport.request.assert_called_once() + call_args = mock_transport.request.call_args + assert call_args[0][0] == "POST" + assert call_args[0][1] == "/api/v2/organizations/my-org/policy-sets" + + assert isinstance(result, PolicySet) + assert result.id == "ps-new" + assert result.name == "new-policy-set" + + def test_create_payload_shape(self, service, mock_transport): + """create() sends a correctly shaped JSON:API payload.""" + mock_response = Mock() + mock_response.json.return_value = {"data": self._policy_set_data("ps-123")} + mock_transport.request.return_value = mock_response + + options = PolicySetCreateOptions(name="shaped-ps", kind=PolicyKind.OPA) + service.create("my-org", options) + + payload = mock_transport.request.call_args[1]["json_body"] + assert "data" in payload + data = payload["data"] + assert data["type"] == "policy-sets" + assert "attributes" in data + assert data["attributes"]["name"] == "shaped-ps" + + # ────────────────────────────────────────────────────────────────────────── + # update() + # ────────────────────────────────────────────────────────────────────────── + + def test_update_invalid_id(self, service): + """update() raises InvalidPolicySetIDError for an invalid ID.""" + options = PolicySetUpdateOptions(name="new-name") + with pytest.raises(InvalidPolicySetIDError): + service.update("", options) + + def test_update_success(self, service, mock_transport): + """update() PATCHes the correct endpoint and returns a PolicySet.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": self._policy_set_data("ps-abc123", "updated-name") + } + mock_transport.request.return_value = mock_response + + options = PolicySetUpdateOptions(name="updated-name") + result = service.update("ps-abc123", options) + + call_args = mock_transport.request.call_args + assert call_args[0][0] == "PATCH" + assert call_args[0][1] == "/api/v2/policy-sets/ps-abc123" + + payload = call_args[1]["json_body"] + assert payload["data"]["type"] == "policy-sets" + assert payload["data"]["id"] == "ps-abc123" + assert payload["data"]["attributes"]["name"] == "updated-name" + + assert isinstance(result, PolicySet) + assert result.name == "updated-name" + + def test_update_no_attributes_raises(self, service): + """update() raises ValueError when no attributes are provided.""" + options = PolicySetUpdateOptions() # all None + with pytest.raises(ValueError): + service.update("ps-abc123", options) + + # ────────────────────────────────────────────────────────────────────────── + # delete() + # ────────────────────────────────────────────────────────────────────────── + + def test_delete_invalid_id(self, service): + """delete() raises InvalidPolicySetIDError for an invalid ID.""" + with pytest.raises(InvalidPolicySetIDError): + service.delete("") + + with pytest.raises(InvalidPolicySetIDError): + service.delete(None) + + def test_delete_hits_correct_endpoint(self, service, mock_transport): + """delete() calls DELETE /api/v2/policy-sets/{id}.""" + mock_transport.request.return_value = Mock() + + service.delete("ps-abc123") + + mock_transport.request.assert_called_once_with( + "DELETE", + "/api/v2/policy-sets/ps-abc123", + ) + + def test_delete_returns_none(self, service, mock_transport): + """delete() returns None on success.""" + mock_transport.request.return_value = Mock() + + result = service.delete("ps-abc123") + + assert result is None diff --git a/tests/units/test_policy_set_parameter.py b/tests/units/test_policy_set_parameter.py index 05c2c4d..c8023c2 100644 --- a/tests/units/test_policy_set_parameter.py +++ b/tests/units/test_policy_set_parameter.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Unit tests for the policy_set_parameter module.""" from unittest.mock import Mock, patch diff --git a/tests/units/test_project.py b/tests/units/test_project.py index 801a29f..c42fb3a 100644 --- a/tests/units/test_project.py +++ b/tests/units/test_project.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from unittest.mock import Mock from pytfe.models import ( @@ -95,12 +98,12 @@ def test_list_projects_success(self): # Check first project assert result[0].id == "prj-123" assert result[0].name == "Test Project 1" - assert result[0].organization == organization + assert result[0].organization is None # Check second project assert result[1].id == "prj-456" assert result[1].name == "Test Project 2" - assert result[1].organization == organization + assert result[1].organization is None # Verify the correct API path was used expected_path = f"/api/v2/organizations/{organization}/projects" @@ -129,12 +132,18 @@ def test_create_project_success(self): assert isinstance(result, Project) assert result.id == "prj-123" assert result.name == project_name - assert result.organization == organization + assert result.organization is None # Verify API call expected_path = f"/api/v2/organizations/{organization}/projects" expected_payload = { - "data": {"type": "projects", "attributes": {"name": project_name}} + "data": { + "type": "projects", + "attributes": { + "name": project_name, + "default-execution-mode": "remote", + }, + } } self.mock_transport.request.assert_called_once_with( "POST", expected_path, json_body=expected_payload @@ -162,7 +171,8 @@ def test_read_project_success(self): assert isinstance(result, Project) assert result.id == project_id assert result.name == "Test Project" - assert result.organization == "test-org" + assert result.organization is not None + assert result.organization.id == "test-org" # Verify API call expected_path = f"/api/v2/projects/{project_id}" @@ -192,15 +202,18 @@ def test_update_project_success(self): assert isinstance(result, Project) assert result.id == project_id assert result.name == new_name - assert result.organization == "test-org" + assert result.organization is not None + assert result.organization.id == "test-org" # Verify API call expected_path = f"/api/v2/projects/{project_id}" expected_payload = { "data": { "type": "projects", - "id": project_id, - "attributes": {"name": new_name}, + "attributes": { + "name": new_name, + "default-execution-mode": "remote", + }, } } self.mock_transport.request.assert_called_once_with( @@ -268,7 +281,7 @@ def test_read_project_missing_organization(self): result = self.projects_service.read(project_id) - assert result.organization == "" # Should default to empty string + assert result.organization is None class TestProjectTagBindings: @@ -381,7 +394,7 @@ def test_list_effective_tag_bindings_success(self): # Verify API call self.mock_transport.request.assert_called_once_with( - "GET", f"/api/v2/projects/{self.project_id}/tag-bindings/effective" + "GET", f"/api/v2/projects/{self.project_id}/effective-tag-bindings" ) def test_list_effective_tag_bindings_invalid_project_id(self): @@ -527,7 +540,14 @@ def test_delete_tag_bindings_success(self): # Verify API call self.mock_transport.request.assert_called_once_with( - "DELETE", f"/api/v2/projects/{self.project_id}/tag-bindings" + "PATCH", + f"/api/v2/projects/{self.project_id}", + json_body={ + "data": { + "type": "projects", + "relationships": {"tag-bindings": {"data": []}}, + } + }, ) def test_delete_tag_bindings_invalid_project_id(self): diff --git a/tests/units/test_query_run.py b/tests/units/test_query_run.py index 3409d13..e31bf4e 100644 --- a/tests/units/test_query_run.py +++ b/tests/units/test_query_run.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """ Comprehensive unit tests for query run operations in the Python TFE SDK. diff --git a/tests/units/test_registry_provider_version.py b/tests/units/test_registry_provider_version.py index 46b76bd..e291e60 100644 --- a/tests/units/test_registry_provider_version.py +++ b/tests/units/test_registry_provider_version.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Unit tests for the registry_provider_version module.""" from unittest.mock import Mock, patch diff --git a/tests/units/test_reserved_tag_key.py b/tests/units/test_reserved_tag_key.py index d7ca66b..9c62bed 100644 --- a/tests/units/test_reserved_tag_key.py +++ b/tests/units/test_reserved_tag_key.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Test the Reserved Tag Keys functionality.""" from unittest.mock import Mock diff --git a/tests/units/test_run.py b/tests/units/test_run.py index bce2d2a..7a72d2c 100644 --- a/tests/units/test_run.py +++ b/tests/units/test_run.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Unit tests for the run module.""" from unittest.mock import Mock, patch @@ -11,7 +14,6 @@ TerraformVersionValidForPlanOnlyError, ) from pytfe.models.run import ( - OrganizationRunList, Run, RunApplyOptions, RunCancelOptions, @@ -19,7 +21,6 @@ RunDiscardOptions, RunForceCancelOptions, RunIncludeOpt, - RunList, RunListForOrganizationOptions, RunListOptions, RunReadOptions, @@ -47,74 +48,59 @@ def runs_service(self, mock_transport): def test_list_runs_success(self, runs_service): """Test successful list operation.""" - mock_response_data = { - "data": [ - { - "id": "run-123", - "attributes": { - "status": "applied", - "source": "tfe-configuration-version", - "message": "Test run", - "created-at": "2023-01-01T12:00:00Z", - "has-changes": True, - "is-destroy": False, - "auto-apply": False, - "plan-only": False, - }, + mock_list_data = [ + { + "id": "run-123", + "attributes": { + "status": "applied", + "source": "tfe-configuration-version", + "message": "Test run", + "created-at": "2023-01-01T12:00:00Z", + "has-changes": True, + "is-destroy": False, + "auto-apply": False, + "plan-only": False, }, - { - "id": "run-456", - "attributes": { - "status": "planned", - "source": "tfe-ui", - "message": "Another test run", - "created-at": "2023-01-02T14:00:00Z", - "has-changes": False, - "is-destroy": True, - "auto-apply": True, - "plan-only": True, - }, + }, + { + "id": "run-456", + "attributes": { + "status": "planned", + "source": "tfe-ui", + "message": "Another test run", + "created-at": "2023-01-02T14:00:00Z", + "has-changes": False, + "is-destroy": True, + "auto-apply": True, + "plan-only": True, }, - ], - "meta": { - "pagination": { - "current-page": 1, - "total-pages": 2, - "prev-page": None, - "next-page": 2, - "total-count": 10, - } }, - } - - mock_response = Mock() - mock_response.json.return_value = mock_response_data + ] - with patch.object(runs_service, "t") as mock_transport: - mock_transport.request.return_value = mock_response + with patch.object(runs_service, "_list") as mock_list: + mock_list.return_value = mock_list_data - # Test with custom page_size - use a print statement to debug what's actually sent + # Test with options options = RunListOptions(page_number=1, page_size=5) - result = runs_service.list("ws-123", options) + result = list(runs_service.list("ws-123", options)) - # Check what was actually called - call_args = mock_transport.request.call_args - actual_params = call_args[1]["params"] - - # Verify the basic structure - assert call_args[0][0] == "GET" - assert call_args[0][1] == "/api/v2/workspaces/ws-123/runs" - assert actual_params["page[number]"] == 1 - - # Verify result structure - assert isinstance(result, RunList) - assert len(result.items) == 2 - assert result.current_page == 1 - assert result.total_pages == 2 - assert result.total_count == 10 - - # Verify run objects - run1 = result.items[0] + # Verify _list was called with correct path + assert mock_list.call_count == 1 + call_args = mock_list.call_args + assert call_args[0][0] == "/api/v2/workspaces/ws-123/runs" + + # Verify params structure includes pagination and options + params = call_args[1]["params"] + assert "page[number]" in params + assert "page[size]" in params + assert "include" in params + + # Verify result structure - iterator yields Run objects + assert len(result) == 2 + + # Verify run objects were created correctly from response data + run1 = result[0] + assert isinstance(run1, Run) assert run1.id == "run-123" assert run1.status == RunStatus.Run_Applied assert run1.source == RunSource.Run_Source_Configuration_Version @@ -122,64 +108,53 @@ def test_list_runs_success(self, runs_service): assert run1.has_changes is True assert run1.is_destroy is False - run2 = result.items[1] + run2 = result[1] + assert isinstance(run2, Run) assert run2.id == "run-456" assert run2.status == RunStatus.Run_Planned assert run2.source == RunSource.Run_Source_UI + assert run2.message == "Another test run" assert run2.has_changes is False assert run2.is_destroy is True def test_list_for_organization_success(self, runs_service): """Test successful list_for_organization operation.""" - mock_response_data = { - "data": [ - { - "id": "run-org-1", - "attributes": { - "status": "applied", - "source": "tfe-api", - "message": "Organization run", - "created-at": "2023-01-01T12:00:00Z", - "has-changes": True, - "is-destroy": False, - }, - } - ], - "meta": { - "pagination": { - "current-page": 1, - "prev-page": None, - "next-page": None, - } - }, - } - - mock_response = Mock() - mock_response.json.return_value = mock_response_data + mock_response_data = [ + { + "id": "run-org-1", + "attributes": { + "status": "applied", + "source": "tfe-api", + "message": "Organization run", + "created-at": "2023-01-01T12:00:00Z", + "has-changes": True, + "is-destroy": False, + }, + } + ] - with patch.object(runs_service, "t") as mock_transport: - mock_transport.request.return_value = mock_response + with patch.object(runs_service, "_list") as mock_list: + mock_list.return_value = mock_response_data options = RunListForOrganizationOptions(status="applied,planned") - result = runs_service.list_for_organization("test-org", options) + result = list(runs_service.list_for_organization("test-org", options)) - # Verify request was made correctly (account for defaults and aliases) + # Verify _list was called with correct path and params expected_params = { "page[number]": 1, "page[size]": 20, "filter[status]": "applied,planned", "include": [], } - mock_transport.request.assert_called_once_with( - "GET", "/api/v2/organizations/test-org/runs", params=expected_params + mock_list.assert_called_once_with( + "/api/v2/organizations/test-org/runs", params=expected_params ) - # Verify result structure - assert isinstance(result, OrganizationRunList) - assert len(result.items) == 1 - assert result.current_page == 1 - assert result.items[0].id == "run-org-1" + # Verify result structure - now returns list of Run objects + assert len(result) == 1 + assert result[0].id == "run-org-1" + assert result[0].status == RunStatus.Run_Applied def test_create_run_validation_errors(self, runs_service): """Test create method with validation errors.""" @@ -190,7 +165,7 @@ def test_create_run_validation_errors(self, runs_service): runs_service.create(options) # Test terraform_version with non-plan-only run - workspace = Workspace(id="ws-123", name="test", organization="test-org") + workspace = Workspace(id="ws-123", name="test", organization=None) options = RunCreateOptions( workspace=workspace, terraform_version="1.5.0", plan_only=False ) @@ -227,7 +202,7 @@ def test_create_run_success(self, runs_service): with patch.object(runs_service, "t") as mock_transport: mock_transport.request.return_value = mock_response - workspace = Workspace(id="ws-123", name="test", organization="test-org") + workspace = Workspace(id="ws-123", name="test", organization=None) variables = [ RunVariable(key="env", value="test"), RunVariable(key="region", value="us-east-1"), @@ -322,14 +297,113 @@ def test_read_with_options_success(self, runs_service): mock_response_data = { "data": { "id": "run-detailed-123", + "type": "runs", "attributes": { - "status": "planned", - "source": "tfe-api", - "message": "Detailed read test", - "created-at": "2023-01-01T12:00:00Z", + "actions": { + "is-cancelable": False, + "is-confirmable": False, + "is-discardable": False, + "is-force-cancelable": False, + }, + "allow-config-generation": False, + "allow-empty-apply": False, + "auto-apply": False, + "canceled-at": None, + "created-at": "2026-02-19T01:58:46.126Z", "has-changes": True, "is-destroy": False, + "message": "Triggered via CLI", + "plan-only": False, + "refresh": True, + "refresh-only": False, + "replace-addrs": None, + "save-plan": False, + "source": "terraform+cloud", + "status-timestamps": { + "errored-at": "2026-02-19T01:59:19+00:00", + "planned-at": "2026-02-19T01:59:16+00:00", + "queuing-at": "2026-02-19T01:58:46+00:00", + "planning-at": "2026-02-19T01:58:48+00:00", + "plan-queued-at": "2026-02-19T01:58:46+00:00", + "plan-queueable-at": "2026-02-19T01:58:46+00:00", + }, + "status": "errored", + "target-addrs": None, + "trigger-reason": "manual", + "terraform-version": "1.13.5", + "updated-at": "2026-02-19T01:59:19.891Z", + "permissions": { + "can-apply": True, + "can-cancel": True, + "can-comment": True, + "can-discard": True, + "can-force-execute": True, + "can-force-cancel": True, + "can-override-policy-check": True, + }, + "variables": [], + "invoke-action-addrs": None, + }, + "relationships": { + "workspace": { + "data": {"id": "ws-a2Kntu53K79hsPRH", "type": "workspaces"} + }, + "apply": { + "data": {"id": "apply-Y1rVt6MpiwzdMjbK", "type": "applies"}, + "links": {"related": "/api/v2/runs/run-detailed-123/apply"}, + }, + "configuration-version": { + "data": { + "id": "cv-bakH4hn9cPXb2yZq", + "type": "configuration-versions", + }, + "links": { + "related": "/api/v2/runs/run-detailed-123/configuration-version" + }, + }, + "created-by": { + "data": {"id": "user-FRJGnNMX6fpe9Cdd", "type": "users"}, + "links": { + "related": "/api/v2/runs/run-detailed-123/created-by" + }, + }, + "plan": { + "data": {"id": "plan-WooDdHWZnSE3Zs8j", "type": "plans"}, + "links": {"related": "/api/v2/runs/run-detailed-123/plan"}, + }, + "run-events": { + "data": [ + {"id": "re-bqJGaaCrt5QZfexJ", "type": "run-events"}, + {"id": "re-j8d6eWyfyHSUbX7x", "type": "run-events"}, + {"id": "re-UAXd9VyRTXZy3hpx", "type": "run-events"}, + {"id": "re-DFFf51Doi8mmHC9G", "type": "run-events"}, + {"id": "re-U2m4RMQhEY9voN1K", "type": "run-events"}, + {"id": "re-WWfUbu5NTWdYKgBs", "type": "run-events"}, + ], + "links": { + "related": "/api/v2/runs/run-detailed-123/run-events" + }, + }, + "task-stages": { + "data": [], + "links": { + "related": "/api/v2/runs/run-detailed-123/task-stages" + }, + }, + "policy-checks": { + "data": [ + {"id": "polchk-JxgtJ56kFifnngyT", "type": "policy-checks"} + ], + "links": { + "related": "/api/v2/runs/run-detailed-123/policy-checks" + }, + }, + "comments": { + "data": [], + "links": {"related": "/api/v2/runs/run-detailed-123/comments"}, + }, }, + "links": {"self": "/api/v2/runs/run-detailed-123"}, } } @@ -358,6 +432,10 @@ def test_read_with_options_success(self, runs_service): # Verify result assert isinstance(result, Run) assert result.id == "run-detailed-123" + assert result.created_by.id == "user-FRJGnNMX6fpe9Cdd" + assert result.plan.id == "plan-WooDdHWZnSE3Zs8j" + assert result.apply.id == "apply-Y1rVt6MpiwzdMjbK" + assert result.workspace.id == "ws-a2Kntu53K79hsPRH" def test_apply_run_success(self, runs_service): """Test successful apply operation.""" diff --git a/tests/units/test_run_events.py b/tests/units/test_run_events.py new file mode 100644 index 0000000..af82e78 --- /dev/null +++ b/tests/units/test_run_events.py @@ -0,0 +1,298 @@ +"""Unit tests for the run_events module.""" + +from unittest.mock import Mock, patch + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import InvalidRunEventIDError, InvalidRunIDError +from pytfe.models.run_event import ( + RunEvent, + RunEventIncludeOpt, + RunEventListOptions, + RunEventReadOptions, +) +from pytfe.resources.run_event import RunEvents + + +class TestRunEvents: + """Test the RunEvents service class.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock HTTPTransport.""" + return Mock(spec=HTTPTransport) + + @pytest.fixture + def run_events_service(self, mock_transport): + """Create a RunEvents service with mocked transport.""" + return RunEvents(mock_transport) + + def test_list_run_events_success(self, run_events_service): + """Test successful list operation using iterator pattern.""" + + # Mock data for run events + mock_data = [ + { + "id": "re-123", + "attributes": { + "action": "queued", + "description": "Run queued", + "created-at": "2023-01-01T12:00:00Z", + }, + }, + { + "id": "re-456", + "attributes": { + "action": "planning", + "description": "Planning started", + "created-at": "2023-01-01T12:01:00Z", + }, + }, + { + "id": "re-789", + "attributes": { + "action": "planned", + "description": "Planning finished", + "created-at": "2023-01-01T12:02:00Z", + }, + }, + ] + + with patch.object(run_events_service, "_list") as mock_list: + # Mock _list to return an iterator + mock_list.return_value = iter(mock_data) + + options = RunEventListOptions(include=[RunEventIncludeOpt.RUN_EVENT_ACTOR]) + results = list(run_events_service.list("run-123", options)) + + # Verify _list was called correctly + mock_list.assert_called_once_with( + "/api/v2/runs/run-123/run-events", + params={"include": "actor"}, + ) + + # Verify results + assert len(results) == 3 + assert isinstance(results[0], RunEvent) + assert results[0].id == "re-123" + assert results[0].action == "queued" + assert results[1].id == "re-456" + assert results[1].action == "planning" + assert results[2].id == "re-789" + assert results[2].action == "planned" + + def test_list_run_events_with_multiple_includes(self, run_events_service): + """Test list with multiple include options.""" + + mock_data = [ + { + "id": "re-111", + "attributes": { + "action": "apply-queued", + "description": "Apply queued", + "created-at": "2023-01-01T12:10:00Z", + }, + }, + ] + + with patch.object(run_events_service, "_list") as mock_list: + mock_list.return_value = iter(mock_data) + + options = RunEventListOptions( + include=[ + RunEventIncludeOpt.RUN_EVENT_ACTOR, + RunEventIncludeOpt.RUN_EVENT_COMMENT, + ] + ) + results = list(run_events_service.list("run-456", options)) + + # Verify include parameter is formatted correctly + mock_list.assert_called_once_with( + "/api/v2/runs/run-456/run-events", + params={"include": "actor,comment"}, + ) + + assert len(results) == 1 + assert results[0].id == "re-111" + + def test_list_run_events_no_options(self, run_events_service): + """Test list without include options.""" + + mock_data = [ + { + "id": "re-222", + "attributes": { + "action": "apply-finished", + "created-at": "2023-01-01T12:15:00Z", + }, + }, + ] + + with patch.object(run_events_service, "_list") as mock_list: + mock_list.return_value = iter(mock_data) + + results = list(run_events_service.list("run-789")) + + # Verify _list was called with empty params + mock_list.assert_called_once_with( + "/api/v2/runs/run-789/run-events", + params={}, + ) + + assert len(results) == 1 + assert results[0].id == "re-222" + + def test_list_run_events_empty_result(self, run_events_service): + """Test list with no run events returned.""" + + with patch.object(run_events_service, "_list") as mock_list: + mock_list.return_value = iter([]) + + results = list(run_events_service.list("run-empty")) + + assert len(results) == 0 + + def test_list_run_events_invalid_run_id(self, run_events_service): + """Test list with invalid run ID.""" + + with pytest.raises(InvalidRunIDError): + list(run_events_service.list("")) + + with pytest.raises(InvalidRunIDError): + list(run_events_service.list("run/invalid")) + + def test_read_run_event_success(self, run_events_service): + """Test successful read operation.""" + + mock_response_data = { + "data": { + "id": "re-read-123", + "attributes": { + "action": "planned", + "description": "Run planned successfully", + "created-at": "2023-01-01T13:00:00Z", + }, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + + with patch.object(run_events_service, "t") as mock_transport: + mock_transport.request.return_value = mock_response + + result = run_events_service.read("re-read-123") + + # Verify request was made correctly + mock_transport.request.assert_called_once_with( + "GET", + "/api/v2/run-events/re-read-123", + params={}, + ) + + # Verify result + assert isinstance(result, RunEvent) + assert result.id == "re-read-123" + assert result.action == "planned" + assert result.description == "Run planned successfully" + + def test_read_run_event_with_includes(self, run_events_service): + """Test read with include options.""" + + mock_response_data = { + "data": { + "id": "re-read-456", + "attributes": { + "action": "discarded", + "description": "Run discarded", + "created-at": "2023-01-01T13:05:00Z", + }, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + + with patch.object(run_events_service, "t") as mock_transport: + mock_transport.request.return_value = mock_response + + options = RunEventReadOptions(include=[RunEventIncludeOpt.RUN_EVENT_ACTOR]) + result = run_events_service.read_with_options("re-read-456", options) + + # Verify include parameter was passed + mock_transport.request.assert_called_once_with( + "GET", + "/api/v2/run-events/re-read-456", + params={"include": "actor"}, + ) + + assert result.id == "re-read-456" + assert result.action == "discarded" + + def test_read_run_event_invalid_id(self, run_events_service): + """Test read with invalid run event ID.""" + + with pytest.raises(InvalidRunEventIDError): + run_events_service.read("") + + with pytest.raises(InvalidRunEventIDError): + run_events_service.read("re/invalid") + + def test_read_vs_read_with_options(self, run_events_service): + """Test that read() delegates to read_with_options().""" + + mock_response_data = { + "data": { + "id": "re-read-789", + "attributes": { + "action": "completed", + "created-at": "2023-01-01T13:10:00Z", + }, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + + with patch.object(run_events_service, "t") as mock_transport: + mock_transport.request.return_value = mock_response + + result1 = run_events_service.read("re-read-789") + + # Reset mock + mock_transport.reset_mock() + mock_transport.request.return_value = mock_response + + result2 = run_events_service.read_with_options("re-read-789") + + # Both should produce the same result + assert result1.id == result2.id + assert result1.action == result2.action + + def test_list_run_events_iterator_lazy_loading(self, run_events_service): + """Test that list returns an iterator that lazily loads data.""" + + mock_data = [ + { + "id": "re-lazy-1", + "attributes": { + "action": "queued", + "created-at": "2023-01-01T12:00:00Z", + }, + }, + ] + + with patch.object(run_events_service, "_list") as mock_list: + mock_list.return_value = iter(mock_data) + + # Get the iterator without consuming it yet + iterator = run_events_service.list("run-lazy") + + # _list should not have been called yet (iterator not consumed) + # This test ensures lazy evaluation + first_event = next(iterator) + + # Now _list should have been called + mock_list.assert_called_once() + assert first_event.id == "re-lazy-1" diff --git a/tests/units/test_run_task.py b/tests/units/test_run_task.py index a428d79..cb197e3 100644 --- a/tests/units/test_run_task.py +++ b/tests/units/test_run_task.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Unit tests for the run task module.""" from unittest.mock import Mock, patch diff --git a/tests/units/test_run_trigger.py b/tests/units/test_run_trigger.py index 901dafb..6fbcac0 100644 --- a/tests/units/test_run_trigger.py +++ b/tests/units/test_run_trigger.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Unit tests for the run trigger module.""" from datetime import datetime @@ -190,7 +193,7 @@ def test_create_run_trigger_validations(self, run_triggers_service): """Test create method with invalid workspace ID.""" options = RunTriggerCreateOptions( - sourceable=Workspace(id="ws-source", name="source", organization="org") + sourceable=Workspace(id="ws-source", name="source", organization=None) ) with pytest.raises(InvalidWorkspaceIDError): @@ -201,7 +204,7 @@ def test_create_run_trigger_validations(self, run_triggers_service): # is raised when the service method checks for None sourceable # Create valid options but then manually set sourceable to None to bypass model validation options = RunTriggerCreateOptions( - sourceable=Workspace(id="ws-source", name="source", organization="org") + sourceable=Workspace(id="ws-source", name="source", organization=None) ) options.sourceable = None @@ -227,7 +230,7 @@ def test_create_run_trigger_success(self, run_triggers_service): mock_transport.request.return_value = mock_response options = RunTriggerCreateOptions( - sourceable=Workspace(id="ws-source", name="source", organization="org") + sourceable=Workspace(id="ws-source", name="source", organization=None) ) result = run_triggers_service.create("ws-123", options) @@ -340,7 +343,7 @@ def test_validate_run_trigger_filter_param_success(self, run_triggers_service): def test_backfill_deprecated_sourceable_already_exists(self, run_triggers_service): """Test backfill when sourceable already exists.""" - workspace = Workspace(id="ws-1", name="workspace", organization="org") + workspace = Workspace(id="ws-1", name="workspace", organization=None) rt = RunTrigger( id="rt-1", created_at=datetime.now(), diff --git a/tests/units/test_ssh_keys.py b/tests/units/test_ssh_keys.py index 5500846..adbf3f7 100644 --- a/tests/units/test_ssh_keys.py +++ b/tests/units/test_ssh_keys.py @@ -1,6 +1,9 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Test the SSH Keys functionality.""" -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest @@ -10,7 +13,9 @@ InvalidSSHKeyIDError, ) from pytfe.models.ssh_key import ( + SSHKey, SSHKeyCreateOptions, + SSHKeyListOptions, SSHKeyUpdateOptions, ) from pytfe.resources.ssh_keys import SSHKeys @@ -51,7 +56,8 @@ def ssh_keys_service(self): def test_list_ssh_keys_invalid_org(self, ssh_keys_service): """Test listing SSH keys with invalid organization.""" with pytest.raises(InvalidOrgError): - ssh_keys_service.list("") + # Need to consume the iterator to trigger the error + list(ssh_keys_service.list("")) def test_create_ssh_key_invalid_org(self, ssh_keys_service): """Test creating SSH key with invalid organization.""" @@ -74,3 +80,61 @@ def test_delete_ssh_key_invalid_id(self, ssh_keys_service): """Test deleting SSH key with invalid ID.""" with pytest.raises(InvalidSSHKeyIDError): ssh_keys_service.delete("") + + def test_list_ssh_keys_success(self, ssh_keys_service): + """Test successful list operation with iterator.""" + mock_list_data = [ + { + "id": "sshkey-123", + "attributes": { + "name": "Test SSH Key 1", + }, + }, + { + "id": "sshkey-456", + "attributes": { + "name": "Test SSH Key 2", + }, + }, + ] + + with patch.object(ssh_keys_service, "_list") as mock_list: + mock_list.return_value = mock_list_data + + # Test with options + options = SSHKeyListOptions(page_number=1, page_size=5) + result = list(ssh_keys_service.list("test-org", options)) + + # Verify _list was called with correct path + assert mock_list.call_count == 1 + call_args = mock_list.call_args + assert call_args[0][0] == "/api/v2/organizations/test-org/ssh-keys" + + # Verify params structure includes pagination and options + params = call_args[1]["params"] + assert "page[number]" in params + assert "page[size]" in params + + # Verify result structure - iterator yields SSHKey objects + assert len(result) == 2 + + # Verify SSH key objects were created correctly from response data + key1 = result[0] + assert isinstance(key1, SSHKey) + assert key1.id == "sshkey-123" + assert key1.name == "Test SSH Key 1" + + key2 = result[1] + assert isinstance(key2, SSHKey) + assert key2.id == "sshkey-456" + assert key2.name == "Test SSH Key 2" + + def test_list_ssh_keys_empty(self, ssh_keys_service): + """Test list operation returning empty results.""" + with patch.object(ssh_keys_service, "_list") as mock_list: + mock_list.return_value = [] + + result = list(ssh_keys_service.list("test-org")) + + assert len(result) == 0 + mock_list.assert_called_once() diff --git a/tests/units/test_state_version.py b/tests/units/test_state_version.py new file mode 100644 index 0000000..11f67c8 --- /dev/null +++ b/tests/units/test_state_version.py @@ -0,0 +1,315 @@ +"""Unit tests for the state_versions module.""" + +from unittest.mock import Mock, patch + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import NotFound +from pytfe.models.state_version import ( + StateVersion, + StateVersionCreateOptions, + StateVersionCurrentOptions, + StateVersionIncludeOpt, + StateVersionListOptions, + StateVersionReadOptions, + StateVersionStatus, +) +from pytfe.models.state_version_output import StateVersionOutputsListOptions +from pytfe.resources.state_versions import StateVersions + + +class TestStateVersions: + """Test the StateVersions service class.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock HTTPTransport.""" + return Mock(spec=HTTPTransport) + + @pytest.fixture + def state_versions_service(self, mock_transport): + """Create a StateVersions service with mocked transport.""" + return StateVersions(mock_transport) + + def test_list_state_versions_iterator(self, state_versions_service): + """Test list() returns an iterator of StateVersion models.""" + mock_items = [ + { + "id": "sv-1", + "type": "state-versions", + "attributes": { + "created-at": "2024-01-01T00:00:00Z", + "serial": 9, + "state-version": 4, + "status": "finalized", + "hosted-state-download-url": "https://example.com/download-1", + "hosted-json-state-download-url": "https://example.com/json-download-1", + "resources-processed": True, + "terraform-version": "1.7.5", + }, + "relationships": { + "workspace": {"data": {"id": "ws-123", "type": "workspaces"}}, + "run": {"data": {"id": "run-123", "type": "runs"}}, + }, + "links": {"self": "/api/v2/state-versions/sv-1"}, + }, + { + "id": "sv-2", + "type": "state-versions", + "attributes": { + "created-at": "2024-01-02T00:00:00Z", + "serial": 10, + "state-version": 4, + "status": "pending", + }, + "relationships": { + "workspace": {"data": {"id": "ws-123", "type": "workspaces"}}, + }, + "links": {"self": "/api/v2/state-versions/sv-2"}, + }, + ] + + with patch.object(state_versions_service, "_list") as mock_list: + mock_list.return_value = mock_items + + options = StateVersionListOptions( + page_size=2, + organization="demo-org", + workspace="demo-ws", + ) + result = list(state_versions_service.list(options)) + + mock_list.assert_called_once() + call_args = mock_list.call_args + assert call_args[0][0].startswith("/api/v2/state-versions?") + params = call_args[1]["params"] + assert params["page[size]"] == 2 + assert params["filter[organization][name]"] == "demo-org" + assert params["filter[workspace][name]"] == "demo-ws" + + assert len(result) == 2 + assert all(isinstance(item, StateVersion) for item in result) + assert result[0].id == "sv-1" + assert result[0].status == StateVersionStatus.FINALIZED + assert result[1].id == "sv-2" + assert result[1].status == StateVersionStatus.PENDING + + def test_read_state_version_invalid_id(self, state_versions_service): + """Test read() with invalid state version id.""" + with pytest.raises(ValueError, match="invalid state version id"): + state_versions_service.read("") + + def test_read_state_version_success(self, state_versions_service, mock_transport): + """Test successful read() operation.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "id": "sv-read-1", + "type": "state-versions", + "attributes": { + "created-at": "2024-01-01T00:00:00Z", + "serial": 9, + "state-version": 4, + "status": "finalized", + "hosted-state-download-url": "https://example.com/download", + "resources-processed": True, + }, + "relationships": { + "workspace": {"data": {"id": "ws-123", "type": "workspaces"}}, + "run": {"data": {"id": "run-123", "type": "runs"}}, + }, + "links": {"self": "/api/v2/state-versions/sv-read-1"}, + } + } + mock_transport.request.return_value = mock_response + + result = state_versions_service.read("sv-read-1") + + mock_transport.request.assert_called_once_with( + "GET", "/api/v2/state-versions/sv-read-1" + ) + assert result.id == "sv-read-1" + assert result.status == StateVersionStatus.FINALIZED + assert result.hosted_state_download_url == "https://example.com/download" + + def test_read_with_options_success(self, state_versions_service, mock_transport): + """Test successful read_with_options() operation.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "id": "sv-read-2", + "type": "state-versions", + "attributes": { + "created-at": "2024-01-01T00:00:00Z", + "status": "pending", + }, + "relationships": { + "outputs": { + "data": [{"id": "wsout-1", "type": "state-version-outputs"}] + } + }, + } + } + mock_transport.request.return_value = mock_response + + options = StateVersionReadOptions( + include=[StateVersionIncludeOpt.OUTPUTS, StateVersionIncludeOpt.RUN] + ) + result = state_versions_service.read_with_options("sv-read-2", options) + + mock_transport.request.assert_called_once_with( + "GET", + "/api/v2/state-versions/sv-read-2", + params={"include": "outputs,run"}, + ) + assert result.id == "sv-read-2" + assert result.status == StateVersionStatus.PENDING + + def test_read_current_with_options_success( + self, state_versions_service, mock_transport + ): + """Test successful read_current_with_options() operation. + + Mock payload shape follows docs sample for: + GET /api/v2/workspaces/:workspace_id/current-state-version + """ + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "id": "sv-current-1", + "type": "state-versions", + "attributes": { + "created-at": "2024-01-01T00:00:00Z", + "serial": 9, + "status": "finalized", + "resources-processed": True, + }, + "relationships": { + "workspace": {"data": {"id": "ws-123", "type": "workspaces"}}, + "created-by": {"data": {"id": "user-123", "type": "users"}}, + }, + } + } + mock_transport.request.return_value = mock_response + + options = StateVersionCurrentOptions( + include=[StateVersionIncludeOpt.CREATED_BY] + ) + result = state_versions_service.read_current_with_options("ws-123", options) + + mock_transport.request.assert_called_once_with( + "GET", + "/api/v2/workspaces/ws-123/current-state-version", + params={"include": "created_by"}, + ) + assert result.id == "sv-current-1" + + def test_create_state_version_success(self, state_versions_service, mock_transport): + """Test successful create() operation.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "id": "sv-new-1", + "type": "state-versions", + "attributes": { + "created-at": "2024-01-01T00:00:00Z", + "status": "pending", + "hosted-state-upload-url": "https://example.com/upload", + "hosted-json-state-upload-url": "https://example.com/json-upload", + "serial": 10, + }, + "links": {"self": "/api/v2/state-versions/sv-new-1"}, + } + } + mock_transport.request.return_value = mock_response + + options = StateVersionCreateOptions(serial=10, md5="abc123") + + with patch.object( + state_versions_service, "_resolve_workspace_id", return_value="ws-123" + ): + result = state_versions_service.create("my-workspace", options) + + mock_transport.request.assert_called_once_with( + "POST", + "/api/v2/workspaces/ws-123/state-versions", + json_body={ + "data": { + "type": "state-versions", + "attributes": { + "serial": 10, + "md5": "abc123", + }, + } + }, + ) + assert result.id == "sv-new-1" + assert result.status == StateVersionStatus.PENDING + + def test_download_state_version_not_found_when_url_missing( + self, state_versions_service + ): + """Test download() raises NotFound if signed download URL is missing.""" + sv = StateVersion( + id="sv-404", + created_at="2024-01-01T00:00:00Z", + status=StateVersionStatus.FINALIZED, + hosted_state_download_url=None, + ) + + with patch.object(state_versions_service, "read", return_value=sv): + with pytest.raises(NotFound, match="download url not available"): + state_versions_service.download("sv-404") + + def test_download_state_version_success( + self, state_versions_service, mock_transport + ): + """Test successful download() operation using signed URL.""" + sv = StateVersion( + id="sv-dl-1", + created_at="2024-01-01T00:00:00Z", + status=StateVersionStatus.FINALIZED, + hosted_state_download_url="https://example.com/signed-download", + ) + mock_response = Mock() + mock_response.content = b"{}" + mock_transport.request.return_value = mock_response + + with patch.object(state_versions_service, "read", return_value=sv): + result = state_versions_service.download("sv-dl-1") + + mock_transport.request.assert_called_once_with( + "GET", + "https://example.com/signed-download", + allow_redirects=True, + headers={"Accept": "application/json"}, + ) + assert result == b"{}" + + def test_list_outputs_success(self, state_versions_service): + """Test successful list_outputs() iterator operation.""" + mock_items = [ + { + "id": "wsout-1", + "attributes": { + "name": "vpc_id", + "sensitive": False, + "type": "string", + "value": "vpc-123", + }, + } + ] + + with patch.object(state_versions_service, "_list") as mock_list: + mock_list.return_value = mock_items + + options = StateVersionOutputsListOptions(page_size=5) + result = list(state_versions_service.list_outputs("sv-outputs-1", options)) + + mock_list.assert_called_once_with( + "/api/v2/state-versions/sv-outputs-1/outputs", + params={"page[size]": 5}, + ) + assert len(result) == 1 + assert result[0].id == "wsout-1" diff --git a/tests/units/test_transport.py b/tests/units/test_transport.py index 3d4fac3..40996b4 100644 --- a/tests/units/test_transport.py +++ b/tests/units/test_transport.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + from pytfe._http import HTTPTransport from pytfe.config import TFEConfig diff --git a/tests/units/test_variable_sets.py b/tests/units/test_variable_sets.py index 01ad868..efaa6ad 100644 --- a/tests/units/test_variable_sets.py +++ b/tests/units/test_variable_sets.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Unit tests for Variable Set resources.""" from unittest.mock import Mock @@ -72,7 +75,7 @@ def test_list_variable_sets_success(self): self.mock_transport.request.return_value = mock_response # Call the method - result = self.variable_sets_service.list(self.org_name) + result = list(self.variable_sets_service.list(self.org_name)) # Assertions assert len(result) == 1 @@ -85,7 +88,9 @@ def test_list_variable_sets_success(self): # Verify API call self.mock_transport.request.assert_called_once_with( - "GET", f"/api/v2/organizations/{self.org_name}/varsets", params={} + "GET", + f"/api/v2/organizations/{self.org_name}/varsets", + params={"page[number]": 1, "page[size]": 100}, ) def test_list_variable_sets_with_options(self): @@ -97,21 +102,20 @@ def test_list_variable_sets_with_options(self): # Create options options = VariableSetListOptions( - page_number=2, page_size=50, query="test", include=[VariableSetIncludeOpt.WORKSPACES, VariableSetIncludeOpt.PROJECTS], ) # Call the method - result = self.variable_sets_service.list(self.org_name, options) + result = list(self.variable_sets_service.list(self.org_name, options)) # Verify the result assert isinstance(result, list) # Verify API call with parameters expected_params = { - "page[number]": "2", + "page[number]": 1, "page[size]": "50", "q": "test", "include": "workspaces,projects", @@ -125,10 +129,10 @@ def test_list_variable_sets_with_options(self): def test_list_variable_sets_invalid_organization(self): """Test listing variable sets with invalid organization.""" with pytest.raises(ValueError, match="Organization name is required"): - self.variable_sets_service.list("") + list(self.variable_sets_service.list("")) with pytest.raises(ValueError, match="Organization name is required"): - self.variable_sets_service.list(None) + list(self.variable_sets_service.list(None)) def test_list_for_workspace_success(self): """Test successful listing of variable sets for workspace.""" @@ -152,7 +156,7 @@ def test_list_for_workspace_success(self): self.mock_transport.request.return_value = mock_response # Call the method - result = self.variable_sets_service.list_for_workspace(self.workspace_id) + result = list(self.variable_sets_service.list_for_workspace(self.workspace_id)) # Assertions assert len(result) == 1 @@ -160,13 +164,15 @@ def test_list_for_workspace_success(self): # Verify API call self.mock_transport.request.assert_called_once_with( - "GET", f"/api/v2/workspaces/{self.workspace_id}/varsets", params={} + "GET", + f"/api/v2/workspaces/{self.workspace_id}/varsets", + params={"page[number]": 1, "page[size]": 100}, ) def test_list_for_workspace_invalid_id(self): """Test listing for workspace with invalid workspace ID.""" with pytest.raises(ValueError, match="Workspace ID is required"): - self.variable_sets_service.list_for_workspace("") + list(self.variable_sets_service.list_for_workspace("")) def test_list_for_project_success(self): """Test successful listing of variable sets for project.""" @@ -190,7 +196,7 @@ def test_list_for_project_success(self): self.mock_transport.request.return_value = mock_response # Call the method - result = self.variable_sets_service.list_for_project(self.project_id) + result = list(self.variable_sets_service.list_for_project(self.project_id)) # Assertions assert len(result) == 1 @@ -198,13 +204,15 @@ def test_list_for_project_success(self): # Verify API call self.mock_transport.request.assert_called_once_with( - "GET", f"/api/v2/projects/{self.project_id}/varsets", params={} + "GET", + f"/api/v2/projects/{self.project_id}/varsets", + params={"page[number]": 1, "page[size]": 100}, ) def test_list_for_project_invalid_id(self): """Test listing for project with invalid project ID.""" with pytest.raises(ValueError, match="Project ID is required"): - self.variable_sets_service.list_for_project("") + list(self.variable_sets_service.list_for_project("")) def test_create_variable_set_success(self): """Test successful variable set creation.""" @@ -731,7 +739,7 @@ def test_list_variables_success(self): self.mock_transport.request.return_value = mock_response # Call the method - result = self.variables_service.list(self.variable_set_id) + result = list(self.variables_service.list(self.variable_set_id)) # Assertions assert len(result) == 1 @@ -748,13 +756,13 @@ def test_list_variables_success(self): self.mock_transport.request.assert_called_once_with( "GET", f"/api/v2/varsets/{self.variable_set_id}/relationships/vars", - params={}, + params={"page[number]": 1, "page[size]": 100}, ) def test_list_variables_invalid_varset_id(self): """Test listing variables with invalid variable set ID.""" with pytest.raises(ValueError, match="Variable set ID is required"): - self.variables_service.list("") + list(self.variables_service.list("")) def test_create_variable_success(self): """Test successful variable creation.""" diff --git a/tests/units/test_workspace_resources.py b/tests/units/test_workspace_resources.py index f685d84..28909b7 100644 --- a/tests/units/test_workspace_resources.py +++ b/tests/units/test_workspace_resources.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Unit tests for workspace resources service.""" from unittest.mock import Mock @@ -143,7 +146,7 @@ def test_list_workspace_resources_with_options( mock_transport.request.return_value = mock_response # Create options - options = WorkspaceResourceListOptions(page_number=2, page_size=50) + options = WorkspaceResourceListOptions(page_number=1, page_size=50) # Call the service result = list(service.list("ws-abc123", options)) @@ -152,7 +155,7 @@ def test_list_workspace_resources_with_options( mock_transport.request.assert_called_once_with( "GET", "/api/v2/workspaces/ws-abc123/resources", - params={"page[number]": 2, "page[size]": 50}, + params={"page[number]": 1, "page[size]": 50}, ) # Verify response diff --git a/tests/units/test_workspaces.py b/tests/units/test_workspaces.py index 762b97f..5c93f03 100644 --- a/tests/units/test_workspaces.py +++ b/tests/units/test_workspaces.py @@ -1,3 +1,6 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """ Comprehensive unit tests for workspace operations in the Python TFE SDK. @@ -33,7 +36,7 @@ ) from src.pytfe.models.project import Project from src.pytfe.models.workspace import ( - VCSRepo, + VCSRepoOptions, Workspace, WorkspaceAddRemoteStateConsumersOptions, WorkspaceAddTagBindingsOptions, @@ -304,7 +307,7 @@ def test_create_workspace_with_vcs( sample_workspace_response ) - vcs_repo = VCSRepo( + vcs_repo = VCSRepoOptions( identifier="myorg/myrepo", branch="main", oauth_token_id="ot-123456", @@ -316,7 +319,6 @@ def test_create_workspace_with_vcs( name="vcs-workspace", vcs_repo=vcs_repo, working_directory="terraform/", - # Remove trigger_prefixes to avoid conflict with tags_regex ) workspace = workspaces_service.create("test-org", options=options) @@ -339,7 +341,9 @@ def test_create_workspace_with_project( sample_workspace_response ) - project = Project(id="prj-123", name="Test Project", organization="test-org") + project = Project( + id="prj-123", + ) options = WorkspaceCreateOptions(name="project-workspace", project=project) @@ -611,11 +615,11 @@ def test_unassign_ssh_key( def test_ws_from_conversion(self, sample_workspace_response): """Test _ws_from helper function conversion.""" workspace_data = sample_workspace_response["data"] - workspace = _ws_from(workspace_data, "test-org") + workspace = _ws_from(workspace_data) assert workspace.id == "ws-abc123def456" assert workspace.name == "test-workspace" - assert workspace.organization == "test-org" + assert workspace.organization is None assert workspace.auto_apply assert workspace.execution_mode == ExecutionMode.REMOTE assert workspace.resource_count == 25 @@ -633,11 +637,11 @@ def test_ws_from_minimal_data(self): """Test _ws_from with minimal data.""" minimal_data = {"id": "ws-minimal", "attributes": {"name": "minimal-workspace"}} - workspace = _ws_from(minimal_data, "test-org") + workspace = _ws_from(minimal_data) assert workspace.id == "ws-minimal" assert workspace.name == "minimal-workspace" - assert workspace.organization == "test-org" + assert workspace.organization is None assert not workspace.auto_apply # Default value assert not workspace.locked # Default value @@ -676,11 +680,11 @@ def test_none_values_handling(self): }, } - workspace = _ws_from(data_with_nones, "test-org") + workspace = _ws_from(data_with_nones) - assert workspace.description == "" # Should convert None to empty string - assert workspace.terraform_version == "" - assert workspace.working_directory == "" + assert workspace.description is None # None values are preserved + assert workspace.terraform_version is None + assert workspace.working_directory is None assert workspace.vcs_repo is None # ========================================== @@ -751,21 +755,20 @@ def test_list_remote_state_consumers_with_pagination( """Test remote state consumers listing with pagination options.""" mock_transport.request.return_value.json.return_value = {"data": []} - options = WorkspaceListRemoteStateConsumersOptions(page_number=2, page_size=5) + options = WorkspaceListRemoteStateConsumersOptions(page_size=5) list(workspaces_service.list_remote_state_consumers("ws-123", options)) # Verify pagination parameters were passed call_args = mock_transport.request.call_args params = call_args[1]["params"] - assert params["page[number]"] == 2 assert params["page[size]"] == 5 def test_add_remote_state_consumers_basic(self, workspaces_service, mock_transport): """Test adding remote state consumers.""" consumer_workspaces = [ - Workspace(id="ws-consumer-1", name="consumer-1", organization="test-org"), - Workspace(id="ws-consumer-2", name="consumer-2", organization="test-org"), + Workspace(id="ws-consumer-1", name="consumer-1", organization=None), + Workspace(id="ws-consumer-2", name="consumer-2", organization=None), ] options = WorkspaceAddRemoteStateConsumersOptions( @@ -806,7 +809,7 @@ def test_add_remote_state_consumers_validation_errors(self, workspaces_service): # Test invalid workspace ID format (with slash) options = WorkspaceAddRemoteStateConsumersOptions( - workspaces=[Workspace(id="ws-valid", name="valid", organization="test-org")] + workspaces=[Workspace(id="ws-valid", name="valid", organization=None)] ) with pytest.raises(InvalidWorkspaceIDError): @@ -817,7 +820,7 @@ def test_remove_remote_state_consumers_basic( ): """Test removing remote state consumers.""" consumer_workspaces = [ - Workspace(id="ws-consumer-1", name="consumer-1", organization="test-org"), + Workspace(id="ws-consumer-1", name="consumer-1", organization=None), ] options = WorkspaceRemoveRemoteStateConsumersOptions( @@ -844,8 +847,8 @@ def test_update_remote_state_consumers_basic( ): """Test updating (replacing) remote state consumers.""" consumer_workspaces = [ - Workspace(id="ws-consumer-3", name="consumer-3", organization="test-org"), - Workspace(id="ws-consumer-4", name="consumer-4", organization="test-org"), + Workspace(id="ws-consumer-3", name="consumer-3", organization=None), + Workspace(id="ws-consumer-4", name="consumer-4", organization=None), ] options = WorkspaceUpdateRemoteStateConsumersOptions( @@ -921,7 +924,7 @@ def test_list_tags_with_query_and_pagination( """Test tag listing with query and pagination options.""" mock_transport.request.return_value.json.return_value = {"data": []} - options = WorkspaceTagListOptions(query="env", page_number=2, page_size=5) + options = WorkspaceTagListOptions(query="env", page_size=5) list(workspaces_service.list_tags("ws-123", options)) @@ -929,7 +932,6 @@ def test_list_tags_with_query_and_pagination( call_args = mock_transport.request.call_args params = call_args[1]["params"] assert params["name"] == "env" - assert params["page[number]"] == 2 assert params["page[size]"] == 5 def test_add_tags_basic(self, workspaces_service, mock_transport):