From 89a77050ba296ddf139af5a8318637e880ac2175 Mon Sep 17 00:00:00 2001 From: aayushsingh2502 Date: Sun, 4 Jan 2026 12:08:57 +0530 Subject: [PATCH 1/2] contributing doc, test.md and changelog added --- CHANGELOG.md | 142 +++++++++++ docs/CONTRIBUTING.md | 583 ++++++++++++++++++++++++++++++++++++++++++- docs/TESTS.md | 478 ++++++++++++++++++++++++++++++++++- 3 files changed, 1201 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..77af2dd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,142 @@ +# Unreleased + +# 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/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 36ac46a..da977f1 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1 +1,582 @@ -# 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 adding a new endpoint, make sure to update the API coverage list where we keep track of the HCP Terraform APIs that this SDK supports. + +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. +* 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 ExampleList(BaseModel): + """Represents a paginated list of examples.""" + + model_config = ConfigDict(populate_by_name=True) + + items: list[Example] = Field(default_factory=list, description="List of examples") + current_page: int | None = Field(None, description="Current page number") + total_pages: int | None = Field(None, description="Total number of pages") + prev_page: int | str | None = Field(None, description="Previous page number") + next_page: int | str | None = Field(None, description="Next page number") + total_count: int | None = Field(None, description="Total number of items") + + +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", + "ExampleList", + "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 +- [ ] API coverage list is updated (if adding endpoints) +- [ ] 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/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/) From d5b4c2ce5188318ee96a166f7721e12152626086 Mon Sep 17 00:00:00 2001 From: aayushsingh2502 Date: Tue, 6 Jan 2026 11:33:15 +0530 Subject: [PATCH 2/2] contributing doc fix --- docs/CONTRIBUTING.md | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index da977f1..02cca97 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -93,19 +93,6 @@ class Example(BaseModel): ) -class ExampleList(BaseModel): - """Represents a paginated list of examples.""" - - model_config = ConfigDict(populate_by_name=True) - - items: list[Example] = Field(default_factory=list, description="List of examples") - current_page: int | None = Field(None, description="Current page number") - total_pages: int | None = Field(None, description="Total number of pages") - prev_page: int | str | None = Field(None, description="Previous page number") - next_page: int | str | None = Field(None, description="Next page number") - total_count: int | None = Field(None, description="Total number of items") - - class ExampleListOptions(BaseModel): """Options for listing examples.""" @@ -333,7 +320,6 @@ __all__ = [ # ... existing exports ... "Example", "ExampleCreateOptions", - "ExampleList", "ExampleListOptions", "ExampleStatus", "ExampleUpdateOptions",