diff --git a/.gitignore b/.gitignore index 7e44cac..17c23a0 100644 --- a/.gitignore +++ b/.gitignore @@ -109,7 +109,7 @@ profiles .DS_Store # Repo-specific -bin/ +.bin/ # Generated protoset files *.protoset diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..90eaf39 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,175 @@ +# Unreleased + +# v0.1.2 + +## Features + +### Registry Management +* Added registry provider version resource with full CRUD operations by @isivaselvan [#66](https://github.com/hashicorp/python-tfe/pull/66) +* Added create method for registry provider versions by @isivaselvan [#66](https://github.com/hashicorp/python-tfe/pull/66) +* Added list method with pagination support for registry provider versions by @isivaselvan [#66](https://github.com/hashicorp/python-tfe/pull/66) +* Added read method for fetching specific registry provider version details by @isivaselvan [#66](https://github.com/hashicorp/python-tfe/pull/66) +* Added delete method for removing registry provider versions by @isivaselvan [#66](https://github.com/hashicorp/python-tfe/pull/66) +* Added comprehensive unit tests for registry provider versions by @isivaselvan [#66](https://github.com/hashicorp/python-tfe/pull/66) + +## Breaking Change + +### Iterator Pattern Migration for List Method +* Migrated Policy Evaluation resource to use iterator pattern for list operations and renamed attribute task_stage to policy_attachable at PolicyEvaluation Model by @isivaselvan [#68](https://github.com/hashicorp/python-tfe/pull/68) +* Migrated Policy Set Outcome resource to use iterator pattern for list operations by @isivaselvan [#68](https://github.com/hashicorp/python-tfe/pull/68) +* Migrated OAuth Token resource to use iterator pattern and removed deprecated Uid attribute by @isivaselvan [#68](https://github.com/hashicorp/python-tfe/pull/68) +* Migrated Reserved Tag Key resource to use iterator pattern, removed read method, and renamed service class by @isivaselvan [#68](https://github.com/hashicorp/python-tfe/pull/68) + +### Deprecations +* Models OAuthTokenList, PolicyEvaluationList, PolicySetOutcomeList, ReservedTagKeyList were removed from models as part of initial Iterator pattern conversion of List Method. +* page_number attribute was removed at Models of OAuthTokenListOptions, PolicyEvaluationListOptions, PolicySetOutcomeListFilter and ReservedTagKeyListOptions. +* Removed deprecated Uid attribute at OauthToken Model. + +### Enhancements +* Updated query run functions with correct api endpoints, parameters and payload options for improved performance and consistency by @aayushsingh2502 [#69](https://github.com/hashicorp/python-tfe/pull/69) +* Removed ListOptions from model and improved Cancel and Force Cancel option handling by @aayushsingh2502 [#69](https://github.com/hashicorp/python-tfe/pull/69) +* Updated function naming conventions in example files for better clarity by @aayushsingh2502 [#69](https://github.com/hashicorp/python-tfe/pull/69) + +## Bug Fixes +* Fixed the issue related to the Regex pattern on string id validation for registry resource by @isivaselvan [#66](https://github.com/hashicorp/python-tfe/pull/66) + +# v0.1.1 + +## Features + +### Organization Management +* Added organization membership list functionality with flexible filtering and pagination by @aayushsingh2502 [#54](https://github.com/hashicorp/python-tfe/pull/54) +* Added organization membership read functionality by @aayushsingh2502 [#54](https://github.com/hashicorp/python-tfe/pull/54) +* Added organization membership read with relationship includes by @aayushsingh2502 [#54](https://github.com/hashicorp/python-tfe/pull/54) +* Added organization membership create functionality to invite users via email with optional team assignments by @aayushsingh2502 [#54](https://github.com/hashicorp/python-tfe/pull/54) +* Added organization membership delete functionality by @aayushsingh2502 [#54](https://github.com/hashicorp/python-tfe/pull/54) + +### Workspace Management +* Added workspace resources list functionality with pagination support by @KshitijaChoudhari [#58](https://github.com/hashicorp/python-tfe/pull/58) +* Added robust data models with Pydantic validation for workspace resources by @KshitijaChoudhari [#58](https://github.com/hashicorp/python-tfe/pull/58) +* Added comprehensive filtering options for workspace resources by @KshitijaChoudhari [#58](https://github.com/hashicorp/python-tfe/pull/58) + +### Policy Management +* Added policy set parameter list functionality by @isivaselvan [#53](https://github.com/hashicorp/python-tfe/pull/53) +* Added policy set parameter create functionality by @isivaselvan [#53](https://github.com/hashicorp/python-tfe/pull/53) +* Added policy set parameter read functionality by @isivaselvan [#53](https://github.com/hashicorp/python-tfe/pull/53) +* Added policy set parameter update functionality by @isivaselvan [#53](https://github.com/hashicorp/python-tfe/pull/53) +* Added policy set parameter delete functionality by @isivaselvan [#53](https://github.com/hashicorp/python-tfe/pull/53) + +## Enhancements +* Code cleanup and improvements across example files by @aayushsingh2502 [#54](https://github.com/hashicorp/python-tfe/pull/54) + +# v0.1.0 + +## Features + +### Core Infrastructure & Foundation +* Established base client architecture, HTTP transport layer, pagination and response handling with retries by @iam404 [#9](https://github.com/hashicorp/python-tfe/pull/9) +* Implemented configuration management and authentication patterns by @iam404 [#9](https://github.com/hashicorp/python-tfe/pull/9) +* Added comprehensive error handling and logging infrastructure by @iam404 [#9](https://github.com/hashicorp/python-tfe/pull/9) + +### Organization Management +* Added full CRUD operations for organizations by @aayushsingh2502 +* Added organization membership and user management by @aayushsingh2502 +* Added organization settings and feature toggles by @aayushsingh2502 + +### Workspace Management +* Added comprehensive workspace lifecycle management by @isivaselvan [#16](https://github.com/hashicorp/python-tfe/pull/16) +* Added VCS integration support for GitHub, GitLab, Bitbucket, Azure DevOps by @isivaselvan [#16](https://github.com/hashicorp/python-tfe/pull/16) +* Added workspace settings, tags, and remote state consumers by @isivaselvan [#16](https://github.com/hashicorp/python-tfe/pull/16) +* Added workspace variable management functionality by @aayushsingh2502 [#16](https://github.com/hashicorp/python-tfe/pull/16) +* Added variable sets integration by @aayushsingh2502 [#16](https://github.com/hashicorp/python-tfe/pull/16) +* Added sensitive variable handling with encryption by @aayushsingh2502 [#16](https://github.com/hashicorp/python-tfe/pull/16) + +### Project Management +* Added project creation, configuration, and management by @KshitijaChoudhari [#23](https://github.com/hashicorp/python-tfe/pull/23) +* Added project tagging and organization by @KshitijaChoudhari [#25](https://github.com/hashicorp/python-tfe/pull/25) +* Added tag binding functionality for improved project organization by @KshitijaChoudhari [#25](https://github.com/hashicorp/python-tfe/pull/25) + +### State Management +* Added state version listing, downloading, and rollback capabilities by @iam404 [#22](https://github.com/hashicorp/python-tfe/pull/22) +* Added state output retrieval and management by @iam404 [#22](https://github.com/hashicorp/python-tfe/pull/22) +* Added secure state file operations with locking mechanisms by @iam404 [#22](https://github.com/hashicorp/python-tfe/pull/22) + +### Variable Sets +* Added variable set creation and management by @KshitijaChoudhari [#27](https://github.com/hashicorp/python-tfe/pull/27) +* Added workspace association and inheritance by @KshitijaChoudhari [#27](https://github.com/hashicorp/python-tfe/pull/27) +* Added global and workspace-specific variable sets by @KshitijaChoudhari [#27](https://github.com/hashicorp/python-tfe/pull/27) + +### Registry Management +* Added private module registry implementation by @aayushsingh2502 [#24](https://github.com/hashicorp/python-tfe/pull/24) +* Added module publishing and version management by @aayushsingh2502 [#24](https://github.com/hashicorp/python-tfe/pull/24) +* Added VCS integration for automated module updates by @aayushsingh2502 [#24](https://github.com/hashicorp/python-tfe/pull/24) +* Added dependency management and semantic versioning by @aayushsingh2502 [#24](https://github.com/hashicorp/python-tfe/pull/24) +* Added custom and community provider management by @aayushsingh2502 [#28](https://github.com/hashicorp/python-tfe/pull/28) +* Added provider version publishing and distribution by @aayushsingh2502 [#28](https://github.com/hashicorp/python-tfe/pull/28) +* Added GPG signature verification support by @aayushsingh2502 [#28](https://github.com/hashicorp/python-tfe/pull/28) + +### Run Management +* Added run creation, execution, and monitoring by @isivaselvan [#30](https://github.com/hashicorp/python-tfe/pull/30) +* Added run status tracking with real-time updates by @isivaselvan [#30](https://github.com/hashicorp/python-tfe/pull/30) +* Added run cancellation and force-cancellation capabilities by @isivaselvan [#30](https://github.com/hashicorp/python-tfe/pull/30) +* Added detailed plan analysis and review by @isivaselvan [#33](https://github.com/hashicorp/python-tfe/pull/33) +* Added apply operations with confirmation workflows by @isivaselvan [#33](https://github.com/hashicorp/python-tfe/pull/33) +* Added plan output parsing and visualization by @isivaselvan [#33](https://github.com/hashicorp/python-tfe/pull/33) +* Added run task creation and execution by @isivaselvan [#26](https://github.com/hashicorp/python-tfe/pull/26) +* Added trigger-based automated runs by @isivaselvan [#26](https://github.com/hashicorp/python-tfe/pull/26) +* Added webhook integration for external triggers by @isivaselvan [#26](https://github.com/hashicorp/python-tfe/pull/26) +* Added comprehensive run event logging by @isivaselvan [#36](https://github.com/hashicorp/python-tfe/pull/36) +* Added event filtering and querying capabilities by @isivaselvan [#36](https://github.com/hashicorp/python-tfe/pull/36) +* Added real-time event streaming support by @isivaselvan [#36](https://github.com/hashicorp/python-tfe/pull/36) + +### Configuration Management +* Added configuration version creation and upload by @aayushsingh2502 [#32](https://github.com/hashicorp/python-tfe/pull/32) +* Added tar.gz archive support for configuration bundles by @aayushsingh2502 [#32](https://github.com/hashicorp/python-tfe/pull/32) +* Added VCS-triggered configuration updates by @aayushsingh2502 [#32](https://github.com/hashicorp/python-tfe/pull/32) + +### Query and Search +* Added complex run filtering and search by @KshitijaChoudhari [#35](https://github.com/hashicorp/python-tfe/pull/35) +* Added historical run data analysis by @KshitijaChoudhari [#35](https://github.com/hashicorp/python-tfe/pull/35) +* Added performance metrics and statistics by @KshitijaChoudhari [#35](https://github.com/hashicorp/python-tfe/pull/35) + +### Agent Management +* Added agent pool creation and configuration by @KshitijaChoudhari [#31](https://github.com/hashicorp/python-tfe/pull/31) +* Added agent registration and lifecycle management by @KshitijaChoudhari [#31](https://github.com/hashicorp/python-tfe/pull/31) +* Added health monitoring and capacity management by @KshitijaChoudhari [#31](https://github.com/hashicorp/python-tfe/pull/31) + +### Authentication & Security +* Added OAuth client creation and configuration by @aayushsingh2502 [#37](https://github.com/hashicorp/python-tfe/pull/37) +* Added VCS provider authentication setup by @aayushsingh2502 [#37](https://github.com/hashicorp/python-tfe/pull/37) +* Added OAuth token refresh and management by @aayushsingh2502 [#37](https://github.com/hashicorp/python-tfe/pull/37) +* Added OAuth token creation and renewal by @aayushsingh2502 [#40](https://github.com/hashicorp/python-tfe/pull/40) +* Added secure token storage and retrieval by @aayushsingh2502 [#40](https://github.com/hashicorp/python-tfe/pull/40) +* Added token scope and permission management by @aayushsingh2502 [#40](https://github.com/hashicorp/python-tfe/pull/40) +* Added SSH key upload and management by @KshitijaChoudhari [#38](https://github.com/hashicorp/python-tfe/pull/38) +* Added key validation and security checks by @KshitijaChoudhari [#38](https://github.com/hashicorp/python-tfe/pull/38) +* Added repository access configuration by @KshitijaChoudhari [#38](https://github.com/hashicorp/python-tfe/pull/38) + +### Tagging & Organization +* Added reserved tag key creation and enforcement by @KshitijaChoudhari [#39](https://github.com/hashicorp/python-tfe/pull/39) +* Added tag validation and naming conventions by @KshitijaChoudhari [#39](https://github.com/hashicorp/python-tfe/pull/39) +* Added organizational tag policies by @KshitijaChoudhari [#39](https://github.com/hashicorp/python-tfe/pull/39) + +### Policy Management +* Added Sentinel policy creation and enforcement by @isivaselvan [#41](https://github.com/hashicorp/python-tfe/pull/41) +* Added policy version management by @isivaselvan [#41](https://github.com/hashicorp/python-tfe/pull/41) +* Added policy evaluation and reporting by @isivaselvan [#41](https://github.com/hashicorp/python-tfe/pull/41) +* Added policy check execution and results by @isivaselvan [#42](https://github.com/hashicorp/python-tfe/pull/42) +* Added override capabilities for policy failures by @isivaselvan [#42](https://github.com/hashicorp/python-tfe/pull/42) +* Added detailed policy violation reporting by @isivaselvan [#42](https://github.com/hashicorp/python-tfe/pull/42) +* Added policy set creation and configuration by @isivaselvan [#45](https://github.com/hashicorp/python-tfe/pull/45) +* Added workspace and organization policy assignment by @isivaselvan [#45](https://github.com/hashicorp/python-tfe/pull/45) +* Added policy set versioning and rollback by @isivaselvan [#45](https://github.com/hashicorp/python-tfe/pull/45) +* Added policy set version management by @isivaselvan [#46](https://github.com/hashicorp/python-tfe/pull/46) +* Added policy set outcome tracking by @isivaselvan [#46](https://github.com/hashicorp/python-tfe/pull/46) +* Added comprehensive evaluation reporting by @isivaselvan [#46](https://github.com/hashicorp/python-tfe/pull/46) + +### Notification Management +* Added notification configuration and management by @KshitijaChoudhari [#43](https://github.com/hashicorp/python-tfe/pull/43) +* Added multi-channel notification support for Slack, email, and webhooks by @KshitijaChoudhari [#43](https://github.com/hashicorp/python-tfe/pull/43) +* Added event-driven notification triggers by @KshitijaChoudhari [#43](https://github.com/hashicorp/python-tfe/pull/43) +* Added custom notification templates and formatting by @KshitijaChoudhari [#43](https://github.com/hashicorp/python-tfe/pull/43) + +## Notes +* Requires Python 3.10 or higher +* Compatible with HCP Terraform and Terraform Enterprise v2 and later diff --git a/README.md b/README.md index 1c0d9e7..45c869f 100644 --- a/README.md +++ b/README.md @@ -25,15 +25,13 @@ Construct a new **pyTFE** client, then use the resource services on the client t ### (Recommended) Using explicit config ```python -import os from pytfe import TFEClient, TFEConfig config = TFEConfig( - host="https://tfe.local", + address="https://tfe.local", token="insert-your-token-here", - retry_server_errors=True, timeout=30.0, - user_agent="example-app/0.1 pytfe/0.1", + user_agent_suffix="example-app/0.1 pytfe/0.1", ) client = TFEClient(config) @@ -51,7 +49,7 @@ The default configuration reads the `TFE_ADDRESS` and `TFE_TOKEN` environment va 2. `TFE_TOKEN` — An [API token](https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/api-tokens) for the HCP Terraform or Terraform Enterprise instance. -Environment variables are used as a fallback when `host` or `token` are not provided explicitly: +Environment variables are used as a fallback when `address` or `token` are not provided explicitly: #### Using the default configuration ```python diff --git a/bin/publish-pypi.sh b/bin/publish-pypi.sh new file mode 100755 index 0000000..9185a93 --- /dev/null +++ b/bin/publish-pypi.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -euo pipefail + + +PYPI_REPO="${PYPI_REPO:-testpypi}" # "pypi" or "testpypi" +PYPI_TOKEN="${PYPI_TOKEN:-}" # export PYPI_TOKEN="pypi-***" (or test token) +PYTHON="${PYTHON:-python}" # "python" or "python3" +DIST_DIR="dist" +PYPROJECT="pyproject.toml" + + +if [[ ! -f "$PYPROJECT" ]]; then + echo "$PYPROJECT not found (are you in the project root?)" + exit 1 +fi + +if [[ -z "$PYPI_TOKEN" ]]; then + echo "PYPI_TOKEN not set. Run: export PYPI_TOKEN='pypi-XXXX...'" + exit 1 +fi + +# Read project metadata (name & version) from pyproject.toml using Python +read_pyproject() { + "$PYTHON" - <<'PY' +import sys, pathlib +pp = pathlib.Path("pyproject.toml") +if not pp.exists(): + print("unknown|unknown"); sys.exit(0) +try: + import tomllib +except ModuleNotFoundError: + # Python <3.11 fallback + import json, re + # very small fallback parser: good enough to grab name/version in simple cases + text = pp.read_text() + name = re.search(r'(?m)^\s*name\s*=\s*"([^"]+)"', text) + ver = re.search(r'(?m)^\s*version\s*=\s*"([^"]+)"', text) + print(f"{name.group(1) if name else 'unknown'}|{ver.group(1) if ver else 'unknown'}") + sys.exit(0) + +data = tomllib.loads(pp.read_bytes()) +proj = data.get("project", {}) +print(f"{proj.get('name','unknown')}|{proj.get('version','unknown')}") +PY +} + +IFS="|" read -r PKG_NAME PKG_VER < <(read_pyproject) +echo "Package: ${PKG_NAME} Version: ${PKG_VER}" + + +echo "Cleaning old builds..." +rm -rf "$DIST_DIR" build ./*.egg-info + + +echo "Installing build tools..." +$PYTHON -m pip install -q --upgrade pip build twine + +echo "Building sdist & wheel..." +$PYTHON -m build # uses hatchling per your [build-system] + +echo "Validating with twine..." +$PYTHON -m twine check "$DIST_DIR"/* + + +if [[ "$PYPI_REPO" == "testpypi" ]]; then + REPO_URL="https://test.pypi.org/legacy/" + echo "Target: TestPyPI" +else + REPO_URL="https://upload.pypi.org/legacy/" + echo "Target: PyPI" +fi + +# ----------------------------- +# Upload +# ----------------------------- +echo "Uploading distributions..." +$PYTHON -m twine upload \ + --non-interactive \ + --repository-url "$REPO_URL" \ + -u __token__ \ + -p "$PYPI_TOKEN" \ + "$DIST_DIR"/* + +echo "Published ${PKG_NAME} ${PKG_VER} to ${PYPI_REPO}" diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 36ac46a..d9a41c1 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1 +1,566 @@ -# Contributing to pytfe \ No newline at end of file +# Contributing to python-tfe + +If you find an issue with this package, please create an issue in GitHub. If you'd like, we welcome any contributions. Fork this repository and submit a pull request. + +## Adding New Functionality or Fixing Bugs + +If you are making relevant changes worth communicating to our users, please include a note about it in our `CHANGELOG.md`. You can include it as part of the PR where you are submitting your changes. + +`CHANGELOG.md` should have the next minor version listed as `# v0.X.0 (Unreleased)` and any changes can go under there. But if you feel that your changes are better suited for a patch version (like a critical bug fix), you may list a new section for this version. You should repeat the same formatting style introduced by previous versions. + +### Scoping Pull Requests That Add New Resources + +There are instances where several new resources being added (i.e., Workspace Run Tasks and Organization Run Tasks) are coalesced into one PR. In order to keep the review process as efficient and least error-prone as possible, we ask that you please scope each PR to an individual resource even if the multiple resources you're adding share similarities. If joining multiple related PRs into one single PR makes more sense logistically, we'd ask that you organize your commit history by resource. A general convention for this repository is one commit for the implementation of the resource's methods, one for tests, and one for cleanup and housekeeping (e.g., modifying the changelog/docs, updating examples, etc.). + +**Note HashiCorp Employees Only:** When submitting a new set of endpoints please ensure that one of your respective team members approves the changes as well before merging. + +## Linting + +After opening a PR, our CI system will perform a series of code checks, one of which is linting. Linting is not strictly required for a change to be merged, but it helps smooth the review process and catch common mistakes early. If you'd like to run the linters manually, follow these steps: + +1. Install development dependencies: `make dev-install` +2. Format your code: `make fmt` +3. Run lint checks: `make lint` + +We use [ruff](https://docs.astral.sh/ruff/) for both formatting and linting, and [mypy](https://mypy.readthedocs.io/) for type checking. + +## Writing Tests + +The test suite contains unit tests with mocked API responses. You can read more about running the tests in [TESTS.md](TESTS.md). Our CI system (GitHub Actions) will not test your fork until a one-time approval takes place. + +To run tests: +```bash +make test +``` + +## Adding New Endpoints + +### Guidelines for Adding New Endpoints + +* A resource class should cover one RESTful resource, which sometimes involves two or more endpoints. +* Each resource class must be registered in the `TFEClient` class in `client.py`. +* You'll need to add unit tests that cover each method of the resource class with mocked responses. +* Each API resource implementation must have a corresponding example file added to the `examples/` directory demonstrating its usage. +* Option classes serve as a proxy for either passing query params or request bodies: + - `ListOptions` and `ReadOptions` are values passed as query parameters. + - `CreateOptions` and `UpdateOptions` represent the request body. +* URL parameters should be defined as method parameters. +* Any resource-specific errors must be defined in `errors.py`. + +Here is a comprehensive example of what a resource looks like when implemented: + +#### 1. Create the Model (`src/pytfe/models/example.py`) + +```python +"""Models for example resources.""" + +from __future__ import annotations + +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field + + +class ExampleStatus(str, Enum): + """Status of an example.""" + + PENDING = "pending" + ACTIVE = "active" + COMPLETED = "completed" + + +class Example(BaseModel): + """Represents an example resource.""" + + model_config = ConfigDict(populate_by_name=True) + + id: str = Field(..., description="The unique identifier") + name: str | None = Field(None, description="The name of the example") + status: ExampleStatus | None = Field(None, description="The current status") + url: str | None = Field(None, description="The URL") + optional_value: str | None = Field( + None, alias="optional-value", description="An optional value" + ) + created_at: datetime | None = Field( + None, alias="created-at", description="When this was created" + ) + + # Relationships + organization_name: str | None = Field( + None, description="The organization this belongs to" + ) + + +class ExampleListOptions(BaseModel): + """Options for listing examples.""" + + model_config = ConfigDict(populate_by_name=True) + + page_number: int | None = Field( + None, alias="page[number]", description="Page number", ge=1 + ) + page_size: int | None = Field( + None, alias="page[size]", description="Items per page", ge=1, le=100 + ) + + +class ExampleCreateOptions(BaseModel): + """Options for creating an example.""" + + model_config = ConfigDict(populate_by_name=True) + + name: str = Field(..., description="The name of the example") + url: str = Field(..., description="The URL") + optional_value: str | None = Field( + None, alias="optional-value", description="An optional value" + ) + + +class ExampleUpdateOptions(BaseModel): + """Options for updating an example.""" + + model_config = ConfigDict(populate_by_name=True) + + name: str | None = Field(None, description="The name") + url: str | None = Field(None, description="The URL") + optional_value: str | None = Field( + None, alias="optional-value", description="An optional value" + ) +``` + +#### 2. Create the Resource Class (`src/pytfe/resources/example.py`) + +```python +"""Example API resource.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +from ..errors import InvalidExampleIDError, InvalidOrgError +from ..models.example import ( + Example, + ExampleCreateOptions, + ExampleListOptions, + ExampleUpdateOptions, +) +from ..utils import valid_string_id +from ._base import _Service + + +class Examples(_Service): + """Example API for Terraform Enterprise.""" + + def list( + self, organization: str, options: ExampleListOptions | None = None + ) -> Iterator[Example]: + """Iterate through all examples in an organization. + + This method automatically handles pagination. + + Args: + organization: The name of the organization + options: Optional list options (page_size, page_number) + + Yields: + Example objects one at a time + """ + if not valid_string_id(organization): + raise InvalidOrgError() + + params: dict[str, Any] = {} + if options: + params = options.model_dump(by_alias=True, exclude_none=True) + + path = f"/api/v2/organizations/{organization}/examples" + for item in self._list(path, params=params): + attrs = item.get("attributes", {}) + attrs["id"] = item.get("id") + + # Extract relationships if needed + relationships = item.get("relationships", {}) + org_rel = relationships.get("organization", {}) + org_data = org_rel.get("data", {}) + if org_data and isinstance(org_data, dict): + attrs["organization_name"] = org_data.get("id") + + yield Example.model_validate(attrs) + + def create( + self, organization: str, options: ExampleCreateOptions + ) -> Example: + """Create a new example. + + Args: + organization: The name of the organization + options: Options for creating the example + + Returns: + The created Example object + """ + if not valid_string_id(organization): + raise InvalidOrgError() + + path = f"/api/v2/organizations/{organization}/examples" + body = { + "data": { + "type": "examples", + "attributes": options.model_dump(by_alias=True, exclude_none=True), + } + } + + response = self.t.request("POST", path, json_body=body) + data = response.json()["data"] + attrs = data.get("attributes", {}) + attrs["id"] = data.get("id") + return Example.model_validate(attrs) + + def read(self, example_id: str) -> Example: + """Read an example by ID. + + Args: + example_id: The ID of the example + + Returns: + The Example object + """ + if not valid_string_id(example_id): + raise InvalidExampleIDError() + + path = f"/api/v2/examples/{example_id}" + response = self.t.request("GET", path) + data = response.json()["data"] + attrs = data.get("attributes", {}) + attrs["id"] = data.get("id") + return Example.model_validate(attrs) + + def update( + self, example_id: str, options: ExampleUpdateOptions + ) -> Example: + """Update an example. + + Args: + example_id: The ID of the example + options: Options for updating the example + + Returns: + The updated Example object + """ + if not valid_string_id(example_id): + raise InvalidExampleIDError() + + path = f"/api/v2/examples/{example_id}" + body = { + "data": { + "type": "examples", + "id": example_id, + "attributes": options.model_dump(by_alias=True, exclude_none=True), + } + } + + response = self.t.request("PATCH", path, json_body=body) + data = response.json()["data"] + attrs = data.get("attributes", {}) + attrs["id"] = data.get("id") + return Example.model_validate(attrs) + + def delete(self, example_id: str) -> None: + """Delete an example. + + Args: + example_id: The ID of the example + + Returns: + None (204 No Content on success) + """ + if not valid_string_id(example_id): + raise InvalidExampleIDError() + + path = f"/api/v2/examples/{example_id}" + self.t.request("DELETE", path) +``` + +#### 3. Add Custom Errors (`src/pytfe/errors.py`) + +```python +class InvalidExampleIDError(InvalidValues): + """Raised when an invalid example ID is provided.""" + + def __init__(self, message: str = "invalid value for example ID") -> None: + super().__init__(message) +``` + +#### 4. Register in Client (`src/pytfe/client.py`) + +```python +from .resources.example import Examples + +class TFEClient: + def __init__(self, config: TFEConfig | None = None): + # ... existing code ... + self.examples = Examples(self._transport) +``` + +#### 5. Export Models (`src/pytfe/models/__init__.py`) + +```python +from .example import ( + Example, + ExampleCreateOptions, + ExampleList, + ExampleListOptions, + ExampleStatus, + ExampleUpdateOptions, +) + +__all__ = [ + # ... existing exports ... + "Example", + "ExampleCreateOptions", + "ExampleListOptions", + "ExampleStatus", + "ExampleUpdateOptions", +] +``` + +#### 6. Create Tests (`tests/units/test_example.py`) + +```python +from unittest.mock import MagicMock, Mock + +import pytest + +from pytfe import TFEClient, TFEConfig +from pytfe.errors import InvalidExampleIDError, InvalidOrgError +from pytfe.models.example import ( + Example, + ExampleCreateOptions, + ExampleListOptions, + ExampleStatus, + ExampleUpdateOptions, +) + + +class TestExampleModels: + """Test example models and validation.""" + + def test_example_model_basic(self): + """Test basic Example model creation.""" + example = Example( + id="ex-123", + name="test-example", + status=ExampleStatus.ACTIVE, + ) + assert example.id == "ex-123" + assert example.name == "test-example" + assert example.status == ExampleStatus.ACTIVE + + +class TestExampleOperations: + """Test example operations.""" + + @pytest.fixture + def client(self): + """Create a test client.""" + config = TFEConfig(address="https://test.terraform.io", token="test-token") + return TFEClient(config) + + @pytest.fixture + def mock_list_response(self): + """Create a mock list response.""" + mock = Mock() + mock.json.return_value = { + "data": [ + { + "id": "ex-123", + "type": "examples", + "attributes": { + "name": "example1", + "status": "active", + "url": "https://example.com", + }, + } + ], + "meta": { + "pagination": { + "current-page": 1, + "total-pages": 1, + "prev-page": None, + "next-page": None, + "total-count": 1, + } + }, + } + return mock + + def test_list_examples(self, client, mock_list_response): + """Test listing examples.""" + client._transport.request = MagicMock(return_value=mock_list_response) + + examples = list(client.examples.list("test-org")) + + assert len(examples) == 1 + assert examples[0].id == "ex-123" + assert examples[0].name == "example1" + + client._transport.request.assert_called_once_with( + "GET", + "/api/v2/organizations/test-org/examples", + params={"page[number]": 1, "page[size]": 100}, + ) + + def test_list_examples_invalid_org(self, client): + """Test listing examples with invalid organization.""" + with pytest.raises(InvalidOrgError): + list(client.examples.list("")) + + def test_create_example(self, client): + """Test creating an example.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "id": "ex-new", + "type": "examples", + "attributes": { + "name": "new-example", + "url": "https://new.example.com", + }, + } + } + client._transport.request = MagicMock(return_value=mock_response) + + options = ExampleCreateOptions( + name="new-example", url="https://new.example.com" + ) + example = client.examples.create("test-org", options) + + assert example.id == "ex-new" + assert example.name == "new-example" + + def test_read_example_invalid_id(self, client): + """Test reading example with invalid ID.""" + with pytest.raises(InvalidExampleIDError): + client.examples.read("") +``` + +#### 7. Create Example File (`examples/example.py`) + +```python +#!/usr/bin/env python3 +""" +Example Resource Management + +This example demonstrates all available example operations in the Python TFE SDK. +""" + +import os + +from pytfe import TFEClient, TFEConfig +from pytfe.models import ExampleCreateOptions, ExampleListOptions + + +def main(): + """Main function to demonstrate example operations.""" + print("\n" + "=" * 70) + print("Example Resource Management") + print("=" * 70) + + # Initialize client + token = os.getenv("TFE_TOKEN") + if not token: + print("\nError: TFE_TOKEN environment variable not set") + return + + address = os.getenv("TFE_ADDRESS", "https://app.terraform.io") + config = TFEConfig(address=address, token=token) + client = TFEClient(config) + + organization_name = os.getenv("TFE_ORGANIZATION", "your-org-name") + print(f"\nOrganization: {organization_name}") + print(f"API Address: {address}") + print("-" * 70) + + # List examples + print("\n1. Listing Examples:") + try: + examples = list(client.examples.list(organization_name)) + print(f" Found {len(examples)} examples") + for example in examples[:5]: + print(f" - {example.name} (ID: {example.id})") + except Exception as e: + print(f" Error: {e}") + + print("\n" + "=" * 70) + print("Example Resource Management Complete") + print("=" * 70 + "\n") + + +if __name__ == "__main__": + main() +``` + +### Key Conventions + +1. **Models**: Use Pydantic with `Field` for validation and JSON:API alias mapping +2. **Resources**: Inherit from `_Service`, use `self.t.request()` for HTTP calls +3. **Validation**: Use `valid_string_id()` utility and raise appropriate errors +4. **Iterator Pattern**: For list operations, use `self._list()` for auto-pagination +5. **JSON:API Format**: Request/response bodies use `{"data": {"type": "...", "attributes": {...}}}` +6. **Tests**: Mock `client._transport.request`, test all methods and error conditions +7. **Documentation**: Add docstrings with Args/Returns/Yields sections + +## Adding API Changes That Are Not Generally Available + +In general, beta features should not be merged/released until generally available (GA). However, the maintainers recognize almost any reason to release beta features on a case-by-case basis. These could include: partial customer availability, software dependency, or any reason short of feature completeness. + +Beta features, if released, should be clearly documented: + +```python +class Example(BaseModel): + """Represents an example resource.""" + + # Note: This field is still in BETA and subject to change. + example_new_field: bool | None = Field( + None, alias="example-new-field", description="Beta feature" + ) +``` + +When adding test cases, you can temporarily skip beta features to omit them from running in CI: + +```python +@pytest.mark.skip(reason="Beta feature - skip until GA") +def test_beta_feature(self, client): + """Test beta feature.""" + # test logic here +``` + +**Note**: After your PR has been merged, and the feature either reaches general availability, you should remove the skip decorator. + +## Code Style + +- Follow [PEP 8](https://peps.python.org/pep-0008/) style guidelines +- Use type hints throughout (enforced by mypy) +- Use descriptive variable names +- Keep functions focused and single-purpose +- Add docstrings to all public classes and methods +- Use f-strings for string formatting +- Prefer list comprehensions over map/filter when readable + +## Pull Request Checklist + +Before submitting a PR, ensure: + +- [ ] Code is formatted (`make fmt`) +- [ ] Linting passes (`make lint`) +- [ ] Type checking passes (`make type-check`) +- [ ] All tests pass (`make test`) +- [ ] New functionality has unit tests +- [ ] CHANGELOG.md is updated +- [ ] Example file is added/updated (if adding resource) +- [ ] Docstrings are added to new classes/methods + +## Questions? + +Feel free to open an issue for questions about contributing, or reach out to the maintainers for guidance on larger changes. diff --git a/docs/RUN_TASKS_INTEGRATION_EXAMPLE.md b/docs/RUN_TASKS_INTEGRATION_EXAMPLE.md new file mode 100644 index 0000000..1f113be --- /dev/null +++ b/docs/RUN_TASKS_INTEGRATION_EXAMPLE.md @@ -0,0 +1,296 @@ +# Run Tasks Integration Example - Explanation + +## What is `examples/run_tasks_integration.py`? + +It's a **webhook server** that integrates with Terraform Cloud/Enterprise (TFC/TFE) run tasks. This is NOT a test file - it's a fully functional example server that you can deploy and customize. + +--- + +## How It Works: The Complete Flow + +### Step 1: You Start the Server +```bash +python examples/run_tasks_integration.py --port 8888 +``` + +The server starts and waits for incoming webhooks from TFC/TFE. + +### Step 2: Configure in TFC/TFE +You configure a run task in TFC/TFE pointing to your server: +- **URL**: `http://your-server:8888` +- **Stage**: When to run (pre-plan, post-plan, pre-apply, post-apply) +- **Enforcement**: Advisory (warn) or Mandatory (block) + +### Step 3: Someone Triggers a Terraform Run +When a user clicks "Start Run" in TFC/TFE or pushes code: + +``` +User triggers run + ↓ +TFC/TFE prepares the run + ↓ +TFC/TFE sends webhook → http://your-server:8888 +``` + +### Step 4: Your Server Receives the Webhook +The webhook payload contains: +```json +{ + "run_id": "run-abc123", + "workspace_name": "prod-app", + "organization_name": "my-company", + "stage": "pre_plan", + "access_token": "secret-token", + "task_result_callback_url": "https://app.terraform.io/api/v2/task-results/xyz", + ... +} +``` + +### Step 5: Your Server Processes It +```python +# Parse the incoming webhook +request = RunTaskRequest.model_validate(payload) + +# YOUR CUSTOM VALIDATION LOGIC HERE +# Examples: +# - Check if resources have required tags +# - Validate naming conventions +# - Run security scans (Checkov, tfsec, etc.) +# - Check cost estimates +# - Verify compliance policies +# - Check for sensitive data in configs + +result_status = "passed" # or "failed" +result_message = "All checks passed!" +``` + +### Step 6: Your Server Sends Results Back +```python +client.run_tasks_integration.callback( + callback_url=request.task_result_callback_url, + access_token=request.access_token, + options=TaskResultCallbackOptions( + status="passed", # or "failed" + message="All checks passed!", + url="https://your-dashboard.com/results", + outcomes=[ + TaskResultOutcome( + outcome_id="check-1", + description="Security scan passed", + body="No vulnerabilities found", + tags={ + "Status": [TaskResultTag(label="Passed", level="info")], + "Severity": [TaskResultTag(label="Low")] + } + ) + ] + ) +) +``` + +### Step 7: TFC/TFE Receives and Displays Results +In the TFC/TFE UI, users see: +- **Run Task Status**: Passed or Failed +- **Message**: Your custom message +- **Outcomes**: Detailed results with tags +- **Link**: To your detailed results page + +If mandatory and failed → Run is blocked +If advisory and failed → Run continues with warning + +--- + +## Real-World Use Cases + +### Example 1: Cost Control +```python +# Check estimated costs +if estimated_cost > 10000: + result_status = "failed" + result_message = f"Cost ${estimated_cost} exceeds budget limit" +``` + +### Example 2: Production Safety +```python +# Require approval for production +if request.workspace_name.startswith("prod-"): + result_status = "failed" + result_message = "Production changes require manual approval" +``` + +### Example 3: Security Scanning +```python +# Run Checkov security scan +scan_results = run_checkov(request.configuration_version_download_url) +if scan_results.has_critical_issues: + result_status = "failed" + result_message = f"Found {len(scan_results.critical)} critical security issues" +``` + +### Example 4: Tagging Enforcement +```python +# Check if all resources have required tags +if not all_resources_have_tags(config, required_tags=["owner", "project"]): + result_status = "failed" + result_message = "All resources must have 'owner' and 'project' tags" +``` + +### Example 5: Compliance Checking +```python +# Check against compliance policies +if not meets_compliance_standards(config): + result_status = "failed" + result_message = "Configuration violates compliance policy XYZ-123" +``` + +--- + +## What the Example Demonstrates + +The example file shows you how to: + +- **Receive webhooks** from TFC/TFE using a simple HTTP server +- **Parse `RunTaskRequest`** - the webhook payload from TFC/TFE +- **Access run information** - workspace, organization, stage, run ID +- **Add custom validation logic** - where you insert your checks +- **Create detailed outcomes** - with descriptions, tags, and links +- **Send results back** - using the `callback()` method +- **Handle errors gracefully** - proper error handling and responses + +--- + +## Why This Example is Important + +### Without Run Tasks Integration: +- Manual code reviews for every change +- Inconsistent policy enforcement +- Security issues discovered after deployment +- Cost overruns without warnings + +### With Run Tasks Integration: +- Automated validation before apply +- Consistent policy enforcement +- Security issues caught early +- Cost controls built into workflow +- Detailed audit trail +- Custom business logic enforcement + +--- + +## How to Use This Example + +### 1. Basic Usage (Local Testing) +```bash +# Start the server +python examples/run_tasks_integration.py --port 8888 + +# In another terminal, test with mock data +python test_run_tasks_local.py +``` + +### 2. Deploy to Cloud (Real Usage) +```bash +# On your cloud server (EC2, Azure, GCP, etc.) +python examples/run_tasks_integration.py --port 8888 + +# Configure in TFC/TFE: +# URL: http://your-server-ip:8888 +``` + +### 3. Customize the Logic +Edit the example file around line 54-67: +```python +# Replace this section with your custom checks +# Example: Check workspace naming +if not request.workspace_name.startswith(("dev-", "prod-", "staging-")): + result_status = "failed" + result_message = "Workspace must be prefixed with dev-, prod-, or staging-" +``` + +--- + +## Key Components Used + +### 1. `RunTaskRequest` +Parses the incoming webhook from TFC/TFE: +- `run_id` - The Terraform run ID +- `workspace_name` - Which workspace +- `organization_name` - Which organization +- `stage` - When it's running (pre-plan, post-plan, etc.) +- `access_token` - Token for sending callback +- `task_result_callback_url` - Where to send results + +### 2. `TaskResultCallbackOptions` +Defines the result to send back: +- `status` - "passed", "failed", "running" +- `message` - Short summary +- `url` - Link to detailed results (optional) +- `outcomes` - Detailed results list (optional) + +### 3. `TaskResultOutcome` +Individual check result: +- `outcome_id` - Unique identifier +- `description` - What was checked +- `body` - Detailed explanation +- `url` - Link to more info +- `tags` - Categorization (Status, Severity, etc.) + +### 4. `TaskResultTag` +Tag for categorization: +- `label` - Tag name (e.g., "Critical", "Passed") +- `level` - Severity (e.g., "error", "warning", "info") + +### 5. `run_tasks_integration.callback()` +Sends results back to TFC/TFE: +- Uses the callback URL from the webhook +- Authenticates with the access token +- Sends structured result data + +--- + +## Testing Strategy + +### Level 1: Unit Tests +```bash +pytest tests/units/test_run_tasks_integration.py +``` +Tests parsing and validation logic. + +### Level 2: Local Integration +```bash +python test_run_tasks_local.py +``` +Simulates complete flow with mock TFC/TFE server. + +### Level 3: Cloud Deployment +Deploy to EC2/cloud and test with real webhooks. + +### Level 4: Real HCP Terraform +Configure in actual TFC/TFE and trigger real runs. + +--- + +## Summary + +**What it is**: A working webhook server that integrates with TFC/TFE run tasks + +**What it does**: Receives run information, validates it, sends results back + +**Why it's important**: Enables automated policy enforcement and custom validation + +**How to use it**: Deploy the server, configure in TFC/TFE, customize the validation logic + +**Not a test**: It's a functional example you can deploy and use in production! + +--- + +## Next Steps + +1. Review the example code +2. Test locally with `test_run_tasks_local.py` +3. Customize validation logic for your needs +4. Deploy to cloud server +5. Configure in TFC/TFE +6. Monitor and iterate + +**The example gives you everything you need to build your own run tasks integration!** diff --git a/docs/TESTS.md b/docs/TESTS.md index a95f37f..9ddc2af 100644 --- a/docs/TESTS.md +++ b/docs/TESTS.md @@ -1 +1,477 @@ -# Running tests \ No newline at end of file +# Running Tests + +python-tfe includes a comprehensive test suite with unit tests that use mocked API responses. The tests are designed to run quickly without requiring a live HCP Terraform or Terraform Enterprise instance. + +## Quick Start + +```bash +# Install dependencies +make dev-install + +# Run all tests +make test + +# Run with verbose output +python -m pytest -v + +# Run specific test file +python -m pytest tests/units/test_workspaces.py -v + +# Run specific test class or function +python -m pytest tests/units/test_workspaces.py::TestWorkspaceOperations::test_create_workspace_basic -v +``` + +## Test Structure + +Tests are organized in the `tests/units/` directory, with one test file per resource: + +``` +tests/ +├── units/ +│ ├── test_workspaces.py # Workspace tests +│ ├── test_runs.py # Run tests +│ ├── test_variables.py # Variable tests +│ ├── test_organization_tags.py # Organization tags tests +│ └── ... +``` + +Each test file typically contains: +- **Model tests**: Validate Pydantic models and enums +- **Operation tests**: Test CRUD operations with mocked responses +- **Error handling tests**: Validate error conditions +- **Integration tests**: Test complete workflows + +## Test Organization + +Tests follow a consistent structure using pytest classes: + +```python +class TestResourceModels: + """Test model validation and creation.""" + + def test_model_basic(self): + """Test basic model creation.""" + # Test model instantiation and validation + +class TestResourceOperations: + """Test resource operations.""" + + @pytest.fixture + def client(self): + """Create a test client.""" + config = TFEConfig(address="https://test.terraform.io", token="test-token") + return TFEClient(config) + + @pytest.fixture + def mock_response(self): + """Create mock API response.""" + # Return mock response structure + + def test_list_resources(self, client, mock_response): + """Test listing resources.""" + client._transport.request = MagicMock(return_value=mock_response) + # Test the operation + +class TestResourceErrorHandling: + """Test error conditions.""" + + def test_invalid_id_error(self, client): + """Test error handling for invalid IDs.""" + with pytest.raises(InvalidResourceIDError): + client.resources.read("") +``` + +## Writing Tests + +### 1. Create Mock Responses + +Mock API responses follow the JSON:API format: + +```python +@pytest.fixture +def mock_list_response(self): + """Create a mock list response.""" + mock = Mock() + mock.json.return_value = { + "data": [ + { + "id": "ws-123", + "type": "workspaces", + "attributes": { + "name": "my-workspace", + "created-at": "2023-01-01T00:00:00Z", + }, + } + ], + "meta": { + "pagination": { + "current-page": 1, + "total-pages": 1, + "prev-page": None, + "next-page": None, + "total-count": 1, + } + }, + } + return mock +``` + +### 2. Mock the Transport Layer + +Use `MagicMock` to mock the HTTP transport: + +```python +def test_create_workspace(self, client): + """Test creating a workspace.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "id": "ws-new", + "type": "workspaces", + "attributes": {"name": "new-workspace"}, + } + } + + # Mock the transport request method + client._transport.request = MagicMock(return_value=mock_response) + + # Execute the operation + options = WorkspaceCreateOptions(name="new-workspace", organization="test-org") + workspace = client.workspaces.create(options) + + # Assertions + assert workspace.id == "ws-new" + assert workspace.name == "new-workspace" + + # Verify the request was made correctly + client._transport.request.assert_called_once() + call_args = client._transport.request.call_args + assert call_args[0][0] == "POST" # HTTP method + assert "/workspaces" in call_args[0][1] # URL path +``` + +### 3. Test Error Conditions + +Always test validation and error handling: + +```python +def test_create_workspace_invalid_org(self, client): + """Test creating workspace with invalid organization.""" + with pytest.raises(InvalidOrgError): + options = WorkspaceCreateOptions(name="test", organization="") + client.workspaces.create(options) + +def test_read_workspace_invalid_id(self, client): + """Test reading workspace with invalid ID.""" + with pytest.raises(InvalidWorkspaceIDError): + client.workspaces.read(workspace_id="") +``` + +### 4. Test Pagination + +For list operations that use the iterator pattern: + +```python +def test_list_with_pagination(self, client): + """Test listing with pagination.""" + # Setup two pages of responses + page1 = Mock() + page1.json.return_value = { + "data": [{"id": "ws-1", "type": "workspaces", "attributes": {"name": "ws1"}}], + "meta": {"pagination": {"current-page": 1, "total-pages": 2}}, + } + + page2 = Mock() + page2.json.return_value = { + "data": [{"id": "ws-2", "type": "workspaces", "attributes": {"name": "ws2"}}], + "meta": {"pagination": {"current-page": 2, "total-pages": 2}}, + } + + client._transport.request = MagicMock(side_effect=[page1, page2]) + + # List returns an iterator, so convert to list + workspaces = list(client.workspaces.list("test-org")) + + # Should have called request twice (once per page) + assert len(workspaces) == 2 + assert client._transport.request.call_count == 2 +``` + +## Running Tests + +### Run All Tests + +```bash +# Using Makefile +make test + +# Using pytest directly +python -m pytest + +# With verbose output +python -m pytest -v + +# With coverage +python -m pytest --cov=src/pytfe --cov-report=html +``` + +### Run Specific Tests + +```bash +# Run specific file +python -m pytest tests/units/test_workspaces.py + +# Run specific class +python -m pytest tests/units/test_workspaces.py::TestWorkspaceOperations + +# Run specific test +python -m pytest tests/units/test_workspaces.py::TestWorkspaceOperations::test_create_workspace_basic + +# Run tests matching pattern +python -m pytest -k "workspace" -v + +# Run tests matching multiple patterns +python -m pytest -k "create or update" -v +``` + +### Run Tests with Options + +```bash +# Stop on first failure +python -m pytest -x + +# Show local variables in tracebacks +python -m pytest -l + +# Run last failed tests +python -m pytest --lf + +# Run failed tests first, then others +python -m pytest --ff + +# Show test durations +python -m pytest --durations=10 + +# Parallel execution (requires pytest-xdist) +python -m pytest -n auto +``` + +## Test Coverage + +Check test coverage to ensure new code is tested: + +```bash +# Run tests with coverage +python -m pytest --cov=src/pytfe --cov-report=term-missing + +# Generate HTML coverage report +python -m pytest --cov=src/pytfe --cov-report=html + +# Open the HTML report +open htmlcov/index.html +``` + +## Debugging Tests + +### Using Print Statements + +```python +def test_something(self, client): + """Test something.""" + # Use -s flag to see print output + print("Debug info:", some_variable) + assert some_variable == expected +``` + +Run with: `python -m pytest -s tests/units/test_file.py` + +### Using pdb Debugger + +```python +def test_something(self, client): + """Test something.""" + import pdb; pdb.set_trace() # Debugger will stop here + result = client.some_operation() + assert result == expected +``` + +### Using pytest's Built-in Debugger + +```bash +# Drop into debugger on failure +python -m pytest --pdb + +# Drop into debugger at start of each test +python -m pytest --trace +``` + +## Continuous Integration + +Tests run automatically on GitHub Actions for: +- Every push to main branches +- Every pull request +- Scheduled daily runs + +The CI pipeline: +1. Sets up Python 3.11+ environment +2. Installs dependencies +3. Runs linting (ruff, mypy) +4. Runs full test suite +5. Reports coverage + +## Test Best Practices + +### DO: +- Mock all HTTP requests - tests should not hit real APIs +- Test both success and error conditions +- Use descriptive test names that explain what is being tested +- Keep tests independent - each test should be able to run alone +- Use fixtures for common setup code +- Test edge cases and boundary conditions +- Verify request parameters (method, URL, body) in assertions +- Follow the existing test patterns in the codebase + +### DON'T: +- Don't make real API calls in tests +- Don't depend on test execution order +- Don't share state between tests +- Don't use sleep() or time delays +- Don't test implementation details, test behavior +- Don't write overly complex tests - keep them simple and readable + +## Testing Checklist for New Features + +When adding a new resource or endpoint, ensure you have: + +- [ ] Model tests validating all fields and enums +- [ ] Tests for each CRUD operation (Create, Read, Update, Delete, List) +- [ ] Tests for optional parameters and filtering +- [ ] Tests for pagination (if list operation) +- [ ] Tests for all error conditions (invalid IDs, missing required fields, etc.) +- [ ] Tests verifying correct HTTP methods and URL paths +- [ ] Tests verifying request body structure (for POST/PATCH) +- [ ] Tests verifying query parameters (for GET) +- [ ] All tests passing (`make test`) +- [ ] Code coverage above 80% for new code + +## Common Testing Patterns + +### Testing Create Operations + +```python +def test_create_resource(self, client): + """Test creating a resource.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "id": "res-123", + "type": "resources", + "attributes": {"name": "test-resource"}, + } + } + client._transport.request = MagicMock(return_value=mock_response) + + options = ResourceCreateOptions(name="test-resource") + resource = client.resources.create("org-name", options) + + assert resource.id == "res-123" + + # Verify the request + call_args = client._transport.request.call_args + assert call_args[0][0] == "POST" + assert call_args[1]["json_body"]["data"]["type"] == "resources" +``` + +### Testing List Operations + +```python +def test_list_resources(self, client, mock_list_response): + """Test listing resources.""" + client._transport.request = MagicMock(return_value=mock_list_response) + + resources = list(client.resources.list("org-name")) + + assert len(resources) > 0 + assert all(isinstance(r, Resource) for r in resources) +``` + +### Testing Update Operations + +```python +def test_update_resource(self, client): + """Test updating a resource.""" + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "id": "res-123", + "type": "resources", + "attributes": {"name": "updated-name"}, + } + } + client._transport.request = MagicMock(return_value=mock_response) + + options = ResourceUpdateOptions(name="updated-name") + resource = client.resources.update("res-123", options) + + assert resource.name == "updated-name" + + call_args = client._transport.request.call_args + assert call_args[0][0] == "PATCH" +``` + +### Testing Delete Operations + +```python +def test_delete_resource(self, client): + """Test deleting a resource.""" + mock_response = Mock() + mock_response.status_code = 204 + client._transport.request = MagicMock(return_value=mock_response) + + # Should not raise an exception + client.resources.delete("res-123") + + call_args = client._transport.request.call_args + assert call_args[0][0] == "DELETE" + assert "res-123" in call_args[0][1] +``` + +## Troubleshooting + +### Tests Pass Locally But Fail in CI + +- Ensure you're using the same Python version as CI +- Check for environment-specific issues (file paths, etc.) +- Run `make lint` to catch style issues + +### Import Errors + +```bash +# Reinstall in development mode +make dev-install + +# Or manually +pip install -e ".[dev]" +``` + +### Fixture Not Found + +Ensure fixtures are defined in the same test class or in `conftest.py`: + +```python +# In tests/conftest.py for shared fixtures +import pytest +from pytfe import TFEClient, TFEConfig + +@pytest.fixture +def client(): + """Create a test client.""" + config = TFEConfig(address="https://test.terraform.io", token="test-token") + return TFEClient(config) +``` + +## Additional Resources + +- [pytest documentation](https://docs.pytest.org/) +- [unittest.mock documentation](https://docs.python.org/3/library/unittest.mock.html) +- [Python testing best practices](https://docs.python-guide.org/writing/tests/) diff --git a/examples/agent.py b/examples/agent.py index d756abf..c80eb62 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 @@ -81,45 +81,45 @@ def main(): if agent_list: total_agents += len(agent_list) for agent in agent_list: - print(f" - Agent {agent.id}") - print(f" Name: {agent.name or 'Unnamed'}") - print(f" Status: {agent.status}") - print(f" Version: {agent.version or 'Unknown'}") - print(f" IP: {agent.ip_address or 'Unknown'}") - print(f" Last Ping: {agent.last_ping_at or 'Never'}") + print(f"Agent {agent.id}") + print(f"Name: {agent.name or 'Unnamed'}") + print(f"Status: {agent.status}") + print(f"Version: {agent.version or 'Unknown'}") + print(f"IP: {agent.ip_address or 'Unknown'}") + print(f"Last Ping: {agent.last_ping_at or 'Never'}") # Example 3: Read detailed agent information try: agent_details = client.agents.read(agent.id) - print(" ✅ Agent details retrieved successfully") - print(f" Full name: {agent_details.name or 'Unnamed'}") - print(f" Current status: {agent_details.status}") + 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") + 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..bbaf14e 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,39 +76,39 @@ 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}") - print(f" Policy: {pool_details.allowed_workspace_policy}") - print(f" Agent Count: {pool_details.agent_count}") + print(f"Name: {pool_details.name}") + print(f"Organization Scoped: {pool_details.organization_scoped}") + print(f"Policy: {pool_details.allowed_workspace_policy}") + 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 @@ -118,21 +118,21 @@ def main(): print(f" - {token.description or 'No description'} (ID: {token.id})") # Example 7: Clean up - delete the token and pool - print("\n🧹 Cleaning up...") + 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/apply.py b/examples/apply.py index cf280ef..44fd443 100644 --- a/examples/apply.py +++ b/examples/apply.py @@ -39,9 +39,9 @@ def main(): # Display timestamp details if available if apply.status_timestamps: - print(f" Queued At: {apply.status_timestamps.queued_at}") - print(f" Started At: {apply.status_timestamps.started_at}") - print(f" Finished At: {apply.status_timestamps.finished_at}") + print(f"Queued At: {apply.status_timestamps.queued_at}") + print(f"Started At: {apply.status_timestamps.started_at}") + print(f"Finished At: {apply.status_timestamps.finished_at}") except Exception as e: print(f"Error reading apply: {e}") return 1 diff --git a/examples/configuration_version.py b/examples/configuration_version.py index e983e94..87fa6d1 100644 --- a/examples/configuration_version.py +++ b/examples/configuration_version.py @@ -191,33 +191,31 @@ 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:") + print("Recent configuration versions:") for i, cv in enumerate(cv_list[:5], 1): - print(f" {i}. {cv.id}") - print(f" Status: {cv.status}") - print(f" Source: {cv.source}") + print(f"{i}. {cv.id}") + print(f"Status: {cv.status}") + print(f"Source: {cv.source}") if cv.status_timestamps and "queued-at" in cv.status_timestamps: - print(f" Queued at: {cv.status_timestamps['queued-at']}") + print(f"Queued at: {cv.status_timestamps['queued-at']}") elif cv.status_timestamps: first_timestamp = list(cv.status_timestamps.keys())[0] - print( - f" {first_timestamp}: {cv.status_timestamps[first_timestamp]}" - ) + print(f"{first_timestamp}: {cv.status_timestamps[first_timestamp]}") else: - print(" No timestamps available") + print("No timestamps available") # Test with options - print("\n Testing list with options:") + print("\nTesting list with options:") try: list_options = ConfigurationVersionListOptions( include=[ConfigVerIncludeOpt.INGRESS_ATTRIBUTES], page_size=5, # Reduced page size page_number=1, ) - print(f" Making request with include: {list_options.include[0].value}") + print(f"Making request with include: {list_options.include[0].value}") # Add timeout protection by limiting the iterator cv_list_opts = [] @@ -228,18 +226,16 @@ def main(): if count >= 10: # Limit to prevent infinite loop break - print(f" ✓ Found {len(cv_list_opts)} configuration versions with options") - print( - f" Include options: {[opt.value for opt in list_options.include]}" - ) + 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(" This may be expected if the API doesn't support these options") - print(" Basic list functionality still works") + 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() @@ -250,7 +246,7 @@ def main(): print("\n2. Testing create() function:") try: # Test 2a: Create and upload a REAL configuration version that will show in runs - print(" 2a. Creating REAL NON-SPECULATIVE configuration version:") + print("2a. Creating REAL NON-SPECULATIVE configuration version:") create_options = ConfigurationVersionCreateOptions( auto_queue_runs=True, # This will create a run automatically speculative=False, # This will make it appear in workspace runs @@ -258,23 +254,23 @@ 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" 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)") - print(f" Upload URL available: {bool(new_cv.upload_url)}") + 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)") + print(f"Upload URL available: {bool(new_cv.upload_url)}") # UPLOAD REAL TERRAFORM CODE IMMEDIATELY if new_cv.upload_url: - print("\n → Uploading real Terraform configuration...") + print("\nUploading real Terraform configuration...") with tempfile.TemporaryDirectory() as temp_dir: - print(f" Creating Terraform files in: {temp_dir}") + print(f"Creating Terraform files in: {temp_dir}") create_test_terraform_configuration(temp_dir) # List created files files = os.listdir(temp_dir) - print(f" Created {len(files)} Terraform files:") + print(f"Created {len(files)} Terraform files:") for filename in sorted(files): filepath = os.path.join(temp_dir, filename) size = os.path.getsize(filepath) @@ -282,7 +278,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,43 +292,41 @@ 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("\nChecking 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}") + 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(" (Upload may still be processing)") + 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:") + print("\n 2b. Creating standard configuration version for upload tests:") standard_options = ConfigurationVersionCreateOptions( auto_queue_runs=False, speculative=False ) @@ -341,24 +335,24 @@ 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" Status: {standard_cv.status}") - print(f" Speculative: {standard_cv.speculative}") - print(f" Auto-queue runs: {standard_cv.auto_queue_runs}") + 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}") # Test 2c: Create with auto-queue runs (will trigger run when uploaded) - print("\n 2c. Creating configuration version with auto-queue:") + print("\n 2c. Creating configuration version with auto-queue:") auto_options = ConfigurationVersionCreateOptions( auto_queue_runs=True, speculative=False ) auto_cv = client.configuration_versions.create(workspace_id, auto_options) - 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(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") except Exception as e: - print(f" ✗ Error: {e}") + print(f"Error: {e}") import traceback traceback.print_exc() @@ -371,29 +365,25 @@ def main(): try: cv_details = client.configuration_versions.read(created_cv_id) - print(f" ✓ Read configuration version: {cv_details.id}") - print(f" Status: {cv_details.status}") - print(f" Source: {cv_details.source}") + 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: - print( - f" Status timestamps: {list(cv_details.status_timestamps.keys())}" - ) + print(f"Status timestamps: {list(cv_details.status_timestamps.keys())}") if "queued-at" in cv_details.status_timestamps: - print( - f" Queued at: {cv_details.status_timestamps['queued-at']}" - ) + print(f"Queued at: {cv_details.status_timestamps['queued-at']}") else: - print(" No status timestamps available") - print(f" Auto-queue runs: {cv_details.auto_queue_runs}") - print(f" Speculative: {cv_details.speculative}") + print("No status timestamps available") + print(f"Auto-queue runs: {cv_details.auto_queue_runs}") + print(f"Speculative: {cv_details.speculative}") if cv_details.upload_url: - print(f" Upload URL: {cv_details.upload_url[:60]}...") + print(f"Upload URL: {cv_details.upload_url[:60]}...") else: - print(" Upload URL: None") + print("Upload URL: None") # Test field validation - print("\n Field validation:") + print("\n Field validation:") required_fields = [ "id", "status", @@ -405,12 +395,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() @@ -428,59 +418,59 @@ def main(): ) fresh_cv = client.configuration_versions.create(workspace_id, upload_options) - print(f" Created fresh CV for upload: {fresh_cv.id}") + print(f"Created fresh CV for upload: {fresh_cv.id}") upload_url = fresh_cv.upload_url if not upload_url: - print(" ⚠ No upload URL available for this configuration version") - print(" Configuration version may not be in uploadable state") + 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: - print(f" Creating test configuration in: {temp_dir}") + print(f"Creating test configuration in: {temp_dir}") create_test_terraform_configuration(temp_dir) # List created files files = os.listdir(temp_dir) - print(f" Created {len(files)} files:") + print(f"Created {len(files)} files:") for filename in sorted(files): filepath = os.path.join(temp_dir, filename) size = os.path.getsize(filepath) print(f" - {filename} ({size} bytes)") - print(f"\n Uploading configuration to CV: {fresh_cv.id}") - print(f" Upload URL: {upload_url[:60]}...") + print(f"\n Uploading configuration to CV: {fresh_cv.id}") + print(f"Upload URL: {upload_url[:60]}...") try: client.configuration_versions.upload(upload_url, temp_dir) - print(" ✓ Configuration uploaded successfully!") + print("Configuration uploaded successfully!") # Check status after upload - print("\n Checking status after upload:") + print("\n Checking status after upload:") time.sleep(3) # Give TFE time to process updated_cv = client.configuration_versions.read(fresh_cv.id) - print(f" Status after upload: {updated_cv.status}") + 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(" Install with: pip install go-slug") + print("go-slug package not available") + print("Install with: pip install go-slug") print( - " Upload function exists but requires go-slug for packaging" + "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() @@ -494,7 +484,7 @@ def main(): cv_generator = client.configuration_versions.list(workspace_id) downloadable_cvs = [] - print(" Scanning for downloadable configuration versions:") + print("Scanning for downloadable configuration versions:") # Convert generator to list and limit to avoid infinite loop cv_list = [] count = 0 @@ -505,46 +495,46 @@ def main(): break for cv in cv_list: - print(f" CV {cv.id}: Status = {cv.status}") + print(f"CV {cv.id}: Status = {cv.status}") if cv.status.value in ["uploaded", "archived"]: downloadable_cvs.append(cv) if not downloadable_cvs: - print(" ⚠ No uploaded configuration versions found to download") - print(" This is not a test failure - upload a configuration first") + 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] - print(f"\n Downloading CV: {downloadable_cv.id}") - print(f" Status: {downloadable_cv.status}") + print(f"\n Downloading CV: {downloadable_cv.id}") + 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:") + 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: - print("\n Testing multiple downloads:") + print("\n Testing multiple downloads:") 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,19 +558,19 @@ 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" + "This is not a test failure - create more configuration versions first" ) else: # Find suitable candidates for archiving archivable_cvs = [] already_archived = [] - print(" Scanning configuration versions for archiving:") + print("Scanning configuration versions for archiving:") for cv in cv_list: - print(f" CV {cv.id}: Status = {cv.status}") + print(f"CV {cv.id}: Status = {cv.status}") if cv.status.value == "archived": already_archived.append(cv) elif cv.status.value in ["uploaded", "errored", "pending"]: @@ -598,62 +588,56 @@ def main(): if candidates: cv_to_archive = candidates[0] # Pick an older uploaded CV - print(f"\n Attempting to archive CV: {cv_to_archive.id}") - print(f" Current status: {cv_to_archive.status}") - print(" (Skipping most recent uploaded CV to avoid 'current' error)") + print(f"\n Attempting to archive CV: {cv_to_archive.id}") + print(f"Current status: {cv_to_archive.status}") + print("(Skipping most recent uploaded CV to avoid 'current' error)") 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:") + print("\n Checking status after archive request:") time.sleep(3) try: updated_cv = client.configuration_versions.read( cv_to_archive.id ) - print(f" Status after archive: {updated_cv.status}") + 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)" - ) + print("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( - " ✓ Function correctly handles 'current' CV restriction" - ) + print("Cannot archive current configuration version") + print("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( - " Need at least 2 uploaded CVs (to avoid archiving current one)" - ) - print(" ✓ Function correctly validates archivable CVs") + 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") # Test archiving already archived CV if already_archived: - print("\n Testing archive of already archived CV:") + print("\n Testing archive of already archived CV:") already_archived_cv = already_archived[0] - print(f" CV ID: {already_archived_cv.id} (already archived)") + print(f"CV ID: {already_archived_cv.id} (already archived)") 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,33 +657,31 @@ def main(): created_cv_id, read_options ) - 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}") + 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}") if ( 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}") + print(f"Branch: {cv_with_options.ingress_attributes.branch}") if hasattr(cv_with_options.ingress_attributes, "clone_url"): - print( - f" Clone URL: {cv_with_options.ingress_attributes.clone_url}" - ) + print(f"Clone URL: {cv_with_options.ingress_attributes.clone_url}") else: - print(" ⚠ No ingress attributes (expected for API-created CVs)") - print(" Ingress attributes are only present for VCS-connected 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) @@ -716,37 +698,33 @@ def main(): "provider": "aws", } - print(" Testing registry module configuration version creation:") - print(f" Module ID: {module_id}") + print("Testing registry module configuration version creation:") + print(f"Module ID: {module_id}") try: registry_cv = client.configuration_versions.create_for_registry_module( module_id ) - print(f" ✓ Created registry module CV: {registry_cv.id}") - print(f" Status: {registry_cv.status}") - print(f" Source: {registry_cv.source}") + 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)" - ) - print(" Function exists and properly handles missing modules") + print("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(" Function exists and properly handles permission errors") + 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(" Function exists but may need parameter adjustment") + 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}" - ) - print(" This may be expected if no registry modules exist") + print(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() @@ -768,8 +746,8 @@ def main(): upload_url = upload_test_cv.upload_url if upload_url: - print(f" Created CV for upload test: {upload_test_cv_id}") - print(f" Upload URL available: {bool(upload_url)}") + print(f"Created CV for upload test: {upload_test_cv_id}") + print(f"Upload URL available: {bool(upload_url)}") # Create a simple tar.gz archive in memory for testing import tarfile @@ -786,32 +764,30 @@ def main(): tar.add(test_file, arcname="main.tf") archive_buffer.seek(0) - print( - f" Created test archive: {len(archive_buffer.getvalue())} bytes" - ) + print(f"Created test archive: {len(archive_buffer.getvalue())} bytes") # Test direct tar.gz upload try: 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) updated_upload_cv = client.configuration_versions.read( upload_test_cv_id ) - print(f" Status after upload: {updated_upload_cv.status}") + print(f"Status after upload: {updated_upload_cv.status}") except Exception as e: - print(f" ⚠ Upload failed: {type(e).__name__}: {e}") - print(" This may be expected depending on TFE configuration") + 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() @@ -825,38 +801,38 @@ def main(): # on non-Enterprise installations, but we test that the functions exist if created_cv_id: - print(f" Testing with CV: {created_cv_id}") + print(f"Testing with CV: {created_cv_id}") # Test soft delete backing data - print("\n 10a. Testing soft_delete_backing_data():") + 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():") + 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():") + print("\n 10c. Testing permanently_delete_backing_data():") try: # Create a separate CV for this destructive test perm_delete_options = ConfigurationVersionCreateOptions( @@ -871,15 +847,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(" sFunction exists and properly handles Enterprise restrictions") # ===================================================== # TEST SUMMARY @@ -887,20 +863,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..789367f 100644 --- a/examples/notification_configuration.py +++ b/examples/notification_configuration.py @@ -54,9 +54,9 @@ def main(): f"Found {len(workspace_notifications.items)} notification configurations" ) for nc in workspace_notifications.items: - print(f" - {nc.name} (ID: {nc.id}, Enabled: {nc.enabled})") + print(f"- {nc.name} (ID: {nc.id}, Enabled: {nc.enabled})") except Exception as e: - print(f" Error listing workspace notifications: {e}") + print(f"Error listing workspace notifications: {e}") print() @@ -76,14 +76,14 @@ def main(): f"Found {len(team_notifications.items)} team notification configurations" ) for nc in team_notifications.items: - print(f" - {nc.name} (ID: {nc.id}, Enabled: {nc.enabled})") + print(f"- {nc.name} (ID: {nc.id}, Enabled: {nc.enabled})") 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() @@ -113,7 +113,7 @@ def main(): workspace_id, create_options ) print( - f" Created notification: {new_notification.name} (ID: {new_notification.id})" + f"Created notification: {new_notification.name} (ID: {new_notification.id})" ) notification_id = new_notification.id @@ -123,10 +123,10 @@ def main(): read_notification = client.notification_configurations.read( notification_config_id=notification_id ) - print(f" Read notification: {read_notification.name}") - print(f" Destination type: {read_notification.destination_type}") - print(f" Enabled: {read_notification.enabled}") - print(f" Triggers: {read_notification.triggers}") + print(f"Read notification: {read_notification.name}") + print(f"Destination type: {read_notification.destination_type}") + print(f"Enabled: {read_notification.enabled}") + print(f"Triggers: {read_notification.triggers}") # ===== Update the notification configuration ===== print("\n5. Updating the notification configuration...") @@ -139,24 +139,22 @@ def main(): updated_notification = client.notification_configurations.update( notification_config_id=notification_id, options=update_options ) - print(f" Updated notification: {updated_notification.name}") - print(f" Enabled: {updated_notification.enabled}") + print(f"Updated notification: {updated_notification.name}") + print(f"Enabled: {updated_notification.enabled}") # ===== Verify the notification configuration ===== print("\n6. Verifying the notification configuration...") - print(" Note: This will fail with fake URLs - that's expected!") + print("Note: This will fail with fake URLs - that's expected!") try: client.notification_configurations.verify( notification_config_id=notification_id ) - print( - f" ✅ Verification successful for notification ID: {notification_id}" - ) - print(" Note: Verification sends a test payload to the configured URL") + print(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 ===== @@ -164,29 +162,27 @@ def main(): client.notification_configurations.delete( notification_config_id=notification_id ) - print(f" Deleted notification configuration: {notification_id}") + print(f"Deleted notification configuration: {notification_id}") # Verify deletion try: client.notification_configurations.read( notification_config_id=notification_id ) - print(" ERROR: Notification still exists after deletion!") + print("ERROR: Notification still exists after deletion!") except Exception: - print(" Confirmed: Notification configuration has been deleted") + print("Confirmed: Notification configuration has been deleted") except Exception as e: error_msg = str(e).lower() if "verification failed" in error_msg and "404" in error_msg: - print(" ⚠️ Webhook verification failed (expected with fake URL)") - print( - " 💡 The fake Slack URL returns 404 - this is normal for testing" - ) - print(" 🔗 To test real verification, use a webhook from:") - print(" • webhook.site (instant test URL)") - print(" • Slack, Teams, or Discord webhook") + 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() @@ -215,28 +211,28 @@ def main(): team_id, team_create_options ) print( - f" Created team notification: {team_notification.name} (ID: {team_notification.id})" + f"Created team notification: {team_notification.name} (ID: {team_notification.id})" ) # Clean up team notification client.notification_configurations.delete( notification_config_id=team_notification.id ) - print(f" Cleaned up team notification: {team_notification.id}") + print(f"Cleaned up team notification: {team_notification.id}") else: print( - f" Skipping team notifications - no real team ID available (using: {team_id})" + f"Skipping team notifications - no real team ID available (using: {team_id})" ) 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() @@ -265,17 +261,17 @@ def main(): workspace_id, teams_create_options ) print( - f" Created Teams notification: {teams_notification.name} (ID: {teams_notification.id})" + f"Created Teams notification: {teams_notification.name} (ID: {teams_notification.id})" ) # Clean up Teams notification client.notification_configurations.delete( notification_config_id=teams_notification.id ) - print(f" Cleaned up Teams notification: {teams_notification.id}") + print(f"Cleaned up Teams notification: {teams_notification.id}") except Exception as e: - print(f" Error in Teams notification operations: {e}") + print(f"Error in Teams notification operations: {e}") except Exception as e: print(f"Error: {e}") diff --git a/examples/oauth_client.py b/examples/oauth_client.py index 96671dd..e9cf62a 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,13 +89,13 @@ 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}") + print(f"{i}. {oauth_client.id} - {oauth_client.service_provider}") if oauth_client.name: - print(f" Name: {oauth_client.name}") - print(f" Service Provider: {oauth_client.service_provider_name}") + print(f"Name: {oauth_client.name}") + print(f"Service Provider: {oauth_client.service_provider_name}") # Test list with options if len(oauth_clients) > 0: @@ -110,21 +110,19 @@ def main(): oauth_clients_with_options = list( client.oauth_clients.list(organization_name, options) ) - print( - f" ✓ Found {len(oauth_clients_with_options)} OAuth clients with options" - ) + print(f"Found {len(oauth_clients_with_options)} OAuth clients with options") if oauth_clients_with_options: first_client = oauth_clients_with_options[0] print( - f" First client includes - OAuth Tokens: {len(first_client.oauth_tokens or [])}" + f"First client includes - OAuth Tokens: {len(first_client.oauth_tokens or [])}" ) print( f" - Projects: {len(first_client.projects or [])}" ) except Exception as e: - print(f" ✗ Error listing OAuth clients: {e}") + print(f"Error listing OAuth clients: {e}") # ===================================================== # TEST 2: CREATE OAUTH CLIENT @@ -152,19 +150,17 @@ def main(): created_oauth_client = client.oauth_clients.create( organization_name, create_options ) - 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}") - print(f" HTTP URL: {created_oauth_client.http_url}") - print( - f" Organization Scoped: {created_oauth_client.organization_scoped}" - ) + 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}") + print(f"HTTP URL: {created_oauth_client.http_url}") + print(f"Organization Scoped: {created_oauth_client.organization_scoped}") 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,15 +174,15 @@ 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" Name: {read_oauth_client.name}") - print(f" Service Provider: {read_oauth_client.service_provider}") - print(f" Created At: {read_oauth_client.created_at}") - print(f" Callback URL: {read_oauth_client.callback_url}") - print(f" Connect Path: {read_oauth_client.connect_path}") + 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}") + print(f"Callback URL: {read_oauth_client.callback_url}") + 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 +192,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" Service Provider: {read_oauth_client.service_provider}") + 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,20 +230,20 @@ 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" OAuth Tokens: {len(read_oauth_client.oauth_tokens or [])}") - print(f" Projects: {len(read_oauth_client.projects or [])}") + 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 [])}") if read_oauth_client.oauth_tokens: - print(" OAuth Token details:") + print(" OAuth Token details:") for i, token in enumerate(read_oauth_client.oauth_tokens[:2], 1): if isinstance(token, dict): - print(f" {i}. Token ID: {token.get('id', 'N/A')}") + 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,8 +264,8 @@ 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 Name: {updated_oauth_client.name}") + 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 +274,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 +294,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 +302,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 +323,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 +334,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 +363,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 +374,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 +399,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}") + 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 +428,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..4a31bfc 100644 --- a/examples/oauth_token.py +++ b/examples/oauth_token.py @@ -30,7 +30,7 @@ from pytfe import TFEClient, TFEConfig from pytfe.errors import NotFound -from pytfe.models import OAuthTokenListOptions, OAuthTokenUpdateOptions +from pytfe.models import OAuthTokenUpdateOptions def main(): @@ -55,41 +55,25 @@ def main(): # ===================================================== print("\n1. Testing list() function:") try: - # Test basic list without options - token_list = client.oauth_tokens.list(organization_name) - 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 - print(f" {i}. Token ID: {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}") - print(f" Created: {token.created_at}") + for token in client.oauth_tokens.list(organization_name): + print(f"Token ID: {token.id}") + print(f"Service Provider User: {token.service_provider_user}") + print(f"Has SSH Key: {token.has_ssh_key}") + print(f"Created: {token.created_at}") if token.oauth_client: - print(f" OAuth Client: {token.oauth_client.id}") - - # Store first token for subsequent tests - if token_list.items: - test_token_id = token_list.items[0].id - print(f"\n Using token {test_token_id} for subsequent tests") - - # Test list with options - 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") - if token_list_with_options.current_page: - print(f" Current page: {token_list_with_options.current_page}") - if token_list_with_options.total_count: - print(f" Total count: {token_list_with_options.total_count}") + print(f"OAuth Client: {token.oauth_client.id}") + + # Store first token for subsequent tests + if token and not test_token_id: + test_token_id = token.id + print(f"\n Using token {test_token_id} for subsequent tests \n") 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,19 +82,18 @@ 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" UID: {token.uid}") - print(f" Service Provider User: {token.service_provider_user}") - print(f" Has SSH Key: {token.has_ssh_key}") - print(f" Created: {token.created_at}") + print(f"Read OAuth token: {token.id}") + print(f"Service Provider User: {token.service_provider_user}") + print(f"Has SSH Key: {token.has_ssh_key}") + print(f"Created: {token.created_at}") if token.oauth_client: - print(f" OAuth Client: {token.oauth_client.id}") + 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 @@ -119,29 +102,29 @@ def main(): print("\n3. Testing update() function:") try: # Test updating with SSH key - print(" Testing update with SSH key...") + print("Testing update with SSH key...") ssh_key = """-----BEGIN RSA PRIVATE KEY----- -----END RSA PRIVATE KEY-----""" 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" Has SSH Key after update: {updated_token.has_ssh_key}") + 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" + "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 @@ -152,44 +135,44 @@ def main(): delete_token_id = "ot-WQf5ARHA1Qxzo9d4" try: - print(f" Attempting to delete OAuth token: {delete_token_id}") + 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}") + 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! """ if test_token_id: try: - print(f" Attempting to delete OAuth token: {test_token_id}") + 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}") + 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 +182,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..6538f8d 100644 --- a/examples/org.py +++ b/examples/org.py @@ -16,21 +16,21 @@ 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): - print(f" {i:2d}. {org.name} (ID: {org.id})") + print(f"{i:2d}. {org.name} (ID: {org.id})") if org.email: - print(f" Email: {org.email}") + print(f"Email: {org.email}") if len(orgs) > 5: - print(f" ... and {len(orgs) - 5} more") + print(f"... and {len(orgs) - 5} more") return orgs[0].name if orgs else None # Return first org name for testing except Exception as e: - print(f" ✗ Error listing organizations: {e}") + print(f"Error listing organizations: {e}") return None @@ -42,63 +42,63 @@ 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" 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}") + 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(f" Pending runs: {capacity.pending}") - print(f" Running runs: {capacity.running}") - print(f" Total active: {capacity.pending + capacity.running}") + 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(f" Operations: {entitlements.operations}") - print(f" Teams: {entitlements.teams}") - print(f" State Storage: {entitlements.state_storage}") - print(f" VCS Integrations: {entitlements.vcs_integrations}") - print(f" Cost Estimation: {entitlements.cost_estimation}") - print(f" Sentinel: {entitlements.sentinel}") - print(f" Private Module Registry: {entitlements.private_module_registry}") - print(f" SSO: {entitlements.sso}") + print("Entitlements:") + print(f"Operations: {entitlements.operations}") + print(f"Teams: {entitlements.teams}") + print(f"State Storage: {entitlements.state_storage}") + print(f"VCS Integrations: {entitlements.vcs_integrations}") + print(f"Cost Estimation: {entitlements.cost_estimation}") + print(f"Sentinel: {entitlements.sentinel}") + print(f"Private Module Registry: {entitlements.private_module_registry}") + print(f"SSO: {entitlements.sso}") except Exception as e: - print(f" ✗ Error reading entitlements: {e}") + print(f"Error reading entitlements: {e}") # Test run queue print("\n4. Reading Organization Run Queue:") try: queue_options = ReadRunQueueOptions(page_number=1, page_size=10) run_queue = client.organizations.read_run_queue(org_name, queue_options) - print(" ✓ Run Queue:") - print(f" Items in queue: {len(run_queue.items)}") + print("Run Queue:") + print(f"Items in queue: {len(run_queue.items)}") if run_queue.pagination: - print(f" Current page: {run_queue.pagination.current_page}") - print(f" Total count: {run_queue.pagination.total_count}") + print(f"Current page: {run_queue.pagination.current_page}") + print(f"Total count: {run_queue.pagination.total_count}") # Show details of first few runs for i, run in enumerate(run_queue.items[:3], 1): - print(f" Run {i}: ID={run.id}, Status={run.status}") + print(f"Run {i}: ID={run.id}, Status={run.status}") if len(run_queue.items) > 3: - print(f" ... and {len(run_queue.items) - 3} more runs") + 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(f" ID: {policy.id}") - print(f" Delete after: {policy.delete_older_than_n_days} days") + 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(f" ID: {policy.id}") - print(f" Delete after: {policy.delete_older_than_n_days} days") + 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,57 @@ 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(f" ID: {policy.id}") - print(" Data will never be automatically deleted") + 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" - ) + print(f"Legacy representation: {legacy.delete_older_than_n_days} days") except Exception as e: if "not found" in str(e).lower() or "404" in str(e): - print(" ⚠ Feature not available (Terraform Enterprise only)") + 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,41 +237,39 @@ 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" ID: {new_org.id}") - print(f" Email: {new_org.email}") + 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( - " This is normal if you don't have organization management permissions" - ) + print(f"Organization creation/deletion test skipped: {e}") + print("This is normal if you don't have organization management permissions") return False def main(): """Main function to test all organization functionalities.""" - print("🚀 Python TFE Organization Functions Test Suite") + print("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 +278,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 +292,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..da6d45d --- /dev/null +++ b/examples/organization_membership.py @@ -0,0 +1,314 @@ +#!/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)") + 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") + 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") + 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}") + 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/policy.py b/examples/policy.py index d196597..74352f4 100644 --- a/examples/policy.py +++ b/examples/policy.py @@ -137,11 +137,11 @@ def main(): try: policy = client.policies.create(args.org, create_options) print(f"Created policy: {policy.id}") - print(f" Name: {policy.name}") - print(f" Kind: {policy.kind}") - print(f" Enforcement: {policy.enforcement_level}") + print(f"Name: {policy.name}") + print(f"Kind: {policy.kind}") + print(f"Enforcement: {policy.enforcement_level}") if policy.query: - print(f" Query: {policy.query}") + print(f"Query: {policy.query}") existing_policy = policy except Exception as e: print(f"Error creating policy: {e}") @@ -240,8 +240,8 @@ def main(): updated_policy = client.policies.update(existing_policy.id, update_options) print(f"Updated policy: {updated_policy.id}") - print(f" New description: {updated_policy.description}") - print(f" Enforcement level: {updated_policy.enforcement_level}") + print(f"New description: {updated_policy.description}") + print(f"Enforcement level: {updated_policy.enforcement_level}") except Exception as e: print(f"Error updating policy: {e}") diff --git a/examples/policy_check.py b/examples/policy_check.py index 771a576..67f2081 100644 --- a/examples/policy_check.py +++ b/examples/policy_check.py @@ -66,17 +66,17 @@ def main(): else: for pc in pc_list.items: print(f"- ID: {pc.id}") - print(f" Status: {pc.status}") - print(f" Scope: {pc.scope}") + print(f"Status: {pc.status}") + print(f"Scope: {pc.scope}") if pc.result: print( - f" Result: passed={pc.result.passed}, failed={pc.result.total_failed}" + f"Result: passed={pc.result.passed}, failed={pc.result.total_failed}" ) - print(f" Duration: {pc.result.duration}ms") + print(f"Duration: {pc.result.duration}ms") if pc.actions: - print(f" Can Override: {pc.actions.is_overridable}") + print(f"Can Override: {pc.actions.is_overridable}") if pc.permissions: - print(f" Has Override Permission: {pc.permissions.can_override}") + print(f"Has Override Permission: {pc.permissions.can_override}") print() except Exception as e: @@ -96,34 +96,34 @@ def main(): if pc.result: print("Result Summary:") - print(f" - Passed: {pc.result.passed}") - print(f" - Hard Failed: {pc.result.hard_failed}") - print(f" - Soft Failed: {pc.result.soft_failed}") - print(f" - Advisory Failed: {pc.result.advisory_failed}") - print(f" - Total Failed: {pc.result.total_failed}") - print(f" - Duration: {pc.result.duration}ms") - print(f" - Overall Result: {pc.result.result}") + print(f"- Passed: {pc.result.passed}") + print(f"- Hard Failed: {pc.result.hard_failed}") + print(f"- Soft Failed: {pc.result.soft_failed}") + print(f"- Advisory Failed: {pc.result.advisory_failed}") + print(f"- Total Failed: {pc.result.total_failed}") + print(f"- Duration: {pc.result.duration}ms") + print(f"- Overall Result: {pc.result.result}") if pc.actions: print("Actions:") - print(f" - Is Overridable: {pc.actions.is_overridable}") + print(f"- Is Overridable: {pc.actions.is_overridable}") if pc.permissions: print("Permissions:") - print(f" - Can Override: {pc.permissions.can_override}") + print(f"- Can Override: {pc.permissions.can_override}") if pc.status_timestamps: print("Status Timestamps:") if pc.status_timestamps.queued_at: - print(f" - Queued At: {pc.status_timestamps.queued_at}") + print(f"- Queued At: {pc.status_timestamps.queued_at}") if pc.status_timestamps.passed_at: - print(f" - Passed At: {pc.status_timestamps.passed_at}") + print(f"- Passed At: {pc.status_timestamps.passed_at}") if pc.status_timestamps.soft_failed_at: - print(f" - Soft Failed At: {pc.status_timestamps.soft_failed_at}") + print(f"- Soft Failed At: {pc.status_timestamps.soft_failed_at}") if pc.status_timestamps.hard_failed_at: - print(f" - Hard Failed At: {pc.status_timestamps.hard_failed_at}") + print(f"- Hard Failed At: {pc.status_timestamps.hard_failed_at}") if pc.status_timestamps.errored_at: - print(f" - Errored At: {pc.status_timestamps.errored_at}") + print(f"- Errored At: {pc.status_timestamps.errored_at}") except Exception as e: print(f"Error reading policy check: {e}") diff --git a/examples/policy_evaluation.py b/examples/policy_evaluation.py index 9a0bd05..d7cb2fd 100644 --- a/examples/policy_evaluation.py +++ b/examples/policy_evaluation.py @@ -26,7 +26,6 @@ def main(): required=True, help="Task stage ID to list policy evaluations for", ) - parser.add_argument("--page", type=int, default=1) parser.add_argument("--page-size", type=int, default=20) args = parser.parse_args() @@ -41,62 +40,55 @@ def main(): _print_header(f"Listing policy evaluations for task stage: {args.task_stage_id}") options = PolicyEvaluationListOptions( - page_number=args.page, page_size=args.page_size, ) try: - pe_list = client.policy_evaluations.list(args.task_stage_id, options) - - print(f"Total policy evaluations: {pe_list.total_count}") - print(f"Page {pe_list.current_page} of {pe_list.total_pages}") - print() - - if not pe_list.items: + pe_count = 0 + for pe in client.policy_evaluations.list(args.task_stage_id, options): + pe_count += 1 + print(f"- ID: {pe.id}") + print(f"Status: {pe.status}") + print(f"Policy Kind: {pe.policy_kind}") + + if pe.result_count: + print(" Result Count:") + if pe.result_count.passed is not None: + print(f"- Passed: {pe.result_count.passed}") + if pe.result_count.advisory_failed is not None: + print(f"- Advisory Failed: {pe.result_count.advisory_failed}") + if pe.result_count.mandatory_failed is not None: + print(f"- Mandatory Failed: {pe.result_count.mandatory_failed}") + if pe.result_count.errored is not None: + print(f"- Errored: {pe.result_count.errored}") + + if pe.status_timestamp: + print(" Status Timestamps:") + if pe.status_timestamp.passed_at: + print(f"- Passed At: {pe.status_timestamp.passed_at}") + if pe.status_timestamp.failed_at: + print(f"- Failed At: {pe.status_timestamp.failed_at}") + if pe.status_timestamp.running_at: + print(f"- Running At: {pe.status_timestamp.running_at}") + if pe.status_timestamp.canceled_at: + print(f"- Canceled At: {pe.status_timestamp.canceled_at}") + if pe.status_timestamp.errored_at: + print(f"- Errored At: {pe.status_timestamp.errored_at}") + + if pe.policy_attachable: + print(f"Task Stage: {pe.task_stage.id} ({pe.task_stage.type})") + + if pe.created_at: + print(f"Created At: {pe.created_at}") + if pe.updated_at: + print(f"Updated At: {pe.updated_at}") + + print() + + if pe_count == 0: print("No policy evaluations found for this task stage.") else: - for pe in pe_list.items: - print(f"- ID: {pe.id}") - print(f" Status: {pe.status}") - print(f" Policy Kind: {pe.policy_kind}") - - if pe.result_count: - print(" Result Count:") - if pe.result_count.passed is not None: - print(f" - Passed: {pe.result_count.passed}") - if pe.result_count.advisory_failed is not None: - print( - f" - Advisory Failed: {pe.result_count.advisory_failed}" - ) - if pe.result_count.mandatory_failed is not None: - print( - f" - Mandatory Failed: {pe.result_count.mandatory_failed}" - ) - if pe.result_count.errored is not None: - print(f" - Errored: {pe.result_count.errored}") - - if pe.status_timestamp: - print(" Status Timestamps:") - if pe.status_timestamp.passed_at: - print(f" - Passed At: {pe.status_timestamp.passed_at}") - if pe.status_timestamp.failed_at: - print(f" - Failed At: {pe.status_timestamp.failed_at}") - if pe.status_timestamp.running_at: - print(f" - Running At: {pe.status_timestamp.running_at}") - if pe.status_timestamp.canceled_at: - print(f" - Canceled At: {pe.status_timestamp.canceled_at}") - if pe.status_timestamp.errored_at: - print(f" - Errored At: {pe.status_timestamp.errored_at}") - - if pe.task_stage: - print(f" Task Stage: {pe.task_stage.id} ({pe.task_stage.type})") - - if pe.created_at: - print(f" Created At: {pe.created_at}") - if pe.updated_at: - print(f" Updated At: {pe.updated_at}") - - print() + print(f"\nTotal: {pe_count} policy evaluations") except Exception as e: print(f"Error listing policy evaluations: {e}") diff --git a/examples/policy_set.py b/examples/policy_set.py index f6da973..1808d80 100644 --- a/examples/policy_set.py +++ b/examples/policy_set.py @@ -177,9 +177,9 @@ def main(): f"- ID: {ps.id} | Name: {ps.name} | Kind: {ps.kind} | Global: {ps.Global}" ) print( - f" Policy Count: {ps.policy_count} | Workspace Count: {ps.workspace_count}" + f"Policy Count: {ps.policy_count} | Workspace Count: {ps.workspace_count}" ) - print(f" Created: {ps.created_at}") + print(f"Created: {ps.created_at}") print() except Exception as e: diff --git a/examples/policy_set_parameter.py b/examples/policy_set_parameter.py new file mode 100644 index 0000000..9ffdf71 --- /dev/null +++ b/examples/policy_set_parameter.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import argparse +import os + +from pytfe import TFEClient, TFEConfig +from pytfe.models import ( + PolicySetParameterCreateOptions, + PolicySetParameterListOptions, + PolicySetParameterUpdateOptions, +) + + +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-size", + type=int, + default=100, + 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") + 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" + ) + 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_size=args.page_size, + ) + + 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 param_count == 0: + print("No parameters found.") + else: + print(f"Total: {param_count} parameters") + + # 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) 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") + 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: + 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 if args.value else "", + 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") + 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__": + main() diff --git a/examples/project.py b/examples/project.py index 6999e4f..7702b09 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(" This feature may require a higher HCP Terraform plan") + 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,16 +656,16 @@ 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(" This feature may require a higher HCP Terraform plan") + print(f"add_tag_bindings not available: {e}") + print("This feature may require a higher HCP Terraform plan") add_tag_bindings_available = False delete_tag_bindings_available = False else: @@ -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" - print(f" {feature_name}: {status}") + 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(" export TFE_TOKEN='your-hcp-terraform-token'") - print(" export TFE_ORG='your-organization-name'") + 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/query_run.py b/examples/query_run.py index 610caa7..66ee804 100644 --- a/examples/query_run.py +++ b/examples/query_run.py @@ -1,413 +1,290 @@ #!/usr/bin/env python3 """ -Query Run Management Example +Query Run Individual Function Tests -This example demonstrates all available query run operations in the Python TFE SDK, -including create, read, list, logs, results, cancel, and force cancel operations. +This file provides individual test functions for each query run operation. +You can run specific functions to test individual parts of the API. + +Functions available: +- run_list() - List query runs in a workspace +- run_create() - Create a new query run +- run_read() - Read a specific query run +- run_logs() - Retrieve logs for a query run +- run_cancel() - Cancel a query run +- run_force_cancel() - Force cancel a query run Usage: - python examples/query_run.py - -Requirements: - - TFE_TOKEN environment variable set - - TFE_ADDRESS # Get logs - logs = client.query_runs.logs(query_run_id) - print(f" ✓ Retrieved execution logs ({len(logs.logs)} characters)")ironment variable set (optional, defaults to Terraform Cloud) - - An existing organization in your Terraform Cloud/Enterprise instance - -Query Run Operations Demonstrated: - 1. List query runs with various filters - 2. Create new query runs with different types - 3. Read query run details - 4. Read query run with additional options - 5. Retrieve query run logs - 6. Retrieve query run results - 7. Cancel running query runs - 8. Force cancel stuck query runs + python query_run.py + +Note: Query Runs require Terraform ~>1.14 which includes the 'terraform query' command. + These tests may fail with error status since the feature is not fully available yet. """ import os import time -from datetime import datetime from pytfe import TFEClient, TFEConfig from pytfe.models import ( - QueryRunCancelOptions, QueryRunCreateOptions, - QueryRunForceCancelOptions, QueryRunListOptions, - QueryRunReadOptions, - QueryRunStatus, - QueryRunType, + QueryRunSource, ) -def test_list_query_runs(client, organization_name): - """Test listing query runs with various options.""" - print("=== Testing Query Run List Operations ===") +def get_client_and_workspace(): + """Initialize client and get workspace ID.""" + client = TFEClient(TFEConfig.from_env()) + organization = os.getenv("TFE_ORG", "aayush-test") + workspace_name = "query-test" # Default workspace for testing - # 1. List all query runs - print("\n1. Listing All Query Runs:") - try: - query_runs = client.query_runs.list(organization_name) - print(f" ✓ Found {len(query_runs.items)} query runs") - if query_runs.items: - print(f" ✓ Latest query run: {query_runs.items[0].id}") - print(f" ✓ Status: {query_runs.items[0].status}") - print(f" ✓ Query type: {query_runs.items[0].query_type}") - except Exception as e: - print(f" ✗ Error: {e}") + # Get workspace + workspace = client.workspaces.read(workspace_name, organization=organization) + return client, workspace - # 2. List with pagination - print("\n2. Listing Query Runs with Pagination:") - try: - options = QueryRunListOptions(page_number=1, page_size=5) - query_runs = client.query_runs.list(organization_name, options) - print(f" ✓ Page 1 has {len(query_runs.items)} query runs") - print(f" ✓ Total pages: {query_runs.total_pages}") - print(f" ✓ Total count: {query_runs.total_count}") - except Exception as e: - print(f" ✗ Error: {e}") - # 3. List with filters - print("\n3. Listing Query Runs with Filters:") - try: - options = QueryRunListOptions( - query_type=QueryRunType.FILTER, - status=QueryRunStatus.COMPLETED, - page_size=10, - ) - query_runs = client.query_runs.list(organization_name, options) - print(f" ✓ Found {len(query_runs.items)} completed filter query runs") - for qr in query_runs.items[:3]: # Show first 3 - print(f" - {qr.id}: {qr.query[:50]}...") - except Exception as e: - print(f" ✗ Error: {e}") +def run_list(): + """Test 1: List query runs in a workspace.""" + print("=== Test 1: List Query Runs ===") - return query_runs.items[0] if query_runs.items else None + client, workspace = get_client_and_workspace() + try: + # Simple list + query_runs = list(client.query_runs.list(workspace.id)) + print(f"Found {len(query_runs)} query runs in workspace '{workspace.name}'") -def test_create_query_runs(client, organization_name): - """Test creating different types of query runs.""" - print("\n=== Testing Query Run Creation ===") + for i, qr in enumerate(query_runs[:5], 1): + print(f" {i}. {qr.id}") + print(f" Status: {qr.status}") + print(f" Created: {qr.created_at}") + print() - created_query_runs = [] + # List with options + options = QueryRunListOptions(page_size=5) + limited_runs = list(client.query_runs.list(workspace.id, options)) + print(f"Retrieved {len(limited_runs)} query runs (page_size=5)") - # 1. Create a filter query run - print("\n1. Creating Filter Query Run:") - try: - options = QueryRunCreateOptions( - query="SELECT id, status, created_at FROM runs WHERE status = 'completed' ORDER BY created_at DESC", - query_type=QueryRunType.FILTER, - organization_name=organization_name, - timeout_seconds=300, - max_results=100, - ) - query_run = client.query_runs.create(organization_name, options) - created_query_runs.append(query_run) - print(f" ✓ Created filter query run: {query_run.id}") - print(f" ✓ Status: {query_run.status}") - print(f" ✓ Query: {query_run.query}") - except Exception as e: - print(f" ✗ Error: {e}") + return query_runs - # 2. Create a search query run - print("\n2. Creating Search Query Run:") - try: - options = QueryRunCreateOptions( - query="SEARCH workspaces WHERE name CONTAINS 'production'", - query_type=QueryRunType.SEARCH, - organization_name=organization_name, - timeout_seconds=180, - max_results=50, - ) - query_run = client.query_runs.create(organization_name, options) - created_query_runs.append(query_run) - print(f" ✓ Created search query run: {query_run.id}") - print(f" ✓ Status: {query_run.status}") - print(f" ✓ Query type: {query_run.query_type}") except Exception as e: - print(f" ✗ Error: {e}") + print(f"Error: {e}") + return [] + + +def run_create(): + """Test 2: Create a new query run.""" + print("\n=== Test 2: Create Query Run ===") + + client, workspace = get_client_and_workspace() - # 3. Create an analytics query run - print("\n3. Creating Analytics Query Run:") try: + # Get the latest configuration version + config_versions = list(client.configuration_versions.list(workspace.id)) + if not config_versions: + print("ERROR: No configuration versions found in workspace") + return None + + config_version = config_versions[0] + print(f"Using configuration version: {config_version.id}") + + # Create query run options = QueryRunCreateOptions( - query="ANALYZE run_durations GROUP BY workspace_id ORDER BY avg_duration DESC", - query_type=QueryRunType.ANALYTICS, - organization_name=organization_name, - timeout_seconds=600, - max_results=200, - filters={"time_range": "last_30_days", "include_failed": False}, + source=QueryRunSource.API, + workspace_id=workspace.id, + configuration_version_id=config_version.id, ) - query_run = client.query_runs.create(organization_name, options) - created_query_runs.append(query_run) - print(f" ✓ Created analytics query run: {query_run.id}") - print(f" ✓ Status: {query_run.status}") - print(f" ✓ Timeout: {query_run.timeout_seconds}s") - print(f" ✓ Max results: {query_run.max_results}") + + query_run = client.query_runs.create(options) + print(f"Created query run: {query_run.id}") + print(f" Status: {query_run.status}") + print(f" Source: {query_run.source}") + print(f" Created: {query_run.created_at}") + + return query_run + except Exception as e: - print(f" ✗ Error: {e}") + print(f"Error: {e}") + return None - return created_query_runs +def run_read(query_run_id=None): + """Test 3: Read a specific query run.""" + print("\n=== Test 3: Read Query Run ===") -def test_read_query_run(client, query_run_id): - """Test reading query run details.""" - print(f"\n=== Testing Query Run Read Operations for {query_run_id} ===") + client, workspace = get_client_and_workspace() - # 1. Basic read - print("\n1. Reading Query Run Details:") try: + # If no query_run_id provided, get the first one from the list + if not query_run_id: + query_runs = list(client.query_runs.list(workspace.id)) + if not query_runs: + print("ERROR: No query runs found to read") + return None + query_run_id = query_runs[0].id + print(f"Using first query run from list: {query_run_id}") + + # Read the query run query_run = client.query_runs.read(query_run_id) - print(f" ✓ Query Run ID: {query_run.id}") - print(f" ✓ Status: {query_run.status}") - print(f" ✓ Query Type: {query_run.query_type}") - print(f" ✓ Created: {query_run.created_at}") - print(f" ✓ Updated: {query_run.updated_at}") - if query_run.results_count: - print(f" ✓ Results Count: {query_run.results_count}") - if query_run.error_message: - print(f" ✗ Error: {query_run.error_message}") - except Exception as e: - print(f" ✗ Error: {e}") - return None + print(f"Read query run: {query_run.id}") + print(f" Status: {query_run.status}") + print(f" Source: {query_run.source}") + print(f" Created: {query_run.created_at}") + + if query_run.status_timestamps: + print(" Status Timestamps:") + if query_run.status_timestamps.queued_at: + print(f" Queued: {query_run.status_timestamps.queued_at}") + if query_run.status_timestamps.running_at: + print(f" Running: {query_run.status_timestamps.running_at}") + if query_run.status_timestamps.finished_at: + print(f" Finished: {query_run.status_timestamps.finished_at}") + if query_run.status_timestamps.errored_at: + print(f" Errored: {query_run.status_timestamps.errored_at}") + + return query_run - # 2. Read with options - print("\n2. Reading Query Run with Options:") - try: - options = QueryRunReadOptions(include_results=True, include_logs=True) - query_run = client.query_runs.read_with_options(query_run_id, options) - print(" ✓ Read query run with additional data") - print(f" ✓ Status: {query_run.status}") - if query_run.logs_url: - print(f" ✓ Logs URL available: {query_run.logs_url[:50]}...") - if query_run.results_url: - print(f" ✓ Results URL available: {query_run.results_url[:50]}...") except Exception as e: - print(f" ✗ Error: {e}") + print(f"Error: {e}") + return None - return query_run +def run_logs(query_run_id=None): + """Test 4: Retrieve logs for a query run.""" + print("\n=== Test 4: Get Query Run Logs ===") -def test_query_run_logs(client, query_run_id): - """Test retrieving query run logs.""" - print(f"\n=== Testing Query Run Logs for {query_run_id} ===") + client, workspace = get_client_and_workspace() try: + # If no query_run_id provided, get the first one from the list + if not query_run_id: + query_runs = list(client.query_runs.list(workspace.id)) + if not query_runs: + print("ERROR: No query runs found to get logs") + return None + query_run_id = query_runs[0].id + print(f"Using first query run from list: {query_run_id}") + + # Get logs logs = client.query_runs.logs(query_run_id) - print(f" ✓ Retrieved logs for query run: {logs.query_run_id}") - print(f" ✓ Log level: {logs.log_level}") - if logs.timestamp: - print(f" ✓ Log timestamp: {logs.timestamp}") - - # Show first few lines of logs - log_lines = logs.logs.split("\n")[:5] - print(" ✓ Log preview:") - for line in log_lines: - if line.strip(): - print(f" {line}") - except Exception as e: - print(f" ✗ Error retrieving logs: {e}") + log_content = logs.read().decode("utf-8") + print(f"Retrieved logs for query run: {query_run_id}") + print(f" Log size: {len(log_content)} bytes") + print("\n--- Log Preview (first 500 chars) ---") + print(log_content[:500]) + if len(log_content) > 500: + print(f"\n... ({len(log_content) - 500} more characters)") + print("--- End of Log Preview ---") -def test_query_run_results(client, query_run_id): - """Test retrieving query run results.""" - print(f"\n=== Testing Query Run Results for {query_run_id} ===") + return log_content - try: - results = client.query_runs.results(query_run_id) - print(f" ✓ Retrieved results for query run: {results.query_run_id}") - print(f" ✓ Total results: {results.total_count}") - print(f" ✓ Truncated: {results.truncated}") - - # Show first few results - if results.results: - print(" ✓ Sample results:") - for i, result in enumerate(results.results[:3]): - print(f" {i + 1}. {result}") - else: - print(" ℹ No results available") except Exception as e: - print(f" ✗ Error retrieving results: {e}") + print(f"Error: {e}") + print(" Note: Logs may not be available if the query run hasn't started yet") + return None -def test_query_run_cancellation(client, query_run_id): - """Test canceling query runs.""" - print(f"\n=== Testing Query Run Cancellation for {query_run_id} ===") +def run_cancel(query_run_id=None): + """Test 5: Cancel a query run.""" + print("\n=== Test 5: Cancel Query Run ===") - # First check if the query run is in a cancelable state - try: - query_run = client.query_runs.read(query_run_id) - if query_run.status not in [QueryRunStatus.PENDING, QueryRunStatus.RUNNING]: - print( - f" ℹ Query run is {query_run.status}, creating new one for cancellation test" - ) - - # Create a new query run for cancellation test - options = QueryRunCreateOptions( - query="SELECT * FROM runs LIMIT 10000", # Large query to ensure it runs long enough - query_type=QueryRunType.FILTER, - organization_name=query_run.organization_name, - timeout_seconds=300, - ) - query_run = client.query_runs.create(query_run.organization_name, options) - query_run_id = query_run.id - print(f" ✓ Created new query run for cancellation: {query_run_id}") - except Exception as e: - print(f" ✗ Error checking query run status: {e}") - return + client, workspace = get_client_and_workspace() - # 1. Test regular cancel - print("\n1. Testing Regular Cancellation:") - try: - cancel_options = QueryRunCancelOptions( - reason="User requested cancellation for testing" - ) - canceled_query_run = client.query_runs.cancel(query_run_id, cancel_options) - print(f" ✓ Canceled query run: {canceled_query_run.id}") - print(f" ✓ New status: {canceled_query_run.status}") - except Exception as e: - print(f" ✗ Error canceling query run: {e}") - - # If regular cancel fails, try force cancel - print("\n2. Testing Force Cancellation:") - try: - force_cancel_options = QueryRunForceCancelOptions( - reason="Force cancel after regular cancel failed" - ) - force_canceled_query_run = client.query_runs.force_cancel( - query_run_id, force_cancel_options - ) - print(f" ✓ Force canceled query run: {force_canceled_query_run.id}") - print(f" ✓ New status: {force_canceled_query_run.status}") - except Exception as e: - print(f" ✗ Error force canceling query run: {e}") - - -def test_query_run_workflow(client, organization_name): - """Test a complete query run workflow.""" - print("\n=== Testing Complete Query Run Workflow ===") - - # 1. Create a query run - print("\n1. Creating Query Run:") - try: - options = QueryRunCreateOptions( - query="SELECT id, name, status FROM workspaces ORDER BY created_at DESC LIMIT 10", - query_type=QueryRunType.FILTER, - organization_name=organization_name, - timeout_seconds=120, - max_results=50, - ) - query_run = client.query_runs.create(organization_name, options) - print(f" ✓ Created: {query_run.id}") - query_run_id = query_run.id - except Exception as e: - print(f" ✗ Error creating query run: {e}") - return - - # 2. Monitor execution - print("\n2. Monitoring Execution:") - max_attempts = 30 - attempt = 0 - - while attempt < max_attempts: - try: - query_run = client.query_runs.read(query_run_id) - print(f" Attempt {attempt + 1}: Status = {query_run.status}") - - if query_run.status in [ - QueryRunStatus.COMPLETED, - QueryRunStatus.ERRORED, - QueryRunStatus.CANCELED, - ]: - break - - time.sleep(2) # Wait 2 seconds before checking again - attempt += 1 - except Exception as e: - print(f" ✗ Error monitoring query run: {e}") - break - - # 3. Get final results - print("\n3. Getting Final Results:") try: - if query_run.status == QueryRunStatus.COMPLETED: - results = client.query_runs.results(query_run_id) - print(" ✓ Query completed successfully") - print(f" ✓ Total results: {results.total_count}") - print(f" ✓ Truncated: {results.truncated}") - - # Get logs - logs = client.query_runs.logs(query_run_id) - print(f" ✓ Retrieved execution logs ({len(logs.logs)} characters)") - else: - print(f" ✗ Query run finished with status: {query_run.status}") - if query_run.error_message: - print(f" ✗ Error message: {query_run.error_message}") - except Exception as e: - print(f" ✗ Error getting final results: {e}") + # If no query_run_id provided, create a new one + if not query_run_id: + print("Creating a new query run to cancel...") + new_run = run_create() + if not new_run: + print("ERROR: Could not create query run to cancel") + return False + query_run_id = new_run.id + time.sleep(1) # Give it a moment to start + + # Cancel the query run + client.query_runs.cancel(query_run_id) + print(f"Cancel requested for query run: {query_run_id}") + + # Verify cancellation + time.sleep(2) + query_run = client.query_runs.read(query_run_id) + print(f" Status after cancel: {query_run.status}") - return query_run_id + return True + except Exception as e: + print(f"Error: {e}") + print(" Note: Query run may not be in a cancelable state") + return False -def main(): - """Main function to demonstrate query run operations.""" - # Get configuration from environment - token = os.environ.get("TFE_TOKEN") - org = os.environ.get("TFE_ORG") - address = os.environ.get("TFE_ADDRESS", "https://app.terraform.io") - if not token: - print("Error: TFE_TOKEN environment variable is required") - return 1 +def run_force_cancel(query_run_id=None): + """Test 6: Force cancel a query run.""" + print("\n=== Test 6: Force Cancel Query Run ===") - if not org: - print("Error: TFE_ORG environment variable is required") - return 1 + client, workspace = get_client_and_workspace() - # Initialize client - print("=== Terraform Enterprise Query Run SDK Example ===") - print(f"Address: {address}") - print(f"Organization: {org}") - print(f"Timestamp: {datetime.now()}") + try: + # If no query_run_id provided, create a new one + if not query_run_id: + print("Creating a new query run to force cancel...") + new_run = run_create() + if not new_run: + print("ERROR: Could not create query run to force cancel") + return False + query_run_id = new_run.id + time.sleep(1) # Give it a moment to start + + # Force cancel the query run + client.query_runs.force_cancel(query_run_id) + print(f"Force cancel requested for query run: {query_run_id}") + + # Verify force cancellation + time.sleep(2) + query_run = client.query_runs.read(query_run_id) + print(f" Status after force cancel: {query_run.status}") - config = TFEConfig(address=address, token=token) - client = TFEClient(config) + return True - try: - # 1. List existing query runs - existing_query_run = test_list_query_runs(client, org) + except Exception as e: + print(f"Error: {e}") + print(" Note: Query run may not be in a force-cancelable state") + return False - # 2. Create new query runs - created_query_runs = test_create_query_runs(client, org) - # 3. Test read operations - if existing_query_run: - test_read_query_run(client, existing_query_run.id) +def main(): + """Run all tests in sequence.""" + print("=" * 80) + print("QUERY RUN FUNCTION TESTS") + print("=" * 80) + print("Testing all Query Run API operations") + print() + print("NOTE: Query Runs require Terraform 1.10+ with 'terraform query' command.") + print(" Most query runs will error since this feature is not yet available.") + print("=" * 80) - # Only test logs and results if query run is completed - if existing_query_run.status == QueryRunStatus.COMPLETED: - test_query_run_logs(client, existing_query_run.id) - test_query_run_results(client, existing_query_run.id) + # Test 1: List query runs + query_runs = run_list() - # 4. Test cancellation (with a new query run if needed) - if created_query_runs: - test_query_run_cancellation(client, created_query_runs[0].id) + # Test 2: Create a query run + new_query_run = run_create() - # 5. Test complete workflow - test_query_run_workflow(client, org) + # Test 3: Read a query run + if query_runs: + run_read(query_runs[0].id) + elif new_query_run: + run_read(new_query_run.id) - print("\n" + "=" * 80) - print("Query Run operations completed successfully!") - print("=" * 80) + # Test 4: Get logs (use first query run from list) + if query_runs: + run_logs(query_runs[0].id) - except Exception as e: - print(f"\nUnexpected error: {e}") - return 1 + # Test 5: Cancel a query run (creates new one) + run_cancel() - return 0 + # Test 6: Force cancel a query run (creates new one) + run_force_cancel() if __name__ == "__main__": - exit(main()) + main() diff --git a/examples/registry_module.py b/examples/registry_module.py index 0be3b99..cc53edf 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})") + 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}") + 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" Status: {read_module.status}") - print(f" Created: {read_module.created_at}") + 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" Status: {version.status}") + 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" Status: {read_version.status}") - print(f" ID: {read_version.id}") + 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,20 +247,20 @@ 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" 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')}") + 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) # ===================================================== print("\n8. Testing create() function (non-VCS module):") - print(" NOTE: Non-VCS modules start in PENDING status until content is uploaded") + print("NOTE: Non-VCS modules start in PENDING status until content is uploaded") try: unique_suffix = f"{int(time.time())}-{random.randint(1000, 9999)}" @@ -274,19 +274,19 @@ 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(f"ID: {created_simple_module.id}") print( - f" Status: {created_simple_module.status} (PENDING until content uploaded)" + f"Status: {created_simple_module.status} (PENDING until content uploaded)" ) - print(f" No Code: {created_simple_module.no_code}") + print(f"No Code: {created_simple_module.no_code}") # Store for later tests (will be overridden by upload test module) created_module = created_simple_module except Exception as e: - print(f" ✗ Error: {e}") + print(f"Error: {e}") # ===================================================== # TEST 8A: LIST VERSIONS @@ -303,20 +303,20 @@ 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})") + 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 # ===================================================== if created_module: print("\n8B. Testing update() function:") - print(" NOTE: Update functionality may vary by TFE version") + print("NOTE: Update functionality may vary by TFE version") try: module_id = RegistryModuleID( organization=organization_name, @@ -327,7 +327,7 @@ def main(): # First check current module status current_module = client.registry_modules.read(module_id) - print(f" Current module no_code setting: {current_module.no_code}") + print(f"Current module no_code setting: {current_module.no_code}") # Try to update no_code setting update_options = RegistryModuleUpdateOptions( @@ -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" No Code: {updated_module.no_code}") - print(f" Status: {updated_module.status}") + 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" Provider: {created_module.provider}") - print(f" Status: {created_module.status}") + 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,24 +387,24 @@ 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" Status: {version.status}") + print(f"Created version: {created_version}") + print(f"Status: {version.status}") # Check if upload URL is available upload_url = ( version.links.get("upload") if hasattr(version, "links") else None ) - print(f" Upload URL available: {'Yes' if upload_url else 'No'}") + 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 # ===================================================== if created_module and created_version and version_object: print("\n11. Testing upload_tar_gzip() function:") - print(" This will change module status from PENDING to SETUP_COMPLETE") + print("This will change module status from PENDING to SETUP_COMPLETE") try: # Create a simple module structure in memory tar_buffer = io.BytesIO() @@ -475,12 +475,10 @@ 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()" - ) + print("Successfully uploaded tar.gz content using upload_tar_gzip()") # Wait for processing - print(" Waiting 5 seconds for processing...") + print("Waiting 5 seconds for processing...") time.sleep(5) # Check module status after upload @@ -492,27 +490,27 @@ def main(): ) updated_module = client.registry_modules.read(module_id) - print(f" Updated Module Status: {updated_module.status}") + print(f"Updated Module Status: {updated_module.status}") 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") + 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 # ===================================================== if created_module and created_version and version_object: print("\n12. Testing upload() function:") - print(" NOTE: This function uploads from a local file path") + print("NOTE: This function uploads from a local file path") try: # Create a temporary directory with module structure with tempfile.TemporaryDirectory() as temp_dir: @@ -582,8 +580,8 @@ def main(): """.strip() ) - print(f" Created temporary module files in: {temp_dir}") - print(f" Files: {os.listdir(temp_dir)}") + print(f"Created temporary module files in: {temp_dir}") + print(f"Files: {os.listdir(temp_dir)}") # Check if upload URL is available upload_url = ( @@ -592,15 +590,15 @@ def main(): else None ) if upload_url: - print(" Upload URL available: Yes") + print("Upload URL available: Yes") # 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...") + print("Waiting 5 seconds for processing...") time.sleep(5) module_id = RegistryModuleID( @@ -611,14 +609,14 @@ def main(): ) updated_module = client.registry_modules.read(module_id) - print(f" Updated Module Status: {updated_module.status}") + print(f"Updated Module Status: {updated_module.status}") except NotImplementedError as nie: - print(f" ⚠ upload() function not fully implemented: {nie}") - print(" This is expected - the function is a placeholder") + print(f"upload() function not fully implemented: {nie}") + print("This is expected - the function is a placeholder") # Fallback to upload_tar_gzip - print(" Trying fallback: upload_tar_gzip()...") + print("Trying fallback: upload_tar_gzip()...") tar_buffer = io.BytesIO() with tarfile.open(fileobj=tar_buffer, mode="w:gz") as tar: @@ -637,24 +635,24 @@ 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 # ===================================================== # Create a test module and version for delete testing print("\n13. Testing delete_version() function:") - print(" Creating test module and version for deletion...") + print("Creating test module and version for deletion...") test_module_for_deletion = None test_version_for_deletion = None @@ -670,7 +668,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,15 +682,15 @@ 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}...") + print(f"Testing deletion of version {test_version_for_deletion}...") # 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 +704,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 @@ -731,54 +729,54 @@ def main(): try: client.registry_modules.read(module_id) print( - f" Module {test_module_for_deletion.name}/{test_module_for_deletion.provider} exists" + f"Module {test_module_for_deletion.name}/{test_module_for_deletion.provider} exists" ) # 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}") + 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) # ===================================================== print("\n15. Testing delete() function:") - print(" NOTE: Testing with non-existent module to avoid conflicts") + print("NOTE: Testing with non-existent module to avoid conflicts") try: # This function takes organization and name directly # We'll test with a non-existent module to avoid conflicts test_name = "non-existent-module-for-testing" - print(f" Testing delete with non-existent module: {test_name}") + 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: - print(f" Expected error for non-existent module: {e}") + print(f"Expected error for non-existent module: {e}") # ===================================================== # TEST 16: DELETE PROVIDER (SAFE VERSION - CREATES TEST PROVIDER) # ===================================================== print("\n16. Testing delete_provider() function:") - print(" Creating a test provider specifically for deletion testing...") + print("Creating a test provider specifically for deletion testing...") try: # Create a test module with a valid provider for deletion testing @@ -794,7 +792,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( @@ -804,23 +802,23 @@ def main(): registry_name=RegistryName.PRIVATE, ) - print(f" Testing delete_provider() for provider: {test_provider_name}") + 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 +827,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..d2b7c4b 100644 --- a/examples/registry_provider.py +++ b/examples/registry_provider.py @@ -49,20 +49,20 @@ 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}") - print(f" Namespace: {provider.namespace}") - print(f" Registry: {provider.registry_name.value}") - print(f" ID: {provider.id}") - print(f" Can Delete: {provider.permissions.can_delete}") + print(f"{i}. {provider.name}") + print(f"Namespace: {provider.namespace}") + print(f"Registry: {provider.registry_name.value}") + print(f"ID: {provider.id}") + print(f"Can Delete: {provider.permissions.can_delete}") print() 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,16 +112,16 @@ def test_create_private(): ) provider = client.registry_providers.create(org, options) - print(f"✓ Created private provider: {provider.name}") - print(f" ID: {provider.id}") - print(f" Namespace: {provider.namespace}") - print(f" Registry: {provider.registry_name.value}") - print(f" Created: {provider.created_at}") + print(f"Created private provider: {provider.name}") + print(f"ID: {provider.id}") + print(f"Namespace: {provider.namespace}") + print(f"Registry: {provider.registry_name.value}") + print(f"Created: {provider.created_at}") return provider except Exception as e: - print(f"✗ Error creating private provider: {e}") + print(f"Error creating private provider: {e}") return None @@ -142,16 +142,16 @@ def test_create_public(): ) provider = client.registry_providers.create(org, options) - print(f"✓ Created public provider: {provider.name}") - print(f" ID: {provider.id}") - print(f" Namespace: {provider.namespace}") - print(f" Registry: {provider.registry_name.value}") - print(f" Created: {provider.created_at}") + print(f"Created public provider: {provider.name}") + print(f"ID: {provider.id}") + print(f"Namespace: {provider.namespace}") + print(f"Registry: {provider.registry_name.value}") + print(f"Created: {provider.created_at}") 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,13 +175,13 @@ def test_read_with_id(provider_data): # Basic read provider = client.registry_providers.read(provider_id) - print(f"✓ Read provider: {provider.name}") - print(f" ID: {provider.id}") - print(f" Namespace: {provider.namespace}") - print(f" Registry: {provider.registry_name.value}") - print(f" Created: {provider.created_at}") - print(f" Updated: {provider.updated_at}") - print(f" Can Delete: {provider.permissions.can_delete}") + print(f"Read provider: {provider.name}") + print(f"ID: {provider.id}") + print(f"Namespace: {provider.namespace}") + print(f"Registry: {provider.registry_name.value}") + print(f"Created: {provider.created_at}") + print(f"Updated: {provider.updated_at}") + print(f"Can Delete: {provider.permissions.can_delete}") # Read with options options = RegistryProviderReadOptions( @@ -189,19 +189,17 @@ 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( - f" Found {len(detailed_provider.registry_provider_versions)} versions" - ) + print(f"Found {len(detailed_provider.registry_provider_versions)} versions") else: - print(" No versions found") + print("No versions found") return provider except Exception as e: - print(f"✗ Error reading provider: {e}") + print(f"Error reading provider: {e}") return None @@ -212,7 +210,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 +223,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 +236,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 +260,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 +295,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/registry_provider_version.py b/examples/registry_provider_version.py new file mode 100644 index 0000000..4da1c83 --- /dev/null +++ b/examples/registry_provider_version.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +import argparse +import os + +from pytfe import TFEClient, TFEConfig +from pytfe.models import ( + RegistryProviderID, + RegistryProviderVersionCreateOptions, + RegistryProviderVersionID, + RegistryProviderVersionListOptions, +) + + +def _print_header(title: str): + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def main(): + parser = argparse.ArgumentParser( + description="Registry Provider Versions demo for python-tfe SDK" + ) + parser.add_argument( + "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") + ) + parser.add_argument("--token", default=os.getenv("TFE_TOKEN", "")) + parser.add_argument("--organization", required=True, help="Organization name") + parser.add_argument( + "--registry-name", + default="private", + help="Registry name (default: private)", + ) + parser.add_argument("--namespace", required=True, help="Provider namespace") + parser.add_argument("--name", required=True, help="Provider name") + parser.add_argument( + "--page-size", + type=int, + default=100, + help="Page size for fetching versions", + ) + parser.add_argument("--create", action="store_true", help="Create a test version") + parser.add_argument("--read", action="store_true", help="Read a specific version") + parser.add_argument( + "--delete", action="store_true", help="Delete a specific version" + ) + parser.add_argument("--version", help="Version number (e.g., 1.0.0)") + parser.add_argument("--key-id", help="GPG key ID for version signing") + parser.add_argument( + "--protocols", + nargs="+", + help="Supported protocols (e.g., 5.0 6.0)", + ) + args = parser.parse_args() + + cfg = TFEConfig(address=args.address, token=args.token) + client = TFEClient(cfg) + + # 1) List all versions for the registry provider + _print_header( + f"Listing versions for {args.registry_name}/{args.namespace}/{args.name}" + ) + provider_id = RegistryProviderID( + organization_name=args.organization, + registry_name=args.registry_name, + namespace=args.namespace, + name=args.name, + ) + + options = RegistryProviderVersionListOptions( + page_size=args.page_size, + ) + + version_count = 0 + for version in client.registry_provider_versions.list( + provider_id=provider_id, + options=options, + ): + version_count += 1 + print(f"- Version {version.version} (ID: {version.id})") + print(f" Created: {version.created_at}") + print(f" Updated: {version.updated_at}") + print(f" Key ID: {version.key_id}") + print(f" Protocols: {', '.join(version.protocols)}") + print(f" Shasums Uploaded: {version.shasums_uploaded}") + print(f" Shasums Signature Uploaded: {version.shasums_sig_uploaded}") + if version.permissions: + print(" Permissions:") + print(f" Can Delete: {version.permissions.can_delete}") + print(f" Can Upload Asset: {version.permissions.can_upload_asset}") + print() + + if version_count == 0: + print("No versions found.") + else: + print(f"Total: {version_count} versions") + + # 2) Create a new version (if --create flag is provided) + if args.create: + if not args.version: + print("Error: --version is required for create operation") + return + if not args.key_id: + print("Error: --key-id is required for create operation") + return + if not args.protocols: + print("Error: --protocols is required for create operation") + return + + _print_header(f"Creating new version: {args.version}") + + create_options = RegistryProviderVersionCreateOptions( + version=args.version, + key_id=args.key_id, + protocols=args.protocols, + ) + + new_version = client.registry_provider_versions.create( + provider_id=provider_id, + options=create_options, + ) + + print(f"Created version: {new_version.id}") + print(f" Version: {new_version.version}") + print(f" Created: {new_version.created_at}") + print(f" Key ID: {new_version.key_id}") + print(f" Protocols: {', '.join(new_version.protocols)}") + print(f" Shasums Uploaded: {new_version.shasums_uploaded}") + print(f" Shasums Signature Uploaded: {new_version.shasums_sig_uploaded}") + + # Show upload URLs if available in links + if new_version.links: + print("\n Upload URLs:") + if "shasums-upload" in new_version.links: + print(f" Shasums: {new_version.links['shasums-upload']}") + if "shasums-sig-upload" in new_version.links: + print( + f" Shasums Signature: {new_version.links['shasums-sig-upload']}" + ) + + # 3) Read a specific version (if --read flag is provided) + if args.read: + if not args.version: + print("Error: --version is required for read operation") + return + + _print_header(f"Reading version: {args.version}") + + version_id = RegistryProviderVersionID( + organization_name=args.organization, + registry_name=args.registry_name, + namespace=args.namespace, + name=args.name, + version=args.version, + ) + + version = client.registry_provider_versions.read(version_id) + + print(f"Version ID: {version.id}") + print(f" Version: {version.version}") + print(f" Created: {version.created_at}") + print(f" Updated: {version.updated_at}") + print(f" Key ID: {version.key_id}") + print(f" Protocols: {', '.join(version.protocols)}") + print(f" Shasums Uploaded: {version.shasums_uploaded}") + print(f" Shasums Signature Uploaded: {version.shasums_sig_uploaded}") + + if version.permissions: + print(" Permissions:") + print(f" Can Delete: {version.permissions.can_delete}") + print(f" Can Upload Asset: {version.permissions.can_upload_asset}") + + # Show links if available + if version.links: + print(" Links:") + for key, value in version.links.items(): + print(f" {key}: {value}") + + # 4) Delete a version (if --delete flag is provided) + if args.delete: + if not args.version: + print("Error: --version is required for delete operation") + return + + _print_header(f"Deleting version: {args.version}") + + version_id = RegistryProviderVersionID( + organization_name=args.organization, + registry_name=args.registry_name, + namespace=args.namespace, + name=args.name, + version=args.version, + ) + + # First read the version to show what's being deleted + try: + version_to_delete = client.registry_provider_versions.read(version_id) + print("Version to delete:") + print(f" ID: {version_to_delete.id}") + print(f" Version: {version_to_delete.version}") + print(f" Protocols: {', '.join(version_to_delete.protocols)}") + print(f" Key ID: {version_to_delete.key_id}") + except Exception as e: + print(f"Error reading version: {e}") + return + + # Delete the version + client.registry_provider_versions.delete(version_id) + print(f"\n Successfully deleted version: {args.version}") + + # List remaining versions + _print_header("Listing versions after deletion") + provider_id = RegistryProviderID( + organization_name=args.organization, + registry_name=args.registry_name, + namespace=args.namespace, + name=args.name, + ) + + options = RegistryProviderVersionListOptions( + page_size=args.page_size, + ) + print("Remaining versions:") + remaining_count = 0 + for version in client.registry_provider_versions.list( + provider_id=provider_id, + options=options, + ): + remaining_count += 1 + print( + f"- Version {version.version}: " + f" protocols={', '.join(version.protocols)}, " + f" shasums_uploaded={version.shasums_uploaded}" + ) + + if remaining_count == 0: + print("No versions remaining.") + else: + print(f"\nTotal: {remaining_count} versions") + + +if __name__ == "__main__": + main() diff --git a/examples/reserved_tag_key.py b/examples/reserved_tag_key.py index 8eeb5e7..8e62b1a 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 @@ -53,9 +53,7 @@ def main(): try: # 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:") - for rtk in reserved_tag_keys.items: + for rtk in client.reserved_tag_key.list(TFE_ORG): print( f" - ID: {rtk.id}, Key: {rtk.key}, Disable Overrides: {rtk.disable_overrides}" ) @@ -67,8 +65,8 @@ 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" Disable Overrides: {new_rtk.disable_overrides}") + 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 print("\n3. Updating the reserved tag key...") @@ -77,48 +75,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" Disable Overrides: {updated_rtk.disable_overrides}") + 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)}" - ) + reserved_tag_keys_after = list(client.reserved_tag_key.list(TFE_ORG)) + print(f"Reserved tag keys after deletion: {len(reserved_tag_keys_after)}") # 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" Total pages: {paginated_rtks.total_pages}") - print(f" Total count: {paginated_rtks.total_count}") + list_options = ReservedTagKeyListOptions(page_size=5) + for rtk in client.reserved_tag_key.list(TFE_ORG, list_options): + print( + f" - ID: {rtk.id}, Key: {rtk.key}, Disable Overrides: {rtk.disable_overrides}" + ) - 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.py b/examples/run.py index 79cbca9..d95b6e9 100644 --- a/examples/run.py +++ b/examples/run.py @@ -82,10 +82,8 @@ def main(): for run in run_list.items: print(f"- {run.id} | status={run.status} | created={run.created_at}") - print(f" message: {run.message}") - print( - f" has_changes: {run.has_changes} | is_destroy: {run.is_destroy}" - ) + print(f"message: {run.message}") + print(f"has_changes: {run.has_changes} | is_destroy: {run.is_destroy}") if not run_list.items: print("No runs found.") @@ -119,11 +117,11 @@ def main(): if detailed_run.actions: print("\nAvailable Actions:") - print(f" Can Apply: {detailed_run.actions.is_confirmable}") - print(f" Can Cancel: {detailed_run.actions.is_cancelable}") - print(f" Can Discard: {detailed_run.actions.is_discardable}") + print(f"Can Apply: {detailed_run.actions.is_confirmable}") + print(f"Can Cancel: {detailed_run.actions.is_cancelable}") + print(f"Can Discard: {detailed_run.actions.is_discardable}") print( - f" Can Force Cancel: {detailed_run.actions.is_force_cancelable}" + f"Can Force Cancel: {detailed_run.actions.is_force_cancelable}" ) if detailed_run.created_by: @@ -196,7 +194,7 @@ def main(): for run in org_runs.items[:3]: # Show first 3 print(f"- {run.id} | status={run.status}") if run.workspace: - print(f" workspace: {run.workspace.name}") + print(f"workspace: {run.workspace.name}") except Exception as e: print(f"Error listing organization runs: {e}") @@ -226,37 +224,37 @@ def main(): print("\n1. Basic read():") try: basic_run = client.runs.read(demo_run.id) - print(f" Read run {basic_run.id} - status: {basic_run.status}") + print(f"Read run {basic_run.id} - status: {basic_run.status}") except Exception as e: - print(f" Error: {e}") + print(f"Error: {e}") # Show action methods (but don't execute them for safety) print("\n2. Available action methods (not executed):") - print(" # Apply run:") + print("# Apply run:") print( - f" # client.runs.apply('{demo_run.id}', RunApplyOptions(comment='Applied via SDK'))" + f"# client.runs.apply('{demo_run.id}', RunApplyOptions(comment='Applied via SDK'))" ) - print(" # Cancel run:") + print("# Cancel run:") print( - f" # client.runs.cancel('{demo_run.id}', RunCancelOptions(comment='Canceled via SDK'))" + f"# client.runs.cancel('{demo_run.id}', RunCancelOptions(comment='Canceled via SDK'))" ) - print(" # Force cancel run:") + print("# Force cancel run:") print( - f" # client.runs.force_cancel('{demo_run.id}', RunForceCancelOptions(comment='Force canceled'))" + f"# client.runs.force_cancel('{demo_run.id}', RunForceCancelOptions(comment='Force canceled'))" ) - print(" # Discard run:") + print("# Discard run:") print( - f" # client.runs.discard('{demo_run.id}', RunDiscardOptions(comment='Discarded via SDK'))" + f"# client.runs.discard('{demo_run.id}', RunDiscardOptions(comment='Discarded via SDK'))" ) - print(" # Force execute run:") - print(f" # client.runs.force_execute('{demo_run.id}')") + print("# Force execute run:") + print(f"# client.runs.force_execute('{demo_run.id}')") print("\n Note: These actions are commented out for safety.") - print(" Uncomment and use them carefully in your own code.") + print("Uncomment and use them carefully in your own code.") if __name__ == "__main__": diff --git a/examples/run_events.py b/examples/run_events.py index 75033c2..a648c5b 100644 --- a/examples/run_events.py +++ b/examples/run_events.py @@ -106,9 +106,9 @@ def main(): else: for event in event_list.items: print(f"Event ID: {event.id}") - print(f" Action: {event.action or 'N/A'}") - print(f" Description: {event.description or 'N/A'}") - print(f" Created At: {event.created_at or 'N/A'}") + print(f"Action: {event.action or 'N/A'}") + print(f"Description: {event.description or 'N/A'}") + print(f"Created At: {event.created_at or 'N/A'}") print() diff --git a/examples/run_task.py b/examples/run_task.py index 102874d..8331941 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: @@ -120,15 +120,15 @@ def main(): else: for i, task in enumerate(run_task_list, 1): print(f"{i:2d}. {task.name}") - print(f" ID: {task.id}") - print(f" URL: {task.url}") - print(f" Category: {task.category}") - print(f" Enabled: {task.enabled}") + print(f"ID: {task.id}") + print(f"URL: {task.url}") + print(f"Category: {task.category}") + print(f"Enabled: {task.enabled}") if task.description: - print(f" Description: {task.description}") + 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,19 +149,19 @@ 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(f" Name: {run_task.name}") - print(f" ID: {run_task.id}") - print(f" URL: {run_task.url}") - print(f" Category: {run_task.category}") - print(f" Enabled: {run_task.enabled}") - print(f" Description: {run_task.description}") - print(f" HMAC Key: {'[CONFIGURED]' if run_task.hmac_key else 'None'}") + print("Successfully created run task!") + print(f"Name: {run_task.name}") + print(f"ID: {run_task.id}") + print(f"URL: {run_task.url}") + print(f"Category: {run_task.category}") + print(f"Enabled: {run_task.enabled}") + print(f"Description: {run_task.description}") + print(f"HMAC Key: {'[CONFIGURED]' if run_task.hmac_key else 'None'}") print() 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,26 +180,24 @@ def main(): run_task = client.run_tasks.read(args.task_id) print("Reading run task details...") - print("✓ Successfully read run task!") - print(f" Name: {run_task.name}") - print(f" ID: {run_task.id}") - print(f" URL: {run_task.url}") - print(f" Category: {run_task.category}") - print(f" Enabled: {run_task.enabled}") - print(f" Description: {run_task.description or 'None'}") - print(f" HMAC Key: {'[SET]' if run_task.hmac_key else 'None'}") + print("Successfully read run task!") + print(f"Name: {run_task.name}") + print(f"ID: {run_task.id}") + print(f"URL: {run_task.url}") + print(f"Category: {run_task.category}") + print(f"Enabled: {run_task.enabled}") + print(f"Description: {run_task.description or 'None'}") + print(f"HMAC Key: {'[SET]' if run_task.hmac_key else 'None'}") if run_task.organization: - print(f" Organization: {run_task.organization.id}") + print(f"Organization: {run_task.organization.id}") if run_task.workspace_run_tasks: - print( - f" Workspace Run Tasks: {len(run_task.workspace_run_tasks)} items" - ) + print(f"Workspace Run Tasks: {len(run_task.workspace_run_tasks)} items") 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 +212,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(f" Name: {updated_task.name}") - print(f" Description: {updated_task.description}") - print(f" URL: {updated_task.url}") - print(f" Enabled: {updated_task.enabled}") + 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 +228,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_tasks_integration.py b/examples/run_tasks_integration.py new file mode 100644 index 0000000..9f19ff2 --- /dev/null +++ b/examples/run_tasks_integration.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python +""" +Run Tasks Integration Example - Real TFC/TFE Testing + +This example shows how to create a webhook server that integrates with +Terraform Cloud/Enterprise run tasks to validate runs and send results back. + +STEP-BY-STEP TESTING WITH REAL TFC/TFE: + +1. START THE SERVER: + python examples/run_tasks_integration.py --port 8888 + +2. MAKE IT ACCESSIBLE (choose one): + + Option A - Using ngrok (for local testing): + - Install: https://ngrok.com/download + - Run: ngrok http 8888 + - Copy the public URL (e.g., https://abc123.ngrok.io) + + Option B - Deploy to cloud (recommended for production): + + AWS EC2: + - Launch EC2 instance (t2.micro sufficient for testing) + - Upload this file: scp run_tasks_integration.py ec2-user@YOUR-IP:~/ + - SSH in: ssh ec2-user@YOUR-IP + - Install Python 3.11+: sudo dnf install python3.11 python3.11-pip + - Install dependencies: python3.11 -m pip install --user pytfe + - Run server: python3.11 run_tasks_integration.py --port 8888 + - Configure security group: Allow port 8888 from 0.0.0.0/0 + - Use public IP: http://YOUR-EC2-IP:8888 + + Heroku (easiest): + - Create Procfile: web: python run_tasks_integration.py --port $PORT + - Create requirements.txt: pytfe>=0.1.0 + - Deploy: git push heroku main + - Use Heroku URL: https://your-app.herokuapp.com + + Google Cloud Run: + - Create Dockerfile: FROM python:3.11 / RUN pip install pytfe / COPY . . / CMD ["python", "run_tasks_integration.py", "--port", "8080"] + - Deploy: gcloud run deploy --source . + - Use Cloud Run URL: https://your-service-hash.run.app + + DigitalOcean Droplet: + - Create Ubuntu droplet + - Upload file and install Python/pytfe + - Run with: python3 run_tasks_integration.py --port 8888 + - Use droplet IP: http://YOUR-DROPLET-IP:8888 + + Benefits of cloud deployment: + - Permanent URL (no ngrok reconnections) + - Better reliability and uptime + - Can handle production workloads + - SSL/HTTPS support available + - Scalable if needed + +3. CREATE RUN TASK IN TFC/TFE: + - Go to: https://app.terraform.io/app/YOUR_ORG/settings/tasks + - Click "Create run task" + - Name: "python-tfe-test" + - URL: Your public URL from step 2 + - Save and wait for verification (check mark) + +4. ATTACH TO WORKSPACE: + - Go to workspace settings → Run Tasks + - Click "Add run task" + - Select "python-tfe-test" + - Enforcement: Advisory (for testing) + - Stage: Pre-plan + - Save + +5. TRIGGER A RUN: + - Go to your workspace + - Click "Actions" → "Start new run" + - Watch this terminal for webhook activity! + - Check TFC/TFE UI for run task results + +CUSTOMIZE VALIDATION LOGIC: +Edit the section around line 80 to add your custom checks: +- Cost validation +- Security scanning (Checkov, tfsec) +- Policy enforcement +- Custom approval workflows + +API Documentation: + https://developer.hashicorp.com/terraform/enterprise/api-docs/run-tasks/run-tasks-integration +""" + +import json +from http.server import BaseHTTPRequestHandler, HTTPServer + +from pytfe import TFEClient +from pytfe.models import RunTaskRequest +from pytfe.resources.run_tasks_integration import ( + TaskResultCallbackOptions, + TaskResultOutcome, + TaskResultTag, +) + + +class RunTaskHandler(BaseHTTPRequestHandler): + """HTTP handler for run task callbacks from TFC/TFE.""" + + def do_POST(self): + """Handle POST request from TFC/TFE run task webhook.""" + # Read the request body + content_length = int(self.headers["Content-Length"]) + body = self.rfile.read(content_length) + + try: + # Parse the incoming run task request + payload = json.loads(body) + print("\n" + "=" * 60) + print("Received Run Task Request") + print("=" * 60) + + # Parse into RunTaskRequest model + request = RunTaskRequest.model_validate(payload) + + print(f"Run ID: {request.run_id}") + print(f"Organization: {request.organization_name}") + print(f"Workspace: {request.workspace_name}") + print(f"Workspace ID: {request.workspace_id}") + print(f"Stage: {request.stage}") + print(f"Callback URL: {request.task_result_callback_url}") + print(f"Is Speculative: {request.is_speculative}") + + # Handle verification requests (test webhooks from TFC/TFE) + if ( + request.organization_name == "test-org" + or request.workspace_name == "test-workspace" + ): + print("\n[OK] Verification request detected - responding with 200 OK") + print("=" * 60 + "\n") + self.send_response(200) + self.send_header("Content-type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"status": "ok"}).encode()) + return + + # =============================================================== + # CUSTOMIZE YOUR VALIDATION LOGIC HERE + # =============================================================== + # This is where you add your custom checks and validation. + # Examples: + # + # 1. Cost Control: + # if estimated_cost > 1000: + # result_status = "failed" + # result_message = f"Cost ${estimated_cost} exceeds limit" + # + # 2. Security Scanning: + # scan_results = run_checkov(request.configuration_version_download_url) + # if scan_results.failed: + # result_status = "failed" + # result_message = "Security scan failed" + # + # 3. Policy Enforcement: + # if not workspace_has_required_tags(request.workspace_name): + # result_status = "failed" + # result_message = "Workspace missing required tags" + # + # 4. Custom Approval: + # if request.workspace_name.startswith("prod-"): + # result_status = "failed" + # result_message = "Production changes require manual approval" + + # For this example, we'll just pass the task + result_status = "passed" + result_message = "All checks passed successfully" + + # Create detailed outcomes (optional but recommended) + outcomes = [ + TaskResultOutcome( + outcome_id="check-1", + description="Configuration validation passed", + body="All Terraform configurations are valid and follow best practices.", + url="https://example.com/results/check-1", + tags={ + "Status": [TaskResultTag(label="Passed", level="info")], + "Category": [TaskResultTag(label="Validation")], + }, + ) + ] + + # Create callback options + callback_options = TaskResultCallbackOptions( + status=result_status, + message=result_message, + url="https://example.com/full-results", + outcomes=outcomes, + ) + + # Initialize client and send callback + print("\nInitializing TFEClient...") + print(f"Access token from webhook: {request.access_token[:10]}***") + client = TFEClient() + print("Client initialized successfully") + + print(f"Sending callback to: {request.task_result_callback_url[:50]}...") + client.run_tasks_integration.callback( + callback_url=request.task_result_callback_url, + access_token=request.access_token, + options=callback_options, + ) + + print(f"\n[SUCCESS] Callback sent successfully: {result_status}") + print("=" * 60 + "\n") + + # Respond to TFC/TFE + self.send_response(200) + self.send_header("Content-type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"status": "received"}).encode()) + + except Exception as e: + print(f"Error processing request: {e}") + self.send_response(500) + self.send_header("Content-type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"error": str(e)}).encode()) + + def log_message(self, format, *args): + """Suppress default HTTP logging.""" + pass + + +def run_server(port=8080): + """Start the run task callback server.""" + server_address = ("", port) + httpd = HTTPServer(server_address, RunTaskHandler) + + print("=" * 60) + print("Run Tasks Integration Callback Server") + print("=" * 60) + print(f"Listening on http://localhost:{port}") + print("\nFor local testing:") + print(" 1. Use ngrok or similar tool to expose this server:") + print(f" ngrok http {port}") + print(" 2. Configure your run task in TFC/TFE with the ngrok URL") + print(" 3. Trigger a run in your workspace") + print("\nWaiting for requests from TFC/TFE...") + print("=" * 60 + "\n") + + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\n\nShutting down server...") + httpd.shutdown() + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser( + description="Run Tasks Integration callback server" + ) + parser.add_argument( + "--port", + type=int, + default=8080, + help="Port to listen on (default: 8080)", + ) + args = parser.parse_args() + + run_server(port=args.port) diff --git a/examples/run_trigger.py b/examples/run_trigger.py index c651210..c6fed59 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: @@ -150,15 +150,15 @@ def main(): print( f"{i:2d}. {trigger.sourceable_name} → {trigger.workspace_name}" ) - print(f" ID: {trigger.id}") - print(f" Created: {trigger.created_at}") + print(f"ID: {trigger.id}") + print(f"Created: {trigger.created_at}") if trigger.sourceable and hasattr(trigger.sourceable, "id"): - print(f" Source Workspace ID: {trigger.sourceable.id}") + print(f"Source Workspace ID: {trigger.sourceable.id}") if trigger.workspace and hasattr(trigger.workspace, "id"): - print(f" Target Workspace ID: {trigger.workspace.id}") + 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,11 +178,11 @@ 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(f" ID: {run_trigger.id}") - print(f" Source: {run_trigger.sourceable_name}") - print(f" Target: {run_trigger.workspace_name}") - print(f" Created: {run_trigger.created_at}") + 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}") + print(f"Created: {run_trigger.created_at}") if run_trigger.sourceable: print( @@ -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,37 +211,37 @@ def main(): print("Reading run trigger details...") run_trigger = client.run_triggers.read(args.trigger_id) - print("✓ Successfully read run trigger!") - print(f" ID: {run_trigger.id}") - print(f" Type: {run_trigger.type}") - print(f" Source: {run_trigger.sourceable_name}") - print(f" Target: {run_trigger.workspace_name}") - print(f" Created: {run_trigger.created_at}") + print("Successfully read run trigger!") + print(f"ID: {run_trigger.id}") + print(f"Type: {run_trigger.type}") + print(f"Source: {run_trigger.sourceable_name}") + print(f"Target: {run_trigger.workspace_name}") + print(f"Created: {run_trigger.created_at}") # Show detailed workspace information if run_trigger.sourceable: - print(" Source Workspace Details:") - print(f" - Name: {run_trigger.sourceable.name}") - print(f" - ID: {run_trigger.sourceable.id}") + print("Source Workspace Details:") + print(f"- Name: {run_trigger.sourceable.name}") + print(f"- ID: {run_trigger.sourceable.id}") if ( hasattr(run_trigger.sourceable, "organization") and run_trigger.sourceable.organization ): - print(f" - Organization: {run_trigger.sourceable.organization}") + print(f"- Organization: {run_trigger.sourceable.organization}") if run_trigger.workspace: - print(" Target Workspace Details:") - print(f" - Name: {run_trigger.workspace.name}") - print(f" - ID: {run_trigger.workspace.id}") + print("Target Workspace Details:") + print(f"- Name: {run_trigger.workspace.name}") + print(f"- ID: {run_trigger.workspace.id}") if ( hasattr(run_trigger.workspace, "organization") and run_trigger.workspace.organization ): - print(f" - Organization: {run_trigger.workspace.organization}") + print(f"- Organization: {run_trigger.workspace.organization}") 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..743bd98 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("Token Type: Organization Token (AT-*)") + print("SSH Keys API does NOT support Organization Tokens") + print("Please create a User Token instead") print("") - 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") + 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("Token Type: User Token (TF-*)") + 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("Token Type: User/Team Token (.atlasv1. format)") + 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(f"Token Type: Unknown format ({token[:10]}...)") + 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,9 +100,9 @@ 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}") + print(f"- ID: {key.id}, Name: {key.name}") # 2. Create a new SSH key print("\n2. Creating a new SSH key...") @@ -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" Total pages: {paginated_keys.total_pages}") - print(f" Total count: {paginated_keys.total_count}") + 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(" - 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(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") 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/variable_sets.py b/examples/variable_sets.py index 4a6ca50..0b41084 100644 --- a/examples/variable_sets.py +++ b/examples/variable_sets.py @@ -68,7 +68,7 @@ def variable_set_example(): print(f"Found {len(variable_sets)} existing variable sets") for vs in variable_sets[:3]: # Show first 3 - print(f" - {vs.name} (ID: {vs.id}, Global: {vs.global_})") + print(f"- {vs.name} (ID: {vs.id}, Global: {vs.global_})") print() # 2. Create a new variable set @@ -87,9 +87,9 @@ def variable_set_example(): print( f"Created variable set: {new_variable_set.name} (ID: {new_variable_set.id})" ) - print(f" Description: {new_variable_set.description}") - print(f" Global: {new_variable_set.global_}") - print(f" Priority: {new_variable_set.priority}") + print(f"Description: {new_variable_set.description}") + print(f"Global: {new_variable_set.global_}") + print(f"Priority: {new_variable_set.priority}") print() # 3. Create variables in the variable set @@ -155,8 +155,8 @@ def variable_set_example(): for var in variables: sensitive_note = " (sensitive)" if var.sensitive else "" hcl_note = " (HCL)" if var.hcl else "" - print(f" - {var.key}: {var.category.value}{sensitive_note}{hcl_note}") - print(f" Description: {var.description}") + print(f"- {var.key}: {var.category.value}{sensitive_note}{hcl_note}") + print(f"Description: {var.description}") print() # 5. Update a variable @@ -171,7 +171,7 @@ def variable_set_example(): created_variable_set_id, tf_variable.id, update_var_options ) print(f"Updated variable: {updated_variable.key} = {updated_variable.value}") - print(f" New description: {updated_variable.description}") + print(f"New description: {updated_variable.description}") print() # 6. Update the variable set itself @@ -186,8 +186,8 @@ def variable_set_example(): created_variable_set_id, update_set_options ) print(f"Updated variable set: {updated_variable_set.name}") - print(f" New description: {updated_variable_set.description}") - print(f" Priority: {updated_variable_set.priority}") + print(f"New description: {updated_variable_set.description}") + print(f"Priority: {updated_variable_set.priority}") print() # 7. Example: Apply to workspaces (if any exist) @@ -279,8 +279,8 @@ def variable_set_example(): created_variable_set_id, read_options ) print(f"Variable set: {detailed_varset.name}") - print(f" Variables count: {len(detailed_varset.vars or [])}") - print(f" Workspaces count: {len(detailed_varset.workspaces or [])}") + print(f"Variables count: {len(detailed_varset.vars or [])}") + print(f"Workspaces count: {len(detailed_varset.workspaces or [])}") print() print("=== Variable Set Operations Completed Successfully ===") @@ -347,8 +347,8 @@ def global_variable_set_example(): global_varset = client.variable_sets.create(org_name, global_create_options) created_variable_set_id = global_varset.id print(f"Created global variable set: {global_varset.name}") - print(f" Global: {global_varset.global_}") - print(f" Priority: {global_varset.priority}") + print(f"Global: {global_varset.global_}") + print(f"Priority: {global_varset.priority}") # Add some common variables print("\nAdding common variables...") @@ -376,7 +376,7 @@ def global_variable_set_example(): variable = client.variable_set_variables.create( created_variable_set_id, var_options ) - print(f" Added {variable.category.value} variable: {variable.key}") + print(f"Added {variable.category.value} variable: {variable.key}") print(f"\nGlobal variable set is now available to all workspaces in {org_name}") @@ -463,7 +463,7 @@ def project_scoped_variable_set_example(): variable = client.variable_set_variables.create( created_variable_set_id, var_options ) - print(f" Added variable: {variable.key}") + print(f"Added variable: {variable.key}") print( f"\nProject-scoped variable set is available to workspaces in project: {target_project.name}" diff --git a/examples/variables.py b/examples/variables.py index bc5227e..0b3fe34 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" ID: {variable.id}, Category: {variable.category}") + 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" ID: {variable.id}, Category: {variable.category}") + 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" ID: {variable.id}, Category: {variable.category}") + 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) @@ -101,11 +101,9 @@ def main(): print(f"Found {len(variables)} workspace variables:") for var in variables: value_display = "***SENSITIVE***" if var.sensitive else var.value - print( - f" • {var.key} = {value_display} ({var.category}) [ID: {var.id}]" - ) + print(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):") @@ -116,11 +114,9 @@ def main(): print(f"Found {len(all_variables)} total variables (including inherited):") for var in all_variables: value_display = "***SENSITIVE***" if var.sensitive else var.value - print( - f" • {var.key} = {value_display} ({var.category}) [ID: {var.id}]" - ) + print(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,18 +130,18 @@ 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" ID: {variable.id}") - print(f" Description: {variable.description}") - print(f" Category: {variable.category}") - print(f" HCL: {variable.hcl}") - print(f" Sensitive: {variable.sensitive}") + print(f"Read variable: {variable.key} = {variable.value}") + 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}") if hasattr(variable, "version_id"): - print(f" Version ID: {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,15 +171,15 @@ 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}") - print(f" HCL: {updated_variable.hcl}") - print(f" Sensitive: {updated_variable.sensitive}") - print(f" ID: {updated_variable.id}") + print(f"Description: {updated_variable.description}") + print(f"Category: {updated_variable.category}") + print(f"HCL: {updated_variable.hcl}") + 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:") @@ -197,25 +193,25 @@ def main(): # First read the variable to confirm it exists before deletion variable = client.variables.read(workspace_id, test_variable_id) print(f"Variable to delete: {variable.key} = {variable.value}") - print(f" ID: {variable.id}") + print(f"ID: {variable.id}") # 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 +224,14 @@ def main(): value_display = ( "***SENSITIVE***" if variable.sensitive else variable.value ) - 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}") + 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 +258,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}") + 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 +275,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:") @@ -298,14 +294,14 @@ def main(): f"Warning: {len(remaining_test_vars)} test variables still exist:" ) for var in remaining_test_vars: - print(f" • {var.key} [ID: {var.id}]") + 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..4dfb643 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: @@ -184,12 +184,12 @@ def main(): else: for i, ws in enumerate(workspace_list, 1): print(f"{i:2d}. {ws.name}") - print(f" ID: {ws.id}") - print(f" Execution Mode: {ws.execution_mode}") - print(f" Auto Apply: {ws.auto_apply}") + print(f"ID: {ws.id}") + print(f"Execution Mode: {ws.execution_mode}") + print(f"Auto Apply: {ws.auto_apply}") print() except Exception as e: - print(f"✗ Error listing workspaces: {e}") + print(f"Error listing workspaces: {e}") return # 2) Create a new workspace if requested @@ -216,13 +216,13 @@ def main(): f"Creating workspace '{workspace_name}' in organization '{args.org}'..." ) workspace = client.workspaces.create(args.org, create_options) - print("✓ Successfully created workspace!") - print(f" Name: {workspace.name}") - print(f" ID: {workspace.id}") - print(f" Description: {workspace.description}") - print(f" Execution Mode: {workspace.execution_mode}") - print(f" Auto Apply: {workspace.auto_apply}") - print(f" Terraform Version: {workspace.terraform_version}") + print("Successfully created workspace!") + print(f"Name: {workspace.name}") + print(f"ID: {workspace.id}") + print(f"Description: {workspace.description}") + print(f"Execution Mode: {workspace.execution_mode}") + print(f"Auto Apply: {workspace.auto_apply}") + print(f"Terraform Version: {workspace.terraform_version}") print() args.workspace = ( @@ -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,20 +246,20 @@ def main(): workspace = client.workspaces.read_with_options( args.workspace, read_options, organization=args.org ) - print(f"✓ read_with_options: {workspace.name}") - print(f" ID: {workspace.id}") - print(f" Description: {workspace.description}") - print(f" Execution Mode: {workspace.execution_mode}") - print(f" Auto Apply: {workspace.auto_apply}") - print(f" Locked: {workspace.locked}") - print(f" Terraform Version: {workspace.terraform_version}") - print(f" Working Directory: {workspace.working_directory}") + print(f"read_with_options: {workspace.name}") + print(f"ID: {workspace.id}") + print(f"Description: {workspace.description}") + print(f"Execution Mode: {workspace.execution_mode}") + print(f"Auto Apply: {workspace.auto_apply}") + print(f"Locked: {workspace.locked}") + print(f"Terraform Version: {workspace.terraform_version}") + print(f"Working Directory: {workspace.working_directory}") # Set workspace_id for further operations 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" Description: {workspace.description}") - print(f" Execution Mode: {workspace.execution_mode}") + 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(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("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(f" Workspace: {workspace.name}") + 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,10 +423,10 @@ 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)") + print(f"force_unlock result: {e}") + print("(Expected if workspace wasn't locked)") # 11) Test SSH key operations if (args.all_tests or args.ssh_keys) and args.workspace_id: @@ -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,17 +473,17 @@ 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}") + print(f"remove_tags: {e}") try: # Test list_tag_bindings print("Testing list_tag_bindings()...") bindings = list(client.workspaces.list_tag_bindings(args.workspace_id)) - print(f"✓ list_tag_bindings: Found {len(bindings)} tag bindings") + 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,20 +492,20 @@ 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(" add_remote_state_consumers() - Requires consumer workspace IDs") - print(" update_remote_state_consumers() - Requires specific setup") - print(" remove_remote_state_consumers() - Requires existing consumers") + print("list_remote_state_consumers() - Already tested above") + print("add_remote_state_consumers() - Requires consumer workspace IDs") + print("update_remote_state_consumers() - Requires specific setup") + print("remove_remote_state_consumers() - Requires existing consumers") # 14) Test data retention policies if (args.all_tests or args.retention) and args.workspace_id: @@ -514,26 +514,26 @@ 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)") + print(f"read_data_retention_policy: {e}") + print("(Expected if no policy is set)") try: print("Testing read_data_retention_policy_choice()...") 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}") + print(f"read_data_retention_policy_choice: {e}") print("Available policy setting methods:") - print(" set_data_retention_policy() - Set custom retention policy") - print(" set_data_retention_policy_delete_older() - Delete older runs") - print(" set_data_retention_policy_dont_delete() - Keep all runs") - print(" delete_data_retention_policy() - Remove retention policy") - print(" (Not executed to preserve workspace settings)") + print("set_data_retention_policy() - Set custom retention policy") + print("set_data_retention_policy_delete_older() - Delete older runs") + print("set_data_retention_policy_dont_delete() - Keep all runs") + print("delete_data_retention_policy() - Remove retention policy") + print("(Not executed to preserve workspace settings)") # 15) Test readme functionality if (args.all_tests or args.readme) and args.workspace_id: @@ -543,17 +543,17 @@ 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]}..." + f"Preview: {readme[:100]}..." if len(readme) > 100 - else f" Content: {readme}" + else f"Content: {readme}" ) else: - print(" readme: No README content found") + print("readme: No README content found") except Exception as e: - print(f" readme result: {e}") - print(" (Expected if workspace has no README)") + print(f"readme result: {e}") + print("(Expected if workspace has no README)") # 16) Delete workspace if requested (should be last operation) if args.delete and args.workspace: diff --git a/examples/workspace_resources.py b/examples/workspace_resources.py new file mode 100644 index 0000000..15fbf1b --- /dev/null +++ b/examples/workspace_resources.py @@ -0,0 +1,118 @@ +"""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/pyproject.toml b/pyproject.toml index 31f289f..ad9117e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pytfe" -version = "0.0.1-alpha" +version = "0.1.2" description = "Official Python SDK for HashiCorp Terraform Cloud / Terraform Enterprise (TFE) API v2" readme = "README.md" license = { text = "MPL-2.0" } diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 3894886..fee5cf6 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -9,28 +9,33 @@ 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 from .resources.policy_check import PolicyChecks 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_outcome import 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 from .resources.registry_module import RegistryModules from .resources.registry_provider import RegistryProviders -from .resources.reserved_tag_key import ReservedTagKey +from .resources.registry_provider_version import RegistryProviderVersions +from .resources.reserved_tag_key import ReservedTagKeys from .resources.run import Runs from .resources.run_event import RunEvents from .resources.run_task import RunTasks +from .resources.run_tasks_integration import RunTasksIntegration from .resources.run_trigger import RunTriggers from .resources.ssh_keys import SSHKeys from .resources.state_version_outputs import StateVersionOutputs 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 @@ -64,18 +69,22 @@ 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) 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) + self.registry_provider_versions = RegistryProviderVersions(self._transport) # State and execution resources self.state_versions = StateVersions(self._transport) self.state_version_outputs = StateVersionOutputs(self._transport) self.run_tasks = RunTasks(self._transport) + self.run_tasks_integration = RunTasksIntegration(self._transport) self.run_triggers = RunTriggers(self._transport) self.runs = Runs(self._transport) self.query_runs = QueryRuns(self._transport) @@ -84,6 +93,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) @@ -91,7 +101,7 @@ def __init__(self, config: TFEConfig | None = None): self.ssh_keys = SSHKeys(self._transport) # Reserved Tag Key - self.reserved_tag_key = ReservedTagKey(self._transport) + self.reserved_tag_key = ReservedTagKeys(self._transport) def close(self) -> None: try: diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index 3eac2be..168d37b 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" @@ -460,3 +468,62 @@ 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) + + +# Policy Set Outcome errors +class InvalidPolicySetOutcomeIDError(InvalidValues): + """Raised when an invalid policy set outcome ID is provided.""" + + def __init__(self, message: str = "invalid value for policy set outcome ID"): + super().__init__(message) + + +# Registry Provider Version errors +class RequiredPrivateRegistryError(RequiredFieldMissing): + """Raised when a required private registry field is missing.""" + + def __init__(self, message: str = "only private registry is allowed"): + super().__init__(message) + + +class InvalidVersionError(InvalidValues): + """Raised when an invalid version is provided.""" + + def __init__(self, message: str = "invalid value for version"): + super().__init__(message) + + +class InvalidKeyIDError(InvalidValues): + """Raised when an invalid key ID is provided.""" + + def __init__(self, message: str = "invalid value for key-id"): + super().__init__(message) diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index f3eb33b..2d1f75c 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -71,7 +71,6 @@ ) from .oauth_token import ( OAuthToken, - OAuthTokenList, OAuthTokenListOptions, OAuthTokenUpdateOptions, ) @@ -86,6 +85,14 @@ ReadRunQueueOptions, RunQueue, ) +from .organization_membership import ( + OrganizationMembership, + OrganizationMembershipCreateOptions, + OrganizationMembershipListOptions, + OrganizationMembershipReadOptions, + OrganizationMembershipStatus, + OrgMembershipIncludeOpt, +) from .policy import ( Policy, PolicyCreateOptions, @@ -110,7 +117,6 @@ from .policy_evaluation import ( PolicyAttachable, PolicyEvaluation, - PolicyEvaluationList, PolicyEvaluationListOptions, PolicyEvaluationStatus, PolicyEvaluationStatusTimestamps, @@ -133,6 +139,12 @@ PolicySetRemoveWorkspacesOptions, PolicySetUpdateOptions, ) +from .policy_set_parameter import ( + PolicySetParameter, + PolicySetParameterCreateOptions, + PolicySetParameterListOptions, + PolicySetParameterUpdateOptions, +) from .policy_types import ( EnforcementLevel, PolicyKind, @@ -148,16 +160,15 @@ # ── Query Runs ──────────────────────────────────────────────────────────────── from .query_run import ( QueryRun, - QueryRunCancelOptions, + QueryRunActions, QueryRunCreateOptions, - QueryRunForceCancelOptions, - QueryRunList, + QueryRunIncludeOpt, QueryRunListOptions, - QueryRunLogs, QueryRunReadOptions, - QueryRunResults, + QueryRunSource, QueryRunStatus, - QueryRunType, + QueryRunStatusTimestamps, + QueryRunVariable, ) # ── Registry Modules / Providers ────────────────────────────────────────────── @@ -203,12 +214,18 @@ RegistryProviderPermissions, RegistryProviderReadOptions, ) +from .registry_provider_version import ( + RegistryProviderVersion, + RegistryProviderVersionCreateOptions, + RegistryProviderVersionID, + RegistryProviderVersionListOptions, + RegistryProviderVersionPermissions, +) # ── Reserved Tag Keys ───────────────────────────────────────────────────────── from .reserved_tag_key import ( ReservedTagKey, ReservedTagKeyCreateOptions, - ReservedTagKeyList, ReservedTagKeyListOptions, ReservedTagKeyUpdateOptions, ) @@ -274,6 +291,28 @@ SSHKeyListOptions, SSHKeyUpdateOptions, ) +from .task_result import ( + TaskEnforcementLevel, + TaskResult, + TaskResultStatus, + TaskResultStatusTimestamps, +) +from .task_stages import ( + Actions, + Permissions, + Stage, + TaskStage, + TaskStageListOptions, + TaskStageOverrideOptions, + TaskStageReadOptions, + TaskStageStatus, + TaskStageStatusTimestamps, +) +from .team import ( + OrganizationAccess, + Team, + TeamPermissions, +) # Variables from .variable import ( @@ -334,6 +373,12 @@ WorkspaceUpdateRemoteStateConsumersOptions, ) +# ── Workspace Resources ─────────────────────────────────────────────────────── +from .workspace_resource import ( + WorkspaceResource, + WorkspaceResourceListOptions, +) + # ── Public surface ──────────────────────────────────────────────────────────── __all__ = [ # OAuth @@ -349,7 +394,6 @@ "ServiceProviderType", # OAuth token "OAuthToken", - "OAuthTokenList", "OAuthTokenListOptions", "OAuthTokenUpdateOptions", # SSH keys @@ -361,7 +405,6 @@ # Reserved tag keys "ReservedTagKey", "ReservedTagKeyCreateOptions", - "ReservedTagKeyList", "ReservedTagKeyListOptions", "ReservedTagKeyUpdateOptions", # Agent & pools @@ -430,18 +473,23 @@ "RegistryProviderListOptions", "RegistryProviderPermissions", "RegistryProviderReadOptions", + # Registry provider versions + "RegistryProviderVersion", + "RegistryProviderVersionCreateOptions", + "RegistryProviderVersionID", + "RegistryProviderVersionListOptions", + "RegistryProviderVersionPermissions", # Query runs "QueryRun", - "QueryRunCancelOptions", + "QueryRunActions", "QueryRunCreateOptions", - "QueryRunForceCancelOptions", - "QueryRunList", + "QueryRunIncludeOpt", "QueryRunListOptions", - "QueryRunLogs", "QueryRunReadOptions", - "QueryRunResults", + "QueryRunSource", "QueryRunStatus", - "QueryRunType", + "QueryRunStatusTimestamps", + "QueryRunVariable", # Core (from old types.py, now split) "Entitlements", "ExecutionMode", @@ -449,6 +497,15 @@ "Organization", "OrganizationCreateOptions", "OrganizationUpdateOptions", + "OrganizationMembership", + "OrganizationMembershipCreateOptions", + "OrganizationMembershipListOptions", + "OrganizationMembershipReadOptions", + "OrganizationMembershipStatus", + "OrgMembershipIncludeOpt", + "OrganizationAccess", + "Team", + "TeamPermissions", "Project", "ProjectAddTagBindingsOptions", "ProjectCreateOptions", @@ -496,6 +553,9 @@ "WorkspaceTagListOptions", "WorkspaceUpdateOptions", "WorkspaceUpdateRemoteStateConsumersOptions", + # Workspace Resources + "WorkspaceResource", + "WorkspaceResourceListOptions", "RunQueue", "ReadRunQueueOptions", # Runs @@ -559,7 +619,6 @@ # Policy Evaluation "PolicyAttachable", "PolicyEvaluation", - "PolicyEvaluationList", "PolicyEvaluationListOptions", "PolicyEvaluationStatus", "PolicyEvaluationStatusTimestamps", @@ -586,6 +645,11 @@ "PolicySetRemoveWorkspaceExclusionsOptions", "PolicySetRemoveProjectsOptions", "PolicySetUpdateOptions", + # Policy Set Parameters + "PolicySetParameter", + "PolicySetParameterCreateOptions", + "PolicySetParameterListOptions", + "PolicySetParameterUpdateOptions", "PolicyKind", "EnforcementLevel", # Variable Sets diff --git a/src/pytfe/models/oauth_token.py b/src/pytfe/models/oauth_token.py index c6b004b..a70c20e 100644 --- a/src/pytfe/models/oauth_token.py +++ b/src/pytfe/models/oauth_token.py @@ -15,7 +15,6 @@ class OAuthToken(BaseModel): model_config = ConfigDict(extra="forbid") id: str = Field(..., description="OAuth token ID") - uid: str = Field(..., description="OAuth token UID") created_at: datetime = Field(..., description="Creation timestamp") has_ssh_key: bool = Field(..., description="Whether the token has an SSH key") service_provider_user: str = Field(..., description="Service provider user") @@ -26,26 +25,12 @@ class OAuthToken(BaseModel): ) -class OAuthTokenList(BaseModel): - """List of OAuth tokens with pagination information.""" - - model_config = ConfigDict(extra="forbid") - - items: list[OAuthToken] = Field(default_factory=list, description="OAuth tokens") - current_page: int | None = Field(None, description="Current page number") - prev_page: int | None = Field(None, description="Previous page number") - next_page: int | None = Field(None, description="Next page number") - total_pages: int | None = Field(None, description="Total number of pages") - total_count: int | None = Field(None, description="Total count of items") - - class OAuthTokenListOptions(BaseModel): """Options for listing OAuth tokens.""" - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) - page_number: int | None = Field(None, description="Page number") - page_size: int | None = Field(None, description="Page size") + page_size: int | None = Field(None, alias="page[size]", description="Page size") class OAuthTokenUpdateOptions(BaseModel): @@ -63,7 +48,6 @@ class OAuthTokenUpdateOptions(BaseModel): from .oauth_client import OAuthClient # noqa: F401 OAuthToken.model_rebuild() - OAuthTokenList.model_rebuild() except ImportError: # If OAuthClient is not available, create a dummy class pass 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/policy_evaluation.py b/src/pytfe/models/policy_evaluation.py index 86175e9..49ad257 100644 --- a/src/pytfe/models/policy_evaluation.py +++ b/src/pytfe/models/policy_evaluation.py @@ -37,7 +37,7 @@ class PolicyEvaluation(BaseModel): updated_at: datetime | None = Field(None, alias="updated-at") # The task stage the policy evaluation belongs to - task_stage: PolicyAttachable | None = Field(None, alias="policy-attachable") + policy_attachable: PolicyAttachable | None = Field(None, alias="policy-attachable") class PolicyEvaluationStatusTimestamps(BaseModel): @@ -72,23 +72,9 @@ class PolicyResultCount(BaseModel): errored: int | None = Field(None, alias="errored") -class PolicyEvaluationList(BaseModel): - """PolicyEvaluationList represents a list of policy evaluations""" - - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) - - items: list[PolicyEvaluation] | None = Field(default_factory=list) - current_page: int | None = None - next_page: str | None = None - prev_page: str | None = None - total_count: int | None = None - total_pages: int | None = None - - class PolicyEvaluationListOptions(BaseModel): """PolicyEvaluationListOptions represents the options for listing policy evaluations""" model_config = ConfigDict(populate_by_name=True, validate_by_name=True) - page_number: int | None = Field(None, alias="page[number]") page_size: int | None = Field(None, alias="page[size]") diff --git a/src/pytfe/models/policy_set_outcome.py b/src/pytfe/models/policy_set_outcome.py index 4059546..ffb9723 100644 --- a/src/pytfe/models/policy_set_outcome.py +++ b/src/pytfe/models/policy_set_outcome.py @@ -34,19 +34,6 @@ class Outcome(BaseModel): description: str | None = Field(None, alias="description") -class PolicySetOutcomeList(BaseModel): - """PolicySetOutcomeList represents a list of policy set outcomes""" - - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) - - items: list[PolicySetOutcome] | None = Field(default_factory=list) - current_page: int | None = None - next_page: str | None = None - prev_page: str | None = None - total_count: int | None = None - total_pages: int | None = None - - class PolicySetOutcomeListFilter(BaseModel): """PolicySetOutcomeListFilter represents the filters that are supported while listing a policy set outcome""" @@ -62,5 +49,4 @@ class PolicySetOutcomeListOptions(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) filter: dict[str, PolicySetOutcomeListFilter] | None = None - page_number: int | None = Field(None, alias="page[number]") page_size: int | None = Field(None, alias="page[size]") diff --git a/src/pytfe/models/policy_set_parameter.py b/src/pytfe/models/policy_set_parameter.py new file mode 100644 index 0000000..01a88c2 --- /dev/null +++ b/src/pytfe/models/policy_set_parameter.py @@ -0,0 +1,44 @@ +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 PolicySetParameterListOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + 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/models/query_run.py b/src/pytfe/models/query_run.py index 3670830..cdcfe57 100644 --- a/src/pytfe/models/query_run.py +++ b/src/pytfe/models/query_run.py @@ -2,7 +2,6 @@ from datetime import datetime from enum import Enum -from typing import Any from pydantic import BaseModel, ConfigDict, Field @@ -11,204 +10,166 @@ class QueryRunStatus(str, Enum): """QueryRunStatus represents the status of a query run operation.""" PENDING = "pending" + QUEUED = "queued" RUNNING = "running" - COMPLETED = "completed" + FINISHED = "finished" ERRORED = "errored" CANCELED = "canceled" -class QueryRunType(str, Enum): - """QueryRunType represents different types of query runs.""" +class QueryRunSource(str, Enum): + """QueryRunSource represents the source of a query run.""" - FILTER = "filter" - SEARCH = "search" - ANALYTICS = "analytics" + API = "tfe-api" -class QueryRun(BaseModel): - """Represents a query run in Terraform Enterprise.""" +class QueryRunActions(BaseModel): + """Actions available on a query run.""" model_config = ConfigDict(populate_by_name=True) - id: str = Field(..., description="The unique identifier for this query run") - type: str = Field(default="query-runs", description="The type of this resource") - query: str = Field(..., description="The query string used for this run") - query_type: QueryRunType = Field( - ..., alias="query-type", description="The type of query being executed" - ) - status: QueryRunStatus = Field( - ..., description="The current status of the query run" + is_cancelable: bool = Field( + ..., alias="is-cancelable", description="Whether the query run can be canceled" ) - results_count: int | None = Field( - None, alias="results-count", description="The number of results returned" + is_force_cancelable: bool = Field( + ..., + alias="is-force-cancelable", + description="Whether the query run can be force canceled", ) - created_at: datetime = Field( - ..., alias="created-at", description="The time this query run was created" + + +class QueryRunStatusTimestamps(BaseModel): + """Timestamps for each status of a query run.""" + + model_config = ConfigDict(populate_by_name=True) + + pending_at: datetime | None = Field( + None, alias="pending-at", description="When the query run was created" ) - updated_at: datetime = Field( - ..., alias="updated-at", description="The time this query run was last updated" + queued_at: datetime | None = Field( + None, alias="queued-at", description="When the query run was queued" ) - started_at: datetime | None = Field( - None, alias="started-at", description="The time this query run was started" + running_at: datetime | None = Field( + None, alias="running-at", description="When the query run started running" ) finished_at: datetime | None = Field( - None, alias="finished-at", description="The time this query run was finished" - ) - error_message: str | None = Field( - None, alias="error-message", description="Error message if the query run failed" - ) - logs_url: str | None = Field( - None, alias="logs-url", description="URL to retrieve the query run logs" - ) - results_url: str | None = Field( - None, alias="results-url", description="URL to retrieve the query run results" - ) - workspace_id: str | None = Field( None, - alias="workspace-id", - description="The workspace ID if query is workspace-scoped", + alias="finished-at", + description="When the query run finished successfully", ) - organization_name: str | None = Field( - None, alias="organization-name", description="The organization name" + errored_at: datetime | None = Field( + None, alias="errored-at", description="When the query run encountered an error" ) - timeout_seconds: int | None = Field( - None, alias="timeout-seconds", description="Query timeout in seconds" - ) - max_results: int | None = Field( - None, alias="max-results", description="Maximum number of results to return" + canceled_at: datetime | None = Field( + None, alias="canceled-at", description="When the query run was canceled" ) -class QueryRunCreateOptions(BaseModel): - """Options for creating a new query run.""" +class QueryRunVariable(BaseModel): + """A variable for a query run.""" + + key: str = Field(..., description="Variable key") + value: str = Field(..., description="Variable value") + + +class QueryRun(BaseModel): + """Represents a query run in Terraform Enterprise.""" model_config = ConfigDict(populate_by_name=True) - query: str = Field(..., description="The query string to execute") - query_type: QueryRunType = Field( - ..., alias="query-type", description="The type of query being executed" + id: str = Field(..., description="The unique identifier for this query run") + type: str = Field(default="queries", description="The type of this resource") + actions: QueryRunActions | None = Field( + None, description="Actions available on this query run" ) - workspace_id: str | None = Field( - None, - alias="workspace-id", - description="The workspace ID if query is workspace-scoped", + canceled_at: datetime | None = Field( + None, alias="canceled-at", description="When the query run was canceled" ) - organization_name: str | None = Field( - None, alias="organization-name", description="The organization name" + created_at: datetime = Field( + ..., alias="created-at", description="The time this query run was created" ) - timeout_seconds: int | None = Field( - None, - alias="timeout-seconds", - description="Query timeout in seconds", - gt=0, - le=3600, + updated_at: datetime | None = Field( + None, alias="updated-at", description="The time this query run was last updated" ) - max_results: int | None = Field( + source: QueryRunSource | str = Field(..., description="The source of the query run") + status: QueryRunStatus = Field( + ..., description="The current status of the query run" + ) + status_timestamps: QueryRunStatusTimestamps | None = Field( None, - alias="max-results", - description="Maximum number of results to return", - gt=0, - le=10000, + alias="status-timestamps", + description="Timestamps for each status of the query run", ) - filters: dict[str, Any] | None = Field( - None, description="Additional filters to apply to the query" + variables: list[QueryRunVariable] | None = Field( + None, description="Run-specific variable values" ) - - -class QueryRunListOptions(BaseModel): - """Options for listing query runs.""" - - model_config = ConfigDict(populate_by_name=True) - - page_number: int | None = Field( - None, alias="page[number]", description="Page number to retrieve", ge=1 + log_read_url: str | None = Field( + None, alias="log-read-url", description="URL to retrieve the query run logs" ) - page_size: int | None = Field( - None, alias="page[size]", description="Number of items per page", ge=1, le=100 + # Relationships + workspace_id: str | None = Field( + None, description="The workspace ID associated with this query run" ) - query_type: QueryRunType | None = Field( - None, alias="filter[query-type]", description="Filter by query type" + configuration_version_id: str | None = Field( + None, description="The configuration version ID used for this query run" ) - status: QueryRunStatus | None = Field( - None, alias="filter[status]", description="Filter by status" + created_by_id: str | None = Field( + None, description="The user ID who created this query run" ) - workspace_id: str | None = Field( - None, alias="filter[workspace-id]", description="Filter by workspace ID" - ) - organization_name: str | None = Field( - None, - alias="filter[organization-name]", - description="Filter by organization name", + canceled_by_id: str | None = Field( + None, description="The user ID who canceled this query run" ) -class QueryRunReadOptions(BaseModel): - """Options for reading a query run with additional data.""" +class QueryRunCreateOptions(BaseModel): + """Options for creating a new query run.""" model_config = ConfigDict(populate_by_name=True) - include_results: bool | None = Field( - None, alias="include[results]", description="Include query results in response" + source: QueryRunSource | str = Field(..., description="The source of the query run") + variables: list[QueryRunVariable] | None = Field( + None, description="Run-specific variable values" ) - include_logs: bool | None = Field( - None, alias="include[logs]", description="Include query logs in response" + workspace_id: str = Field( + ..., + alias="workspace-id", + description="The workspace ID to run the query against", ) - - -class QueryRunCancelOptions(BaseModel): - """Options for canceling a query run.""" - - model_config = ConfigDict(populate_by_name=True) - - reason: str | None = Field(None, description="Reason for canceling the query run") - - -class QueryRunForceCancelOptions(BaseModel): - """Options for force canceling a query run.""" - - model_config = ConfigDict(populate_by_name=True) - - reason: str | None = Field( - None, description="Reason for force canceling the query run" + configuration_version_id: str | None = Field( + None, + alias="configuration-version-id", + description="The configuration version ID to use for the query", ) -class QueryRunList(BaseModel): - """Represents a paginated list of query runs.""" +class QueryRunIncludeOpt(str, Enum): + """Options for including related resources in query run requests.""" - model_config = ConfigDict(populate_by_name=True) - - items: list[QueryRun] = Field( - default_factory=list, description="List of query runs" + CREATED_BY = "created_by" + CONFIGURATION_VERSION = "configuration_version" + CONFIGURATION_VERSION_INGRESS_ATTRIBUTES = ( + "configuration_version.ingress_attributes" ) - current_page: int | None = Field(None, description="Current page number") - total_pages: int | None = Field(None, description="Total number of pages") - prev_page: str | None = Field(None, description="URL of the previous page") - next_page: str | None = Field(None, description="URL of the next page") - total_count: int | None = Field(None, description="Total number of items") -class QueryRunResults(BaseModel): - """Represents the results of a query run.""" +class QueryRunListOptions(BaseModel): + """Options for listing query runs.""" model_config = ConfigDict(populate_by_name=True) - query_run_id: str = Field(..., description="The ID of the query run") - results: list[dict[str, Any]] = Field( - default_factory=list, description="The query results" + page_size: int | None = Field( + None, alias="page[size]", description="Number of items per page", ge=1, le=100 ) - total_count: int = Field(..., description="Total number of results") - truncated: bool = Field( - False, description="Whether the results were truncated due to limits" + include: list[QueryRunIncludeOpt] | None = Field( + None, description="List of related resources to include" ) -class QueryRunLogs(BaseModel): - """Represents the logs of a query run.""" +class QueryRunReadOptions(BaseModel): + """Options for reading a query run with additional data.""" model_config = ConfigDict(populate_by_name=True) - query_run_id: str = Field(..., description="The ID of the query run") - logs: str = Field(..., description="The query run logs") - log_level: str | None = Field(None, description="The log level") - timestamp: datetime | None = Field(None, description="When the logs were generated") + include: list[QueryRunIncludeOpt] | None = Field( + None, description="List of related resources to include" + ) diff --git a/src/pytfe/models/registry_provider_version.py b/src/pytfe/models/registry_provider_version.py new file mode 100644 index 0000000..6c043d3 --- /dev/null +++ b/src/pytfe/models/registry_provider_version.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from ..errors import ( + InvalidKeyIDError, + InvalidVersionError, + RequiredPrivateRegistryError, +) +from ..utils import valid_string_id +from .registry_provider import ( + RegistryName, + RegistryProviderID, +) + + +class RegistryProviderVersionPermissions(BaseModel): + """Registry provider version permissions.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + can_delete: bool = Field(alias="can-delete") + can_upload_asset: bool = Field(alias="can-upload-asset") + + +class RegistryProviderVersion(BaseModel): + """Registry provider version model.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str + version: str + created_at: datetime = Field(alias="created-at") + updated_at: datetime = Field(alias="updated-at") + key_id: str = Field(alias="key-id") + protocols: list[str] + permissions: RegistryProviderVersionPermissions + shasums_uploaded: bool = Field(alias="shasums-uploaded") + shasums_sig_uploaded: bool = Field(alias="shasums-sig-uploaded") + + # Relations + registry_provider: dict[str, Any] | None = Field( + alias="registry-provider", default=None + ) + registry_provider_platforms: list[dict[str, Any]] | None = Field( + alias="platforms", default=None + ) + + # Links + links: dict[str, Any] | None = None + + def shasums_upload_url(self) -> str: + """ShasumsUploadURL returns the upload URL to upload shasums if one is available""" + if self.links is None: + raise ValueError( + "The registry provider version does not contain a shasums upload link" + ) + upload_url = str(self.links.get("shasums-upload")) + if not upload_url: + raise ValueError( + "The registry provider version does not contain a shasums upload link" + ) + + if upload_url == "": + raise ValueError( + "The registry provider version shasums upload URL is empty" + ) + + return upload_url + + def shasums_sig_upload_url(self) -> str: + """ShasumsSigUploadURL returns the URL to upload a shasums sig""" + if self.links is None: + raise ValueError( + "The registry provider version does not contain a shasums sig upload link" + ) + upload_url = str(self.links.get("shasums-sig-upload")) + if not upload_url: + raise ValueError( + "The registry provider version does not contain a shasums sig upload link" + ) + + if upload_url == "": + raise ValueError( + "The registry provider version shasums sig upload URL is empty" + ) + + return upload_url + + def shasums_download_url(self) -> str: + """ShasumsDownloadURL returns the URL to download the shasums for the registry version""" + if self.links is None: + raise ValueError( + "The registry provider version does not contain a shasums download link" + ) + download_url = str(self.links.get("shasums-download")) + if not download_url: + raise ValueError( + "The registry provider version does not contain a shasums download link" + ) + + if download_url == "": + raise ValueError( + "The registry provider version shasums download URL is empty" + ) + + return download_url + + def shasums_sig_download_url(self) -> str: + """ShasumsSigDownloadURL returns the URL to download the shasums sig for the registry version""" + if self.links is None: + raise ValueError( + "The registry provider version does not contain a shasums sig download link" + ) + download_url = str(self.links.get("shasums-sig-download")) + if not download_url: + raise ValueError( + "The registry provider version does not contain a shasums sig download link" + ) + + if download_url == "": + raise ValueError( + "The registry provider version shasums sig download URL is empty" + ) + + return download_url + + +class RegistryProviderVersionID(RegistryProviderID): + """Registry provider version identifier. + + This extends RegistryProviderID with a version field to uniquely + identify a specific version of a provider. + """ + + version: str + + @model_validator(mode="after") + def valid(self) -> RegistryProviderVersionID: + if not valid_string_id(self.version): + raise InvalidVersionError() + if self.registry_name != RegistryName.PRIVATE: + raise RequiredPrivateRegistryError() + return self + + +class RegistryProviderVersionCreateOptions(BaseModel): + """Options for creating a registry provider version.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + version: str + key_id: str = Field(alias="key-id") + protocols: list[str] + + # validation method for version and key_id + @model_validator(mode="after") + def valid(self) -> RegistryProviderVersionCreateOptions: + if not valid_string_id(self.version): + raise InvalidVersionError() + if not valid_string_id(self.key_id): + raise InvalidKeyIDError() + return self + + +class RegistryProviderVersionListOptions(BaseModel): + """Options for listing registry provider versions.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + page_size: int | None = Field(alias="page[size]", default=None) diff --git a/src/pytfe/models/reserved_tag_key.py b/src/pytfe/models/reserved_tag_key.py index eb125ea..c332742 100644 --- a/src/pytfe/models/reserved_tag_key.py +++ b/src/pytfe/models/reserved_tag_key.py @@ -65,24 +65,6 @@ class ReservedTagKeyListOptions(BaseModel): model_config = ConfigDict(populate_by_name=True) - page_number: int | None = Field( - None, alias="page[number]", description="Page number to retrieve", ge=1 - ) page_size: int | None = Field( None, alias="page[size]", description="Number of items per page", ge=1, le=100 ) - - -class ReservedTagKeyList(BaseModel): - """Represents a paginated list of reserved tag keys.""" - - model_config = ConfigDict(populate_by_name=True) - - items: list[ReservedTagKey] = Field( - default_factory=list, description="List of reserved tag keys" - ) - current_page: int | None = Field(None, description="Current page number") - total_pages: int | None = Field(None, description="Total number of pages") - prev_page: str | None = Field(None, description="URL of the previous page") - next_page: str | None = Field(None, description="URL of the next page") - total_count: int | None = Field(None, description="Total number of items") diff --git a/src/pytfe/models/run_task.py b/src/pytfe/models/run_task.py index 8741162..eac0fcc 100644 --- a/src/pytfe/models/run_task.py +++ b/src/pytfe/models/run_task.py @@ -7,6 +7,8 @@ from ..models.common import Pagination from .agent import AgentPool from .organization import Organization +from .task_result import TaskEnforcementLevel +from .task_stages import Stage from .workspace_run_task import WorkspaceRunTask @@ -37,18 +39,6 @@ class GlobalRunTaskOptions(BaseModel): enforcement_level: TaskEnforcementLevel | None = None -class Stage(str, Enum): - PRE_PLAN = "pre-plan" - POST_PLAN = "post-plan" - PRE_APPLY = "pre-apply" - POST_APPLY = "post-apply" - - -class TaskEnforcementLevel(str, Enum): - ADVISORY = "advisory" - MANDATORY = "mandatory" - - class RunTaskIncludeOptions(str, Enum): RUN_TASK_WORKSPACE_TASKS = "workspace_tasks" RUN_TASK_WORKSPACE = "workspace_tasks.workspace" diff --git a/src/pytfe/models/run_task_request.py b/src/pytfe/models/run_task_request.py new file mode 100644 index 0000000..e2c4179 --- /dev/null +++ b/src/pytfe/models/run_task_request.py @@ -0,0 +1,87 @@ +"""Run Task Request models for python-tfe. + +This module contains the RunTaskRequest model which represents the payload +that TFC/TFE sends to external run task servers. +""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class RunTaskRequestCapabilities(BaseModel): + """Capabilities that the caller supports.""" + + outcomes: bool = Field( + default=False, description="Whether the run task server supports outcomes" + ) + + model_config = ConfigDict(populate_by_name=True) + + +class RunTaskRequest(BaseModel): + """Represents the payload that TFC/TFE sends to a run task's URL. + + This is the incoming request that your external run task server receives + from Terraform Cloud/Enterprise when a run task is triggered. + + API Documentation: + https://developer.hashicorp.com/terraform/enterprise/api-docs/run-tasks/run-tasks-integration#common-properties + """ + + access_token: str = Field( + description="Token to use for authentication when sending callback" + ) + capabilities: RunTaskRequestCapabilities | None = Field( + default=None, description="Capabilities that the caller supports" + ) + configuration_version_download_url: str | None = Field( + default=None, description="URL to download the configuration version" + ) + configuration_version_id: str | None = Field( + default=None, description="ID of the configuration version" + ) + is_speculative: bool = Field(description="Whether this is a speculative run") + organization_name: str = Field(description="Name of the organization") + payload_version: int = Field(description="Version of the payload format") + plan_json_api_url: str | None = Field( + default=None, + description="URL to access the plan JSON via API (post_plan, pre_apply, post_apply stages)", + ) + run_app_url: str = Field(description="URL to view the run in TFC/TFE UI") + run_created_at: datetime = Field(description="Timestamp when the run was created") + run_created_by: str = Field(description="Username of the user who created the run") + run_id: str = Field(description="ID of the run") + run_message: str = Field(description="Message associated with the run") + stage: str = Field( + description="Stage when the run task is executed (pre_plan, post_plan, pre_apply, post_apply)" + ) + task_result_callback_url: str = Field( + description="URL to send the task result callback to" + ) + task_result_enforcement_level: str = Field( + description="Enforcement level for the task result (advisory, mandatory)" + ) + task_result_id: str = Field(description="ID of the task result") + vcs_branch: str | None = Field(default=None, description="VCS branch name") + vcs_commit_url: str | None = Field( + default=None, description="URL to the VCS commit" + ) + vcs_pull_request_url: str | None = Field( + default=None, description="URL to the VCS pull request" + ) + vcs_repo_url: str | None = Field( + default=None, description="URL to the VCS repository" + ) + workspace_app_url: str = Field( + description="URL to view the workspace in TFC/TFE UI" + ) + workspace_id: str = Field(description="ID of the workspace") + workspace_name: str = Field(description="Name of the workspace") + workspace_working_directory: str | None = Field( + default=None, description="Working directory for the workspace" + ) + + model_config = ConfigDict(populate_by_name=True) diff --git a/src/pytfe/models/run_tasks_integration.py b/src/pytfe/models/run_tasks_integration.py new file mode 100644 index 0000000..ef96b2a --- /dev/null +++ b/src/pytfe/models/run_tasks_integration.py @@ -0,0 +1,156 @@ +"""Run Tasks Integration models for python-tfe. + +This module contains models for run tasks integration callback functionality. +""" + +from __future__ import annotations + +from typing import Any + +from .task_result import TaskResultStatus + + +class TaskResultTag: + """Tag to enrich outcomes display in TFC/TFE. + + API Documentation: + https://developer.hashicorp.com/terraform/enterprise/api-docs/run-tasks/run-tasks-integration#severity-and-status-tags + """ + + def __init__(self, label: str, level: str | None = None): + """Initialize a task result tag. + + Args: + label: The label for the tag + level: Optional severity level (error, warning, info) + """ + self.label = label + self.level = level + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + result = {"label": self.label} + if self.level: + result["level"] = self.level + return result + + +class TaskResultOutcome: + """Detailed run task outcome for improved visibility in TFC/TFE UI. + + API Documentation: + https://developer.hashicorp.com/terraform/enterprise/api-docs/run-tasks/run-tasks-integration#outcomes-payload-body + """ + + def __init__( + self, + outcome_id: str | None = None, + description: str | None = None, + body: str | None = None, + url: str | None = None, + tags: dict[str, list[TaskResultTag]] | None = None, + ): + """Initialize a task result outcome. + + Args: + outcome_id: Unique identifier for the outcome + description: Brief description of the outcome + body: Detailed body content (supports markdown) + url: URL to view more details + tags: Dictionary of tag categories to lists of tags + """ + self.outcome_id = outcome_id + self.description = description + self.body = body + self.url = url + self.tags = tags or {} + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for JSON:API serialization.""" + result: dict[str, Any] = {"type": "task-result-outcomes", "attributes": {}} + + if self.outcome_id: + result["attributes"]["outcome-id"] = self.outcome_id + if self.description: + result["attributes"]["description"] = self.description + if self.body: + result["attributes"]["body"] = self.body + if self.url: + result["attributes"]["url"] = self.url + if self.tags: + result["attributes"]["tags"] = { + key: [tag.to_dict() for tag in tags] for key, tags in self.tags.items() + } + + return result + + +class TaskResultCallbackOptions: + """Options for sending task result callback to TFC/TFE. + + API Documentation: + https://developer.hashicorp.com/terraform/enterprise/api-docs/run-tasks/run-tasks-integration#request-body-1 + """ + + def __init__( + self, + status: str, + message: str | None = None, + url: str | None = None, + outcomes: list[TaskResultOutcome] | None = None, + ): + """Initialize callback options. + + Args: + status: Task result status (passed, failed, running) + message: Optional message about the task result + url: Optional URL to view detailed results + outcomes: Optional list of detailed outcomes + """ + self.status = status + self.message = message + self.url = url + self.outcomes = outcomes or [] + + def validate(self) -> None: + """Validate the callback options. + + Only passed, failed, and running statuses are allowed for callbacks. + pending and errored are not valid callback statuses per TFC/TFE API. + + Raises: + InvalidTaskResultsCallbackStatus: If status is not valid for callbacks + """ + from ..errors import InvalidTaskResultsCallbackStatus + + valid_statuses = [ + TaskResultStatus.PASSED.value, + TaskResultStatus.FAILED.value, + TaskResultStatus.RUNNING.value, + ] + if self.status not in valid_statuses: + raise InvalidTaskResultsCallbackStatus( + f"Invalid task result status: {self.status}. " + f"Must be one of: {', '.join(valid_statuses)}" + ) + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for JSON:API format.""" + data: dict[str, Any] = { + "type": "task-results", + "attributes": { + "status": self.status, + }, + } + + if self.message: + data["attributes"]["message"] = self.message + if self.url: + data["attributes"]["url"] = self.url + + if self.outcomes: + data["relationships"] = { + "outcomes": {"data": [outcome.to_dict() for outcome in self.outcomes]} + } + + return {"data": data} diff --git a/src/pytfe/models/task_result.py b/src/pytfe/models/task_result.py new file mode 100644 index 0000000..d001c3f --- /dev/null +++ b/src/pytfe/models/task_result.py @@ -0,0 +1,74 @@ +"""Task Result models for python-tfe. + +This module contains models related to task results in Terraform Cloud/Enterprise. +""" + +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import TYPE_CHECKING + +from pydantic import BaseModel, ConfigDict, Field + +if TYPE_CHECKING: + from .task_stages import TaskStage + + +class TaskResultStatus(str, Enum): + """Task result status enum.""" + + PASSED = "passed" + FAILED = "failed" + PENDING = "pending" + RUNNING = "running" + UNREACHABLE = "unreachable" + ERRORED = "errored" + + +class TaskEnforcementLevel(str, Enum): + """Task enforcement level enum.""" + + ADVISORY = "advisory" + MANDATORY = "mandatory" + + +class TaskResultStatusTimestamps(BaseModel): + """Timestamps recorded for a task result.""" + + errored_at: datetime | None = Field(default=None, alias="errored-at") + running_at: datetime | None = Field(default=None, alias="running-at") + canceled_at: datetime | None = Field(default=None, alias="canceled-at") + failed_at: datetime | None = Field(default=None, alias="failed-at") + passed_at: datetime | None = Field(default=None, alias="passed-at") + + model_config = ConfigDict(populate_by_name=True) + + +class TaskResult(BaseModel): + """Represents a HCP Terraform or Terraform Enterprise run task result. + + API Documentation: + https://developer.hashicorp.com/terraform/cloud-docs/api-docs/task-results + """ + + id: str + status: TaskResultStatus + message: str + status_timestamps: TaskResultStatusTimestamps = Field(alias="status-timestamps") + url: str + created_at: datetime = Field(alias="created-at") + updated_at: datetime = Field(alias="updated-at") + task_id: str = Field(alias="task-id") + task_name: str = Field(alias="task-name") + task_url: str = Field(alias="task-url") + workspace_task_id: str = Field(alias="workspace-task-id") + workspace_task_enforcement_level: TaskEnforcementLevel = Field( + alias="workspace-task-enforcement-level" + ) + agent_pool_id: str | None = Field(default=None, alias="agent-pool-id") + + # Relationships + task_stage: TaskStage | None = Field(default=None, alias="task-stage") + + model_config = ConfigDict(populate_by_name=True) diff --git a/src/pytfe/models/task_stages.py b/src/pytfe/models/task_stages.py new file mode 100644 index 0000000..32f1318 --- /dev/null +++ b/src/pytfe/models/task_stages.py @@ -0,0 +1,118 @@ +"""Task Stage models for python-tfe. + +This module contains models related to task stages in Terraform Cloud/Enterprise. +""" + +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import TYPE_CHECKING + +from pydantic import BaseModel, ConfigDict, Field + +if TYPE_CHECKING: + from .policy_evaluation import PolicyEvaluation + from .run import Run + from .task_result import TaskResult + + +class Stage(str, Enum): + """Enum representing possible run stages for run tasks.""" + + PRE_PLAN = "pre-plan" + POST_PLAN = "post-plan" + PRE_APPLY = "pre-apply" + POST_APPLY = "post-apply" + + +class TaskStageStatus(str, Enum): + """Enum representing all possible statuses for a task stage.""" + + PENDING = "pending" + RUNNING = "running" + PASSED = "passed" + FAILED = "failed" + AWAITING_OVERRIDE = "awaiting_override" + CANCELED = "canceled" + ERRORED = "errored" + UNREACHABLE = "unreachable" + + +class Permissions(BaseModel): + """Permission types for overriding a task stage.""" + + can_override_policy: bool | None = Field(default=None, alias="can-override-policy") + can_override_tasks: bool | None = Field(default=None, alias="can-override-tasks") + can_override: bool | None = Field(default=None, alias="can-override") + + model_config = ConfigDict(populate_by_name=True) + + +class Actions(BaseModel): + """Task stage actions.""" + + is_overridable: bool | None = Field(default=None, alias="is-overridable") + + model_config = ConfigDict(populate_by_name=True) + + +class TaskStageStatusTimestamps(BaseModel): + """Timestamps recorded for a task stage.""" + + errored_at: datetime | None = Field(default=None, alias="errored-at") + running_at: datetime | None = Field(default=None, alias="running-at") + canceled_at: datetime | None = Field(default=None, alias="canceled-at") + failed_at: datetime | None = Field(default=None, alias="failed-at") + passed_at: datetime | None = Field(default=None, alias="passed-at") + + model_config = ConfigDict(populate_by_name=True) + + +class TaskStage(BaseModel): + """Represents a HCP Terraform or Terraform Enterprise run's task stage. + + Task stages are where run tasks can occur during a run lifecycle. + + API Documentation: + https://developer.hashicorp.com/terraform/cloud-docs/api-docs/task-stages + """ + + id: str + stage: Stage + status: TaskStageStatus + status_timestamps: TaskStageStatusTimestamps = Field(alias="status-timestamps") + created_at: datetime = Field(alias="created-at") + updated_at: datetime = Field(alias="updated-at") + permissions: Permissions | None = None + actions: Actions | None = None + + # Relationships + run: Run | None = None + task_results: list[TaskResult] = Field(default_factory=list, alias="task-results") + policy_evaluations: list[PolicyEvaluation] = Field( + default_factory=list, alias="policy-evaluations" + ) + + model_config = ConfigDict(populate_by_name=True) + + +class TaskStageOverrideOptions(BaseModel): + """Options for overriding a task stage.""" + + comment: str | None = None + + +class TaskStageReadOptions(BaseModel): + """Options for reading a task stage.""" + + include: list[str] | None = None + + +class TaskStageListOptions(BaseModel): + """Options for listing task stages.""" + + page_number: int | None = Field(default=None, alias="page[number]") + page_size: int | None = Field(default=None, alias="page[size]") + + model_config = ConfigDict(populate_by_name=True) 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/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/oauth_token.py b/src/pytfe/resources/oauth_token.py index fb25074..337fa02 100644 --- a/src/pytfe/resources/oauth_token.py +++ b/src/pytfe/resources/oauth_token.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Iterator from datetime import datetime from typing import Any from urllib.parse import quote @@ -7,11 +8,10 @@ from ..errors import ERR_INVALID_OAUTH_TOKEN_ID, ERR_INVALID_ORG from ..models.oauth_token import ( OAuthToken, - OAuthTokenList, OAuthTokenListOptions, OAuthTokenUpdateOptions, ) -from ..utils import encode_query, valid_string_id +from ..utils import valid_string_id from ._base import _Service @@ -20,7 +20,7 @@ class OAuthTokens(_Service): def list( self, organization: str, options: OAuthTokenListOptions | None = None - ) -> OAuthTokenList: + ) -> Iterator[OAuthToken]: """List all the OAuth tokens for a given organization.""" if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) @@ -29,37 +29,11 @@ def list( params = {} if options: - if options.page_number: - params["page[number]"] = str(options.page_number) if options.page_size: params["page[size]"] = str(options.page_size) - query_string = encode_query(params) - full_path = f"{path}{query_string}" - - response = self.t.request("GET", full_path) - data = response.json() - - tokens = [] - if "data" in data: - for item in data["data"]: - tokens.append(self._parse_oauth_token(item)) - - # Parse pagination metadata - pagination = {} - if "meta" in data: - meta = data["meta"] - if "pagination" in meta: - page_info = meta["pagination"] - pagination = { - "current_page": page_info.get("current-page"), - "prev_page": page_info.get("prev-page"), - "next_page": page_info.get("next-page"), - "total_pages": page_info.get("total-pages"), - "total_count": page_info.get("total-count"), - } - - return OAuthTokenList(items=tokens, **pagination) + for item in self._list(path, params=params): + yield self._parse_oauth_token(item) def read(self, oauth_token_id: str) -> OAuthToken: """Read an OAuth token by its ID.""" @@ -128,7 +102,6 @@ def _parse_oauth_token(self, data: dict[str, Any]) -> OAuthToken: return OAuthToken( id=data.get("id", ""), - uid=attributes.get("uid", ""), created_at=created_at, has_ssh_key=attributes.get("has-ssh-key", False), service_provider_user=attributes.get("service-provider-user", ""), diff --git a/src/pytfe/resources/organization_membership.py b/src/pytfe/resources/organization_membership.py new file mode 100644 index 0000000..659608b --- /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 | None = None, + ) -> 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/policy_evaluation.py b/src/pytfe/resources/policy_evaluation.py index bc30193..2f911f6 100644 --- a/src/pytfe/resources/policy_evaluation.py +++ b/src/pytfe/resources/policy_evaluation.py @@ -1,11 +1,12 @@ from __future__ import annotations +from collections.abc import Iterator + from ..errors import ( InvalidTaskStageIDError, ) from ..models.policy_evaluation import ( PolicyEvaluation, - PolicyEvaluationList, PolicyEvaluationListOptions, ) from ..utils import valid_string_id @@ -20,34 +21,21 @@ class PolicyEvaluations(_Service): def list( self, task_stage_id: str, options: PolicyEvaluationListOptions | None = None - ) -> PolicyEvaluationList: + ) -> Iterator[PolicyEvaluation]: """ **Note: This method is still in BETA and subject to change.** - List all policy evaluations in the task stage. Only available for OPA policies. + List all policy evaluations in the task stage. Only available for OPA policies. """ if not valid_string_id(task_stage_id): raise InvalidTaskStageIDError() params = options.model_dump(by_alias=True) if options else {} path = f"api/v2/task-stages/{task_stage_id}/policy-evaluations" - r = self.t.request("GET", path, params=params) - jd = r.json() - items = [] - meta = jd.get("meta", {}) - pagination = meta.get("pagination", {}) - for item in jd.get("data", []): + for item in self._list(path, params=params): attrs = item.get("attributes", {}) attrs["id"] = item.get("id") - attrs["task-stage"] = ( + attrs["policy-attachable"] = ( item.get("relationships", {}) .get("policy-attachable", {}) .get("data", {}) ) - items.append(PolicyEvaluation.model_validate(attrs)) - return PolicyEvaluationList( - items=items, - current_page=pagination.get("current-page"), - next_page=pagination.get("next-page"), - prev_page=pagination.get("prev-page"), - total_count=pagination.get("total-count"), - total_pages=pagination.get("total-pages"), - ) + yield PolicyEvaluation.model_validate(attrs) diff --git a/src/pytfe/resources/policy_set_outcome.py b/src/pytfe/resources/policy_set_outcome.py index 56f7f34..42389d3 100644 --- a/src/pytfe/resources/policy_set_outcome.py +++ b/src/pytfe/resources/policy_set_outcome.py @@ -1,18 +1,21 @@ from __future__ import annotations +from collections.abc import Iterator +from typing import Any + from ..errors import ( InvalidPolicyEvaluationIDError, + InvalidPolicySetOutcomeIDError, ) from ..models.policy_set_outcome import ( PolicySetOutcome, - PolicySetOutcomeList, PolicySetOutcomeListOptions, ) from ..utils import valid_string_id from ._base import _Service -class PolicySets(_Service): +class PolicySetOutcomes(_Service): """ PolicySetOutcomes describes all the policy set outcome related methods that the Terraform Enterprise API supports. TFE API docs: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policy-checks @@ -22,7 +25,7 @@ def list( self, policy_evaluation_id: str, options: PolicySetOutcomeListOptions | None = None, - ) -> PolicySetOutcomeList: + ) -> Iterator[PolicySetOutcome]: """ **Note: This method is still in BETA and subject to change.** List all policy set outcomes in the policy evaluation. Only available for OPA policies. @@ -35,28 +38,8 @@ def list( if additional_query_params: params.update(additional_query_params) path = f"api/v2/policy-evaluations/{policy_evaluation_id}/policy-set-outcomes" - r = self.t.request("GET", path, params=params) - jd = r.json() - items = [] - meta = jd.get("meta", {}) - pagination = meta.get("pagination", {}) - for item in jd.get("data", []): - attrs = item.get("attributes", {}) - attrs["id"] = item.get("id") - attrs["policy-evaluation"] = ( - item.get("relationships", {}) - .get("policy-evaluation", {}) - .get("data", {}) - ) - items.append(PolicySetOutcome.model_validate(attrs)) - return PolicySetOutcomeList( - items=items, - current_page=pagination.get("current-page"), - next_page=pagination.get("next-page"), - prev_page=pagination.get("prev-page"), - total_count=pagination.get("total-count"), - total_pages=pagination.get("total-pages"), - ) + for item in self._list(path, params=params): + yield self._policy_set_outcome_from(item) def build_query_string( self, options: PolicySetOutcomeListOptions | None @@ -77,14 +60,17 @@ def read(self, policy_set_outcome_id: str) -> PolicySetOutcome: **Note: This method is still in BETA and subject to change.** Read a single policy set outcome by ID. Only available for OPA policies.""" if not valid_string_id(policy_set_outcome_id): - raise InvalidPolicyEvaluationIDError() + raise InvalidPolicySetOutcomeIDError() path = f"api/v2/policy-set-outcomes/{policy_set_outcome_id}" r = self.t.request("GET", path) - jd = r.json() - item = jd.get("data", {}) - attrs = item.get("attributes", {}) - attrs["id"] = item.get("id") + data = r.json().get("data", {}) + return PolicySetOutcome.model_validate(data) + + def _policy_set_outcome_from(self, d: dict[str, Any]) -> PolicySetOutcome: + """Convert API response dict to PolicySetParameter model.""" + attrs = d.get("attributes", {}) + attrs["id"] = d.get("id") attrs["policy-evaluation"] = ( - item.get("relationships", {}).get("policy-evaluation", {}).get("data", {}) + d.get("relationships", {}).get("policy-evaluation", {}).get("data", {}) ) return PolicySetOutcome.model_validate(attrs) diff --git a/src/pytfe/resources/policy_set_parameter.py b/src/pytfe/resources/policy_set_parameter.py new file mode 100644 index 0000000..076579c --- /dev/null +++ b/src/pytfe/resources/policy_set_parameter.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +from ..errors import ( + InvalidCategoryError, + InvalidParamIDError, + InvalidPolicySetIDError, + RequiredCategoryError, + RequiredKeyError, +) +from ..models.policy_set_parameter import ( + PolicySetParameter, + PolicySetParameterCreateOptions, + PolicySetParameterListOptions, + PolicySetParameterUpdateOptions, +) +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 + ) -> 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 {} + path = f"/api/v2/policy-sets/{policy_set_id}/parameters" + for item in self._list(path, params=params): + yield self._policy_set_parameter_from(item) + + 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, + ) + 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.""" + 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}", + ) + data = r.json().get("data", {}) + return self._policy_set_parameter_from(data) + + def update( + self, + policy_set_id: str, + parameter_id: str, + options: PolicySetParameterUpdateOptions, + ) -> PolicySetParameter: + """Update values of an existing parameter.""" + if not valid_string_id(policy_set_id): + raise InvalidPolicySetIDError() + + if not valid_string_id(parameter_id): + raise InvalidParamIDError() + 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, + ) + 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.""" + if not valid_string_id(policy_set_id): + raise InvalidPolicySetIDError() + + 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 + + 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) 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/src/pytfe/resources/query_run.py b/src/pytfe/resources/query_run.py index 1540c70..a552e64 100644 --- a/src/pytfe/resources/query_run.py +++ b/src/pytfe/resources/query_run.py @@ -1,21 +1,18 @@ from __future__ import annotations +import io +from collections.abc import Iterator from typing import Any from ..errors import ( - InvalidOrgError, InvalidQueryRunIDError, + InvalidWorkspaceIDError, ) from ..models.query_run import ( QueryRun, - QueryRunCancelOptions, QueryRunCreateOptions, - QueryRunForceCancelOptions, - QueryRunList, QueryRunListOptions, - QueryRunLogs, QueryRunReadOptions, - QueryRunResults, ) from ..utils import valid_string_id from ._base import _Service @@ -25,57 +22,69 @@ class QueryRuns(_Service): """Query Runs API for Terraform Enterprise.""" def list( - self, organization: str, options: QueryRunListOptions | None = None - ) -> QueryRunList: - """List query runs for the given organization.""" - if not valid_string_id(organization): - raise InvalidOrgError() - - params = ( - options.model_dump(by_alias=True, exclude_none=True) if options else None - ) + self, workspace_id: str, options: QueryRunListOptions | None = None + ) -> Iterator[QueryRun]: + """Iterate through all query runs for the given workspace. + + This method automatically handles pagination and yields QueryRun objects one at a time. + + Args: + workspace_id: The ID of the workspace + options: Optional list options (page_size, include, etc.) + + Yields: + QueryRun objects one at a time + + Example: + for query_run in client.query_runs.list(workspace_id): + print(f"Query Run: {query_run.id} - Status: {query_run.status}") + """ + if not valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + + params: dict[str, Any] = {} + if options: + params = options.model_dump(by_alias=True, exclude_none=True) + # Convert include list to comma-separated string + if "include" in params and params["include"] and options.include: + params["include"] = ",".join([i.value for i in options.include]) + + path = f"/api/v2/workspaces/{workspace_id}/queries" + for item in self._list(path, params=params): + attrs = item.get("attributes", {}) + attrs["id"] = item.get("id") + yield QueryRun.model_validate(attrs) + + def create(self, options: QueryRunCreateOptions) -> QueryRun: + """Create a new query run.""" + attrs = options.model_dump(by_alias=True, exclude_none=True) - r = self.t.request( - "GET", - f"/api/v2/organizations/{organization}/query-runs", - params=params, - ) + # Build relationships + relationships: dict[str, Any] = {} - jd = r.json() - items = [] - meta = jd.get("meta", {}) - pagination = meta.get("pagination", {}) - - for d in jd.get("data", []): - attrs = d.get("attributes", {}) - attrs["id"] = d.get("id") - items.append(QueryRun.model_validate(attrs)) - - return QueryRunList( - 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"), - ) + if workspace_id := attrs.pop("workspace-id", None): + relationships["workspace"] = { + "data": {"type": "workspaces", "id": workspace_id} + } - def create(self, organization: str, options: QueryRunCreateOptions) -> QueryRun: - """Create a new query run for the given organization.""" - if not valid_string_id(organization): - raise InvalidOrgError() + if config_version_id := attrs.pop("configuration-version-id", None): + relationships["configuration-version"] = { + "data": {"type": "configuration-versions", "id": config_version_id} + } - attrs = options.model_dump(by_alias=True, exclude_none=True) body: dict[str, Any] = { "data": { + "type": "queries", "attributes": attrs, - "type": "query-runs", } } + if relationships: + body["data"]["relationships"] = relationships + r = self.t.request( "POST", - f"/api/v2/organizations/{organization}/query-runs", + "/api/v2/queries", json_body=body, ) @@ -91,7 +100,7 @@ def read(self, query_run_id: str) -> QueryRun: if not valid_string_id(query_run_id): raise InvalidQueryRunIDError() - r = self.t.request("GET", f"/api/v2/query-runs/{query_run_id}") + r = self.t.request("GET", f"/api/v2/queries/{query_run_id}") jd = r.json() data = jd.get("data", {}) @@ -108,8 +117,11 @@ def read_with_options( raise InvalidQueryRunIDError() params = options.model_dump(by_alias=True, exclude_none=True) + # Convert include list to comma-separated string + if "include" in params and params["include"] and options.include: + params["include"] = ",".join([i.value for i in options.include]) - r = self.t.request("GET", f"/api/v2/query-runs/{query_run_id}", params=params) + r = self.t.request("GET", f"/api/v2/queries/{query_run_id}", params=params) jd = r.json() data = jd.get("data", {}) @@ -118,99 +130,48 @@ def read_with_options( return QueryRun.model_validate(attrs) - def logs(self, query_run_id: str) -> QueryRunLogs: - """Retrieve the logs for a query run.""" - if not valid_string_id(query_run_id): - raise InvalidQueryRunIDError() + def logs(self, query_run_id: str) -> io.IOBase: + """Retrieve the logs for a query run. - r = self.t.request("GET", f"/api/v2/query-runs/{query_run_id}/logs") - - # Handle both JSON and plain text responses - content_type = r.headers.get("content-type", "").lower() - - if "application/json" in content_type: - jd = r.json() - return QueryRunLogs.model_validate(jd.get("data", {})) - else: - # Plain text logs - return QueryRunLogs( - query_run_id=query_run_id, - logs=r.text, - log_level="info", - timestamp=None, - ) - - def results(self, query_run_id: str) -> QueryRunResults: - """Retrieve the results for a query run.""" + Returns an IO stream that can be read to get the log content. + """ if not valid_string_id(query_run_id): raise InvalidQueryRunIDError() - r = self.t.request("GET", f"/api/v2/query-runs/{query_run_id}/results") + # First get the query run to retrieve the log read URL + query_run = self.read(query_run_id) - jd = r.json() - data = jd.get("data", {}) + if not query_run.log_read_url: + raise ValueError(f"Query run {query_run_id} does not have a log URL") - return QueryRunResults( - query_run_id=query_run_id, - results=data.get("results", []), - total_count=data.get("total_count", 0), - truncated=data.get("truncated", False), - ) + # Fetch the logs from the URL (absolute URLs are handled by _build_url) + r = self.t.request("GET", query_run.log_read_url) - def cancel( - self, query_run_id: str, options: QueryRunCancelOptions | None = None - ) -> QueryRun: - """Cancel a query run.""" - if not valid_string_id(query_run_id): - raise InvalidQueryRunIDError() + # Return the content as a BytesIO stream + return io.BytesIO(r.content) - attrs = options.model_dump(by_alias=True, exclude_none=True) if options else {} + def cancel(self, query_run_id: str) -> None: + """Cancel a query run. - body: dict[str, Any] = { - "data": { - "attributes": attrs, - "type": "query-runs", - } - } + Returns 202 on success with empty body. + """ + if not valid_string_id(query_run_id): + raise InvalidQueryRunIDError() - r = self.t.request( + self.t.request( "POST", - f"/api/v2/query-runs/{query_run_id}/actions/cancel", - json_body=body, + f"/api/v2/queries/{query_run_id}/actions/cancel", ) - jd = r.json() - data = jd.get("data", {}) - attrs = data.get("attributes", {}) - attrs["id"] = data.get("id") + def force_cancel(self, query_run_id: str) -> None: + """Force cancel a query run. - return QueryRun.model_validate(attrs) - - def force_cancel( - self, query_run_id: str, options: QueryRunForceCancelOptions | None = None - ) -> QueryRun: - """Force cancel a query run.""" + Returns 202 on success with empty body. + """ if not valid_string_id(query_run_id): raise InvalidQueryRunIDError() - attrs = options.model_dump(by_alias=True, exclude_none=True) if options else {} - - body: dict[str, Any] = { - "data": { - "attributes": attrs, - "type": "query-runs", - } - } - - r = self.t.request( + self.t.request( "POST", - f"/api/v2/query-runs/{query_run_id}/actions/force-cancel", - json_body=body, + f"/api/v2/queries/{query_run_id}/actions/force-cancel", ) - - jd = r.json() - data = jd.get("data", {}) - attrs = data.get("attributes", {}) - attrs["id"] = data.get("id") - - return QueryRun.model_validate(attrs) diff --git a/src/pytfe/resources/registry_provider_version.py b/src/pytfe/resources/registry_provider_version.py new file mode 100644 index 0000000..f2d4fb3 --- /dev/null +++ b/src/pytfe/resources/registry_provider_version.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +from ..errors import ( + RequiredPrivateRegistryError, +) +from ..models.registry_provider import ( + RegistryName, + RegistryProviderID, +) +from ..models.registry_provider_version import ( + RegistryProviderVersion, + RegistryProviderVersionCreateOptions, + RegistryProviderVersionID, + RegistryProviderVersionListOptions, +) +from ..utils import valid_string_id +from ._base import _Service + + +class RegistryProviderVersions(_Service): + """Registry providers service for managing Terraform registry providers.""" + + def create( + self, + provider_id: RegistryProviderID, + options: RegistryProviderVersionCreateOptions, + ) -> RegistryProviderVersion: + """Create a registry provider version""" + if not self._validate_provider_id(provider_id): + raise ValueError("Invalid provider ID") + + if provider_id.registry_name != RegistryName.PRIVATE: + raise RequiredPrivateRegistryError() + path = f"/api/v2/organizations/{provider_id.organization_name}/registry-providers/{provider_id.registry_name.value}/{provider_id.namespace}/{provider_id.name}/versions" + attributes = options.model_dump(by_alias=True, exclude_none=True) + payload = { + "data": { + "type": "registry-provider-versions", + "attributes": attributes, + } + } + r = self.t.request( + "POST", + path=path, + json_body=payload, + ) + data = r.json().get("data", {}) + return self._registry_provider_version_from(data) + + def _validate_provider_id(self, provider_id: RegistryProviderID) -> bool: + """Validate a registry provider ID.""" + if not valid_string_id(provider_id.organization_name): + return False + if not valid_string_id(provider_id.name): + return False + if not valid_string_id(provider_id.namespace): + return False + if provider_id.registry_name not in [RegistryName.PRIVATE, RegistryName.PUBLIC]: + return False + return True + + def _registry_provider_version_from( + self, data: dict[str, Any] + ) -> RegistryProviderVersion: + """Parse a registry provider version from API response data.""" + + attrs = data.get("attributes", {}) + relationships = data.get("relationships", {}) + attrs["id"] = data.get("id") + + # Parse relationships + if "registry-provider" in relationships: + attrs["registry_provider"] = relationships["registry-provider"].get( + "data", {} + ) + + if "platforms" in relationships: + attrs["registry_provider_platforms"] = relationships["platforms"].get( + "data", [] + ) + + return RegistryProviderVersion.model_validate(attrs) + + def list( + self, + provider_id: RegistryProviderID, + options: RegistryProviderVersionListOptions | None = None, + ) -> Iterator[RegistryProviderVersion]: + """List registry provider versions""" + if not self._validate_provider_id(provider_id): + raise ValueError("Invalid provider ID") + + path = f"/api/v2/organizations/{provider_id.organization_name}/registry-providers/{provider_id.registry_name.value}/{provider_id.namespace}/{provider_id.name}/versions" + params = options.model_dump(by_alias=True) if options else {} + for item in self._list(path=path, params=params): + yield self._registry_provider_version_from(item) + + def read(self, version_id: RegistryProviderVersionID) -> RegistryProviderVersion: + """Read a specific registry provider version""" + if not self._validate_provider_id(version_id): + raise ValueError("Invalid provider ID") + + path = f"/api/v2/organizations/{version_id.organization_name}/registry-providers/{version_id.registry_name.value}/{version_id.namespace}/{version_id.name}/versions/{version_id.version}" + r = self.t.request( + "GET", + path=path, + ) + data = r.json().get("data", {}) + return self._registry_provider_version_from(data) + + def delete(self, version_id: RegistryProviderVersionID) -> None: + """Delete a specific registry provider version""" + if not self._validate_provider_id(version_id): + raise ValueError("Invalid provider ID") + + path = f"/api/v2/organizations/{version_id.organization_name}/registry-providers/{version_id.registry_name.value}/{version_id.namespace}/{version_id.name}/versions/{version_id.version}" + self.t.request( + "DELETE", + path=path, + ) + return None diff --git a/src/pytfe/resources/reserved_tag_key.py b/src/pytfe/resources/reserved_tag_key.py index aeff161..8eed7fa 100644 --- a/src/pytfe/resources/reserved_tag_key.py +++ b/src/pytfe/resources/reserved_tag_key.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Iterator from typing import Any from ..errors import ( @@ -7,11 +8,8 @@ ValidationError, ) from ..models.reserved_tag_key import ( - ReservedTagKey as ReservedTagKeyModel, -) -from ..models.reserved_tag_key import ( + ReservedTagKey, ReservedTagKeyCreateOptions, - ReservedTagKeyList, ReservedTagKeyListOptions, ReservedTagKeyUpdateOptions, ) @@ -19,12 +17,12 @@ from ._base import _Service -class ReservedTagKey(_Service): +class ReservedTagKeys(_Service): """Reserved Tag Key API for Terraform Enterprise.""" def list( self, organization: str, options: ReservedTagKeyListOptions | None = None - ) -> ReservedTagKeyList: + ) -> Iterator[ReservedTagKey]: """List reserved tag keys for the given organization.""" if not valid_string_id(organization): raise InvalidOrgError() @@ -32,33 +30,13 @@ def list( params = ( options.model_dump(by_alias=True, exclude_none=True) if options else None ) - - r = self.t.request( - "GET", - f"/api/v2/organizations/{organization}/reserved-tag-keys", - params=params, - ) - - jd = r.json() - items = [] - meta = jd.get("meta", {}) - pagination = meta.get("pagination", {}) - - for d in jd.get("data", []): - items.append(self._parse_reserved_tag_key(d)) - - return ReservedTagKeyList( - items=items, - current_page=pagination.get("current-page"), - total_pages=pagination.get("total-pages"), - prev_page=pagination.get("prev-page"), - next_page=pagination.get("next-page"), - total_count=pagination.get("total-count"), - ) + path = f"/api/v2/organizations/{organization}/reserved-tag-keys" + for item in self._list(path, params=params): + yield self._parse_reserved_tag_key(item) def create( self, organization: str, options: ReservedTagKeyCreateOptions - ) -> ReservedTagKeyModel: + ) -> ReservedTagKey: """Create a new reserved tag key for the given organization.""" if not valid_string_id(organization): raise InvalidOrgError() @@ -82,20 +60,9 @@ def create( return self._parse_reserved_tag_key(data) - def read(self, reserved_tag_key_id: str) -> ReservedTagKeyModel: - """Read a reserved tag key by its ID.""" - if not valid_string_id(reserved_tag_key_id): - raise ValidationError("Invalid reserved tag key ID") - - # Note: Based on the API docs, there's no explicit GET endpoint for individual reserved tag keys - # This method would need to be implemented if such an endpoint exists - raise NotImplementedError( - "Individual reserved tag key read is not supported by the API" - ) - def update( self, reserved_tag_key_id: str, options: ReservedTagKeyUpdateOptions - ) -> ReservedTagKeyModel: + ) -> ReservedTagKey: """Update a reserved tag key.""" if not valid_string_id(reserved_tag_key_id): raise ValidationError("Invalid reserved tag key ID") @@ -125,10 +92,10 @@ def delete(self, reserved_tag_key_id: str) -> None: raise ValidationError("Invalid reserved tag key ID") self.t.request("DELETE", f"/api/v2/reserved-tag-keys/{reserved_tag_key_id}") - # DELETE returns 204 No Content on success + return None - def _parse_reserved_tag_key(self, data: dict[str, Any]) -> ReservedTagKeyModel: + def _parse_reserved_tag_key(self, data: dict[str, Any]) -> ReservedTagKey: """Parse reserved tag key data from API response.""" attrs = data.get("attributes", {}) attrs["id"] = data.get("id") - return ReservedTagKeyModel.model_validate(attrs) + return ReservedTagKey.model_validate(attrs) diff --git a/src/pytfe/resources/run_tasks_integration.py b/src/pytfe/resources/run_tasks_integration.py new file mode 100644 index 0000000..910ad24 --- /dev/null +++ b/src/pytfe/resources/run_tasks_integration.py @@ -0,0 +1,204 @@ +"""Run Tasks Integration resource for python-tfe. + +This module provides the callback functionality for external run task servers +to send results back to Terraform Cloud/Enterprise. +""" + +from __future__ import annotations + +from typing import Any + +from ..errors import TFEError +from ..models.task_result import TaskResultStatus +from ._base import _Service + + +class TaskResultTag: + """Tag to enrich outcomes display in TFC/TFE. + + API Documentation: + https://developer.hashicorp.com/terraform/enterprise/api-docs/run-tasks/run-tasks-integration#severity-and-status-tags + """ + + def __init__(self, label: str, level: str | None = None): + """Initialize a task result tag. + + Args: + label: The label for the tag + level: Optional severity level (error, warning, info) + """ + self.label = label + self.level = level + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + result = {"label": self.label} + if self.level: + result["level"] = self.level + return result + + +class TaskResultOutcome: + """Detailed run task outcome for improved visibility in TFC/TFE UI. + + API Documentation: + https://developer.hashicorp.com/terraform/enterprise/api-docs/run-tasks/run-tasks-integration#outcomes-payload-body + """ + + def __init__( + self, + outcome_id: str | None = None, + description: str | None = None, + body: str | None = None, + url: str | None = None, + tags: dict[str, list[TaskResultTag]] | None = None, + ): + """Initialize a task result outcome. + + Args: + outcome_id: Unique identifier for the outcome + description: Brief description of the outcome + body: Detailed body content (supports markdown) + url: URL to view more details + tags: Dictionary of tag categories to lists of tags + """ + self.outcome_id = outcome_id + self.description = description + self.body = body + self.url = url + self.tags = tags or {} + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for JSON:API serialization.""" + result: dict[str, Any] = {"type": "task-result-outcomes", "attributes": {}} + + if self.outcome_id: + result["attributes"]["outcome-id"] = self.outcome_id + if self.description: + result["attributes"]["description"] = self.description + if self.body: + result["attributes"]["body"] = self.body + if self.url: + result["attributes"]["url"] = self.url + if self.tags: + result["attributes"]["tags"] = { + key: [tag.to_dict() for tag in tags] for key, tags in self.tags.items() + } + + return result + + +class TaskResultCallbackOptions: + """Options for sending task result callback to TFC/TFE. + + API Documentation: + https://developer.hashicorp.com/terraform/enterprise/api-docs/run-tasks/run-tasks-integration#request-body-1 + """ + + def __init__( + self, + status: str, + message: str | None = None, + url: str | None = None, + outcomes: list[TaskResultOutcome] | None = None, + ): + """Initialize callback options. + + Args: + status: Task result status (passed, failed, running) + message: Optional message about the task result + url: Optional URL to view detailed results + outcomes: Optional list of detailed outcomes + """ + self.status = status + self.message = message + self.url = url + self.outcomes = outcomes or [] + + def validate(self) -> None: + """Validate the callback options. + + Only passed, failed, and running statuses are allowed for callbacks. + pending and errored are not valid callback statuses per TFC/TFE API. + """ + valid_statuses = [ + TaskResultStatus.PASSED.value, + TaskResultStatus.FAILED.value, + TaskResultStatus.RUNNING.value, + ] + if self.status not in valid_statuses: + raise TFEError( + f"Invalid task result status: {self.status}. " + f"Must be one of: {', '.join(valid_statuses)}" + ) + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for JSON:API format.""" + data: dict[str, Any] = { + "type": "task-results", + "attributes": { + "status": self.status, + }, + } + + if self.message: + data["attributes"]["message"] = self.message + if self.url: + data["attributes"]["url"] = self.url + + if self.outcomes: + data["relationships"] = { + "outcomes": {"data": [outcome.to_dict() for outcome in self.outcomes]} + } + + return {"data": data} + + +class RunTasksIntegration(_Service): + """Run Tasks Integration API for sending callbacks to TFC/TFE. + + This service is used by external run task servers to send task results + back to Terraform Cloud/Enterprise. + + API Documentation: + https://developer.hashicorp.com/terraform/enterprise/api-docs/run-tasks/run-tasks-integration + """ + + def callback( + self, + callback_url: str, + access_token: str, + options: TaskResultCallbackOptions, + ) -> None: + """Send task result callback to TFC/TFE. + + Args: + callback_url: The callback URL from the run task request + access_token: The access token from the run task request + options: Task result callback options + + Raises: + TFEError: If callback_url or access_token is invalid + TFEError: If options validation fails + """ + if not callback_url or not callback_url.strip(): + raise TFEError("callback_url cannot be empty") + + if not access_token or not access_token.strip(): + raise TFEError("access_token cannot be empty") + + options.validate() + + # Create custom headers with the access token from the request + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/vnd.api+json", + } + + # Send PATCH request to callback URL + self.t.request( + "PATCH", + callback_url, + json_body=options.to_dict(), + headers=headers, + ) diff --git a/src/pytfe/resources/workspace_resources.py b/src/pytfe/resources/workspace_resources.py new file mode 100644 index 0000000..617b5f7 --- /dev/null +++ b/src/pytfe/resources/workspace_resources.py @@ -0,0 +1,66 @@ +"""Workspace resources service for Terraform Enterprise.""" + +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 = f"/api/v2/workspaces/{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/src/pytfe/utils.py b/src/pytfe/utils.py index d6e9b38..02c43cc 100644 --- a/src/pytfe/utils.py +++ b/src/pytfe/utils.py @@ -37,7 +37,7 @@ WorkspaceUpdateOptions, ) -_STRING_ID_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{2,}$") +_STRING_ID_PATTERN = re.compile(r"^[^/\s]+$") _WS_ID_RE = re.compile(r"^ws-[A-Za-z0-9]+$") _VERSION_PATTERN = re.compile( r"^\d+\.\d+\.\d+(?:-[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*)?(?:\+[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*)?$" diff --git a/tests/units/test_apply.py b/tests/units/test_apply.py index 458c87b..62f7509 100644 --- a/tests/units/test_apply.py +++ b/tests/units/test_apply.py @@ -25,7 +25,7 @@ def test_read_apply_validation_errors(self): self.applies.read("") with self.assertRaises(InvalidApplyIDError): - self.applies.read("a") + self.applies.read("! / nope") # Contains spaces and slashes def test_read_apply_success(self): """Test successful apply read.""" diff --git a/tests/units/test_oauth_token.py b/tests/units/test_oauth_token.py index 1af0708..b60ee9b 100644 --- a/tests/units/test_oauth_token.py +++ b/tests/units/test_oauth_token.py @@ -5,7 +5,7 @@ """ from datetime import datetime -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest @@ -35,7 +35,6 @@ def test_parse_oauth_token_minimal(self, oauth_tokens_service): data = { "id": "ot-test123", "attributes": { - "uid": "uid-test123", "created-at": "2023-01-01T00:00:00Z", "has-ssh-key": False, "service-provider-user": "testuser", @@ -46,7 +45,6 @@ def test_parse_oauth_token_minimal(self, oauth_tokens_service): result = oauth_tokens_service._parse_oauth_token(data) assert result.id == "ot-test123" - assert result.uid == "uid-test123" assert isinstance(result.created_at, datetime) assert result.has_ssh_key is False assert result.service_provider_user == "testuser" @@ -57,7 +55,6 @@ def test_parse_oauth_token_with_oauth_client(self, oauth_tokens_service): data = { "id": "ot-test123", "attributes": { - "uid": "uid-test123", "created-at": "2023-01-01T00:00:00Z", "has-ssh-key": True, "service-provider-user": "testuser", @@ -84,7 +81,6 @@ def test_parse_oauth_token_empty_relationships(self, oauth_tokens_service): data = { "id": "ot-test123", "attributes": { - "uid": "uid-test123", "created-at": "2023-01-01T00:00:00Z", "has-ssh-key": False, "service-provider-user": "testuser", @@ -119,7 +115,6 @@ def test_list_oauth_tokens_basic(self, oauth_tokens_service, mock_transport): { "id": "ot-test1", "attributes": { - "uid": "uid-test1", "created-at": "2023-01-01T00:00:00Z", "has-ssh-key": False, "service-provider-user": "testuser1", @@ -129,7 +124,6 @@ def test_list_oauth_tokens_basic(self, oauth_tokens_service, mock_transport): { "id": "ot-test2", "attributes": { - "uid": "uid-test2", "created-at": "2023-01-02T00:00:00Z", "has-ssh-key": True, "service-provider-user": "testuser2", @@ -149,38 +143,33 @@ def test_list_oauth_tokens_basic(self, oauth_tokens_service, mock_transport): } mock_transport.request.return_value = mock_response - result = oauth_tokens_service.list("test-org") + result = list(oauth_tokens_service.list("test-org")) - mock_transport.request.assert_called_once_with( - "GET", "/api/v2/organizations/test-org/oauth-tokens" - ) - assert len(result.items) == 2 - assert result.items[0].id == "ot-test1" - assert result.items[1].id == "ot-test2" - assert result.current_page == 1 - assert result.total_count == 2 + assert mock_transport.request.call_count == 1 + assert len(result) == 2 + assert result[0].id == "ot-test1" + assert result[1].id == "ot-test2" def test_list_oauth_tokens_with_options(self, oauth_tokens_service, mock_transport): """Test listing OAuth tokens with pagination options.""" - mock_response = Mock() - mock_response.json.return_value = { - "data": [], - "meta": {"pagination": {"current-page": 2}}, - } - mock_transport.request.return_value = mock_response + options = OAuthTokenListOptions(page_size=50) - options = OAuthTokenListOptions(page_number=2, page_size=50) - oauth_tokens_service.list("test-org", options) + with patch.object(oauth_tokens_service, "_list") as mock_list: + mock_list.return_value = [] - mock_transport.request.assert_called_once_with( - "GET", - "/api/v2/organizations/test-org/oauth-tokens?page[number]=2&page[size]=50", - ) + list(oauth_tokens_service.list("test-org", options)) + + expected_params = { + "page[size]": "50", + } + mock_list.assert_called_once_with( + "/api/v2/organizations/test-org/oauth-tokens", params=expected_params + ) def test_list_oauth_tokens_invalid_org(self, oauth_tokens_service): """Test listing OAuth tokens with invalid organization ID.""" with pytest.raises(ValueError, match=ERR_INVALID_ORG): - oauth_tokens_service.list("") + list(oauth_tokens_service.list("")) def test_read_oauth_token_success(self, oauth_tokens_service, mock_transport): """Test reading an OAuth token successfully.""" @@ -189,7 +178,6 @@ def test_read_oauth_token_success(self, oauth_tokens_service, mock_transport): "data": { "id": "ot-test123", "attributes": { - "uid": "uid-test123", "created-at": "2023-01-01T00:00:00Z", "has-ssh-key": False, "service-provider-user": "testuser", @@ -205,7 +193,6 @@ def test_read_oauth_token_success(self, oauth_tokens_service, mock_transport): "GET", "/api/v2/oauth-tokens/ot-test123" ) assert result.id == "ot-test123" - assert result.uid == "uid-test123" def test_read_oauth_token_invalid_id(self, oauth_tokens_service): """Test reading an OAuth token with invalid ID.""" @@ -219,7 +206,6 @@ def test_update_oauth_token_success(self, oauth_tokens_service, mock_transport): "data": { "id": "ot-test123", "attributes": { - "uid": "uid-test123", "created-at": "2023-01-01T00:00:00Z", "has-ssh-key": True, "service-provider-user": "testuser", @@ -253,7 +239,6 @@ def test_update_oauth_token_no_ssh_key(self, oauth_tokens_service, mock_transpor "data": { "id": "ot-test123", "attributes": { - "uid": "uid-test123", "created-at": "2023-01-01T00:00:00Z", "has-ssh-key": False, "service-provider-user": "testuser", @@ -308,9 +293,8 @@ def oauth_tokens_service(self): def test_oauth_token_list_options(self, oauth_tokens_service): """Test OAuth token list options creation.""" - options = OAuthTokenListOptions(page_number=1, page_size=25) + options = OAuthTokenListOptions(page_size=25) - assert options.page_number == 1 assert options.page_size == 25 def test_oauth_token_update_options(self, oauth_tokens_service): 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 diff --git a/tests/units/test_policy_evaluation.py b/tests/units/test_policy_evaluation.py new file mode 100644 index 0000000..820496b --- /dev/null +++ b/tests/units/test_policy_evaluation.py @@ -0,0 +1,211 @@ +"""Unit tests for the policy evaluation module.""" + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import InvalidTaskStageIDError +from pytfe.models.policy_evaluation import ( + PolicyEvaluation, + PolicyEvaluationListOptions, + PolicyEvaluationStatus, +) +from pytfe.resources.policy_evaluation import PolicyEvaluations + + +class TestPolicyEvaluations: + """Test the PolicyEvaluations service class.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock HTTPTransport.""" + return Mock(spec=HTTPTransport) + + @pytest.fixture + def policy_evaluations_service(self, mock_transport): + """Create a PolicyEvaluations service with mocked transport.""" + return PolicyEvaluations(mock_transport) + + def test_list_validations(self, policy_evaluations_service): + """Test list method with invalid task stage ID.""" + + # Test empty task stage ID + with pytest.raises(InvalidTaskStageIDError): + list(policy_evaluations_service.list("")) + + # Test None task stage ID + with pytest.raises(InvalidTaskStageIDError): + list(policy_evaluations_service.list(None)) + + def test_list_success_with_options( + self, policy_evaluations_service, mock_transport + ): + """Test successful iteration with custom pagination options.""" + + mock_response_data = { + "data": [ + { + "id": "poleval-456", + "type": "policy-evaluations", + "attributes": { + "status": "failed", + "policy-kind": "opa", + "status-timestamp": { + "passed-at": None, + "failed-at": "2023-01-02T12:00:00Z", + "running-at": "2023-01-02T11:59:00Z", + "canceled-at": None, + "errored-at": None, + }, + "result-count": { + "advisory-failed": 2, + "mandatory-failed": 1, + "passed": 3, + "errored": 0, + }, + "created-at": "2023-01-02T11:58:00Z", + "updated-at": "2023-01-02T12:00:00Z", + }, + "relationships": { + "policy-attachable": { + "data": {"id": "ts-456", "type": "task-stages"} + } + }, + } + ] + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + mock_transport.request.return_value = mock_response + + options = PolicyEvaluationListOptions(page_size=5) + result = list(policy_evaluations_service.list("ts-456", options=options)) + + # Verify the request was made with correct parameters + assert mock_transport.request.call_count == 1 + call_args = mock_transport.request.call_args + assert call_args[0][0] == "GET" + assert call_args[0][1] == "api/v2/task-stages/ts-456/policy-evaluations" + + # Verify custom options were passed and merged with _list defaults + params = call_args[1]["params"] + assert params["page[size]"] == 5 # Custom value from options + + # Verify the result + assert len(result) == 1 + assert isinstance(result[0], PolicyEvaluation) + assert result[0].id == "poleval-456" + assert result[0].status == PolicyEvaluationStatus.POLICYEVALUATIONFAILED + assert result[0].result_count.advisory_failed == 2 + assert result[0].result_count.mandatory_failed == 1 + + def test_list_empty_result(self, policy_evaluations_service, mock_transport): + """Test iteration with no results.""" + + mock_response_data = {"data": []} + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + mock_transport.request.return_value = mock_response + + result = list(policy_evaluations_service.list("ts-empty")) + + # Verify the request was made + assert mock_transport.request.call_count == 1 + + # Verify iterator yields no items + assert len(result) == 0 + assert result == [] + + def test_list_with_different_statuses( + self, policy_evaluations_service, mock_transport + ): + """Test list operation returns evaluations with different statuses.""" + + mock_response_data = { + "data": [ + { + "id": "poleval-pending", + "type": "policy-evaluations", + "attributes": { + "status": "pending", + "policy-kind": "opa", + "status-timestamp": {}, + "result-count": { + "advisory-failed": 0, + "mandatory-failed": 0, + "passed": 0, + "errored": 0, + }, + "created-at": "2023-01-01T11:58:00Z", + "updated-at": "2023-01-01T11:58:00Z", + }, + "relationships": { + "policy-attachable": { + "data": {"id": "ts-multi", "type": "task-stages"} + } + }, + }, + { + "id": "poleval-running", + "type": "policy-evaluations", + "attributes": { + "status": "running", + "policy-kind": "opa", + "status-timestamp": {"running-at": "2023-01-01T11:59:00Z"}, + "result-count": { + "advisory-failed": 0, + "mandatory-failed": 0, + "passed": 0, + "errored": 0, + }, + "created-at": "2023-01-01T11:58:00Z", + "updated-at": "2023-01-01T11:59:00Z", + }, + "relationships": { + "policy-attachable": { + "data": {"id": "ts-multi", "type": "task-stages"} + } + }, + }, + { + "id": "poleval-errored", + "type": "policy-evaluations", + "attributes": { + "status": "errored", + "policy-kind": "opa", + "status-timestamp": {"errored-at": "2023-01-01T12:00:00Z"}, + "result-count": { + "advisory-failed": 0, + "mandatory-failed": 0, + "passed": 0, + "errored": 1, + }, + "created-at": "2023-01-01T11:58:00Z", + "updated-at": "2023-01-01T12:00:00Z", + }, + "relationships": { + "policy-attachable": { + "data": {"id": "ts-multi", "type": "task-stages"} + } + }, + }, + ] + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + mock_transport.request.return_value = mock_response + + result = list(policy_evaluations_service.list("ts-multi")) + + # Verify the iterator yields all items with correct statuses + assert len(result) == 3 + assert result[0].status == PolicyEvaluationStatus.POLICYEVALUATIONPENDING + assert result[1].status == PolicyEvaluationStatus.POLICYEVALUATIONRUNNING + assert result[2].status == PolicyEvaluationStatus.POLICYEVALUATIONERRORED + + # Verify all are PolicyEvaluation instances + assert all(isinstance(item, PolicyEvaluation) for item in result) 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 diff --git a/tests/units/test_project.py b/tests/units/test_project.py index 262787a..801a29f 100644 --- a/tests/units/test_project.py +++ b/tests/units/test_project.py @@ -345,7 +345,9 @@ def test_list_tag_bindings_invalid_project_id(self): with pytest.raises( ValueError, match="Project ID is required and must be valid" ): - self.projects_service.list_tag_bindings("x") # Too short + self.projects_service.list_tag_bindings( + "! / nope" + ) # Contains spaces and slashes def test_list_effective_tag_bindings_success(self): """Test successful listing of effective tag bindings""" @@ -541,5 +543,5 @@ def test_delete_tag_bindings_invalid_project_id(self): ValueError, match="Project ID is required and must be valid" ): self.projects_service.delete_tag_bindings( - "ab" - ) # Too short (needs at least 3 chars) + "bad/id" + ) # Contains forward slash diff --git a/tests/units/test_query_run.py b/tests/units/test_query_run.py index 8808090..3409d13 100644 --- a/tests/units/test_query_run.py +++ b/tests/units/test_query_run.py @@ -1,564 +1,551 @@ -from datetime import datetime -from unittest.mock import MagicMock, Mock +""" +Comprehensive unit tests for query run operations in the Python TFE SDK. + +This test suite covers all query run methods including: +1. list() - List query runs for a workspace with pagination +2. create() - Create new query runs +3. read() - Read query run details +4. read_with_options() - Read with include options +5. logs() - Retrieve query run logs +6. cancel() - Cancel a query run +7. force_cancel() - Force cancel a query run + +Usage: + pytest tests/units/test_query_run.py -v +""" + +from unittest.mock import Mock, patch import pytest -from pytfe import TFEClient, TFEConfig -from pytfe.errors import InvalidOrgError, InvalidQueryRunIDError -from pytfe.models.query_run import ( +from pytfe.errors import InvalidQueryRunIDError, InvalidWorkspaceIDError +from pytfe.models import ( QueryRun, - QueryRunCancelOptions, QueryRunCreateOptions, - QueryRunForceCancelOptions, - QueryRunList, + QueryRunIncludeOpt, QueryRunListOptions, - QueryRunLogs, QueryRunReadOptions, - QueryRunResults, + QueryRunSource, QueryRunStatus, - QueryRunType, + QueryRunStatusTimestamps, + QueryRunVariable, ) +from pytfe.resources.query_run import QueryRuns + +# ============================================================================ +# Fixtures +# ============================================================================ + + +@pytest.fixture +def mock_transport(): + """Create a mock HTTPTransport.""" + return Mock() + + +@pytest.fixture +def query_runs_service(mock_transport): + """Create a QueryRuns service with mocked transport.""" + return QueryRuns(mock_transport) + + +@pytest.fixture +def sample_query_run_data(): + """Sample query run data from API.""" + return { + "id": "qr-123abc456def", + "type": "queries", + "attributes": { + "source": "tfe-api", + "status": "finished", + "created-at": "2024-01-15T10:00:00Z", + "updated-at": "2024-01-15T10:05:00Z", + "canceled-at": None, + "log-read-url": "https://app.terraform.io/api/v2/queries/qr-123abc456def/logs", + "status-timestamps": { + "queued-at": "2024-01-15T10:00:00Z", + "running-at": "2024-01-15T10:01:00Z", + "finished-at": "2024-01-15T10:05:00Z", + }, + "variables": [ + {"key": "environment", "value": "production"}, + {"key": "region", "value": "us-east-1"}, + ], + "actions": { + "is-cancelable": True, + "is-force-cancelable": False, + }, + }, + "relationships": { + "workspace": {"data": {"id": "ws-abc123", "type": "workspaces"}}, + "configuration-version": { + "data": {"id": "cv-def456", "type": "configuration-versions"} + }, + "created-by": {"data": {"id": "user-123", "type": "users"}}, + }, + } + + +@pytest.fixture +def sample_query_run_list_response(sample_query_run_data): + """Sample query run list response.""" + return { + "data": [ + sample_query_run_data, + { + "id": "qr-789ghi012jkl", + "type": "queries", + "attributes": { + "source": "tfe-api", + "status": "running", + "created-at": "2024-01-15T11:00:00Z", + "updated-at": "2024-01-15T11:02:00Z", + "canceled-at": None, + "log-read-url": None, + "status-timestamps": { + "queued-at": "2024-01-15T11:00:00Z", + "running-at": "2024-01-15T11:01:00Z", + }, + "variables": [], + "actions": { + "is-cancelable": True, + "is-force-cancelable": False, + }, + }, + }, + ], + "meta": { + "pagination": { + "current-page": 1, + "page-size": 20, + "total-pages": 1, + "total-count": 2, + } + }, + "links": {"next": None}, + } -class TestQueryRunModels: - """Test query run models and validation.""" +# ============================================================================ +# List Operations Tests +# ============================================================================ - def test_query_run_model_basic(self): - """Test basic QueryRun model creation.""" - query_run = QueryRun( - id="qr-test123", - query="SELECT * FROM runs WHERE status = 'completed'", - query_type=QueryRunType.FILTER, - status=QueryRunStatus.COMPLETED, - created_at=datetime.now(), - updated_at=datetime.now(), - ) - assert query_run.id == "qr-test123" - assert query_run.query == "SELECT * FROM runs WHERE status = 'completed'" - assert query_run.query_type == QueryRunType.FILTER - assert query_run.status == QueryRunStatus.COMPLETED - - def test_query_run_status_enum(self): - """Test QueryRunStatus enum values.""" - assert QueryRunStatus.PENDING == "pending" - assert QueryRunStatus.RUNNING == "running" - assert QueryRunStatus.COMPLETED == "completed" - assert QueryRunStatus.ERRORED == "errored" - assert QueryRunStatus.CANCELED == "canceled" - - def test_query_run_type_enum(self): - """Test QueryRunType enum values.""" - assert QueryRunType.FILTER == "filter" - assert QueryRunType.SEARCH == "search" - assert QueryRunType.ANALYTICS == "analytics" - - def test_query_run_create_options(self): - """Test QueryRunCreateOptions model.""" - options = QueryRunCreateOptions( - query="SELECT * FROM workspaces", - query_type=QueryRunType.SEARCH, - organization_name="test-org", - timeout_seconds=300, - max_results=1000, - ) - assert options.query == "SELECT * FROM workspaces" - assert options.query_type == QueryRunType.SEARCH - assert options.organization_name == "test-org" - assert options.timeout_seconds == 300 - assert options.max_results == 1000 - - def test_query_run_list_options(self): - """Test QueryRunListOptions model.""" - options = QueryRunListOptions( - page_number=2, - page_size=50, - query_type=QueryRunType.FILTER, - status=QueryRunStatus.COMPLETED, - organization_name="test-org", - ) - assert options.page_number == 2 - assert options.page_size == 50 - assert options.query_type == QueryRunType.FILTER - assert options.status == QueryRunStatus.COMPLETED - assert options.organization_name == "test-org" - - -class TestQueryRunOperations: - """Test query run operations.""" - - @pytest.fixture - def client(self): - """Create a test client.""" - config = TFEConfig(address="https://test.terraform.io", token="test-token") - return TFEClient(config) - - @pytest.fixture - def mock_response(self): - """Create a mock response.""" - mock = Mock() - mock.json.return_value = { - "data": [ - { - "id": "qr-test123", - "type": "query-runs", - "attributes": { - "query": "SELECT * FROM runs", - "query-type": "filter", - "status": "completed", - "results-count": 42, - "created-at": "2023-01-01T00:00:00Z", - "updated-at": "2023-01-01T00:05:00Z", - "started-at": "2023-01-01T00:01:00Z", - "finished-at": "2023-01-01T00:05:00Z", - "organization-name": "test-org", - }, - } - ], - "meta": { - "pagination": { - "current-page": 1, - "total-pages": 1, - "prev-page": None, - "next-page": None, - "total-count": 1, - } - }, - } - return mock - def test_list_query_runs(self, client, mock_response): - """Test listing query runs.""" - client._transport.request = MagicMock(return_value=mock_response) +class TestQueryRunsList: + """Test suite for query run list operations.""" - result = client.query_runs.list("test-org") + def test_list_basic( + self, query_runs_service, mock_transport, sample_query_run_list_response + ): + """Test basic query run listing.""" + mock_response = Mock() + mock_response.json.return_value = sample_query_run_list_response + mock_transport.request.return_value = mock_response - assert isinstance(result, QueryRunList) - assert len(result.items) == 1 - assert result.items[0].id == "qr-test123" - assert result.items[0].query == "SELECT * FROM runs" - assert result.current_page == 1 - assert result.total_count == 1 + workspace_id = "ws-abc123" + query_runs = list(query_runs_service.list(workspace_id)) - client._transport.request.assert_called_once_with( - "GET", "/api/v2/organizations/test-org/query-runs", params=None + # Verify the request + mock_transport.request.assert_called_with( + "GET", + f"/api/v2/workspaces/{workspace_id}/queries", + params={"page[number]": 1, "page[size]": 100}, ) - def test_list_query_runs_with_options(self, client, mock_response): - """Test listing query runs with options.""" - client._transport.request = MagicMock(return_value=mock_response) + # Verify the results + assert len(query_runs) == 2 + + # Check first query run + qr1 = query_runs[0] + assert qr1.id == "qr-123abc456def" + assert qr1.status == QueryRunStatus.FINISHED + assert qr1.source == QueryRunSource.API + assert qr1.log_read_url is not None + assert len(qr1.variables) == 2 + assert qr1.variables[0].key == "environment" + assert qr1.variables[0].value == "production" + + # Check second query run + qr2 = query_runs[1] + assert qr2.id == "qr-789ghi012jkl" + assert qr2.status == QueryRunStatus.RUNNING + assert qr2.log_read_url is None + assert len(qr2.variables) == 0 + + def test_list_with_options( + self, query_runs_service, mock_transport, sample_query_run_list_response + ): + """Test list with options.""" + mock_response = Mock() + mock_response.json.return_value = sample_query_run_list_response + mock_transport.request.return_value = mock_response + workspace_id = "ws-abc123" options = QueryRunListOptions( - page_number=2, - page_size=25, - query_type=QueryRunType.FILTER, - status=QueryRunStatus.COMPLETED, + page_number=1, + page_size=10, + include=[ + QueryRunIncludeOpt.CREATED_BY, + QueryRunIncludeOpt.CONFIGURATION_VERSION, + ], ) - result = client.query_runs.list("test-org", options) - assert isinstance(result, QueryRunList) - client._transport.request.assert_called_once_with( - "GET", - "/api/v2/organizations/test-org/query-runs", - params={ - "page[number]": 2, - "page[size]": 25, - "filter[query-type]": "filter", - "filter[status]": "completed", - }, - ) + query_runs = list(query_runs_service.list(workspace_id, options)) + + # Verify the request includes options + call_args = mock_transport.request.call_args + assert call_args[0][0] == "GET" + assert call_args[0][1] == f"/api/v2/workspaces/{workspace_id}/queries" + params = call_args[1]["params"] + assert params["page[number]"] == 1 + assert params["page[size]"] == 10 + assert params["include"] == "created_by,configuration_version" + + assert len(query_runs) == 2 + + def test_list_invalid_workspace_id(self, query_runs_service): + """Test list with invalid workspace ID.""" + with pytest.raises(InvalidWorkspaceIDError): + list(query_runs_service.list("")) + + with pytest.raises(InvalidWorkspaceIDError): + list(query_runs_service.list(None)) - def test_create_query_run(self, client): - """Test creating a query run.""" + +# ============================================================================ +# Create Operations Tests +# ============================================================================ + + +class TestQueryRunsCreate: + """Test suite for query run create operations.""" + + def test_create_basic( + self, query_runs_service, mock_transport, sample_query_run_data + ): + """Test basic query run creation.""" mock_response = Mock() - mock_response.json.return_value = { - "data": { - "id": "qr-new123", - "type": "query-runs", - "attributes": { - "query": "SELECT * FROM workspaces", - "query-type": "search", - "status": "pending", - "created-at": "2023-01-01T00:00:00Z", - "updated-at": "2023-01-01T00:00:00Z", - "organization-name": "test-org", - }, - } - } - client._transport.request = MagicMock(return_value=mock_response) + mock_response.json.return_value = {"data": sample_query_run_data} + mock_transport.request.return_value = mock_response options = QueryRunCreateOptions( - query="SELECT * FROM workspaces", - query_type=QueryRunType.SEARCH, - organization_name="test-org", - timeout_seconds=300, + source=QueryRunSource.API, + workspace_id="ws-abc123", + configuration_version_id="cv-def456", ) - result = client.query_runs.create("test-org", options) - assert isinstance(result, QueryRun) - assert result.id == "qr-new123" - assert result.query == "SELECT * FROM workspaces" - assert result.status == QueryRunStatus.PENDING + result = query_runs_service.create(options) - client._transport.request.assert_called_once_with( - "POST", - "/api/v2/organizations/test-org/query-runs", - json_body={ - "data": { - "attributes": { - "query": "SELECT * FROM workspaces", - "query-type": "search", - "organization-name": "test-org", - "timeout-seconds": 300, - }, - "type": "query-runs", - } - }, + # Verify the request + call_args = mock_transport.request.call_args + assert call_args[0][0] == "POST" + assert call_args[0][1] == "/api/v2/queries" + + json_body = call_args[1]["json_body"] + assert json_body["data"]["type"] == "queries" + assert json_body["data"]["attributes"]["source"] == "tfe-api" + assert ( + json_body["data"]["relationships"]["workspace"]["data"]["id"] == "ws-abc123" + ) + assert ( + json_body["data"]["relationships"]["configuration-version"]["data"]["id"] + == "cv-def456" ) - def test_read_query_run(self, client): - """Test reading a query run.""" + # Verify the result + assert isinstance(result, QueryRun) + assert result.id == "qr-123abc456def" + assert result.status == QueryRunStatus.FINISHED + assert result.source == QueryRunSource.API + + def test_create_with_variables( + self, query_runs_service, mock_transport, sample_query_run_data + ): + """Test query run creation with variables.""" mock_response = Mock() - mock_response.json.return_value = { - "data": { - "id": "qr-test123", - "type": "query-runs", - "attributes": { - "query": "SELECT * FROM runs", - "query-type": "filter", - "status": "completed", - "results-count": 42, - "created-at": "2023-01-01T00:00:00Z", - "updated-at": "2023-01-01T00:05:00Z", - }, - } - } - client._transport.request = MagicMock(return_value=mock_response) + mock_response.json.return_value = {"data": sample_query_run_data} + mock_transport.request.return_value = mock_response - result = client.query_runs.read("qr-test123") - - assert isinstance(result, QueryRun) - assert result.id == "qr-test123" - assert result.status == QueryRunStatus.COMPLETED - assert result.results_count == 42 + variables = [ + QueryRunVariable(key="environment", value="production"), + QueryRunVariable(key="region", value="us-east-1"), + ] - client._transport.request.assert_called_once_with( - "GET", "/api/v2/query-runs/qr-test123" + options = QueryRunCreateOptions( + source=QueryRunSource.API, + workspace_id="ws-abc123", + configuration_version_id="cv-def456", + variables=variables, ) - def test_read_query_run_with_options(self, client): - """Test reading a query run with options.""" - mock_response = Mock() - mock_response.json.return_value = { - "data": { - "id": "qr-test123", - "type": "query-runs", - "attributes": { - "query": "SELECT * FROM runs", - "query-type": "filter", - "status": "completed", - "created-at": "2023-01-01T00:00:00Z", - "updated-at": "2023-01-01T00:05:00Z", - }, - } - } - client._transport.request = MagicMock(return_value=mock_response) + result = query_runs_service.create(options) - options = QueryRunReadOptions(include_results=True, include_logs=True) - result = client.query_runs.read_with_options("qr-test123", options) + # Verify variables in request + call_args = mock_transport.request.call_args + json_body = call_args[1]["json_body"] + assert "variables" in json_body["data"]["attributes"] + assert len(json_body["data"]["attributes"]["variables"]) == 2 - assert isinstance(result, QueryRun) - assert result.id == "qr-test123" + # Verify result + assert result.id == "qr-123abc456def" + assert len(result.variables) == 2 - client._transport.request.assert_called_once_with( - "GET", - "/api/v2/query-runs/qr-test123", - params={"include[results]": True, "include[logs]": True}, - ) - def test_query_run_logs(self, client): - """Test retrieving query run logs.""" +# ============================================================================ +# Read Operations Tests +# ============================================================================ + + +class TestQueryRunsRead: + """Test suite for query run read operations.""" + + def test_read_success( + self, query_runs_service, mock_transport, sample_query_run_data + ): + """Test successful query run read.""" mock_response = Mock() - mock_response.headers = {"content-type": "text/plain"} - mock_response.text = ( - "Starting query execution...\nQuery completed successfully." + mock_response.json.return_value = {"data": sample_query_run_data} + mock_transport.request.return_value = mock_response + + result = query_runs_service.read("qr-123abc456def") + + # Verify the request + mock_transport.request.assert_called_once_with( + "GET", "/api/v2/queries/qr-123abc456def" ) - client._transport.request = MagicMock(return_value=mock_response) - result = client.query_runs.logs("qr-test123") + # Verify the result + assert isinstance(result, QueryRun) + assert result.id == "qr-123abc456def" + assert result.status == QueryRunStatus.FINISHED + assert result.source == QueryRunSource.API + assert result.log_read_url is not None - assert isinstance(result, QueryRunLogs) - assert result.query_run_id == "qr-test123" - assert "Starting query execution" in result.logs - assert result.log_level == "info" + def test_read_invalid_id(self, query_runs_service): + """Test read with invalid query run ID.""" + with pytest.raises(InvalidQueryRunIDError): + query_runs_service.read("") - client._transport.request.assert_called_once_with( - "GET", "/api/v2/query-runs/qr-test123/logs" - ) + with pytest.raises(InvalidQueryRunIDError): + query_runs_service.read(None) - def test_query_run_results(self, client): - """Test retrieving query run results.""" + def test_read_with_options_success( + self, query_runs_service, mock_transport, sample_query_run_data + ): + """Test read with options.""" mock_response = Mock() - mock_response.json.return_value = { - "data": { - "results": [ - {"id": "run-1", "status": "completed"}, - {"id": "run-2", "status": "pending"}, - ], - "total_count": 2, - "truncated": False, - } - } - client._transport.request = MagicMock(return_value=mock_response) + mock_response.json.return_value = {"data": sample_query_run_data} + mock_transport.request.return_value = mock_response + + options = QueryRunReadOptions( + include=[ + QueryRunIncludeOpt.CREATED_BY, + QueryRunIncludeOpt.CONFIGURATION_VERSION, + ] + ) - result = client.query_runs.results("qr-test123") + result = query_runs_service.read_with_options("qr-123abc456def", options) - assert isinstance(result, QueryRunResults) - assert result.query_run_id == "qr-test123" - assert len(result.results) == 2 - assert result.total_count == 2 - assert not result.truncated + # Verify the request includes options + call_args = mock_transport.request.call_args + assert call_args[0][0] == "GET" + assert call_args[0][1] == "/api/v2/queries/qr-123abc456def" + params = call_args[1]["params"] + assert params["include"] == "created_by,configuration_version" - client._transport.request.assert_called_once_with( - "GET", "/api/v2/query-runs/qr-test123/results" - ) + # Verify the result + assert result.id == "qr-123abc456def" - def test_cancel_query_run(self, client): - """Test canceling a query run.""" - mock_response = Mock() - mock_response.json.return_value = { - "data": { - "id": "qr-test123", - "type": "query-runs", - "attributes": { - "query": "SELECT * FROM runs", - "query-type": "filter", - "status": "canceled", - "created-at": "2023-01-01T00:00:00Z", - "updated-at": "2023-01-01T00:02:00Z", - }, - } - } - client._transport.request = MagicMock(return_value=mock_response) - options = QueryRunCancelOptions(reason="User requested cancellation") - result = client.query_runs.cancel("qr-test123", options) +# ============================================================================ +# Logs Operations Tests +# ============================================================================ - assert isinstance(result, QueryRun) - assert result.id == "qr-test123" - assert result.status == QueryRunStatus.CANCELED - client._transport.request.assert_called_once_with( - "POST", - "/api/v2/query-runs/qr-test123/actions/cancel", - json_body={ - "data": { - "attributes": {"reason": "User requested cancellation"}, - "type": "query-runs", - } - }, +class TestQueryRunsLogs: + """Test suite for query run logs operations.""" + + def test_logs_success(self, query_runs_service, mock_transport): + """Test successful logs retrieval.""" + # Mock the read method to return a query run with log URL + mock_query_run = Mock() + mock_query_run.log_read_url = ( + "https://app.terraform.io/api/v2/queries/qr-123/logs" ) - def test_force_cancel_query_run(self, client): - """Test force canceling a query run.""" - mock_response = Mock() - mock_response.json.return_value = { - "data": { - "id": "qr-test123", - "type": "query-runs", - "attributes": { - "query": "SELECT * FROM runs", - "query-type": "filter", - "status": "canceled", - "created-at": "2023-01-01T00:00:00Z", - "updated-at": "2023-01-01T00:02:00Z", - }, - } - } - client._transport.request = MagicMock(return_value=mock_response) + # Mock the logs content + mock_logs_response = Mock() + mock_logs_response.content = b"Query run logs content\nLine 2\nLine 3" - options = QueryRunForceCancelOptions(reason="Force cancel due to timeout") - result = client.query_runs.force_cancel("qr-test123", options) + with patch.object(query_runs_service, "read", return_value=mock_query_run): + mock_transport.request.return_value = mock_logs_response - assert isinstance(result, QueryRun) - assert result.id == "qr-test123" - assert result.status == QueryRunStatus.CANCELED + result = query_runs_service.logs("qr-123abc456def") - client._transport.request.assert_called_once_with( - "POST", - "/api/v2/query-runs/qr-test123/actions/force-cancel", - json_body={ - "data": { - "attributes": {"reason": "Force cancel due to timeout"}, - "type": "query-runs", - } - }, - ) + # Verify read was called + query_runs_service.read.assert_called_once_with("qr-123abc456def") + # Verify logs request was made + mock_transport.request.assert_called_once_with( + "GET", "https://app.terraform.io/api/v2/queries/qr-123/logs" + ) -class TestQueryRunErrorHandling: - """Test query run error handling.""" + # Verify the result is an IO stream + assert result.read() == b"Query run logs content\nLine 2\nLine 3" - @pytest.fixture - def client(self): - """Create a test client.""" - config = TFEConfig(address="https://test.terraform.io", token="test-token") - return TFEClient(config) + def test_logs_no_url_error(self, query_runs_service): + """Test logs method when query run has no log URL.""" + mock_query_run = Mock() + mock_query_run.log_read_url = None - def test_invalid_organization_error(self, client): - """Test invalid organization error.""" - with pytest.raises(InvalidOrgError): - client.query_runs.list("") + with patch.object(query_runs_service, "read", return_value=mock_query_run): + with pytest.raises(ValueError) as exc: + query_runs_service.logs("qr-123abc456def") - with pytest.raises(InvalidOrgError): - client.query_runs.list(None) + assert "does not have a log URL" in str(exc.value) - def test_invalid_query_run_id_error(self, client): - """Test invalid query run ID error.""" + def test_logs_invalid_id(self, query_runs_service): + """Test logs with invalid query run ID.""" with pytest.raises(InvalidQueryRunIDError): - client.query_runs.read("") + query_runs_service.logs("") - with pytest.raises(InvalidQueryRunIDError): - client.query_runs.read(None) - with pytest.raises(InvalidQueryRunIDError): - client.query_runs.logs("") +# ============================================================================ +# Cancel Operations Tests +# ============================================================================ - with pytest.raises(InvalidQueryRunIDError): - client.query_runs.results("") - with pytest.raises(InvalidQueryRunIDError): - client.query_runs.cancel("") +class TestQueryRunsCancel: + """Test suite for query run cancel operations.""" + + def test_cancel_success(self, query_runs_service, mock_transport): + """Test successful query run cancellation.""" + mock_response = Mock() + mock_transport.request.return_value = mock_response + + query_runs_service.cancel("qr-123abc456def") + + # Verify the request + mock_transport.request.assert_called_once_with( + "POST", + "/api/v2/queries/qr-123abc456def/actions/cancel", + ) + def test_cancel_invalid_id(self, query_runs_service): + """Test cancel with invalid query run ID.""" with pytest.raises(InvalidQueryRunIDError): - client.query_runs.force_cancel("") + query_runs_service.cancel("") - def test_create_query_run_validation_errors(self, client): - """Test create query run validation errors.""" - with pytest.raises(InvalidOrgError): - options = QueryRunCreateOptions( - query="SELECT * FROM runs", query_type=QueryRunType.FILTER - ) - client.query_runs.create("", options) +# ============================================================================ +# Force Cancel Operations Tests +# ============================================================================ -class TestQueryRunIntegration: - """Test query run integration scenarios.""" - @pytest.fixture - def client(self): - """Create a test client with mocked transport.""" - from unittest.mock import MagicMock, patch +class TestQueryRunsForceCancel: + """Test suite for query run force cancel operations.""" - # Mock the HTTPTransport to prevent any network calls during initialization - with patch("pytfe.client.HTTPTransport") as mock_transport_class: - mock_transport_instance = MagicMock() - mock_transport_class.return_value = mock_transport_instance + def test_force_cancel_success(self, query_runs_service, mock_transport): + """Test successful force cancellation.""" + mock_response = Mock() + mock_transport.request.return_value = mock_response - config = TFEConfig(address="https://test.terraform.io", token="test-token") - client = TFEClient(config) - return client + query_runs_service.force_cancel("qr-123abc456def") - def test_full_query_run_workflow(self, client): - """Test a complete query run workflow simulation.""" - # Use the already mocked transport from the fixture - mock_transport = client._transport + # Verify the request + mock_transport.request.assert_called_once_with( + "POST", + "/api/v2/queries/qr-123abc456def/actions/force-cancel", + ) - # 1. Create query run - create_response = Mock() - create_response.json.return_value = { - "data": { - "id": "qr-workflow123", - "type": "query-runs", - "attributes": { - "query": "SELECT * FROM runs WHERE status = 'completed'", - "query-type": "filter", - "status": "pending", - "created-at": "2023-01-01T00:00:00Z", - "updated-at": "2023-01-01T00:00:00Z", - "organization-name": "test-org", - }, - } - } - - # 2. Read query run (running state) - read_response = Mock() - read_response.json.return_value = { - "data": { - "id": "qr-workflow123", - "type": "query-runs", - "attributes": { - "query": "SELECT * FROM runs WHERE status = 'completed'", - "query-type": "filter", - "status": "running", - "created-at": "2023-01-01T00:00:00Z", - "updated-at": "2023-01-01T00:01:00Z", - "started-at": "2023-01-01T00:01:00Z", - }, - } - } - - # 3. Read query run (completed state) - completed_response = Mock() - completed_response.json.return_value = { - "data": { - "id": "qr-workflow123", - "type": "query-runs", - "attributes": { - "query": "SELECT * FROM runs WHERE status = 'completed'", - "query-type": "filter", - "status": "completed", - "results-count": 15, - "created-at": "2023-01-01T00:00:00Z", - "updated-at": "2023-01-01T00:05:00Z", - "started-at": "2023-01-01T00:01:00Z", - "finished-at": "2023-01-01T00:05:00Z", - }, - } - } - - # 4. Get results - results_response = Mock() - results_response.json.return_value = { - "data": { - "results": [ - {"id": f"run-{i}", "status": "completed"} for i in range(15) - ], - "total_count": 15, - "truncated": False, - } - } + def test_force_cancel_invalid_id(self, query_runs_service): + """Test force cancel with invalid query run ID.""" + with pytest.raises(InvalidQueryRunIDError): + query_runs_service.force_cancel("") - mock_transport.request.side_effect = [ - create_response, - read_response, - completed_response, - results_response, + +# ============================================================================ +# Unit Tests - Model Validation +# ============================================================================ + + +class TestQueryRunCreateOptions: + """Unit tests for QueryRunCreateOptions model.""" + + def test_create_with_required_fields(self): + """Test creating options with required fields only.""" + options = QueryRunCreateOptions( + source=QueryRunSource.API, + workspace_id="ws-123", + ) + + assert options.source == QueryRunSource.API + assert options.workspace_id == "ws-123" + assert options.configuration_version_id is None + assert options.variables is None + + def test_create_with_all_fields(self): + """Test creating options with all fields.""" + variables = [ + QueryRunVariable(key="var1", value="value1"), + QueryRunVariable(key="var2", value="value2"), ] - # Execute workflow options = QueryRunCreateOptions( - query="SELECT * FROM runs WHERE status = 'completed'", - query_type=QueryRunType.FILTER, - organization_name="test-org", + source=QueryRunSource.API, + workspace_id="ws-123", + configuration_version_id="cv-456", + variables=variables, ) - # 1. Create - query_run = client.query_runs.create("test-org", options) - assert query_run.status == QueryRunStatus.PENDING + assert options.source == QueryRunSource.API + assert options.workspace_id == "ws-123" + assert options.configuration_version_id == "cv-456" + assert len(options.variables) == 2 + assert options.variables[0].key == "var1" + + +class TestQueryRunModel: + """Unit tests for QueryRun model.""" + + def test_status_enum_values(self): + """Test all status enum values.""" + assert QueryRunStatus.PENDING.value == "pending" + assert QueryRunStatus.QUEUED.value == "queued" + assert QueryRunStatus.RUNNING.value == "running" + assert QueryRunStatus.FINISHED.value == "finished" + assert QueryRunStatus.ERRORED.value == "errored" + assert QueryRunStatus.CANCELED.value == "canceled" + + def test_source_enum_value(self): + """Test source enum value.""" + assert QueryRunSource.API.value == "tfe-api" + + +# ============================================================================ +# Test Utilities +# ============================================================================ + + +def test_query_run_variable(): + """Test QueryRunVariable model.""" + var = QueryRunVariable(key="test_key", value="test_value") - # 2. Check status (running) - query_run = client.query_runs.read(query_run.id) - assert query_run.status == QueryRunStatus.RUNNING + assert var.key == "test_key" + assert var.value == "test_value" - # 3. Check status (completed) - query_run = client.query_runs.read(query_run.id) - assert query_run.status == QueryRunStatus.COMPLETED - assert query_run.results_count == 15 - # 4. Get results - results = client.query_runs.results(query_run.id) - assert len(results.results) == 15 - assert not results.truncated +def test_query_run_status_timestamps(): + """Test QueryRunStatusTimestamps model.""" + timestamps = QueryRunStatusTimestamps( + queued_at="2024-01-15T10:00:00Z", + running_at="2024-01-15T10:05:00Z", + errored_at="2024-01-15T10:10:00Z", + ) - # Verify all calls were made - assert mock_transport.request.call_count == 4 + # Timestamps are datetime objects + assert timestamps.queued_at is not None + assert timestamps.running_at is not None + assert timestamps.errored_at is not None + assert timestamps.finished_at is None + assert timestamps.canceled_at is None diff --git a/tests/units/test_registry_provider_version.py b/tests/units/test_registry_provider_version.py new file mode 100644 index 0000000..46b76bd --- /dev/null +++ b/tests/units/test_registry_provider_version.py @@ -0,0 +1,402 @@ +"""Unit tests for the registry_provider_version module.""" + +from unittest.mock import Mock, patch + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import ( + InvalidKeyIDError, + InvalidVersionError, + RequiredPrivateRegistryError, +) +from pytfe.models.registry_provider import ( + RegistryName, + RegistryProviderID, +) +from pytfe.models.registry_provider_version import ( + RegistryProviderVersion, + RegistryProviderVersionCreateOptions, + RegistryProviderVersionID, +) +from pytfe.resources.registry_provider_version import RegistryProviderVersions + + +class TestRegistryProviderVersions: + """Test the RegistryProviderVersions service class.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock HTTPTransport.""" + return Mock(spec=HTTPTransport) + + @pytest.fixture + def versions_service(self, mock_transport): + """Create a RegistryProviderVersions service with mocked transport.""" + return RegistryProviderVersions(mock_transport) + + @pytest.fixture + def valid_provider_id(self): + """Create a valid provider ID.""" + return RegistryProviderID( + organization_name="test-org", + registry_name=RegistryName.PRIVATE, + namespace="test-namespace", + name="test-provider", + ) + + @pytest.fixture + def valid_version_id(self): + """Create a valid version ID.""" + return RegistryProviderVersionID( + organization_name="test-org", + registry_name=RegistryName.PRIVATE, + namespace="test-namespace", + name="test-provider", + version="1.0.0", + ) + + def test_validate_provider_id_success(self, versions_service, valid_provider_id): + """Test _validate_provider_id with valid provider ID.""" + result = versions_service._validate_provider_id(valid_provider_id) + assert result is True + + def test_validate_provider_id_invalid_organization( + self, versions_service, valid_provider_id + ): + """Test _validate_provider_id with invalid organization name.""" + valid_provider_id.organization_name = "" + result = versions_service._validate_provider_id(valid_provider_id) + assert result is False + + def test_create_version_validations(self, versions_service): + """Test create method validations.""" + # Test with invalid provider ID + invalid_provider_id = RegistryProviderID( + organization_name="", + registry_name=RegistryName.PRIVATE, + namespace="test-namespace", + name="test-provider", + ) + options = RegistryProviderVersionCreateOptions( + version="1.0.0", **{"key-id": "test-key-id"}, protocols=["5.0"] + ) + + with pytest.raises(ValueError, match="Invalid provider ID"): + versions_service.create(invalid_provider_id, options) + + def test_create_version_requires_private_registry( + self, versions_service, mock_transport + ): + """Test create method requires private registry.""" + public_provider_id = RegistryProviderID( + organization_name="test-org", + registry_name=RegistryName.PUBLIC, + namespace="hashicorp", + name="aws", + ) + options = RegistryProviderVersionCreateOptions( + version="1.0.0", **{"key-id": "test-key-id"}, protocols=["5.0"] + ) + + with pytest.raises(RequiredPrivateRegistryError): + versions_service.create(public_provider_id, options) + + def test_create_version_success( + self, versions_service, valid_provider_id, mock_transport + ): + """Test successful create operation.""" + mock_response_data = { + "data": { + "id": "provver-123", + "type": "registry-provider-versions", + "attributes": { + "version": "1.0.0", + "created-at": "2023-01-01T12:00:00Z", + "updated-at": "2023-01-01T12:00:00Z", + "key-id": "test-key-id", + "protocols": ["5.0"], + "shasums-uploaded": False, + "shasums-sig-uploaded": False, + "permissions": { + "can-delete": True, + "can-upload-asset": True, + }, + }, + "relationships": { + "registry-provider": { + "data": {"id": "prov-123", "type": "registry-providers"} + } + }, + "links": { + "shasums-upload": "https://example.com/upload", + "shasums-sig-upload": "https://example.com/sig-upload", + }, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + mock_transport.request.return_value = mock_response + + options = RegistryProviderVersionCreateOptions( + version="1.0.0", **{"key-id": "test-key-id"}, protocols=["5.0"] + ) + + result = versions_service.create(valid_provider_id, options) + + mock_transport.request.assert_called_once_with( + "POST", + path="/api/v2/organizations/test-org/registry-providers/private/test-namespace/test-provider/versions", + json_body={ + "data": { + "type": "registry-provider-versions", + "attributes": { + "version": "1.0.0", + "key-id": "test-key-id", + "protocols": ["5.0"], + }, + } + }, + ) + + assert isinstance(result, RegistryProviderVersion) + assert result.id == "provver-123" + assert result.version == "1.0.0" + assert result.key_id == "test-key-id" + assert result.protocols == ["5.0"] + assert result.permissions.can_delete is True + + def test_list_versions_success_without_options( + self, versions_service, valid_provider_id, mock_transport + ): + """Test successful list operation without options.""" + mock_response_data = { + "data": [ + { + "id": "provver-123", + "type": "registry-provider-versions", + "attributes": { + "version": "1.0.0", + "created-at": "2023-01-01T12:00:00Z", + "updated-at": "2023-01-01T12:00:00Z", + "key-id": "test-key-id", + "protocols": ["5.0"], + "shasums-uploaded": False, + "shasums-sig-uploaded": False, + "permissions": { + "can-delete": True, + "can-upload-asset": True, + }, + }, + }, + { + "id": "provver-456", + "type": "registry-provider-versions", + "attributes": { + "version": "1.1.0", + "created-at": "2023-02-01T12:00:00Z", + "updated-at": "2023-02-01T12:00:00Z", + "key-id": "test-key-id-2", + "protocols": ["5.0", "6.0"], + "shasums-uploaded": True, + "shasums-sig-uploaded": True, + "permissions": { + "can-delete": True, + "can-upload-asset": False, + }, + }, + }, + ], + "meta": { + "pagination": { + "current-page": 1, + "total-pages": 1, + "prev-page": None, + "next-page": None, + "total-count": 2, + } + }, + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + mock_transport.request.return_value = mock_response + + with patch.object( + versions_service, "_list", return_value=mock_response_data["data"] + ): + result = list(versions_service.list(valid_provider_id)) + + assert len(result) == 2 + assert result[0].id == "provver-123" + assert result[0].version == "1.0.0" + assert result[0].shasums_uploaded is False + assert result[1].id == "provver-456" + assert result[1].version == "1.1.0" + assert result[1].shasums_uploaded is True + + def test_read_version_validations(self, versions_service): + """Test read method with invalid version ID.""" + invalid_version_id = RegistryProviderVersionID( + organization_name="", + registry_name=RegistryName.PRIVATE, + namespace="test-namespace", + name="test-provider", + version="1.0.0", + ) + + with pytest.raises(ValueError, match="Invalid provider ID"): + versions_service.read(invalid_version_id) + + def test_read_version_success( + self, versions_service, valid_version_id, mock_transport + ): + """Test successful read operation.""" + mock_response_data = { + "data": { + "id": "provver-789", + "type": "registry-provider-versions", + "attributes": { + "version": "1.0.0", + "created-at": "2023-01-01T12:00:00Z", + "updated-at": "2023-01-01T12:00:00Z", + "key-id": "test-key-id", + "protocols": ["5.0", "6.0"], + "shasums-uploaded": True, + "shasums-sig-uploaded": True, + "permissions": { + "can-delete": True, + "can-upload-asset": False, + }, + }, + "relationships": { + "registry-provider": { + "data": {"id": "prov-123", "type": "registry-providers"} + }, + "platforms": { + "data": [ + {"id": "plat-123", "type": "registry-provider-platforms"} + ] + }, + }, + "links": { + "shasums-download": "https://example.com/download", + "shasums-sig-download": "https://example.com/sig-download", + }, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + mock_transport.request.return_value = mock_response + + result = versions_service.read(valid_version_id) + + mock_transport.request.assert_called_once_with( + "GET", + path="/api/v2/organizations/test-org/registry-providers/private/test-namespace/test-provider/versions/1.0.0", + ) + + assert isinstance(result, RegistryProviderVersion) + assert result.id == "provver-789" + assert result.version == "1.0.0" + assert result.key_id == "test-key-id" + assert result.protocols == ["5.0", "6.0"] + assert result.shasums_uploaded is True + assert result.shasums_sig_uploaded is True + + def test_delete_version_success( + self, versions_service, valid_version_id, mock_transport + ): + """Test successful delete operation.""" + result = versions_service.delete(valid_version_id) + + mock_transport.request.assert_called_once_with( + "DELETE", + path="/api/v2/organizations/test-org/registry-providers/private/test-namespace/test-provider/versions/1.0.0", + ) + + assert result is None + + def test_registry_provider_version_from_success(self, versions_service): + """Test _registry_provider_version_from with valid data.""" + data = { + "id": "provver-123", + "type": "registry-provider-versions", + "attributes": { + "version": "1.0.0", + "created-at": "2023-01-01T12:00:00Z", + "updated-at": "2023-01-01T12:00:00Z", + "key-id": "test-key-id", + "protocols": ["5.0"], + "shasums-uploaded": False, + "shasums-sig-uploaded": False, + "permissions": { + "can-delete": True, + "can-upload-asset": True, + }, + }, + "relationships": { + "registry-provider": { + "data": {"id": "prov-123", "type": "registry-providers"} + }, + "platforms": { + "data": [ + {"id": "plat-123", "type": "registry-provider-platforms"}, + {"id": "plat-456", "type": "registry-provider-platforms"}, + ] + }, + }, + } + + result = versions_service._registry_provider_version_from(data) + + assert isinstance(result, RegistryProviderVersion) + assert result.id == "provver-123" + assert result.version == "1.0.0" + assert result.key_id == "test-key-id" + assert result.registry_provider == { + "id": "prov-123", + "type": "registry-providers", + } + assert result.registry_provider_platforms is not None + assert len(result.registry_provider_platforms) == 2 + + def test_create_options_validation_invalid_version(self): + """Test RegistryProviderVersionCreateOptions with invalid version.""" + with pytest.raises(InvalidVersionError): + RegistryProviderVersionCreateOptions( + version="", **{"key-id": "test-key-id"}, protocols=["5.0"] + ) + + def test_create_options_validation_invalid_key_id(self): + """Test RegistryProviderVersionCreateOptions with invalid key_id.""" + with pytest.raises(InvalidKeyIDError): + RegistryProviderVersionCreateOptions( + version="1.0.0", **{"key-id": ""}, protocols=["5.0"] + ) + + def test_create_options_validation_success(self): + """Test RegistryProviderVersionCreateOptions with valid data.""" + options = RegistryProviderVersionCreateOptions( + version="1.0.0", **{"key-id": "test-key-id"}, protocols=["5.0", "6.0"] + ) + assert options.version == "1.0.0" + assert options.key_id == "test-key-id" + assert options.protocols == ["5.0", "6.0"] + + def test_version_id_validation_success(self): + """Test RegistryProviderVersionID with valid data.""" + version_id = RegistryProviderVersionID( + organization_name="test-org", + registry_name=RegistryName.PRIVATE, + namespace="test-namespace", + name="test-provider", + version="1.0.0", + ) + assert version_id.organization_name == "test-org" + assert version_id.registry_name == RegistryName.PRIVATE + assert version_id.namespace == "test-namespace" + assert version_id.name == "test-provider" + assert version_id.version == "1.0.0" diff --git a/tests/units/test_reserved_tag_key.py b/tests/units/test_reserved_tag_key.py index 490a93a..d7ca66b 100644 --- a/tests/units/test_reserved_tag_key.py +++ b/tests/units/test_reserved_tag_key.py @@ -14,7 +14,7 @@ ReservedTagKeyListOptions, ReservedTagKeyUpdateOptions, ) -from pytfe.resources.reserved_tag_key import ReservedTagKey +from pytfe.resources.reserved_tag_key import ReservedTagKeys class TestReservedTagKeyParsing: @@ -24,7 +24,7 @@ class TestReservedTagKeyParsing: def reserved_tag_key_service(self): """Create a ReservedTagKey service for testing parsing.""" mock_transport = Mock(spec=HTTPTransport) - return ReservedTagKey(mock_transport) + return ReservedTagKeys(mock_transport) def test_parse_reserved_tag_key_minimal(self, reserved_tag_key_service): """Test _parse_reserved_tag_key with minimal data.""" @@ -68,12 +68,12 @@ class TestReservedTagKey: def reserved_tag_key_service(self): """Create a ReservedTagKey service for testing.""" mock_transport = Mock(spec=HTTPTransport) - return ReservedTagKey(mock_transport) + return ReservedTagKeys(mock_transport) def test_list_reserved_tag_keys_invalid_org(self, reserved_tag_key_service): """Test listing reserved tag keys with invalid organization.""" with pytest.raises(InvalidOrgError): - reserved_tag_key_service.list("") + list(reserved_tag_key_service.list("")) def test_create_reserved_tag_key_invalid_org(self, reserved_tag_key_service): """Test creating reserved tag key with invalid organization.""" @@ -83,11 +83,6 @@ def test_create_reserved_tag_key_invalid_org(self, reserved_tag_key_service): with pytest.raises(InvalidOrgError): reserved_tag_key_service.create("", options) - def test_read_reserved_tag_key_not_implemented(self, reserved_tag_key_service): - """Test reading reserved tag key raises NotImplementedError.""" - with pytest.raises(NotImplementedError): - reserved_tag_key_service.read("rtk-123") - def test_update_reserved_tag_key_invalid_id(self, reserved_tag_key_service): """Test updating reserved tag key with invalid ID.""" options = ReservedTagKeyUpdateOptions(key="updated-key") @@ -115,6 +110,5 @@ def test_reserved_tag_key_update_options_model(self): def test_reserved_tag_key_list_options_model(self): """Test ReservedTagKeyListOptions model validation.""" - options = ReservedTagKeyListOptions(page_number=2, page_size=50) - assert options.page_number == 2 + options = ReservedTagKeyListOptions(page_size=50) assert options.page_size == 50 diff --git a/tests/units/test_run_tasks_integration.py b/tests/units/test_run_tasks_integration.py new file mode 100644 index 0000000..d5ed1ff --- /dev/null +++ b/tests/units/test_run_tasks_integration.py @@ -0,0 +1,324 @@ +"""Unit tests for Run Tasks Integration.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from pytfe.errors import TFEError +from pytfe.models.run_task_request import RunTaskRequest +from pytfe.resources.run_tasks_integration import ( + RunTasksIntegration, + TaskResultCallbackOptions, + TaskResultOutcome, + TaskResultStatus, + TaskResultTag, +) + + +class TestRunTaskRequest: + """Tests for RunTaskRequest model.""" + + def test_run_task_request_minimal(self): + """Test parsing minimal run task request.""" + data = { + "access_token": "test-token-123", + "is_speculative": False, + "organization_name": "my-org", + "payload_version": 1, + "run_app_url": "https://app.terraform.io/app/my-org/my-workspace/runs/run-123", + "run_created_at": "2025-12-22T10:00:00Z", + "run_created_by": "user@example.com", + "run_id": "run-123", + "run_message": "Test run", + "stage": "post_plan", + "task_result_callback_url": "https://app.terraform.io/api/v2/task-results/tr-123/callback", + "task_result_enforcement_level": "mandatory", + "task_result_id": "tr-123", + "workspace_app_url": "https://app.terraform.io/app/my-org/my-workspace", + "workspace_id": "ws-123", + "workspace_name": "my-workspace", + } + + request = RunTaskRequest(**data) + + assert request.access_token == "test-token-123" + assert request.organization_name == "my-org" + assert request.run_id == "run-123" + assert request.stage == "post_plan" + assert ( + request.task_result_callback_url + == "https://app.terraform.io/api/v2/task-results/tr-123/callback" + ) + + def test_run_task_request_complete(self): + """Test parsing complete run task request with all fields.""" + data = { + "access_token": "test-token-456", + "capabilities": {"outcomes": True}, + "configuration_version_download_url": "https://app.terraform.io/api/v2/configuration-versions/cv-123/download", + "configuration_version_id": "cv-123", + "is_speculative": True, + "organization_name": "test-org", + "payload_version": 1, + "plan_json_api_url": "https://app.terraform.io/api/v2/plans/plan-123/json-output", + "run_app_url": "https://app.terraform.io/app/test-org/test-workspace/runs/run-456", + "run_created_at": "2025-12-22T11:30:00Z", + "run_created_by": "admin@example.com", + "run_id": "run-456", + "run_message": "Test with VCS", + "stage": "pre_plan", + "task_result_callback_url": "https://app.terraform.io/api/v2/task-results/tr-456/callback", + "task_result_enforcement_level": "advisory", + "task_result_id": "tr-456", + "vcs_branch": "main", + "vcs_commit_url": "https://github.com/org/repo/commit/abc123", + "vcs_pull_request_url": "https://github.com/org/repo/pull/42", + "vcs_repo_url": "https://github.com/org/repo", + "workspace_app_url": "https://app.terraform.io/app/test-org/test-workspace", + "workspace_id": "ws-456", + "workspace_name": "test-workspace", + "workspace_working_directory": "terraform/", + } + + request = RunTaskRequest(**data) + + assert request.access_token == "test-token-456" + assert request.capabilities is not None + assert request.capabilities.outcomes is True + assert request.configuration_version_id == "cv-123" + assert request.vcs_branch == "main" + assert request.vcs_commit_url == "https://github.com/org/repo/commit/abc123" + assert request.workspace_working_directory == "terraform/" + + +class TestTaskResultTag: + """Tests for TaskResultTag.""" + + def test_tag_with_level(self): + """Test tag with level.""" + tag = TaskResultTag(label="High", level="error") + data = tag.to_dict() + + assert data["label"] == "High" + assert data["level"] == "error" + + def test_tag_without_level(self): + """Test tag without level.""" + tag = TaskResultTag(label="Passed") + data = tag.to_dict() + + assert data["label"] == "Passed" + assert "level" not in data + + +class TestTaskResultOutcome: + """Tests for TaskResultOutcome.""" + + def test_outcome_complete(self): + """Test complete outcome with all fields.""" + tags = { + "Status": [TaskResultTag(label="Failed", level="error")], + "Severity": [TaskResultTag(label="High", level="error")], + } + + outcome = TaskResultOutcome( + outcome_id="ISSUE-123", + description="Security issue found", + body="# Details\n\nSecurity vulnerability detected.", + url="https://example.com/issues/123", + tags=tags, + ) + + data = outcome.to_dict() + + assert data["type"] == "task-result-outcomes" + assert data["attributes"]["outcome-id"] == "ISSUE-123" + assert data["attributes"]["description"] == "Security issue found" + assert ( + data["attributes"]["body"] + == "# Details\n\nSecurity vulnerability detected." + ) + assert data["attributes"]["url"] == "https://example.com/issues/123" + assert "Status" in data["attributes"]["tags"] + + def test_outcome_minimal(self): + """Test minimal outcome.""" + outcome = TaskResultOutcome() + data = outcome.to_dict() + + assert data["type"] == "task-result-outcomes" + assert "attributes" in data + + +class TestTaskResultCallbackOptions: + """Tests for TaskResultCallbackOptions.""" + + def test_callback_options_passed(self): + """Test callback options with passed status.""" + options = TaskResultCallbackOptions( + status=TaskResultStatus.PASSED, + message="All checks passed", + url="https://example.com/results/123", + ) + + options.validate() + data = options.to_dict() + + assert data["data"]["type"] == "task-results" + assert data["data"]["attributes"]["status"] == "passed" + assert data["data"]["attributes"]["message"] == "All checks passed" + assert data["data"]["attributes"]["url"] == "https://example.com/results/123" + + def test_callback_options_with_outcomes(self): + """Test callback options with outcomes.""" + outcome = TaskResultOutcome( + outcome_id="ISSUE-1", + description="Test issue", + ) + + options = TaskResultCallbackOptions( + status=TaskResultStatus.FAILED, + message="1 issue found", + outcomes=[outcome], + ) + + data = options.to_dict() + + assert "relationships" in data["data"] + assert "outcomes" in data["data"]["relationships"] + assert len(data["data"]["relationships"]["outcomes"]["data"]) == 1 + + def test_validate_invalid_status(self): + """Test validation fails with invalid status.""" + options = TaskResultCallbackOptions(status="invalid") + + with pytest.raises(TFEError) as exc_info: + options.validate() + + assert "Invalid task result status" in str(exc_info.value) + + def test_validate_valid_statuses(self): + """Test validation passes with all valid statuses.""" + for status in [ + TaskResultStatus.PASSED, + TaskResultStatus.FAILED, + TaskResultStatus.RUNNING, + ]: + options = TaskResultCallbackOptions(status=status) + options.validate() # Should not raise + + +class TestRunTasksIntegration: + """Tests for RunTasksIntegration service.""" + + def test_callback_success(self): + """Test successful callback.""" + mock_transport = MagicMock() + integration = RunTasksIntegration(mock_transport) + + options = TaskResultCallbackOptions( + status=TaskResultStatus.PASSED, + message="All tests passed", + ) + + integration.callback( + callback_url="https://app.terraform.io/api/v2/task-results/tr-123/callback", + access_token="test-token-123", + options=options, + ) + + # Verify request was made + mock_transport.request.assert_called_once() + call_args = mock_transport.request.call_args + + assert call_args[0][0] == "PATCH" + assert ( + call_args[0][1] + == "https://app.terraform.io/api/v2/task-results/tr-123/callback" + ) + assert "Authorization" in call_args[1]["headers"] + assert call_args[1]["headers"]["Authorization"] == "Bearer test-token-123" + + def test_callback_empty_url(self): + """Test callback fails with empty URL.""" + mock_transport = MagicMock() + integration = RunTasksIntegration(mock_transport) + + options = TaskResultCallbackOptions(status=TaskResultStatus.PASSED) + + with pytest.raises(TFEError) as exc_info: + integration.callback( + callback_url="", + access_token="test-token", + options=options, + ) + + assert "callback_url cannot be empty" in str(exc_info.value) + + def test_callback_empty_token(self): + """Test callback fails with empty token.""" + mock_transport = MagicMock() + integration = RunTasksIntegration(mock_transport) + + options = TaskResultCallbackOptions(status=TaskResultStatus.PASSED) + + with pytest.raises(TFEError) as exc_info: + integration.callback( + callback_url="https://example.com/callback", + access_token="", + options=options, + ) + + assert "access_token cannot be empty" in str(exc_info.value) + + def test_callback_invalid_status(self): + """Test callback fails with invalid status.""" + mock_transport = MagicMock() + integration = RunTasksIntegration(mock_transport) + + options = TaskResultCallbackOptions(status="invalid-status") + + with pytest.raises(TFEError) as exc_info: + integration.callback( + callback_url="https://example.com/callback", + access_token="test-token", + options=options, + ) + + assert "Invalid task result status" in str(exc_info.value) + + def test_callback_with_outcomes(self): + """Test callback with detailed outcomes.""" + mock_transport = MagicMock() + integration = RunTasksIntegration(mock_transport) + + outcome = TaskResultOutcome( + outcome_id="CHECK-1", + description="Policy violation", + body="## Issue\n\nPolicy check failed.", + url="https://example.com/check-1", + tags={ + "Severity": [TaskResultTag(label="High", level="error")], + }, + ) + + options = TaskResultCallbackOptions( + status=TaskResultStatus.FAILED, + message="Policy check failed", + url="https://example.com/results", + outcomes=[outcome], + ) + + integration.callback( + callback_url="https://app.terraform.io/api/v2/task-results/tr-123/callback", + access_token="test-token-123", + options=options, + ) + + call_args = mock_transport.request.call_args + body = call_args[1]["json_body"] + + assert "relationships" in body["data"] + assert "outcomes" in body["data"]["relationships"] diff --git a/tests/units/test_workspace_resources.py b/tests/units/test_workspace_resources.py new file mode 100644 index 0000000..f685d84 --- /dev/null +++ b/tests/units/test_workspace_resources.py @@ -0,0 +1,278 @@ +"""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_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