From 113cbb22000d9139cf1238474c609e4f1a2c1502 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Mon, 24 Nov 2025 17:02:56 +0530 Subject: [PATCH 01/14] feat(policy set parameter): added list, create and read methods --- examples/policy_set_parameter.py | 122 +++++++++++++++++ src/pytfe/client.py | 2 + src/pytfe/errors.py | 29 ++++ src/pytfe/models/__init__.py | 13 ++ src/pytfe/models/policy_set_parameter.py | 56 ++++++++ src/pytfe/resources/policy_set_parameter.py | 138 ++++++++++++++++++++ 6 files changed, 360 insertions(+) create mode 100644 examples/policy_set_parameter.py create mode 100644 src/pytfe/models/policy_set_parameter.py create mode 100644 src/pytfe/resources/policy_set_parameter.py diff --git a/examples/policy_set_parameter.py b/examples/policy_set_parameter.py new file mode 100644 index 0000000..e202cf8 --- /dev/null +++ b/examples/policy_set_parameter.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import argparse +import os + +from pytfe import TFEClient, TFEConfig +from pytfe.models import ( + PolicySetParameterCreateOptions, + PolicySetParameterListOptions, +) + + +def _print_header(title: str): + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def main(): + parser = argparse.ArgumentParser( + description="Policy Set Parameters demo for python-tfe SDK" + ) + parser.add_argument( + "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") + ) + parser.add_argument("--token", default=os.getenv("TFE_TOKEN", "")) + parser.add_argument("--policy-set-id", required=True, help="Policy Set ID") + parser.add_argument("--page", type=int, default=1) + parser.add_argument("--page-size", type=int, default=10) + parser.add_argument("--create", action="store_true", help="Create a test parameter") + parser.add_argument("--read", action="store_true", help="Read a specific parameter") + parser.add_argument("--parameter-id", help="Parameter ID for read operation") + parser.add_argument( + "--key", default="test_param", help="Parameter key for creation" + ) + parser.add_argument( + "--value", default="test_value", help="Parameter value for creation" + ) + parser.add_argument( + "--sensitive", action="store_true", help="Mark parameter as sensitive" + ) + args = parser.parse_args() + + cfg = TFEConfig(address=args.address, token=args.token) + client = TFEClient(cfg) + + # 1) List all parameters for the policy set + _print_header(f"Listing parameters for policy set: {args.policy_set_id}") + + options = PolicySetParameterListOptions( + page_number=args.page, + page_size=args.page_size, + ) + + param_list = client.policy_set_parameters.list(args.policy_set_id, options) + + print(f"Total parameters: {param_list.total_count}") + print(f"Page {param_list.current_page} of {param_list.total_pages}") + print() + + if not param_list.items: + print("No parameters found.") + else: + for param in param_list.items: + # Sensitive parameters will have masked values + value_display = "***SENSITIVE***" if param.sensitive else param.value + print(f"- {param.id}") + print(f" Key: {param.key}") + print(f" Value: {value_display}") + print(f" Category: {param.category.value}") + print(f" Sensitive: {param.sensitive}") + print() + + # 2) Read a specific parameter (if --read flag is provided) + if args.read: + if not args.parameter_id: + print("Error: --parameter-id is required for read operation") + return + + _print_header(f"Reading parameter: {args.parameter_id}") + + param = client.policy_set_parameters.read(args.policy_set_id, args.parameter_id) + + print(f"Parameter ID: {param.id}") + print(f" Key: {param.key}") + value_display = "***SENSITIVE***" if param.sensitive else param.value + print(f" Value: {value_display}") + print(f" Category: {param.category.value}") + print(f" Sensitive: {param.sensitive}") + + # 3) Create a new parameter (if --create flag is provided) + if args.create: + _print_header(f"Creating new parameter with key: {args.key}") + + create_options = PolicySetParameterCreateOptions( + key=args.key, + value=args.value, + sensitive=args.sensitive, + ) + + new_param = client.policy_set_parameters.create( + args.policy_set_id, create_options + ) + + print(f"Created parameter: {new_param.id}") + print(f" Key: {new_param.key}") + value_display = "***SENSITIVE***" if new_param.sensitive else new_param.value + print(f" Value: {value_display}") + print(f" Category: {new_param.category.value}") + print(f" Sensitive: {new_param.sensitive}") + + # List again to show the new parameter + _print_header("Listing parameters after creation") + updated_list = client.policy_set_parameters.list(args.policy_set_id) + print(f"Total parameters: {updated_list.total_count}") + for param in updated_list.items: + value_display = "***SENSITIVE***" if param.sensitive else param.value + print(f"- {param.key}: {value_display} (sensitive={param.sensitive})") + + +if __name__ == "__main__": + main() diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 3894886..4d0227d 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -16,6 +16,7 @@ from .resources.policy_evaluation import PolicyEvaluations from .resources.policy_set import PolicySets from .resources.policy_set_outcome import PolicySets as PolicySetOutcomes +from .resources.policy_set_parameter import PolicySetParameters from .resources.policy_set_version import PolicySetVersions from .resources.projects import Projects from .resources.query_run import QueryRuns @@ -84,6 +85,7 @@ def __init__(self, config: TFEConfig | None = None): self.policy_evaluations = PolicyEvaluations(self._transport) self.policy_checks = PolicyChecks(self._transport) self.policy_sets = PolicySets(self._transport) + self.policy_set_parameters = PolicySetParameters(self._transport) self.policy_set_outcomes = PolicySetOutcomes(self._transport) self.policy_set_versions = PolicySetVersions(self._transport) diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index 3eac2be..d365904 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -460,3 +460,32 @@ class InvalidPolicyEvaluationIDError(InvalidValues): def __init__(self, message: str = "invalid value for policy evaluation ID"): super().__init__(message) + + +# Policy Set Parameter errors +class InvalidParamIDError(InvalidValues): + """Raised when an invalid policy set parameter ID is provided.""" + + def __init__(self, message: str = "invalid value for parameter ID"): + super().__init__(message) + + +class RequiredCategoryError(RequiredFieldMissing): + """Raised when a required category field is missing.""" + + def __init__(self, message: str = "category is required"): + super().__init__(message) + + +class InvalidCategoryError(InvalidValues): + """Raised when an invalid category field is provided.""" + + def __init__(self, message: str = "category must be policy-set"): + super().__init__(message) + + +class RequiredKeyError(RequiredFieldMissing): + """Raised when a required key field is missing.""" + + def __init__(self, message: str = "key is required"): + super().__init__(message) diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index f3eb33b..b19c446 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -133,6 +133,13 @@ PolicySetRemoveWorkspacesOptions, PolicySetUpdateOptions, ) +from .policy_set_parameter import ( + PolicySetParameter, + PolicySetParameterCreateOptions, + PolicySetParameterList, + PolicySetParameterListOptions, + PolicySetParameterUpdateOptions, +) from .policy_types import ( EnforcementLevel, PolicyKind, @@ -586,6 +593,12 @@ "PolicySetRemoveWorkspaceExclusionsOptions", "PolicySetRemoveProjectsOptions", "PolicySetUpdateOptions", + # Policy Set Parameters + "PolicySetParameter", + "PolicySetParameterCreateOptions", + "PolicySetParameterList", + "PolicySetParameterListOptions", + "PolicySetParameterUpdateOptions", "PolicyKind", "EnforcementLevel", # Variable Sets diff --git a/src/pytfe/models/policy_set_parameter.py b/src/pytfe/models/policy_set_parameter.py new file mode 100644 index 0000000..8666a63 --- /dev/null +++ b/src/pytfe/models/policy_set_parameter.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +from .policy_set import PolicySet +from .variable import CategoryType + + +class PolicySetParameter(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str + key: str = Field(..., alias="key") + value: str | None = Field(None, alias="value") + category: CategoryType = Field(..., alias="category") + sensitive: bool = Field(..., alias="sensitive") + + # relations + policy_set: PolicySet = Field(..., alias="configurable") + + +class PolicySetParameterList(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + items: list[PolicySetParameter] = Field(default_factory=list) + current_page: int | None = None + total_pages: int | None = None + prev_page: int | None = None + next_page: int | None = None + total_count: int | None = None + + +class PolicySetParameterListOptions(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]") + + +class PolicySetParameterCreateOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + key: str = Field(..., alias="key") + value: str | None = Field(None, alias="value") + + # Required: The Category of the parameter, should always be "policy-set" + category: CategoryType = Field(default=CategoryType.POLICY_SET, alias="category") + sensitive: bool | None = Field(None, alias="sensitive") + + +class PolicySetParameterUpdateOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + key: str | None = Field(None, alias="key") + value: str | None = Field(None, alias="value") + sensitive: bool | None = Field(None, alias="sensitive") diff --git a/src/pytfe/resources/policy_set_parameter.py b/src/pytfe/resources/policy_set_parameter.py new file mode 100644 index 0000000..56fa961 --- /dev/null +++ b/src/pytfe/resources/policy_set_parameter.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from ..errors import ( + InvalidCategoryError, + InvalidParamIDError, + InvalidPolicySetIDError, + RequiredCategoryError, + RequiredKeyError, +) +from ..models.policy_set_parameter import ( + PolicySetParameter, + PolicySetParameterCreateOptions, + PolicySetParameterList, + PolicySetParameterListOptions, +) +from ..models.variable import CategoryType +from ..utils import valid_string, valid_string_id +from ._base import _Service + + +class PolicySetParameters(_Service): + """ + PolicySetParameters describes all the parameter related methods that the Terraform Enterprise API supports. + TFE API docs: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policy-set-params + """ + + def list( + self, policy_set_id: str, options: PolicySetParameterListOptions | None = None + ) -> PolicySetParameterList: + """List all the parameters associated with the given policy-set.""" + if not valid_string_id(policy_set_id): + raise InvalidPolicySetIDError() + params = options.model_dump(by_alias=True, exclude_none=True) if options else {} + r = self.t.request( + "GET", + path=f"api/v2/policy-sets/{policy_set_id}/parameters", + 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["policy_set"] = ( + d.get("relationships", {}).get("configurable", {}).get("data", {}) + ) + items.append(PolicySetParameter.model_validate(attrs)) + return PolicySetParameterList( + 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"), + ) + + def create( + self, policy_set_id: str, options: PolicySetParameterCreateOptions + ) -> PolicySetParameter: + """Create is used to create a new parameter.""" + if not valid_string_id(policy_set_id): + raise InvalidPolicySetIDError() + + if not valid_string(options.key): + raise RequiredKeyError() + + if options.category is None: + raise RequiredCategoryError() + if options.category != CategoryType.POLICY_SET: + raise InvalidCategoryError() + + attributes = options.model_dump(by_alias=True, exclude_none=True) + payload = { + "data": { + "type": "vars", + "attributes": attributes, + } + } + r = self.t.request( + "POST", + path=f"api/v2/policy-sets/{policy_set_id}/parameters", + json_body=payload, + ) + jd = r.json() + data = jd.get("data", {}) + attrs = data.get("attributes", {}) + attrs["id"] = data.get("id") + attrs["policy_set"] = ( + data.get("relationships", {}).get("configurable", {}).get("data", {}) + ) + return PolicySetParameter.model_validate(attrs) + + def read(self, policy_set_id: str, parameter_id: str) -> PolicySetParameter: + """Read a parameter by its ID.""" + if not valid_string_id(policy_set_id): + raise InvalidPolicySetIDError() + + if not valid_string_id(parameter_id): + raise InvalidParamIDError() + + r = self.t.request( + "GET", + path=f"api/v2/policy-sets/{policy_set_id}/parameters/{parameter_id}", + ) + jd = r.json() + data = jd.get("data", {}) + attrs = data.get("attributes", {}) + attrs["id"] = data.get("id") + attrs["policy_set"] = ( + data.get("relationships", {}).get("configurable", {}).get("data", {}) + ) + return PolicySetParameter.model_validate(attrs) + + +""" + def update( + self, + policy_set_id: str, + parameter_id: str, + options: PolicySetParameterUpdateOptions, + ) -> PolicySetParameter: + if not valid_string_id(policy_set_id): + raise InvalidPolicySetIDError() + + if not valid_string_id(parameter_id): + raise InvalidParamIDError() + return PolicySetParameter() + + def delete(self, policy_set_id: str, parameter_id: str) -> None: + if not valid_string_id(policy_set_id): + raise InvalidPolicySetIDError() + + if not valid_string_id(parameter_id): + raise InvalidParamIDError() + return None +""" From 97e32304f6b32b0e656712e7b09d85d2d3e8da76 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Mon, 24 Nov 2025 17:04:25 +0530 Subject: [PATCH 02/14] feat(policy set parameter): added update and delete methods --- examples/policy_set_parameter.py | 100 ++++++++++++++++++-- src/pytfe/resources/policy_set_parameter.py | 30 +++++- 2 files changed, 117 insertions(+), 13 deletions(-) diff --git a/examples/policy_set_parameter.py b/examples/policy_set_parameter.py index e202cf8..7730220 100644 --- a/examples/policy_set_parameter.py +++ b/examples/policy_set_parameter.py @@ -7,6 +7,7 @@ from pytfe.models import ( PolicySetParameterCreateOptions, PolicySetParameterListOptions, + PolicySetParameterUpdateOptions, ) @@ -29,13 +30,11 @@ def main(): parser.add_argument("--page-size", type=int, default=10) parser.add_argument("--create", action="store_true", help="Create a test parameter") parser.add_argument("--read", action="store_true", help="Read a specific parameter") - parser.add_argument("--parameter-id", help="Parameter ID for read operation") - parser.add_argument( - "--key", default="test_param", help="Parameter key for creation" - ) - parser.add_argument( - "--value", default="test_value", help="Parameter value for creation" - ) + parser.add_argument("--update", action="store_true", help="Update a parameter") + parser.add_argument("--delete", action="store_true", help="Delete a parameter") + parser.add_argument("--parameter-id", help="Parameter ID for read/update/delete operation") + parser.add_argument("--key", help="Parameter key for creation/update") + parser.add_argument("--value", help="Parameter value for creation/update") parser.add_argument( "--sensitive", action="store_true", help="Mark parameter as sensitive" ) @@ -88,13 +87,96 @@ def main(): print(f" Category: {param.category.value}") print(f" Sensitive: {param.sensitive}") - # 3) Create a new parameter (if --create flag is provided) + # 3) Update a parameter (if --update flag is provided) + if args.update: + if not args.parameter_id: + print("Error: --parameter-id is required for update operation") + return + + _print_header(f"Updating parameter: {args.parameter_id}") + + # First read the current parameter to show before state + current_param = client.policy_set_parameters.read( + args.policy_set_id, args.parameter_id + ) + print("Before update:") + print(f" Key: {current_param.key}") + value_display = ( + "***SENSITIVE***" if current_param.sensitive else current_param.value + ) + print(f" Value: {value_display}") + print(f" Sensitive: {current_param.sensitive}") + + # Update the parameter + update_options = PolicySetParameterUpdateOptions( + key=args.key if args.key else None, + value=args.value if args.value else None, + sensitive=args.sensitive if args.sensitive else None, + ) + + updated_param = client.policy_set_parameters.update( + args.policy_set_id, args.parameter_id, update_options + ) + + print("\nAfter update:") + print(f" Key: {updated_param.key}") + value_display = ( + "***SENSITIVE***" if updated_param.sensitive else updated_param.value + ) + print(f" Value: {value_display}") + print(f" Sensitive: {updated_param.sensitive}") + + # 4) Delete a parameter (if --delete flag is provided) + if args.delete: + if not args.parameter_id: + print("Error: --parameter-id is required for delete operation") + return + + _print_header(f"Deleting parameter: {args.parameter_id}") + + # First read the parameter to show what's being deleted + try: + param_to_delete = client.policy_set_parameters.read( + args.policy_set_id, args.parameter_id + ) + print("Parameter to delete:") + print(f" ID: {param_to_delete.id}") + print(f" Key: {param_to_delete.key}") + value_display = ( + "***SENSITIVE***" if param_to_delete.sensitive else param_to_delete.value + ) + print(f" Value: {value_display}") + print(f" Sensitive: {param_to_delete.sensitive}") + except Exception as e: + print(f"Error reading parameter: {e}") + return + + # Delete the parameter + client.policy_set_parameters.delete(args.policy_set_id, args.parameter_id) + print(f"\nāœ“ Successfully deleted parameter: {args.parameter_id}") + + # List remaining parameters + _print_header("Listing parameters after deletion") + remaining_list = client.policy_set_parameters.list(args.policy_set_id) + print(f"Total parameters: {remaining_list.total_count}") + if remaining_list.items: + for param in remaining_list.items: + value_display = "***SENSITIVE***" if param.sensitive else param.value + print(f"- {param.key}: {value_display} (sensitive={param.sensitive})") + else: + print("No parameters remaining.") + + # 5) Create a new parameter (if --create flag is provided) if args.create: + if not args.key: + print("Error: --key is required for create operation") + return + _print_header(f"Creating new parameter with key: {args.key}") create_options = PolicySetParameterCreateOptions( key=args.key, - value=args.value, + value=args.value if args.value else "", sensitive=args.sensitive, ) diff --git a/src/pytfe/resources/policy_set_parameter.py b/src/pytfe/resources/policy_set_parameter.py index 56fa961..ab79afb 100644 --- a/src/pytfe/resources/policy_set_parameter.py +++ b/src/pytfe/resources/policy_set_parameter.py @@ -12,6 +12,7 @@ PolicySetParameterCreateOptions, PolicySetParameterList, PolicySetParameterListOptions, + PolicySetParameterUpdateOptions, ) from ..models.variable import CategoryType from ..utils import valid_string, valid_string_id @@ -113,8 +114,6 @@ def read(self, policy_set_id: str, parameter_id: str) -> PolicySetParameter: ) return PolicySetParameter.model_validate(attrs) - -""" def update( self, policy_set_id: str, @@ -126,7 +125,27 @@ def update( if not valid_string_id(parameter_id): raise InvalidParamIDError() - return PolicySetParameter() + attributes = options.model_dump(by_alias=True, exclude_none=True) + payload = { + "data": { + "type": "vars", + "id": parameter_id, + "attributes": attributes, + } + } + r = self.t.request( + "PATCH", + path=f"api/v2/policy-sets/{policy_set_id}/parameters/{parameter_id}", + json_body=payload, + ) + jd = r.json() + data = jd.get("data", {}) + attrs = data.get("attributes", {}) + attrs["id"] = data.get("id") + attrs["policy_set"] = ( + data.get("relationships", {}).get("configurable", {}).get("data", {}) + ) + return PolicySetParameter.model_validate(attrs) def delete(self, policy_set_id: str, parameter_id: str) -> None: if not valid_string_id(policy_set_id): @@ -134,5 +153,8 @@ def delete(self, policy_set_id: str, parameter_id: str) -> None: if not valid_string_id(parameter_id): raise InvalidParamIDError() + self.t.request( + "DELETE", + path=f"api/v2/policy-sets/{policy_set_id}/parameters/{parameter_id}", + ) return None -""" From 2dfcd5da52542f1183e50f614d7a89c1a3b7e985 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Mon, 24 Nov 2025 17:06:10 +0530 Subject: [PATCH 03/14] feat(policy set parameter): adding iterator pattern to list method --- examples/policy_set_parameter.py | 69 ++++++++++++--------- src/pytfe/models/policy_set_parameter.py | 2 +- src/pytfe/resources/policy_set_parameter.py | 45 ++++++-------- 3 files changed, 59 insertions(+), 57 deletions(-) diff --git a/examples/policy_set_parameter.py b/examples/policy_set_parameter.py index 7730220..86b5363 100644 --- a/examples/policy_set_parameter.py +++ b/examples/policy_set_parameter.py @@ -26,13 +26,19 @@ def main(): ) parser.add_argument("--token", default=os.getenv("TFE_TOKEN", "")) parser.add_argument("--policy-set-id", required=True, help="Policy Set ID") - parser.add_argument("--page", type=int, default=1) - parser.add_argument("--page-size", type=int, default=10) + parser.add_argument( + "--page-size", + type=int, + default=100, + help="Page size for fetching parameters (iterator fetches all pages)", + ) parser.add_argument("--create", action="store_true", help="Create a test parameter") parser.add_argument("--read", action="store_true", help="Read a specific parameter") parser.add_argument("--update", action="store_true", help="Update a parameter") parser.add_argument("--delete", action="store_true", help="Delete a parameter") - parser.add_argument("--parameter-id", help="Parameter ID for read/update/delete operation") + parser.add_argument( + "--parameter-id", help="Parameter ID for read/update/delete operation" + ) parser.add_argument("--key", help="Parameter key for creation/update") parser.add_argument("--value", help="Parameter value for creation/update") parser.add_argument( @@ -47,28 +53,25 @@ def main(): _print_header(f"Listing parameters for policy set: {args.policy_set_id}") options = PolicySetParameterListOptions( - page_number=args.page, page_size=args.page_size, ) - param_list = client.policy_set_parameters.list(args.policy_set_id, options) - - print(f"Total parameters: {param_list.total_count}") - print(f"Page {param_list.current_page} of {param_list.total_pages}") - print() + param_count = 0 + for param in client.policy_set_parameters.list(args.policy_set_id, options): + param_count += 1 + # Sensitive parameters will have masked values + value_display = "***SENSITIVE***" if param.sensitive else param.value + print(f"- {param.id}") + print(f" Key: {param.key}") + print(f" Value: {value_display}") + print(f" Category: {param.category.value}") + print(f" Sensitive: {param.sensitive}") + print() - if not param_list.items: + if param_count == 0: print("No parameters found.") else: - for param in param_list.items: - # Sensitive parameters will have masked values - value_display = "***SENSITIVE***" if param.sensitive else param.value - print(f"- {param.id}") - print(f" Key: {param.key}") - print(f" Value: {value_display}") - print(f" Category: {param.category.value}") - print(f" Sensitive: {param.sensitive}") - print() + print(f"Total: {param_count} parameters") # 2) Read a specific parameter (if --read flag is provided) if args.read: @@ -143,7 +146,9 @@ def main(): print(f" ID: {param_to_delete.id}") print(f" Key: {param_to_delete.key}") value_display = ( - "***SENSITIVE***" if param_to_delete.sensitive else param_to_delete.value + "***SENSITIVE***" + if param_to_delete.sensitive + else param_to_delete.value ) print(f" Value: {value_display}") print(f" Sensitive: {param_to_delete.sensitive}") @@ -157,14 +162,17 @@ def main(): # List remaining parameters _print_header("Listing parameters after deletion") - remaining_list = client.policy_set_parameters.list(args.policy_set_id) - print(f"Total parameters: {remaining_list.total_count}") - if remaining_list.items: - for param in remaining_list.items: - value_display = "***SENSITIVE***" if param.sensitive else param.value - print(f"- {param.key}: {value_display} (sensitive={param.sensitive})") - else: + print("Remaining parameters:") + remaining_count = 0 + for param in client.policy_set_parameters.list(args.policy_set_id): + remaining_count += 1 + value_display = "***SENSITIVE***" if param.sensitive else param.value + print(f"- {param.key}: {value_display} (sensitive={param.sensitive})") + + if remaining_count == 0: print("No parameters remaining.") + else: + print(f"\nTotal: {remaining_count} parameters") # 5) Create a new parameter (if --create flag is provided) if args.create: @@ -193,11 +201,12 @@ def main(): # List again to show the new parameter _print_header("Listing parameters after creation") - updated_list = client.policy_set_parameters.list(args.policy_set_id) - print(f"Total parameters: {updated_list.total_count}") - for param in updated_list.items: + param_count = 0 + for param in client.policy_set_parameters.list(args.policy_set_id): + param_count += 1 value_display = "***SENSITIVE***" if param.sensitive else param.value print(f"- {param.key}: {value_display} (sensitive={param.sensitive})") + print(f"\nTotal: {param_count} parameters") if __name__ == "__main__": diff --git a/src/pytfe/models/policy_set_parameter.py b/src/pytfe/models/policy_set_parameter.py index 8666a63..366b6df 100644 --- a/src/pytfe/models/policy_set_parameter.py +++ b/src/pytfe/models/policy_set_parameter.py @@ -33,7 +33,7 @@ class PolicySetParameterList(BaseModel): class PolicySetParameterListOptions(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) - page_number: int | None = Field(None, alias="page[number]") + # page_number: int | None = Field(None, alias="page[number]") page_size: int | None = Field(None, alias="page[size]") diff --git a/src/pytfe/resources/policy_set_parameter.py b/src/pytfe/resources/policy_set_parameter.py index ab79afb..1137854 100644 --- a/src/pytfe/resources/policy_set_parameter.py +++ b/src/pytfe/resources/policy_set_parameter.py @@ -1,5 +1,8 @@ from __future__ import annotations +from collections.abc import Iterator +from typing import Any + from ..errors import ( InvalidCategoryError, InvalidParamIDError, @@ -10,7 +13,6 @@ from ..models.policy_set_parameter import ( PolicySetParameter, PolicySetParameterCreateOptions, - PolicySetParameterList, PolicySetParameterListOptions, PolicySetParameterUpdateOptions, ) @@ -19,6 +21,16 @@ from ._base import _Service +def _policy_set_parameter_from(d: dict[str, Any]) -> PolicySetParameter: + """Convert API response dict to PolicySetParameter model.""" + attrs = d.get("attributes", {}) + attrs["id"] = d.get("id") + attrs["policy_set"] = ( + d.get("relationships", {}).get("configurable", {}).get("data", {}) + ) + return PolicySetParameter.model_validate(attrs) + + class PolicySetParameters(_Service): """ PolicySetParameters describes all the parameter related methods that the Terraform Enterprise API supports. @@ -27,35 +39,14 @@ class PolicySetParameters(_Service): def list( self, policy_set_id: str, options: PolicySetParameterListOptions | None = None - ) -> PolicySetParameterList: + ) -> Iterator[PolicySetParameter]: """List all the parameters associated with the given policy-set.""" if not valid_string_id(policy_set_id): raise InvalidPolicySetIDError() params = options.model_dump(by_alias=True, exclude_none=True) if options else {} - r = self.t.request( - "GET", - path=f"api/v2/policy-sets/{policy_set_id}/parameters", - 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["policy_set"] = ( - d.get("relationships", {}).get("configurable", {}).get("data", {}) - ) - items.append(PolicySetParameter.model_validate(attrs)) - return PolicySetParameterList( - 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/policy-sets/{policy_set_id}/parameters" + for item in self._list(path, params=params): + yield _policy_set_parameter_from(item) def create( self, policy_set_id: str, options: PolicySetParameterCreateOptions @@ -120,6 +111,7 @@ def update( parameter_id: str, options: PolicySetParameterUpdateOptions, ) -> PolicySetParameter: + """Update values of an existing parameter.""" if not valid_string_id(policy_set_id): raise InvalidPolicySetIDError() @@ -148,6 +140,7 @@ def update( return PolicySetParameter.model_validate(attrs) def delete(self, policy_set_id: str, parameter_id: str) -> None: + """Delete a parameter by its ID.""" if not valid_string_id(policy_set_id): raise InvalidPolicySetIDError() From 9db92e9d1c473bb43b622976cc191955ddd244e0 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Tue, 25 Nov 2025 14:47:46 +0530 Subject: [PATCH 04/14] test(policy set parameter): added unit tests --- tests/units/test_policy_set_parameter.py | 393 +++++++++++++++++++++++ 1 file changed, 393 insertions(+) create mode 100644 tests/units/test_policy_set_parameter.py diff --git a/tests/units/test_policy_set_parameter.py b/tests/units/test_policy_set_parameter.py new file mode 100644 index 0000000..05c2c4d --- /dev/null +++ b/tests/units/test_policy_set_parameter.py @@ -0,0 +1,393 @@ +"""Unit tests for the policy_set_parameter module.""" + +from unittest.mock import Mock, patch + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import ( + InvalidCategoryError, + InvalidParamIDError, + InvalidPolicySetIDError, + RequiredKeyError, +) +from pytfe.models import ( + CategoryType, + PolicySetParameter, + PolicySetParameterCreateOptions, + PolicySetParameterListOptions, + PolicySetParameterUpdateOptions, +) +from pytfe.resources.policy_set_parameter import PolicySetParameters + + +class TestPolicySetParameters: + """Test the PolicySetParameters service class.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock HTTPTransport.""" + return Mock(spec=HTTPTransport) + + @pytest.fixture + def policy_set_parameters_service(self, mock_transport): + """Create a PolicySetParameters service with mocked transport.""" + return PolicySetParameters(mock_transport) + + def test_list_parameters_validations(self, policy_set_parameters_service): + """Test list method with invalid policy set ID.""" + + # Test empty policy set ID + with pytest.raises(InvalidPolicySetIDError): + list(policy_set_parameters_service.list("")) + + # Test None policy set ID + with pytest.raises(InvalidPolicySetIDError): + list(policy_set_parameters_service.list(None)) + + def test_list_parameters_success_without_options( + self, policy_set_parameters_service + ): + """Test successful list operation without options.""" + + mock_data = [ + { + "id": "var-123", + "attributes": { + "key": "test_param", + "value": "test_value", + "category": "policy-set", + "sensitive": False, + }, + "relationships": { + "configurable": { + "data": {"id": "polset-123", "type": "policy-sets"} + } + }, + } + ] + + with patch.object(policy_set_parameters_service, "_list") as mock_list: + mock_list.return_value = iter(mock_data) + + result = list(policy_set_parameters_service.list("polset-123")) + + mock_list.assert_called_once_with( + "/api/v2/policy-sets/polset-123/parameters", params={} + ) + + assert len(result) == 1 + assert result[0].id == "var-123" + assert result[0].key == "test_param" + assert result[0].value == "test_value" + assert result[0].category == CategoryType.POLICY_SET + assert result[0].sensitive is False + + def test_list_parameters_with_options(self, policy_set_parameters_service): + """Test successful list operation with pagination options.""" + + mock_data = [] + + with patch.object(policy_set_parameters_service, "_list") as mock_list: + mock_list.return_value = iter(mock_data) + + options = PolicySetParameterListOptions(page_size=10) + result = list(policy_set_parameters_service.list("polset-123", options)) + + mock_list.assert_called_once_with( + "/api/v2/policy-sets/polset-123/parameters", + params={"page[size]": 10}, + ) + + assert len(result) == 0 + + def test_list_parameters_returns_iterator(self, policy_set_parameters_service): + """Test that list method returns an iterator.""" + + with patch.object(policy_set_parameters_service, "_list") as mock_list: + mock_list.return_value = iter([]) + + result = policy_set_parameters_service.list("polset-123") + + # Verify it's an iterator + assert hasattr(result, "__iter__") + assert hasattr(result, "__next__") + + def test_create_parameter_validations(self, policy_set_parameters_service): + """Test create method validations.""" + + # Test invalid policy set ID + options = PolicySetParameterCreateOptions(key="test") + with pytest.raises(InvalidPolicySetIDError): + policy_set_parameters_service.create("", options) + + # Test missing key + options = PolicySetParameterCreateOptions(key="") + with pytest.raises(RequiredKeyError): + policy_set_parameters_service.create("polset-123", options) + + # Test invalid category (not policy-set) + options = PolicySetParameterCreateOptions( + key="test", category=CategoryType.TERRAFORM + ) + with pytest.raises(InvalidCategoryError): + policy_set_parameters_service.create("polset-123", options) + + def test_create_parameter_success( + self, policy_set_parameters_service, mock_transport + ): + """Test successful create operation.""" + + mock_response_data = { + "data": { + "id": "var-456", + "attributes": { + "key": "new_param", + "value": "new_value", + "category": "policy-set", + "sensitive": False, + }, + "relationships": { + "configurable": { + "data": {"id": "polset-123", "type": "policy-sets"} + } + }, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + mock_transport.request.return_value = mock_response + + options = PolicySetParameterCreateOptions( + key="new_param", value="new_value", sensitive=False + ) + + result = policy_set_parameters_service.create("polset-123", options) + + mock_transport.request.assert_called_once_with( + "POST", + path="api/v2/policy-sets/polset-123/parameters", + json_body={ + "data": { + "type": "vars", + "attributes": { + "key": "new_param", + "value": "new_value", + "category": "policy-set", + "sensitive": False, + }, + } + }, + ) + + assert isinstance(result, PolicySetParameter) + assert result.id == "var-456" + assert result.key == "new_param" + assert result.value == "new_value" + + def test_create_sensitive_parameter( + self, policy_set_parameters_service, mock_transport + ): + """Test creating a sensitive parameter.""" + + mock_response_data = { + "data": { + "id": "var-789", + "attributes": { + "key": "secret_param", + "value": None, + "category": "policy-set", + "sensitive": True, + }, + "relationships": { + "configurable": { + "data": {"id": "polset-123", "type": "policy-sets"} + } + }, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + mock_transport.request.return_value = mock_response + + options = PolicySetParameterCreateOptions( + key="secret_param", value="secret_value", sensitive=True + ) + + result = policy_set_parameters_service.create("polset-123", options) + + assert isinstance(result, PolicySetParameter) + assert result.id == "var-789" + assert result.key == "secret_param" + assert result.value is None # Sensitive values are not returned + assert result.sensitive is True + + def test_read_parameter_validations(self, policy_set_parameters_service): + """Test read method validations.""" + + # Test invalid policy set ID + with pytest.raises(InvalidPolicySetIDError): + policy_set_parameters_service.read("", "var-123") + + # Test invalid parameter ID + with pytest.raises(InvalidParamIDError): + policy_set_parameters_service.read("polset-123", "") + + def test_read_parameter_success( + self, policy_set_parameters_service, mock_transport + ): + """Test successful read operation.""" + + mock_response_data = { + "data": { + "id": "var-789", + "attributes": { + "key": "existing_param", + "value": "existing_value", + "category": "policy-set", + "sensitive": False, + }, + "relationships": { + "configurable": { + "data": {"id": "polset-123", "type": "policy-sets"} + } + }, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + mock_transport.request.return_value = mock_response + + result = policy_set_parameters_service.read("polset-123", "var-789") + + mock_transport.request.assert_called_once_with( + "GET", path="api/v2/policy-sets/polset-123/parameters/var-789" + ) + + assert isinstance(result, PolicySetParameter) + assert result.id == "var-789" + assert result.key == "existing_param" + assert result.value == "existing_value" + + def test_update_parameter_validations(self, policy_set_parameters_service): + """Test update method validations.""" + + options = PolicySetParameterUpdateOptions(value="updated") + + # Test invalid policy set ID + with pytest.raises(InvalidPolicySetIDError): + policy_set_parameters_service.update("", "var-123", options) + + # Test invalid parameter ID + with pytest.raises(InvalidParamIDError): + policy_set_parameters_service.update("polset-123", "", options) + + def test_update_parameter_success( + self, policy_set_parameters_service, mock_transport + ): + """Test successful update operation.""" + + mock_response_data = { + "data": { + "id": "var-789", + "attributes": { + "key": "updated_param", + "value": "updated_value", + "category": "policy-set", + "sensitive": False, + }, + "relationships": { + "configurable": { + "data": {"id": "polset-123", "type": "policy-sets"} + } + }, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + mock_transport.request.return_value = mock_response + + options = PolicySetParameterUpdateOptions( + key="updated_param", value="updated_value" + ) + + result = policy_set_parameters_service.update("polset-123", "var-789", options) + + mock_transport.request.assert_called_once_with( + "PATCH", + path="api/v2/policy-sets/polset-123/parameters/var-789", + json_body={ + "data": { + "type": "vars", + "id": "var-789", + "attributes": {"key": "updated_param", "value": "updated_value"}, + } + }, + ) + + assert isinstance(result, PolicySetParameter) + assert result.id == "var-789" + assert result.key == "updated_param" + assert result.value == "updated_value" + + def test_update_parameter_to_sensitive( + self, policy_set_parameters_service, mock_transport + ): + """Test updating a parameter to make it sensitive.""" + + mock_response_data = { + "data": { + "id": "var-789", + "attributes": { + "key": "param", + "value": None, + "category": "policy-set", + "sensitive": True, + }, + "relationships": { + "configurable": { + "data": {"id": "polset-123", "type": "policy-sets"} + } + }, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + mock_transport.request.return_value = mock_response + + options = PolicySetParameterUpdateOptions(sensitive=True) + + result = policy_set_parameters_service.update("polset-123", "var-789", options) + + assert isinstance(result, PolicySetParameter) + assert result.sensitive is True + assert result.value is None + + def test_delete_parameter_validations(self, policy_set_parameters_service): + """Test delete method validations.""" + + # Test invalid policy set ID + with pytest.raises(InvalidPolicySetIDError): + policy_set_parameters_service.delete("", "var-123") + + # Test invalid parameter ID + with pytest.raises(InvalidParamIDError): + policy_set_parameters_service.delete("polset-123", "") + + def test_delete_parameter_success( + self, policy_set_parameters_service, mock_transport + ): + """Test successful delete operation.""" + + result = policy_set_parameters_service.delete("polset-123", "var-789") + + mock_transport.request.assert_called_once_with( + "DELETE", path="api/v2/policy-sets/polset-123/parameters/var-789" + ) + + assert result is None From e7a60334a3abf4325846969abea7d6195488f76c Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Wed, 26 Nov 2025 13:17:23 +0530 Subject: [PATCH 05/14] feat(policy set parameter): added a helper function --- examples/policy_set_parameter.py | 2 +- src/pytfe/models/__init__.py | 2 - src/pytfe/models/policy_set_parameter.py | 12 ----- src/pytfe/resources/policy_set_parameter.py | 51 +++++++-------------- 4 files changed, 17 insertions(+), 50 deletions(-) diff --git a/examples/policy_set_parameter.py b/examples/policy_set_parameter.py index 86b5363..c2ffea3 100644 --- a/examples/policy_set_parameter.py +++ b/examples/policy_set_parameter.py @@ -30,7 +30,7 @@ def main(): "--page-size", type=int, default=100, - help="Page size for fetching parameters (iterator fetches all pages)", + help="Page size for fetching parameters", ) parser.add_argument("--create", action="store_true", help="Create a test parameter") parser.add_argument("--read", action="store_true", help="Read a specific parameter") diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index b19c446..c40737e 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -136,7 +136,6 @@ from .policy_set_parameter import ( PolicySetParameter, PolicySetParameterCreateOptions, - PolicySetParameterList, PolicySetParameterListOptions, PolicySetParameterUpdateOptions, ) @@ -596,7 +595,6 @@ # Policy Set Parameters "PolicySetParameter", "PolicySetParameterCreateOptions", - "PolicySetParameterList", "PolicySetParameterListOptions", "PolicySetParameterUpdateOptions", "PolicyKind", diff --git a/src/pytfe/models/policy_set_parameter.py b/src/pytfe/models/policy_set_parameter.py index 366b6df..01a88c2 100644 --- a/src/pytfe/models/policy_set_parameter.py +++ b/src/pytfe/models/policy_set_parameter.py @@ -19,21 +19,9 @@ class PolicySetParameter(BaseModel): policy_set: PolicySet = Field(..., alias="configurable") -class PolicySetParameterList(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) - - items: list[PolicySetParameter] = Field(default_factory=list) - current_page: int | None = None - total_pages: int | None = None - prev_page: int | None = None - next_page: int | None = None - total_count: int | None = None - - class PolicySetParameterListOptions(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/resources/policy_set_parameter.py b/src/pytfe/resources/policy_set_parameter.py index 1137854..076579c 100644 --- a/src/pytfe/resources/policy_set_parameter.py +++ b/src/pytfe/resources/policy_set_parameter.py @@ -21,16 +21,6 @@ from ._base import _Service -def _policy_set_parameter_from(d: dict[str, Any]) -> PolicySetParameter: - """Convert API response dict to PolicySetParameter model.""" - attrs = d.get("attributes", {}) - attrs["id"] = d.get("id") - attrs["policy_set"] = ( - d.get("relationships", {}).get("configurable", {}).get("data", {}) - ) - return PolicySetParameter.model_validate(attrs) - - class PolicySetParameters(_Service): """ PolicySetParameters describes all the parameter related methods that the Terraform Enterprise API supports. @@ -46,7 +36,7 @@ def list( params = options.model_dump(by_alias=True, exclude_none=True) if options else {} path = f"/api/v2/policy-sets/{policy_set_id}/parameters" for item in self._list(path, params=params): - yield _policy_set_parameter_from(item) + yield self._policy_set_parameter_from(item) def create( self, policy_set_id: str, options: PolicySetParameterCreateOptions @@ -75,14 +65,8 @@ def create( path=f"api/v2/policy-sets/{policy_set_id}/parameters", json_body=payload, ) - jd = r.json() - data = jd.get("data", {}) - attrs = data.get("attributes", {}) - attrs["id"] = data.get("id") - attrs["policy_set"] = ( - data.get("relationships", {}).get("configurable", {}).get("data", {}) - ) - return PolicySetParameter.model_validate(attrs) + data = r.json().get("data", {}) + return self._policy_set_parameter_from(data) def read(self, policy_set_id: str, parameter_id: str) -> PolicySetParameter: """Read a parameter by its ID.""" @@ -96,14 +80,8 @@ def read(self, policy_set_id: str, parameter_id: str) -> PolicySetParameter: "GET", path=f"api/v2/policy-sets/{policy_set_id}/parameters/{parameter_id}", ) - jd = r.json() - data = jd.get("data", {}) - attrs = data.get("attributes", {}) - attrs["id"] = data.get("id") - attrs["policy_set"] = ( - data.get("relationships", {}).get("configurable", {}).get("data", {}) - ) - return PolicySetParameter.model_validate(attrs) + data = r.json().get("data", {}) + return self._policy_set_parameter_from(data) def update( self, @@ -130,14 +108,8 @@ def update( path=f"api/v2/policy-sets/{policy_set_id}/parameters/{parameter_id}", json_body=payload, ) - jd = r.json() - data = jd.get("data", {}) - attrs = data.get("attributes", {}) - attrs["id"] = data.get("id") - attrs["policy_set"] = ( - data.get("relationships", {}).get("configurable", {}).get("data", {}) - ) - return PolicySetParameter.model_validate(attrs) + data = r.json().get("data", {}) + return self._policy_set_parameter_from(data) def delete(self, policy_set_id: str, parameter_id: str) -> None: """Delete a parameter by its ID.""" @@ -151,3 +123,12 @@ def delete(self, policy_set_id: str, parameter_id: str) -> None: path=f"api/v2/policy-sets/{policy_set_id}/parameters/{parameter_id}", ) return None + + def _policy_set_parameter_from(self, d: dict[str, Any]) -> PolicySetParameter: + """Convert API response dict to PolicySetParameter model.""" + attrs = d.get("attributes", {}) + attrs["id"] = d.get("id") + attrs["policy_set"] = ( + d.get("relationships", {}).get("configurable", {}).get("data", {}) + ) + return PolicySetParameter.model_validate(attrs) From 6d3f2d0ec4ff81f003cb81cd47196ed887d9fba9 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Fri, 28 Nov 2025 14:44:46 +0530 Subject: [PATCH 06/14] feat(policy set parameters): modified the example --- examples/policy_set_parameter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/policy_set_parameter.py b/examples/policy_set_parameter.py index c2ffea3..9d6f662 100644 --- a/examples/policy_set_parameter.py +++ b/examples/policy_set_parameter.py @@ -158,7 +158,7 @@ def main(): # Delete the parameter client.policy_set_parameters.delete(args.policy_set_id, args.parameter_id) - print(f"\nāœ“ Successfully deleted parameter: {args.parameter_id}") + print(f"\n Successfully deleted parameter: {args.parameter_id}") # List remaining parameters _print_header("Listing parameters after deletion") From 41b0d75912aebb0b6e99c56d4efb871b3815dfa3 Mon Sep 17 00:00:00 2001 From: aayushsingh2502 Date: Fri, 28 Nov 2025 00:15:36 +0530 Subject: [PATCH 07/14] Added all api functionality for organization membership and done some code cleanup --- examples/agent.py | 32 +- examples/agent_pool.py | 36 +- examples/configuration_version.py | 196 ++-- examples/notification_configuration.py | 30 +- examples/oauth_client.py | 94 +- examples/oauth_token.py | 48 +- examples/org.py | 104 +-- examples/organization_membership.py | 320 +++++++ examples/project.py | 240 +++-- examples/registry_module.py | 138 +-- examples/registry_provider.py | 50 +- examples/reserved_tag_key.py | 32 +- examples/run_task.py | 20 +- examples/run_trigger.py | 20 +- examples/ssh_keys.py | 62 +- examples/variables.py | 54 +- examples/workspace.py | 66 +- src/pytfe/client.py | 2 + src/pytfe/errors.py | 8 + src/pytfe/models/__init__.py | 22 + src/pytfe/models/organization_membership.py | 97 ++ src/pytfe/models/team.py | 72 ++ src/pytfe/models/user.py | 22 +- .../resources/organization_membership.py | 284 ++++++ src/pytfe/resources/policy_check.py | 2 +- src/pytfe/resources/projects.py | 18 +- tests/units/test_organization_membership.py | 835 ++++++++++++++++++ 27 files changed, 2264 insertions(+), 640 deletions(-) create mode 100644 examples/organization_membership.py create mode 100644 src/pytfe/models/organization_membership.py create mode 100644 src/pytfe/models/team.py create mode 100644 src/pytfe/resources/organization_membership.py create mode 100644 tests/units/test_organization_membership.py diff --git a/examples/agent.py b/examples/agent.py index d756abf..5b0990c 100644 --- a/examples/agent.py +++ b/examples/agent.py @@ -36,29 +36,29 @@ def main(): address = os.environ.get("TFE_ADDRESS", "https://app.terraform.io") if not token: - print("āŒ TFE_TOKEN environment variable is required") + print(" TFE_TOKEN environment variable is required") return 1 if not org: - print("āŒ TFE_ORG environment variable is required") + print(" TFE_ORG environment variable is required") return 1 # Create TFE client config = TFEConfig(token=token, address=address) client = TFEClient(config) - print(f"šŸ”— Connected to: {address}") - print(f"šŸ¢ Organization: {org}") + print(f"Connected to: {address}") + print(f" Organization: {org}") try: # Example 1: Find agent pools to demonstrate agent operations - print("\nšŸ“‹ Finding agent pools...") + print("\n Finding agent pools...") agent_pools = client.agent_pools.list(org) # Convert to list to check if empty and get count pool_list = list(agent_pools) if not pool_list: - print("āš ļø No agent pools found. Create an agent pool first.") + print(" No agent pools found. Create an agent pool first.") return 1 print(f"Found {len(pool_list)} agent pools:") @@ -66,11 +66,11 @@ def main(): print(f" - {pool.name} (ID: {pool.id}, Agents: {pool.agent_count})") # Example 2: List agents in each pool - print("\nšŸ¤– Listing agents in each pool...") + print("\n Listing agents in each pool...") total_agents = 0 for pool in pool_list: - print(f"\nšŸ“‚ Agents in pool '{pool.name}':") + print(f"\n Agents in pool '{pool.name}':") # Use optional parameters for listing list_options = AgentListOptions(page_size=10) # Optional parameter @@ -91,35 +91,35 @@ def main(): # Example 3: Read detailed agent information try: agent_details = client.agents.read(agent.id) - print(" āœ… Agent details retrieved successfully") + print(" Agent details retrieved successfully") print(f" Full name: {agent_details.name or 'Unnamed'}") print(f" Current status: {agent_details.status}") except NotFound: - print(" āš ļø Agent details not accessible") + print(" Agent details not accessible") except Exception as e: - print(f" āŒ Error reading agent details: {e}") + print(f" Error reading agent details: {e}") print("") else: print(" No agents found in this pool") if total_agents == 0: - print("\nāš ļø No agents found in any pools.") + print("\n No agents found in any pools.") print("To see agents in action:") print("1. Create an agent pool") print("2. Run a Terraform Enterprise agent binary connected to the pool") print("3. Run this example again") else: - print(f"\nšŸ“Š Total agents found across all pools: {total_agents}") + print(f"\n Total agents found across all pools: {total_agents}") - print("\nšŸŽ‰ Agent operations completed successfully!") + print("\n Agent operations completed successfully!") return 0 except NotFound as e: - print(f"āŒ Resource not found: {e}") + print(f" Resource not found: {e}") return 1 except Exception as e: - print(f"āŒ Error: {e}") + print(f" Error: {e}") return 1 diff --git a/examples/agent_pool.py b/examples/agent_pool.py index 1b6e15b..acc9750 100644 --- a/examples/agent_pool.py +++ b/examples/agent_pool.py @@ -39,23 +39,23 @@ def main(): address = os.environ.get("TFE_ADDRESS", "https://app.terraform.io") if not token: - print("āŒ TFE_TOKEN environment variable is required") + print(" TFE_TOKEN environment variable is required") return 1 if not org: - print("āŒ TFE_ORG environment variable is required") + print(" TFE_ORG environment variable is required") return 1 # Create TFE client config = TFEConfig(token=token, address=address) client = TFEClient(config=config) - print(f"šŸ”— Connected to: {address}") - print(f"šŸ¢ Organization: {org}") + print(f"Connected to: {address}") + print(f" Organization: {org}") try: # Example 1: List existing agent pools - print("\nšŸ“‹ Listing existing agent pools...") + print("\n Listing existing agent pools...") list_options = AgentPoolListOptions(page_size=10) # Optional parameters agent_pools = client.agent_pools.list(org, options=list_options) @@ -66,7 +66,7 @@ def main(): print(f" - {pool.name} (ID: {pool.id}, Agents: {pool.agent_count})") # Example 2: Create a new agent pool - print("\nšŸ†• Creating a new agent pool...") + print("\n Creating a new agent pool...") unique_name = f"sdk-example-pool-{uuid.uuid4().hex[:8]}" create_options = AgentPoolCreateOptions( @@ -76,10 +76,10 @@ def main(): ) new_pool = client.agent_pools.create(org, create_options) - print(f"āœ… Created agent pool: {new_pool.name} (ID: {new_pool.id})") + print(f"Created agent pool: {new_pool.name} (ID: {new_pool.id})") # Example 3: Read the agent pool - print("\nšŸ“– Reading agent pool details...") + print("\n Reading agent pool details...") pool_details = client.agent_pools.read(new_pool.id) print(f" Name: {pool_details.name}") print(f" Organization Scoped: {pool_details.organization_scoped}") @@ -87,28 +87,28 @@ def main(): print(f" Agent Count: {pool_details.agent_count}") # Example 4: Update the agent pool - print("\nāœļø Updating agent pool...") + print("\n Updating agent pool...") update_options = AgentPoolUpdateOptions( name=f"{unique_name}-updated", organization_scoped=False, # Making this optional parameter different ) updated_pool = client.agent_pools.update(new_pool.id, update_options) - print(f"āœ… Updated agent pool name to: {updated_pool.name}") + print(f"Updated agent pool name to: {updated_pool.name}") # Example 5: Create an agent token - print("\nšŸ”‘ Creating agent token...") + print("\n Creating agent token...") token_options = AgentTokenCreateOptions( description="SDK example token" # Optional description ) agent_token = client.agent_tokens.create(new_pool.id, token_options) - print(f"āœ… Created agent token: {agent_token.id}") + print(f"Created agent token: {agent_token.id}") if agent_token.token: print(f" Token (first 10 chars): {agent_token.token[:10]}...") # Example 6: List agent tokens - print("\nšŸ“ Listing agent tokens...") + print("\n Listing agent tokens...") tokens = client.agent_tokens.list(new_pool.id) # Convert to list to get count and iterate @@ -120,19 +120,19 @@ def main(): # Example 7: Clean up - delete the token and pool print("\n🧹 Cleaning up...") client.agent_tokens.delete(agent_token.id) - print("āœ… Deleted agent token") + print("Deleted agent token") client.agent_pools.delete(new_pool.id) - print("āœ… Deleted agent pool") + print("Deleted agent pool") - print("\nšŸŽ‰ Agent pool operations completed successfully!") + print("\n Agent pool operations completed successfully!") return 0 except NotFound as e: - print(f"āŒ Resource not found: {e}") + print(f" Resource not found: {e}") return 1 except Exception as e: - print(f"āŒ Error: {e}") + print(f" Error: {e}") return 1 diff --git a/examples/configuration_version.py b/examples/configuration_version.py index e983e94..92840b4 100644 --- a/examples/configuration_version.py +++ b/examples/configuration_version.py @@ -191,7 +191,7 @@ def main(): try: # Basic list without options cv_list = list(client.configuration_versions.list(workspace_id)) - print(f" āœ“ Found {len(cv_list)} configuration versions") + print(f" Found {len(cv_list)} configuration versions") if cv_list: print(" Recent configuration versions:") @@ -228,18 +228,18 @@ def main(): if count >= 10: # Limit to prevent infinite loop break - print(f" āœ“ Found {len(cv_list_opts)} configuration versions with options") + print(f" Found {len(cv_list_opts)} configuration versions with options") print( f" Include options: {[opt.value for opt in list_options.include]}" ) except Exception as opts_error: - print(f" ⚠ Error with options: {opts_error}") + print(f" Error with options: {opts_error}") print(" This may be expected if the API doesn't support these options") print(" Basic list functionality still works") except Exception as e: - print(f" āœ— Error: {e}") + print(f" Error: {e}") import traceback traceback.print_exc() @@ -258,7 +258,7 @@ def main(): new_cv = client.configuration_versions.create(workspace_id, create_options) created_cv_id = new_cv.id - print(f" āœ“ Created NON-SPECULATIVE CV: {created_cv_id}") + print(f" Created NON-SPECULATIVE CV: {created_cv_id}") print(f" Status: {new_cv.status}") print(f" Speculative: {new_cv.speculative} (will show in runs)") print(f" Auto-queue runs: {new_cv.auto_queue_runs} (will create run)") @@ -266,7 +266,7 @@ def main(): # UPLOAD REAL TERRAFORM CODE IMMEDIATELY if new_cv.upload_url: - print("\n → Uploading real Terraform configuration...") + print("\n Uploading real Terraform configuration...") with tempfile.TemporaryDirectory() as temp_dir: print(f" Creating Terraform files in: {temp_dir}") @@ -282,7 +282,7 @@ def main(): try: # Create tar.gz archive manually since go-slug isn't available - print(" → Creating tar.gz archive manually...") + print(" Creating tar.gz archive manually...") import tarfile @@ -296,40 +296,38 @@ def main(): archive_buffer.seek(0) archive_bytes = archive_buffer.getvalue() - print(f" → Created archive: {len(archive_bytes)} bytes") + 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...") + 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(" āœ“ Terraform configuration uploaded successfully!") + print(" Terraform configuration uploaded successfully!") # Wait and check status - print("\n → Checking status after upload...") + print("\n Checking status after upload...") time.sleep(5) # Give TFE time to process updated_cv = client.configuration_versions.read(created_cv_id) print(f" Status after upload: {updated_cv.status}") if updated_cv.status.value in ["uploaded", "fetching"]: + print(" REAL configuration version created successfully!") + print(" This CV now contains actual Terraform code") print( - " āœ… REAL configuration version created successfully!" - ) - print(" → This CV now contains actual Terraform code") - print( - " → You can now see this CV in your Terraform Cloud workspace!" + " You can now see this CV in your Terraform Cloud workspace!" ) else: - print(f" ⚠ Status is still: {updated_cv.status.value}") + print(f" Status is still: {updated_cv.status.value}") print(" (Upload may still be processing)") except Exception as e: - print(f" ⚠ Upload failed: {type(e).__name__}: {e}") - print(" → CV created but no configuration uploaded") + print(f" Upload failed: {type(e).__name__}: {e}") + print(" CV created but no configuration uploaded") else: - print(" ⚠ No upload URL - cannot upload Terraform code") + print(" No upload URL - cannot upload Terraform code") # Test 2b: Create standard configuration version for upload testing print("\n 2b. Creating standard configuration version for upload tests:") @@ -341,7 +339,7 @@ def main(): workspace_id, standard_options ) uploadable_cv_id = standard_cv.id # Save for summary display - print(f" āœ“ Created standard CV: {standard_cv.id}") + print(f" Created standard CV: {standard_cv.id}") print(f" Status: {standard_cv.status}") print(f" Speculative: {standard_cv.speculative}") print(f" Auto-queue runs: {standard_cv.auto_queue_runs}") @@ -353,12 +351,12 @@ def main(): ) auto_cv = client.configuration_versions.create(workspace_id, auto_options) - print(f" āœ“ Created auto-queue CV: {auto_cv.id}") + print(f" Created auto-queue CV: {auto_cv.id}") print(f" Auto-queue runs: {auto_cv.auto_queue_runs}") - print(" ⚠ This will trigger a Terraform run when code is uploaded") + print(" This will trigger a Terraform run when code is uploaded") except Exception as e: - print(f" āœ— Error: {e}") + print(f" Error: {e}") import traceback traceback.print_exc() @@ -371,7 +369,7 @@ def main(): try: cv_details = client.configuration_versions.read(created_cv_id) - print(f" āœ“ Read configuration version: {cv_details.id}") + print(f" Read configuration version: {cv_details.id}") print(f" Status: {cv_details.status}") print(f" Source: {cv_details.source}") if cv_details.status_timestamps: @@ -405,12 +403,12 @@ def main(): for field in required_fields: if hasattr(cv_details, field): value = getattr(cv_details, field) - print(f" āœ“ {field}: {type(value).__name__}") + print(f" {field}: {type(value).__name__}") else: - print(f" āœ— {field}: Missing") + print(f" {field}: Missing") except Exception as e: - print(f" āœ— Error: {e}") + print(f" Error: {e}") import traceback traceback.print_exc() @@ -433,7 +431,7 @@ def main(): upload_url = fresh_cv.upload_url if not upload_url: - print(" ⚠ No upload URL available for this configuration version") + print(" No upload URL available for this configuration version") print(" Configuration version may not be in uploadable state") else: with tempfile.TemporaryDirectory() as temp_dir: @@ -453,7 +451,7 @@ def main(): try: client.configuration_versions.upload(upload_url, temp_dir) - print(" āœ“ Configuration uploaded successfully!") + print(" Configuration uploaded successfully!") # Check status after upload print("\n Checking status after upload:") @@ -462,25 +460,25 @@ def main(): print(f" Status after upload: {updated_cv.status}") if updated_cv.status.value != "pending": - print(" āœ“ Status changed (upload processed)") + print(" Status changed (upload processed)") else: - print(" ⚠ Status still pending (may need more time)") + 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(" 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" + " Function correctly raises ImportError when go-slug unavailable" ) else: raise except Exception as e: - print(f" āœ— Error: {e}") + print(f" Error: {e}") import traceback traceback.print_exc() @@ -510,7 +508,7 @@ def main(): downloadable_cvs.append(cv) if not downloadable_cvs: - print(" ⚠ No uploaded configuration versions found to download") + print(" No uploaded configuration versions found to download") print(" This is not a test failure - upload a configuration first") else: downloadable_cv = downloadable_cvs[0] @@ -518,20 +516,20 @@ def main(): print(f" Status: {downloadable_cv.status}") archive_data = client.configuration_versions.download(downloadable_cv.id) - print(f" āœ“ Downloaded {len(archive_data)} bytes") + print(f" Downloaded {len(archive_data)} bytes") # Validate downloaded data print("\n Validating downloaded data:") if len(archive_data) > 0: - print(" āœ“ Archive data is non-empty") + print(" Archive data is non-empty") # Basic format check if archive_data[:2] == b"\x1f\x8b": - print(" āœ“ Data appears to be gzip format") + print(" Data appears to be gzip format") else: - print(" ⚠ Data may not be gzip format (could still be valid)") + print(" Data may not be gzip format (could still be valid)") else: - print(" āœ— Archive data is empty") + print(" Archive data is empty") # Test multiple downloads if available if len(downloadable_cvs) > 1: @@ -539,12 +537,12 @@ def main(): for i, cv in enumerate(downloadable_cvs[1:3], 2): try: data = client.configuration_versions.download(cv.id) - print(f" āœ“ CV {i}: {cv.id} - {len(data)} bytes") + print(f" CV {i}: {cv.id} - {len(data)} bytes") except Exception as e: - print(f" ⚠ CV {i}: {cv.id} - Failed: {type(e).__name__}") + print(f" CV {i}: {cv.id} - Failed: {type(e).__name__}") except Exception as e: - print(f" āœ— Error: {e}") + print(f" Error: {e}") import traceback traceback.print_exc() @@ -568,7 +566,7 @@ def main(): if len(cv_list) < 2: print( - " ⚠ Need at least 2 configuration versions to test archive functionality" + " Need at least 2 configuration versions to test archive functionality" ) print( " This is not a test failure - create more configuration versions first" @@ -604,7 +602,7 @@ def main(): try: client.configuration_versions.archive(cv_to_archive.id) - print(" āœ“ Archive request sent successfully") + print(" Archive request sent successfully") # Check status after archive request print("\n Checking status after archive request:") @@ -615,30 +613,30 @@ def main(): ) print(f" Status after archive: {updated_cv.status}") if updated_cv.status.value == "archived": - print(" āœ“ Successfully archived") + print(" Successfully archived") else: - print(" ⚠ Still processing (archive may take time)") + print(" Still processing (archive may take time)") except Exception: print( - " ⚠ Could not read status after archive (may be expected)" + " Could not read status after archive (may be expected)" ) except Exception as e: if "404" in str(e) or "not found" in str(e).lower(): - print(" ⚠ CV may have been auto-archived or removed") + print(" CV may have been auto-archived or removed") elif "current" in str(e).lower(): - print(" ⚠ Cannot archive current configuration version") + print(" Cannot archive current configuration version") print( - " āœ“ Function correctly handles 'current' CV restriction" + " Function correctly handles 'current' CV restriction" ) else: - print(f" ⚠ Archive failed: {type(e).__name__}: {e}") + print(f" Archive failed: {type(e).__name__}: {e}") else: - print("\n ⚠ No suitable configuration versions found for archiving") + print("\n No suitable configuration versions found for archiving") print( " Need at least 2 uploaded CVs (to avoid archiving current one)" ) - print(" āœ“ Function correctly validates archivable CVs") + print(" Function correctly validates archivable CVs") # Test archiving already archived CV if already_archived: @@ -648,12 +646,12 @@ def main(): try: client.configuration_versions.archive(already_archived_cv.id) - print(" āœ“ Handled gracefully (no-op for already archived)") + print(" Handled gracefully (no-op for already archived)") except Exception as e: - print(f" āœ“ Correctly rejected: {type(e).__name__}") + print(f" Correctly rejected: {type(e).__name__}") except Exception as e: - print(f" āœ— Error: {e}") + print(f" Error: {e}") import traceback traceback.print_exc() @@ -673,7 +671,7 @@ def main(): created_cv_id, read_options ) - print(f" āœ“ Read configuration version with options: {cv_with_options.id}") + print(f" Read configuration version with options: {cv_with_options.id}") print(f" Status: {cv_with_options.status}") print(f" Source: {cv_with_options.source}") @@ -681,7 +679,7 @@ def main(): hasattr(cv_with_options, "ingress_attributes") and cv_with_options.ingress_attributes ): - print(" āœ“ Ingress attributes included in response") + print(" Ingress attributes included in response") if hasattr(cv_with_options.ingress_attributes, "branch"): print(f" Branch: {cv_with_options.ingress_attributes.branch}") if hasattr(cv_with_options.ingress_attributes, "clone_url"): @@ -689,17 +687,17 @@ def main(): f" Clone URL: {cv_with_options.ingress_attributes.clone_url}" ) else: - print(" ⚠ No ingress attributes (expected for API-created CVs)") + print(" No ingress attributes (expected for API-created CVs)") print(" Ingress attributes are only present for VCS-connected CVs") except Exception as e: - print(f" āœ— Error: {e}") + print(f" Error: {e}") import traceback traceback.print_exc() else: print("\n7. Testing read_with_options() function:") - print(" ⚠ Skipped - no configuration version created for testing") + print(" Skipped - no configuration version created for testing") # ===================================================== # TEST 8: CREATE FOR REGISTRY MODULE (BETA) @@ -723,30 +721,30 @@ def main(): registry_cv = client.configuration_versions.create_for_registry_module( module_id ) - print(f" āœ“ Created registry module CV: {registry_cv.id}") + print(f" Created registry module CV: {registry_cv.id}") print(f" Status: {registry_cv.status}") print(f" Source: {registry_cv.source}") except Exception as e: if "404" in str(e) or "not found" in str(e).lower(): print( - " ⚠ Registry module not found (expected - requires actual module)" + " Registry module not found (expected - requires actual module)" ) print(" Function exists and properly handles missing modules") elif "403" in str(e) or "forbidden" in str(e).lower(): - print(" ⚠ No permission to access registry modules (expected)") + print(" No permission to access registry modules (expected)") print(" Function exists and properly handles permission errors") elif "AttributeError" in str(e): - print(f" ⚠ Function parameter error: {e}") + print(f" Function parameter error: {e}") print(" Function exists but may need parameter adjustment") else: print( - f" ⚠ Registry module CV creation failed: {type(e).__name__}: {e}" + f" Registry module CV creation failed: {type(e).__name__}: {e}" ) print(" This may be expected if no registry modules exist") except Exception as e: - print(f" āœ— Error: {e}") + print(f" Error: {e}") import traceback traceback.print_exc() @@ -795,7 +793,7 @@ def main(): client.configuration_versions.upload_tar_gzip( upload_url, archive_buffer ) - print(" āœ“ Direct tar.gz upload successful!") + print(" Direct tar.gz upload successful!") # Check status after upload time.sleep(2) @@ -805,13 +803,13 @@ def main(): print(f" Status after upload: {updated_upload_cv.status}") except Exception as e: - print(f" ⚠ Upload failed: {type(e).__name__}: {e}") + print(f" Upload failed: {type(e).__name__}: {e}") print(" This may be expected depending on TFE configuration") else: - print(" ⚠ No upload URL available - cannot test upload_tar_gzip") + print(" No upload URL available - cannot test upload_tar_gzip") except Exception as e: - print(f" āœ— Error: {e}") + print(f" Error: {e}") import traceback traceback.print_exc() @@ -831,29 +829,29 @@ def main(): print("\n 10a. Testing soft_delete_backing_data():") try: client.configuration_versions.soft_delete_backing_data(created_cv_id) - print(" āœ“ Soft delete backing data request sent successfully") + print(" Soft delete backing data request sent successfully") except Exception as e: if "404" in str(e) or "not found" in str(e).lower(): - print(" ⚠ CV not found for backing data operation") + print(" CV not found for backing data operation") elif "403" in str(e) or "forbidden" in str(e).lower(): - print(" ⚠ Enterprise feature - not available (expected)") + print(" Enterprise feature - not available (expected)") else: - print(f" ⚠ Soft delete failed: {type(e).__name__}: {e}") - print(" āœ“ Function exists and properly handles Enterprise restrictions") + print(f" Soft delete failed: {type(e).__name__}: {e}") + print(" Function exists and properly handles Enterprise restrictions") # Test restore backing data print("\n 10b. Testing restore_backing_data():") try: client.configuration_versions.restore_backing_data(created_cv_id) - print(" āœ“ Restore backing data request sent successfully") + print(" Restore backing data request sent successfully") except Exception as e: if "404" in str(e) or "not found" in str(e).lower(): - print(" ⚠ CV not found for backing data operation") + print(" CV not found for backing data operation") elif "403" in str(e) or "forbidden" in str(e).lower(): - print(" ⚠ Enterprise feature - not available (expected)") + print(" Enterprise feature - not available (expected)") else: - print(f" ⚠ Restore failed: {type(e).__name__}: {e}") - print(" āœ“ Function exists and properly handles Enterprise restrictions") + print(f" Restore failed: {type(e).__name__}: {e}") + print(" Function exists and properly handles Enterprise restrictions") # Test permanently delete backing data print("\n 10c. Testing permanently_delete_backing_data():") @@ -871,15 +869,15 @@ def main(): client.configuration_versions.permanently_delete_backing_data( perm_delete_cv_id ) - print(" āœ“ Permanent delete backing data request sent successfully") + print(" Permanent delete backing data request sent successfully") except Exception as e: if "404" in str(e) or "not found" in str(e).lower(): - print(" ⚠ CV not found for backing data operation") + print(" CV not found for backing data operation") elif "403" in str(e) or "forbidden" in str(e).lower(): - print(" ⚠ Enterprise feature - not available (expected)") + print(" Enterprise feature - not available (expected)") else: - print(f" ⚠ Permanent delete failed: {type(e).__name__}: {e}") - print(" āœ“ Function exists and properly handles Enterprise restrictions") + print(f" Permanent delete failed: {type(e).__name__}: {e}") + print(" Function exists and properly handles Enterprise restrictions") # ===================================================== # TEST SUMMARY @@ -887,20 +885,18 @@ def main(): print("\n" + "=" * 80) print("CONFIGURATION VERSION COMPLETE TESTING SUMMARY") print("=" * 80) - print("āœ… TEST 1: list() - List configuration versions for workspace") - print( - "āœ… 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 5: download() - Download configuration version archives") - print("āœ… TEST 6: archive() - Archive configuration versions") - print("āœ… TEST 7: read_with_options() - Read with include options") - print("āœ… TEST 8: create_for_registry_module() - Registry module CVs (BETA)") - print("āœ… TEST 9: upload_tar_gzip() - Direct tar.gz archive upload") + print("TEST 1: list() - List configuration versions for workspace") print( - "āœ… TEST 10: Enterprise backing data operations (soft/restore/permanent delete)" + "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 5: download() - Download configuration version archives") + print("TEST 6: archive() - Archive configuration versions") + print("TEST 7: read_with_options() - Read with include options") + print("TEST 8: create_for_registry_module() - Registry module CVs (BETA)") + print("TEST 9: upload_tar_gzip() - Direct tar.gz archive upload") + print("TEST 10: Enterprise backing data operations (soft/restore/permanent delete)") print("=" * 80) print("ALL 12 configuration version functions have been tested!") print("Review the output above for any errors or warnings.") diff --git a/examples/notification_configuration.py b/examples/notification_configuration.py index 07360f3..ea20380 100644 --- a/examples/notification_configuration.py +++ b/examples/notification_configuration.py @@ -80,10 +80,10 @@ def main(): except Exception as e: error_msg = str(e).lower() if "not found" in error_msg: - print(f" āš ļø Team not found (expected with fake team ID): {team_id}") - print(" šŸ’” Teams are not available in HCP Terraform free plan") + print(f" Team not found (expected with fake team ID): {team_id}") + print(" Teams are not available in HCP Terraform free plan") else: - print(f" āŒ Error listing team notifications: {e}") + print(f" Error listing team notifications: {e}") print() @@ -150,13 +150,13 @@ def main(): notification_config_id=notification_id ) print( - f" āœ… Verification successful for notification ID: {notification_id}" + f" Verification successful for notification ID: {notification_id}" ) print(" Note: Verification sends a test payload to the configured URL") except Exception as e: - print(f" āš ļø Verification failed (expected with fake URL): {e}") + print(f" Verification failed (expected with fake URL): {e}") print( - " šŸ’” To test verification, use a real webhook URL from Slack, Teams, or Discord" + " To test verification, use a real webhook URL from Slack, Teams, or Discord" ) # ===== Delete the notification configuration ===== @@ -178,15 +178,13 @@ 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 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") else: - print(f" āŒ Error in workspace notification operations: {e}") + print(f" Error in workspace notification operations: {e}") print() @@ -229,14 +227,14 @@ def main(): ) except Exception as e: - print(f" āŒ Error in team notification operations: {e}") + print(f" Error in team notification operations: {e}") error_msg = str(e).lower() if "not found" in error_msg: - print(" šŸ’” Team may not exist or token lacks team permissions") + print(" Team may not exist or token lacks team permissions") elif "forbidden" in error_msg or "unauthorized" in error_msg: - print(" šŸ’” Token may lack team notification permissions") + print(" Token may lack team notification permissions") elif "team" in error_msg: - print(" šŸ’” Team-specific error - check team settings or plan level") + print(" Team-specific error - check team settings or plan level") print() diff --git a/examples/oauth_client.py b/examples/oauth_client.py index 96671dd..b4c7aa2 100644 --- a/examples/oauth_client.py +++ b/examples/oauth_client.py @@ -71,7 +71,7 @@ def main(): github_token = os.getenv("OAUTH_CLIENT_GITHUB_TOKEN") if not github_token: print( - "\n⚠ WARNING: OAUTH_CLIENT_GITHUB_TOKEN not set. GitHub-related tests will be skipped." + "\n WARNING: OAUTH_CLIENT_GITHUB_TOKEN not set. GitHub-related tests will be skipped." ) print( "Set this environment variable to test OAuth client creation with GitHub." @@ -89,7 +89,7 @@ def main(): # Test basic list without options oauth_clients = list(client.oauth_clients.list(organization_name)) - print(f" āœ“ Found {len(oauth_clients)} OAuth clients") + print(f" Found {len(oauth_clients)} OAuth clients") for i, oauth_client in enumerate(oauth_clients[:3], 1): print(f" {i}. {oauth_client.id} - {oauth_client.service_provider}") @@ -111,7 +111,7 @@ def main(): client.oauth_clients.list(organization_name, options) ) print( - f" āœ“ Found {len(oauth_clients_with_options)} OAuth clients with options" + f" Found {len(oauth_clients_with_options)} OAuth clients with options" ) if oauth_clients_with_options: @@ -124,7 +124,7 @@ def main(): ) except Exception as e: - print(f" āœ— Error listing OAuth clients: {e}") + print(f" Error listing OAuth clients: {e}") # ===================================================== # TEST 2: CREATE OAUTH CLIENT @@ -152,7 +152,7 @@ def main(): created_oauth_client = client.oauth_clients.create( organization_name, create_options ) - print(f" āœ“ Created OAuth client: {created_oauth_client.id}") + print(f" Created OAuth client: {created_oauth_client.id}") print(f" Name: {created_oauth_client.name}") print(f" Service Provider: {created_oauth_client.service_provider}") print(f" API URL: {created_oauth_client.api_url}") @@ -162,9 +162,9 @@ def main(): ) except Exception as e: - print(f" āœ— Error creating OAuth client: {e}") + print(f" Error creating OAuth client: {e}") else: - print(" ⚠ Skipped - OAUTH_CLIENT_GITHUB_TOKEN not set") + print(" Skipped - OAUTH_CLIENT_GITHUB_TOKEN not set") # ===================================================== # TEST 3: READ OAUTH CLIENT @@ -178,7 +178,7 @@ def main(): print(f"Reading OAuth client: {created_oauth_client.id}") read_oauth_client = client.oauth_clients.read(created_oauth_client.id) - print(f" āœ“ Read OAuth client: {read_oauth_client.id}") + print(f" Read OAuth client: {read_oauth_client.id}") print(f" Name: {read_oauth_client.name}") print(f" Service Provider: {read_oauth_client.service_provider}") print(f" Created At: {read_oauth_client.created_at}") @@ -186,7 +186,7 @@ def main(): print(f" Connect Path: {read_oauth_client.connect_path}") except Exception as e: - print(f" āœ— Error reading OAuth client: {e}") + print(f" Error reading OAuth client: {e}") else: # Try to read an existing OAuth client if no client was created try: @@ -196,12 +196,12 @@ def main(): print(f"Reading existing OAuth client: {test_client.id}") read_oauth_client = client.oauth_clients.read(test_client.id) - print(f" āœ“ Read existing OAuth client: {read_oauth_client.id}") + print(f" Read existing OAuth client: {read_oauth_client.id}") print(f" Service Provider: {read_oauth_client.service_provider}") else: - print(" ⚠ No existing OAuth clients found to test read()") + print(" No existing OAuth clients found to test read()") except Exception as e: - print(f" āœ— Error reading existing OAuth client: {e}") + print(f" Error reading existing OAuth client: {e}") # ===================================================== # TEST 4: READ OAUTH CLIENT WITH OPTIONS @@ -234,7 +234,7 @@ def main(): read_oauth_client = client.oauth_clients.read_with_options( target_client.id, read_options ) - print(f" āœ“ Read OAuth client with options: {read_oauth_client.id}") + print(f" Read OAuth client with options: {read_oauth_client.id}") print(f" OAuth Tokens: {len(read_oauth_client.oauth_tokens or [])}") print(f" Projects: {len(read_oauth_client.projects or [])}") @@ -245,9 +245,9 @@ def main(): print(f" {i}. Token ID: {token.get('id', 'N/A')}") except Exception as e: - print(f" āœ— Error reading OAuth client with options: {e}") + print(f" Error reading OAuth client with options: {e}") else: - print(" ⚠ No OAuth client available to test read_with_options()") + print(" No OAuth client available to test read_with_options()") # ===================================================== # TEST 5: UPDATE OAUTH CLIENT @@ -268,7 +268,7 @@ def main(): updated_oauth_client = client.oauth_clients.update( created_oauth_client.id, update_options ) - print(f" āœ“ Updated OAuth client: {updated_oauth_client.id}") + print(f" Updated OAuth client: {updated_oauth_client.id}") print(f" Updated Name: {updated_oauth_client.name}") print( f" Updated Organization Scoped: {updated_oauth_client.organization_scoped}" @@ -278,9 +278,9 @@ def main(): created_oauth_client = updated_oauth_client except Exception as e: - print(f" āœ— Error updating OAuth client: {e}") + print(f" Error updating OAuth client: {e}") else: - print(" ⚠ No OAuth client created to test update()") + print(" No OAuth client created to test update()") # ===================================================== # TEST 6: PREPARE TEST PROJECTS (for project operations) @@ -298,7 +298,7 @@ def main(): {"type": "projects", "id": project.id} for project in projects[:2] ] print( - f" āœ“ Found {len(projects)} projects, using {len(test_projects)} for testing:" + f" Found {len(projects)} projects, using {len(test_projects)} for testing:" ) for i, project_ref in enumerate(test_projects, 1): corresponding_project = projects[i - 1] @@ -306,10 +306,10 @@ def main(): f" {i}. {corresponding_project.name} (ID: {project_ref['id']})" ) else: - print(" ⚠ No projects found - project operations tests will be skipped") + print(" No projects found - project operations tests will be skipped") except Exception as e: - print(f" ⚠ Error getting projects: {e}") + print(f" Error getting projects: {e}") # ===================================================== # TEST 7: ADD PROJECTS TO OAUTH CLIENT @@ -327,7 +327,7 @@ def main(): client.oauth_clients.add_projects(created_oauth_client.id, add_options) print( - f" āœ“ Successfully added {len(test_projects)} projects to OAuth client" + f" Successfully added {len(test_projects)} projects to OAuth client" ) # Verify the projects were added by reading the client with projects included @@ -338,16 +338,16 @@ def main(): created_oauth_client.id, read_options ) print( - f" āœ“ Verification: OAuth client now has {len(updated_client.projects or [])} projects" + f" Verification: OAuth client now has {len(updated_client.projects or [])} projects" ) except Exception as e: - print(f" āœ— Error adding projects to OAuth client: {e}") + print(f" Error adding projects to OAuth client: {e}") else: if not created_oauth_client: - print(" ⚠ No OAuth client created to test add_projects()") + print(" No OAuth client created to test add_projects()") if not test_projects: - print(" ⚠ No projects available to test add_projects()") + print(" No projects available to test add_projects()") # ===================================================== # TEST 8: REMOVE PROJECTS FROM OAUTH CLIENT @@ -367,7 +367,7 @@ def main(): created_oauth_client.id, remove_options ) print( - f" āœ“ Successfully removed {len(test_projects)} projects from OAuth client" + f" Successfully removed {len(test_projects)} projects from OAuth client" ) # Verify the projects were removed by reading the client with projects included @@ -378,16 +378,16 @@ def main(): created_oauth_client.id, read_options ) print( - f" āœ“ Verification: OAuth client now has {len(updated_client.projects or [])} projects" + f" Verification: OAuth client now has {len(updated_client.projects or [])} projects" ) except Exception as e: - print(f" āœ— Error removing projects from OAuth client: {e}") + print(f" Error removing projects from OAuth client: {e}") else: if not created_oauth_client: - print(" ⚠ No OAuth client created to test remove_projects()") + print(" No OAuth client created to test remove_projects()") if not test_projects: - print(" ⚠ No projects available to test remove_projects()") + print(" No projects available to test remove_projects()") # ===================================================== # TEST 9: DELETE OAUTH CLIENT @@ -403,29 +403,27 @@ def main(): # First, let's confirm it exists try: client.oauth_clients.read(created_oauth_client.id) - print(" āœ“ Confirmed OAuth client exists before deletion") + print(" Confirmed OAuth client exists before deletion") except NotFound: - print(" ⚠ OAuth client not found before deletion attempt") + print(" OAuth client not found before deletion attempt") # Delete the OAuth client client.oauth_clients.delete(created_oauth_client.id) - print(f" āœ“ Successfully deleted OAuth client: {created_oauth_client.id}") + print(f" Successfully deleted OAuth client: {created_oauth_client.id}") # Verify deletion by trying to read it try: client.oauth_clients.read(created_oauth_client.id) - print(" ⚠ Warning: OAuth client still exists after deletion") + print(" Warning: OAuth client still exists after deletion") except NotFound: - print( - " āœ“ Verification: OAuth client successfully deleted (not found)" - ) + print(" Verification: OAuth client successfully deleted (not found)") except Exception as e: print(f" ? Verification error: {e}") except Exception as e: - print(f" āœ— Error deleting OAuth client: {e}") + print(f" Error deleting OAuth client: {e}") else: - print(" ⚠ No OAuth client created to test delete()") + print(" No OAuth client created to test delete()") # ===================================================== # SUMMARY @@ -434,14 +432,14 @@ def main(): print("OAUTH CLIENT TESTING COMPLETE") print("=" * 80) print("Functions tested:") - print("āœ“ 1. list() - List OAuth clients for organization") - print("āœ“ 2. create() - Create OAuth client with VCS provider") - print("āœ“ 3. read() - Read OAuth client by ID") - print("āœ“ 4. read_with_options() - Read OAuth client with includes") - print("āœ“ 5. update() - Update existing OAuth client") - print("āœ“ 6. add_projects() - Add projects to OAuth client") - print("āœ“ 7. remove_projects() - Remove projects from OAuth client") - print("āœ“ 8. delete() - Delete OAuth client") + print(" 1. list() - List OAuth clients for organization") + print(" 2. create() - Create OAuth client with VCS provider") + print(" 3. read() - Read OAuth client by ID") + print(" 4. read_with_options() - Read OAuth client with includes") + print(" 5. update() - Update existing OAuth client") + print(" 6. add_projects() - Add projects to OAuth client") + print(" 7. remove_projects() - Remove projects from OAuth client") + print(" 8. delete() - Delete OAuth client") print("\nAll OAuth client functions have been tested!") print("Check the output above for any errors or warnings.") print("=" * 80) diff --git a/examples/oauth_token.py b/examples/oauth_token.py index 725fb59..5a0ae1d 100644 --- a/examples/oauth_token.py +++ b/examples/oauth_token.py @@ -57,7 +57,7 @@ def main(): try: # Test basic list without options token_list = client.oauth_tokens.list(organization_name) - print(f" āœ“ Found {len(token_list.items)} OAuth tokens") + print(f" Found {len(token_list.items)} OAuth tokens") # Show token details for i, token in enumerate(token_list.items[:3], 1): # Show first 3 @@ -78,7 +78,7 @@ def main(): print("\n Testing list() with pagination options:") options = OAuthTokenListOptions(page_size=10, page_number=1) token_list_with_options = client.oauth_tokens.list(organization_name, options) - print(f" āœ“ Found {len(token_list_with_options.items)} tokens with options") + print(f" Found {len(token_list_with_options.items)} tokens with options") if token_list_with_options.current_page: print(f" Current page: {token_list_with_options.current_page}") if token_list_with_options.total_count: @@ -86,10 +86,10 @@ def main(): except NotFound: print( - " āœ“ No OAuth tokens found (organization may not exist or no tokens available)" + " No OAuth tokens found (organization may not exist or no tokens available)" ) except Exception as e: - print(f" āœ— Error: {e}") + print(f" Error: {e}") # ===================================================== # TEST 2: READ OAUTH TOKEN @@ -98,7 +98,7 @@ def main(): print("\n2. Testing read() function:") try: token = client.oauth_tokens.read(test_token_id) - print(f" āœ“ Read OAuth token: {token.id}") + print(f" Read OAuth token: {token.id}") print(f" UID: {token.uid}") print(f" Service Provider User: {token.service_provider_user}") print(f" Has SSH Key: {token.has_ssh_key}") @@ -107,10 +107,10 @@ def main(): print(f" OAuth Client: {token.oauth_client.id}") except Exception as e: - print(f" āœ— Error: {e}") + print(f" Error: {e}") else: print("\n2. Testing read() function:") - print(" ⚠ Skipped - No OAuth token available to read") + print(" Skipped - No OAuth token available to read") # ===================================================== # TEST 3: UPDATE OAUTH TOKEN @@ -125,23 +125,23 @@ def main(): options = OAuthTokenUpdateOptions(private_ssh_key=ssh_key) updated_token = client.oauth_tokens.update(test_token_id, options) - print(f" āœ“ Updated OAuth token: {updated_token.id}") + print(f" Updated OAuth token: {updated_token.id}") print(f" Has SSH Key after update: {updated_token.has_ssh_key}") # Test updating without SSH key (no changes) print("\n Testing update without changes...") options_empty = OAuthTokenUpdateOptions() updated_token_2 = client.oauth_tokens.update(test_token_id, options_empty) - print(f" āœ“ Updated OAuth token (no changes): {updated_token_2.id}") + print(f" Updated OAuth token (no changes): {updated_token_2.id}") except Exception as e: - print(f" āœ— Error: {e}") + print(f" Error: {e}") print( " Note: This may fail if the SSH key format is invalid or constraints apply" ) else: print("\n3. Testing update() function:") - print(" ⚠ Skipped - No OAuth token available to update") + print(" Skipped - No OAuth token available to update") # ===================================================== # TEST 4: DELETE OAUTH TOKEN @@ -154,19 +154,19 @@ def main(): try: print(f" Attempting to delete OAuth token: {delete_token_id}") client.oauth_tokens.delete(delete_token_id) - print(f" āœ“ Successfully deleted OAuth token: {delete_token_id}") + print(f" Successfully deleted OAuth token: {delete_token_id}") # Verify deletion by trying to read the token try: client.oauth_tokens.read(delete_token_id) - print(" āœ— Token still exists after deletion!") + print(" Token still exists after deletion!") except NotFound: - print(" āœ“ Confirmed token was deleted - no longer accessible") + print(" Confirmed token was deleted - no longer accessible") except Exception as e: print(f" ? Verification failed: {e}") except Exception as e: - print(f" āœ— Error deleting token: {e}") + print(f" Error deleting token: {e}") # Uncomment the following section ONLY if you have a disposable OAuth token # WARNING: This will permanently delete the OAuth token! @@ -175,21 +175,21 @@ def main(): try: print(f" Attempting to delete OAuth token: {test_token_id}") client.oauth_tokens.delete(test_token_id) - print(f" āœ“ Successfully deleted OAuth token: {test_token_id}") + print(f" Successfully deleted OAuth token: {test_token_id}") # Verify deletion by trying to read the token try: client.oauth_tokens.read(test_token_id) - print(f" āœ— Token still exists after deletion!") + print(f" Token still exists after deletion!") except NotFound: - print(f" āœ“ Confirmed token was deleted - no longer accessible") + print(f" Confirmed token was deleted - no longer accessible") except Exception as e: print(f" ? Verification failed: {e}") except Exception as e: - print(f" āœ— Error deleting token: {e}") + print(f" Error deleting token: {e}") else: - print(" ⚠ Skipped - No OAuth token available to delete") + print(" Skipped - No OAuth token available to delete") """ # ===================================================== @@ -199,10 +199,10 @@ def main(): print("OAUTH TOKEN TESTING COMPLETE") print("=" * 80) print("Functions tested:") - print("āœ“ 1. list() - List OAuth tokens for organization") - print("āœ“ 2. read() - Read OAuth token by ID") - print("āœ“ 3. update() - Update existing OAuth token") - print("āœ“ 4. delete() - Delete OAuth token (testing with ot-WQf5ARHA1Qxzo9d4)") + print(" 1. list() - List OAuth tokens for organization") + print(" 2. read() - Read OAuth token by ID") + print(" 3. update() - Update existing OAuth token") + print(" 4. delete() - Delete OAuth token (testing with ot-WQf5ARHA1Qxzo9d4)") print("") print("All OAuth token functions have been tested!") print("Check the output above for any errors or warnings.") diff --git a/examples/org.py b/examples/org.py index 93b8f0f..eb767b2 100644 --- a/examples/org.py +++ b/examples/org.py @@ -16,7 +16,7 @@ def test_basic_org_operations(client): try: org_list = client.organizations.list() orgs = list(org_list) - print(f" āœ“ Found {len(orgs)} organizations") + print(f" Found {len(orgs)} organizations") # Show first few organizations for i, org in enumerate(orgs[:5], 1): @@ -30,7 +30,7 @@ def test_basic_org_operations(client): return orgs[0].name if orgs else None # Return first org name for testing except Exception as e: - print(f" āœ— Error listing organizations: {e}") + print(f" Error listing organizations: {e}") return None @@ -42,31 +42,31 @@ def test_org_read_operations(client, org_name): print("\n1. Reading Organization Details:") try: org = client.organizations.read(org_name) - print(f" āœ“ Organization: {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}") + print(f" Error reading organization: {e}") # Test capacity print("\n2. Reading Organization Capacity:") try: capacity = client.organizations.read_capacity(org_name) - print(" āœ“ Capacity:") + 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}") + print(f" Error reading capacity: {e}") # Test entitlements print("\n3. Reading Organization Entitlements:") try: entitlements = client.organizations.read_entitlements(org_name) - print(" āœ“ Entitlements:") + print(" Entitlements:") print(f" Operations: {entitlements.operations}") print(f" Teams: {entitlements.teams}") print(f" State Storage: {entitlements.state_storage}") @@ -76,14 +76,14 @@ def test_org_read_operations(client, org_name): print(f" Private Module Registry: {entitlements.private_module_registry}") print(f" SSO: {entitlements.sso}") except Exception as e: - print(f" āœ— Error reading entitlements: {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(" Run Queue:") print(f" Items in queue: {len(run_queue.items)}") if run_queue.pagination: @@ -98,7 +98,7 @@ def test_org_read_operations(client, org_name): print(f" ... and {len(run_queue.items) - 3} more runs") except Exception as e: - print(f" āœ— Error reading run queue: {e}") + print(f" Error reading run queue: {e}") def test_data_retention_policies(client, org_name): @@ -111,27 +111,27 @@ def test_data_retention_policies(client, org_name): try: policy_choice = client.organizations.read_data_retention_policy_choice(org_name) if policy_choice is None: - print(" āœ“ No data retention policy currently configured") + 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})" + 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})") + 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})" + 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)" + " Data retention policies not available (Terraform Enterprise feature)" ) else: - print(f" āœ— Error reading data retention policy: {e}") + print(f" Error reading data retention policy: {e}") # Test setting delete older policy print("\n2. Setting Delete Older Data Retention Policy (30 days):") @@ -140,14 +140,14 @@ def test_data_retention_policies(client, org_name): policy = client.organizations.set_data_retention_policy_delete_older( org_name, options ) - print(" āœ“ Created Delete Older Policy:") + 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)") + print(" Feature not available (Terraform Enterprise only)") else: - print(f" āœ— Error setting delete older policy: {e}") + print(f" Error setting delete older policy: {e}") # Test updating delete older policy print("\n3. Updating Delete Older Policy (15 days):") @@ -156,14 +156,14 @@ def test_data_retention_policies(client, org_name): policy = client.organizations.set_data_retention_policy_delete_older( org_name, options ) - print(" āœ“ Updated Delete Older Policy:") + 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)") + print(" Feature not available (Terraform Enterprise only)") else: - print(f" āœ— Error updating delete older policy: {e}") + print(f" Error updating delete older policy: {e}") # Test setting don't delete policy print("\n4. Setting Don't Delete Data Retention Policy:") @@ -172,59 +172,59 @@ def test_data_retention_policies(client, org_name): policy = client.organizations.set_data_retention_policy_dont_delete( org_name, options ) - print(" āœ“ Created Don't Delete Policy:") + 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)") + print(" Feature not available (Terraform Enterprise only)") else: - print(f" āœ— Error setting don't delete policy: {e}") + 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") + 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)" + 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") + 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" + 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)") + print(" Feature not available (Terraform Enterprise only)") else: - print(f" āœ— Error reading updated policy: {e}") + 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") + 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") + print(" Verified: No policy configured after deletion") else: - print(" ⚠ Policy still exists after deletion attempt") + 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)") + print(" Feature not available (Terraform Enterprise only)") else: - print(f" āœ— Error deleting policy: {e}") + print(f" Error deleting policy: {e}") def test_organization_creation_and_cleanup(client): @@ -239,24 +239,24 @@ def test_organization_creation_and_cleanup(client): 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" 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}") + 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") + print(" Successfully deleted test organization") return True except Exception as e: - print(f" ⚠ Organization creation/deletion test skipped: {e}") + print(f" Organization creation/deletion test skipped: {e}") print( " This is normal if you don't have organization management permissions" ) @@ -265,15 +265,15 @@ def test_organization_creation_and_cleanup(client): def main(): """Main function to test all organization functionalities.""" - print("šŸš€ Python TFE Organization Functions Test Suite") + print(" Python TFE Organization Functions Test Suite") print("=" * 60) # Initialize client try: client = TFEClient(TFEConfig.from_env()) - print("āœ“ TFE Client initialized successfully") + print(" TFE Client initialized successfully") except Exception as e: - print(f"āœ— Failed to initialize TFE client: {e}") + print(f" Failed to initialize TFE client: {e}") print( "Please ensure TF_CLOUD_ORGANIZATION and TF_CLOUD_TOKEN environment variables are set" ) @@ -282,7 +282,7 @@ def main(): # Test basic operations test_org_name = test_basic_org_operations(client) if not test_org_name: - print("\nāœ— Cannot continue without a valid organization") + print("\n Cannot continue without a valid organization") return 1 # Test read operations @@ -296,20 +296,20 @@ def main(): # Summary print("\n" + "=" * 60) - print("šŸ“Š Test Summary:") - print("āœ“ Basic organization operations tested") - print("āœ“ Organization read operations tested") - print("āœ“ Data retention policy operations tested") + 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") + print("Organization creation/deletion tested") else: - print("⚠ Organization creation/deletion skipped (permissions)") + print("Organization creation/deletion skipped (permissions)") print( - f"\nšŸŽÆ All available organization functions have been tested against '{test_org_name}'" + 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!") + print("\nTest suite completed successfully!") return 0 diff --git a/examples/organization_membership.py b/examples/organization_membership.py new file mode 100644 index 0000000..2fcc833 --- /dev/null +++ b/examples/organization_membership.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +""" +Example and test script for organization membership list functionality. + +Requirements: +- TFE_TOKEN environment variable set +- TFE_ADDRESS environment variable set (optional, defaults to Terraform Cloud) +- An organization with members to list + +Usage: + python examples/organization_membership.py +""" + +import sys + +from pytfe import TFEClient +from pytfe.models import ( + OrganizationMembershipListOptions, + OrganizationMembershipReadOptions, + OrganizationMembershipStatus, + OrgMembershipIncludeOpt, +) + + +def main(): + """Demonstrate organization membership list functionality.""" + + organization_name = "aayush-test" + + # Initialize the client (reads TFE_TOKEN and TFE_ADDRESS from environment) + try: + client = TFEClient() + print(" Connected to Terraform Cloud/Enterprise") + except Exception as e: + print(f" Error connecting: {e}") + print("\nMake sure TFE_TOKEN environment variable is set:") + print(" export TFE_TOKEN='your-token-here'") + sys.exit(1) + + print(f"\nTesting Organization Membership List for: {organization_name}") + print("=" * 70) + + # Test 1: List all organization memberships (no options) + print("\n[Test 1] List all organization memberships:") + try: + count = 0 + memberships_list = [] + for membership in client.organization_memberships.list(organization_name): + count += 1 + memberships_list.append(membership) + if count <= 5: # Show first 5 + print( + f" {membership.email} (ID: {membership.id[:8]}..., Status: {membership.status.value})" + ) + + print(memberships_list) + print(f" Total memberships: {count}") + + if count == 0: + print( + " No memberships found - organization may not exist or has no members" + ) + else: + print(f" Success: Retrieved {count} membership(s)") + except ValueError as e: + print(f" Validation Error: {e}") + except Exception as e: + print(f" Error: {type(e).__name__}: {e}") + + # Test 2: Iterate with custom page size + print("\n[Test 2] Iterate with custom page size (3 items per page):") + try: + options = OrganizationMembershipListOptions( + page_size=3, # Fetch 3 items per page + ) + count = 0 + for membership in client.organization_memberships.list( + organization_name, options + ): + count += 1 + if count <= 3: + print(f" {membership.email}") + + print(f" Processed {count} memberships (fetched in batches of 3)") + print(" Success: Pagination working correctly") + except Exception as e: + print(f" Error: {type(e).__name__}: {e}") + + # Test 3: Iterate with user relationships included + print("\n[Test 3] Iterate with user relationships included:") + try: + options = OrganizationMembershipListOptions( + include=[OrgMembershipIncludeOpt.USER], + ) + count = 0 + users_found = 0 + for membership in client.organization_memberships.list( + organization_name, options + ): + count += 1 + if membership.user: + users_found += 1 + if count <= 3: # Show first 3 + user_id = membership.user.id if membership.user else "N/A" + print(f" {membership.email} (User ID: {user_id})") + + print(f" Processed {count} memberships, {users_found} with user data") + print(" Success: Include parameter working") + except Exception as e: + print(f" Error: {type(e).__name__}: {e}") + + # Test 4: Filter by status (invited) + print("\n[Test 4] Filter by status (invited only):") + try: + options = OrganizationMembershipListOptions( + status=OrganizationMembershipStatus.INVITED, + ) + invited = [] + for membership in client.organization_memberships.list( + organization_name, options + ): + invited.append(membership.email) + if membership.status != OrganizationMembershipStatus.INVITED: + print(f" ERROR: Found non-invited member: {membership.email}") + + print(f" Found {len(invited)} invited membership(s)") + for email in invited[:5]: # Show first 5 + print(f" {email}") + + if len(invited) == 0: + print(" No invited members found") + print(" Success: Status filter working") + except Exception as e: + print(f" Error: {type(e).__name__}: {e}") + + # Test 5: Filter by email addresses (using first member found in Test 1) + print("\n[Test 5] Filter by specific email address:") + try: + if count > 0 and len(memberships_list) > 0: + test_email = memberships_list[0].email + print(f" Testing with email: {test_email}") + + options = OrganizationMembershipListOptions( + emails=[test_email], + ) + matching = [] + for membership in client.organization_memberships.list( + organization_name, options + ): + matching.append(membership.email) + + print(f" Found {len(matching)} matching membership(s)") + for email in matching: + print(f" {email}") + + if len(matching) == 1 and matching[0] == test_email: + print(" Success: Email filter working correctly") + else: + print(f" Warning: Expected 1 result with email {test_email}") + else: + print(" Skipped: No memberships available from Test 1") + except ValueError as e: + print(f" Validation Error: {e}") + except Exception as e: + print(f" Error: {type(e).__name__}: {e}") + + # Test 6: Search by query string + print("\n[Test 6] Search memberships by query string:") + try: + if count > 0 and len(memberships_list) > 0: + # Extract domain from first email for testing + test_email = memberships_list[0].email + domain = test_email.split("@")[1] if "@" in test_email else None + + if domain: + print(f" Searching for: {domain}") + options = OrganizationMembershipListOptions( + query=domain, # Searches in user name and email + ) + results = [] + for membership in client.organization_memberships.list( + organization_name, options + ): + results.append(membership.email) + + print(f" Found {len(results)} membership(s) matching query") + for email in results[:5]: # Show first 5 + print(f" {email}") + + if len(results) > 0: + print(" Success: Query filter working") + else: + print(f" Warning: No results found for query '{domain}'") + else: + print(" Skipped: Could not extract domain from email") + else: + print(" Skipped: No memberships available from Test 1") + except Exception as e: + print(f" Error: {type(e).__name__}: {e}") + + # Test 7: Combined filters (active + includes) + print("\n[Test 7] Combined filters: active members with user & teams included:") + try: + options = OrganizationMembershipListOptions( + status=OrganizationMembershipStatus.ACTIVE, + include=[OrgMembershipIncludeOpt.USER, OrgMembershipIncludeOpt.TEAMS], + page_size=5, + ) + active_members = [] + for membership in client.organization_memberships.list( + organization_name, options + ): + team_count = len(membership.teams) if membership.teams else 0 + has_user = membership.user is not None + active_members.append((membership.email, team_count, has_user)) + + print(f" Found {len(active_members)} active membership(s)") + for email, team_count, has_user in active_members[:5]: # Show first 5 + user_str = " User" if has_user else " No User" + print(f" {email} (Teams: {team_count}, {user_str})") + + if len(active_members) > 0: + print(" Success: Combined filters working") + else: + print(" No active members found") + except Exception as e: + print(f" Error: {type(e).__name__}: {e}") + + # Test 8: Read a specific organization membership + print("\n[Test 8] Read a specific organization membership:") + try: + if count > 0 and len(memberships_list) > 0: + test_membership_id = memberships_list[0].id + print(f" Reading membership ID: {test_membership_id}") + + membership = client.organization_memberships.read(test_membership_id) + print(f" Email: {membership.email}") + print(f" Status: {membership.status.value}") + print(f" ID: {membership.id}") + print(" Success: Read membership successfully") + else: + print(" Skipped: No memberships available from Test 1") + except Exception as e: + print(f" Error: {type(e).__name__}: {e}") + + # Test 9: Read with options (include user and teams) + print("\n[Test 9] Read membership with options (include user & teams):") + try: + if count > 0 and len(memberships_list) > 0: + test_membership_id = memberships_list[0].id + print(f" Reading membership ID: {test_membership_id}") + + read_options = OrganizationMembershipReadOptions( + include=[OrgMembershipIncludeOpt.USER, OrgMembershipIncludeOpt.TEAMS] + ) + membership = client.organization_memberships.read_with_options( + test_membership_id, read_options + ) + + print(f" Email: {membership.email}") + print(f" Status: {membership.status.value}") + user_id = membership.user.id if membership.user else "N/A" + print(f" User ID: {user_id}") + team_count = len(membership.teams) if membership.teams else 0 + print(f" Teams: {team_count}") + print(" Success: Read with options working") + else: + print(" Skipped: No memberships available from Test 1") + except Exception as e: + print(f" Error: {type(e).__name__}: {e}") + + # CREATE EXAMPLES + print("\n[Create Example] Add a new organization membership:") + try: + from pytfe.models import OrganizationMembershipCreateOptions, Team + + # Replace with a valid email for your organization + new_member_email = "sivaselvan.i@hashicorp.com" + + # Create membership with teams (uncomment to use) + from pytfe.models import OrganizationAccess + + team = Team( + id="team-dx24FR9xQUuwNTHA", + organization_access=OrganizationAccess(read_workspaces=True), + ) # Replace with actual team ID + create_options = OrganizationMembershipCreateOptions( + email=new_member_email, teams=[team] + ) + + created_membership = client.organization_memberships.create( + organization_name, create_options + ) + print(f" Created membership for: {created_membership.email}") + print(f" ID: {created_membership.id}") + print(f" Status: {created_membership.status.value}") + + except Exception as e: + print(f" Error: {type(e).__name__}: {e}") + + # Delete membership example + print("\n[Delete Example] Delete an organization membership:") + try: + from pytfe.errors import NotFound + + membership_id = "ou-9mG77c6uE5GScg9k" # Replace with actual membership ID + print(f" Attempting to delete membership: {membership_id}") + + client.organization_memberships.delete(membership_id) + print(f" āœ“ Successfully deleted membership {membership_id}") + + except NotFound as e: + print(f" āœ— Membership not found: {e}") + print(" ℹ The membership may have already been deleted or the ID is invalid") + except Exception as e: + print(f" āœ— Error deleting membership: {type(e).__name__}: {e}") + + +if __name__ == "__main__": + main() diff --git a/examples/project.py b/examples/project.py index 6999e4f..3935878 100644 --- a/examples/project.py +++ b/examples/project.py @@ -31,6 +31,7 @@ from pytfe._http import HTTPTransport from pytfe.config import TFEConfig +from pytfe.errors import NotFound from pytfe.models import ( ProjectAddTagBindingsOptions, ProjectCreateOptions, @@ -50,7 +51,7 @@ def integration_client(): if not token: pytest.skip( "TFE_TOKEN environment variable is required. " - "Get your token from HCP Terraform: Settings → API Tokens" + "Get your token from HCP Terraform: Settings API Tokens" ) if not org: @@ -59,8 +60,8 @@ def integration_client(): "Use your organization name from HCP Terraform URL" ) - print(f"\nšŸ”§ Testing against organization: {org}") - print(f"šŸ”§ Using token: {token[:10]}...") + print(f"\n Testing against organization: {org}") + print(f" Using token: {token[:10]}...") config = TFEConfig() @@ -95,9 +96,9 @@ def test_list_projects_integration(integration_client): try: # Test basic list without options - print("šŸ“‹ Testing LIST operation: basic list") + print(" Testing LIST operation: basic list") project_list = list(projects.list(org)) - print(f"āœ… Found {len(project_list)} projects in organization '{org}'") + print(f"Found {len(project_list)} projects in organization '{org}'") assert isinstance(project_list, list) @@ -111,18 +112,16 @@ def test_list_projects_integration(integration_client): 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}") + print(f" Example project: {project.name} (ID: {project.id})") + print(f" Created: {project.created_at}, Updated: {project.updated_at}") else: - print("šŸ“‹ No projects found - this is normal for a new organization") + print(" No projects found - this is normal for a new organization") # Test list with options - print("šŸ“‹ Testing LIST operation: 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" - ) + print(f"List with options returned {len(project_list_with_options)} projects") except Exception as e: pytest.fail( @@ -145,7 +144,7 @@ def test_create_project_integration(integration_client): try: # Test CREATE operation - print(f"šŸ”Ø Testing CREATE operation: {test_name}") + print(f" Testing CREATE operation: {test_name}") create_options = ProjectCreateOptions( name=test_name, description=test_description ) @@ -169,9 +168,9 @@ def test_create_project_integration(integration_client): ) project_id = created_project.id - print(f"āœ… CREATE successful: {project_id}") + print(f"CREATE successful: {project_id}") print( - f"āœ… Project details: {created_project.name} - {created_project.description}" + f"Project details: {created_project.name} - {created_project.description}" ) except Exception as e: @@ -181,11 +180,11 @@ def test_create_project_integration(integration_client): # Clean up created project if project_id: try: - print(f"šŸ—‘ļø Cleaning up created project: {project_id}") + print(f"Cleaning up created project: {project_id}") projects.delete(project_id) - print("āœ… Cleanup successful") + print("Cleanup successful") except Exception as e: - print(f"āŒ Warning: Failed to clean up project {project_id}: {e}") + print(f" Warning: Failed to clean up project {project_id}: {e}") def test_read_project_integration(integration_client): @@ -210,7 +209,7 @@ def test_read_project_integration(integration_client): project_id = created_project.id # Test READ operation - print(f"šŸ“– Testing READ operation: {project_id}") + print(f" Testing READ operation: {project_id}") read_project = projects.read(project_id) # Validate read project @@ -226,11 +225,11 @@ def test_read_project_integration(integration_client): 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}") + 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") + print("READ operation completed successfully") except Exception as e: pytest.fail(f"READ operation failed: {e}") @@ -239,11 +238,11 @@ def test_read_project_integration(integration_client): # Clean up created project if project_id: try: - print(f"šŸ—‘ļø Cleaning up read test project: {project_id}") + print(f"Cleaning up read test project: {project_id}") projects.delete(project_id) - print("āœ… Cleanup successful") + print("Cleanup successful") except Exception as e: - print(f"āŒ Warning: Failed to clean up project {project_id}: {e}") + print(f" Warning: Failed to clean up project {project_id}: {e}") def test_update_project_integration(integration_client): @@ -263,7 +262,7 @@ def test_update_project_integration(integration_client): try: # Create a project to update - print(f"šŸ”Ø Creating project for UPDATE test: {original_name}") + print(f" Creating project for UPDATE test: {original_name}") create_options = ProjectCreateOptions( name=original_name, description=original_description ) @@ -271,7 +270,7 @@ def test_update_project_integration(integration_client): project_id = created_project.id # Test UPDATE operation - name only - print("āœļø Testing UPDATE operation: name only") + print(" Testing UPDATE operation: name only") update_options = ProjectUpdateOptions(name=updated_name) updated_project = projects.update(project_id, update_options) @@ -284,10 +283,10 @@ def test_update_project_integration(integration_client): assert updated_project.description == original_description, ( "Description should remain unchanged" ) - print(f"āœ… UPDATE name successful: {updated_project.name}") + print(f"UPDATE name successful: {updated_project.name}") # Test UPDATE operation - description only - print("āœļø Testing UPDATE operation: description only") + print(" Testing UPDATE operation: description only") update_options = ProjectUpdateOptions(description=updated_description) updated_project = projects.update(project_id, update_options) @@ -295,12 +294,12 @@ def test_update_project_integration(integration_client): assert updated_project.description == updated_description, ( f"Expected updated description {updated_description}, got {updated_project.description}" ) - print("āœ… UPDATE description successful") + print("UPDATE description successful") # 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") + print(" Testing UPDATE operation: both name and description") update_options = ProjectUpdateOptions( name=final_name, description=final_description ) @@ -312,7 +311,7 @@ def test_update_project_integration(integration_client): 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}") + print(f"UPDATE both fields successful: {updated_project.name}") except Exception as e: pytest.fail(f"UPDATE operation failed: {e}") @@ -321,11 +320,11 @@ def test_update_project_integration(integration_client): # Clean up created project if project_id: try: - print(f"šŸ—‘ļø Cleaning up update test project: {project_id}") + print(f"Cleaning up update test project: {project_id}") projects.delete(project_id) - print("āœ… Cleanup successful") + print("Cleanup successful") except Exception as e: - print(f"āŒ Warning: Failed to clean up project {project_id}: {e}") + print(f" Warning: Failed to clean up project {project_id}: {e}") def test_delete_project_integration(integration_client): @@ -342,33 +341,33 @@ def test_delete_project_integration(integration_client): try: # Create a project to delete - print(f"šŸ”Ø Creating project for DELETE test: {test_name}") + 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}") + print(f"Project created for deletion: {project_id}") # Verify project exists - print("šŸ“– Verifying project exists before deletion") + 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}") + print(f"Project confirmed to exist: {read_project.name}") # Test DELETE operation - print(f"šŸ—‘ļø Testing DELETE operation: {project_id}") + print(f"Testing DELETE operation: {project_id}") projects.delete(project_id) - print("āœ… DELETE operation completed") + print("DELETE operation completed") # Verify project is deleted - print("šŸ“– Verifying 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") + print("Project successfully deleted - confirmed by 404 error") else: raise e @@ -382,7 +381,7 @@ def test_delete_project_integration(integration_client): # Additional cleanup attempt (should be unnecessary) if project_id: try: - print(f"šŸ—‘ļø Additional cleanup attempt: {project_id}") + print(f"Additional cleanup attempt: {project_id}") projects.delete(project_id) except Exception: pass # Project might already be deleted @@ -391,8 +390,8 @@ def test_delete_project_integration(integration_client): 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 + WARNING: This test creates and deletes real resources! + Tests complete workflow: CREATE READ UPDATE LIST DELETE """ projects, org = integration_client @@ -404,10 +403,10 @@ def test_comprehensive_crud_integration(integration_client): project_id = None try: - print(f"šŸ”„ Starting comprehensive CRUD test: {test_name}") + print(f" Starting comprehensive CRUD test: {test_name}") # 1. CREATE - print("1ļøāƒ£ CREATE: Creating project") + print("1 CREATE: Creating project") create_options = ProjectCreateOptions( name=test_name, description=test_description ) @@ -416,19 +415,19 @@ def test_comprehensive_crud_integration(integration_client): assert created_project.name == test_name assert created_project.description == test_description - print(f"āœ… CREATE: {project_id}") + print(f"CREATE: {project_id}") # 2. READ - print("2ļøāƒ£ READ: Reading created project") + print("2 READ: Reading created project") read_project = projects.read(project_id) 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(f"READ: {read_project.name}") # 3. UPDATE - print("3ļøāƒ£ UPDATE: Updating project") + print("3 UPDATE: Updating project") update_options = ProjectUpdateOptions( name=updated_name, description=updated_description ) @@ -437,10 +436,10 @@ def test_comprehensive_crud_integration(integration_client): assert updated_project.id == project_id assert updated_project.name == updated_name assert updated_project.description == updated_description - print(f"āœ… UPDATE: {updated_project.name}") + print(f"UPDATE: {updated_project.name}") # 4. LIST (verify updated project appears) - print("4ļøāƒ£ LIST: Verifying project appears in list") + print("4 LIST: Verifying project appears in list") project_list = list(projects.list(org)) found_project = None for p in project_list: @@ -452,26 +451,26 @@ def test_comprehensive_crud_integration(integration_client): f"Updated project {project_id} should appear in list" ) assert found_project.name == updated_name - print("āœ… LIST: Found updated project in list") + print("LIST: Found updated project in list") # 5. DELETE - print("5ļøāƒ£ DELETE: Deleting project") + print("5 DELETE: Deleting project") projects.delete(project_id) - print("āœ… DELETE: Project deleted") + print("DELETE: Project deleted") # 6. Verify deletion - print("6ļøāƒ£ VERIFY: Confirming 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") + print("VERIFY: Deletion confirmed") else: raise e project_id = None # Clear since deleted - print("šŸŽ‰ Comprehensive CRUD test completed successfully!") + print(" Comprehensive CRUD test completed successfully!") except Exception as e: pytest.fail(f"Comprehensive CRUD test failed: {e}") @@ -479,7 +478,7 @@ def test_comprehensive_crud_integration(integration_client): finally: if project_id: try: - print(f"šŸ—‘ļø Final cleanup: {project_id}") + print(f"Final cleanup: {project_id}") projects.delete(project_id) except Exception: pass @@ -492,14 +491,14 @@ def test_validation_integration(integration_client): """ projects, org = integration_client - print("šŸ” Testing validation with real API calls") + 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}") + print(f"Testing valid project creation: {valid_name}") create_options = ProjectCreateOptions( name=valid_name, description="Valid project" ) @@ -507,20 +506,20 @@ def test_validation_integration(integration_client): assert created_project.name == valid_name project_id = created_project.id - print(f"āœ… Valid project created successfully: {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}") + 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") + print("Valid project updated successfully") # Clean up projects.delete(project_id) - print("āœ… Validation test cleanup completed") + print("Validation test cleanup completed") except Exception as e: pytest.fail(f"Validation integration test failed: {e}") @@ -533,44 +532,42 @@ def test_error_handling_integration(integration_client): """ projects, org = integration_client - print("🚫 Testing error handling scenarios") + print(" Testing error handling scenarios") # Test reading a non-existent project - print("🚫 Testing read 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__}" - ) + 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") + 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: print( - f"āœ… Correctly handled update error for non-existent project: {type(e).__name__}" + 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") + 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: print( - f"āœ… Correctly handled delete error for non-existent project: {type(e).__name__}" + f"Correctly handled delete error for non-existent project: {type(e).__name__}" ) assert "404" in str(e) or "not found" in str(e).lower() - print("āœ… All error handling scenarios tested successfully") + print("All error handling scenarios tested successfully") def test_project_tag_bindings_integration(integration_client): @@ -589,42 +586,42 @@ def test_project_tag_bindings_integration(integration_client): try: # Create a test project for tagging operations - print(f"šŸ·ļø Setting up test project for tagging: {test_name}") + 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}") + print(f"Created test project: {project_id}") # Test 1: List tag bindings (this should work) - print("šŸ·ļø Testing LIST_TAG_BINDINGS") + 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") + 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}") + 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") + 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" + f"list_effective_tag_bindings works: {len(effective_bindings)} bindings" ) effective_tag_bindings_available = True except Exception as e: - print(f"āŒ list_effective_tag_bindings not available: {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") + print(" Testing ADD_TAG_BINDINGS") try: test_tags = [ TagBinding(key="environment", value="testing"), @@ -637,9 +634,7 @@ def test_project_tag_bindings_integration(integration_client): assert len(added_bindings) == len(test_tags), ( "Should return all added tags" ) - print( - f"āœ… add_tag_bindings works: added {len(added_bindings)} bindings" - ) + print(f"add_tag_bindings works: added {len(added_bindings)} bindings") # Verify tags were actually added current_bindings = projects.list_tag_bindings(project_id) @@ -648,12 +643,12 @@ def test_project_tag_bindings_integration(integration_client): assert tag.key in added_keys, ( f"Tag {tag.key} not found after adding" ) - print(f"āœ… Verified tags added: {len(current_bindings)} total bindings") + 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") + print(" Testing DELETE_TAG_BINDINGS") try: result = projects.delete_tag_bindings(project_id) assert result is None, "Delete should return None" @@ -661,15 +656,15 @@ def test_project_tag_bindings_integration(integration_client): # Verify deletion final_bindings = projects.list_tag_bindings(project_id) print( - f"āœ… delete_tag_bindings works: {len(final_bindings)} bindings remain" + 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}") + 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(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 @@ -678,7 +673,7 @@ def test_project_tag_bindings_integration(integration_client): delete_tag_bindings_available = False # Summary - print("\nšŸ“Š Project Tag Bindings API Availability 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), @@ -687,20 +682,20 @@ def test_project_tag_bindings_integration(integration_client): ] for feature_name, available in features: - status = "āœ… Available" if available else "āŒ Not Available" + 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" + 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!") + print(" All project tag binding operations work perfectly!") elif available_count > 0: - print("āœ… Partial functionality available - basic operations work!") + print("Partial functionality available - basic operations work!") else: - print("āš ļø Tag binding features may require a higher HCP Terraform plan") + print(" Tag binding features may require a higher HCP Terraform plan") except Exception as e: pytest.fail( @@ -714,10 +709,10 @@ def test_project_tag_bindings_integration(integration_client): try: print(f"🧹 Cleaning up test project: {project_id}") projects.delete(project_id) - print("āœ… Test project deleted successfully") + print("Test project deleted successfully") except Exception as cleanup_error: print( - f"āš ļø Warning: Failed to clean up test project {project_id}: {cleanup_error}" + f" Warning: Failed to clean up test project {project_id}: {cleanup_error}" ) @@ -732,10 +727,10 @@ def test_project_tag_bindings_error_scenarios(integration_client): """ projects, org = integration_client - print("šŸ·ļø Testing tag binding error scenarios") + print(" Testing tag binding error scenarios") # Test invalid project ID validation - print("🚫 Testing invalid project ID scenarios") + print(" Testing invalid project ID scenarios") invalid_project_ids = ["", "x", "invalid-id", None] @@ -746,41 +741,42 @@ def test_project_tag_bindings_error_scenarios(integration_client): try: projects.list_tag_bindings(invalid_id) pytest.fail( - f"Should have raised ValueError for invalid project ID: {invalid_id}" + f"Should have raised ValueError or NotFound for invalid project ID: {invalid_id}" ) - except ValueError as e: - print(f"āœ… Correctly rejected invalid project ID '{invalid_id}': {e}") - assert "Project ID is required and must be valid" in str(e) + 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 for invalid project ID: {invalid_id}" + f"Should have raised ValueError or NotFound for invalid project ID: {invalid_id}" ) - except ValueError as e: - print(f"āœ… Correctly rejected invalid project ID '{invalid_id}': {e}") + except (ValueError, NotFound) as e: + print(f"Correctly rejected invalid project ID '{invalid_id}': {e}") try: projects.delete_tag_bindings(invalid_id) pytest.fail( - f"Should have raised ValueError for invalid project ID: {invalid_id}" + f"Should have raised ValueError or NotFound for invalid project ID: {invalid_id}" ) - except ValueError as e: - print(f"āœ… Correctly rejected invalid project ID '{invalid_id}': {e}") + 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") + 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}") + 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") + print(" Testing operations on non-existent project") fake_project_id = "prj-doesnotexist123" # These should raise HTTP errors (404) from the API @@ -797,7 +793,7 @@ def test_project_tag_bindings_error_scenarios(integration_client): 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__}" + f"{operation_name} correctly failed for non-existent project: {type(e).__name__}" ) # Should be some kind of HTTP error (404, not found, etc.) assert ( @@ -814,7 +810,7 @@ def test_project_tag_bindings_error_scenarios(integration_client): 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__}" + f"add_tag_bindings correctly failed for non-existent project: {type(e).__name__}" ) assert ( "404" in str(e) @@ -822,7 +818,7 @@ def test_project_tag_bindings_error_scenarios(integration_client): or "does not exist" in str(e).lower() ) - print("āœ… All tag binding error scenarios tested successfully") + print("All tag binding error scenarios tested successfully") if __name__ == "__main__": @@ -839,12 +835,12 @@ def test_project_tag_bindings_error_scenarios(integration_client): org = os.environ.get("TFE_ORG") if not token or not org: - print("āŒ Please set TFE_TOKEN and TFE_ORG environment variables") + 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(" Running integration tests directly...") print( " For full pytest features, use: pytest examples/integration_test_example.py -v -s" ) diff --git a/examples/registry_module.py b/examples/registry_module.py index 0be3b99..12bcb46 100644 --- a/examples/registry_module.py +++ b/examples/registry_module.py @@ -83,17 +83,17 @@ def main(): organization_name=organization_name, registry_name=RegistryName.PRIVATE ) modules = list(client.registry_modules.list(organization_name, options)) - print(f" āœ“ Found {len(modules)} registry modules") + print(f" Found {len(modules)} registry modules") for i, module in enumerate(modules[:3], 1): print(f" {i}. {module.name}/{module.provider} (ID: {module.id})") except NotFound: print( - " āœ“ No modules found (organization may not exist or no private modules available)" + " No modules found (organization may not exist or no private modules available)" ) except Exception as e: - print(f" āœ— Error: {e}") + print(f" Error: {e}") # ===================================================== # TEST 2: CREATE REGISTRY MODULE WITH VCS CONNECTION [TESTED - COMMENTED] @@ -131,13 +131,13 @@ def main(): vcs_create_options ) print( - f" āœ“ Created VCS module: {created_module.name}/{created_module.provider}" + f" Created VCS module: {created_module.name}/{created_module.provider}" ) print(f" ID: {created_module.id}") print(f" Status: {created_module.status}") except Exception as e: - print(f" āœ— Error: {e}") + print(f" Error: {e}") # ===================================================== # TEST 3: READ REGISTRY MODULE [TESTED - COMMENTED] @@ -153,12 +153,12 @@ def main(): ) read_module = client.registry_modules.read(module_id) - print(f" āœ“ Read module: {read_module.name}") + print(f" Read module: {read_module.name}") print(f" Status: {read_module.status}") print(f" Created: {read_module.created_at}") except Exception as e: - print(f" āœ— Error: {e}") + print(f" Error: {e}") # ===================================================== # TEST 4: LIST COMMITS [TESTED - COMMENTED] @@ -175,10 +175,10 @@ def main(): commits = client.registry_modules.list_commits(module_id) commit_list = list(commits.items) if hasattr(commits, "items") else [] - print(f" āœ“ Found {len(commit_list)} commits") + print(f" Found {len(commit_list)} commits") except Exception as e: - print(f" āœ— Error: {e}") + print(f" Error: {e}") # ===================================================== # TEST 5: CREATE VERSION [TESTED - COMMENTED] @@ -200,11 +200,11 @@ def main(): version = client.registry_modules.create_version(module_id, version_options) created_version = version.version - print(f" āœ“ Created version: {version.version}") + print(f" Created version: {version.version}") print(f" Status: {version.status}") except Exception as e: - print(f" āœ— Error: {e}") + print(f" Error: {e}") # ===================================================== # TEST 6: READ VERSION [TESTED - COMMENTED] @@ -222,12 +222,12 @@ def main(): read_version = client.registry_modules.read_version( module_id, created_version ) - print(f" āœ“ Read version: {read_version.version}") + print(f" Read version: {read_version.version}") print(f" Status: {read_version.status}") print(f" ID: {read_version.id}") except Exception as e: - print(f" āœ— Error: {e}") + print(f" Error: {e}") # ===================================================== # TEST 7: READ PUBLIC TERRAFORM REGISTRY MODULE @@ -247,14 +247,14 @@ def main(): public_module = client.registry_modules.read_terraform_registry_module( public_module_id, version ) - print(f" āœ“ Read public module: {public_module.name}") + print(f" Read public module: {public_module.name}") print(f" Version: {version}") print(f" Downloads: {getattr(public_module, 'downloads', 'N/A')}") print(f" Verified: {getattr(public_module, 'verified', 'N/A')}") print(f" Source: {getattr(public_module, 'source', 'N/A')}") except Exception as e: - print(f" āœ— Error: {e}") + print(f" Error: {e}") # ===================================================== # TEST 8: CREATE SIMPLE REGISTRY MODULE (Non-VCS) @@ -274,7 +274,7 @@ def main(): organization_name, create_options ) print( - f" āœ“ Created simple module: {created_simple_module.name}/{created_simple_module.provider}" + f" Created simple module: {created_simple_module.name}/{created_simple_module.provider}" ) print(f" ID: {created_simple_module.id}") print( @@ -286,7 +286,7 @@ def main(): created_module = created_simple_module except Exception as e: - print(f" āœ— Error: {e}") + print(f" Error: {e}") # ===================================================== # TEST 8A: LIST VERSIONS @@ -303,13 +303,13 @@ def main(): versions = client.registry_modules.list_versions(module_id) versions_list = list(versions) if hasattr(versions, "__iter__") else [] - print(f" āœ“ Found {len(versions_list)} versions") + print(f" Found {len(versions_list)} versions") for i, version in enumerate(versions_list[:3], 1): print(f" {i}. Version {version.version} (Status: {version.status})") except Exception as e: - print(f" āœ— Error: {e}") + print(f" Error: {e}") # ===================================================== # TEST 8B: UPDATE MODULE @@ -335,12 +335,12 @@ def main(): ) updated_module = client.registry_modules.update(module_id, update_options) - print(f" āœ“ Updated module: {updated_module.name}") + print(f" Updated module: {updated_module.name}") print(f" No Code: {updated_module.no_code}") print(f" Status: {updated_module.status}") except Exception as e: - print(f" ⚠ Update may not be supported: {e}") + print(f" Update may not be supported: {e}") # ===================================================== # TEST 9: CREATE MODULE FOR UPLOAD TESTING @@ -358,12 +358,12 @@ def main(): created_module = client.registry_modules.create( organization_name, create_options ) - print(f" āœ“ Created test module: {created_module.name}") + print(f" Created test module: {created_module.name}") print(f" Provider: {created_module.provider}") print(f" Status: {created_module.status}") except Exception as e: - print(f" āœ— Error creating module: {e}") + print(f" Error creating module: {e}") return # ===================================================== @@ -387,7 +387,7 @@ def main(): version = client.registry_modules.create_version(module_id, version_options) created_version = version.version version_object = version - print(f" āœ“ Created version: {created_version}") + print(f" Created version: {created_version}") print(f" Status: {version.status}") # Check if upload URL is available @@ -397,7 +397,7 @@ def main(): print(f" Upload URL available: {'Yes' if upload_url else 'No'}") except Exception as e: - print(f" āœ— Error creating version: {e}") + print(f" Error creating version: {e}") # ===================================================== # TEST 11: UPLOAD_TAR_GZIP FUNCTION TESTING @@ -476,7 +476,7 @@ def main(): if upload_url: client.registry_modules.upload_tar_gzip(upload_url, tar_buffer) print( - " āœ“ Successfully uploaded tar.gz content using upload_tar_gzip()" + " Successfully uploaded tar.gz content using upload_tar_gzip()" ) # Wait for processing @@ -496,16 +496,16 @@ def main(): if updated_module.status.value != "pending": print( - f" āœ… SUCCESS: Module status changed from PENDING to {updated_module.status}" + f" SUCCESS: Module status changed from PENDING to {updated_module.status}" ) else: print(" ā³ Module still processing - may take longer") else: - print(" ⚠ No upload URL available in version links") + print(" No upload URL available in version links") except Exception as e: - print(f" āœ— Error in upload_tar_gzip test: {e}") + print(f" Error in upload_tar_gzip test: {e}") # ===================================================== # TEST 12: UPLOAD FUNCTION TESTING @@ -597,7 +597,7 @@ def main(): # Try the upload function try: client.registry_modules.upload(version_object, temp_dir) - print(" āœ“ Successfully uploaded using upload() function") + print(" Successfully uploaded using upload() function") # Wait and check status print(" Waiting 5 seconds for processing...") @@ -614,7 +614,7 @@ def main(): print(f" Updated Module Status: {updated_module.status}") except NotImplementedError as nie: - print(f" ⚠ upload() function not fully implemented: {nie}") + print(f" upload() function not fully implemented: {nie}") print(" This is expected - the function is a placeholder") # Fallback to upload_tar_gzip @@ -637,17 +637,17 @@ def main(): tar_buffer.seek(0) client.registry_modules.upload_tar_gzip(upload_url, tar_buffer) print( - " āœ“ Successfully uploaded using upload_tar_gzip() as fallback" + " Successfully uploaded using upload_tar_gzip() as fallback" ) except Exception as upload_error: - print(f" āœ— upload() function error: {upload_error}") + print(f" upload() function error: {upload_error}") else: - print(" ⚠ No upload URL available - cannot test upload function") + print(" No upload URL available - cannot test upload function") except Exception as e: - print(f" āœ— Error in upload() test: {e}") + print(f" Error in upload() test: {e}") # ===================================================== # TEST 13: DELETE VERSION @@ -670,7 +670,7 @@ def main(): test_module_for_deletion = client.registry_modules.create( organization_name, delete_create_options ) - print(f" āœ“ Created test module: {test_module_for_deletion.name}") + print(f" Created test module: {test_module_for_deletion.name}") # Create a version for deletion testing module_id = RegistryModuleID( @@ -684,7 +684,7 @@ def main(): version = client.registry_modules.create_version(module_id, version_options) test_version_for_deletion = version.version - print(f" āœ“ Created test version: {test_version_for_deletion}") + print(f" Created test version: {test_version_for_deletion}") # Now test version deletion print(f" Testing deletion of version {test_version_for_deletion}...") @@ -692,7 +692,7 @@ def main(): # Delete the version client.registry_modules.delete_version(module_id, test_version_for_deletion) print( - f" āœ“ Successfully called delete_version() for version: {test_version_for_deletion}" + f" Successfully called delete_version() for version: {test_version_for_deletion}" ) # Verify deletion by trying to read it @@ -706,13 +706,13 @@ def main(): version=test_version_for_deletion, ) print( - " ⚠ Warning: Version still exists after deletion (may take time to process)" + " Warning: Version still exists after deletion (may take time to process)" ) except Exception: - print(" āœ“ Confirmed: Version no longer exists") + print(" Confirmed: Version no longer exists") except Exception as e: - print(f" āœ— Error in delete_version test: {e}") + print(f" Error in delete_version test: {e}") # ===================================================== # TEST 14: DELETE BY NAME @@ -737,23 +737,23 @@ def main(): # Delete the module client.registry_modules.delete_by_name(module_id) print( - f" āœ“ Successfully called delete_by_name() for module: {test_module_for_deletion.name}" + f" Successfully called delete_by_name() for module: {test_module_for_deletion.name}" ) # Verify deletion try: client.registry_modules.read(module_id) print( - " ⚠ Warning: Module still exists after deletion (may take time to process)" + " Warning: Module still exists after deletion (may take time to process)" ) except Exception: - print(" āœ“ Confirmed: Module no longer exists") + print(" Confirmed: Module no longer exists") except Exception as read_error: print(f" Module not found: {read_error}") except Exception as e: - print(f" āœ— Error in delete_by_name test: {e}") + print(f" Error in delete_by_name test: {e}") # ===================================================== # TEST 15: DELETE (Alternative delete method) @@ -768,7 +768,7 @@ def main(): print(f" Testing delete with non-existent module: {test_name}") client.registry_modules.delete(organization_name, test_name) print( - " āœ“ Delete function executed successfully (may return 404 for non-existent module)" + " Delete function executed successfully (may return 404 for non-existent module)" ) except Exception as e: @@ -794,7 +794,7 @@ def main(): test_provider_module = client.registry_modules.create( organization_name, delete_provider_options ) - print(f" āœ“ Created test module with provider: {test_provider_name}") + print(f" Created test module with provider: {test_provider_name}") # Now test delete_provider function test_provider_module_id = RegistryModuleID( @@ -807,20 +807,20 @@ def main(): print(f" Testing delete_provider() for provider: {test_provider_name}") client.registry_modules.delete_provider(test_provider_module_id) print( - f" āœ“ Successfully called delete_provider() for provider: {test_provider_name}" + f" Successfully called delete_provider() for provider: {test_provider_name}" ) # Verify deletion by trying to read the module try: client.registry_modules.read(test_provider_module_id) print( - " ⚠ Warning: Module still exists after provider deletion (may take time to process)" + " Warning: Module still exists after provider deletion (may take time to process)" ) except Exception: - print(" āœ“ Confirmed: All modules for provider have been deleted") + print(" Confirmed: All modules for provider have been deleted") except Exception as e: - print(f" āœ— Error in delete_provider test: {e}") + print(f" Error in delete_provider test: {e}") # ===================================================== # TESTING SUMMARY @@ -829,26 +829,26 @@ def main(): print("REGISTRY MODULE TESTING COMPLETED!") print("=" * 80) print("Summary of ALL 15 Functions Tested:") - print("āœ“ list() - List registry modules in organization") - print("āœ“ create_with_vcs_connection() - Create module with VCS connection") - print("āœ“ read() - Read module details") - print("āœ“ list_commits() - List VCS commits for module") - print("āœ“ create_version() - Create new module version") - print("āœ“ read_version() - Read specific version details") - print("āœ“ read_terraform_registry_module() - Read public registry module") - print("āœ“ create() - Create simple module") - print("āœ“ list_versions() - List all versions of a module") - print("āœ“ update() - Update module settings") - print("āœ“ upload_tar_gzip() - Upload tar.gz archive to upload URL") - print("āœ“ upload() - Upload from local directory path (placeholder)") - print("āœ“ delete_version() - Delete a specific version") - print("āœ“ delete_by_name() - Delete entire module by name") - print("āœ“ delete() - Delete module by organization and name") - print("āœ“ delete_provider() - Delete all modules for a provider") + print(" list() - List registry modules in organization") + print(" create_with_vcs_connection() - Create module with VCS connection") + print(" read() - Read module details") + print(" list_commits() - List VCS commits for module") + print(" create_version() - Create new module version") + print(" read_version() - Read specific version details") + print(" read_terraform_registry_module() - Read public registry module") + print(" create() - Create simple module") + print(" list_versions() - List all versions of a module") + print(" update() - Update module settings") + print(" upload_tar_gzip() - Upload tar.gz archive to upload URL") + print(" upload() - Upload from local directory path (placeholder)") + print(" delete_version() - Delete a specific version") + print(" delete_by_name() - Delete entire module by name") + print(" delete() - Delete module by organization and name") + print(" delete_provider() - Delete all modules for a provider") if created_module: - print(f"āœ“ Created test module: {created_module.name}") + print(f" Created test module: {created_module.name}") print("=" * 80) - print("šŸŽ‰ ALL 15 REGISTRY MODULE FUNCTIONS HAVE BEEN TESTED!") + print(" ALL 15 REGISTRY MODULE FUNCTIONS HAVE BEEN TESTED!") print("=" * 80) diff --git a/examples/registry_provider.py b/examples/registry_provider.py index bcd3e32..cd03dea 100644 --- a/examples/registry_provider.py +++ b/examples/registry_provider.py @@ -49,7 +49,7 @@ def test_list_simple(): try: providers = list(client.registry_providers.list(org)) - print(f"āœ“ Found {len(providers)} providers in organization '{org}'") + print(f" Found {len(providers)} providers in organization '{org}'") for i, provider in enumerate(providers[:5], 1): print(f" {i}. {provider.name}") @@ -62,7 +62,7 @@ def test_list_simple(): return providers except Exception as e: - print(f"āœ— Error: {e}") + print(f" Error: {e}") return [] @@ -79,7 +79,7 @@ def test_list_with_options(): ) providers = list(client.registry_providers.list(org, options)) - print(f"āœ“ Found {len(providers)} providers matching search 'test'") + print(f" Found {len(providers)} providers matching search 'test'") # Test with include include_options = RegistryProviderListOptions( @@ -87,12 +87,12 @@ def test_list_with_options(): ) detailed_providers = list(client.registry_providers.list(org, include_options)) - print(f"āœ“ Found {len(detailed_providers)} providers with version details") + print(f" Found {len(detailed_providers)} providers with version details") return providers except Exception as e: - print(f"āœ— Error: {e}") + print(f" Error: {e}") return [] @@ -112,7 +112,7 @@ def test_create_private(): ) provider = client.registry_providers.create(org, options) - print(f"āœ“ Created private provider: {provider.name}") + print(f" Created private provider: {provider.name}") print(f" ID: {provider.id}") print(f" Namespace: {provider.namespace}") print(f" Registry: {provider.registry_name.value}") @@ -121,7 +121,7 @@ def test_create_private(): return provider except Exception as e: - print(f"āœ— Error creating private provider: {e}") + print(f" Error creating private provider: {e}") return None @@ -142,7 +142,7 @@ def test_create_public(): ) provider = client.registry_providers.create(org, options) - print(f"āœ“ Created public provider: {provider.name}") + print(f" Created public provider: {provider.name}") print(f" ID: {provider.id}") print(f" Namespace: {provider.namespace}") print(f" Registry: {provider.registry_name.value}") @@ -151,7 +151,7 @@ def test_create_public(): return provider except Exception as e: - print(f"āœ— Error creating public provider: {e}") + print(f" Error creating public provider: {e}") return None @@ -162,7 +162,7 @@ def test_read_with_id(provider_data): client, org = get_client_and_org() if not provider_data: - print("āš ļø No provider data provided") + print(" No provider data provided") return None try: @@ -175,7 +175,7 @@ def test_read_with_id(provider_data): # Basic read provider = client.registry_providers.read(provider_id) - print(f"āœ“ Read provider: {provider.name}") + print(f" Read provider: {provider.name}") print(f" ID: {provider.id}") print(f" Namespace: {provider.namespace}") print(f" Registry: {provider.registry_name.value}") @@ -189,7 +189,7 @@ def test_read_with_id(provider_data): ) detailed_provider = client.registry_providers.read(provider_id, options) - print(f"āœ“ Read with options: {detailed_provider.name}") + print(f" Read with options: {detailed_provider.name}") if detailed_provider.registry_provider_versions: print( @@ -201,7 +201,7 @@ def test_read_with_id(provider_data): return provider except Exception as e: - print(f"āœ— Error reading provider: {e}") + print(f" Error reading provider: {e}") return None @@ -212,7 +212,7 @@ def test_delete_by_id(provider_data): client, org = get_client_and_org() if not provider_data: - print("āš ļø No provider data provided") + print(" No provider data provided") return False try: @@ -225,11 +225,11 @@ def test_delete_by_id(provider_data): # Verify provider exists provider = client.registry_providers.read(provider_id) - print(f"āœ“ Found provider to delete: {provider.name}") + print(f" Found provider to delete: {provider.name}") # Delete the provider client.registry_providers.delete(provider_id) - print("āœ“ Successfully called delete() for provider") + print(" Successfully called delete() for provider") # Verify deletion (optional - may take time) import time @@ -238,20 +238,20 @@ def test_delete_by_id(provider_data): try: client.registry_providers.read(provider_id) - print("āš ļø Provider still exists (deletion may take time)") + print(" Provider still exists (deletion may take time)") except Exception: - print("āœ“ Provider successfully deleted") + print(" Provider successfully deleted") return True except Exception as e: - print(f"āœ— Error deleting provider: {e}") + print(f" Error deleting provider: {e}") return False def main(): """Run all tests in sequence.""" - print("šŸš€ REGISTRY PROVIDER INDIVIDUAL TESTS") + print(" REGISTRY PROVIDER INDIVIDUAL TESTS") print("=" * 50) # Test 1: List providers @@ -262,9 +262,9 @@ def main(): test_list_with_options() print() - # āš ļø WARNING: Uncomment the following tests to create/delete providers - print("āš ļø WARNING: Creation and deletion tests are commented out for safety") - print("āš ļø Uncomment them in the code to test creation and deletion") + # WARNING: Uncomment the following tests to create/delete providers + print(" WARNING: Creation and deletion tests are commented out for safety") + print(" Uncomment them in the code to test creation and deletion") print() # UNCOMMENT TO TEST CREATION: @@ -297,8 +297,8 @@ def main(): test_read_with_id(existing_provider) print() - print("āœ… Individual tests completed!") - print("šŸ’” To test creation/deletion, uncomment the relevant sections in the code") + print("Individual tests completed!") + print(" To test creation/deletion, uncomment the relevant sections in the code") if __name__ == "__main__": diff --git a/examples/reserved_tag_key.py b/examples/reserved_tag_key.py index 8eeb5e7..75b9fb9 100644 --- a/examples/reserved_tag_key.py +++ b/examples/reserved_tag_key.py @@ -36,11 +36,11 @@ def main(): # Validate environment variables if not TFE_TOKEN: - print("āŒ Error: TFE_TOKEN environment variable is required") + print(" Error: TFE_TOKEN environment variable is required") sys.exit(1) if not TFE_ORG: - print("āŒ Error: TFE_ORG environment variable is required") + print(" Error: TFE_ORG environment variable is required") sys.exit(1) # Initialize the TFE client @@ -54,7 +54,7 @@ def main(): # 1. List existing reserved tag keys print("\n1. Listing reserved tag keys...") reserved_tag_keys = client.reserved_tag_key.list(TFE_ORG) - print(f"āœ… Found {len(reserved_tag_keys.items)} reserved tag keys:") + print(f"Found {len(reserved_tag_keys.items)} reserved tag keys:") for rtk in reserved_tag_keys.items: print( f" - ID: {rtk.id}, Key: {rtk.key}, Disable Overrides: {rtk.disable_overrides}" @@ -67,7 +67,7 @@ def main(): ) new_rtk = client.reserved_tag_key.create(TFE_ORG, create_options) - print(f"āœ… Created reserved tag key: {new_rtk.id} - {new_rtk.key}") + print(f"Created reserved tag key: {new_rtk.id} - {new_rtk.key}") print(f" Disable Overrides: {new_rtk.disable_overrides}") # 3. Update the reserved tag key @@ -77,48 +77,46 @@ def main(): ) updated_rtk = client.reserved_tag_key.update(new_rtk.id, update_options) - print(f"āœ… Updated reserved tag key: {updated_rtk.id} - {updated_rtk.key}") + print(f"Updated reserved tag key: {updated_rtk.id} - {updated_rtk.key}") print(f" Disable Overrides: {updated_rtk.disable_overrides}") # 4. Delete the reserved tag key print("\n4. Deleting the reserved tag key...") client.reserved_tag_key.delete(new_rtk.id) - print(f"āœ… Deleted reserved tag key: {new_rtk.id}") + print(f"Deleted reserved tag key: {new_rtk.id}") # 5. Verify deletion by listing again print("\n5. Verifying deletion...") reserved_tag_keys_after = client.reserved_tag_key.list(TFE_ORG) - print( - f"āœ… Reserved tag keys after deletion: {len(reserved_tag_keys_after.items)}" - ) + print(f"Reserved tag keys after deletion: {len(reserved_tag_keys_after.items)}") # 6. Demonstrate pagination with options print("\n6. Demonstrating pagination options...") list_options = ReservedTagKeyListOptions(page_size=5, page_number=1) paginated_rtks = client.reserved_tag_key.list(TFE_ORG, list_options) - print(f"āœ… Page 1 with page size 5: {len(paginated_rtks.items)} keys") + print(f"Page 1 with page size 5: {len(paginated_rtks.items)} keys") print(f" Total pages: {paginated_rtks.total_pages}") print(f" Total count: {paginated_rtks.total_count}") - print("\nšŸŽ‰ Reserved Tag Keys API example completed successfully!") + print("\n Reserved Tag Keys API example completed successfully!") except NotImplementedError as e: - print(f"\nāš ļø Note: {e}") + print(f"\n Note: {e}") print("This is expected - the read operation is not supported by the API.") except TFEError as e: - print(f"\nāŒ TFE API Error: {e}") + print(f"\n TFE API Error: {e}") if hasattr(e, "status"): if e.status == 403: - print("šŸ’” Permission denied - check token permissions") + print(" Permission denied - check token permissions") elif e.status == 401: - print("šŸ’” Authentication failed - check token validity") + print(" Authentication failed - check token validity") elif e.status == 422: - print("šŸ’” Validation error - check reserved tag key format") + print(" Validation error - check reserved tag key format") sys.exit(1) except Exception as e: - print(f"\nāŒ Unexpected error: {e}") + print(f"\n Unexpected error: {e}") sys.exit(1) diff --git a/examples/run_task.py b/examples/run_task.py index 102874d..e62a7da 100644 --- a/examples/run_task.py +++ b/examples/run_task.py @@ -112,7 +112,7 @@ def main(): if count >= args.page_size * 2: # Safety limit based on page size break - print(f"āœ“ Found {len(run_task_list)} run tasks") + print(f" Found {len(run_task_list)} run tasks") print() if not run_task_list: @@ -128,7 +128,7 @@ def main(): print(f" Description: {task.description}") print() except Exception as e: - print(f"āœ— Error listing run tasks: {e}") + print(f" Error listing run tasks: {e}") return # 2) Create a new run task if requested @@ -149,7 +149,7 @@ def main(): print(f"Creating run task '{task_name}' in organization '{args.org}'...") run_task = client.run_tasks.create(args.org, create_options) - print("āœ“ Successfully created run task!") + print(" Successfully created run task!") print(f" Name: {run_task.name}") print(f" ID: {run_task.id}") print(f" URL: {run_task.url}") @@ -161,7 +161,7 @@ def main(): args.task_id = run_task.id # Use the created task for other operations except Exception as e: - print(f"āœ— Error creating run task: {e}") + print(f" Error creating run task: {e}") return # 3) Read run task details if task ID is provided @@ -180,7 +180,7 @@ def main(): run_task = client.run_tasks.read(args.task_id) print("Reading run task details...") - print("āœ“ Successfully read run task!") + print(" Successfully read run task!") print(f" Name: {run_task.name}") print(f" ID: {run_task.id}") print(f" URL: {run_task.url}") @@ -199,7 +199,7 @@ def main(): print() except Exception as e: - print(f"āœ— Error reading run task: {e}") + print(f" Error reading run task: {e}") return # 4) Update run task if requested @@ -214,14 +214,14 @@ def main(): ) print(f"Updating run task '{args.task_id}'...") updated_task = client.run_tasks.update(args.task_id, update_options) - print("āœ“ Successfully updated run task!") + print(" Successfully updated run task!") print(f" Name: {updated_task.name}") print(f" Description: {updated_task.description}") print(f" URL: {updated_task.url}") print(f" Enabled: {updated_task.enabled}") print() except Exception as e: - print(f"āœ— Error updating run task: {e}") + print(f" Error updating run task: {e}") return # 5) Delete run task if requested (should be last operation) @@ -230,10 +230,10 @@ def main(): try: print(f"Deleting run task '{args.task_id}'...") client.run_tasks.delete(args.task_id) - print(f"āœ“ Successfully deleted run task: {args.task_id}") + print(f" Successfully deleted run task: {args.task_id}") print() except Exception as e: - print(f"āœ— Error deleting run task: {e}") + print(f" Error deleting run task: {e}") return diff --git a/examples/run_trigger.py b/examples/run_trigger.py index c651210..35dba80 100644 --- a/examples/run_trigger.py +++ b/examples/run_trigger.py @@ -140,7 +140,7 @@ def main(): if count >= args.page_size * 2: # Safety limit based on page size break - print(f"āœ“ Found {len(run_trigger_list)} run triggers") + print(f" Found {len(run_trigger_list)} run triggers") print() if not run_trigger_list: @@ -158,7 +158,7 @@ def main(): print(f" Target Workspace ID: {trigger.workspace.id}") print() except Exception as e: - print(f"āœ— Error listing run triggers: {e}") + print(f" Error listing run triggers: {e}") return # 2) Create a new run trigger if requested @@ -178,7 +178,7 @@ def main(): f"Creating run trigger from workspace '{args.source_workspace_id}' to '{args.workspace_id}'..." ) run_trigger = client.run_triggers.create(args.workspace_id, create_options) - print("āœ“ Successfully created run trigger!") + print(" Successfully created run trigger!") print(f" ID: {run_trigger.id}") print(f" Source: {run_trigger.sourceable_name}") print(f" Target: {run_trigger.workspace_name}") @@ -198,12 +198,10 @@ def main(): run_trigger.id ) # Use the created trigger for other operations except Exception as e: - print(f"āœ— Error creating run trigger: {e}") + print(f" Error creating run trigger: {e}") return elif args.create: - print( - "āœ— Error: --create requires both --workspace-id and --source-workspace-id" - ) + print(" Error: --create requires both --workspace-id and --source-workspace-id") return # 3) Read run trigger details if trigger ID is provided @@ -213,7 +211,7 @@ def main(): print("Reading run trigger details...") run_trigger = client.run_triggers.read(args.trigger_id) - print("āœ“ Successfully read run trigger!") + print(" Successfully read run trigger!") print(f" ID: {run_trigger.id}") print(f" Type: {run_trigger.type}") print(f" Source: {run_trigger.sourceable_name}") @@ -243,7 +241,7 @@ def main(): print() except Exception as e: - print(f"āœ— Error reading run trigger: {e}") + print(f" Error reading run trigger: {e}") return # 4) Delete run trigger if requested (should be last operation) @@ -252,10 +250,10 @@ def main(): try: print(f"Deleting run trigger '{args.trigger_id}'...") client.run_triggers.delete(args.trigger_id) - print(f"āœ“ Successfully deleted run trigger: {args.trigger_id}") + print(f" Successfully deleted run trigger: {args.trigger_id}") print() except Exception as e: - print(f"āœ— Error deleting run trigger: {e}") + print(f" Error deleting run trigger: {e}") return diff --git a/examples/ssh_keys.py b/examples/ssh_keys.py index 7d2efa3..43e4a8a 100644 --- a/examples/ssh_keys.py +++ b/examples/ssh_keys.py @@ -9,9 +9,9 @@ 5. Delete an SSH key IMPORTANT: SSH Keys API has special authentication requirements: -- āŒ CANNOT use Organization Tokens (AT-*) -- āœ… MUST use User Tokens or Team Tokens -- āœ… MUST have 'manage VCS settings' permission +- CANNOT use Organization Tokens (AT-*) +- MUST use User Tokens or Team Tokens +- MUST have 'manage VCS settings' permission Before running this script: 1. Create a User Token in Terraform Cloud: @@ -42,28 +42,28 @@ def check_token_type(token): """Check and validate token type for SSH Keys API.""" - print("šŸ” Token Analysis:") + print(" Token Analysis:") if token.startswith("AT-"): print(" Token Type: Organization Token (AT-*)") - print(" āŒ SSH Keys API does NOT support Organization Tokens") - print(" šŸ’” Please create a User Token instead") + print(" SSH Keys API does NOT support Organization Tokens") + print(" Please create a User Token instead") print("") - print("šŸ”§ To create a User Token:") + print(" To create a User Token:") print(" 1. Go to Terraform Cloud → User Settings → Tokens") print(" 2. Create new token with VCS management permissions") print(" 3. Replace TFE_TOKEN environment variable") return False elif token.startswith("TF-"): print(" Token Type: User Token (TF-*)") - print(" āœ… SSH Keys API supports User Tokens") + print(" SSH Keys API supports User Tokens") return True elif ".atlasv1." in token: print(" Token Type: User/Team Token (.atlasv1. format)") - print(" āœ… SSH Keys API supports User/Team Tokens") + print(" SSH Keys API supports User/Team Tokens") return True else: print(f" Token Type: Unknown format ({token[:10]}...)") - print(" šŸ’” Expected User Token (TF-*) or Team Token") + print(" Expected User Token (TF-*) or Team Token") return True # Allow unknown formats to try @@ -72,17 +72,17 @@ def main(): # Validate environment variables if not TFE_TOKEN: - print("āŒ Error: TFE_TOKEN environment variable is required") - print("šŸ’” Create a User Token (not Organization Token) in Terraform Cloud") + print(" Error: TFE_TOKEN environment variable is required") + print(" Create a User Token (not Organization Token) in Terraform Cloud") sys.exit(1) if not TFE_ORG: - print("āŒ Error: TFE_ORG environment variable is required") + print(" Error: TFE_ORG environment variable is required") sys.exit(1) if not SSH_KEY_VALUE: - print("āŒ Error: SSH_PRIVATE_KEY environment variable is required") - print("šŸ’” Provide a valid SSH private key for testing") + print(" Error: SSH_PRIVATE_KEY environment variable is required") + print(" Provide a valid SSH private key for testing") sys.exit(1) # Check token type first @@ -100,7 +100,7 @@ def main(): # 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:") + print(f" Found {len(ssh_keys.items)} SSH keys:") for key in ssh_keys.items: print(f" - ID: {key.id}, Name: {key.name}") @@ -111,62 +111,62 @@ def main(): ) new_key = client.ssh_keys.create(TFE_ORG, create_options) - print(f"āœ… Created SSH key: {new_key.id} - {new_key.name}") + print(f" Created SSH key: {new_key.id} - {new_key.name}") # 3. Read the SSH key we just created print("\n3. Reading the SSH key...") read_key = client.ssh_keys.read(new_key.id) - print(f"āœ… Read SSH key: {read_key.id} - {read_key.name}") + print(f" Read SSH key: {read_key.id} - {read_key.name}") # 4. Update the SSH key print("\n4. Updating the SSH key...") update_options = SSHKeyUpdateOptions(name="Updated Python TFE Example SSH Key") updated_key = client.ssh_keys.update(new_key.id, update_options) - print(f"āœ… Updated SSH key: {updated_key.id} - {updated_key.name}") + print(f" Updated SSH key: {updated_key.id} - {updated_key.name}") # 5. Delete the SSH key print("\n5. Deleting the SSH key...") client.ssh_keys.delete(new_key.id) - print(f"āœ… Deleted SSH key: {new_key.id}") + print(f" Deleted SSH key: {new_key.id}") # 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)}") + print(f" SSH keys after deletion: {len(ssh_keys_after.items)}") # 7. Demonstrate pagination with options print("\n7. Demonstrating 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" 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}") - print("\nšŸŽ‰ SSH Keys API example completed successfully!") + print("\n SSH Keys API example completed successfully!") except NotFound as e: - print(f"\nāŒ SSH Keys API Error: {e}") - print("\nšŸ’” This error usually means:") + print(f"\n SSH Keys API Error: {e}") + print("\n This error usually means:") print(" - Using Organization Token (not allowed)") print(" - SSH Keys feature not available") print(" - Insufficient permissions") - print("\nšŸ”§ Try using a User Token instead of Organization Token") + print("\n Try using a User Token instead of Organization Token") sys.exit(1) except TFEError as e: - print(f"\nāŒ TFE API Error: {e}") + print(f"\n TFE API Error: {e}") if hasattr(e, "status"): if e.status == 403: - print("šŸ’” Permission denied - check token type and permissions") + print(" Permission denied - check token type and permissions") elif e.status == 401: - print("šŸ’” Authentication failed - check token validity") + print(" Authentication failed - check token validity") elif e.status == 422: - print("šŸ’” Validation error - check SSH key format") + print(" Validation error - check SSH key format") sys.exit(1) except Exception as e: - print(f"\nāŒ Unexpected error: {e}") + print(f"\n Unexpected error: {e}") sys.exit(1) diff --git a/examples/variables.py b/examples/variables.py index bc5227e..ba47f09 100644 --- a/examples/variables.py +++ b/examples/variables.py @@ -48,10 +48,10 @@ def main(): try: variable = client.variables.create(workspace_id, terraform_var) created_variables.append(variable.id) - print(f"āœ“ Created Terraform variable: {variable.key} = {variable.value}") + print(f" Created Terraform variable: {variable.key} = {variable.value}") print(f" ID: {variable.id}, Category: {variable.category}") except Exception as e: - print(f"āœ— Error creating Terraform variable: {e}") + print(f" Error creating Terraform variable: {e}") # Create an environment variable env_var = VariableCreateOptions( @@ -66,10 +66,10 @@ def main(): try: variable = client.variables.create(workspace_id, env_var) created_variables.append(variable.id) - print(f"āœ“ Created environment variable: {variable.key} = {variable.value}") + print(f" Created environment variable: {variable.key} = {variable.value}") print(f" ID: {variable.id}, Category: {variable.category}") except Exception as e: - print(f"āœ— Error creating environment variable: {e}") + print(f" Error creating environment variable: {e}") # Create a sensitive variable secret_var = VariableCreateOptions( @@ -84,10 +84,10 @@ def main(): try: variable = client.variables.create(workspace_id, secret_var) created_variables.append(variable.id) - print(f"āœ“ Created sensitive variable: {variable.key} = ***HIDDEN***") + print(f" Created sensitive variable: {variable.key} = ***HIDDEN***") print(f" ID: {variable.id}, Category: {variable.category}") except Exception as e: - print(f"āœ— Error creating sensitive variable: {e}") + print(f" Error creating sensitive variable: {e}") # Small delay to ensure variables are created time.sleep(1) @@ -105,7 +105,7 @@ def main(): f" • {var.key} = {value_display} ({var.category}) [ID: {var.id}]" ) except Exception as e: - print(f"āœ— Error listing variables: {e}") + print(f" Error listing variables: {e}") # 3. Test LIST_ALL function (includes inherited variables from variable sets) print("\n3. Testing LIST_ALL operation (includes variable sets):") @@ -120,7 +120,7 @@ def main(): f" • {var.key} = {value_display} ({var.category}) [ID: {var.id}]" ) except Exception as e: - print(f"āœ— Error listing all variables: {e}") + print(f" Error listing all variables: {e}") # Test READ function with specific variable ID - COMMENTED OUT print("\n4. Testing READ operation with specific variable ID:") @@ -134,9 +134,9 @@ def main(): variable = client.variables.read(workspace_id, test_variable_id) # For testing, show actual values even for sensitive variables if variable.sensitive: - print(f"āœ“ Read variable: {variable.key} = {variable.value} (SENSITIVE)") + print(f" Read variable: {variable.key} = {variable.value} (SENSITIVE)") else: - print(f"āœ“ Read variable: {variable.key} = {variable.value}") + print(f" Read variable: {variable.key} = {variable.value}") print(f" ID: {variable.id}") print(f" Description: {variable.description}") print(f" Category: {variable.category}") @@ -145,7 +145,7 @@ def main(): if hasattr(variable, "version_id"): print(f" Version ID: {variable.version_id}") except Exception as e: - print(f"āœ— Error reading variable {test_variable_id}: {e}") + print(f" Error reading variable {test_variable_id}: {e}") # Test UPDATE function with specific variable ID - COMMENTED OUT print("\n5. Testing UPDATE operation with specific variable ID:") @@ -175,7 +175,7 @@ def main(): workspace_id, test_variable_id, update_options ) print( - f"āœ“ Updated variable: {updated_variable.key} = {updated_variable.value}" + f" Updated variable: {updated_variable.key} = {updated_variable.value}" ) print(f" Description: {updated_variable.description}") print(f" Category: {updated_variable.category}") @@ -183,7 +183,7 @@ def main(): print(f" Sensitive: {updated_variable.sensitive}") print(f" ID: {updated_variable.id}") except Exception as e: - print(f"āœ— Error updating variable {test_variable_id}: {e}") + print(f" Error updating variable {test_variable_id}: {e}") # Test DELETE function with specific variable ID print("\n6. Testing DELETE operation with specific variable ID:") @@ -201,21 +201,21 @@ def main(): # Delete the variable client.variables.delete(workspace_id, test_variable_id) - print(f"āœ“ Successfully deleted variable with ID: {test_variable_id}") + print(f" Successfully deleted variable with ID: {test_variable_id}") # Try to read it again to verify deletion print("Verifying deletion...") try: client.variables.read(workspace_id, test_variable_id) - print("āœ— Warning: Variable still exists after deletion!") + print(" Warning: Variable still exists after deletion!") except Exception as read_error: if "not found" in str(read_error).lower() or "404" in str(read_error): - print("āœ“ Confirmed: Variable no longer exists") + print(" Confirmed: Variable no longer exists") else: - print(f"āœ— Unexpected error verifying deletion: {read_error}") + print(f" Unexpected error verifying deletion: {read_error}") except Exception as e: - print(f"āœ— Error deleting variable {test_variable_id}: {e}") + print(f" Error deleting variable {test_variable_id}: {e}") # 4. Test READ function print("\n4. Testing READ operation:") @@ -228,14 +228,14 @@ def main(): value_display = ( "***SENSITIVE***" if variable.sensitive else variable.value ) - print(f"āœ“ Read variable: {variable.key} = {value_display}") + print(f" Read variable: {variable.key} = {value_display}") print(f" ID: {variable.id}") print(f" Description: {variable.description}") print(f" Category: {variable.category}") print(f" HCL: {variable.hcl}") print(f" Sensitive: {variable.sensitive}") except Exception as e: - print(f"āœ— Error reading variable {test_var_id}: {e}") + print(f" Error reading variable {test_var_id}: {e}") else: print("No variables available to read") @@ -262,12 +262,12 @@ def main(): workspace_id, test_var_id, update_options ) print( - f"āœ“ Updated variable: {updated_variable.key} = {updated_variable.value}" + f" Updated variable: {updated_variable.key} = {updated_variable.value}" ) print(f" New description: {updated_variable.description}") print(f" ID: {updated_variable.id}") except Exception as e: - print(f"āœ— Error updating variable {test_var_id}: {e}") + print(f" Error updating variable {test_var_id}: {e}") else: print("No variables available to update") @@ -279,9 +279,9 @@ def main(): for var_id in created_variables: try: client.variables.delete(workspace_id, var_id) - print(f"āœ“ Deleted variable with ID: {var_id}") + print(f" Deleted variable with ID: {var_id}") except Exception as e: - print(f"āœ— Error deleting variable {var_id}: {e}") + print(f" Error deleting variable {var_id}: {e}") # Verify deletion by listing variables again print("\nVerifying deletion - listing variables after cleanup:") @@ -300,12 +300,12 @@ def main(): for var in remaining_test_vars: print(f" • {var.key} [ID: {var.id}]") else: - print("āœ“ All test variables successfully deleted") + print(" All test variables successfully deleted") except Exception as e: - print(f"āœ— Error verifying deletion: {e}") + print(f" Error verifying deletion: {e}") except Exception as e: - print(f"āœ— Unexpected error during testing: {e}") + print(f" Unexpected error during testing: {e}") print("\n" + "=" * 60) print("Variable testing complete!") diff --git a/examples/workspace.py b/examples/workspace.py index 3f55a94..dca9b11 100644 --- a/examples/workspace.py +++ b/examples/workspace.py @@ -7,7 +7,7 @@ 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 +Testing Status: All operations tested and validated Organization: Logically grouped into 16 sections for easy navigation Prerequisites: @@ -176,7 +176,7 @@ def main(): if count >= args.page_size * 2: # Safety limit based on page size break - print(f"āœ“ Found {len(workspace_list)} workspaces") + print(f" Found {len(workspace_list)} workspaces") print() if not workspace_list: @@ -189,7 +189,7 @@ def main(): print(f" Auto Apply: {ws.auto_apply}") print() except Exception as e: - print(f"āœ— Error listing workspaces: {e}") + print(f" Error listing workspaces: {e}") return # 2) Create a new workspace if requested @@ -216,7 +216,7 @@ def main(): f"Creating workspace '{workspace_name}' in organization '{args.org}'..." ) workspace = client.workspaces.create(args.org, create_options) - print("āœ“ Successfully created workspace!") + print(" Successfully created workspace!") print(f" Name: {workspace.name}") print(f" ID: {workspace.id}") print(f" Description: {workspace.description}") @@ -230,7 +230,7 @@ def main(): ) # Use the created workspace for other operations args.workspace_id = workspace.id except Exception as e: - print(f"āœ— Error creating workspace: {e}") + print(f" Error creating workspace: {e}") return # 3a) Read workspace details using read_with_options @@ -246,7 +246,7 @@ def main(): workspace = client.workspaces.read_with_options( args.workspace, read_options, organization=args.org ) - print(f"āœ“ read_with_options: {workspace.name}") + print(f" read_with_options: {workspace.name}") print(f" ID: {workspace.id}") print(f" Description: {workspace.description}") print(f" Execution Mode: {workspace.execution_mode}") @@ -259,7 +259,7 @@ def main(): if not args.workspace_id: args.workspace_id = workspace.id except Exception as e: - print(f"āœ— read_with_options error: {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: @@ -268,11 +268,11 @@ def main(): workspace = client.workspaces.read( args.workspace, organization=args.org ) - print(f"āœ“ read: {workspace.name} (ID: {workspace.id})") + 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}") + 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): @@ -283,9 +283,9 @@ def main(): 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})") + print(f" read_by_id: {workspace.name} (ID: {workspace.id})") except Exception as e: - print(f"āœ— read_by_id error: {e}") + print(f" read_by_id error: {e}") # Test read_by_id_with_options try: @@ -295,10 +295,10 @@ def main(): args.workspace_id, options ) print( - f"āœ“ read_by_id_with_options: {workspace.name} with organization included" + f" read_by_id_with_options: {workspace.name} with organization included" ) except Exception as e: - print(f"āœ— read_by_id_with_options error: {e}") + 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: @@ -317,14 +317,14 @@ def main(): updated_workspace = client.workspaces.update( args.workspace, update_options, organization=args.org ) - print("āœ“ update: Successfully updated workspace!") + 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}") + print(f" update error: {e}") # 4b) Update workspace by ID if args.workspace_id and (args.update_all or args.all_tests): @@ -340,10 +340,10 @@ def main(): args.workspace_id, update_options ) print( - f"āœ“ update_by_id: Updated description to '{updated_workspace.description}'" + f" update_by_id: Updated description to '{updated_workspace.description}'" ) except Exception as e: - print(f"āœ— update_by_id error: {e}") + print(f" update_by_id error: {e}") # 5) Lock workspace if requested if args.lock and args.workspace_id: @@ -371,11 +371,11 @@ def main(): workspace = client.workspaces.remove_vcs_connection( args.workspace, organization=args.org ) - print("āœ“ Successfully removed VCS connection from workspace!") + print(" Successfully removed VCS connection from workspace!") print(f" Workspace: {workspace.name}") print() except Exception as e: - print(f"āœ— Error removing VCS connection: {e}") + print(f" Error removing VCS connection: {e}") # 8) Demonstrate tag operations if args.workspace_id: @@ -423,7 +423,7 @@ def main(): try: print("Testing force_unlock()...") workspace = client.workspaces.force_unlock(args.workspace_id) - print(f"āœ“ force_unlock: Workspace {workspace.name} force unlocked") + print(f" force_unlock: Workspace {workspace.name} force unlocked") except Exception as e: print(f" force_unlock result: {e}") print(" (Expected if workspace wasn't locked)") @@ -446,15 +446,15 @@ def main(): workspace = client.workspaces.assign_ssh_key( args.workspace_id, ssh_key.id ) - print(f"āœ“ assign_ssh_key: Assigned key to {workspace.name}") + print(f" assign_ssh_key: Assigned key to {workspace.name}") # Test unassign SSH key print("Testing unassign_ssh_key()...") workspace = client.workspaces.unassign_ssh_key(args.workspace_id) - print(f"āœ“ unassign_ssh_key: Removed key from {workspace.name}") + print(f" unassign_ssh_key: Removed key from {workspace.name}") except Exception as e: - print(f"āœ— SSH key assignment error: {e}") + print(f" SSH key assignment error: {e}") else: print("No SSH keys available for testing") print( @@ -462,7 +462,7 @@ def main(): ) except Exception as e: - print(f"āœ— SSH key listing error: {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: @@ -473,7 +473,7 @@ def main(): 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") + print(" remove_tags: Removed 'demo' tag") except Exception as e: print(f" remove_tags: {e}") @@ -481,9 +481,9 @@ def main(): # 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") + print(f" list_tag_bindings: Found {len(bindings)} tag bindings") except Exception as e: - print(f"āœ— list_tag_bindings error: {e}") + print(f" list_tag_bindings error: {e}") try: # Test list_effective_tag_bindings @@ -492,17 +492,17 @@ def main(): client.workspaces.list_effective_tag_bindings(args.workspace_id) ) print( - f"āœ“ list_effective_tag_bindings: Found {len(effective_bindings)} effective bindings" + f" list_effective_tag_bindings: Found {len(effective_bindings)} effective bindings" ) except Exception as e: - print(f"āœ— list_effective_tag_bindings error: {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(" 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") @@ -514,7 +514,7 @@ def main(): try: print("Testing read_data_retention_policy()...") policy = client.workspaces.read_data_retention_policy(args.workspace_id) - print(f"āœ“ read_data_retention_policy: {policy}") + print(f" read_data_retention_policy: {policy}") except Exception as e: print(f" read_data_retention_policy: {e}") print(" (Expected if no policy is set)") @@ -524,7 +524,7 @@ def main(): choice = client.workspaces.read_data_retention_policy_choice( args.workspace_id ) - print(f"āœ“ read_data_retention_policy_choice: {choice}") + print(f" read_data_retention_policy_choice: {choice}") except Exception as e: print(f" read_data_retention_policy_choice: {e}") @@ -543,7 +543,7 @@ def main(): print("Testing readme()...") readme = client.workspaces.readme(args.workspace_id) if readme: - print(f"āœ“ readme: Found README content ({len(readme)} characters)") + print(f" readme: Found README content ({len(readme)} characters)") print( f" Preview: {readme[:100]}..." if len(readme) > 100 diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 4d0227d..7aa1bd8 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -9,6 +9,7 @@ from .resources.notification_configuration import NotificationConfigurations from .resources.oauth_client import OAuthClients from .resources.oauth_token import OAuthTokens +from .resources.organization_membership import OrganizationMemberships from .resources.organizations import Organizations from .resources.plan import Plans from .resources.policy import Policies @@ -65,6 +66,7 @@ def __init__(self, config: TFEConfig | None = None): self.applies = Applies(self._transport) 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/errors.py b/src/pytfe/errors.py index d365904..61853d1 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -55,11 +55,19 @@ class RequiredFieldMissing(TFEError): ... class ErrStateVersionUploadNotSupported(TFEError): ... +# Generic error constants +ERR_UNAUTHORIZED = "unauthorized" +ERR_RESOURCE_NOT_FOUND = "resource not found" +ERR_MISSING_DIRECTORY = "path needs to be an existing directory" +ERR_NAMESPACE_NOT_AUTHORIZED = "namespace not authorized" + # 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" +ERR_INVALID_EMAIL = "invalid email format" +ERR_INVALID_MEMBERSHIP_ID = "invalid value for organization membership ID" # Registry Module Error Constants ERR_REQUIRED_PROVIDER = "provider is required" diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index c40737e..a3cc71f 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -86,6 +86,14 @@ ReadRunQueueOptions, RunQueue, ) +from .organization_membership import ( + OrganizationMembership, + OrganizationMembershipCreateOptions, + OrganizationMembershipListOptions, + OrganizationMembershipReadOptions, + OrganizationMembershipStatus, + OrgMembershipIncludeOpt, +) from .policy import ( Policy, PolicyCreateOptions, @@ -280,6 +288,11 @@ SSHKeyListOptions, SSHKeyUpdateOptions, ) +from .team import ( + OrganizationAccess, + Team, + TeamPermissions, +) # Variables from .variable import ( @@ -455,6 +468,15 @@ "Organization", "OrganizationCreateOptions", "OrganizationUpdateOptions", + "OrganizationMembership", + "OrganizationMembershipCreateOptions", + "OrganizationMembershipListOptions", + "OrganizationMembershipReadOptions", + "OrganizationMembershipStatus", + "OrgMembershipIncludeOpt", + "OrganizationAccess", + "Team", + "TeamPermissions", "Project", "ProjectAddTagBindingsOptions", "ProjectCreateOptions", diff --git a/src/pytfe/models/organization_membership.py b/src/pytfe/models/organization_membership.py new file mode 100644 index 0000000..a588e9c --- /dev/null +++ b/src/pytfe/models/organization_membership.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from enum import Enum +from typing import TYPE_CHECKING + +from pydantic import BaseModel, ConfigDict, Field + +if TYPE_CHECKING: + from .organization import Organization + from .team import Team + from .user import User + + +class OrganizationMembershipStatus(str, Enum): + """Organization membership status enum.""" + + ACTIVE = "active" + INVITED = "invited" + + +class OrgMembershipIncludeOpt(str, Enum): + """Include options for organization membership queries.""" + + USER = "user" + TEAMS = "teams" + + +class OrganizationMembership(BaseModel): + """Represents a Terraform Enterprise organization membership.""" + + model_config = ConfigDict(populate_by_name=True) + + id: str + status: OrganizationMembershipStatus + email: str + + # Relations + organization: Organization | None = None + user: User | None = None + teams: list[Team] | None = None + + +class OrganizationMembershipListOptions(BaseModel): + """Options for listing organization memberships.""" + + model_config = ConfigDict(populate_by_name=True) + + # Pagination + page_number: int | None = Field(None, alias="page[number]") + page_size: int | None = Field(None, alias="page[size]") + + # Include related resources + include: list[OrgMembershipIncludeOpt] | None = None + + # Filters + emails: list[str] | None = Field(None, alias="filter[email]") + status: OrganizationMembershipStatus | None = Field(None, alias="filter[status]") + query: str | None = Field(None, alias="q") + + +class OrganizationMembershipReadOptions(BaseModel): + """Options for reading an organization membership.""" + + model_config = ConfigDict(populate_by_name=True) + + # Include related resources + include: list[OrgMembershipIncludeOpt] | None = None + + +class OrganizationMembershipCreateOptions(BaseModel): + """Options for creating an organization membership.""" + + model_config = ConfigDict(populate_by_name=True) + + # Required + email: str + + # Optional: A list of teams to add the user to + teams: list[Team] | None = None + + +# Rebuild models after all definitions to resolve forward references +def _rebuild_models() -> None: + """Rebuild models to resolve forward references.""" + try: + from .organization import Organization # noqa: F401 + from .team import Team # noqa: F401 + from .user import User # noqa: F401 + + OrganizationMembership.model_rebuild() + OrganizationMembershipCreateOptions.model_rebuild() + except Exception: + # If rebuild fails, models will still work at runtime + pass + + +_rebuild_models() diff --git a/src/pytfe/models/team.py b/src/pytfe/models/team.py new file mode 100644 index 0000000..c19b007 --- /dev/null +++ b/src/pytfe/models/team.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pydantic import BaseModel, ConfigDict + +if TYPE_CHECKING: + from .organization_membership import OrganizationMembership + from .user import User + + +class OrganizationAccess(BaseModel): + """Organization access permissions for a team.""" + + model_config = ConfigDict(populate_by_name=True) + + manage_policies: bool = False + manage_policy_overrides: bool = False + manage_workspaces: bool = False + manage_vcs_settings: bool = False + manage_providers: bool = False + manage_modules: bool = False + manage_run_tasks: bool = False + manage_projects: bool = False + read_workspaces: bool = False + read_projects: bool = False + manage_membership: bool = False + manage_teams: bool = False + manage_organization_access: bool = False + access_secret_teams: bool = False + manage_agent_pools: bool = False + + +class TeamPermissions(BaseModel): + """Team permissions for the current user.""" + + model_config = ConfigDict(populate_by_name=True) + + can_destroy: bool = False + can_update_membership: bool = False + + +class Team(BaseModel): + """Represents a Terraform Enterprise team.""" + + model_config = ConfigDict(populate_by_name=True) + + id: str + name: str | None = None + is_unified: bool = False + organization_access: OrganizationAccess | None = None + visibility: str | None = None + permissions: TeamPermissions | None = None + user_count: int = 0 + sso_team_id: str | None = None + allow_member_token_management: bool = False + + # Relations + users: list[User] | None = None + organization_memberships: list[OrganizationMembership] | None = None + + +def _rebuild_models() -> None: + """Rebuild models to resolve forward references.""" + from .organization import Organization # noqa: F401 + from .organization_membership import OrganizationMembership # noqa: F401 + from .user import User # noqa: F401 + + Team.model_rebuild() + + +_rebuild_models() diff --git a/src/pytfe/models/user.py b/src/pytfe/models/user.py index 69fe53d..26b902e 100644 --- a/src/pytfe/models/user.py +++ b/src/pytfe/models/user.py @@ -7,17 +7,17 @@ class User(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) id: str = Field(..., alias="id") - avatar_url: str = Field(..., alias="avatar-url") - email: str = Field(..., alias="email") - is_service_account: bool = Field(..., alias="is-service-account") - two_factor: dict = Field(..., alias="two-factor") - unconfirmed_email: str = Field(..., alias="unconfirmed-email") - username: str = Field(..., alias="username") - v2_only: bool = Field(..., alias="v2-only") - is_site_admin: bool = Field(..., alias="is-site-admin") # Deprecated - is_admin: bool = Field(..., alias="is-admin") - is_sso_login: bool = Field(..., alias="is-sso-login") - permissions: dict = Field(..., alias="permissions") + avatar_url: str = Field(default="", alias="avatar-url") + email: str = Field(default="", alias="email") + is_service_account: bool = Field(default=False, alias="is-service-account") + two_factor: dict = Field(default_factory=dict, alias="two-factor") + unconfirmed_email: str = Field(default="", alias="unconfirmed-email") + username: str = Field(default="", alias="username") + v2_only: bool = Field(default=False, alias="v2-only") + is_site_admin: bool = Field(default=False, alias="is-site-admin") # Deprecated + is_admin: bool = Field(default=False, alias="is-admin") + is_sso_login: bool = Field(default=False, alias="is-sso-login") + permissions: dict = Field(default_factory=dict, alias="permissions") # Relations # authentication_tokens: AuthenticationTokens = Field(..., alias="authentication-tokens") diff --git a/src/pytfe/resources/organization_membership.py b/src/pytfe/resources/organization_membership.py new file mode 100644 index 0000000..11d7eb2 --- /dev/null +++ b/src/pytfe/resources/organization_membership.py @@ -0,0 +1,284 @@ +from __future__ import annotations + +import re +from collections.abc import Iterator +from typing import Any + +from ..errors import ERR_INVALID_EMAIL, ERR_INVALID_ORG +from ..models.organization import Organization +from ..models.organization_membership import ( + OrganizationMembership, + OrganizationMembershipCreateOptions, + OrganizationMembershipListOptions, + OrganizationMembershipReadOptions, +) +from ..models.team import Team +from ..models.user import User +from ..utils import valid_string_id +from ._base import _Service + + +def _valid_email(email: str) -> bool: + """Validate email format.""" + if not email or not isinstance(email, str): + return False + # Simple email validation pattern + pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" + return re.match(pattern, email) is not None + + +def _validate_email_params(emails: list[str] | None) -> None: + """Validate a list of email parameters.""" + if not emails: + return + for email in emails: + if not _valid_email(email): + raise ValueError(ERR_INVALID_EMAIL) + + +class OrganizationMemberships(_Service): + """Organization memberships service for managing organization members.""" + + def create( + self, + organization: str, + options: OrganizationMembershipCreateOptions, + ) -> OrganizationMembership: + """Create an organization membership with the given options. + + Args: + organization: The name of the organization + options: The options for creating the organization membership + + Returns: + The created OrganizationMembership + + Raises: + ValueError: If organization name is invalid or options are invalid + """ + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + + # Validate email is provided + if not options.email: + raise ValueError("email is required") + + # Validate email format + if not _valid_email(options.email): + raise ValueError(ERR_INVALID_EMAIL) + + # Build the URL path + path = f"/api/v2/organizations/{organization}/organization-memberships" + + # Build the request body + body = { + "data": { + "type": "organization-memberships", + "attributes": { + "email": options.email, + }, + } + } + + # Add teams relationship if provided + if options.teams: + body["data"]["relationships"] = { + "teams": { + "data": [{"type": "teams", "id": team.id} for team in options.teams] + } + } + + # Make the POST request + response = self.t.request("POST", path, json_body=body) + data = response.json() + + return self._parse_membership(data["data"]) + + def list( + self, + organization: str, + options: OrganizationMembershipListOptions | None = None, + ) -> Iterator[OrganizationMembership]: + """List all the organization memberships of the given organization. + + Args: + organization: The name of the organization + options: Optional filters and pagination options + + Yields: + OrganizationMembership instances one at a time + + Raises: + ValueError: If organization name is invalid or email filters are invalid + """ + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + + # Validate options if provided + if options and options.emails: + _validate_email_params(options.emails) + + # Build the URL path + path = f"/api/v2/organizations/{organization}/organization-memberships" + + # Build query parameters from options + params: dict[str, Any] = {} + if options: + options_dict = options.model_dump(by_alias=True, exclude_none=True) + + # Handle include parameter - convert list to comma-separated string + if "include" in options_dict and isinstance(options_dict["include"], list): + options_dict["include"] = ",".join( + opt.value if hasattr(opt, "value") else str(opt) + for opt in options.include or [] + ) + + # Handle emails filter - convert list to comma-separated string + if "filter[email]" in options_dict and isinstance( + options_dict["filter[email]"], list + ): + options_dict["filter[email]"] = ",".join(options_dict["filter[email]"]) + + # Handle status filter - extract value from enum + if "filter[status]" in options_dict: + status_value = options_dict["filter[status]"] + if hasattr(status_value, "value"): + options_dict["filter[status]"] = status_value.value + + params.update(options_dict) + + # Use the _list helper for automatic pagination + for item in self._list(path, params=params): + yield self._parse_membership(item) + + def read(self, organization_membership_id: str) -> OrganizationMembership: + """Read an organization membership by its ID. + + Args: + organization_membership_id: The ID of the organization membership to read + + Returns: + The OrganizationMembership + + Raises: + ValueError: If organization membership ID is invalid + NotFound: If the resource is not found + """ + return self.read_with_options( + organization_membership_id, OrganizationMembershipReadOptions() + ) + + def read_with_options( + self, + organization_membership_id: str, + options: OrganizationMembershipReadOptions, + ) -> OrganizationMembership: + """Read an organization membership by ID with options. + + Args: + organization_membership_id: The ID of the organization membership to read + options: Read options including include parameters + + Returns: + The OrganizationMembership with requested included data + + Raises: + ValueError: If organization membership ID is invalid + NotFound: If the resource is not found + """ + if not valid_string_id(organization_membership_id): + raise ValueError("invalid organization membership ID") + + # Build the URL path + path = f"/api/v2/organization-memberships/{organization_membership_id}" + + # Build query parameters from options + params: dict[str, Any] = {} + if options: + options_dict = options.model_dump(by_alias=True, exclude_none=True) + + # Handle include parameter - convert list to comma-separated string + if "include" in options_dict and isinstance(options_dict["include"], list): + options_dict["include"] = ",".join( + opt.value if hasattr(opt, "value") else str(opt) + for opt in options.include or [] + ) + + params.update(options_dict) + + # Make the GET request + # NotFound exception will be raised by self.t.request if resource doesn't exist + response = self.t.request("GET", path, params=params) + data = response.json() + return self._parse_membership(data["data"]) + + def delete(self, organization_membership_id: str) -> None: + """Delete an organization membership by its ID. + + Args: + organization_membership_id: The ID of the organization membership to delete + + Raises: + ValueError: If organization membership ID is invalid + """ + if not valid_string_id(organization_membership_id): + raise ValueError("invalid organization membership ID") + + # Build the URL path + path = f"/api/v2/organization-memberships/{organization_membership_id}" + + # Make the DELETE request + self.t.request("DELETE", path) + + def _parse_membership(self, data: dict[str, Any]) -> OrganizationMembership: + """Parse a membership from API response data. + + Args: + data: The raw API response data for a membership + + Returns: + OrganizationMembership instance + """ + membership_id = data.get("id", "") + attributes = data.get("attributes", {}) + + # Extract basic attributes + status = attributes.get("status", "active") + email = attributes.get("email", "") + + # Extract relationships if present + relationships = data.get("relationships", {}) + + # Parse organization relationship + organization = None + if "organization" in relationships: + org_data = relationships["organization"].get("data") + if org_data: + organization = Organization(id=org_data.get("id")) + + # Parse user relationship + user = None + if "user" in relationships: + user_data = relationships["user"].get("data") + if user_data: + user = User(id=user_data.get("id")) + + # Parse teams relationship + teams = None + if "teams" in relationships: + teams_data = relationships["teams"].get("data", []) + if teams_data: + teams = [Team(id=team.get("id")) for team in teams_data] + + # Handle included data if present (for full user/org objects) + # This would be populated when include options are used + # For now, keeping it simple with just IDs + + return OrganizationMembership( + id=membership_id, + status=status, + email=email, + organization=organization, + user=user, + teams=teams, + ) diff --git a/src/pytfe/resources/policy_check.py b/src/pytfe/resources/policy_check.py index 7529f61..affae77 100644 --- a/src/pytfe/resources/policy_check.py +++ b/src/pytfe/resources/policy_check.py @@ -97,7 +97,7 @@ def logs(self, policy_check_id: str) -> str: # Continue polling if the policy check is still pending or queued if pc.status in (PolicyStatus.POLICY_PENDING, PolicyStatus.POLICY_QUEUED): - time.sleep(0.5) # 500ms wait, equivalent to Go's 500 * time.Millisecond + time.sleep(0.5) # 500ms wait continue # Policy check is finished, get the logs diff --git a/src/pytfe/resources/projects.py b/src/pytfe/resources/projects.py index 9011a3c..e64cb34 100644 --- a/src/pytfe/resources/projects.py +++ b/src/pytfe/resources/projects.py @@ -105,7 +105,7 @@ def list( self, organization: str, options: ProjectListOptions | None = None ) -> Iterator[Project]: """List projects in an organization""" - # Validate inputs following Go patterns + # Validate inputs validate_project_list_options(organization) path = f"/api/v2/organizations/{organization}/projects" @@ -129,7 +129,7 @@ def list( items_iter = self._list(path) for item in items_iter: - # Extract project data following Go patterns + # Extract project data attr = item.get("attributes", {}) or {} project_data = { "id": _safe_str(item.get("id")), @@ -147,7 +147,7 @@ def list( def create(self, organization: str, options: ProjectCreateOptions) -> Project: """Create a new project in an organization""" - # Validate inputs following Go patterns + # Validate inputs validate_project_create_options(organization, options.name, options.description) path = f"/api/v2/organizations/{organization}/projects" @@ -160,7 +160,7 @@ def create(self, organization: str, options: ProjectCreateOptions) -> Project: response = self.t.request("POST", path, json_body=payload) data = response.json()["data"] - # Extract project data following Go patterns + # Extract project data attr = data.get("attributes", {}) or {} project_data = { "id": _safe_str(data.get("id")), @@ -180,7 +180,7 @@ def read( self, project_id: str, include: builtins.list[str] | None = None ) -> Project: """Get a specific project by ID""" - # Validate inputs following Go patterns + # Validate inputs if not valid_string_id(project_id): raise ValueError("Project ID is required and must be valid") @@ -201,7 +201,7 @@ def read( org_data = relationships.get("organization", {}).get("data", {}) organization = _safe_str(org_data.get("id")) - # Extract project data following Go patterns + # Extract project data attr = data.get("attributes", {}) or {} project_data = { "id": _safe_str(data.get("id")), @@ -219,7 +219,7 @@ def read( def update(self, project_id: str, options: ProjectUpdateOptions) -> Project: """Update a project's name and/or description""" - # Validate inputs following Go patterns + # Validate inputs validate_project_update_options(project_id, options.name, options.description) path = f"/api/v2/projects/{project_id}" @@ -242,7 +242,7 @@ def update(self, project_id: str, options: ProjectUpdateOptions) -> Project: org_data = relationships.get("organization", {}).get("data", {}) organization = _safe_str(org_data.get("id")) - # Extract project data following Go patterns + # Extract project data attr = data.get("attributes", {}) or {} project_data = { "id": _safe_str(data.get("id")), @@ -260,7 +260,7 @@ def update(self, project_id: str, options: ProjectUpdateOptions) -> Project: def delete(self, project_id: str) -> None: """Delete a project""" - # Validate inputs following Go patterns + # Validate inputs if not valid_string_id(project_id): raise ValueError("Project ID is required and must be valid") diff --git a/tests/units/test_organization_membership.py b/tests/units/test_organization_membership.py new file mode 100644 index 0000000..11888d5 --- /dev/null +++ b/tests/units/test_organization_membership.py @@ -0,0 +1,835 @@ +""" +Comprehensive unit tests for organization membership operations in the Python TFE SDK. + +This test suite covers all organization membership methods including list, create, read, +read with options, and delete operations. +""" + +from unittest.mock import Mock + +import pytest + +from src.pytfe.errors import ( + ERR_INVALID_EMAIL, + ERR_INVALID_ORG, + ERR_REQUIRED_EMAIL, + NotFound, +) +from src.pytfe.models.organization_membership import ( + OrganizationMembershipCreateOptions, + OrganizationMembershipListOptions, + OrganizationMembershipReadOptions, + OrganizationMembershipStatus, + OrgMembershipIncludeOpt, +) +from src.pytfe.models.team import OrganizationAccess, Team +from src.pytfe.resources.organization_membership import OrganizationMemberships + + +class TestOrganizationMembershipList: + """Test suite for organization membership list operations.""" + + @pytest.fixture + def mock_transport(self): + """Mock HTTP transport.""" + transport = Mock() + return transport + + @pytest.fixture + def membership_service(self, mock_transport): + """Create organization membership service with mocked transport.""" + return OrganizationMemberships(mock_transport) + + @pytest.fixture + def sample_membership_response(self): + """Sample JSON:API organization membership response.""" + return { + "data": [ + { + "type": "organization-memberships", + "id": "ou-abc123def456", + "attributes": {"status": "active"}, + "relationships": { + "teams": { + "data": [{"type": "teams", "id": "team-yUrEehvfG4pdmSjc"}] + }, + "user": {"data": {"type": "users", "id": "user-123"}}, + "organization": { + "data": {"type": "organizations", "id": "org-test"} + }, + }, + }, + { + "type": "organization-memberships", + "id": "ou-xyz789ghi012", + "attributes": {"status": "invited"}, + "relationships": { + "teams": { + "data": [{"type": "teams", "id": "team-yUrEehvfG4pdmSjc"}] + }, + "user": {"data": {"type": "users", "id": "user-456"}}, + "organization": { + "data": {"type": "organizations", "id": "org-test"} + }, + }, + }, + ], + "meta": {"pagination": {"current-page": 1, "total-count": 2}}, + } + + def test_list_without_options( + self, membership_service, mock_transport, sample_membership_response + ): + """Test listing organization memberships without options.""" + mock_response = Mock() + mock_response.json.return_value = sample_membership_response + mock_transport.request.return_value = mock_response + + memberships = list(membership_service.list("test-org")) + + assert len(memberships) == 2 + assert memberships[0].status == OrganizationMembershipStatus.ACTIVE + assert memberships[0].id == "ou-abc123def456" + assert memberships[1].status == OrganizationMembershipStatus.INVITED + assert memberships[1].id == "ou-xyz789ghi012" + mock_transport.request.assert_called_once() + + def test_list_with_pagination_options(self, membership_service, mock_transport): + """Test listing with pagination options.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": [], + "meta": {"pagination": {"current-page": 999, "total-count": 2}}, + } + mock_transport.request.return_value = mock_response + + options = OrganizationMembershipListOptions(page_number=999, page_size=100) + memberships = list(membership_service.list("test-org", options)) + + assert len(memberships) == 0 + # Verify pagination params are passed + call_args = mock_transport.request.call_args + assert call_args is not None + + def test_list_with_include_options( + self, membership_service, mock_transport, sample_membership_response + ): + """Test listing with include options for user and teams.""" + mock_response = Mock() + mock_response.json.return_value = sample_membership_response + mock_transport.request.return_value = mock_response + + options = OrganizationMembershipListOptions( + include=[OrgMembershipIncludeOpt.USER, OrgMembershipIncludeOpt.TEAMS] + ) + memberships = list(membership_service.list("test-org", options)) + + assert len(memberships) == 2 + mock_transport.request.assert_called_once() + + def test_list_with_email_filter(self, membership_service, mock_transport): + """Test listing with email filter option.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": [ + { + "type": "organization-memberships", + "id": "ou-abc123", + "attributes": {"status": "active"}, + "relationships": { + "teams": {"data": [{"type": "teams", "id": "team-xyz"}]}, + "user": {"data": {"type": "users", "id": "user-abc"}}, + "organization": { + "data": {"type": "organizations", "id": "test-org"} + }, + }, + } + ], + "meta": {"pagination": {"current-page": 1, "total-count": 1}}, + } + mock_transport.request.return_value = mock_response + + options = OrganizationMembershipListOptions(emails=["specific@example.com"]) + memberships = list(membership_service.list("test-org", options)) + + assert len(memberships) == 1 + assert memberships[0].status == OrganizationMembershipStatus.ACTIVE + + def test_list_with_status_filter(self, membership_service, mock_transport): + """Test listing with status filter option.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": [ + { + "type": "organization-memberships", + "id": "ou-abc123", + "attributes": {"status": "invited"}, + "relationships": { + "teams": {"data": [{"type": "teams", "id": "team-xyz"}]}, + "user": {"data": {"type": "users", "id": "user-abc"}}, + "organization": { + "data": {"type": "organizations", "id": "test-org"} + }, + }, + } + ], + "meta": {"pagination": {"current-page": 1, "total-count": 1}}, + } + mock_transport.request.return_value = mock_response + + options = OrganizationMembershipListOptions( + status=OrganizationMembershipStatus.INVITED + ) + memberships = list(membership_service.list("test-org", options)) + + assert len(memberships) == 1 + assert memberships[0].status == OrganizationMembershipStatus.INVITED + + def test_list_with_query_string(self, membership_service, mock_transport): + """Test listing with search query string.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": [ + { + "type": "organization-memberships", + "id": "ou-abc123", + "attributes": {"status": "active"}, + "relationships": { + "teams": {"data": [{"type": "teams", "id": "team-xyz"}]}, + "user": {"data": {"type": "users", "id": "user-abc"}}, + "organization": { + "data": {"type": "organizations", "id": "test-org"} + }, + }, + } + ], + "meta": {"pagination": {"current-page": 1, "total-count": 1}}, + } + mock_transport.request.return_value = mock_response + + options = OrganizationMembershipListOptions(query="example.com") + memberships = list(membership_service.list("test-org", options)) + + assert len(memberships) == 1 + + def test_list_with_invalid_organization(self, membership_service): + """Test listing with invalid organization name.""" + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + list(membership_service.list("")) + + +class TestOrganizationMembershipCreate: + """Test suite for organization membership create operations.""" + + @pytest.fixture + def mock_transport(self): + """Mock HTTP transport.""" + transport = Mock() + return transport + + @pytest.fixture + def membership_service(self, mock_transport): + """Create organization membership service with mocked transport.""" + return OrganizationMemberships(mock_transport) + + @pytest.fixture + def sample_create_response(self): + """Sample JSON:API create response.""" + return { + "data": { + "type": "organization-memberships", + "id": "ou-newmember123", + "attributes": {"status": "invited"}, + "relationships": { + "teams": { + "data": [{"type": "teams", "id": "team-GeLZkdnK6xAVjA5H"}] + }, + "user": {"data": {"type": "users", "id": "user-J8oxGmRk5eC2WLfX"}}, + "organization": { + "data": {"type": "organizations", "id": "my-organization"} + }, + }, + }, + "included": [ + { + "id": "user-J8oxGmRk5eC2WLfX", + "type": "users", + "attributes": { + "username": None, + "is-service-account": False, + "auth-method": "hcp_sso", + "avatar-url": "https://www.gravatar.com/avatar/55502f40dc8b7c769880b10874abc9d0?s=100&d=mm", + "two-factor": {"enabled": False, "verified": False}, + "email": "newuser@example.com", + "permissions": { + "can-create-organizations": True, + "can-change-email": True, + "can-change-username": True, + "can-manage-user-tokens": False, + }, + }, + "relationships": { + "authentication-tokens": { + "links": { + "related": "/api/v2/users/user-J8oxGmRk5eC2WLfX/authentication-tokens" + } + } + }, + "links": {"self": "/api/v2/users/user-J8oxGmRk5eC2WLfX"}, + } + ], + } + + def test_create_with_valid_options( + self, membership_service, mock_transport, sample_create_response + ): + """Test creating organization membership with valid options.""" + mock_response = Mock() + mock_response.json.return_value = sample_create_response + mock_transport.request.return_value = mock_response + + options = OrganizationMembershipCreateOptions(email="newuser@example.com") + membership = membership_service.create("test-org", options) + + assert membership.status == OrganizationMembershipStatus.INVITED + assert membership.id == "ou-newmember123" + assert membership.user is not None + # User is parsed as a User object with id + assert membership.user.id == "user-J8oxGmRk5eC2WLfX" + mock_transport.request.assert_called_once() + + def test_create_with_teams(self, membership_service, mock_transport): + """Test creating organization membership with initial teams.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "type": "organization-memberships", + "id": "ou-withteams123", + "attributes": {"status": "invited"}, + "relationships": { + "teams": { + "data": [ + {"type": "teams", "id": "team-123"}, + {"type": "teams", "id": "team-456"}, + ] + }, + "user": {"data": {"type": "users", "id": "user-xyz"}}, + "organization": { + "data": {"type": "organizations", "id": "test-org"} + }, + }, + } + } + mock_transport.request.return_value = mock_response + + team1 = Team(id="team-123") + team2 = Team(id="team-456") + options = OrganizationMembershipCreateOptions( + email="teamuser@example.com", teams=[team1, team2] + ) + membership = membership_service.create("test-org", options) + + assert membership.status == OrganizationMembershipStatus.INVITED + assert membership.teams is not None + assert len(membership.teams) == 2 + + def test_create_with_organization_access(self, membership_service, mock_transport): + """Test creating membership with team that has organization access.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "type": "organization-memberships", + "id": "ou-orgaccess123", + "attributes": {"status": "invited"}, + "relationships": { + "teams": {"data": [{"type": "teams", "id": "team-123"}]}, + "user": {"data": {"type": "users", "id": "user-abc"}}, + "organization": { + "data": {"type": "organizations", "id": "test-org"} + }, + }, + } + } + mock_transport.request.return_value = mock_response + + team = Team( + id="team-123", organization_access=OrganizationAccess(read_workspaces=True) + ) + options = OrganizationMembershipCreateOptions( + email="orgaccess@example.com", teams=[team] + ) + membership = membership_service.create("test-org", options) + + assert membership.status == OrganizationMembershipStatus.INVITED + assert membership.id == "ou-orgaccess123" + + def test_create_with_invalid_organization(self, membership_service): + """Test creating with invalid organization name.""" + options = OrganizationMembershipCreateOptions(email="user@example.com") + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + membership_service.create("", options) + + def test_create_with_missing_email(self, membership_service): + """Test creating without required email.""" + options = OrganizationMembershipCreateOptions(email="") + with pytest.raises(ValueError, match=ERR_REQUIRED_EMAIL): + membership_service.create("test-org", options) + + def test_create_with_invalid_email(self, membership_service): + """Test creating with invalid email format.""" + options = OrganizationMembershipCreateOptions(email="not-an-email") + with pytest.raises(ValueError, match=ERR_INVALID_EMAIL): + membership_service.create("test-org", options) + + +class TestOrganizationMembershipRead: + """Test suite for organization membership read operations.""" + + @pytest.fixture + def mock_transport(self): + """Mock HTTP transport.""" + transport = Mock() + return transport + + @pytest.fixture + def membership_service(self, mock_transport): + """Create organization membership service with mocked transport.""" + return OrganizationMemberships(mock_transport) + + @pytest.fixture + def sample_read_response(self): + """Sample JSON:API read response.""" + return { + "data": { + "type": "organization-memberships", + "id": "ou-abc123def456", + "attributes": {"status": "active"}, + "relationships": { + "teams": { + "data": [{"type": "teams", "id": "team-97LkM7QciNkwb2nh"}] + }, + "user": {"data": {"type": "users", "id": "user-123"}}, + "organization": { + "data": {"type": "organizations", "id": "org-test"} + }, + }, + } + } + + def test_read_when_membership_exists( + self, membership_service, mock_transport, sample_read_response + ): + """Test reading organization membership when it exists.""" + mock_response = Mock() + mock_response.json.return_value = sample_read_response + mock_transport.request.return_value = mock_response + + membership = membership_service.read("ou-abc123def456") + + assert membership is not None + assert membership.id == "ou-abc123def456" + assert membership.status == OrganizationMembershipStatus.ACTIVE + mock_transport.request.assert_called_once() + + def test_read_when_membership_not_found(self, membership_service, mock_transport): + """Test reading when membership does not exist.""" + mock_transport.request.side_effect = NotFound("not found", status=404) + + with pytest.raises(NotFound): + membership_service.read("ou-nonexisting") + + def test_read_with_invalid_membership_id(self, membership_service): + """Test reading with invalid membership ID.""" + with pytest.raises(ValueError, match="invalid organization membership ID"): + membership_service.read("") + + +class TestOrganizationMembershipReadWithOptions: + """Test suite for organization membership read with options operations.""" + + @pytest.fixture + def mock_transport(self): + """Mock HTTP transport.""" + transport = Mock() + return transport + + @pytest.fixture + def membership_service(self, mock_transport): + """Create organization membership service with mocked transport.""" + return OrganizationMemberships(mock_transport) + + @pytest.fixture + def sample_read_with_user_response(self): + """Sample JSON:API read response with user included.""" + return { + "data": { + "type": "organization-memberships", + "id": "ou-abc123def456", + "attributes": {"status": "active"}, + "relationships": { + "teams": { + "data": [{"type": "teams", "id": "team-97LkM7QciNkwb2nh"}] + }, + "user": {"data": {"type": "users", "id": "user-123"}}, + "organization": { + "data": {"type": "organizations", "id": "org-test"} + }, + }, + }, + "included": [ + { + "type": "users", + "id": "user-123", + "attributes": { + "username": "testuser", + "is-service-account": False, + "avatar-url": "https://www.gravatar.com/avatar/test?s=100&d=mm", + "two-factor": {"enabled": False, "verified": False}, + "email": "user@example.com", + "permissions": { + "can-create-organizations": True, + "can-change-email": True, + }, + }, + "relationships": { + "authentication-tokens": { + "links": { + "related": "/api/v2/users/user-123/authentication-tokens" + } + } + }, + "links": {"self": "/api/v2/users/user-123"}, + } + ], + } + + def test_read_with_options_include_user( + self, membership_service, mock_transport, sample_read_with_user_response + ): + """Test reading with include user option.""" + mock_response = Mock() + mock_response.json.return_value = sample_read_with_user_response + mock_transport.request.return_value = mock_response + + options = OrganizationMembershipReadOptions( + include=[OrgMembershipIncludeOpt.USER] + ) + membership = membership_service.read_with_options("ou-abc123def456", options) + + assert membership is not None + assert membership.id == "ou-abc123def456" + assert membership.user is not None + + def test_read_with_options_include_teams(self, membership_service, mock_transport): + """Test reading with include teams option.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "type": "organization-memberships", + "id": "ou-abc123def456", + "attributes": {"status": "active"}, + "relationships": { + "teams": { + "data": [ + {"type": "teams", "id": "team-123"}, + {"type": "teams", "id": "team-456"}, + ] + }, + "user": {"data": {"type": "users", "id": "user-123"}}, + "organization": { + "data": {"type": "organizations", "id": "org-test"} + }, + }, + } + } + mock_transport.request.return_value = mock_response + + options = OrganizationMembershipReadOptions( + include=[OrgMembershipIncludeOpt.TEAMS] + ) + membership = membership_service.read_with_options("ou-abc123def456", options) + + assert membership is not None + assert membership.teams is not None + assert len(membership.teams) == 2 + + def test_read_with_options_without_options( + self, membership_service, mock_transport + ): + """Test reading with empty options.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "type": "organization-memberships", + "id": "ou-abc123def456", + "attributes": {"status": "active"}, + "relationships": { + "teams": { + "data": [{"type": "teams", "id": "team-97LkM7QciNkwb2nh"}] + }, + "user": {"data": {"type": "users", "id": "user-123"}}, + "organization": { + "data": {"type": "organizations", "id": "org-test"} + }, + }, + } + } + mock_transport.request.return_value = mock_response + + options = OrganizationMembershipReadOptions() + membership = membership_service.read_with_options("ou-abc123def456", options) + + assert membership is not None + assert membership.id == "ou-abc123def456" + + def test_read_with_options_not_found(self, membership_service, mock_transport): + """Test reading with options when membership doesn't exist.""" + mock_transport.request.side_effect = NotFound("not found", status=404) + + options = OrganizationMembershipReadOptions( + include=[OrgMembershipIncludeOpt.USER] + ) + with pytest.raises(NotFound): + membership_service.read_with_options("ou-nonexisting", options) + + def test_read_with_options_invalid_id(self, membership_service): + """Test reading with options with invalid membership ID.""" + options = OrganizationMembershipReadOptions() + with pytest.raises(ValueError, match="invalid organization membership ID"): + membership_service.read_with_options("", options) + + +class TestOrganizationMembershipDelete: + """Test suite for organization membership delete operations.""" + + @pytest.fixture + def mock_transport(self): + """Mock HTTP transport.""" + transport = Mock() + return transport + + @pytest.fixture + def membership_service(self, mock_transport): + """Create organization membership service with mocked transport.""" + return OrganizationMemberships(mock_transport) + + def test_delete_with_valid_id(self, membership_service, mock_transport): + """Test deleting organization membership with valid ID.""" + mock_response = Mock() + mock_response.status_code = 204 + mock_transport.request.return_value = mock_response + + membership_service.delete("ou-abc123def456") + + mock_transport.request.assert_called_once() + call_args = mock_transport.request.call_args + assert call_args[0][0] == "DELETE" + + def test_delete_with_invalid_id(self, membership_service): + """Test deleting with invalid membership ID.""" + with pytest.raises(ValueError, match="invalid organization membership ID"): + membership_service.delete("") + + def test_delete_nonexistent_membership(self, membership_service, mock_transport): + """Test deleting a membership that doesn't exist.""" + mock_transport.request.side_effect = NotFound("not found", status=404) + + with pytest.raises(NotFound): + membership_service.delete("ou-nonexisting") + + +class TestOrganizationMembershipValidation: + """Test suite for organization membership validation.""" + + @pytest.fixture + def mock_transport(self): + """Mock HTTP transport.""" + transport = Mock() + return transport + + @pytest.fixture + def membership_service(self, mock_transport): + """Create organization membership service with mocked transport.""" + return OrganizationMemberships(mock_transport) + + def test_validate_email_format(self, membership_service): + """Test email validation with invalid formats.""" + invalid_emails = [ + "not-an-email", + "@example.com", + "user@", + "user", + "", + ] + + for email in invalid_emails: + options = OrganizationMembershipCreateOptions(email=email) + with pytest.raises(ValueError): + membership_service.create("test-org", options) + + def test_validate_valid_email_format(self, membership_service, mock_transport): + """Test email validation with valid formats.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "type": "organization-memberships", + "id": "ou-test", + "attributes": {"status": "invited"}, + "relationships": { + "teams": {"data": [{"type": "teams", "id": "team-abc"}]}, + "user": {"data": {"type": "users", "id": "user-xyz"}}, + "organization": { + "data": {"type": "organizations", "id": "test-org"} + }, + }, + } + } + mock_transport.request.return_value = mock_response + + valid_emails = [ + "user@example.com", + "user.name@example.com", + "user+tag@example.co.uk", + ] + + for email in valid_emails: + options = OrganizationMembershipCreateOptions(email=email) + membership = membership_service.create("test-org", options) + assert membership is not None + + +class TestOrganizationMembershipIntegration: + """Integration tests for complete workflows.""" + + @pytest.fixture + def mock_transport(self): + """Mock HTTP transport.""" + transport = Mock() + return transport + + @pytest.fixture + def membership_service(self, mock_transport): + """Create organization membership service with mocked transport.""" + return OrganizationMemberships(mock_transport) + + def test_create_read_delete_workflow(self, membership_service, mock_transport): + """Test complete workflow: create, read, then delete.""" + # Mock create response + create_response = Mock() + create_response.json.return_value = { + "data": { + "type": "organization-memberships", + "id": "ou-workflow123", + "attributes": {"status": "invited"}, + "relationships": { + "teams": {"data": [{"type": "teams", "id": "team-abc"}]}, + "user": {"data": {"type": "users", "id": "user-xyz"}}, + "organization": { + "data": {"type": "organizations", "id": "test-org"} + }, + }, + } + } + + # Mock read response + read_response = Mock() + read_response.json.return_value = { + "data": { + "type": "organization-memberships", + "id": "ou-workflow123", + "attributes": {"status": "invited"}, + "relationships": { + "teams": {"data": [{"type": "teams", "id": "team-abc"}]}, + "user": {"data": {"type": "users", "id": "user-xyz"}}, + "organization": { + "data": {"type": "organizations", "id": "test-org"} + }, + }, + } + } + + # Mock delete response + delete_response = Mock() + delete_response.status_code = 204 + + mock_transport.request.side_effect = [ + create_response, + read_response, + delete_response, + ] + + # Create + options = OrganizationMembershipCreateOptions(email="workflow@example.com") + created = membership_service.create("test-org", options) + assert created.id == "ou-workflow123" + + # Read + read_membership = membership_service.read("ou-workflow123") + assert read_membership.id == created.id + + # Delete + membership_service.delete("ou-workflow123") + + assert mock_transport.request.call_count == 3 + + def test_list_filter_and_read_workflow(self, membership_service, mock_transport): + """Test workflow: list with filters, then read specific member.""" + # Mock list response + list_response = Mock() + list_response.json.return_value = { + "data": [ + { + "type": "organization-memberships", + "id": "ou-member1", + "attributes": {"status": "active"}, + "relationships": { + "teams": { + "data": [{"type": "teams", "id": "team-yUrEehvfG4pdmSjc"}] + }, + "user": {"data": {"type": "users", "id": "user-123"}}, + "organization": { + "data": {"type": "organizations", "id": "test-org"} + }, + }, + } + ], + "meta": {"pagination": {"current-page": 1, "total-count": 1}}, + } + + # Mock read response + read_response = Mock() + read_response.json.return_value = { + "data": { + "type": "organization-memberships", + "id": "ou-member1", + "attributes": {"status": "active"}, + "relationships": { + "teams": { + "data": [{"type": "teams", "id": "team-yUrEehvfG4pdmSjc"}] + }, + "user": {"data": {"type": "users", "id": "user-123"}}, + "organization": { + "data": {"type": "organizations", "id": "test-org"} + }, + }, + } + } + + mock_transport.request.side_effect = [list_response, read_response] + + # List with filter + options = OrganizationMembershipListOptions( + status=OrganizationMembershipStatus.ACTIVE + ) + memberships = list(membership_service.list("test-org", options)) + assert len(memberships) == 1 + assert memberships[0].status == OrganizationMembershipStatus.ACTIVE + + # Read specific member with options + read_options = OrganizationMembershipReadOptions( + include=[OrgMembershipIncludeOpt.USER] + ) + member = membership_service.read_with_options(memberships[0].id, read_options) + assert member.user is not None + + assert mock_transport.request.call_count == 2 From a642c9eee0a82d95f5daeb7accd8c8cd33ddcd1a Mon Sep 17 00:00:00 2001 From: aayushsingh2502 Date: Mon, 1 Dec 2025 11:42:17 +0530 Subject: [PATCH 08/14] code cleanup --- examples/organization_membership.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/organization_membership.py b/examples/organization_membership.py index 2fcc833..921c6d4 100644 --- a/examples/organization_membership.py +++ b/examples/organization_membership.py @@ -307,13 +307,13 @@ def main(): print(f" Attempting to delete membership: {membership_id}") client.organization_memberships.delete(membership_id) - print(f" āœ“ Successfully deleted membership {membership_id}") + print(f" Successfully deleted membership {membership_id}") except NotFound as e: - print(f" āœ— Membership not found: {e}") - print(" ℹ The membership may have already been deleted or the ID is invalid") + print(f" Membership not found: {e}") + print(" The membership may have already been deleted or the ID is invalid") except Exception as e: - print(f" āœ— Error deleting membership: {type(e).__name__}: {e}") + print(f" Error deleting membership: {type(e).__name__}: {e}") if __name__ == "__main__": From d9f770cda22aac725581b7443133467d28f48cb5 Mon Sep 17 00:00:00 2001 From: aayushsingh2502 Date: Mon, 1 Dec 2025 15:44:31 +0530 Subject: [PATCH 09/14] read with options update --- examples/organization_membership.py | 4 ---- src/pytfe/resources/organization_membership.py | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/examples/organization_membership.py b/examples/organization_membership.py index 921c6d4..0c8e1c0 100644 --- a/examples/organization_membership.py +++ b/examples/organization_membership.py @@ -82,7 +82,6 @@ def main(): print(f" {membership.email}") print(f" Processed {count} memberships (fetched in batches of 3)") - print(" Success: Pagination working correctly") except Exception as e: print(f" Error: {type(e).__name__}: {e}") @@ -105,7 +104,6 @@ def main(): print(f" {membership.email} (User ID: {user_id})") print(f" Processed {count} memberships, {users_found} with user data") - print(" Success: Include parameter working") except Exception as e: print(f" Error: {type(e).__name__}: {e}") @@ -129,7 +127,6 @@ def main(): if len(invited) == 0: print(" No invited members found") - print(" Success: Status filter working") except Exception as e: print(f" Error: {type(e).__name__}: {e}") @@ -263,7 +260,6 @@ def main(): print(f" User ID: {user_id}") team_count = len(membership.teams) if membership.teams else 0 print(f" Teams: {team_count}") - print(" Success: Read with options working") else: print(" Skipped: No memberships available from Test 1") except Exception as e: diff --git a/src/pytfe/resources/organization_membership.py b/src/pytfe/resources/organization_membership.py index 11d7eb2..659608b 100644 --- a/src/pytfe/resources/organization_membership.py +++ b/src/pytfe/resources/organization_membership.py @@ -171,7 +171,7 @@ def read(self, organization_membership_id: str) -> OrganizationMembership: def read_with_options( self, organization_membership_id: str, - options: OrganizationMembershipReadOptions, + options: OrganizationMembershipReadOptions | None = None, ) -> OrganizationMembership: """Read an organization membership by ID with options. From 47102795d83e3f42d496b1e4850d23cf85862874 Mon Sep 17 00:00:00 2001 From: KshitijaChoudhari Date: Wed, 3 Dec 2025 15:53:26 +0530 Subject: [PATCH 10/14] feat: add workspace resources functionality - Add WorkspaceResource model and WorkspaceResourceListOptions - Add WorkspaceResourcesService for listing workspace resources - Add workspace_resources.py example CLI tool with flag-based interface - Add comprehensive unit tests for workspace resources - Update client.py to include workspace_resources service - Update models/__init__.py with WorkspaceResource exports --- examples/workspace_resources.py | 130 +++++++++ src/pytfe/client.py | 2 + src/pytfe/models/__init__.py | 9 + src/pytfe/models/workspace_resource.py | 29 ++ src/pytfe/resources/workspace_resources.py | 69 +++++ tests/units/test_workspace_resources.py | 297 +++++++++++++++++++++ 6 files changed, 536 insertions(+) create mode 100644 examples/workspace_resources.py create mode 100644 src/pytfe/models/workspace_resource.py create mode 100644 src/pytfe/resources/workspace_resources.py create mode 100644 tests/units/test_workspace_resources.py diff --git a/examples/workspace_resources.py b/examples/workspace_resources.py new file mode 100644 index 0000000..730e24d --- /dev/null +++ b/examples/workspace_resources.py @@ -0,0 +1,130 @@ +"""Example script for working with workspace resources in Terraform Enterprise. + +This script demonstrates how to list resources within a workspace. +""" + +import argparse +import sys + +from pytfe import TFEClient +from pytfe.models import WorkspaceResourceListOptions + + +def list_workspace_resources( + client: TFEClient, + workspace_id: str, + page_number: int | None = None, + page_size: int | None = None, +) -> None: + """List all resources in a workspace.""" + try: + print(f"Listing resources for workspace: {workspace_id}") + + # Prepare list options + options = None + if page_number or page_size: + options = WorkspaceResourceListOptions() + if page_number: + options.page_number = page_number + if page_size: + options.page_size = page_size + + # List workspace resources (returns an iterator) + resources = list(client.workspace_resources.list(workspace_id, options)) + + if not resources: + print("No resources found in this workspace.") + return + + print(f"\nFound {len(resources)} resource(s):") + print("-" * 80) + + for resource in resources: + print(f"ID: {resource.id}") + print(f"Address: {resource.address}") + print(f"Name: {resource.name}") + print(f"Module: {resource.module}") + print(f"Provider: {resource.provider}") + print(f"Provider Type: {resource.provider_type}") + print(f"Created At: {resource.created_at}") + print(f"Updated At: {resource.updated_at}") + print(f"Modified By State Version: {resource.modified_by_state_version_id}") + if resource.name_index: + print(f"Name Index: {resource.name_index}") + print("-" * 80) + + except Exception as e: + print(f"Error listing workspace resources: {e}", file=sys.stderr) + sys.exit(1) + + +def main(): + """Main function to handle command line arguments and execute operations.""" + parser = argparse.ArgumentParser( + description="Manage workspace resources in Terraform Enterprise", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # List all resources in a workspace + python workspace_resources.py --list --workspace-id ws-abc123 + + # List with pagination + python workspace_resources.py --list --workspace-id ws-abc123 --page-number 2 --page-size 50 + +Environment variables: + TFE_TOKEN: Your Terraform Enterprise API token + TFE_URL: Your Terraform Enterprise URL (default: https://app.terraform.io) + TFE_ORG: Your Terraform Enterprise organization name + """, + ) + + # Add command flags + parser.add_argument( + "--list", + action="store_true", + help="List workspace resources" + ) + parser.add_argument( + "--workspace-id", + required=True, + help="ID of the workspace (required, e.g., ws-abc123)" + ) + parser.add_argument( + "--page-number", + type=int, + help="Page number for pagination" + ) + parser.add_argument( + "--page-size", + type=int, + help="Page size for pagination" + ) + + args = parser.parse_args() + + if not args.list: + parser.print_help() + sys.exit(1) + + # Initialize TFE client + try: + client = TFEClient() + except Exception as e: + print(f"Error initializing TFE client: {e}", file=sys.stderr) + print( + "Make sure TFE_TOKEN and TFE_URL environment variables are set.", + file=sys.stderr, + ) + sys.exit(1) + + # Execute the list command + list_workspace_resources( + client, + args.workspace_id, + args.page_number, + args.page_size, + ) + + +if __name__ == "__main__": + main() diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 7aa1bd8..f50cdfe 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -33,6 +33,7 @@ from .resources.state_versions import StateVersions from .resources.variable import Variables from .resources.variable_sets import VariableSets, VariableSetVariables +from .resources.workspace_resources import WorkspaceResourcesService from .resources.workspaces import Workspaces @@ -72,6 +73,7 @@ def __init__(self, config: TFEConfig | None = None): self.variable_sets = VariableSets(self._transport) self.variable_set_variables = VariableSetVariables(self._transport) self.workspaces = Workspaces(self._transport) + self.workspace_resources = WorkspaceResourcesService(self._transport) self.registry_modules = RegistryModules(self._transport) self.registry_providers = RegistryProviders(self._transport) diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index a3cc71f..c70dd05 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -353,6 +353,12 @@ WorkspaceUpdateRemoteStateConsumersOptions, ) +# ── Workspace Resources ─────────────────────────────────────────────────────── +from .workspace_resource import ( + WorkspaceResource, + WorkspaceResourceListOptions, +) + # ── Public surface ──────────────────────────────────────────────────────────── __all__ = [ # OAuth @@ -524,6 +530,9 @@ "WorkspaceTagListOptions", "WorkspaceUpdateOptions", "WorkspaceUpdateRemoteStateConsumersOptions", + # Workspace Resources + "WorkspaceResource", + "WorkspaceResourceListOptions", "RunQueue", "ReadRunQueueOptions", # Runs diff --git a/src/pytfe/models/workspace_resource.py b/src/pytfe/models/workspace_resource.py new file mode 100644 index 0000000..78eaa40 --- /dev/null +++ b/src/pytfe/models/workspace_resource.py @@ -0,0 +1,29 @@ +"""Workspace resources models for Terraform Enterprise.""" + +from pydantic import BaseModel + + +class WorkspaceResource(BaseModel): + """Represents a Terraform Enterprise workspace resource. + + These are resources managed by Terraform in a workspace's state. + """ + + id: str + address: str + name: str + created_at: str + updated_at: str + module: str + provider: str + provider_type: str + modified_by_state_version_id: str + name_index: str | None = None + + +class WorkspaceResourceListOptions(BaseModel): + """Options for listing workspace resources.""" + + # Pagination + page_number: int | None = None + page_size: int | None = None diff --git a/src/pytfe/resources/workspace_resources.py b/src/pytfe/resources/workspace_resources.py new file mode 100644 index 0000000..ff3e0ee --- /dev/null +++ b/src/pytfe/resources/workspace_resources.py @@ -0,0 +1,69 @@ +"""Workspace resources service for Terraform Enterprise.""" + +import urllib.parse +from collections.abc import Iterator +from typing import Any + +from pytfe.models import ( + WorkspaceResource, + WorkspaceResourceListOptions, +) + +from ._base import _Service + + +def _workspace_resource_from(data: dict[str, Any]) -> WorkspaceResource: + """Convert API response data to WorkspaceResource model.""" + attributes = data.get("attributes", {}) + + return WorkspaceResource( + id=data.get("id", ""), + address=attributes.get("address", ""), + name=attributes.get("name", ""), + created_at=attributes.get("created-at", ""), + updated_at=attributes.get("updated-at", ""), + module=attributes.get("module", ""), + provider=attributes.get("provider", ""), + provider_type=attributes.get("provider-type", ""), + modified_by_state_version_id=attributes.get("modified-by-state-version-id", ""), + name_index=attributes.get("name-index"), + ) + + +class WorkspaceResourcesService(_Service): + """Service for managing workspace resources in Terraform Enterprise. + + Workspace resources represent the infrastructure resources + managed by Terraform in a workspace's state file. + """ + + def list( + self, workspace_id: str, options: WorkspaceResourceListOptions | None = None + ) -> Iterator[WorkspaceResource]: + """List workspace resources for a given workspace. + + Args: + workspace_id: The ID of the workspace to list resources for + options: Optional query parameters for filtering and pagination + + Yields: + WorkspaceResource objects + """ + if not workspace_id or not workspace_id.strip(): + raise ValueError("workspace_id is required") + + # URL encode the workspace ID and construct URL + encoded_workspace_id = urllib.parse.quote(workspace_id, safe="") + url = f"/api/v2/workspaces/{encoded_workspace_id}/resources" + + # Handle parameters + params: dict[str, int] = {} + 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 + + # Use the _list method from base service to handle pagination + for item in self._list(url, params=params): + yield _workspace_resource_from(item) diff --git a/tests/units/test_workspace_resources.py b/tests/units/test_workspace_resources.py new file mode 100644 index 0000000..05f7c15 --- /dev/null +++ b/tests/units/test_workspace_resources.py @@ -0,0 +1,297 @@ +"""Unit tests for workspace resources service.""" + +from unittest.mock import Mock + +import pytest + +from pytfe.models.workspace_resource import ( + WorkspaceResource, + WorkspaceResourceListOptions, +) +from pytfe.resources.workspace_resources import WorkspaceResourcesService + + +class TestWorkspaceResourcesService: + """Test suite for WorkspaceResourcesService.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock transport for testing.""" + return Mock() + + @pytest.fixture + def service(self, mock_transport): + """Create a WorkspaceResourcesService instance for testing.""" + return WorkspaceResourcesService(mock_transport) + + @pytest.fixture + def sample_workspace_resource_response(self): + """Sample API response for workspace resources list.""" + return { + "data": [ + { + "id": "resource-1", + "type": "resources", + "attributes": { + "address": "media_bucket.aws_s3_bucket_public_access_block.this[0]", + "name": "this", + "created-at": "2023-01-01T00:00:00Z", + "updated-at": "2023-01-01T00:00:00Z", + "module": "media_bucket", + "provider": "hashicorp/aws", + "provider-type": "aws", + "modified-by-state-version-id": "sv-abc123", + "name-index": "0", + }, + }, + { + "id": "resource-2", + "type": "resources", + "attributes": { + "address": "aws_instance.example", + "name": "example", + "created-at": "2023-01-02T00:00:00Z", + "updated-at": "2023-01-02T00:00:00Z", + "module": "root", + "provider": "hashicorp/aws", + "provider-type": "aws", + "modified-by-state-version-id": "sv-def456", + "name-index": None, + }, + }, + ], + "meta": { + "pagination": { + "current_page": 1, + "total_pages": 1, + "total_count": 2, + "page_size": 20, + } + }, + } + + @pytest.fixture + def sample_empty_response(self): + """Sample API response for empty workspace resources list.""" + return { + "data": [], + "meta": { + "pagination": { + "current_page": 1, + "total_pages": 1, + "total_count": 0, + "page_size": 20, + } + }, + } + + def test_list_workspace_resources_success( + self, service, mock_transport, sample_workspace_resource_response + ): + """Test successful listing of workspace resources.""" + # Mock the transport response + mock_response = Mock() + mock_response.json.return_value = sample_workspace_resource_response + mock_transport.request.return_value = mock_response + + # Call the service + result = list(service.list("ws-abc123")) + + # Verify request was made correctly + mock_transport.request.assert_called_once_with( + "GET", + "/api/v2/workspaces/ws-abc123/resources", + params={"page[number]": 1, "page[size]": 100}, + ) + + # Verify response parsing + assert isinstance(result, list) + assert len(result) == 2 + + # Check first resource + resource1 = result[0] + assert isinstance(resource1, WorkspaceResource) + assert resource1.id == "resource-1" + assert ( + resource1.address + == "media_bucket.aws_s3_bucket_public_access_block.this[0]" + ) + assert resource1.name == "this" + assert resource1.module == "media_bucket" + assert resource1.provider == "hashicorp/aws" + assert resource1.provider_type == "aws" + assert resource1.modified_by_state_version_id == "sv-abc123" + assert resource1.name_index == "0" + assert resource1.created_at == "2023-01-01T00:00:00Z" + assert resource1.updated_at == "2023-01-01T00:00:00Z" + + # Check second resource + resource2 = result[1] + assert resource2.id == "resource-2" + assert resource2.address == "aws_instance.example" + assert resource2.name == "example" + assert resource2.module == "root" + assert resource2.name_index is None + + def test_list_workspace_resources_with_options( + self, service, mock_transport, sample_workspace_resource_response + ): + """Test listing workspace resources with pagination options.""" + # Mock the transport response + mock_response = Mock() + mock_response.json.return_value = sample_workspace_resource_response + mock_transport.request.return_value = mock_response + + # Create options + options = WorkspaceResourceListOptions(page_number=2, page_size=50) + + # Call the service + result = list(service.list("ws-abc123", options)) + + # Verify request was made correctly + mock_transport.request.assert_called_once_with( + "GET", + "/api/v2/workspaces/ws-abc123/resources", + params={"page[number]": 2, "page[size]": 50}, + ) + + # Verify response + assert isinstance(result, list) + assert len(result) == 2 + + def test_list_workspace_resources_empty( + self, service, mock_transport, sample_empty_response + ): + """Test listing workspace resources when no resources exist.""" + # Mock the transport response + mock_response = Mock() + mock_response.json.return_value = sample_empty_response + mock_transport.request.return_value = mock_response + + # Call the service + result = list(service.list("ws-abc123")) + + # Verify request was made correctly + mock_transport.request.assert_called_once_with( + "GET", + "/api/v2/workspaces/ws-abc123/resources", + params={"page[number]": 1, "page[size]": 100}, + ) + + # Verify response + assert isinstance(result, list) + assert len(result) == 0 + + def test_list_workspace_resources_invalid_workspace_id(self, service): + """Test listing workspace resources with invalid workspace ID.""" + with pytest.raises(ValueError, match="workspace_id is required"): + list(service.list("")) + + with pytest.raises(ValueError, match="workspace_id is required"): + list(service.list(None)) + + def test_list_workspace_resources_url_encoding( + self, service, mock_transport, sample_workspace_resource_response + ): + """Test that workspace ID is properly URL encoded.""" + # Mock the transport response + mock_response = Mock() + mock_response.json.return_value = sample_workspace_resource_response + mock_transport.request.return_value = mock_response + + # Call with workspace ID that needs encoding + list(service.list("ws-abc/123")) + + # Verify the URL was properly encoded + mock_transport.request.assert_called_once_with( + "GET", + "/api/v2/workspaces/ws-abc%2F123/resources", + params={"page[number]": 1, "page[size]": 100}, + ) + + def test_list_workspace_resources_malformed_response(self, service, mock_transport): + """Test handling of malformed API response.""" + # Mock malformed response + mock_response = Mock() + mock_response.json.return_value = {"invalid": "response"} + mock_transport.request.return_value = mock_response + + # Call the service + result = list(service.list("ws-abc123")) + + # Should handle gracefully and return empty list + assert isinstance(result, list) + assert len(result) == 0 + + def test_list_workspace_resources_api_error(self, service, mock_transport): + """Test handling of API errors.""" + # Mock API error + mock_transport.request.side_effect = Exception("API Error") + + # Should propagate the exception + with pytest.raises(Exception, match="API Error"): + list(service.list("ws-abc123")) + + +class TestWorkspaceResourceModel: + """Test suite for WorkspaceResource model.""" + + def test_workspace_resource_creation(self): + """Test creating a WorkspaceResource instance.""" + resource = WorkspaceResource( + id="resource-1", + address="aws_instance.example", + name="example", + created_at="2023-01-01T00:00:00Z", + updated_at="2023-01-01T00:00:00Z", + module="root", + provider="hashicorp/aws", + provider_type="aws", + modified_by_state_version_id="sv-abc123", + name_index="0", + ) + + assert resource.id == "resource-1" + assert resource.address == "aws_instance.example" + assert resource.name == "example" + assert resource.module == "root" + assert resource.provider == "hashicorp/aws" + assert resource.provider_type == "aws" + assert resource.modified_by_state_version_id == "sv-abc123" + assert resource.name_index == "0" + + def test_workspace_resource_optional_fields(self): + """Test WorkspaceResource with optional fields.""" + resource = WorkspaceResource( + id="resource-1", + address="aws_instance.example", + name="example", + created_at="2023-01-01T00:00:00Z", + updated_at="2023-01-01T00:00:00Z", + module="root", + provider="hashicorp/aws", + provider_type="aws", + modified_by_state_version_id="sv-abc123", + # name_index is optional + ) + + assert resource.name_index is None + + +class TestWorkspaceResourceListOptions: + """Test suite for WorkspaceResourceListOptions model.""" + + def test_workspace_resource_list_options_creation(self): + """Test creating WorkspaceResourceListOptions.""" + options = WorkspaceResourceListOptions(page_number=2, page_size=50) + + assert options.page_number == 2 + assert options.page_size == 50 + + def test_workspace_resource_list_options_defaults(self): + """Test WorkspaceResourceListOptions with defaults.""" + options = WorkspaceResourceListOptions() + + # Should use default values from BaseListOptions + assert options.page_number is None + assert options.page_size is None From 631e4fc738361ae9fca477a33c82a44a74091786 Mon Sep 17 00:00:00 2001 From: KshitijaChoudhari Date: Wed, 3 Dec 2025 16:06:48 +0530 Subject: [PATCH 11/14] style: format workspace_resources.py with ruff --- examples/workspace_resources.py | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/examples/workspace_resources.py b/examples/workspace_resources.py index 730e24d..15fbf1b 100644 --- a/examples/workspace_resources.py +++ b/examples/workspace_resources.py @@ -79,26 +79,14 @@ def main(): ) # Add command flags - parser.add_argument( - "--list", - action="store_true", - help="List workspace resources" - ) + parser.add_argument("--list", action="store_true", help="List workspace resources") parser.add_argument( "--workspace-id", required=True, - help="ID of the workspace (required, e.g., ws-abc123)" - ) - parser.add_argument( - "--page-number", - type=int, - help="Page number for pagination" - ) - parser.add_argument( - "--page-size", - type=int, - help="Page size for pagination" + help="ID of the workspace (required, e.g., ws-abc123)", ) + parser.add_argument("--page-number", type=int, help="Page number for pagination") + parser.add_argument("--page-size", type=int, help="Page size for pagination") args = parser.parse_args() From 1db66e47909a4370d9326cda814eb3bab8bd09fb Mon Sep 17 00:00:00 2001 From: KshitijaChoudhari Date: Wed, 3 Dec 2025 16:34:12 +0530 Subject: [PATCH 12/14] refactor: remove unnecessary URL encoding from workspace_resources - Remove urllib.parse import - Remove URL encoding for workspace_id parameter - Workspace IDs are alphanumeric with hyphens, no encoding needed - Consistent with other services in the codebase --- src/pytfe/resources/workspace_resources.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/pytfe/resources/workspace_resources.py b/src/pytfe/resources/workspace_resources.py index ff3e0ee..617b5f7 100644 --- a/src/pytfe/resources/workspace_resources.py +++ b/src/pytfe/resources/workspace_resources.py @@ -1,6 +1,5 @@ """Workspace resources service for Terraform Enterprise.""" -import urllib.parse from collections.abc import Iterator from typing import Any @@ -52,9 +51,7 @@ def list( if not workspace_id or not workspace_id.strip(): raise ValueError("workspace_id is required") - # URL encode the workspace ID and construct URL - encoded_workspace_id = urllib.parse.quote(workspace_id, safe="") - url = f"/api/v2/workspaces/{encoded_workspace_id}/resources" + url = f"/api/v2/workspaces/{workspace_id}/resources" # Handle parameters params: dict[str, int] = {} From 9582dfb876052e7ecf5cd5139ef4eb80a797d7ca Mon Sep 17 00:00:00 2001 From: KshitijaChoudhari Date: Wed, 3 Dec 2025 16:36:58 +0530 Subject: [PATCH 13/14] test: remove URL encoding test from workspace_resources - Remove test_list_workspace_resources_url_encoding test - URL encoding is not needed for workspace IDs - All 376 tests now passing --- tests/units/test_workspace_resources.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/tests/units/test_workspace_resources.py b/tests/units/test_workspace_resources.py index 05f7c15..f685d84 100644 --- a/tests/units/test_workspace_resources.py +++ b/tests/units/test_workspace_resources.py @@ -190,25 +190,6 @@ def test_list_workspace_resources_invalid_workspace_id(self, service): with pytest.raises(ValueError, match="workspace_id is required"): list(service.list(None)) - def test_list_workspace_resources_url_encoding( - self, service, mock_transport, sample_workspace_resource_response - ): - """Test that workspace ID is properly URL encoded.""" - # Mock the transport response - mock_response = Mock() - mock_response.json.return_value = sample_workspace_resource_response - mock_transport.request.return_value = mock_response - - # Call with workspace ID that needs encoding - list(service.list("ws-abc/123")) - - # Verify the URL was properly encoded - mock_transport.request.assert_called_once_with( - "GET", - "/api/v2/workspaces/ws-abc%2F123/resources", - params={"page[number]": 1, "page[size]": 100}, - ) - def test_list_workspace_resources_malformed_response(self, service, mock_transport): """Test handling of malformed API response.""" # Mock malformed response From ffcd5cbc11b33bbea33ea7c79eedc0fdbd40c9dc Mon Sep 17 00:00:00 2001 From: KshitijaChoudhari Date: Tue, 9 Dec 2025 11:16:35 +0530 Subject: [PATCH 14/14] Add workspace run task feature - Add WorkspaceRunTask models and resources - Add WorkspaceRunTasks service with CRUD operations - Update RunTask model to include workspace_run_tasks relationship - Fix Stage enum to use underscore format (pre_plan, post_plan, etc.) - Add comprehensive unit tests (20 tests) - Add example CLI for workspace run task operations - Update client to include workspace_run_tasks service --- examples/workspace_run_task.py | 313 +++++++++++++++ src/pytfe/client.py | 2 + src/pytfe/errors.py | 7 + src/pytfe/models/__init__.py | 15 + src/pytfe/models/run_task.py | 51 ++- src/pytfe/models/workspace_run_task.py | 72 +++- src/pytfe/resources/run_task.py | 39 +- src/pytfe/resources/workspace_run_task.py | 334 +++++++++++++++ tests/units/test_run_task.py | 4 +- tests/units/test_workspace_run_task.py | 469 ++++++++++++++++++++++ 10 files changed, 1289 insertions(+), 17 deletions(-) create mode 100644 examples/workspace_run_task.py create mode 100644 src/pytfe/resources/workspace_run_task.py create mode 100644 tests/units/test_workspace_run_task.py diff --git a/examples/workspace_run_task.py b/examples/workspace_run_task.py new file mode 100644 index 0000000..cf47294 --- /dev/null +++ b/examples/workspace_run_task.py @@ -0,0 +1,313 @@ +""" +Terraform Cloud/Enterprise Workspace Run Task Management Example + +This example demonstrates comprehensive workspace run task operations using the python-tfe SDK. +It provides a command-line interface for managing workspace run tasks with various operations +including attach/create, read, update, delete, and listing attached tasks. + +Prerequisites: + - Set TFE_TOKEN environment variable with your Terraform Cloud API token + - Ensure you have access to the target organization and workspaces + - Run tasks must exist in the organization before attaching to workspaces + +Basic Usage: + python examples/workspace_run_task.py --help + +Core Operations: + +1. List Workspace Run Tasks (default operation): + python examples/workspace_run_task.py --workspace-id ws-abc123 + python examples/workspace_run_task.py --workspace-id ws-abc123 --page-size 20 + +2. Attach Run Task to Workspace (Create): + python examples/workspace_run_task.py --workspace-id ws-abc123 --run-task-id task-def456 --create --enforcement-level mandatory --stages pre-plan post-plan + +3. Read Workspace Run Task Details: + python examples/workspace_run_task.py --workspace-id ws-abc123 --workspace-task-id wstask-xyz789 + +4. Update Workspace Run Task: + python examples/workspace_run_task.py --workspace-id ws-abc123 --workspace-task-id wstask-xyz789 --update --enforcement-level advisory --stages pre-plan + +5. Delete Workspace Run Task: + python examples/workspace_run_task.py --workspace-id ws-abc123 --workspace-task-id wstask-xyz789 --delete +""" + +from __future__ import annotations + +import argparse +import os + +from pytfe import TFEClient, TFEConfig +from pytfe.models import ( + RunTask, + Stage, + TaskEnforcementLevel, + WorkspaceRunTaskCreateOptions, + WorkspaceRunTaskListOptions, + WorkspaceRunTaskUpdateOptions, +) + +# Ensure models are fully rebuilt to resolve forward references +WorkspaceRunTaskUpdateOptions.model_rebuild() +WorkspaceRunTaskCreateOptions.model_rebuild() + + +def _print_header(title: str) -> None: + """Print a formatted header for operations.""" + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def main(): + parser = argparse.ArgumentParser( + description="Workspace Run Task demo for python-tfe SDK" + ) + parser.add_argument( + "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") + ) + parser.add_argument("--token", default=os.getenv("TFE_TOKEN", "")) + parser.add_argument("--workspace-id", required=True, help="Workspace ID") + parser.add_argument( + "--run-task-id", help="Run Task ID to attach (for create operation)" + ) + parser.add_argument( + "--workspace-task-id", help="Workspace Run Task ID for read/update/delete" + ) + parser.add_argument( + "--create", action="store_true", help="Create/attach a workspace run task" + ) + parser.add_argument( + "--update", action="store_true", help="Update a workspace run task" + ) + parser.add_argument( + "--delete", action="store_true", help="Delete a workspace run task" + ) + parser.add_argument( + "--enforcement-level", + choices=["advisory", "mandatory"], + help="Enforcement level for create/update", + ) + parser.add_argument( + "--stages", + nargs="+", + choices=["pre-plan", "post-plan", "pre-apply", "post-apply"], + help="Stages to run the task in (for create/update)", + ) + parser.add_argument( + "--stage", + choices=["pre-plan", "post-plan", "pre-apply", "post-apply"], + help="Deprecated: Single stage to run the task in (use --stages instead)", + ) + 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" + ) + args = parser.parse_args() + + cfg = TFEConfig(address=args.address, token=args.token) + client = TFEClient(cfg) + + # Create a new workspace run task (attach run task to workspace) + if args.create: + if not args.run_task_id: + print("Error: --run-task-id is required for creating a workspace run task") + return + + if not args.enforcement_level: + print("Error: --enforcement-level is required for creating") + return + + _print_header("Creating Workspace Run Task") + + # Convert enforcement level string to enum + enforcement_level = ( + TaskEnforcementLevel.MANDATORY + if args.enforcement_level == "mandatory" + else TaskEnforcementLevel.ADVISORY + ) + + # Convert stages to enum + stages = None + if args.stages: + stages = [] + for stage_str in args.stages: + if stage_str == "pre-plan": + stages.append(Stage.PRE_PLAN) + elif stage_str == "post-plan": + stages.append(Stage.POST_PLAN) + elif stage_str == "pre-apply": + stages.append(Stage.PRE_APPLY) + elif stage_str == "post-apply": + stages.append(Stage.POST_APPLY) + + # Deprecated stage support + stage = None + if args.stage: + if args.stage == "pre-plan": + stage = Stage.PRE_PLAN + elif args.stage == "post-plan": + stage = Stage.POST_PLAN + elif args.stage == "pre-apply": + stage = Stage.PRE_APPLY + elif args.stage == "post-apply": + stage = Stage.POST_APPLY + + # Create RunTask object with just ID (minimal required) + run_task = RunTask( + id=args.run_task_id, + name="", # Name not needed for attachment + url="", # URL not needed for attachment + category="task", + enabled=True, + ) + + options = WorkspaceRunTaskCreateOptions( + enforcement_level=enforcement_level, + run_task=run_task, + stages=stages, + stage=stage, + ) + + try: + workspace_task = client.workspace_run_tasks.create( + args.workspace_id, options + ) + print("āœ“ Successfully attached run task to workspace") + print(f" Workspace Task ID: {workspace_task.id}") + print(f" Enforcement Level: {workspace_task.enforcement_level.value}") + print(f" Stage: {workspace_task.stage.value}") + if workspace_task.stages: + print(f" Stages: {[s.value for s in workspace_task.stages]}") + except Exception as e: + print(f"āœ— Failed to create workspace run task: {e}") + + # Update an existing workspace run task + elif args.update: + if not args.workspace_task_id: + print("Error: --workspace-task-id is required for updating") + return + + _print_header("Updating Workspace Run Task") + + # Build update options + enforcement_level = None + if args.enforcement_level: + enforcement_level = ( + TaskEnforcementLevel.MANDATORY + if args.enforcement_level == "mandatory" + else TaskEnforcementLevel.ADVISORY + ) + + # Update stages if provided + stages = None + if args.stages: + stages = [] + for stage_str in args.stages: + if stage_str == "pre-plan": + stages.append(Stage.PRE_PLAN) + elif stage_str == "post-plan": + stages.append(Stage.POST_PLAN) + elif stage_str == "pre-apply": + stages.append(Stage.PRE_APPLY) + elif stage_str == "post-apply": + stages.append(Stage.POST_APPLY) + + options = WorkspaceRunTaskUpdateOptions( + enforcement_level=enforcement_level, stages=stages + ) + + # Update stage if provided (deprecated) + if args.stage: + if args.stage == "pre-plan": + options.stage = Stage.PRE_PLAN + elif args.stage == "post-plan": + options.stage = Stage.POST_PLAN + elif args.stage == "pre-apply": + options.stage = Stage.PRE_APPLY + elif args.stage == "post-apply": + options.stage = Stage.POST_APPLY + + try: + workspace_task = client.workspace_run_tasks.update( + args.workspace_id, args.workspace_task_id, options + ) + print("āœ“ Successfully updated workspace run task") + print(f" Workspace Task ID: {workspace_task.id}") + print(f" Enforcement Level: {workspace_task.enforcement_level.value}") + print(f" Stage: {workspace_task.stage.value}") + if workspace_task.stages: + print(f" Stages: {[s.value for s in workspace_task.stages]}") + except Exception as e: + print(f"āœ— Failed to update workspace run task: {e}") + + # Delete a workspace run task + elif args.delete: + if not args.workspace_task_id: + print("Error: --workspace-task-id is required for deleting") + return + + _print_header("Deleting Workspace Run Task") + + try: + client.workspace_run_tasks.delete(args.workspace_id, args.workspace_task_id) + print( + f"āœ“ Successfully deleted workspace run task: {args.workspace_task_id}" + ) + except Exception as e: + print(f"āœ— Failed to delete workspace run task: {e}") + + # Read a specific workspace run task + elif args.workspace_task_id: + _print_header("Reading Workspace Run Task") + + try: + workspace_task = client.workspace_run_tasks.read( + args.workspace_id, args.workspace_task_id + ) + print("āœ“ Workspace Run Task Details:") + print(f" ID: {workspace_task.id}") + print(f" Enforcement Level: {workspace_task.enforcement_level.value}") + print(f" Stage (deprecated): {workspace_task.stage.value}") + if workspace_task.stages: + print(f" Stages: {[s.value for s in workspace_task.stages]}") + if workspace_task.run_task: + print(f" Run Task ID: {workspace_task.run_task.id}") + if workspace_task.workspace: + print(f" Workspace ID: {workspace_task.workspace.id}") + except Exception as e: + print(f"āœ— Failed to read workspace run task: {e}") + + # List all workspace run tasks (default operation) + else: + _print_header(f"Listing Workspace Run Tasks for Workspace: {args.workspace_id}") + + options = WorkspaceRunTaskListOptions( + page_number=args.page, + page_size=args.page_size, + ) + + try: + count = 0 + for workspace_task in client.workspace_run_tasks.list( + args.workspace_id, options + ): + count += 1 + print(f"\n{count}. Workspace Run Task ID: {workspace_task.id}") + print(f" Enforcement Level: {workspace_task.enforcement_level.value}") + print(f" Stage: {workspace_task.stage.value}") + if workspace_task.stages: + print(f" Stages: {[s.value for s in workspace_task.stages]}") + if workspace_task.run_task: + print(f" Run Task ID: {workspace_task.run_task.id}") + + if count == 0: + print("No workspace run tasks found for this workspace.") + else: + print(f"\nāœ“ Total workspace run tasks listed: {count}") + except Exception as e: + print(f"āœ— Failed to list workspace run tasks: {e}") + + +if __name__ == "__main__": + main() diff --git a/src/pytfe/client.py b/src/pytfe/client.py index f50cdfe..8092106 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -34,6 +34,7 @@ from .resources.variable import Variables from .resources.variable_sets import VariableSets, VariableSetVariables from .resources.workspace_resources import WorkspaceResourcesService +from .resources.workspace_run_task import WorkspaceRunTasks from .resources.workspaces import Workspaces @@ -81,6 +82,7 @@ def __init__(self, config: TFEConfig | None = None): self.state_versions = StateVersions(self._transport) self.state_version_outputs = StateVersionOutputs(self._transport) self.run_tasks = RunTasks(self._transport) + self.workspace_run_tasks = WorkspaceRunTasks(self._transport) self.run_triggers = RunTriggers(self._transport) self.runs = Runs(self._transport) self.query_runs = QueryRuns(self._transport) diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index 61853d1..08631bd 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -312,6 +312,13 @@ def __init__(self, message: str = 'category must be "task"'): super().__init__(message) +class InvalidWorkspaceRunTaskIDError(InvalidValues): + """Raised when an invalid workspace run task ID is provided.""" + + def __init__(self, message: str = "invalid value for workspace run task ID"): + super().__init__(message) + + # Run Trigger errors class RequiredRunTriggerListOpsError(RequiredFieldMissing): """Raised when required run trigger list options are missing.""" diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index c70dd05..657d9c1 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -359,6 +359,15 @@ WorkspaceResourceListOptions, ) +# ── Workspace Run Tasks ────────────────────────────────────────────────────── +from .workspace_run_task import ( + WorkspaceRunTask, + WorkspaceRunTaskCreateOptions, + WorkspaceRunTaskList, + WorkspaceRunTaskListOptions, + WorkspaceRunTaskUpdateOptions, +) + # ── Public surface ──────────────────────────────────────────────────────────── __all__ = [ # OAuth @@ -582,6 +591,12 @@ "SourceableChoice", "RunTriggerFilterOp", "RunTriggerIncludeOp", + # Workspace Run Tasks + "WorkspaceRunTask", + "WorkspaceRunTaskCreateOptions", + "WorkspaceRunTaskList", + "WorkspaceRunTaskListOptions", + "WorkspaceRunTaskUpdateOptions", # Policy Checks "PolicyCheck", "PolicyCheckIncludeOpt", diff --git a/src/pytfe/models/run_task.py b/src/pytfe/models/run_task.py index 8741162..2d0e9a3 100644 --- a/src/pytfe/models/run_task.py +++ b/src/pytfe/models/run_task.py @@ -1,13 +1,18 @@ from __future__ import annotations from enum import Enum +from typing import TYPE_CHECKING from pydantic import BaseModel, Field from ..models.common import Pagination from .agent import AgentPool from .organization import Organization -from .workspace_run_task import WorkspaceRunTask + +# Use TYPE_CHECKING to avoid circular import issues between RunTask and WorkspaceRunTask +# This allows forward references without importing at runtime +if TYPE_CHECKING: + from .workspace_run_task import WorkspaceRunTask class RunTask(BaseModel): @@ -22,6 +27,8 @@ class RunTask(BaseModel): agent_pool: AgentPool | None = None organization: Organization | None = None + # Workspace run tasks that use this run task + # Added to support the workspace_run_tasks relationship in the API workspace_run_tasks: list[WorkspaceRunTask] = Field(default_factory=list) @@ -38,10 +45,19 @@ class GlobalRunTaskOptions(BaseModel): class Stage(str, Enum): - PRE_PLAN = "pre-plan" - POST_PLAN = "post-plan" - PRE_APPLY = "pre-apply" - POST_APPLY = "post-apply" + """Run task stage enumeration. + + Defines when a run task should execute in the run lifecycle. + + Note: Values use underscore format (e.g., 'pre_plan') to match the + Terraform Cloud API specification. This was changed from hyphen format + (e.g., 'pre-plan') to align with the actual API responses and requests. + """ + + PRE_PLAN = "pre_plan" + POST_PLAN = "post_plan" + PRE_APPLY = "pre_apply" + POST_APPLY = "post_apply" class TaskEnforcementLevel(str, Enum): @@ -91,3 +107,28 @@ class RunTaskUpdateOptions(BaseModel): enabled: bool | None = None global_configuration: GlobalRunTaskOptions | None = None agent_pool: AgentPool | None = None + + +def _rebuild_models() -> None: + """Rebuild models to resolve forward references. + + This function resolves the circular dependency between RunTask and WorkspaceRunTask. + It imports WorkspaceRunTask and rebuilds the Pydantic models so that forward + references (e.g., list["WorkspaceRunTask"]) are properly resolved. + + The try-except ensures that if WorkspaceRunTask hasn't been defined yet, + the models will rebuild later when first used. + """ + try: + from .workspace_run_task import WorkspaceRunTask # noqa: F401 + + RunTask.model_rebuild() + GlobalRunTask.model_rebuild() + GlobalRunTaskOptions.model_rebuild() + RunTaskUpdateOptions.model_rebuild() + except Exception: + # Models will rebuild later when first used + pass + + +_rebuild_models() diff --git a/src/pytfe/models/workspace_run_task.py b/src/pytfe/models/workspace_run_task.py index b5072a6..0af6aac 100644 --- a/src/pytfe/models/workspace_run_task.py +++ b/src/pytfe/models/workspace_run_task.py @@ -1,7 +1,77 @@ from __future__ import annotations -from pydantic import BaseModel +from typing import TYPE_CHECKING + +from pydantic import BaseModel, Field + +from ..models.common import Pagination + +if TYPE_CHECKING: + from .run_task import RunTask, Stage, TaskEnforcementLevel + from .workspace import Workspace class WorkspaceRunTask(BaseModel): + """Represents a run task attached to a workspace.""" + id: str + enforcement_level: TaskEnforcementLevel | None = None + # Deprecated: Use stages property instead + stage: Stage | None = None + stages: list[Stage] = Field(default_factory=list) + + # Relationships + run_task: RunTask | None = None + workspace: Workspace | None = None + + +class WorkspaceRunTaskList(BaseModel): + """Represents a list of workspace run tasks.""" + + items: list[WorkspaceRunTask] = Field(default_factory=list) + pagination: Pagination | None = None + + +class WorkspaceRunTaskListOptions(BaseModel): + """Options for listing workspace run tasks.""" + + page_number: int | None = None + page_size: int | None = None + + +class WorkspaceRunTaskCreateOptions(BaseModel): + """Options for creating a workspace run task.""" + + type: str = Field(default="workspace-tasks") + enforcement_level: TaskEnforcementLevel + run_task: RunTask # Required + # Deprecated: Use stages property instead + stage: Stage | None = None + stages: list[Stage] | None = None + + +class WorkspaceRunTaskUpdateOptions(BaseModel): + """Options for updating a workspace run task.""" + + type: str = Field(default="workspace-tasks") + enforcement_level: TaskEnforcementLevel | None = None + # Deprecated: Use stages property instead + stage: Stage | None = None + stages: list[Stage] | None = None + + +def _rebuild_models() -> None: + """Rebuild models to resolve forward references.""" + try: + from .run_task import RunTask, Stage, TaskEnforcementLevel # noqa: F401 + from .workspace import Workspace # noqa: F401 + + WorkspaceRunTask.model_rebuild() + WorkspaceRunTaskCreateOptions.model_rebuild() + WorkspaceRunTaskUpdateOptions.model_rebuild() + except Exception: + # Models will rebuild later when first used + pass + + +_rebuild_models() diff --git a/src/pytfe/resources/run_task.py b/src/pytfe/resources/run_task.py index 853eab6..dcda0dc 100644 --- a/src/pytfe/resources/run_task.py +++ b/src/pytfe/resources/run_task.py @@ -94,6 +94,9 @@ def _run_task_from(d: dict[str, Any], org: str | None = None) -> RunTask: ) # Handle workspace run tasks relationship + # Added to support the workspace-tasks relationship returned by the API. + # This populates the workspace_run_tasks field on RunTask objects when + # the API includes this relationship (e.g., when using include query params). workspace_run_tasks = [] wrt_data = relationships.get("workspace-tasks", {}).get("data", []) if isinstance(wrt_data, list): @@ -270,17 +273,35 @@ def attach_to_workspace( Attach a run task to a workspace. This is a convenience method that creates a workspace run task relationship. + Delegates to workspace_run_tasks.create(). + + Args: + workspace_id: The workspace ID + run_task_id: The run task ID to attach + enforcement_level: The enforcement level for this task + + Returns: + WorkspaceRunTask: The created workspace run task + + Raises: + InvalidWorkspaceIDError: If workspace_id is invalid + InvalidRunTaskIDError: If run_task_id is invalid """ - # This would typically delegate to workspace_run_tasks.create() - # For now, we'll create a placeholder implementation - # In a real implementation, this would call: - """ + from ..models.workspace_run_task import WorkspaceRunTaskCreateOptions + from .workspace_run_task import WorkspaceRunTasks + + # Create workspace run tasks service + workspace_run_tasks = WorkspaceRunTasks(self.t) + + # Create the run task object with minimal required fields + run_task = RunTask( + id=run_task_id, name="", url="", category="task", enabled=True + ) + + # Create options for attaching the task create_options = WorkspaceRunTaskCreateOptions( enforcement_level=enforcement_level, - run_task=RunTask(id=run_task_id, name="", url="", category="task", enabled=True) + run_task=run_task, ) - return workspace_run_tasks.create(workspace_id, create_options) - """ - # TODO: Implement actual workspace run task creation - raise NotImplementedError("attach_to_workspace method needs to be implemented") + return workspace_run_tasks.create(workspace_id, create_options) diff --git a/src/pytfe/resources/workspace_run_task.py b/src/pytfe/resources/workspace_run_task.py new file mode 100644 index 0000000..adfc221 --- /dev/null +++ b/src/pytfe/resources/workspace_run_task.py @@ -0,0 +1,334 @@ +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +from ..errors import ( + InvalidRunTaskIDError, + InvalidWorkspaceIDError, + InvalidWorkspaceRunTaskIDError, +) +from ..models.run_task import RunTask, Stage, TaskEnforcementLevel +from ..models.workspace_run_task import ( + WorkspaceRunTask, + WorkspaceRunTaskCreateOptions, + WorkspaceRunTaskListOptions, + WorkspaceRunTaskUpdateOptions, +) +from ..utils import _safe_str, valid_string_id +from ._base import _Service + + +def _workspace_run_task_from(d: dict[str, Any]) -> WorkspaceRunTask: + """ + Convert JSON API response data to WorkspaceRunTask object. + + Maps the JSON API format to Python model fields, handling: + - Basic attributes (id, enforcement_level, stage, stages) + - Relationships (run_task, workspace) + """ + attr: dict[str, Any] = d.get("attributes", {}) or {} + relationships: dict[str, Any] = d.get("relationships", {}) or {} + + id_str: str = _safe_str(d.get("id")) + + # Parse enforcement level + enforcement_level = TaskEnforcementLevel.ADVISORY # Default + if "enforcement-level" in attr: + try: + enforcement_level = TaskEnforcementLevel(attr["enforcement-level"]) + except ValueError: + enforcement_level = TaskEnforcementLevel.ADVISORY + + # Parse stage (deprecated) + stage = Stage.PRE_PLAN # Default + if "stage" in attr: + try: + stage = Stage(attr["stage"]) + except ValueError: + stage = Stage.PRE_PLAN + + # Parse stages list + stages = [] + if "stages" in attr and isinstance(attr["stages"], list): + for stage_str in attr["stages"]: + if isinstance(stage_str, str): + try: + stages.append(Stage(stage_str)) + except ValueError: + pass # Skip invalid stages + + # Handle run_task relationship + run_task = None + run_task_data = relationships.get("task", {}).get("data") + if run_task_data and isinstance(run_task_data, dict): + run_task = RunTask( + id=_safe_str(run_task_data.get("id")), + name="", # Name not available in relationship data + url="", # URL not available in relationship data + category="task", + enabled=True, + ) + + # Handle workspace relationship + workspace = None + workspace_data = relationships.get("workspace", {}).get("data") + if workspace_data and isinstance(workspace_data, dict): + from ..models.workspace import Workspace + + workspace = Workspace( + id=_safe_str(workspace_data.get("id")), + name="", # Name not available in relationship data + ) + + return WorkspaceRunTask( + id=id_str, + enforcement_level=enforcement_level, + stage=stage, + stages=stages, + run_task=run_task, + workspace=workspace, + ) + + +class WorkspaceRunTasks(_Service): + """ + Workspace Run Tasks service for managing run tasks attached to workspaces. + + API Documentation: + https://developer.hashicorp.com/terraform/cloud-docs/api-docs/workspace-run-tasks + """ + + def list( + self, + workspace_id: str, + options: WorkspaceRunTaskListOptions | None = None, + ) -> Iterator[WorkspaceRunTask]: + """ + List all run tasks attached to a workspace. + + Args: + workspace_id: The ID of the workspace + options: Optional pagination parameters + + Yields: + WorkspaceRunTask objects + + Raises: + InvalidWorkspaceIDError: If workspace_id is invalid + + API Endpoint: + GET /workspaces/:workspace_id/tasks + """ + if not valid_string_id(workspace_id): + raise InvalidWorkspaceIDError("Invalid workspace ID") + + url = f"/api/v2/workspaces/{workspace_id}/tasks" + 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 + + while True: + r = self.t.request("GET", url, params=params) + response: dict[str, Any] = r.json() + + # Parse data array + data_list = response.get("data", []) + if not isinstance(data_list, list): + break + + for item in data_list: + yield _workspace_run_task_from(item) + + # Check for next page + links = response.get("links", {}) + next_url = links.get("next") + if not next_url: + break + + # Update URL for next page + url = next_url + params = {} + + def read(self, workspace_id: str, workspace_task_id: str) -> WorkspaceRunTask: + """ + Read a workspace run task by ID. + + Args: + workspace_id: The ID of the workspace + workspace_task_id: The ID of the workspace run task + + Returns: + WorkspaceRunTask object + + Raises: + InvalidWorkspaceIDError: If workspace_id is invalid + InvalidWorkspaceRunTaskIDError: If workspace_task_id is invalid + + API Endpoint: + GET /workspaces/:workspace_id/tasks/:workspace_task_id + """ + if not valid_string_id(workspace_id): + raise InvalidWorkspaceIDError("Invalid workspace ID") + + if not valid_string_id(workspace_task_id): + raise InvalidWorkspaceRunTaskIDError("Invalid workspace run task ID") + + url = f"/api/v2/workspaces/{workspace_id}/tasks/{workspace_task_id}" + r = self.t.request("GET", url) + response: dict[str, Any] = r.json() + + data = response.get("data", {}) + return _workspace_run_task_from(data) + + def create( + self, + workspace_id: str, + options: WorkspaceRunTaskCreateOptions, + ) -> WorkspaceRunTask: + """ + Create a workspace run task (attach a run task to a workspace). + + The run task must exist in the workspace's organization. + + Args: + workspace_id: The ID of the workspace + options: Creation options including run_task and enforcement_level + + Returns: + Created WorkspaceRunTask object + + Raises: + InvalidWorkspaceIDError: If workspace_id is invalid + InvalidRunTaskIDError: If run_task ID is invalid + + API Endpoint: + POST /workspaces/:workspace_id/tasks + """ + if not valid_string_id(workspace_id): + raise InvalidWorkspaceIDError("Invalid workspace ID") + + if not options.run_task or not options.run_task.id: + raise InvalidRunTaskIDError("Invalid run task ID") + + url = f"/api/v2/workspaces/{workspace_id}/tasks" + + # Build request payload + payload: dict[str, Any] = { + "data": { + "type": options.type, + "attributes": { + "enforcement-level": options.enforcement_level.value, + }, + "relationships": { + "task": { + "data": { + "type": "tasks", + "id": options.run_task.id, + } + } + }, + } + } + + # Add optional stage (deprecated) + if options.stage is not None: + payload["data"]["attributes"]["stage"] = options.stage.value + + # Add optional stages + if options.stages is not None: + payload["data"]["attributes"]["stages"] = [s.value for s in options.stages] + + r = self.t.request("POST", url, json_body=payload) + response: dict[str, Any] = r.json() + + data = response.get("data", {}) + return _workspace_run_task_from(data) + + def update( + self, + workspace_id: str, + workspace_task_id: str, + options: WorkspaceRunTaskUpdateOptions, + ) -> WorkspaceRunTask: + """ + Update an existing workspace run task. + + Args: + workspace_id: The ID of the workspace + workspace_task_id: The ID of the workspace run task + options: Update options (enforcement_level, stage, stages) + + Returns: + Updated WorkspaceRunTask object + + Raises: + InvalidWorkspaceIDError: If workspace_id is invalid + InvalidWorkspaceRunTaskIDError: If workspace_task_id is invalid + + API Endpoint: + PATCH /workspaces/:workspace_id/tasks/:workspace_task_id + """ + if not valid_string_id(workspace_id): + raise InvalidWorkspaceIDError("Invalid workspace ID") + + if not valid_string_id(workspace_task_id): + raise InvalidWorkspaceRunTaskIDError("Invalid workspace run task ID") + + url = f"/api/v2/workspaces/{workspace_id}/tasks/{workspace_task_id}" + + # Build request payload + payload: dict[str, Any] = { + "data": { + "type": options.type, + "attributes": {}, + } + } + + # Add optional enforcement level + if options.enforcement_level is not None: + payload["data"]["attributes"]["enforcement-level"] = ( + options.enforcement_level.value + ) + + # Add optional stage (deprecated) + if options.stage is not None: + payload["data"]["attributes"]["stage"] = options.stage.value + + # Add optional stages + if options.stages is not None: + payload["data"]["attributes"]["stages"] = [s.value for s in options.stages] + + r = self.t.request("PATCH", url, json_body=payload) + response: dict[str, Any] = r.json() + + data = response.get("data", {}) + return _workspace_run_task_from(data) + + def delete(self, workspace_id: str, workspace_task_id: str) -> None: + """ + Delete a workspace run task by ID. + + Args: + workspace_id: The ID of the workspace + workspace_task_id: The ID of the workspace run task + + Raises: + InvalidWorkspaceIDError: If workspace_id is invalid + InvalidWorkspaceRunTaskIDError: If workspace_task_id is invalid + + API Endpoint: + DELETE /workspaces/:workspace_id/tasks/:workspace_task_id + """ + if not valid_string_id(workspace_id): + raise InvalidWorkspaceIDError("Invalid workspace ID") + + if not valid_string_id(workspace_task_id): + raise InvalidWorkspaceRunTaskIDError("Invalid workspace run task ID") + + url = f"/api/v2/workspaces/{workspace_id}/tasks/{workspace_task_id}" + self.t.request("DELETE", url) diff --git a/tests/units/test_run_task.py b/tests/units/test_run_task.py index a428d79..fbc5b29 100644 --- a/tests/units/test_run_task.py +++ b/tests/units/test_run_task.py @@ -42,7 +42,7 @@ def test_run_task_from_comprehensive(self): "enabled": True, "global-configuration": { "enabled": True, - "stages": ["pre-plan", "post-apply"], + "stages": ["pre_plan", "post_apply"], "enforcement-level": "mandatory", }, }, @@ -221,7 +221,7 @@ def test_create_run_task(self, run_tasks_service): "hmac_key": "secret-key-123", "global-configuration": { "enabled": True, - "stages": ["pre-plan", "post-plan"], + "stages": ["pre_plan", "post_plan"], "enforcement-level": "mandatory", }, }, diff --git a/tests/units/test_workspace_run_task.py b/tests/units/test_workspace_run_task.py new file mode 100644 index 0000000..fc84784 --- /dev/null +++ b/tests/units/test_workspace_run_task.py @@ -0,0 +1,469 @@ +"""Unit tests for the workspace run task module.""" + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import ( + InvalidRunTaskIDError, + InvalidWorkspaceIDError, + InvalidWorkspaceRunTaskIDError, +) +from pytfe.models.run_task import RunTask, Stage, TaskEnforcementLevel +from pytfe.models.workspace_run_task import ( + WorkspaceRunTask, + WorkspaceRunTaskCreateOptions, + WorkspaceRunTaskListOptions, + WorkspaceRunTaskUpdateOptions, +) +from pytfe.resources.workspace_run_task import ( + WorkspaceRunTasks, + _workspace_run_task_from, +) + +# Ensure models are fully defined for tests +WorkspaceRunTask.model_rebuild() +WorkspaceRunTaskCreateOptions.model_rebuild() +WorkspaceRunTaskUpdateOptions.model_rebuild() + + +class TestWorkspaceRunTaskFrom: + """Test the _workspace_run_task_from function.""" + + def test_workspace_run_task_from_complete(self): + """Test _workspace_run_task_from with all fields populated.""" + + data = { + "id": "wstask-123", + "attributes": { + "enforcement-level": "mandatory", + "stage": "pre_plan", + "stages": ["pre_plan", "post_plan"], + }, + "relationships": { + "task": {"data": {"id": "task-456", "type": "tasks"}}, + "workspace": {"data": {"id": "ws-789", "type": "workspaces"}}, + }, + } + + result = _workspace_run_task_from(data) + + assert result.id == "wstask-123" + assert result.enforcement_level == TaskEnforcementLevel.MANDATORY + assert result.stage == Stage.PRE_PLAN + assert len(result.stages) == 2 + assert result.stages[0] == Stage.PRE_PLAN + assert result.stages[1] == Stage.POST_PLAN + assert result.run_task is not None + assert result.run_task.id == "task-456" + assert result.workspace is not None + assert result.workspace.id == "ws-789" + + def test_workspace_run_task_from_minimal(self): + """Test _workspace_run_task_from with minimal fields.""" + + data = { + "id": "wstask-minimal", + "attributes": {"enforcement-level": "advisory"}, + } + + result = _workspace_run_task_from(data) + + assert result.id == "wstask-minimal" + assert result.enforcement_level == TaskEnforcementLevel.ADVISORY + # Should have default stage + assert result.stage == Stage.PRE_PLAN + # Should have empty stages list + assert result.stages == [] + # Relationships should be None + assert result.run_task is None + assert result.workspace is None + + def test_workspace_run_task_from_invalid_enforcement_level(self): + """Test _workspace_run_task_from handles invalid enforcement level.""" + + data = { + "id": "wstask-invalid", + "attributes": {"enforcement-level": "invalid-level"}, + } + + result = _workspace_run_task_from(data) + + # Should default to ADVISORY for invalid values + assert result.enforcement_level == TaskEnforcementLevel.ADVISORY + + def test_workspace_run_task_from_invalid_stage(self): + """Test _workspace_run_task_from handles invalid stage.""" + + data = { + "id": "wstask-invalid-stage", + "attributes": { + "enforcement-level": "mandatory", + "stage": "invalid-stage", + "stages": ["pre_plan", "invalid-stage", "post_plan"], + }, + } + + result = _workspace_run_task_from(data) + + # Should default to PRE_PLAN for invalid stage value + assert result.stage == Stage.PRE_PLAN + # Stages list should skip invalid stages + assert len(result.stages) == 2 + assert result.stages[0] == Stage.PRE_PLAN + assert result.stages[1] == Stage.POST_PLAN + + +class TestWorkspaceRunTasks: + """Test the WorkspaceRunTasks service class.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock HTTPTransport.""" + return Mock(spec=HTTPTransport) + + @pytest.fixture + def workspace_run_tasks_service(self, mock_transport): + """Create a WorkspaceRunTasks service with mocked transport.""" + return WorkspaceRunTasks(mock_transport) + + # List Tests + def test_list_with_invalid_workspace_id(self, workspace_run_tasks_service): + """Test list with invalid workspace ID.""" + + with pytest.raises(InvalidWorkspaceIDError): + list(workspace_run_tasks_service.list("")) + + def test_list_success(self, workspace_run_tasks_service, mock_transport): + """Test successful list operation.""" + + mock_response_data = { + "data": [ + { + "id": "wstask-1", + "attributes": { + "enforcement-level": "mandatory", + "stage": "pre_plan", + "stages": ["pre_plan"], + }, + }, + { + "id": "wstask-2", + "attributes": { + "enforcement-level": "advisory", + "stage": "post_plan", + "stages": ["post_plan"], + }, + }, + ], + "links": {}, + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + mock_transport.request.return_value = mock_response + + options = WorkspaceRunTaskListOptions(page_number=1, page_size=10) + results = list(workspace_run_tasks_service.list("ws-123", options)) + + assert len(results) == 2 + assert results[0].id == "wstask-1" + assert results[0].enforcement_level == TaskEnforcementLevel.MANDATORY + assert results[1].id == "wstask-2" + assert results[1].enforcement_level == TaskEnforcementLevel.ADVISORY + + # Verify API call + mock_transport.request.assert_called_once() + call_args = mock_transport.request.call_args + assert call_args[0][0] == "GET" + assert call_args[0][1] == "/api/v2/workspaces/ws-123/tasks" + + def test_list_pagination(self, workspace_run_tasks_service, mock_transport): + """Test list with pagination.""" + + # First page + mock_response_page1_data = { + "data": [ + { + "id": "wstask-1", + "attributes": {"enforcement-level": "mandatory"}, + } + ], + "links": {"next": "workspaces/ws-123/tasks?page[number]=2"}, + } + + # Second page + mock_response_page2_data = { + "data": [ + { + "id": "wstask-2", + "attributes": {"enforcement-level": "advisory"}, + } + ], + "links": {}, + } + + mock_response_1 = Mock() + mock_response_1.json.return_value = mock_response_page1_data + mock_response_2 = Mock() + mock_response_2.json.return_value = mock_response_page2_data + mock_transport.request.side_effect = [mock_response_1, mock_response_2] + + results = list(workspace_run_tasks_service.list("ws-123")) + + assert len(results) == 2 + assert results[0].id == "wstask-1" + assert results[1].id == "wstask-2" + assert mock_transport.request.call_count == 2 + + # Read Tests + def test_read_with_invalid_workspace_id(self, workspace_run_tasks_service): + """Test read with invalid workspace ID.""" + + with pytest.raises(InvalidWorkspaceIDError): + workspace_run_tasks_service.read("", "wstask-123") + + def test_read_with_invalid_task_id(self, workspace_run_tasks_service): + """Test read with invalid workspace task ID.""" + + with pytest.raises(InvalidWorkspaceRunTaskIDError): + workspace_run_tasks_service.read("ws-123", "") + + def test_read_success(self, workspace_run_tasks_service, mock_transport): + """Test successful read operation.""" + + mock_response_data = { + "data": { + "id": "wstask-123", + "attributes": { + "enforcement-level": "mandatory", + "stage": "pre_plan", + "stages": ["pre_plan", "post_plan"], + }, + "relationships": { + "task": {"data": {"id": "task-456", "type": "tasks"}}, + "workspace": {"data": {"id": "ws-789", "type": "workspaces"}}, + }, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + mock_transport.request.return_value = mock_response + + result = workspace_run_tasks_service.read("ws-789", "wstask-123") + + assert result.id == "wstask-123" + assert result.enforcement_level == TaskEnforcementLevel.MANDATORY + assert result.stage == Stage.PRE_PLAN + assert len(result.stages) == 2 + assert result.run_task.id == "task-456" + assert result.workspace.id == "ws-789" + + # Verify API call + mock_transport.request.assert_called_once_with( + "GET", "/api/v2/workspaces/ws-789/tasks/wstask-123" + ) + + # Create Tests + def test_create_with_invalid_workspace_id(self, workspace_run_tasks_service): + """Test create with invalid workspace ID.""" + + run_task = RunTask( + id="task-123", + name="Test Task", + url="https://example.com", + category="task", + enabled=True, + ) + options = WorkspaceRunTaskCreateOptions( + enforcement_level=TaskEnforcementLevel.MANDATORY, + run_task=run_task, + ) + + with pytest.raises(InvalidWorkspaceIDError): + workspace_run_tasks_service.create("", options) + + def test_create_with_invalid_run_task(self, workspace_run_tasks_service): + """Test create with invalid run task.""" + + # Run task with no ID + run_task = RunTask( + id="", name="Test", url="https://example.com", category="task", enabled=True + ) + options = WorkspaceRunTaskCreateOptions( + enforcement_level=TaskEnforcementLevel.MANDATORY, + run_task=run_task, + ) + + with pytest.raises(InvalidRunTaskIDError): + workspace_run_tasks_service.create("ws-123", options) + + def test_create_success(self, workspace_run_tasks_service, mock_transport): + """Test successful create operation.""" + + mock_response_data = { + "data": { + "id": "wstask-new", + "attributes": { + "enforcement-level": "mandatory", + "stage": "pre_plan", + "stages": ["pre_plan", "post_plan"], + }, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + mock_transport.request.return_value = mock_response + + run_task = RunTask( + id="task-123", + name="Test Task", + url="https://example.com", + category="task", + enabled=True, + ) + options = WorkspaceRunTaskCreateOptions( + enforcement_level=TaskEnforcementLevel.MANDATORY, + run_task=run_task, + stages=[Stage.PRE_PLAN, Stage.POST_PLAN], + ) + + result = workspace_run_tasks_service.create("ws-789", options) + + assert result.id == "wstask-new" + assert result.enforcement_level == TaskEnforcementLevel.MANDATORY + + # Verify API call + 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/workspaces/ws-789/tasks" + payload = call_args[1]["json_body"] + assert payload["data"]["type"] == "workspace-tasks" + assert payload["data"]["attributes"]["enforcement-level"] == "mandatory" + assert payload["data"]["attributes"]["stages"] == ["pre_plan", "post_plan"] + assert payload["data"]["relationships"]["task"]["data"]["id"] == "task-123" + + def test_create_with_deprecated_stage( + self, workspace_run_tasks_service, mock_transport + ): + """Test create with deprecated stage attribute.""" + + mock_response_data = { + "data": { + "id": "wstask-new", + "attributes": { + "enforcement-level": "advisory", + "stage": "post_plan", + }, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + mock_transport.request.return_value = mock_response + + run_task = RunTask( + id="task-123", + name="Test Task", + url="https://example.com", + category="task", + enabled=True, + ) + options = WorkspaceRunTaskCreateOptions( + enforcement_level=TaskEnforcementLevel.ADVISORY, + run_task=run_task, + stage=Stage.POST_PLAN, + ) + + result = workspace_run_tasks_service.create("ws-789", options) + + assert result.id == "wstask-new" + + # Verify API call includes stage + payload = mock_transport.request.call_args[1]["json_body"] + assert payload["data"]["attributes"]["stage"] == "post_plan" + + # Update Tests + def test_update_with_invalid_workspace_id(self, workspace_run_tasks_service): + """Test update with invalid workspace ID.""" + + options = WorkspaceRunTaskUpdateOptions( + enforcement_level=TaskEnforcementLevel.ADVISORY + ) + + with pytest.raises(InvalidWorkspaceIDError): + workspace_run_tasks_service.update("", "wstask-123", options) + + def test_update_with_invalid_task_id(self, workspace_run_tasks_service): + """Test update with invalid workspace task ID.""" + + options = WorkspaceRunTaskUpdateOptions( + enforcement_level=TaskEnforcementLevel.ADVISORY + ) + + with pytest.raises(InvalidWorkspaceRunTaskIDError): + workspace_run_tasks_service.update("ws-123", "", options) + + def test_update_success(self, workspace_run_tasks_service, mock_transport): + """Test successful update operation.""" + + mock_response_data = { + "data": { + "id": "wstask-123", + "attributes": { + "enforcement-level": "advisory", + "stage": "pre_plan", + "stages": ["pre_plan", "post_plan"], + }, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + mock_transport.request.return_value = mock_response + + options = WorkspaceRunTaskUpdateOptions( + enforcement_level=TaskEnforcementLevel.ADVISORY, + stages=[Stage.PRE_PLAN, Stage.POST_PLAN], + ) + + result = workspace_run_tasks_service.update("ws-789", "wstask-123", options) + + assert result.id == "wstask-123" + assert result.enforcement_level == TaskEnforcementLevel.ADVISORY + + # Verify API call + mock_transport.request.assert_called_once() + call_args = mock_transport.request.call_args + assert call_args[0][0] == "PATCH" + assert call_args[0][1] == "/api/v2/workspaces/ws-789/tasks/wstask-123" + payload = call_args[1]["json_body"] + assert payload["data"]["attributes"]["enforcement-level"] == "advisory" + assert payload["data"]["attributes"]["stages"] == ["pre_plan", "post_plan"] + + # Delete Tests + def test_delete_with_invalid_workspace_id(self, workspace_run_tasks_service): + """Test delete with invalid workspace ID.""" + + with pytest.raises(InvalidWorkspaceIDError): + workspace_run_tasks_service.delete("", "wstask-123") + + def test_delete_with_invalid_task_id(self, workspace_run_tasks_service): + """Test delete with invalid workspace task ID.""" + + with pytest.raises(InvalidWorkspaceRunTaskIDError): + workspace_run_tasks_service.delete("ws-123", "") + + def test_delete_success(self, workspace_run_tasks_service, mock_transport): + """Test successful delete operation.""" + + workspace_run_tasks_service.delete("ws-789", "wstask-123") + + # Verify API call + mock_transport.request.assert_called_once_with( + "DELETE", "/api/v2/workspaces/ws-789/tasks/wstask-123" + )