Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
# Unreleased

# Released
# v0.1.5

* `pytfe.__version__` added in src/pytfe/init.py via importlib.metadata.version("pytfe"). This will resolve to the version from pyproject.toml.
* Updated comments, sshkey, stateversion and cost-estimate models to have id as mandatory attribute by @isivaselvan [#137](https://github.com/hashicorp/python-tfe/pull/137)
* Updated workspace resource to include additional relationship models include AgentPool, Configuration-version, Run, Variables and State-version by @isivaselvan [#138](https://github.com/hashicorp/python-tfe/pull/138)

## Bug Fixes
* Run.read / Run.create fail with pydantic ValidationError when response has a `cost-estimate` and `comments` relationship.

# v0.1.4

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "pytfe"
version = "0.1.4"
version = "0.1.5"
description = "Official Python SDK for HashiCorp Terraform Cloud / Terraform Enterprise (TFE) API v2"
readme = "README.md"
license = { text = "MPL-2.0" }
Expand Down
10 changes: 9 additions & 1 deletion src/pytfe/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: MPL-2.0

from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _pkg_version

from . import errors, models
from .client import TFEClient
from .config import TFEConfig

__all__ = ["TFEConfig", "TFEClient", "errors", "models"]
try:
__version__ = _pkg_version("pytfe")
except PackageNotFoundError: # running from a source checkout without install
__version__ = "0.0.0+unknown"

__all__ = ["TFEConfig", "TFEClient", "errors", "models", "__version__"]
2 changes: 1 addition & 1 deletion src/pytfe/models/comment.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ class Comment(BaseModel):
model_config = ConfigDict(populate_by_name=True, validate_by_name=True)

id: str
body: str = Field(..., alias="body")
body: str = Field(default="", alias="body")
20 changes: 10 additions & 10 deletions src/pytfe/models/cost_estimate.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,17 @@ class CostEstimate(BaseModel):
model_config = ConfigDict(populate_by_name=True, validate_by_name=True)

id: str
delta_monthly_cost: str = Field(..., alias="delta-monthly-cost")
error_message: str = Field(..., alias="error-message")
matched_resources_count: int = Field(..., alias="matched-resources-count")
prior_monthly_cost: str = Field(..., alias="prior-monthly-cost")
proposed_monthly_cost: str = Field(..., alias="proposed-monthly-cost")
resources_count: int = Field(..., alias="resources-count")
status: CostEstimateStatus = Field(..., alias="status")
status_timestamps: CostEstimateStatusTimestamps = Field(
..., alias="status-timestamps"
delta_monthly_cost: str = Field(default="", alias="delta-monthly-cost")
error_message: str = Field(default="", alias="error-message")
matched_resources_count: int = Field(default=0, alias="matched-resources-count")
prior_monthly_cost: str = Field(default="", alias="prior-monthly-cost")
proposed_monthly_cost: str = Field(default="", alias="proposed-monthly-cost")
resources_count: int = Field(default=0, alias="resources-count")
status: CostEstimateStatus | None = Field(default=None, alias="status")
status_timestamps: CostEstimateStatusTimestamps | None = Field(
default=None, alias="status-timestamps"
)
unmatched_resources_count: int = Field(..., alias="unmatched-resources-count")
unmatched_resources_count: int = Field(default=0, alias="unmatched-resources-count")


class CostEstimateStatus(str, Enum):
Expand Down
255 changes: 101 additions & 154 deletions src/pytfe/models/notification_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@

from __future__ import annotations

from collections.abc import Iterator
from datetime import datetime
from enum import Enum
from typing import Any

from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator


class NotificationTriggerType(Enum):
Expand Down Expand Up @@ -49,144 +50,90 @@ class NotificationDestinationType(Enum):
MICROSOFT_TEAMS = "microsoft-teams"


class DeliveryResponse:
class DeliveryResponse(BaseModel):
"""Represents a notification configuration delivery response."""

# Type annotations for instance attributes
body: str
code: str
headers: dict[str, Any]
sent_at: datetime | None
successful: str
url: str

def __init__(self, data: dict[str, Any]):
self.body = data.get("body", "")
self.code = data.get("code", "")
self.headers = data.get("headers", {})
self.sent_at = self._parse_datetime(data.get("sent-at"))
self.successful = data.get("successful", "")
self.url = data.get("url", "")

def _parse_datetime(self, date_str: str | None) -> datetime | None:
"""Parse ISO 8601 datetime string."""
if not date_str:
return None
try:
return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
except (ValueError, AttributeError):
return None
model_config = ConfigDict(populate_by_name=True)

def __repr__(self) -> str:
return f"DeliveryResponse(url='{self.url}', code='{self.code}', successful='{self.successful}')"
body: str | None = None
code: str | None = None
headers: dict[str, Any] | None = Field(default_factory=dict)
sent_at: datetime | None = Field(default=None, alias="sent-at")
successful: str | None = None
url: str | None = None

def __init__(self, data: dict[str, Any] | None = None, /, **kwargs: Any) -> None:
if data is not None:
super().__init__(**{**data, **kwargs})
else:
super().__init__(**kwargs)

class NotificationConfigurationSubscribableChoice:
"""Choice type struct that represents the possible values within a polymorphic relation."""

# Type annotations for instance attributes
team: Any | None
workspace: Any | None
class NotificationConfigurationSubscribableChoice(BaseModel):
"""Choice type struct that represents the possible values within a polymorphic relation."""

def __init__(self, team: Any | None = None, workspace: Any | None = None):
self.team = team
self.workspace = workspace
model_config = ConfigDict(arbitrary_types_allowed=True)

def __repr__(self) -> str:
if self.team:
return f"NotificationConfigurationSubscribableChoice(team={self.team})"
elif self.workspace:
return f"NotificationConfigurationSubscribableChoice(workspace={self.workspace})"
return "NotificationConfigurationSubscribableChoice()"
team: Any | None = None
workspace: Any | None = None


class NotificationConfiguration:
class NotificationConfiguration(BaseModel):
"""Represents a Notification Configuration."""

# Type annotations for instance attributes
id: str | None
created_at: datetime | None
updated_at: datetime | None
destination_type: str | None
enabled: bool
name: str
token: str
url: str
triggers: list[NotificationTriggerType]
delivery_responses: list[Any]
email_addresses: list[str]
email_users: list[Any]
subscribable: Any
subscribable_choice: Any | None

def __init__(self, data: dict[str, Any]):
self.id = data.get("id")
self.created_at = self._parse_datetime(data.get("created-at"))
self.updated_at = self._parse_datetime(data.get("updated-at"))

# Core attributes
self.destination_type = data.get("destination-type")
self.enabled = data.get("enabled", False)
self.name = data.get("name", "")
self.token = data.get("token", "")
self.url = data.get("url", "")

# Triggers - convert from strings to enum values
self.triggers = self._parse_triggers(data.get("triggers", []))

# Delivery responses
delivery_responses_data = data.get("delivery-responses", [])
self.delivery_responses = [
DeliveryResponse(dr) for dr in delivery_responses_data
]

# Email configuration
self.email_addresses = data.get("email-addresses", [])
self.email_users = data.get("email-users", [])

# Relationships - using polymorphic relation pattern
self.subscribable = data.get(
"subscribable"
) # Deprecated but maintained for compatibility
self.subscribable_choice = self._parse_subscribable_choice(
data.get("subscribable-choice")
)

def _parse_datetime(self, date_str: str | None) -> datetime | None:
"""Parse ISO 8601 datetime string."""
if not date_str:
return None
try:
return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
except (ValueError, AttributeError):
return None

def _parse_triggers(self, triggers: list[str]) -> list[NotificationTriggerType]:
"""Parse trigger strings to enum values."""
parsed_triggers = []
for trigger in triggers:
try:
parsed_triggers.append(NotificationTriggerType(trigger))
except ValueError:
# If trigger is not in enum, keep as string for backwards compatibility
pass
return parsed_triggers
model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True)

def _parse_subscribable_choice(
self, choice_data: dict[str, Any] | None
) -> NotificationConfigurationSubscribableChoice | None:
"""Parse subscribable choice data."""
if not choice_data:
return None
id: str | None = None
created_at: datetime | None = Field(default=None, alias="created-at")
updated_at: datetime | None = Field(default=None, alias="updated-at")
destination_type: str | None = Field(default=None, alias="destination-type")
enabled: bool = False
name: str | None = None
token: str | None = None
url: str | None = None
triggers: list[NotificationTriggerType] = Field(default_factory=list)
delivery_responses: list[DeliveryResponse] = Field(
default_factory=list, alias="delivery-responses"
)
email_addresses: list[str] = Field(default_factory=list, alias="email-addresses")
email_users: list[Any] = Field(default_factory=list, alias="email-users")
subscribable: Any = None
subscribable_choice: NotificationConfigurationSubscribableChoice | None = Field(
default=None, alias="subscribable-choice"
)

team = choice_data.get("team")
workspace = choice_data.get("workspace")
return NotificationConfigurationSubscribableChoice(
team=team, workspace=workspace
)
@field_validator(
"delivery_responses",
"email_addresses",
"email_users",
mode="before",
)
@classmethod
def _none_to_empty_list(cls, value: Any) -> Any:
return [] if value is None else value

@field_validator("triggers", mode="before")
@classmethod
def _coerce_triggers(cls, value: Any) -> list[NotificationTriggerType]:
if not value:
return []
parsed: list[NotificationTriggerType] = []
for trigger in value:
if isinstance(trigger, NotificationTriggerType):
parsed.append(trigger)
continue
try:
parsed.append(NotificationTriggerType(trigger))
except (ValueError, TypeError):
# Silently drop unknown triggers for backwards compatibility
pass
return parsed

def __repr__(self) -> str:
return f"NotificationConfiguration(id='{self.id}', name='{self.name}', enabled={self.enabled})"
def __init__(self, data: dict[str, Any] | None = None, /, **kwargs: Any) -> None:
if data is not None:
super().__init__(**{**data, **kwargs})
else:
super().__init__(**kwargs)


def _serialize_triggers(
Expand Down Expand Up @@ -362,43 +309,43 @@ def validate(self) -> list[str]: # type: ignore[override]
return errors


class NotificationConfigurationList:
class NotificationConfigurationList(BaseModel):
"""Represents a list of notification configurations with pagination."""

# Type annotations for instance attributes
items: list[NotificationConfiguration]
current_page: int
page_size: int
prev_page: int | None
next_page: int | None
total_pages: int
total_count: int

def __init__(self, data: dict[str, Any]):
self.items = [
NotificationConfiguration(item.get("attributes", {}))
for item in data.get("data", [])
]

# Pagination metadata
meta = data.get("meta", {})
pagination = meta.get("pagination", {})

self.current_page = pagination.get("current-page", 0)
self.page_size = pagination.get("page-size", 20)
self.prev_page = pagination.get("prev-page")
self.next_page = pagination.get("next-page")
self.total_pages = pagination.get("total-pages", 0)
self.total_count = pagination.get("total-count", 0)
model_config = ConfigDict(populate_by_name=True)

items: list[NotificationConfiguration] = Field(default_factory=list)
current_page: int = 0
page_size: int = 20
prev_page: int | None = None
next_page: int | None = None
total_pages: int = 0
total_count: int = 0

def __init__(self, data: dict[str, Any] | None = None, /, **kwargs: Any) -> None:
if data is None:
super().__init__(**kwargs)
return

items_data = [item.get("attributes", {}) for item in data.get("data") or []]
pagination = (data.get("meta") or {}).get("pagination") or {}
parsed: dict[str, Any] = {
"items": items_data,
"current_page": pagination.get("current-page", 0),
"page_size": pagination.get("page-size", 20),
"prev_page": pagination.get("prev-page"),
"next_page": pagination.get("next-page"),
"total_pages": pagination.get("total-pages", 0),
"total_count": pagination.get("total-count", 0),
}
parsed.update(kwargs)
super().__init__(**parsed)

def __len__(self) -> int:
return len(self.items)

def __iter__(self) -> Any:
def __iter__(self) -> Iterator[NotificationConfiguration]: # type: ignore[override]
return iter(self.items)

def __getitem__(self, index: int) -> NotificationConfiguration:
return self.items[index]

def __repr__(self) -> str:
return f"NotificationConfigurationList(count={len(self.items)}, page={self.current_page}, total={self.total_count})"
2 changes: 1 addition & 1 deletion src/pytfe/models/ssh_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class SSHKey(BaseModel):

id: str = Field(..., description="The unique identifier for this SSH key")
type: str = Field(default="ssh-keys", description="The type of this resource")
name: str = Field(..., description="A name to identify the SSH key")
name: str = Field(default="", description="A name to identify the SSH key")


class SSHKeyCreateOptions(BaseModel):
Expand Down
2 changes: 1 addition & 1 deletion src/pytfe/models/state_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class StateVersion(BaseModel):
model_config = ConfigDict(populate_by_name=True, validate_by_name=True)

id: str = Field(..., alias="id")
created_at: datetime = Field(..., alias="created-at")
created_at: datetime | None = Field(None, alias="created-at")
hosted_state_download_url: str | None = Field(
None, alias="hosted-state-download-url"
)
Expand Down
Loading
Loading