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
72 changes: 72 additions & 0 deletions examples/comment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: MPL-2.0

from __future__ import annotations

import argparse
import os

from pytfe import TFEClient, TFEConfig
from pytfe.models import CommentCreateOptions


def _print_header(title: str):
print("\n" + "=" * 80)
print(title)
print("=" * 80)


def main():
parser = argparse.ArgumentParser(description="Comments 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("--run-id", required=True, help="Run ID (e.g. run-xxxxx)")
parser.add_argument("--create", action="store_true", help="Create a new comment")
parser.add_argument("--body", help="Comment body text (required with --create)")
parser.add_argument("--read", action="store_true", help="Read a specific comment")
parser.add_argument("--id", help="Comment ID (e.g. com-xxxxx), required for --read")
args = parser.parse_args()

cfg = TFEConfig(address=args.address, token=args.token)
client = TFEClient(cfg)

# 1) Always list existing comments for the run
_print_header(f"Listing comments for run: {args.run_id}")
comment_count = 0
for comment in client.comments.list(run_id=args.run_id):
comment_count += 1
print(f"- ID: {comment.id}")
print(f" Body: {comment.body}")
print()

if comment_count == 0:
print("No comments found.")
else:
print(f"Total: {comment_count} comments")

# 2) Create a new comment
if args.create:
if not args.body:
print("--body is required for --create")
else:
_print_header(f"Creating a comment on run: {args.run_id}")
opts = CommentCreateOptions(body=args.body)
comment = client.comments.create(run_id=args.run_id, options=opts)
print(f"Created comment: {comment.id}")
print(f" Body: {comment.body}")

# 3) Read a specific comment
if args.read:
if not args.id:
print("--id is required for --read")
else:
_print_header(f"Reading comment: {args.id}")
comment = client.comments.read(comment_id=args.id)
print(f"ID: {comment.id}")
print(f"Body: {comment.body}")


if __name__ == "__main__":
main()
2 changes: 2 additions & 0 deletions src/pytfe/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from .resources.agent_pools import AgentPools
from .resources.agents import Agents, AgentTokens
from .resources.apply import Applies
from .resources.comment import Comments
from .resources.configuration_version import ConfigurationVersions
from .resources.notification_configuration import NotificationConfigurations
from .resources.oauth_client import OAuthClients
Expand Down Expand Up @@ -104,6 +105,7 @@ def __init__(self, config: TFEConfig | None = None):
self.runs = Runs(self._transport)
self.query_runs = QueryRuns(self._transport)
self.run_events = RunEvents(self._transport)
self.comments = Comments(self._transport)
self.policies = Policies(self._transport)
self.policy_evaluations = PolicyEvaluations(self._transport)
self.policy_checks = PolicyChecks(self._transport)
Expand Down
15 changes: 15 additions & 0 deletions src/pytfe/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -642,3 +642,18 @@ class InvalidStackConfigurationIDError(InvalidValues):

def __init__(self, message: str = "invalid value for stack configuration ID"):
super().__init__(message)


# Comment errors
class InvalidCommentIDError(InvalidValues):
"""Raised when an invalid comment ID is provided."""

def __init__(self, message: str = "invalid value for comment ID"):
super().__init__(message)


class RequiredCommentBodyError(TFEError):
"""Raised when comment body is empty or missing."""

def __init__(self, message: str = "comment body is required"):
super().__init__(message)
7 changes: 7 additions & 0 deletions src/pytfe/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
AgentTokenCreateOptions,
AgentTokenListOptions,
)
from .comment import (
Comment,
CommentCreateOptions,
)

# ── Core models split out of old types.py ─────────────────────────────────────
# Adjust these imports to match where you placed them during the split.
Expand Down Expand Up @@ -642,6 +646,9 @@
"RunEventList",
"RunEventListOptions",
"RunEventReadOptions",
# Comments
"Comment",
"CommentCreateOptions",
# Run tasks
"RunTask",
"RunTaskIncludeOptions",
Expand Down
19 changes: 18 additions & 1 deletion src/pytfe/models/comment.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,28 @@

from __future__ import annotations

from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator

from ..errors import RequiredCommentBodyError
from ..utils import valid_string


class Comment(BaseModel):
model_config = ConfigDict(populate_by_name=True, validate_by_name=True)

id: str
body: str = Field(default="", alias="body")


class CommentCreateOptions(BaseModel):
"""Options for creating a comment on a run."""

model_config = ConfigDict(populate_by_name=True, validate_by_name=True)

body: str = Field(alias="body")

@model_validator(mode="after")
def valid(self) -> CommentCreateOptions:
if not valid_string(self.body):
raise RequiredCommentBodyError()
return self
54 changes: 54 additions & 0 deletions src/pytfe/resources/comment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: MPL-2.0

from __future__ import annotations

from collections.abc import Iterator
from typing import Any

from ..errors import InvalidCommentIDError, InvalidRunIDError
from ..models.comment import Comment, CommentCreateOptions
from ..utils import valid_string_id
from ._base import _Service


class Comments(_Service):
"""Service for managing run comments."""

def list(self, run_id: str) -> Iterator[Comment]:
"""List all comments for the given run."""
if not valid_string_id(run_id):
raise InvalidRunIDError()
path = f"/api/v2/runs/{run_id}/comments"
for item in self._list(path=path):
yield self._comment_from(item)

def read(self, comment_id: str) -> Comment:
"""Read a comment by its ID."""
if not valid_string_id(comment_id):
raise InvalidCommentIDError()
r = self.t.request("GET", path=f"/api/v2/comments/{comment_id}")
data = r.json().get("data", {})
return self._comment_from(data)

def create(self, run_id: str, options: CommentCreateOptions) -> Comment:
"""Create a new comment on the given run."""
if not valid_string_id(run_id):
raise InvalidRunIDError()
payload = {
"data": {
"type": "comments",
"attributes": options.model_dump(by_alias=True, exclude_none=True),
}
}
r = self.t.request(
"POST", path=f"/api/v2/runs/{run_id}/comments", json_body=payload
)
data = r.json().get("data", {})
return self._comment_from(data)

def _comment_from(self, data: dict[str, Any]) -> Comment:
"""Parse a Comment from API response data."""
attrs = dict(data.get("attributes", {}))
attrs["id"] = data.get("id")
return Comment.model_validate(attrs)
164 changes: 164 additions & 0 deletions tests/units/test_comment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: MPL-2.0

"""Unit tests for the comment module."""

from unittest.mock import Mock

import pytest

from pytfe._http import HTTPTransport
from pytfe.errors import (
InvalidCommentIDError,
InvalidRunIDError,
RequiredCommentBodyError,
)
from pytfe.models.comment import Comment, CommentCreateOptions
from pytfe.resources.comment import Comments


class TestComments:
"""Test the Comments service class."""

@pytest.fixture
def mock_transport(self):
"""Create a mock HTTPTransport."""
return Mock(spec=HTTPTransport)

@pytest.fixture
def service(self, mock_transport):
"""Create a Comments service with mocked transport."""
return Comments(mock_transport)

@pytest.fixture
def comment_api_data(self):
"""Typical API response for a single comment."""
return {
"id": "com-abc123",
"type": "comments",
"attributes": {
"body": "This is a test comment.",
},
}

# ── Model tests ──────────────────────────────────────────────────────────

def test_create_options_valid(self):
"""CommentCreateOptions accepts a valid body."""
opts = CommentCreateOptions(body="Hello world")
assert opts.body == "Hello world"

def test_create_options_empty_body_raises(self):
"""CommentCreateOptions raises RequiredCommentBodyError when body is empty."""
with pytest.raises(RequiredCommentBodyError):
CommentCreateOptions(body="")

def test_create_options_serializes_with_alias(self):
"""CommentCreateOptions serialises using the API alias."""
opts = CommentCreateOptions(body="My comment")
dumped = opts.model_dump(by_alias=True, exclude_none=True)
assert dumped == {"body": "My comment"}

def test_comment_model_fields(self):
"""Comment model stores id and body."""
c = Comment(id="com-123", body="test")
assert c.id == "com-123"
assert c.body == "test"

def test_comment_model_default_body(self):
"""Comment body defaults to empty string."""
c = Comment(id="com-123")
assert c.body == ""

# ── Parser tests ─────────────────────────────────────────────────────────

def test_comment_from_full_data(self, service, comment_api_data):
"""_comment_from parses id and body from API data."""
result = service._comment_from(comment_api_data)

assert isinstance(result, Comment)
assert result.id == "com-abc123"
assert result.body == "This is a test comment."

def test_comment_from_missing_body(self, service):
"""_comment_from handles missing body attribute gracefully."""
data = {"id": "com-xyz", "attributes": {}}
result = service._comment_from(data)

assert result.id == "com-xyz"
assert result.body == ""

# ── Resource method tests ─────────────────────────────────────────────────

def test_list_success(self, service, comment_api_data):
"""list() yields Comment objects from paginated results."""
service._list = Mock(return_value=[comment_api_data])

results = list(service.list(run_id="run-abc123"))

service._list.assert_called_once_with(path="/api/v2/runs/run-abc123/comments")
assert len(results) == 1
assert isinstance(results[0], Comment)
assert results[0].id == "com-abc123"
assert results[0].body == "This is a test comment."

def test_list_empty(self, service):
"""list() returns empty iterator when no comments exist."""
service._list = Mock(return_value=[])

results = list(service.list(run_id="run-abc123"))
assert results == []

def test_list_invalid_run_id(self, service):
"""list() raises InvalidRunIDError for a bad run ID."""
with pytest.raises(InvalidRunIDError):
list(service.list(run_id="not valid!"))

def test_read_success(self, service, mock_transport, comment_api_data):
"""read() GETs the correct path and returns a Comment."""
mock_response = Mock()
mock_response.json.return_value = {"data": comment_api_data}
mock_transport.request.return_value = mock_response

result = service.read(comment_id="com-abc123")

mock_transport.request.assert_called_once_with(
"GET", path="/api/v2/comments/com-abc123"
)
assert isinstance(result, Comment)
assert result.id == "com-abc123"
assert result.body == "This is a test comment."

def test_read_invalid_comment_id(self, service):
"""read() raises InvalidCommentIDError for a bad comment ID."""
with pytest.raises(InvalidCommentIDError):
service.read(comment_id="not valid!")

def test_create_success(self, service, mock_transport, comment_api_data):
"""create() POSTs the correct payload and returns a Comment."""
mock_response = Mock()
mock_response.json.return_value = {"data": comment_api_data}
mock_transport.request.return_value = mock_response

opts = CommentCreateOptions(body="This is a test comment.")
result = service.create(run_id="run-abc123", options=opts)

mock_transport.request.assert_called_once_with(
"POST",
path="/api/v2/runs/run-abc123/comments",
json_body={
"data": {
"type": "comments",
"attributes": {"body": "This is a test comment."},
}
},
)
assert isinstance(result, Comment)
assert result.id == "com-abc123"
assert result.body == "This is a test comment."

def test_create_invalid_run_id(self, service):
"""create() raises InvalidRunIDError for a bad run ID."""
opts = CommentCreateOptions(body="Hello")
with pytest.raises(InvalidRunIDError):
service.create(run_id="not valid!", options=opts)
Loading