From 25bf170b7d2545028534406773d2d1e9be82f6cd Mon Sep 17 00:00:00 2001 From: aayushsingh2502 Date: Wed, 10 Sep 2025 15:55:14 +0530 Subject: [PATCH] org functions update --- examples/org.py | 318 ++++++++++++++++++++++++++ examples/ws_list.py | 12 - src/tfe/errors.py | 13 ++ src/tfe/resources/organizations.py | 346 ++++++++++++++++++++++++++++- src/tfe/types.py | 188 +++++++++++++++- src/tfe/utils.py | 11 + 6 files changed, 866 insertions(+), 22 deletions(-) create mode 100644 examples/org.py delete mode 100644 examples/ws_list.py diff --git a/examples/org.py b/examples/org.py new file mode 100644 index 00000000..76364507 --- /dev/null +++ b/examples/org.py @@ -0,0 +1,318 @@ +from tfe import TFEClient, TFEConfig +from tfe.types import ( + DataRetentionPolicyDeleteOlderSetOptions, + DataRetentionPolicyDontDeleteSetOptions, + OrganizationCreateOptions, + ReadRunQueueOptions, +) + + +def test_basic_org_operations(client): + """Test basic organization CRUD operations.""" + print("=== Testing Basic Organization Operations ===") + + # List organizations + print("\n1. Listing Organizations:") + try: + org_list = client.organizations.list() + orgs = list(org_list) + print(f" ✓ Found {len(orgs)} organizations") + + # Show first few organizations + for i, org in enumerate(orgs[:5], 1): + print(f" {i:2d}. {org.name} (ID: {org.id})") + if org.email: + print(f" Email: {org.email}") + + if len(orgs) > 5: + print(f" ... and {len(orgs) - 5} more") + + return orgs[0].name if orgs else None # Return first org name for testing + + except Exception as e: + print(f" ✗ Error listing organizations: {e}") + return None + + +def test_org_read_operations(client, org_name): + """Test organization read operations.""" + print(f"\n=== Testing Organization Read Operations for '{org_name}' ===") + + # Read organization details + print("\n1. Reading Organization Details:") + try: + org = client.organizations.read(org_name) + print(f" ✓ Organization: {org.name}") + print(f" ID: {org.id}") + print(f" Email: {org.email or 'Not set'}") + print(f" Created: {org.created_at or 'Unknown'}") + print(f" Execution Mode: {org.default_execution_mode or 'Not set'}") + print(f" Two-Factor: {org.two_factor_conformant}") + except Exception as e: + print(f" ✗ Error reading organization: {e}") + + # Test capacity + print("\n2. Reading Organization Capacity:") + try: + capacity = client.organizations.read_capacity(org_name) + print(" ✓ Capacity:") + print(f" Pending runs: {capacity.pending}") + print(f" Running runs: {capacity.running}") + print(f" Total active: {capacity.pending + capacity.running}") + except Exception as e: + print(f" ✗ Error reading capacity: {e}") + + # Test entitlements + print("\n3. Reading Organization Entitlements:") + try: + entitlements = client.organizations.read_entitlements(org_name) + print(" ✓ Entitlements:") + print(f" Operations: {entitlements.operations}") + print(f" Teams: {entitlements.teams}") + print(f" State Storage: {entitlements.state_storage}") + print(f" VCS Integrations: {entitlements.vcs_integrations}") + print(f" Cost Estimation: {entitlements.cost_estimation}") + print(f" Sentinel: {entitlements.sentinel}") + print(f" Private Module Registry: {entitlements.private_module_registry}") + print(f" SSO: {entitlements.sso}") + except Exception as e: + print(f" ✗ Error reading entitlements: {e}") + + # Test run queue + print("\n4. Reading Organization Run Queue:") + try: + queue_options = ReadRunQueueOptions(page_number=1, page_size=10) + run_queue = client.organizations.read_run_queue(org_name, queue_options) + print(" ✓ Run Queue:") + print(f" Items in queue: {len(run_queue.items)}") + + if run_queue.pagination: + print(f" Current page: {run_queue.pagination.current_page}") + print(f" Total count: {run_queue.pagination.total_count}") + + # Show details of first few runs + for i, run in enumerate(run_queue.items[:3], 1): + print(f" Run {i}: ID={run.id}, Status={run.status}") + + if len(run_queue.items) > 3: + print(f" ... and {len(run_queue.items) - 3} more runs") + + except Exception as e: + print(f" ✗ Error reading run queue: {e}") + + +def test_data_retention_policies(client, org_name): + """Test data retention policy operations.""" + print(f"\n=== Testing Data Retention Policy Operations for '{org_name}' ===") + print("Note: These functions are only available in Terraform Enterprise") + + # Test reading current policy + print("\n1. Reading Current Data Retention Policy:") + try: + policy_choice = client.organizations.read_data_retention_policy_choice(org_name) + if policy_choice is None: + print(" ✓ No data retention policy currently configured") + elif policy_choice.data_retention_policy_delete_older: + policy = policy_choice.data_retention_policy_delete_older + print( + f" ✓ Delete Older Policy: {policy.delete_older_than_n_days} days (ID: {policy.id})" + ) + elif policy_choice.data_retention_policy_dont_delete: + policy = policy_choice.data_retention_policy_dont_delete + print(f" ✓ Don't Delete Policy (ID: {policy.id})") + elif policy_choice.data_retention_policy: + policy = policy_choice.data_retention_policy + print( + f" ✓ Legacy Policy: {policy.delete_older_than_n_days} days (ID: {policy.id})" + ) + except Exception as e: + if "not found" in str(e).lower() or "404" in str(e): + print( + " ⚠ Data retention policies not available (Terraform Enterprise feature)" + ) + else: + print(f" ✗ Error reading data retention policy: {e}") + + # Test setting delete older policy + print("\n2. Setting Delete Older Data Retention Policy (30 days):") + try: + options = DataRetentionPolicyDeleteOlderSetOptions(delete_older_than_n_days=30) + policy = client.organizations.set_data_retention_policy_delete_older( + org_name, options + ) + print(" ✓ Created Delete Older Policy:") + print(f" ID: {policy.id}") + print(f" Delete after: {policy.delete_older_than_n_days} days") + except Exception as e: + if "not found" in str(e).lower() or "404" in str(e): + print(" ⚠ Feature not available (Terraform Enterprise only)") + else: + print(f" ✗ Error setting delete older policy: {e}") + + # Test updating delete older policy + print("\n3. Updating Delete Older Policy (15 days):") + try: + options = DataRetentionPolicyDeleteOlderSetOptions(delete_older_than_n_days=15) + policy = client.organizations.set_data_retention_policy_delete_older( + org_name, options + ) + print(" ✓ Updated Delete Older Policy:") + print(f" ID: {policy.id}") + print(f" Delete after: {policy.delete_older_than_n_days} days") + except Exception as e: + if "not found" in str(e).lower() or "404" in str(e): + print(" ⚠ Feature not available (Terraform Enterprise only)") + else: + print(f" ✗ Error updating delete older policy: {e}") + + # Test setting don't delete policy + print("\n4. Setting Don't Delete Data Retention Policy:") + try: + options = DataRetentionPolicyDontDeleteSetOptions() + policy = client.organizations.set_data_retention_policy_dont_delete( + org_name, options + ) + print(" ✓ Created Don't Delete Policy:") + print(f" ID: {policy.id}") + print(" Data will never be automatically deleted") + except Exception as e: + if "not found" in str(e).lower() or "404" in str(e): + print(" ⚠ Feature not available (Terraform Enterprise only)") + else: + print(f" ✗ Error setting don't delete policy: {e}") + + # Test reading policy after changes + print("\n5. Reading Data Retention Policy After Changes:") + try: + policy_choice = client.organizations.read_data_retention_policy_choice(org_name) + if policy_choice is None: + print(" ✓ No data retention policy configured") + elif policy_choice.data_retention_policy_delete_older: + policy = policy_choice.data_retention_policy_delete_older + print( + f" ✓ Current Policy: Delete Older ({policy.delete_older_than_n_days} days)" + ) + elif policy_choice.data_retention_policy_dont_delete: + print(" ✓ Current Policy: Don't Delete") + + # Test legacy conversion + if policy_choice and policy_choice.is_populated(): + legacy = policy_choice.convert_to_legacy_struct() + if legacy: + print( + f" ✓ Legacy representation: {legacy.delete_older_than_n_days} days" + ) + except Exception as e: + if "not found" in str(e).lower() or "404" in str(e): + print(" ⚠ Feature not available (Terraform Enterprise only)") + else: + print(f" ✗ Error reading updated policy: {e}") + + # Test deleting policy + print("\n6. Deleting Data Retention Policy:") + try: + client.organizations.delete_data_retention_policy(org_name) + print(" ✓ Successfully deleted data retention policy") + + # Verify deletion + policy_choice = client.organizations.read_data_retention_policy_choice(org_name) + if policy_choice is None or not policy_choice.is_populated(): + print(" ✓ Verified: No policy configured after deletion") + else: + print(" ⚠ Policy still exists after deletion attempt") + except Exception as e: + if "not found" in str(e).lower() or "404" in str(e): + print(" ⚠ Feature not available (Terraform Enterprise only)") + else: + print(f" ✗ Error deleting policy: {e}") + + +def test_organization_creation_and_cleanup(client): + """Test organization creation and cleanup (if permissions allow).""" + print("\n=== Testing Organization Creation (Optional) ===") + + test_org_name = f"python-tfe-test-{int(__import__('time').time())}" + + try: + print(f"\n1. Creating Test Organization '{test_org_name}':") + create_opts = OrganizationCreateOptions( + name=test_org_name, email="aayush.singh@hashicorp.com" + ) + new_org = client.organizations.create(create_opts) + print(f" ✓ Created organization: {new_org.name}") + print(f" ID: {new_org.id}") + print(f" Email: {new_org.email}") + + # Test reading the newly created org + print("\n2. Reading Newly Created Organization:") + read_org = client.organizations.read(test_org_name) + print(f" ✓ Successfully read organization: {read_org.name}") + + # Cleanup + print("\n3. Cleaning Up Test Organization:") + client.organizations.delete(test_org_name) + print(" ✓ Successfully deleted test organization") + + return True + + except Exception as e: + print(f" ⚠ Organization creation/deletion test skipped: {e}") + print( + " This is normal if you don't have organization management permissions" + ) + return False + + +def main(): + """Main function to test all organization functionalities.""" + print("🚀 Python TFE Organization Functions Test Suite") + print("=" * 60) + + # Initialize client + try: + client = TFEClient(TFEConfig.from_env()) + print("✓ TFE Client initialized successfully") + except Exception as e: + print(f"✗ Failed to initialize TFE client: {e}") + print( + "Please ensure TF_CLOUD_ORGANIZATION and TF_CLOUD_TOKEN environment variables are set" + ) + return 1 + + # Test basic operations + test_org_name = test_basic_org_operations(client) + if not test_org_name: + print("\n✗ Cannot continue without a valid organization") + return 1 + + # Test read operations + test_org_read_operations(client, test_org_name) + + # # Test data retention policies + # test_data_retention_policies(client, test_org_name) + + # Test organization creation (if permissions allow) + creation_success = test_organization_creation_and_cleanup(client) + + # Summary + print("\n" + "=" * 60) + print("📊 Test Summary:") + print("✓ Basic organization operations tested") + print("✓ Organization read operations tested") + print("✓ Data retention policy operations tested") + if creation_success: + print("✓ Organization creation/deletion tested") + else: + print("⚠ Organization creation/deletion skipped (permissions)") + + print( + f"\n🎯 All available organization functions have been tested against '{test_org_name}'" + ) + print("Note: Data retention policy features require Terraform Enterprise") + print("\n✅ Test suite completed successfully!") + + return 0 + + +if __name__ == "__main__": + exit(main()) diff --git a/examples/ws_list.py b/examples/ws_list.py deleted file mode 100644 index 159fa3e1..00000000 --- a/examples/ws_list.py +++ /dev/null @@ -1,12 +0,0 @@ -from tfe import TFEClient, TFEConfig - - -def main(): - client = TFEClient(TFEConfig.from_env()) - org = "tfe-xxxxx" - for ws in client.workspaces.list(org): - print("WS:", ws.name, ws.id) - - -if __name__ == "__main__": - main() diff --git a/src/tfe/errors.py b/src/tfe/errors.py index 6742980d..e524f321 100644 --- a/src/tfe/errors.py +++ b/src/tfe/errors.py @@ -44,3 +44,16 @@ class UnsupportedInCloud(TFEError): ... class UnsupportedInEnterprise(TFEError): ... + + +class InvalidValues(TFEError): ... + + +class RequiredFieldMissing(TFEError): ... + + +# Error message constants +ERR_INVALID_NAME = "invalid value for name" +ERR_REQUIRED_NAME = "name is required" +ERR_INVALID_ORG = "invalid organization name" +ERR_REQUIRED_EMAIL = "email is required" diff --git a/src/tfe/resources/organizations.py b/src/tfe/resources/organizations.py index fde1603c..95ec83e3 100644 --- a/src/tfe/resources/organizations.py +++ b/src/tfe/resources/organizations.py @@ -3,7 +3,29 @@ from collections.abc import Iterator from typing import Any -from ..types import Organization +from ..errors import ( + ERR_INVALID_NAME, + ERR_INVALID_ORG, + ERR_REQUIRED_EMAIL, + ERR_REQUIRED_NAME, +) +from ..types import ( + Capacity, + DataRetentionPolicy, + DataRetentionPolicyChoice, + DataRetentionPolicyDeleteOlder, + DataRetentionPolicyDeleteOlderSetOptions, + DataRetentionPolicyDontDelete, + DataRetentionPolicyDontDeleteSetOptions, + DataRetentionPolicySetOptions, + Entitlements, + Organization, + OrganizationCreateOptions, + OrganizationUpdateOptions, + ReadRunQueueOptions, + RunQueue, +) +from ..utils import valid_string, valid_string_id from ._base import _Service @@ -12,19 +34,327 @@ def _safe_str(v: Any, default: str = "") -> str: class Organizations(_Service): + def delete(self, name: str) -> None: + if not valid_string_id(name): + raise ValueError(ERR_INVALID_ORG) + self.t.request("DELETE", f"/api/v2/organizations/{name}") + return None + + def update(self, name: str, options: OrganizationUpdateOptions) -> Organization: + if not valid_string_id(name): + raise ValueError(ERR_INVALID_ORG) + body = { + "data": { + "type": "organizations", + "attributes": options.model_dump(exclude_none=True), + } + } + r = self.t.request("PATCH", f"/api/v2/organizations/{name}", json_body=body) + d = r.json()["data"] + attr = d.get("attributes", {}) or {} + org_id = _safe_str(d.get("id")) + org_data = dict(attr) + org_data["id"] = org_id + return Organization(**org_data) + + def create(self, options: OrganizationCreateOptions) -> Organization: + Organizations.validate(options) + body = { + "data": { + "type": "organizations", + "attributes": options.model_dump(exclude_none=True), + } + } + r = self.t.request("POST", "/api/v2/organizations", json_body=body) + d = r.json()["data"] + attr = d.get("attributes", {}) or {} + org_id = _safe_str(d.get("id")) + org_data = dict(attr) + org_data["id"] = org_id + return Organization(**org_data) + def list(self) -> Iterator[Organization]: for item in self._list("/api/v2/organizations"): attr = item.get("attributes", {}) or {} org_id = _safe_str(item.get("id")) - name = _safe_str(attr.get("name") or item.get("id")) - email = attr.get("email") if isinstance(attr.get("email"), str) else None - yield Organization(id=org_id, name=name, email=email) + # Unpack all attributes, override id + org_data = dict(attr) + org_data["id"] = org_id + yield Organization(**org_data) - def get(self, name: str) -> Organization: + def read(self, name: str) -> Organization: r = self.t.request("GET", f"/api/v2/organizations/{name}") d = r.json()["data"] attr = d.get("attributes", {}) or {} org_id = _safe_str(d.get("id")) - org_name = _safe_str(attr.get("name") or d.get("id")) - email = attr.get("email") if isinstance(attr.get("email"), str) else None - return Organization(id=org_id, name=org_name, email=email) + # Unpack all attributes, override id + org_data = dict(attr) + org_data["id"] = org_id + return Organization(**org_data) + + @staticmethod + def validate(opts: OrganizationCreateOptions) -> None: + """Validate organization creation options.""" + if not valid_string(opts.name): + raise ValueError(ERR_REQUIRED_NAME) + if not valid_string_id(opts.name): + raise ValueError(ERR_INVALID_NAME) + if not valid_string(opts.email): + raise ValueError(ERR_REQUIRED_EMAIL) + + def read_capacity(self, organization: str) -> Capacity: + """Read the currently used capacity of an organization.""" + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + + r = self.t.request("GET", f"/api/v2/organizations/{organization}/capacity") + d = r.json()["data"] + attr = d.get("attributes", {}) or {} + + c = Capacity( + organization=_safe_str(d.get("id")), + pending=attr.get("pending", 0), + running=attr.get("running", 0), + ) + return c + + def read_entitlements(self, organization: str) -> Entitlements: + """Read the entitlements of an organization.""" + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + + r = self.t.request( + "GET", f"/api/v2/organizations/{organization}/entitlement-set" + ) + d = r.json()["data"] + attr = d.get("attributes", {}) or {} + + e = Entitlements( + id=_safe_str(d.get("id")), + agents=attr.get("agents"), + audit_logging=attr.get("audit-logging"), + cost_estimation=attr.get("cost-estimation"), + global_run_tasks=attr.get("global-run-tasks"), + operations=attr.get("operations"), + private_module_registry=attr.get("private-module-registry"), + private_run_tasks=attr.get("private-run-tasks"), + run_tasks=attr.get("run-tasks"), + sso=attr.get("sso"), + sentinel=attr.get("sentinel"), + state_storage=attr.get("state-storage"), + teams=attr.get("teams"), + vcs_integrations=attr.get("vcs-integrations"), + waypoint_actions=attr.get("waypoint-actions"), + waypoint_templates_and_addons=attr.get("waypoint-templates-and-addons"), + ) + return e + + def read_run_queue( + self, organization: str, options: ReadRunQueueOptions + ) -> RunQueue: + """Read the current run queue of an organization.""" + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + + params = {} + 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/organizations/{organization}/runs/queue", params=params + ) + data = r.json() + + from ..types import Pagination, Run, RunStatus + + runs = [] + for item in data.get("data", []): + attr = item.get("attributes", {}) or {} + run_id = _safe_str(item.get("id")) + status_str = attr.get("status", "pending") + + # Map string status to RunStatus enum, fallback to pending + try: + status = RunStatus(status_str) + except ValueError: + status = RunStatus.PLANNING # Default fallback + + runs.append(Run(id=run_id, status=status)) + + # Extract pagination info + pagination = None + meta = data.get("meta", {}) + if meta: + pagination = Pagination( + current_page=meta.get("pagination", {}).get("current-page", 1), + total_count=meta.get("pagination", {}).get("total-count", 0), + ) + + rq = RunQueue(pagination=pagination, items=runs) + return rq + + def read_data_retention_policy_choice( + self, organization: str + ) -> DataRetentionPolicyChoice | None: + """Read an organization's data retention policy choice (polymorphic). + + Note: This functionality is only available in Terraform Enterprise. + Returns None if no data retention policy is configured. + """ + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + + # First read the organization to see if it has a data retention policy + try: + org = self.read(organization) + if ( + not hasattr(org, "data_retention_policy_choice") + or org.data_retention_policy_choice is None + ): + return None + + # If there's a policy choice, fetch the full details + r = self.t.request( + "GET", + f"/api/v2/organizations/{organization}/relationships/data-retention-policy", + ) + d = r.json()["data"] + + choice = DataRetentionPolicyChoice() + + # Determine type and populate appropriate field + policy_type = d.get("type", "") + if policy_type == "data-retention-policy-delete-olders": + attr = d.get("attributes", {}) or {} + choice.data_retention_policy_delete_older = ( + DataRetentionPolicyDeleteOlder( + id=_safe_str(d.get("id")), + delete_older_than_n_days=attr.get( + "delete-older-than-n-days", 0 + ), + ) + ) + elif policy_type == "data-retention-policy-dont-deletes": + choice.data_retention_policy_dont_delete = ( + DataRetentionPolicyDontDelete(id=_safe_str(d.get("id"))) + ) + elif policy_type == "data-retention-policies": + # Legacy type for TFE v202311-1 and v202312-1 + attr = d.get("attributes", {}) or {} + choice.data_retention_policy = DataRetentionPolicy( + id=_safe_str(d.get("id")), + delete_older_than_n_days=attr.get("delete-older-than-n-days", 0), + ) + + return choice if choice.is_populated() else None + + except Exception: + # If organization read fails or policy doesn't exist, return None + return None + + def set_data_retention_policy( + self, organization: str, options: DataRetentionPolicySetOptions + ) -> DataRetentionPolicy: + """Set an organization's data retention policy. + + Deprecated: Use set_data_retention_policy_delete_older instead. + Note: This functionality is only available in Terraform Enterprise versions v202311-1 and v202312-1. + """ + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + + body = { + "data": { + "type": "data-retention-policies", + "attributes": { + "delete-older-than-n-days": options.delete_older_than_n_days + }, + } + } + + r = self.t.request( + "PATCH", + f"/api/v2/organizations/{organization}/relationships/data-retention-policy", + json_body=body, + ) + d = r.json()["data"] + attr = d.get("attributes", {}) or {} + + drp = DataRetentionPolicy( + id=_safe_str(d.get("id")), + delete_older_than_n_days=attr.get("delete-older-than-n-days", 0), + ) + return drp + + def set_data_retention_policy_delete_older( + self, organization: str, options: DataRetentionPolicyDeleteOlderSetOptions + ) -> DataRetentionPolicyDeleteOlder: + """Set an organization's data retention policy to delete data older than a certain number of days. + + Note: This functionality is only available in Terraform Enterprise. + """ + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + + body = { + "data": { + "type": "data-retention-policy-delete-olders", + "attributes": { + "delete-older-than-n-days": options.delete_older_than_n_days + }, + } + } + + r = self.t.request( + "POST", + f"/api/v2/organizations/{organization}/relationships/data-retention-policy", + json_body=body, + ) + d = r.json()["data"] + attr = d.get("attributes", {}) or {} + + drp = DataRetentionPolicyDeleteOlder( + id=_safe_str(d.get("id")), + delete_older_than_n_days=attr.get("delete-older-than-n-days", 0), + ) + return drp + + def set_data_retention_policy_dont_delete( + self, organization: str, options: DataRetentionPolicyDontDeleteSetOptions + ) -> DataRetentionPolicyDontDelete: + """Set an organization's data retention policy to explicitly not delete data. + + Note: This functionality is only available in Terraform Enterprise. + """ + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + + body = { + "data": {"type": "data-retention-policy-dont-deletes", "attributes": {}} + } + + r = self.t.request( + "POST", + f"/api/v2/organizations/{organization}/relationships/data-retention-policy", + json_body=body, + ) + d = r.json()["data"] + + drp = DataRetentionPolicyDontDelete(id=_safe_str(d.get("id"))) + return drp + + def delete_data_retention_policy(self, organization: str) -> None: + """Delete an organization's data retention policy. + + Note: This functionality is only available in Terraform Enterprise. + """ + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + + self.t.request( + "DELETE", + f"/api/v2/organizations/{organization}/relationships/data-retention-policy", + ) + return None diff --git a/src/tfe/types.py b/src/tfe/types.py index 478614eb..f5581e2b 100644 --- a/src/tfe/types.py +++ b/src/tfe/types.py @@ -1,10 +1,63 @@ from __future__ import annotations +from datetime import datetime from enum import Enum from pydantic import BaseModel, Field +class OrganizationUpdateOptions(BaseModel): + name: str | None = None + email: str | None = None + assessments_enforced: bool | None = None + collaborator_auth_policy: str | None = None + cost_estimation_enabled: bool | None = None + default_execution_mode: str | None = None + external_id: str | None = None + is_unified: bool | None = None + owners_team_saml_role_id: str | None = None + permissions: dict | None = None + saml_enabled: bool | None = None + session_remember: int | None = None + session_timeout: int | None = None + two_factor_conformant: bool | None = None + send_passing_statuses_for_untriggered_speculative_plans: bool | None = None + remaining_testable_count: int | None = None + speculative_plan_management_enabled: bool | None = None + aggregated_commit_status_enabled: bool | None = None + allow_force_delete_workspaces: bool | None = None + default_project: dict | None = None + default_agent_pool: dict | None = None + data_retention_policy: dict | None = None + data_retention_policy_choice: dict | None = None + + +class OrganizationCreateOptions(BaseModel): + name: str | None = None + email: str | None = None + assessments_enforced: bool | None = None + collaborator_auth_policy: str | None = None + cost_estimation_enabled: bool | None = None + default_execution_mode: str | None = None + external_id: str | None = None + is_unified: bool | None = None + owners_team_saml_role_id: str | None = None + permissions: dict | None = None + saml_enabled: bool | None = None + session_remember: int | None = None + session_timeout: int | None = None + two_factor_conformant: bool | None = None + send_passing_statuses_for_untriggered_speculative_plans: bool | None = None + remaining_testable_count: int | None = None + speculative_plan_management_enabled: bool | None = None + aggregated_commit_status_enabled: bool | None = None + allow_force_delete_workspaces: bool | None = None + default_project: dict | None = None + default_agent_pool: dict | None = None + data_retention_policy: dict | None = None + data_retention_policy_choice: dict | None = None + + class ExecutionMode(str, Enum): REMOTE = "remote" AGENT = "agent" @@ -20,9 +73,32 @@ class RunStatus(str, Enum): class Organization(BaseModel): - id: str - name: str + name: str | None = None + assessments_enforced: bool | None = None + collaborator_auth_policy: str | None = None + cost_estimation_enabled: bool | None = None + created_at: datetime | None = None + default_execution_mode: str | None = None email: str | None = None + external_id: str | None = None + id: str | None = None + is_unified: bool | None = None + owners_team_saml_role_id: str | None = None + permissions: dict | None = None + saml_enabled: bool | None = None + session_remember: int | None = None + session_timeout: int | None = None + trial_expires_at: datetime | None = None + two_factor_conformant: bool | None = None + send_passing_statuses_for_untriggered_speculative_plans: bool | None = None + remaining_testable_count: int | None = None + speculative_plan_management_enabled: bool | None = None + aggregated_commit_status_enabled: bool | None = None + allow_force_delete_workspaces: bool | None = None + default_project: dict | None = None + default_agent_pool: dict | None = None + data_retention_policy: dict | None = None + data_retention_policy_choice: dict | None = None class Project(BaseModel): @@ -38,3 +114,111 @@ class Workspace(BaseModel): execution_mode: ExecutionMode | None = None project_id: str | None = None tags: list[str] = Field(default_factory=list) + + +class Capacity(BaseModel): + organization: str + pending: int + running: int + + +class Entitlements(BaseModel): + id: str + agents: bool | None = None + audit_logging: bool | None = None + cost_estimation: bool | None = None + global_run_tasks: bool | None = None + operations: bool | None = None + private_module_registry: bool | None = None + private_run_tasks: bool | None = None + run_tasks: bool | None = None + sso: bool | None = None + sentinel: bool | None = None + state_storage: bool | None = None + teams: bool | None = None + vcs_integrations: bool | None = None + waypoint_actions: bool | None = None + waypoint_templates_and_addons: bool | None = None + + +class Run(BaseModel): + id: str + status: RunStatus + # Add other Run fields as needed + + +class Pagination(BaseModel): + current_page: int + total_count: int + # Add other pagination fields as needed + + +class RunQueue(BaseModel): + pagination: Pagination | None = None + items: list[Run] = Field(default_factory=list) + + +class ReadRunQueueOptions(BaseModel): + # List options for pagination + page_number: int | None = None + page_size: int | None = None + + +class DataRetentionPolicy(BaseModel): + """Deprecated: Use DataRetentionPolicyDeleteOlder instead.""" + + id: str + delete_older_than_n_days: int + + +class DataRetentionPolicyDeleteOlder(BaseModel): + id: str + delete_older_than_n_days: int + + +class DataRetentionPolicyDontDelete(BaseModel): + id: str + + +class DataRetentionPolicyChoice(BaseModel): + """Polymorphic data retention policy choice.""" + + data_retention_policy: DataRetentionPolicy | None = None + data_retention_policy_delete_older: DataRetentionPolicyDeleteOlder | None = None + data_retention_policy_dont_delete: DataRetentionPolicyDontDelete | None = None + + def is_populated(self) -> bool: + """Returns whether one of the choices is populated.""" + return ( + self.data_retention_policy is not None + or self.data_retention_policy_delete_older is not None + or self.data_retention_policy_dont_delete is not None + ) + + def convert_to_legacy_struct(self) -> DataRetentionPolicy | None: + """Convert the DataRetentionPolicyChoice to the legacy DataRetentionPolicy struct.""" + if not self.is_populated(): + return None + + if self.data_retention_policy is not None: + return self.data_retention_policy + elif self.data_retention_policy_delete_older is not None: + return DataRetentionPolicy( + id=self.data_retention_policy_delete_older.id, + delete_older_than_n_days=self.data_retention_policy_delete_older.delete_older_than_n_days, + ) + return None + + +class DataRetentionPolicySetOptions(BaseModel): + """Deprecated: Use DataRetentionPolicyDeleteOlderSetOptions instead.""" + + delete_older_than_n_days: int + + +class DataRetentionPolicyDeleteOlderSetOptions(BaseModel): + delete_older_than_n_days: int + + +class DataRetentionPolicyDontDeleteSetOptions(BaseModel): + pass # No additional fields needed diff --git a/src/tfe/utils.py b/src/tfe/utils.py index 968bb4d0..303431a4 100644 --- a/src/tfe/utils.py +++ b/src/tfe/utils.py @@ -1,8 +1,11 @@ from __future__ import annotations +import re import time from collections.abc import Callable +_STRING_ID_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{2,}$") + def poll_until( fn: Callable[[], bool], @@ -18,3 +21,11 @@ def poll_until( if timeout_s is not None and (time.time() - start) > timeout_s: raise TimeoutError("Timed out") time.sleep(interval_s) + + +def valid_string(v: str | None) -> bool: + return v is not None and str(v).strip() != "" + + +def valid_string_id(v: str | None) -> bool: + return v is not None and _STRING_ID_PATTERN.match(str(v)) is not None