From c82b45495089531e79c1204b2cd6c116c1d9a79d Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Thu, 7 May 2026 13:03:41 +0530 Subject: [PATCH 01/10] feat: add organization tags API (list, add_workspaces, delete) with models, resources, and example --- examples/organization_tags.py | 83 ++++++++++++ src/pytfe/client.py | 2 + src/pytfe/models/organization_tags.py | 65 ++++++++++ src/pytfe/resources/organization_tags.py | 122 +++++++++++++++++ tests/units/test_organization_tags.py | 158 +++++++++++++++++++++++ 5 files changed, 430 insertions(+) create mode 100644 examples/organization_tags.py create mode 100644 src/pytfe/models/organization_tags.py create mode 100644 src/pytfe/resources/organization_tags.py create mode 100644 tests/units/test_organization_tags.py diff --git a/examples/organization_tags.py b/examples/organization_tags.py new file mode 100644 index 00000000..96ef0df6 --- /dev/null +++ b/examples/organization_tags.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Organization tags operations example. + +Demonstrates: +1. list() - list tags in an organization + +This phase intentionally uses only organization-level parameters. +Tag IDs and workspace IDs can be passed in a later phase. +""" + +import os + +from pytfe import TFEClient, TFEConfig +from pytfe.errors import TFEError +from pytfe.models.organization_tags import ( + AddWorkspacesToTagOptions, + OrganizationTagsDeleteOptions, +) + + +def main() -> None: + client = TFEClient(TFEConfig.from_env()) + + organization_name = os.getenv("TFE_ORG", "example-org") + tag_id = os.getenv("TFE_TAG_ID", "") + workspace_id = os.getenv("TFE_WORKSPACE_ID", "") + operation = "list" + + try: + print("[LIST] Listing organization tags") + print(f"[LIST] organization={organization_name}") + tags = client.organization_tags.list(organization_name) + print(f"[LIST] total_tags={len(tags.items)}") + for item in tags.items: + print( + f"[LIST] id={item.id}, name={item.name}, instance_count={item.instance_count}" + ) + + # Guard: ensure env vars are set + if not tag_id or not workspace_id: + print("Skipping add/delete: set TFE_TAG_ID and TFE_WORKSPACE_ID first.") + return + + # ---- Add workspace ---- + operation = "add_workspaces" + print("[ADD_WORKSPACES] Associating a workspace to a tag") + print( + f"[ADD_WORKSPACES] organization={organization_name}, tag_id={tag_id}, workspace_id={workspace_id}" + ) + try: + client.organization_tags.add_workspaces( + organization_name, + tag_id, + AddWorkspacesToTagOptions(workspace_ids=[workspace_id]), + ) + print("[ADD_WORKSPACES] workspace associated") + except TFEError as exc: + print(f"[ADD_WORKSPACES] API error: {exc}") + print(f"[ADD_WORKSPACES] failed operation={operation}") + + # ---- Delete tag ---- + operation = "delete" + print("[DELETE] Deleting a tag from the organization") + print(f"[DELETE] organization={organization_name}, tag_id={tag_id}") + try: + client.organization_tags.delete( + organization_name, + OrganizationTagsDeleteOptions(ids=[tag_id]), + ) + print("[DELETE] tag deleted") + except TFEError as exc: + print(f"[DELETE] API error: {exc}") + print(f"[DELETE] failed operation={operation}") + except TFEError as exc: + print(f"API error: {exc}") + print(f"Failed during operation: {operation}") + print("Check TFE_TOKEN, TFE_ADDRESS, and organization/tag/workspace IDs.") + finally: + client.close() + + +if __name__ == "__main__": + main() diff --git a/src/pytfe/client.py b/src/pytfe/client.py index dc1972ce..69759f41 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -13,6 +13,7 @@ from .resources.oauth_client import OAuthClients from .resources.oauth_token import OAuthTokens from .resources.organization_membership import OrganizationMemberships +from .resources.organization_tags import OrganizationTags from .resources.organization_token import OrganizationTokens from .resources.organizations import Organizations from .resources.plan import Plans @@ -73,6 +74,7 @@ def __init__(self, config: TFEConfig | None = None): self.plans = Plans(self._transport) self.organizations = Organizations(self._transport) self.organization_memberships = OrganizationMemberships(self._transport) + self.organization_tags = OrganizationTags(self._transport) self.organization_tokens = OrganizationTokens(self._transport) self.projects = Projects(self._transport) self.variables = Variables(self._transport) diff --git a/src/pytfe/models/organization_tags.py b/src/pytfe/models/organization_tags.py new file mode 100644 index 00000000..957e5384 --- /dev/null +++ b/src/pytfe/models/organization_tags.py @@ -0,0 +1,65 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +from .common import Pagination +from .organization import Organization + + +class OrganizationTag(BaseModel): + """Terraform Enterprise organization tag.""" + + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + id: str = Field(..., description="Tag ID") + name: str | None = Field(None, description="Tag name") + instance_count: int | None = Field( + None, + alias="instance-count", + description="Number of workspaces that have this tag", + ) + organization: Organization | None = Field( + None, + description="Organization this tag belongs to", + ) + + +class OrganizationTagsList(BaseModel): + """Represents a list response for organization tags.""" + + model_config = ConfigDict(extra="forbid") + + pagination: Pagination | None = Field(None) + items: list[OrganizationTag] = Field(default_factory=list) + + +class OrganizationTagsListOptions(BaseModel): + """Options for listing organization tags.""" + + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + filter: str | None = Field(None, alias="filter[exclude][taggable][id]") + query: str | None = Field( + None, + alias="q", + description="Search query string for tag name likeness", + ) + + +class OrganizationTagsDeleteOptions(BaseModel): + """Options for deleting tags from an organization.""" + + model_config = ConfigDict(extra="forbid") + + ids: list[str] = Field(default_factory=list) + + +class AddWorkspacesToTagOptions(BaseModel): + """Options for associating workspaces with a tag.""" + + model_config = ConfigDict(extra="forbid") + + workspace_ids: list[str] = Field(default_factory=list) diff --git a/src/pytfe/resources/organization_tags.py b/src/pytfe/resources/organization_tags.py new file mode 100644 index 00000000..9ec24c0f --- /dev/null +++ b/src/pytfe/resources/organization_tags.py @@ -0,0 +1,122 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from typing import Any +from urllib.parse import quote + +from ..errors import ( + ERR_INVALID_ORG, +) +from ..models.common import Pagination +from ..models.organization import Organization +from ..models.organization_tags import ( + AddWorkspacesToTagOptions, + OrganizationTag, + OrganizationTagsDeleteOptions, + OrganizationTagsList, + OrganizationTagsListOptions, +) +from ..utils import valid_string_id +from ._base import _Service + +ERR_INVALID_TAG = "invalid value for tag" +ERR_REQUIRED_TAG_ID = "tag ID is required" +ERR_REQUIRED_TAG_WORKSPACE_ID = "workspace ID is required" + + +class OrganizationTags(_Service): + """Organization tags service for Terraform Enterprise.""" + + def list( + self, + organization: str, + options: OrganizationTagsListOptions | None = None, + ) -> OrganizationTagsList: + """List all tags within an organization.""" + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + + path = f"/api/v2/organizations/{quote(organization)}/tags" + params = ( + options.model_dump(by_alias=True, exclude_none=True) if options else None + ) + + response = self.t.request("GET", path, params=params) + payload = response.json() or {} + + items = [self._parse_organization_tag(item) for item in payload.get("data", [])] + + pagination = None + meta = payload.get("meta", {}) + pagination_data = meta.get("pagination", {}) if isinstance(meta, dict) else {} + if pagination_data: + pagination = Pagination( + current_page=pagination_data.get("current-page", 1), + total_count=pagination_data.get("total-count", len(items)), + previous_page=pagination_data.get("previous-page"), + next_page=pagination_data.get("next-page"), + total_pages=pagination_data.get("total-pages"), + ) + + return OrganizationTagsList(pagination=pagination, items=items) + + def delete( + self, + organization: str, + options: OrganizationTagsDeleteOptions, + ) -> None: + """Delete tags from an organization.""" + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + + if len(options.ids) == 0: + raise ValueError(ERR_REQUIRED_TAG_ID) + + for tag_id in options.ids: + if not valid_string_id(tag_id): + raise ValueError(f"{tag_id} is not a valid id value") + + body = {"data": [{"type": "tags", "id": tag_id} for tag_id in options.ids]} + path = f"/api/v2/organizations/{quote(organization)}/tags" + self.t.request("DELETE", path, json_body=body) + + def add_workspaces(self, organization: str, tag: str, options: AddWorkspacesToTagOptions) -> None: + """Associate workspaces with an organization tag.""" + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + if not valid_string_id(tag): + raise ValueError(ERR_INVALID_TAG) + + if len(options.workspace_ids) == 0: + raise ValueError(ERR_REQUIRED_TAG_WORKSPACE_ID) + + for workspace_id in options.workspace_ids: + if not valid_string_id(workspace_id): + raise ValueError(f"{workspace_id} is not a valid id value") + + body = { + "data": [ + {"type": "workspaces", "id": workspace_id} + for workspace_id in options.workspace_ids + ] + } + path = f"/api/v2/tags/{quote(tag)}/relationships/workspaces" + self.t.request("POST", path, json_body=body) + + def _parse_organization_tag(self, data: dict[str, Any]) -> OrganizationTag: + attributes = data.get("attributes", {}) + relationships = data.get("relationships", {}) + + org = None + org_data = relationships.get("organization", {}).get("data") + if org_data and isinstance(org_data, dict): + org = Organization(id=org_data.get("id")) + + return OrganizationTag( + id=data.get("id", ""), + name=attributes.get("name"), + instance_count=attributes.get("instance-count"), + organization=org, + ) diff --git a/tests/units/test_organization_tags.py b/tests/units/test_organization_tags.py new file mode 100644 index 00000000..30ca2c26 --- /dev/null +++ b/tests/units/test_organization_tags.py @@ -0,0 +1,158 @@ +"""Unit tests for the organization tags module.""" + +import os +import sys +from unittest.mock import Mock, patch + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) + +from pytfe._http import HTTPTransport +from pytfe.errors import ( + ERR_INVALID_ORG, +) +from pytfe.models.organization_tags import ( + AddWorkspacesToTagOptions, + OrganizationTagsDeleteOptions, + OrganizationTagsList, + OrganizationTagsListOptions, +) +from pytfe.resources.organization_tags import OrganizationTags + +ERR_INVALID_TAG = "invalid value for tag" +ERR_REQUIRED_TAG_ID = "tag ID is required" +ERR_REQUIRED_TAG_WORKSPACE_ID = "workspace ID is required" + + +class TestOrganizationTags: + """Test the OrganizationTags service class.""" + + @pytest.fixture + def mock_transport(self): + return Mock(spec=HTTPTransport) + + @pytest.fixture + def organization_tags_service(self, mock_transport): + return OrganizationTags(mock_transport) + + def test_list_success(self, organization_tags_service): + mock_response_data = { + "data": [ + { + "id": "tag-1", + "attributes": { + "name": "env:dev", + "instance-count": 2, + }, + "relationships": { + "organization": {"data": {"id": "org-1", "type": "organizations"}} + }, + } + ], + "meta": { + "pagination": { + "current-page": 1, + "total-count": 1, + "next-page": None, + "previous-page": None, + "total-pages": 1, + } + }, + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + + with patch.object(organization_tags_service, "t") as mock_t: + mock_t.request.return_value = mock_response + + options = OrganizationTagsListOptions(query="env") + result = organization_tags_service.list("test-org", options) + + assert isinstance(result, OrganizationTagsList) + assert len(result.items) == 1 + assert result.items[0].id == "tag-1" + assert result.items[0].name == "env:dev" + assert result.items[0].instance_count == 2 + assert result.items[0].organization is not None + assert result.items[0].organization.id == "org-1" + assert result.pagination is not None + assert result.pagination.current_page == 1 + assert result.pagination.total_count == 1 + + call_args = mock_t.request.call_args + assert call_args[0][0] == "GET" + assert call_args[0][1] == "/api/v2/organizations/test-org/tags" + assert call_args[1]["params"]["q"] == "env" + + def test_list_validation_errors(self, organization_tags_service): + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + organization_tags_service.list("") + + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + organization_tags_service.list(None) + + def test_delete_success(self, organization_tags_service): + with patch.object(organization_tags_service, "t") as mock_t: + mock_t.request.return_value = Mock() + + options = OrganizationTagsDeleteOptions(ids=["tag-1", "tag-2"]) + organization_tags_service.delete("test-org", options) + + call_args = mock_t.request.call_args + assert call_args[0][0] == "DELETE" + assert call_args[0][1] == "/api/v2/organizations/test-org/tags" + assert call_args[1]["json_body"] == { + "data": [ + {"type": "tags", "id": "tag-1"}, + {"type": "tags", "id": "tag-2"}, + ] + } + + def test_delete_validation_errors(self, organization_tags_service): + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + organization_tags_service.delete( + "", OrganizationTagsDeleteOptions(ids=["tag-1"]) + ) + + with pytest.raises(ValueError, match=ERR_REQUIRED_TAG_ID): + organization_tags_service.delete("test-org", OrganizationTagsDeleteOptions()) + + with pytest.raises(ValueError, match="is not a valid id value"): + organization_tags_service.delete( + "test-org", OrganizationTagsDeleteOptions(ids=[""]) + ) + + def test_add_workspaces_success(self, organization_tags_service): + with patch.object(organization_tags_service, "t") as mock_t: + mock_t.request.return_value = Mock() + + options = AddWorkspacesToTagOptions(workspace_ids=["ws-1", "ws-2"]) + organization_tags_service.add_workspaces("tag-1", options) + + call_args = mock_t.request.call_args + assert call_args[0][0] == "POST" + assert call_args[0][1] == "/api/v2/tags/tag-1/relationships/workspaces" + assert call_args[1]["json_body"] == { + "data": [ + {"type": "workspaces", "id": "ws-1"}, + {"type": "workspaces", "id": "ws-2"}, + ] + } + + def test_add_workspaces_validation_errors(self, organization_tags_service): + with pytest.raises(ValueError, match=ERR_INVALID_TAG): + organization_tags_service.add_workspaces( + "", AddWorkspacesToTagOptions(workspace_ids=["ws-1"]) + ) + + with pytest.raises(ValueError, match=ERR_REQUIRED_TAG_WORKSPACE_ID): + organization_tags_service.add_workspaces( + "tag-1", AddWorkspacesToTagOptions() + ) + + with pytest.raises(ValueError, match="is not a valid id value"): + organization_tags_service.add_workspaces( + "tag-1", AddWorkspacesToTagOptions(workspace_ids=[""]) + ) From a006562e94c253f1d562bb67155a0c96a2169673 Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Thu, 7 May 2026 13:11:50 +0530 Subject: [PATCH 02/10] test: update unit tests for organization tags --- tests/units/test_organization_tags.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/units/test_organization_tags.py b/tests/units/test_organization_tags.py index 30ca2c26..2a876e26 100644 --- a/tests/units/test_organization_tags.py +++ b/tests/units/test_organization_tags.py @@ -129,7 +129,7 @@ def test_add_workspaces_success(self, organization_tags_service): mock_t.request.return_value = Mock() options = AddWorkspacesToTagOptions(workspace_ids=["ws-1", "ws-2"]) - organization_tags_service.add_workspaces("tag-1", options) + organization_tags_service.add_workspaces("test-org", "tag-1", options) call_args = mock_t.request.call_args assert call_args[0][0] == "POST" @@ -142,17 +142,22 @@ def test_add_workspaces_success(self, organization_tags_service): } def test_add_workspaces_validation_errors(self, organization_tags_service): + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + organization_tags_service.add_workspaces( + "", "tag-1", AddWorkspacesToTagOptions(workspace_ids=["ws-1"]) + ) + with pytest.raises(ValueError, match=ERR_INVALID_TAG): organization_tags_service.add_workspaces( - "", AddWorkspacesToTagOptions(workspace_ids=["ws-1"]) + "test-org", "", AddWorkspacesToTagOptions(workspace_ids=["ws-1"]) ) with pytest.raises(ValueError, match=ERR_REQUIRED_TAG_WORKSPACE_ID): organization_tags_service.add_workspaces( - "tag-1", AddWorkspacesToTagOptions() + "test-org", "tag-1", AddWorkspacesToTagOptions() ) with pytest.raises(ValueError, match="is not a valid id value"): organization_tags_service.add_workspaces( - "tag-1", AddWorkspacesToTagOptions(workspace_ids=[""]) + "test-org", "tag-1", AddWorkspacesToTagOptions(workspace_ids=[""]) ) From cec2d8be0e547dd8da188f78b7b2d8319776ca2b Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Thu, 7 May 2026 14:18:17 +0530 Subject: [PATCH 03/10] fix: apply ruff formatting --- src/pytfe/resources/organization_tags.py | 4 +++- tests/units/test_organization_tags.py | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/pytfe/resources/organization_tags.py b/src/pytfe/resources/organization_tags.py index 9ec24c0f..1516522c 100644 --- a/src/pytfe/resources/organization_tags.py +++ b/src/pytfe/resources/organization_tags.py @@ -82,7 +82,9 @@ def delete( path = f"/api/v2/organizations/{quote(organization)}/tags" self.t.request("DELETE", path, json_body=body) - def add_workspaces(self, organization: str, tag: str, options: AddWorkspacesToTagOptions) -> None: + def add_workspaces( + self, organization: str, tag: str, options: AddWorkspacesToTagOptions + ) -> None: """Associate workspaces with an organization tag.""" if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) diff --git a/tests/units/test_organization_tags.py b/tests/units/test_organization_tags.py index 2a876e26..68ebf9fd 100644 --- a/tests/units/test_organization_tags.py +++ b/tests/units/test_organization_tags.py @@ -46,7 +46,9 @@ def test_list_success(self, organization_tags_service): "instance-count": 2, }, "relationships": { - "organization": {"data": {"id": "org-1", "type": "organizations"}} + "organization": { + "data": {"id": "org-1", "type": "organizations"} + } }, } ], @@ -117,7 +119,9 @@ def test_delete_validation_errors(self, organization_tags_service): ) with pytest.raises(ValueError, match=ERR_REQUIRED_TAG_ID): - organization_tags_service.delete("test-org", OrganizationTagsDeleteOptions()) + organization_tags_service.delete( + "test-org", OrganizationTagsDeleteOptions() + ) with pytest.raises(ValueError, match="is not a valid id value"): organization_tags_service.delete( From c02c8362e3dd525fa601b3ae098b18aa6ba54fdb Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Thu, 7 May 2026 14:45:05 +0530 Subject: [PATCH 04/10] client.py is modified --- src/pytfe/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pytfe/client.py b/src/pytfe/client.py index ed5ff4e6..1d73e3ac 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -38,7 +38,6 @@ from .resources.ssh_keys import SSHKeys from .resources.state_version_outputs import StateVersionOutputs from .resources.state_versions import StateVersions -from .resources.user import Users from .resources.variable import Variables from .resources.variable_sets import VariableSets, VariableSetVariables from .resources.workspace_resources import WorkspaceResourcesService From 3b05117b730bda5593180812e3ad8a2715bbd9f3 Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Thu, 7 May 2026 14:51:32 +0530 Subject: [PATCH 05/10] fixing lint issues --- src/pytfe/resources/organization_tags.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/pytfe/resources/organization_tags.py b/src/pytfe/resources/organization_tags.py index 1516522c..51eae9ea 100644 --- a/src/pytfe/resources/organization_tags.py +++ b/src/pytfe/resources/organization_tags.py @@ -116,9 +116,11 @@ def _parse_organization_tag(self, data: dict[str, Any]) -> OrganizationTag: if org_data and isinstance(org_data, dict): org = Organization(id=org_data.get("id")) - return OrganizationTag( - id=data.get("id", ""), - name=attributes.get("name"), - instance_count=attributes.get("instance-count"), - organization=org, + return OrganizationTag.model_validate( + { + "id": data.get("id", ""), + "name": attributes.get("name"), + "instance-count": attributes.get("instance-count"), + "organization": org, + } ) From 4630aa6d1c67dfae3cc9fecffc141fda0f901b5b Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Fri, 8 May 2026 13:20:59 +0530 Subject: [PATCH 06/10] self.Users is added --- src/pytfe/client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 1d73e3ac..585b0f79 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -75,6 +75,7 @@ def __init__(self, config: TFEConfig | None = None): self.plans = Plans(self._transport) self.organizations = Organizations(self._transport) self.organization_memberships = OrganizationMemberships(self._transport) + self.users = Users(self._transport) self.organization_tags = OrganizationTags(self._transport) self.organization_tokens = OrganizationTokens(self._transport) self.projects = Projects(self._transport) From 518455e8fce89ab81eb9cf1a22d86dcc6a76cc22 Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Fri, 8 May 2026 13:25:25 +0530 Subject: [PATCH 07/10] self.Users is added again --- src/pytfe/client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 585b0f79..4f22961f 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -38,6 +38,7 @@ from .resources.ssh_keys import SSHKeys from .resources.state_version_outputs import StateVersionOutputs from .resources.state_versions import StateVersions +from .resources.user import Users from .resources.variable import Variables from .resources.variable_sets import VariableSets, VariableSetVariables from .resources.workspace_resources import WorkspaceResourcesService From 00afd13da0b6fa23c33be978c83efc363ac41689 Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Thu, 14 May 2026 12:46:16 +0530 Subject: [PATCH 08/10] refactor organization tags list API to iterator pattern --- examples/organization_tags.py | 123 ++++++++++++++--------- src/pytfe/models/organization_tags.py | 10 -- src/pytfe/resources/organization_tags.py | 36 ++----- tests/units/test_organization_tags.py | 79 +++++---------- 4 files changed, 114 insertions(+), 134 deletions(-) diff --git a/examples/organization_tags.py b/examples/organization_tags.py index 96ef0df6..553cdfc7 100644 --- a/examples/organization_tags.py +++ b/examples/organization_tags.py @@ -1,82 +1,113 @@ #!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Organization tags operations example. Demonstrates: -1. list() - list tags in an organization +1. list() - list tags in an organization +2. add_workspaces() - associate a workspace with a tag +3. delete() - delete a tag from an organization -This phase intentionally uses only organization-level parameters. -Tag IDs and workspace IDs can be passed in a later phase. +Usage: + python examples/organization_tags.py --org my-org + python examples/organization_tags.py --org my-org --tag-id tag-abc123 --workspace-id ws-xyz """ +from __future__ import annotations + +import argparse import os from pytfe import TFEClient, TFEConfig from pytfe.errors import TFEError -from pytfe.models.organization_tags import ( - AddWorkspacesToTagOptions, - OrganizationTagsDeleteOptions, -) +from pytfe.models.organization_tags import AddWorkspacesToTagOptions, OrganizationTagsDeleteOptions def main() -> None: - client = TFEClient(TFEConfig.from_env()) + parser = argparse.ArgumentParser(description="Organization Tags 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( + "--org", + default=os.getenv("TFE_ORG", ""), + help="Organization name", + ) + parser.add_argument( + "--tag-id", + default=os.getenv("TFE_TAG_ID", ""), + help="Tag ID for add/delete operations", + ) + parser.add_argument( + "--workspace-id", + default=os.getenv("TFE_WORKSPACE_ID", ""), + help="Workspace ID to associate with tag", + ) + args = parser.parse_args() + + if not args.token: + print("Error: TFE_TOKEN environment variable or --token required") + return - organization_name = os.getenv("TFE_ORG", "example-org") - tag_id = os.getenv("TFE_TAG_ID", "") - workspace_id = os.getenv("TFE_WORKSPACE_ID", "") - operation = "list" + if not args.org: + print("Error: TFE_ORG environment variable or --org required") + return + cfg = TFEConfig(address=args.address, token=args.token) + client = TFEClient(cfg) + + # 1) List tags try: print("[LIST] Listing organization tags") - print(f"[LIST] organization={organization_name}") - tags = client.organization_tags.list(organization_name) - print(f"[LIST] total_tags={len(tags.items)}") - for item in tags.items: + print(f"[LIST] organization={args.org}") + tags = list(client.organization_tags.list(args.org)) + print(f"[LIST] total_tags={len(tags)}") + for tag in tags: print( - f"[LIST] id={item.id}, name={item.name}, instance_count={item.instance_count}" + f"[LIST] id={tag.id}, name={tag.name}, instance_count={tag.instance_count}" ) + if not tags: + print("[LIST] no tags found") + except TFEError as exc: + print(f"[LIST] API error: {exc}") + return - # Guard: ensure env vars are set - if not tag_id or not workspace_id: - print("Skipping add/delete: set TFE_TAG_ID and TFE_WORKSPACE_ID first.") - return + if not args.tag_id: + print("[ADD_WORKSPACES] skipped: set --tag-id or TFE_TAG_ID") + print("[DELETE] skipped: set --tag-id or TFE_TAG_ID") + return - # ---- Add workspace ---- - operation = "add_workspaces" + # 2) Add workspace to tag + if args.workspace_id: print("[ADD_WORKSPACES] Associating a workspace to a tag") print( - f"[ADD_WORKSPACES] organization={organization_name}, tag_id={tag_id}, workspace_id={workspace_id}" + f"[ADD_WORKSPACES] organization={args.org}, tag_id={args.tag_id}, workspace_id={args.workspace_id}" ) try: client.organization_tags.add_workspaces( - organization_name, - tag_id, - AddWorkspacesToTagOptions(workspace_ids=[workspace_id]), + args.org, + args.tag_id, + AddWorkspacesToTagOptions(workspace_ids=[args.workspace_id]), ) print("[ADD_WORKSPACES] workspace associated") except TFEError as exc: print(f"[ADD_WORKSPACES] API error: {exc}") - print(f"[ADD_WORKSPACES] failed operation={operation}") + else: + print("[ADD_WORKSPACES] skipped: set --workspace-id or TFE_WORKSPACE_ID") - # ---- Delete tag ---- - operation = "delete" - print("[DELETE] Deleting a tag from the organization") - print(f"[DELETE] organization={organization_name}, tag_id={tag_id}") - try: - client.organization_tags.delete( - organization_name, - OrganizationTagsDeleteOptions(ids=[tag_id]), - ) - print("[DELETE] tag deleted") - except TFEError as exc: - print(f"[DELETE] API error: {exc}") - print(f"[DELETE] failed operation={operation}") + # 3) Delete tag + print("[DELETE] Deleting a tag from the organization") + print(f"[DELETE] organization={args.org}, tag_id={args.tag_id}") + try: + client.organization_tags.delete( + args.org, + OrganizationTagsDeleteOptions(ids=[args.tag_id]), + ) + print("[DELETE] tag deleted") except TFEError as exc: - print(f"API error: {exc}") - print(f"Failed during operation: {operation}") - print("Check TFE_TOKEN, TFE_ADDRESS, and organization/tag/workspace IDs.") - finally: - client.close() + print(f"[DELETE] API error: {exc}") if __name__ == "__main__": diff --git a/src/pytfe/models/organization_tags.py b/src/pytfe/models/organization_tags.py index 957e5384..1bb1e477 100644 --- a/src/pytfe/models/organization_tags.py +++ b/src/pytfe/models/organization_tags.py @@ -5,7 +5,6 @@ from pydantic import BaseModel, ConfigDict, Field -from .common import Pagination from .organization import Organization @@ -27,15 +26,6 @@ class OrganizationTag(BaseModel): ) -class OrganizationTagsList(BaseModel): - """Represents a list response for organization tags.""" - - model_config = ConfigDict(extra="forbid") - - pagination: Pagination | None = Field(None) - items: list[OrganizationTag] = Field(default_factory=list) - - class OrganizationTagsListOptions(BaseModel): """Options for listing organization tags.""" diff --git a/src/pytfe/resources/organization_tags.py b/src/pytfe/resources/organization_tags.py index 51eae9ea..2537c22d 100644 --- a/src/pytfe/resources/organization_tags.py +++ b/src/pytfe/resources/organization_tags.py @@ -3,19 +3,18 @@ from __future__ import annotations +from collections.abc import Iterator from typing import Any from urllib.parse import quote from ..errors import ( ERR_INVALID_ORG, ) -from ..models.common import Pagination from ..models.organization import Organization from ..models.organization_tags import ( AddWorkspacesToTagOptions, OrganizationTag, OrganizationTagsDeleteOptions, - OrganizationTagsList, OrganizationTagsListOptions, ) from ..utils import valid_string_id @@ -33,34 +32,21 @@ def list( self, organization: str, options: OrganizationTagsListOptions | None = None, - ) -> OrganizationTagsList: + ) -> Iterator[OrganizationTag]: """List all tags within an organization.""" if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) + return self._iter_tags(organization, options) + def _iter_tags( + self, + organization: str, + options: OrganizationTagsListOptions | None = None, + ) -> Iterator[OrganizationTag]: path = f"/api/v2/organizations/{quote(organization)}/tags" - params = ( - options.model_dump(by_alias=True, exclude_none=True) if options else None - ) - - response = self.t.request("GET", path, params=params) - payload = response.json() or {} - - items = [self._parse_organization_tag(item) for item in payload.get("data", [])] - - pagination = None - meta = payload.get("meta", {}) - pagination_data = meta.get("pagination", {}) if isinstance(meta, dict) else {} - if pagination_data: - pagination = Pagination( - current_page=pagination_data.get("current-page", 1), - total_count=pagination_data.get("total-count", len(items)), - previous_page=pagination_data.get("previous-page"), - next_page=pagination_data.get("next-page"), - total_pages=pagination_data.get("total-pages"), - ) - - return OrganizationTagsList(pagination=pagination, items=items) + params = options.model_dump(by_alias=True, exclude_none=True) if options else {} + for item in self._list(path, params=params): + yield self._parse_organization_tag(item) def delete( self, diff --git a/tests/units/test_organization_tags.py b/tests/units/test_organization_tags.py index 68ebf9fd..e9bab48b 100644 --- a/tests/units/test_organization_tags.py +++ b/tests/units/test_organization_tags.py @@ -1,13 +1,12 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Unit tests for the organization tags module.""" -import os -import sys from unittest.mock import Mock, patch import pytest -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) - from pytfe._http import HTTPTransport from pytfe.errors import ( ERR_INVALID_ORG, @@ -15,7 +14,6 @@ from pytfe.models.organization_tags import ( AddWorkspacesToTagOptions, OrganizationTagsDeleteOptions, - OrganizationTagsList, OrganizationTagsListOptions, ) from pytfe.resources.organization_tags import OrganizationTags @@ -37,56 +35,31 @@ def organization_tags_service(self, mock_transport): return OrganizationTags(mock_transport) def test_list_success(self, organization_tags_service): - mock_response_data = { - "data": [ - { - "id": "tag-1", - "attributes": { - "name": "env:dev", - "instance-count": 2, - }, - "relationships": { - "organization": { - "data": {"id": "org-1", "type": "organizations"} - } - }, - } - ], - "meta": { - "pagination": { - "current-page": 1, - "total-count": 1, - "next-page": None, - "previous-page": None, - "total-pages": 1, - } - }, - } - - mock_response = Mock() - mock_response.json.return_value = mock_response_data - - with patch.object(organization_tags_service, "t") as mock_t: - mock_t.request.return_value = mock_response + mock_items = [ + { + "id": "tag-1", + "attributes": { + "name": "env:dev", + "instance-count": 2, + }, + "relationships": { + "organization": { + "data": {"id": "org-1", "type": "organizations"} + } + }, + } + ] + with patch.object(organization_tags_service, "_list", return_value=iter(mock_items)): options = OrganizationTagsListOptions(query="env") - result = organization_tags_service.list("test-org", options) - - assert isinstance(result, OrganizationTagsList) - assert len(result.items) == 1 - assert result.items[0].id == "tag-1" - assert result.items[0].name == "env:dev" - assert result.items[0].instance_count == 2 - assert result.items[0].organization is not None - assert result.items[0].organization.id == "org-1" - assert result.pagination is not None - assert result.pagination.current_page == 1 - assert result.pagination.total_count == 1 - - call_args = mock_t.request.call_args - assert call_args[0][0] == "GET" - assert call_args[0][1] == "/api/v2/organizations/test-org/tags" - assert call_args[1]["params"]["q"] == "env" + result = list(organization_tags_service.list("test-org", options)) + + assert len(result) == 1 + assert result[0].id == "tag-1" + assert result[0].name == "env:dev" + assert result[0].instance_count == 2 + assert result[0].organization is not None + assert result[0].organization.id == "org-1" def test_list_validation_errors(self, organization_tags_service): with pytest.raises(ValueError, match=ERR_INVALID_ORG): From 6c9f838571b094c84d8a630106afea6035684aad Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Thu, 14 May 2026 12:48:58 +0530 Subject: [PATCH 09/10] format organization tags files --- examples/organization_tags.py | 9 +++++++-- tests/units/test_organization_tags.py | 8 ++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/examples/organization_tags.py b/examples/organization_tags.py index 553cdfc7..c562f573 100644 --- a/examples/organization_tags.py +++ b/examples/organization_tags.py @@ -21,11 +21,16 @@ from pytfe import TFEClient, TFEConfig from pytfe.errors import TFEError -from pytfe.models.organization_tags import AddWorkspacesToTagOptions, OrganizationTagsDeleteOptions +from pytfe.models.organization_tags import ( + AddWorkspacesToTagOptions, + OrganizationTagsDeleteOptions, +) def main() -> None: - parser = argparse.ArgumentParser(description="Organization Tags demo for python-tfe SDK") + parser = argparse.ArgumentParser( + description="Organization Tags demo for python-tfe SDK" + ) parser.add_argument( "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") ) diff --git a/tests/units/test_organization_tags.py b/tests/units/test_organization_tags.py index e9bab48b..af5a1103 100644 --- a/tests/units/test_organization_tags.py +++ b/tests/units/test_organization_tags.py @@ -43,14 +43,14 @@ def test_list_success(self, organization_tags_service): "instance-count": 2, }, "relationships": { - "organization": { - "data": {"id": "org-1", "type": "organizations"} - } + "organization": {"data": {"id": "org-1", "type": "organizations"}} }, } ] - with patch.object(organization_tags_service, "_list", return_value=iter(mock_items)): + with patch.object( + organization_tags_service, "_list", return_value=iter(mock_items) + ): options = OrganizationTagsListOptions(query="env") result = list(organization_tags_service.list("test-org", options)) From 70d25f948ea1c3d0c13fc2b0d9c5089eb6fe39ed Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Thu, 14 May 2026 16:43:16 +0530 Subject: [PATCH 10/10] move organization tag errors to shared errors module --- src/pytfe/errors.py | 5 +++++ src/pytfe/resources/organization_tags.py | 7 +++---- tests/units/test_organization_tags.py | 7 +++---- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index f2340af3..e9992da0 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -121,6 +121,11 @@ class ErrStateVersionUploadNotSupported(TFEError): ... ERR_REQUIRED_TAG_KEY = "tag key is required" ERR_INVALID_TAG_KEY = "invalid tag key" +# Organization Tag Error Constants +ERR_INVALID_TAG = "invalid value for tag" +ERR_REQUIRED_TAG_ID = "tag ID is required" +ERR_REQUIRED_TAG_WORKSPACE_ID = "workspace ID is required" + class WorkspaceNotFound(NotFound): ... diff --git a/src/pytfe/resources/organization_tags.py b/src/pytfe/resources/organization_tags.py index 2537c22d..a0927959 100644 --- a/src/pytfe/resources/organization_tags.py +++ b/src/pytfe/resources/organization_tags.py @@ -9,6 +9,9 @@ from ..errors import ( ERR_INVALID_ORG, + ERR_INVALID_TAG, + ERR_REQUIRED_TAG_ID, + ERR_REQUIRED_TAG_WORKSPACE_ID, ) from ..models.organization import Organization from ..models.organization_tags import ( @@ -20,10 +23,6 @@ from ..utils import valid_string_id from ._base import _Service -ERR_INVALID_TAG = "invalid value for tag" -ERR_REQUIRED_TAG_ID = "tag ID is required" -ERR_REQUIRED_TAG_WORKSPACE_ID = "workspace ID is required" - class OrganizationTags(_Service): """Organization tags service for Terraform Enterprise.""" diff --git a/tests/units/test_organization_tags.py b/tests/units/test_organization_tags.py index af5a1103..ee23affc 100644 --- a/tests/units/test_organization_tags.py +++ b/tests/units/test_organization_tags.py @@ -10,6 +10,9 @@ from pytfe._http import HTTPTransport from pytfe.errors import ( ERR_INVALID_ORG, + ERR_INVALID_TAG, + ERR_REQUIRED_TAG_ID, + ERR_REQUIRED_TAG_WORKSPACE_ID, ) from pytfe.models.organization_tags import ( AddWorkspacesToTagOptions, @@ -18,10 +21,6 @@ ) from pytfe.resources.organization_tags import OrganizationTags -ERR_INVALID_TAG = "invalid value for tag" -ERR_REQUIRED_TAG_ID = "tag ID is required" -ERR_REQUIRED_TAG_WORKSPACE_ID = "workspace ID is required" - class TestOrganizationTags: """Test the OrganizationTags service class."""