Skip to content

Commit 00afd13

Browse files
refactor organization tags list API to iterator pattern
1 parent 518455e commit 00afd13

4 files changed

Lines changed: 114 additions & 134 deletions

File tree

examples/organization_tags.py

Lines changed: 77 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,82 +1,113 @@
11
#!/usr/bin/env python3
2+
# Copyright IBM Corp. 2025, 2026
3+
# SPDX-License-Identifier: MPL-2.0
4+
25
"""Organization tags operations example.
36
47
Demonstrates:
5-
1. list() - list tags in an organization
8+
1. list() - list tags in an organization
9+
2. add_workspaces() - associate a workspace with a tag
10+
3. delete() - delete a tag from an organization
611
7-
This phase intentionally uses only organization-level parameters.
8-
Tag IDs and workspace IDs can be passed in a later phase.
12+
Usage:
13+
python examples/organization_tags.py --org my-org
14+
python examples/organization_tags.py --org my-org --tag-id tag-abc123 --workspace-id ws-xyz
915
"""
1016

17+
from __future__ import annotations
18+
19+
import argparse
1120
import os
1221

1322
from pytfe import TFEClient, TFEConfig
1423
from pytfe.errors import TFEError
15-
from pytfe.models.organization_tags import (
16-
AddWorkspacesToTagOptions,
17-
OrganizationTagsDeleteOptions,
18-
)
24+
from pytfe.models.organization_tags import AddWorkspacesToTagOptions, OrganizationTagsDeleteOptions
1925

2026

2127
def main() -> None:
22-
client = TFEClient(TFEConfig.from_env())
28+
parser = argparse.ArgumentParser(description="Organization Tags demo for python-tfe SDK")
29+
parser.add_argument(
30+
"--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io")
31+
)
32+
parser.add_argument("--token", default=os.getenv("TFE_TOKEN", ""))
33+
parser.add_argument(
34+
"--org",
35+
default=os.getenv("TFE_ORG", ""),
36+
help="Organization name",
37+
)
38+
parser.add_argument(
39+
"--tag-id",
40+
default=os.getenv("TFE_TAG_ID", ""),
41+
help="Tag ID for add/delete operations",
42+
)
43+
parser.add_argument(
44+
"--workspace-id",
45+
default=os.getenv("TFE_WORKSPACE_ID", ""),
46+
help="Workspace ID to associate with tag",
47+
)
48+
args = parser.parse_args()
49+
50+
if not args.token:
51+
print("Error: TFE_TOKEN environment variable or --token required")
52+
return
2353

24-
organization_name = os.getenv("TFE_ORG", "example-org")
25-
tag_id = os.getenv("TFE_TAG_ID", "")
26-
workspace_id = os.getenv("TFE_WORKSPACE_ID", "")
27-
operation = "list"
54+
if not args.org:
55+
print("Error: TFE_ORG environment variable or --org required")
56+
return
2857

58+
cfg = TFEConfig(address=args.address, token=args.token)
59+
client = TFEClient(cfg)
60+
61+
# 1) List tags
2962
try:
3063
print("[LIST] Listing organization tags")
31-
print(f"[LIST] organization={organization_name}")
32-
tags = client.organization_tags.list(organization_name)
33-
print(f"[LIST] total_tags={len(tags.items)}")
34-
for item in tags.items:
64+
print(f"[LIST] organization={args.org}")
65+
tags = list(client.organization_tags.list(args.org))
66+
print(f"[LIST] total_tags={len(tags)}")
67+
for tag in tags:
3568
print(
36-
f"[LIST] id={item.id}, name={item.name}, instance_count={item.instance_count}"
69+
f"[LIST] id={tag.id}, name={tag.name}, instance_count={tag.instance_count}"
3770
)
71+
if not tags:
72+
print("[LIST] no tags found")
73+
except TFEError as exc:
74+
print(f"[LIST] API error: {exc}")
75+
return
3876

39-
# Guard: ensure env vars are set
40-
if not tag_id or not workspace_id:
41-
print("Skipping add/delete: set TFE_TAG_ID and TFE_WORKSPACE_ID first.")
42-
return
77+
if not args.tag_id:
78+
print("[ADD_WORKSPACES] skipped: set --tag-id or TFE_TAG_ID")
79+
print("[DELETE] skipped: set --tag-id or TFE_TAG_ID")
80+
return
4381

44-
# ---- Add workspace ----
45-
operation = "add_workspaces"
82+
# 2) Add workspace to tag
83+
if args.workspace_id:
4684
print("[ADD_WORKSPACES] Associating a workspace to a tag")
4785
print(
48-
f"[ADD_WORKSPACES] organization={organization_name}, tag_id={tag_id}, workspace_id={workspace_id}"
86+
f"[ADD_WORKSPACES] organization={args.org}, tag_id={args.tag_id}, workspace_id={args.workspace_id}"
4987
)
5088
try:
5189
client.organization_tags.add_workspaces(
52-
organization_name,
53-
tag_id,
54-
AddWorkspacesToTagOptions(workspace_ids=[workspace_id]),
90+
args.org,
91+
args.tag_id,
92+
AddWorkspacesToTagOptions(workspace_ids=[args.workspace_id]),
5593
)
5694
print("[ADD_WORKSPACES] workspace associated")
5795
except TFEError as exc:
5896
print(f"[ADD_WORKSPACES] API error: {exc}")
59-
print(f"[ADD_WORKSPACES] failed operation={operation}")
97+
else:
98+
print("[ADD_WORKSPACES] skipped: set --workspace-id or TFE_WORKSPACE_ID")
6099

61-
# ---- Delete tag ----
62-
operation = "delete"
63-
print("[DELETE] Deleting a tag from the organization")
64-
print(f"[DELETE] organization={organization_name}, tag_id={tag_id}")
65-
try:
66-
client.organization_tags.delete(
67-
organization_name,
68-
OrganizationTagsDeleteOptions(ids=[tag_id]),
69-
)
70-
print("[DELETE] tag deleted")
71-
except TFEError as exc:
72-
print(f"[DELETE] API error: {exc}")
73-
print(f"[DELETE] failed operation={operation}")
100+
# 3) Delete tag
101+
print("[DELETE] Deleting a tag from the organization")
102+
print(f"[DELETE] organization={args.org}, tag_id={args.tag_id}")
103+
try:
104+
client.organization_tags.delete(
105+
args.org,
106+
OrganizationTagsDeleteOptions(ids=[args.tag_id]),
107+
)
108+
print("[DELETE] tag deleted")
74109
except TFEError as exc:
75-
print(f"API error: {exc}")
76-
print(f"Failed during operation: {operation}")
77-
print("Check TFE_TOKEN, TFE_ADDRESS, and organization/tag/workspace IDs.")
78-
finally:
79-
client.close()
110+
print(f"[DELETE] API error: {exc}")
80111

81112

82113
if __name__ == "__main__":

src/pytfe/models/organization_tags.py

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
from pydantic import BaseModel, ConfigDict, Field
77

8-
from .common import Pagination
98
from .organization import Organization
109

1110

@@ -27,15 +26,6 @@ class OrganizationTag(BaseModel):
2726
)
2827

2928

30-
class OrganizationTagsList(BaseModel):
31-
"""Represents a list response for organization tags."""
32-
33-
model_config = ConfigDict(extra="forbid")
34-
35-
pagination: Pagination | None = Field(None)
36-
items: list[OrganizationTag] = Field(default_factory=list)
37-
38-
3929
class OrganizationTagsListOptions(BaseModel):
4030
"""Options for listing organization tags."""
4131

src/pytfe/resources/organization_tags.py

Lines changed: 11 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,18 @@
33

44
from __future__ import annotations
55

6+
from collections.abc import Iterator
67
from typing import Any
78
from urllib.parse import quote
89

910
from ..errors import (
1011
ERR_INVALID_ORG,
1112
)
12-
from ..models.common import Pagination
1313
from ..models.organization import Organization
1414
from ..models.organization_tags import (
1515
AddWorkspacesToTagOptions,
1616
OrganizationTag,
1717
OrganizationTagsDeleteOptions,
18-
OrganizationTagsList,
1918
OrganizationTagsListOptions,
2019
)
2120
from ..utils import valid_string_id
@@ -33,34 +32,21 @@ def list(
3332
self,
3433
organization: str,
3534
options: OrganizationTagsListOptions | None = None,
36-
) -> OrganizationTagsList:
35+
) -> Iterator[OrganizationTag]:
3736
"""List all tags within an organization."""
3837
if not valid_string_id(organization):
3938
raise ValueError(ERR_INVALID_ORG)
39+
return self._iter_tags(organization, options)
4040

41+
def _iter_tags(
42+
self,
43+
organization: str,
44+
options: OrganizationTagsListOptions | None = None,
45+
) -> Iterator[OrganizationTag]:
4146
path = f"/api/v2/organizations/{quote(organization)}/tags"
42-
params = (
43-
options.model_dump(by_alias=True, exclude_none=True) if options else None
44-
)
45-
46-
response = self.t.request("GET", path, params=params)
47-
payload = response.json() or {}
48-
49-
items = [self._parse_organization_tag(item) for item in payload.get("data", [])]
50-
51-
pagination = None
52-
meta = payload.get("meta", {})
53-
pagination_data = meta.get("pagination", {}) if isinstance(meta, dict) else {}
54-
if pagination_data:
55-
pagination = Pagination(
56-
current_page=pagination_data.get("current-page", 1),
57-
total_count=pagination_data.get("total-count", len(items)),
58-
previous_page=pagination_data.get("previous-page"),
59-
next_page=pagination_data.get("next-page"),
60-
total_pages=pagination_data.get("total-pages"),
61-
)
62-
63-
return OrganizationTagsList(pagination=pagination, items=items)
47+
params = options.model_dump(by_alias=True, exclude_none=True) if options else {}
48+
for item in self._list(path, params=params):
49+
yield self._parse_organization_tag(item)
6450

6551
def delete(
6652
self,

tests/units/test_organization_tags.py

Lines changed: 26 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,19 @@
1+
# Copyright IBM Corp. 2025, 2026
2+
# SPDX-License-Identifier: MPL-2.0
3+
14
"""Unit tests for the organization tags module."""
25

3-
import os
4-
import sys
56
from unittest.mock import Mock, patch
67

78
import pytest
89

9-
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
10-
1110
from pytfe._http import HTTPTransport
1211
from pytfe.errors import (
1312
ERR_INVALID_ORG,
1413
)
1514
from pytfe.models.organization_tags import (
1615
AddWorkspacesToTagOptions,
1716
OrganizationTagsDeleteOptions,
18-
OrganizationTagsList,
1917
OrganizationTagsListOptions,
2018
)
2119
from pytfe.resources.organization_tags import OrganizationTags
@@ -37,56 +35,31 @@ def organization_tags_service(self, mock_transport):
3735
return OrganizationTags(mock_transport)
3836

3937
def test_list_success(self, organization_tags_service):
40-
mock_response_data = {
41-
"data": [
42-
{
43-
"id": "tag-1",
44-
"attributes": {
45-
"name": "env:dev",
46-
"instance-count": 2,
47-
},
48-
"relationships": {
49-
"organization": {
50-
"data": {"id": "org-1", "type": "organizations"}
51-
}
52-
},
53-
}
54-
],
55-
"meta": {
56-
"pagination": {
57-
"current-page": 1,
58-
"total-count": 1,
59-
"next-page": None,
60-
"previous-page": None,
61-
"total-pages": 1,
62-
}
63-
},
64-
}
65-
66-
mock_response = Mock()
67-
mock_response.json.return_value = mock_response_data
68-
69-
with patch.object(organization_tags_service, "t") as mock_t:
70-
mock_t.request.return_value = mock_response
38+
mock_items = [
39+
{
40+
"id": "tag-1",
41+
"attributes": {
42+
"name": "env:dev",
43+
"instance-count": 2,
44+
},
45+
"relationships": {
46+
"organization": {
47+
"data": {"id": "org-1", "type": "organizations"}
48+
}
49+
},
50+
}
51+
]
7152

53+
with patch.object(organization_tags_service, "_list", return_value=iter(mock_items)):
7254
options = OrganizationTagsListOptions(query="env")
73-
result = organization_tags_service.list("test-org", options)
74-
75-
assert isinstance(result, OrganizationTagsList)
76-
assert len(result.items) == 1
77-
assert result.items[0].id == "tag-1"
78-
assert result.items[0].name == "env:dev"
79-
assert result.items[0].instance_count == 2
80-
assert result.items[0].organization is not None
81-
assert result.items[0].organization.id == "org-1"
82-
assert result.pagination is not None
83-
assert result.pagination.current_page == 1
84-
assert result.pagination.total_count == 1
85-
86-
call_args = mock_t.request.call_args
87-
assert call_args[0][0] == "GET"
88-
assert call_args[0][1] == "/api/v2/organizations/test-org/tags"
89-
assert call_args[1]["params"]["q"] == "env"
55+
result = list(organization_tags_service.list("test-org", options))
56+
57+
assert len(result) == 1
58+
assert result[0].id == "tag-1"
59+
assert result[0].name == "env:dev"
60+
assert result[0].instance_count == 2
61+
assert result[0].organization is not None
62+
assert result[0].organization.id == "org-1"
9063

9164
def test_list_validation_errors(self, organization_tags_service):
9265
with pytest.raises(ValueError, match=ERR_INVALID_ORG):

0 commit comments

Comments
 (0)