Skip to content

Commit 9817fc4

Browse files
authored
#130 Standardize Notification Configuration option models on Pydantic (#132)
* update models for notification config * Fix lints and update release number * update example
1 parent 9d6c3ae commit 9817fc4

5 files changed

Lines changed: 112 additions & 131 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Unreleased
22

3+
# Released
4+
5+
# v0.1.4
6+
7+
## Enhancements
8+
* Standardize Notification Configuration option models on Pydantic [#132](https://github.com/hashicorp/python-tfe/pull/132)
9+
310
# v0.1.3
411

512
## Enhancements

examples/notification_configuration.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,9 @@
1010
"""
1111

1212
import os
13-
import sys
14-
15-
# Add the src directory to the Python path so we can import the tfe module
16-
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
1713

1814
from pytfe.client import TFEClient
19-
from pytfe.models.notification_configuration import (
15+
from pytfe.models import (
2016
NotificationConfigurationCreateOptions,
2117
NotificationConfigurationListOptions,
2218
NotificationConfigurationSubscribableChoice,

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "pytfe"
7-
version = "0.1.3"
7+
version = "0.1.4"
88
description = "Official Python SDK for HashiCorp Terraform Cloud / Terraform Enterprise (TFE) API v2"
99
readme = "README.md"
1010
license = { text = "MPL-2.0" }

src/pytfe/models/__init__.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,19 @@
5959
DataRetentionPolicySetOptions,
6060
)
6161

62+
# ── Notification Configurations ───────────────────────────────────────────────
63+
from .notification_configuration import (
64+
DeliveryResponse,
65+
NotificationConfiguration,
66+
NotificationConfigurationCreateOptions,
67+
NotificationConfigurationList,
68+
NotificationConfigurationListOptions,
69+
NotificationConfigurationSubscribableChoice,
70+
NotificationConfigurationUpdateOptions,
71+
NotificationDestinationType,
72+
NotificationTriggerType,
73+
)
74+
6275
# ── OAuth ─────────────────────────────────────────────────────────────────────
6376
from .oauth_client import (
6477
OAuthClient,
@@ -376,6 +389,16 @@
376389

377390
# ── Public surface ────────────────────────────────────────────────────────────
378391
__all__ = [
392+
# Notification configurations
393+
"DeliveryResponse",
394+
"NotificationConfiguration",
395+
"NotificationConfigurationCreateOptions",
396+
"NotificationConfigurationList",
397+
"NotificationConfigurationListOptions",
398+
"NotificationConfigurationSubscribableChoice",
399+
"NotificationConfigurationUpdateOptions",
400+
"NotificationDestinationType",
401+
"NotificationTriggerType",
379402
# OAuth
380403
"OAuthClient",
381404
"OAuthClientAddProjectsOptions",

src/pytfe/models/notification_configuration.py

Lines changed: 80 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
from enum import Enum
1414
from typing import Any
1515

16+
from pydantic import BaseModel, ConfigDict, Field
17+
1618

1719
class NotificationTriggerType(Enum):
1820
"""Represents the different TFE notifications that can be sent as a run's progress transitions between different states."""
@@ -187,69 +189,57 @@ def __repr__(self) -> str:
187189
return f"NotificationConfiguration(id='{self.id}', name='{self.name}', enabled={self.enabled})"
188190

189191

190-
class NotificationConfigurationListOptions:
192+
def _serialize_triggers(
193+
triggers: list[NotificationTriggerType | str],
194+
) -> list[str]:
195+
"""Serialize trigger enums or raw strings to their wire value."""
196+
return [t.value if isinstance(t, NotificationTriggerType) else t for t in triggers]
197+
198+
199+
def _validate_triggers(
200+
triggers: list[NotificationTriggerType | str],
201+
) -> list[str]:
202+
"""Collect errors for any non-enum, non-known-string trigger entries."""
203+
errors: list[str] = []
204+
for trigger in triggers:
205+
if isinstance(trigger, NotificationTriggerType):
206+
continue
207+
try:
208+
NotificationTriggerType(trigger)
209+
except ValueError:
210+
errors.append(f"Invalid trigger type: {trigger}")
211+
return errors
212+
213+
214+
class NotificationConfigurationListOptions(BaseModel):
191215
"""Represents the options for listing notification configurations."""
192216

193-
# Type annotations for instance attributes
194-
page_size: int | None
195-
subscribable_choice: NotificationConfigurationSubscribableChoice | None
217+
model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True)
196218

197-
def __init__(
198-
self,
199-
page_size: int | None = None,
200-
subscribable_choice: NotificationConfigurationSubscribableChoice | None = None,
201-
):
202-
self.page_size = page_size
203-
self.subscribable_choice = subscribable_choice
219+
page_size: int | None = Field(default=None, alias="page[size]")
220+
subscribable_choice: NotificationConfigurationSubscribableChoice | None = Field(
221+
default=None, exclude=True
222+
)
204223

205224
def to_dict(self) -> dict[str, Any]:
206225
"""Convert to dictionary for API requests."""
207-
params = {}
208-
209-
if self.page_size is not None:
210-
params["page[size]"] = self.page_size
211-
212-
return params
226+
return self.model_dump(by_alias=True, exclude_none=True)
213227

214228

215-
class NotificationConfigurationCreateOptions:
229+
class NotificationConfigurationCreateOptions(BaseModel):
216230
"""Represents the options for creating a new notification configuration."""
217231

218-
# Type annotations for instance attributes
232+
model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True)
233+
219234
destination_type: NotificationDestinationType
220235
enabled: bool
221236
name: str
222-
token: str | None
223-
triggers: list[NotificationTriggerType]
224-
url: str | None
225-
email_addresses: list[str]
226-
email_users: list[Any]
227-
subscribable_choice: NotificationConfigurationSubscribableChoice | None
228-
229-
def __init__(
230-
self,
231-
destination_type: NotificationDestinationType,
232-
enabled: bool,
233-
name: str,
234-
token: str | None = None,
235-
triggers: list[NotificationTriggerType] | None = None,
236-
url: str | None = None,
237-
email_addresses: list[str] | None = None,
238-
email_users: list[Any] | None = None,
239-
subscribable_choice: NotificationConfigurationSubscribableChoice | None = None,
240-
):
241-
# Required fields
242-
self.destination_type = destination_type
243-
self.enabled = enabled
244-
self.name = name
245-
246-
# Optional fields
247-
self.token = token
248-
self.triggers = triggers or []
249-
self.url = url
250-
self.email_addresses = email_addresses or []
251-
self.email_users = email_users or []
252-
self.subscribable_choice = subscribable_choice
237+
token: str | None = None
238+
triggers: list[NotificationTriggerType | str] = Field(default_factory=list)
239+
url: str | None = None
240+
email_addresses: list[str] = Field(default_factory=list)
241+
email_users: list[Any] = Field(default_factory=list)
242+
subscribable_choice: NotificationConfigurationSubscribableChoice | None = None
253243

254244
def to_dict(self) -> dict[str, Any]:
255245
"""Convert to dictionary for API requests."""
@@ -262,99 +252,70 @@ def to_dict(self) -> dict[str, Any]:
262252
},
263253
}
264254

265-
# Add optional attributes
266255
if self.token is not None:
267256
data["attributes"]["token"] = self.token
268257

269258
if self.triggers:
270-
data["attributes"]["triggers"] = [
271-
trigger.value for trigger in self.triggers
272-
]
259+
data["attributes"]["triggers"] = _serialize_triggers(self.triggers)
273260

274261
if self.url is not None:
275262
data["attributes"]["url"] = self.url
276263

277264
if self.email_addresses:
278265
data["attributes"]["email-addresses"] = self.email_addresses
279266

280-
# Handle relationships
281267
if self.email_users:
282-
data["relationships"] = data.get("relationships", {})
283-
data["relationships"]["users"] = {
284-
"data": [
285-
{
286-
"type": "users",
287-
"id": user.id if hasattr(user, "id") else str(user),
288-
}
289-
for user in self.email_users
290-
]
268+
data["relationships"] = {
269+
"users": {
270+
"data": [
271+
{
272+
"type": "users",
273+
"id": user.id if hasattr(user, "id") else str(user),
274+
}
275+
for user in self.email_users
276+
]
277+
}
291278
}
292279

293280
return data
294281

295-
def validate(self) -> list[str]:
282+
def validate(self) -> list[str]: # type: ignore[override]
296283
"""Validate the create options and return any errors."""
297-
errors = []
284+
errors: list[str] = []
298285

299-
# Required field validation
300286
if not self.name or not self.name.strip():
301287
errors.append("Name is required")
302288

303-
if not isinstance(self.enabled, bool):
304-
errors.append("Enabled must be a boolean") # type: ignore[unreachable]
305-
306-
# URL validation for certain destination types
307-
if self.destination_type in [
289+
if self.destination_type in (
308290
NotificationDestinationType.GENERIC,
309291
NotificationDestinationType.SLACK,
310292
NotificationDestinationType.MICROSOFT_TEAMS,
311-
]:
293+
):
312294
if not self.url:
313295
errors.append("URL is required for this destination type")
314296

315-
# Trigger validation
316-
for trigger in self.triggers:
317-
if not isinstance(trigger, NotificationTriggerType):
318-
errors.append(f"Invalid trigger type: {trigger}") # type: ignore[unreachable]
297+
errors.extend(_validate_triggers(self.triggers))
319298

320299
return errors
321300

322301

323-
class NotificationConfigurationUpdateOptions:
302+
class NotificationConfigurationUpdateOptions(BaseModel):
324303
"""Represents the options for updating an existing notification configuration."""
325304

326-
# Type annotations for instance attributes
327-
enabled: bool | None
328-
name: str | None
329-
token: str | None
330-
triggers: list[NotificationTriggerType] | None
331-
url: str | None
332-
email_addresses: list[str] | None
333-
email_users: list[Any] | None
334-
335-
def __init__(
336-
self,
337-
enabled: bool | None = None,
338-
name: str | None = None,
339-
token: str | None = None,
340-
triggers: list[NotificationTriggerType] | None = None,
341-
url: str | None = None,
342-
email_addresses: list[str] | None = None,
343-
email_users: list[Any] | None = None,
344-
):
345-
self.enabled = enabled
346-
self.name = name
347-
self.token = token
348-
self.triggers = triggers
349-
self.url = url
350-
self.email_addresses = email_addresses
351-
self.email_users = email_users
305+
model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True)
306+
307+
enabled: bool | None = None
308+
name: str | None = None
309+
token: str | None = None
310+
triggers: list[NotificationTriggerType | str] | None = None
311+
url: str | None = None
312+
email_addresses: list[str] | None = None
313+
email_users: list[Any] | None = None
352314

353315
def to_dict(self) -> dict[str, Any]:
354316
"""Convert to dictionary for API requests."""
355317
data: dict[str, Any] = {"type": "notification-configurations", "attributes": {}}
356318

357-
# Add only specified attributes
358319
if self.enabled is not None:
359320
data["attributes"]["enabled"] = self.enabled
360321

@@ -365,44 +326,38 @@ def to_dict(self) -> dict[str, Any]:
365326
data["attributes"]["token"] = self.token
366327

367328
if self.triggers is not None:
368-
data["attributes"]["triggers"] = [
369-
trigger.value for trigger in self.triggers
370-
]
329+
data["attributes"]["triggers"] = _serialize_triggers(self.triggers)
371330

372331
if self.url is not None:
373332
data["attributes"]["url"] = self.url
374333

375334
if self.email_addresses is not None:
376335
data["attributes"]["email-addresses"] = self.email_addresses
377336

378-
# Handle relationships
379337
if self.email_users is not None:
380-
data["relationships"] = data.get("relationships", {})
381-
data["relationships"]["users"] = {
382-
"data": [
383-
{
384-
"type": "users",
385-
"id": user.id if hasattr(user, "id") else str(user),
386-
}
387-
for user in self.email_users
388-
]
338+
data["relationships"] = {
339+
"users": {
340+
"data": [
341+
{
342+
"type": "users",
343+
"id": user.id if hasattr(user, "id") else str(user),
344+
}
345+
for user in self.email_users
346+
]
347+
}
389348
}
390349

391350
return data
392351

393-
def validate(self) -> list[str]:
352+
def validate(self) -> list[str]: # type: ignore[override]
394353
"""Validate the update options and return any errors."""
395-
errors = []
354+
errors: list[str] = []
396355

397-
# Name validation (if provided)
398356
if self.name is not None and (not self.name or not self.name.strip()):
399357
errors.append("Name cannot be empty")
400358

401-
# Trigger validation (if provided)
402359
if self.triggers is not None:
403-
for trigger in self.triggers:
404-
if not isinstance(trigger, NotificationTriggerType):
405-
errors.append(f"Invalid trigger type: {trigger}") # type: ignore[unreachable]
360+
errors.extend(_validate_triggers(self.triggers))
406361

407362
return errors
408363

0 commit comments

Comments
 (0)