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.
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.
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.
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:
- Install development dependencies:
make dev-install - Format your code:
make fmt - Run lint checks:
make lint
We use ruff for both formatting and linting, and mypy for type checking.
The test suite contains unit tests with mocked API responses. You can read more about running the tests in TESTS.md. Our CI system (GitHub Actions) will not test your fork until a one-time approval takes place.
To run tests:
make test- A resource class should cover one RESTful resource, which sometimes involves two or more endpoints.
- Each resource class must be registered in the
TFEClientclass inclient.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:
ListOptionsandReadOptionsare values passed as query parameters.CreateOptionsandUpdateOptionsrepresent 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:
"""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"
)"""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)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)from .resources.example import Examples
class TFEClient:
def __init__(self, config: TFEConfig | None = None):
# ... existing code ...
self.examples = Examples(self._transport)from .example import (
Example,
ExampleCreateOptions,
ExampleList,
ExampleListOptions,
ExampleStatus,
ExampleUpdateOptions,
)
__all__ = [
# ... existing exports ...
"Example",
"ExampleCreateOptions",
"ExampleListOptions",
"ExampleStatus",
"ExampleUpdateOptions",
]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("")#!/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()- Models: Use Pydantic with
Fieldfor validation and JSON:API alias mapping - Resources: Inherit from
_Service, useself.t.request()for HTTP calls - Validation: Use
valid_string_id()utility and raise appropriate errors - Iterator Pattern: For list operations, use
self._list()for auto-pagination - JSON:API Format: Request/response bodies use
{"data": {"type": "...", "attributes": {...}}} - Tests: Mock
client._transport.request, test all methods and error conditions - Documentation: Add docstrings with Args/Returns/Yields sections
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:
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:
@pytest.mark.skip(reason="Beta feature - skip until GA")
def test_beta_feature(self, client):
"""Test beta feature."""
# test logic hereNote: After your PR has been merged, and the feature either reaches general availability, you should remove the skip decorator.
- Follow PEP 8 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
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
Feel free to open an issue for questions about contributing, or reach out to the maintainers for guidance on larger changes.