From 32a9abca1b0f3c85f0f34f9e9c255f991dd10499 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Mon, 16 Mar 2026 17:51:55 +0530 Subject: [PATCH 01/43] feat(teams): Updated and Added models for List, Create and Update options --- src/pytfe/models/team.py | 146 ++++++++++++++++++++++++++++----------- 1 file changed, 107 insertions(+), 39 deletions(-) diff --git a/src/pytfe/models/team.py b/src/pytfe/models/team.py index c19b0079..75820304 100644 --- a/src/pytfe/models/team.py +++ b/src/pytfe/models/team.py @@ -1,12 +1,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from enum import Enum -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field -if TYPE_CHECKING: - from .organization_membership import OrganizationMembership - from .user import User +from .organization_membership import OrganizationMembership +from .user import User class OrganizationAccess(BaseModel): @@ -14,21 +13,25 @@ class OrganizationAccess(BaseModel): model_config = ConfigDict(populate_by_name=True) - manage_policies: bool = False - manage_policy_overrides: bool = False - manage_workspaces: bool = False - manage_vcs_settings: bool = False - manage_providers: bool = False - manage_modules: bool = False - manage_run_tasks: bool = False - manage_projects: bool = False - read_workspaces: bool = False - read_projects: bool = False - manage_membership: bool = False - manage_teams: bool = False - manage_organization_access: bool = False - access_secret_teams: bool = False - manage_agent_pools: bool = False + manage_policies: bool = Field(default=False, alias="manage-policies") + manage_policy_overrides: bool = Field( + default=False, alias="manage-policy-overrides" + ) + manage_workspaces: bool = Field(default=False, alias="manage-workspaces") + manage_vcs_settings: bool = Field(default=False, alias="manage-vcs-settings") + manage_providers: bool = Field(default=False, alias="manage-providers") + manage_modules: bool = Field(default=False, alias="manage-modules") + manage_run_tasks: bool = Field(default=False, alias="manage-run-tasks") + manage_projects: bool = Field(default=False, alias="manage-projects") + read_workspaces: bool = Field(default=False, alias="read-workspaces") + read_projects: bool = Field(default=False, alias="read-projects") + manage_membership: bool = Field(default=False, alias="manage-membership") + manage_teams: bool = Field(default=False, alias="manage-teams") + manage_organization_access: bool = Field( + default=False, alias="manage-organization-access" + ) + access_secret_teams: bool = Field(default=False, alias="access-secret-teams") + manage_agent_pools: bool = Field(default=False, alias="manage-agent-pools") class TeamPermissions(BaseModel): @@ -36,8 +39,8 @@ class TeamPermissions(BaseModel): model_config = ConfigDict(populate_by_name=True) - can_destroy: bool = False - can_update_membership: bool = False + can_destroy: bool = Field(alias="can-destroy") + can_update_membership: bool = Field(alias="can-update-membership") class Team(BaseModel): @@ -46,27 +49,92 @@ class Team(BaseModel): model_config = ConfigDict(populate_by_name=True) id: str - name: str | None = None - is_unified: bool = False - organization_access: OrganizationAccess | None = None - visibility: str | None = None - permissions: TeamPermissions | None = None - user_count: int = 0 - sso_team_id: str | None = None - allow_member_token_management: bool = False + name: str | None = Field(default=None, alias="name") + is_unified: bool = Field(default=False, alias="is-unified") + organization_access: OrganizationAccess | None = Field( + default=None, alias="organization-access" + ) + visibility: str | None = Field(default=None, alias="visibility") + permissions: TeamPermissions | None = Field(default=None, alias="permissions") + user_count: int = Field(default=0, alias="user-count") + sso_team_id: str | None = Field(default=None, alias="sso-team-id") + # AllowMemberTokenManagement is false for TFE versions older than v202408 + allow_member_token_management: bool = Field( + default=False, alias="allow-member-token-management" + ) # Relations - users: list[User] | None = None - organization_memberships: list[OrganizationMembership] | None = None + users: list[User] = Field(alias="users", default_factory=list) + organization_memberships: list[OrganizationMembership] = Field( + alias="organization-memberships", default_factory=list + ) -def _rebuild_models() -> None: - """Rebuild models to resolve forward references.""" - from .organization import Organization # noqa: F401 - from .organization_membership import OrganizationMembership # noqa: F401 - from .user import User # noqa: F401 +class TeamIncludeOpt(str, Enum): + """TeamIncludeOpt represents the available options for include query params.""" - Team.model_rebuild() + TEAM_USERS = "users" + TEAM_ORGANIZATION_MEMBERSHIPS = "organization-memberships" -_rebuild_models() +class TeamListOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + page_size: int | None = Field(None, alias="page[size]") + include: list[TeamIncludeOpt] | None = Field(None, alias="include") + names: list[str] | None = Field(None, alias="filter[names]") + query: str | None = Field(None, alias="q") + + +class OrganizationAccessOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + manage_policies: bool | None = Field(default=False, alias="manage-policies") + manage_policy_overrides: bool | None = Field( + default=False, alias="manage-policy-overrides" + ) + manage_workspaces: bool | None = Field(default=False, alias="manage-workspaces") + manage_vcs_settings: bool | None = Field(default=False, alias="manage-vcs-settings") + manage_providers: bool | None = Field(default=False, alias="manage-providers") + manage_modules: bool | None = Field(default=False, alias="manage-modules") + manage_run_tasks: bool | None = Field(default=False, alias="manage-run-tasks") + manage_projects: bool | None = Field(default=False, alias="manage-projects") + read_workspaces: bool | None = Field(default=False, alias="read-workspaces") + read_projects: bool | None = Field(default=False, alias="read-projects") + manage_membership: bool | None = Field(default=False, alias="manage-membership") + manage_teams: bool | None = Field(default=False, alias="manage-teams") + manage_organization_access: bool | None = Field( + default=False, alias="manage-organization-access" + ) + access_secret_teams: bool | None = Field(default=False, alias="access-secret-teams") + manage_agent_pools: bool | None = Field(default=False, alias="manage-agent-pools") + + +class TeamCreateOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + type: str = "teams" + name: str = Field(alias="name") + sso_team_id: str | None = Field(default=None, alias="sso-team-id") + organization_access: OrganizationAccessOptions | None = Field( + default=None, alias="organization-access" + ) + visibility: str | None = Field(alias="visibility") + allow_member_token_management: bool | None = Field( + default=None, alias="allow-member-token-management" + ) + + +class TeamUpdateOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + type: str = "teams" + name: str | None = Field(default=None, alias="name") + sso_team_id: str | None = Field(default=None, alias="sso-team-id") + organization_access: OrganizationAccessOptions | None = Field( + default=None, alias="organization-access" + ) + visibility: str | None = Field(alias="visibility") + allow_member_token_management: bool | None = Field( + default=None, alias="allow-member-token-management" + ) From 6c9a01567b9c6e3b040be44594c46ddccb97a3bb Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Mon, 16 Mar 2026 22:49:59 +0530 Subject: [PATCH 02/43] feat(teams): Added List method to the teams resource --- examples/team.py | 116 ++++++++++++++++++++ src/pytfe/client.py | 2 + src/pytfe/errors.py | 7 ++ src/pytfe/models/__init__.py | 8 ++ src/pytfe/models/organization_membership.py | 4 +- src/pytfe/models/team.py | 19 +++- src/pytfe/resources/team.py | 56 ++++++++++ 7 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 examples/team.py create mode 100644 src/pytfe/resources/team.py diff --git a/examples/team.py b/examples/team.py new file mode 100644 index 00000000..6ecf66a6 --- /dev/null +++ b/examples/team.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import argparse +import os + +from pytfe import TFEClient, TFEConfig +from pytfe.models import TeamIncludeOpt, TeamListOptions + + +def _print_header(title: str): + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def main(): + parser = argparse.ArgumentParser(description="Teams list 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", + required=True, + help="Organization name", + ) + parser.add_argument( + "--page-size", + type=int, + default=20, + help="Page size for fetching teams", + ) + parser.add_argument( + "--query", + default=None, + help="Optional q filter for team search", + ) + parser.add_argument( + "--names", + nargs="+", + default=None, + help="Optional team names filter (space-separated)", + ) + parser.add_argument( + "--include-users", + action="store_true", + help="Include related users", + ) + parser.add_argument( + "--include-memberships", + action="store_true", + help="Include related organization-memberships", + ) + args = parser.parse_args() + + cfg = TFEConfig(address=args.address, token=args.token) + client = TFEClient(cfg) + + includes: list[TeamIncludeOpt] = [] + if args.include_users: + includes.append(TeamIncludeOpt.TEAM_USERS) + if args.include_memberships: + includes.append(TeamIncludeOpt.TEAM_ORGANIZATION_MEMBERSHIPS) + + options = TeamListOptions( + page_size=args.page_size, + query=args.query, + names=args.names, + include=includes or None, + ) + + _print_header(f"Listing teams for organization: {args.org}") + print("Options:") + print(f"- page_size={args.page_size}") + print(f"- query={args.query}") + print(f"- names={args.names}") + print(f"- include={[item.value for item in includes] if includes else None}") + print("options", options) + print() + + count = 0 + for team in client.teams.list(args.org, options): + count += 1 + print(f"[{count}] Team ID: {team.id}") + print(f"Name: {team.name}") + print(f"Visibility: {team.visibility}") + print(f"Is Unified: {team.is_unified}") + print(f"User Count: {team.user_count}") + print(f"Allow Member Token Management: {team.allow_member_token_management}") + print("team user", team.organization_memberships) + + if team.organization_access: + print("Organization Access:") + print(f" - manage_workspaces={team.organization_access.manage_workspaces}") + print(f" - read_workspaces={team.organization_access.read_workspaces}") + print(f" - manage_projects={team.organization_access.manage_projects}") + + if team.permissions: + print("Permissions:") + print(f" - can_update_membership={team.permissions.can_update_membership}") + print(f" - can_destroy={team.permissions.can_destroy}") + + print(f"Users included: {len(team.users)}") + print( + f"Organization memberships included: {len(team.organization_memberships)}" + ) + print() + + if count == 0: + print("No teams found.") + else: + print(f"Total teams: {count}") + + +if __name__ == "__main__": + main() diff --git a/src/pytfe/client.py b/src/pytfe/client.py index d1c83373..bfa938d5 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -32,6 +32,7 @@ from .resources.ssh_keys import SSHKeys from .resources.state_version_outputs import StateVersionOutputs from .resources.state_versions import StateVersions +from .resources.team import Teams from .resources.variable import Variables from .resources.variable_sets import VariableSets, VariableSetVariables from .resources.workspace_resources import WorkspaceResourcesService @@ -97,6 +98,7 @@ def __init__(self, config: TFEConfig | None = None): # SSH Keys self.ssh_keys = SSHKeys(self._transport) + self.teams = Teams(self._transport) # Reserved Tag Key self.reserved_tag_key = ReservedTagKeys(self._transport) diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index 168d37b4..e1cebbbe 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -527,3 +527,10 @@ class InvalidKeyIDError(InvalidValues): def __init__(self, message: str = "invalid value for key-id"): super().__init__(message) + + +class EmptyTeamNameError(InvalidValues): + """Raised when a team name is empty.""" + + def __init__(self, message: str = "team names cannot be empty"): + super().__init__(message) diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index 8524e6b1..c828fd76 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -294,7 +294,11 @@ from .team import ( OrganizationAccess, Team, + TeamCreateOptions, + TeamIncludeOpt, + TeamListOptions, TeamPermissions, + TeamUpdateOptions, ) # Variables @@ -489,6 +493,10 @@ "OrganizationAccess", "Team", "TeamPermissions", + "TeamCreateOptions", + "TeamIncludeOpt", + "TeamListOptions", + "TeamUpdateOptions", "Project", "ProjectAddTagBindingsOptions", "ProjectCreateOptions", diff --git a/src/pytfe/models/organization_membership.py b/src/pytfe/models/organization_membership.py index a588e9ce..3105bdaa 100644 --- a/src/pytfe/models/organization_membership.py +++ b/src/pytfe/models/organization_membership.py @@ -31,8 +31,8 @@ class OrganizationMembership(BaseModel): model_config = ConfigDict(populate_by_name=True) id: str - status: OrganizationMembershipStatus - email: str + status: OrganizationMembershipStatus | None = Field(default=None, alias="status") + email: str = Field(default="", alias="email") # Relations organization: Organization | None = None diff --git a/src/pytfe/models/team.py b/src/pytfe/models/team.py index 75820304..300921e8 100644 --- a/src/pytfe/models/team.py +++ b/src/pytfe/models/team.py @@ -2,8 +2,9 @@ from enum import Enum -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator +from ..errors import ERR_REQUIRED_NAME, EmptyTeamNameError from .organization_membership import OrganizationMembership from .user import User @@ -85,6 +86,15 @@ class TeamListOptions(BaseModel): names: list[str] | None = Field(None, alias="filter[names]") query: str | None = Field(None, alias="q") + @model_validator(mode="after") + def valid(self) -> TeamListOptions: + """Validate the options.""" + + if self.names is not None and any(not name for name in self.names): + raise EmptyTeamNameError() + + return self + class OrganizationAccessOptions(BaseModel): model_config = ConfigDict(populate_by_name=True) @@ -124,6 +134,13 @@ class TeamCreateOptions(BaseModel): default=None, alias="allow-member-token-management" ) + @model_validator(mode="after") + def valid(self) -> TeamCreateOptions: + """Validate the options.""" + if not self.name: + raise ValueError(ERR_REQUIRED_NAME) + return self + class TeamUpdateOptions(BaseModel): model_config = ConfigDict(populate_by_name=True) diff --git a/src/pytfe/resources/team.py b/src/pytfe/resources/team.py new file mode 100644 index 00000000..8188c4e6 --- /dev/null +++ b/src/pytfe/resources/team.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from collections.abc import Iterator + +from ..errors import ( + ERR_INVALID_ORG, +) +from ..models.organization_membership import OrganizationMembership +from ..models.team import ( + Team, + TeamListOptions, +) +from ..models.user import User +from ..utils import valid_string_id +from ._base import _Service + + +class Teams(_Service): + def list( + self, organization: str, options: TeamListOptions | None = None + ) -> Iterator[Team]: + """List all teams in the given organization.""" + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + params = ( + options.model_dump(by_alias=True, exclude_none=True, exclude={"include"}) + if options + else {} + ) + if options and options.include: + params["include"] = ",".join([opt.value for opt in options.include]) + path = f"/api/v2/organizations/{organization}/teams" + for item in self._list(path, params=params): + yield self._team_from(item) + + def _team_from(self, data: dict) -> Team: + attrs = data.get("attributes", {}) + attrs["id"] = data.get("id") + + relationships = data.get("relationships", {}) + + users_data = relationships.get("users", {}).get("data", []) + attrs["users"] = [ + User.model_validate({"id": user_data.get("id")}) + for user_data in users_data + if user_data.get("id") + ] + attrs["organization-memberships"] = [ + OrganizationMembership.model_validate({"id": om_data.get("id")}) + for om_data in relationships.get("organization-memberships", {}).get( + "data", [] + ) + if om_data.get("id") + ] + + return Team.model_validate(attrs) From 308139b135a849b19a4ad755207dd0133eb646ee Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Tue, 17 Mar 2026 12:53:01 +0530 Subject: [PATCH 03/43] feat(teams): Added create method for team resources --- examples/team.py | 51 ++++++++++++++++++++++++++++++++++--- src/pytfe/models/team.py | 1 - src/pytfe/resources/team.py | 16 ++++++++++++ 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/examples/team.py b/examples/team.py index 6ecf66a6..6045ccac 100644 --- a/examples/team.py +++ b/examples/team.py @@ -4,7 +4,7 @@ import os from pytfe import TFEClient, TFEConfig -from pytfe.models import TeamIncludeOpt, TeamListOptions +from pytfe.models import TeamCreateOptions, TeamIncludeOpt, TeamListOptions def _print_header(title: str): @@ -51,11 +51,58 @@ def main(): action="store_true", help="Include related organization-memberships", ) + parser.add_argument( + "--create", + action="store_true", + help="Create a new team before listing", + ) + parser.add_argument( + "--name", + default=None, + help="Team name for create operation", + ) + parser.add_argument( + "--visibility", + default="secret", + help="Team visibility for create operation (secret or organization)", + ) + parser.add_argument( + "--sso-team-id", + default=None, + help="Optional SSO team ID for create operation", + ) + parser.add_argument( + "--allow-member-token-management", + action="store_true", + help="Enable member token management on create", + ) args = parser.parse_args() cfg = TFEConfig(address=args.address, token=args.token) client = TFEClient(cfg) + if args.create: + if not args.name: + print("Error: --name is required when using --create") + return + + _print_header(f"Creating team in organization: {args.org}") + create_options = TeamCreateOptions( + name=args.name, + visibility=args.visibility, + sso_team_id=args.sso_team_id, + allow_member_token_management=args.allow_member_token_management, + ) + print("Create options:", create_options) + new_team = client.teams.create(args.org, create_options) + print(f"Created Team ID: {new_team.id}") + print(f"Name: {new_team.name}") + print(f"Visibility: {new_team.visibility}") + print( + f"Allow Member Token Management: {new_team.allow_member_token_management}" + ) + print() + includes: list[TeamIncludeOpt] = [] if args.include_users: includes.append(TeamIncludeOpt.TEAM_USERS) @@ -75,7 +122,6 @@ def main(): print(f"- query={args.query}") print(f"- names={args.names}") print(f"- include={[item.value for item in includes] if includes else None}") - print("options", options) print() count = 0 @@ -87,7 +133,6 @@ def main(): print(f"Is Unified: {team.is_unified}") print(f"User Count: {team.user_count}") print(f"Allow Member Token Management: {team.allow_member_token_management}") - print("team user", team.organization_memberships) if team.organization_access: print("Organization Access:") diff --git a/src/pytfe/models/team.py b/src/pytfe/models/team.py index 300921e8..f136e436 100644 --- a/src/pytfe/models/team.py +++ b/src/pytfe/models/team.py @@ -123,7 +123,6 @@ class OrganizationAccessOptions(BaseModel): class TeamCreateOptions(BaseModel): model_config = ConfigDict(populate_by_name=True) - type: str = "teams" name: str = Field(alias="name") sso_team_id: str | None = Field(default=None, alias="sso-team-id") organization_access: OrganizationAccessOptions | None = Field( diff --git a/src/pytfe/resources/team.py b/src/pytfe/resources/team.py index 8188c4e6..ba2c7fcb 100644 --- a/src/pytfe/resources/team.py +++ b/src/pytfe/resources/team.py @@ -8,6 +8,7 @@ from ..models.organization_membership import OrganizationMembership from ..models.team import ( Team, + TeamCreateOptions, TeamListOptions, ) from ..models.user import User @@ -54,3 +55,18 @@ def _team_from(self, data: dict) -> Team: ] return Team.model_validate(attrs) + + def create(self, organization: str, options: TeamCreateOptions) -> Team: + """Create a new team in the given organization.""" + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + attributes = options.model_dump(by_alias=True, exclude_none=True) + payload = {"data": {"attributes": attributes, "type": "teams"}} + print(f"Creating team with payload: {payload}") + r = self.t.request( + "POST", + f"/api/v2/organizations/{organization}/teams", + json_body=payload, + ) + data = r.json().get("data", {}) + return self._team_from(data) From f8dda5e3f5ffa930febd9be4314806f988f64f10 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Tue, 17 Mar 2026 13:04:36 +0530 Subject: [PATCH 04/43] feat(teams): Added update method for the team resource --- examples/team.py | 41 ++++++++++++++++++++++++++++++++++--- src/pytfe/errors.py | 8 ++++++++ src/pytfe/models/team.py | 1 - src/pytfe/resources/team.py | 19 +++++++++++++++-- 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/examples/team.py b/examples/team.py index 6045ccac..f719af3a 100644 --- a/examples/team.py +++ b/examples/team.py @@ -4,7 +4,12 @@ import os from pytfe import TFEClient, TFEConfig -from pytfe.models import TeamCreateOptions, TeamIncludeOpt, TeamListOptions +from pytfe.models import ( + TeamCreateOptions, + TeamIncludeOpt, + TeamListOptions, + TeamUpdateOptions, +) def _print_header(title: str): @@ -74,7 +79,17 @@ def main(): parser.add_argument( "--allow-member-token-management", action="store_true", - help="Enable member token management on create", + help="Enable member token management on create/update", + ) + parser.add_argument( + "--update", + action="store_true", + help="Update a team before listing", + ) + parser.add_argument( + "--team-id", + default=None, + help="Team ID for update operation", ) args = parser.parse_args() @@ -93,7 +108,6 @@ def main(): sso_team_id=args.sso_team_id, allow_member_token_management=args.allow_member_token_management, ) - print("Create options:", create_options) new_team = client.teams.create(args.org, create_options) print(f"Created Team ID: {new_team.id}") print(f"Name: {new_team.name}") @@ -103,6 +117,27 @@ def main(): ) print() + if args.update: + if not args.team_id: + print("Error: --team-id is required when using --update") + return + + _print_header(f"Updating team: {args.team_id}") + update_options = TeamUpdateOptions( + name=args.name, + visibility=args.visibility, + sso_team_id=args.sso_team_id, + allow_member_token_management=args.allow_member_token_management, + ) + updated_team = client.teams.update(args.team_id, update_options) + print(f"Updated Team ID: {updated_team.id}") + print(f"Name: {updated_team.name}") + print(f"Visibility: {updated_team.visibility}") + print( + f"Allow Member Token Management: {updated_team.allow_member_token_management}" + ) + print() + includes: list[TeamIncludeOpt] = [] if args.include_users: includes.append(TeamIncludeOpt.TEAM_USERS) diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index e1cebbbe..7a52768c 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -529,8 +529,16 @@ def __init__(self, message: str = "invalid value for key-id"): super().__init__(message) +# Team errors class EmptyTeamNameError(InvalidValues): """Raised when a team name is empty.""" def __init__(self, message: str = "team names cannot be empty"): super().__init__(message) + + +class InvalidTeamIDError(InvalidValues): + """Raised when an invalid team ID is provided.""" + + def __init__(self, message: str = "invalid value for team ID"): + super().__init__(message) diff --git a/src/pytfe/models/team.py b/src/pytfe/models/team.py index f136e436..769ba975 100644 --- a/src/pytfe/models/team.py +++ b/src/pytfe/models/team.py @@ -144,7 +144,6 @@ def valid(self) -> TeamCreateOptions: class TeamUpdateOptions(BaseModel): model_config = ConfigDict(populate_by_name=True) - type: str = "teams" name: str | None = Field(default=None, alias="name") sso_team_id: str | None = Field(default=None, alias="sso-team-id") organization_access: OrganizationAccessOptions | None = Field( diff --git a/src/pytfe/resources/team.py b/src/pytfe/resources/team.py index ba2c7fcb..a1eb0ce8 100644 --- a/src/pytfe/resources/team.py +++ b/src/pytfe/resources/team.py @@ -4,12 +4,14 @@ from ..errors import ( ERR_INVALID_ORG, + InvalidTeamIDError, ) from ..models.organization_membership import OrganizationMembership from ..models.team import ( Team, TeamCreateOptions, TeamListOptions, + TeamUpdateOptions, ) from ..models.user import User from ..utils import valid_string_id @@ -62,10 +64,23 @@ def create(self, organization: str, options: TeamCreateOptions) -> Team: raise ValueError(ERR_INVALID_ORG) attributes = options.model_dump(by_alias=True, exclude_none=True) payload = {"data": {"attributes": attributes, "type": "teams"}} - print(f"Creating team with payload: {payload}") r = self.t.request( "POST", - f"/api/v2/organizations/{organization}/teams", + path=f"/api/v2/organizations/{organization}/teams", + json_body=payload, + ) + data = r.json().get("data", {}) + return self._team_from(data) + + def update(self, team_id: str, options: TeamUpdateOptions) -> Team: + """Update a team by its ID.""" + if not valid_string_id(team_id): + raise InvalidTeamIDError() + attributes = options.model_dump(by_alias=True, exclude_none=True) + payload = {"data": {"attributes": attributes, "type": "teams"}} + r = self.t.request( + "PATCH", + path=f"/api/v2/teams/{team_id}", json_body=payload, ) data = r.json().get("data", {}) From cf818e89fb4e179494addfca2885716f4104ba35 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Tue, 17 Mar 2026 13:07:25 +0530 Subject: [PATCH 05/43] feat(teams): Added read method for team resource --- examples/team.py | 38 ++++++++++++++++++++++++++++++++++++- src/pytfe/resources/team.py | 11 +++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/examples/team.py b/examples/team.py index f719af3a..b5d267bf 100644 --- a/examples/team.py +++ b/examples/team.py @@ -86,10 +86,15 @@ def main(): action="store_true", help="Update a team before listing", ) + parser.add_argument( + "--read", + action="store_true", + help="Read a team by ID before listing", + ) parser.add_argument( "--team-id", default=None, - help="Team ID for update operation", + help="Team ID for read/update operation", ) args = parser.parse_args() @@ -138,6 +143,37 @@ def main(): ) print() + if args.read: + if not args.team_id: + print("Error: --team-id is required when using --read") + return + + _print_header(f"Reading team: {args.team_id}") + team = client.teams.read(args.team_id) + print(f"Team ID: {team.id}") + print(f"Name: {team.name}") + print(f"Visibility: {team.visibility}") + print(f"Is Unified: {team.is_unified}") + print(f"User Count: {team.user_count}") + print(f"Allow Member Token Management: {team.allow_member_token_management}") + + if team.organization_access: + print("Organization Access:") + print(f" - manage_workspaces={team.organization_access.manage_workspaces}") + print(f" - read_workspaces={team.organization_access.read_workspaces}") + print(f" - manage_projects={team.organization_access.manage_projects}") + + if team.permissions: + print("Permissions:") + print(f" - can_update_membership={team.permissions.can_update_membership}") + print(f" - can_destroy={team.permissions.can_destroy}") + + print(f"Users included: {len(team.users)}") + print( + f"Organization memberships included: {len(team.organization_memberships)}" + ) + print() + includes: list[TeamIncludeOpt] = [] if args.include_users: includes.append(TeamIncludeOpt.TEAM_USERS) diff --git a/src/pytfe/resources/team.py b/src/pytfe/resources/team.py index a1eb0ce8..5e6a2c20 100644 --- a/src/pytfe/resources/team.py +++ b/src/pytfe/resources/team.py @@ -85,3 +85,14 @@ def update(self, team_id: str, options: TeamUpdateOptions) -> Team: ) data = r.json().get("data", {}) return self._team_from(data) + + def read(self, team_id: str) -> Team: + """Read a single team by its ID.""" + if not valid_string_id(team_id): + raise InvalidTeamIDError() + r = self.t.request( + "GET", + path=f"/api/v2/teams/{team_id}", + ) + data = r.json().get("data", {}) + return self._team_from(data) From efa5203cff6f533ded6dbc29301481d6d4e57ac6 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Tue, 17 Mar 2026 13:11:13 +0530 Subject: [PATCH 06/43] feat(teams): Added delete method for team resource --- examples/team.py | 17 ++++++++++++++++- src/pytfe/resources/team.py | 12 +++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/examples/team.py b/examples/team.py index b5d267bf..5615a8b8 100644 --- a/examples/team.py +++ b/examples/team.py @@ -91,10 +91,15 @@ def main(): action="store_true", help="Read a team by ID before listing", ) + parser.add_argument( + "--delete", + action="store_true", + help="Delete a team by ID before listing", + ) parser.add_argument( "--team-id", default=None, - help="Team ID for read/update operation", + help="Team ID for read/update/delete operation", ) args = parser.parse_args() @@ -174,6 +179,16 @@ def main(): ) print() + if args.delete: + if not args.team_id: + print("Error: --team-id is required when using --delete") + return + + _print_header(f"Deleting team: {args.team_id}") + client.teams.delete(args.team_id) + print(f"Deleted Team ID: {args.team_id}") + print() + includes: list[TeamIncludeOpt] = [] if args.include_users: includes.append(TeamIncludeOpt.TEAM_USERS) diff --git a/src/pytfe/resources/team.py b/src/pytfe/resources/team.py index 5e6a2c20..37df9876 100644 --- a/src/pytfe/resources/team.py +++ b/src/pytfe/resources/team.py @@ -85,7 +85,7 @@ def update(self, team_id: str, options: TeamUpdateOptions) -> Team: ) data = r.json().get("data", {}) return self._team_from(data) - + def read(self, team_id: str) -> Team: """Read a single team by its ID.""" if not valid_string_id(team_id): @@ -96,3 +96,13 @@ def read(self, team_id: str) -> Team: ) data = r.json().get("data", {}) return self._team_from(data) + + def delete(self, team_id: str) -> None: + """Delete a team by its ID.""" + if not valid_string_id(team_id): + raise InvalidTeamIDError() + self.t.request( + "DELETE", + path=f"/api/v2/teams/{team_id}", + ) + return None From 72bb7612dbee79c6370846be19382cde32aa9386 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Tue, 17 Mar 2026 13:24:45 +0530 Subject: [PATCH 07/43] feat(teams): Added unit test cases for teams resource --- tests/units/test_team.py | 265 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 tests/units/test_team.py diff --git a/tests/units/test_team.py b/tests/units/test_team.py new file mode 100644 index 00000000..cd38ab0b --- /dev/null +++ b/tests/units/test_team.py @@ -0,0 +1,265 @@ +"""Unit tests for the team resource.""" + +from unittest.mock import Mock, patch + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import ERR_INVALID_ORG, InvalidTeamIDError +from pytfe.models import ( + Team, + TeamCreateOptions, + TeamIncludeOpt, + TeamListOptions, + TeamUpdateOptions, +) +from pytfe.resources.team import Teams + + +class TestTeams: + """Test the Teams service class.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock HTTPTransport.""" + return Mock(spec=HTTPTransport) + + @pytest.fixture + def teams_service(self, mock_transport): + """Create a Teams service with mocked transport.""" + return Teams(mock_transport) + + def test_list_teams_validations(self, teams_service): + """Test list method with invalid organization values.""" + + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + list(teams_service.list("")) + + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + list(teams_service.list(None)) + + def test_list_teams_success_without_options(self, teams_service): + """Test successful list operation without options.""" + + mock_data = [ + { + "id": "team-123", + "attributes": { + "name": "owners", + "visibility": "organization", + "is-unified": False, + "user-count": 2, + "allow-member-token-management": False, + }, + "relationships": {}, + } + ] + + with patch.object(teams_service, "_list") as mock_list: + mock_list.return_value = iter(mock_data) + + result = list(teams_service.list("my-org")) + + mock_list.assert_called_once_with( + "/api/v2/organizations/my-org/teams", params={} + ) + + assert len(result) == 1 + assert isinstance(result[0], Team) + assert result[0].id == "team-123" + assert result[0].name == "owners" + assert result[0].visibility == "organization" + assert result[0].user_count == 2 + + def test_list_teams_with_options(self, teams_service): + """Test successful list operation with list options.""" + + with patch.object(teams_service, "_list") as mock_list: + mock_list.return_value = iter([]) + + options = TeamListOptions( + page_size=10, + query="owner", + names=["owners", "admins"], + include=[ + TeamIncludeOpt.TEAM_USERS, + TeamIncludeOpt.TEAM_ORGANIZATION_MEMBERSHIPS, + ], + ) + + result = list(teams_service.list("my-org", options)) + + mock_list.assert_called_once_with( + "/api/v2/organizations/my-org/teams", + params={ + "page[size]": 10, + "q": "owner", + "filter[names]": ["owners", "admins"], + "include": "users,organization-memberships", + }, + ) + assert len(result) == 0 + + def test_create_team_validations(self, teams_service): + """Test create method validations.""" + + options = TeamCreateOptions(name="platform", visibility="organization") + + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + teams_service.create("", options) + + def test_create_team_success(self, teams_service, mock_transport): + """Test successful create operation.""" + + mock_response_data = { + "data": { + "id": "team-456", + "attributes": { + "name": "platform", + "visibility": "organization", + "is-unified": False, + "user-count": 0, + "allow-member-token-management": True, + }, + "relationships": {}, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + mock_transport.request.return_value = mock_response + + options = TeamCreateOptions( + name="platform", + visibility="organization", + allow_member_token_management=True, + ) + + result = teams_service.create("my-org", options) + + mock_transport.request.assert_called_once_with( + "POST", + path="/api/v2/organizations/my-org/teams", + json_body={ + "data": { + "attributes": { + "name": "platform", + "visibility": "organization", + "allow-member-token-management": True, + }, + "type": "teams", + } + }, + ) + + assert isinstance(result, Team) + assert result.id == "team-456" + assert result.name == "platform" + assert result.visibility == "organization" + + def test_update_team_validations(self, teams_service): + """Test update method validations.""" + + options = TeamUpdateOptions(name="new-name", visibility="organization") + + with pytest.raises(InvalidTeamIDError): + teams_service.update("", options) + + def test_update_team_success(self, teams_service, mock_transport): + """Test successful update operation.""" + + mock_response_data = { + "data": { + "id": "team-789", + "attributes": { + "name": "platform-admins", + "visibility": "secret", + "is-unified": False, + "user-count": 1, + "allow-member-token-management": False, + }, + "relationships": {}, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + mock_transport.request.return_value = mock_response + + options = TeamUpdateOptions(name="platform-admins", visibility="secret") + + result = teams_service.update("team-789", options) + + mock_transport.request.assert_called_once_with( + "PATCH", + path="/api/v2/teams/team-789", + json_body={ + "data": { + "attributes": { + "name": "platform-admins", + "visibility": "secret", + }, + "type": "teams", + } + }, + ) + + assert isinstance(result, Team) + assert result.id == "team-789" + assert result.name == "platform-admins" + assert result.visibility == "secret" + + def test_read_team_validations(self, teams_service): + """Test read method validations.""" + + with pytest.raises(InvalidTeamIDError): + teams_service.read("") + + def test_read_team_success(self, teams_service, mock_transport): + """Test successful read operation.""" + + mock_response_data = { + "data": { + "id": "team-789", + "attributes": { + "name": "platform-admins", + "visibility": "secret", + "is-unified": False, + "user-count": 1, + "allow-member-token-management": False, + }, + "relationships": {}, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + mock_transport.request.return_value = mock_response + + result = teams_service.read("team-789") + + mock_transport.request.assert_called_once_with( + "GET", + path="/api/v2/teams/team-789", + ) + + assert isinstance(result, Team) + assert result.id == "team-789" + assert result.name == "platform-admins" + + def test_delete_team_validations(self, teams_service): + """Test delete method validations.""" + + with pytest.raises(InvalidTeamIDError): + teams_service.delete("") + + def test_delete_team_success(self, teams_service, mock_transport): + """Test successful delete operation.""" + + result = teams_service.delete("team-789") + + mock_transport.request.assert_called_once_with( + "DELETE", + path="/api/v2/teams/team-789", + ) + assert result is None From b811f60ee514db8316a2a368aa759bc5c031dd60 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Tue, 17 Mar 2026 16:03:28 +0530 Subject: [PATCH 08/43] feat(team-project-access): Added models for the team-project-access --- src/pytfe/errors.py | 15 ++ src/pytfe/models/team_project_access.py | 199 ++++++++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 src/pytfe/models/team_project_access.py diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index e913f6d4..8c69a607 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -530,3 +530,18 @@ class InvalidKeyIDError(InvalidValues): def __init__(self, message: str = "invalid value for key-id"): super().__init__(message) + + +# Team Project Access errors +class InvalidProjectIDError(InvalidValues): + """Raised when an invalid project ID is provided.""" + + def __init__(self, message: str = "invalid value for project ID"): + super().__init__(message) + + +class RequiredTeamError(RequiredFieldMissing): + """Raised when a required team field is missing.""" + + def __init__(self, message: str = "team is required"): + super().__init__(message) diff --git a/src/pytfe/models/team_project_access.py b/src/pytfe/models/team_project_access.py new file mode 100644 index 00000000..82225a67 --- /dev/null +++ b/src/pytfe/models/team_project_access.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from ..errors import ERR_REQUIRED_PROJECT, InvalidProjectIDError, RequiredTeamError +from ..utils import valid_string_id +from .project import Project +from .team import Team + + +class TeamProjectAccessType(str, Enum): + """TeamProjectAccessType represents a team project access type.""" + + TEAM_PROJECT_ACCESS_ADMIN = "admin" + TEAM_PROJECT_ACCESS_MAINTAIN = "maintain" + TEAM_PROJECT_ACCESS_WRITE = "write" + TEAM_PROJECT_ACCESS_READ = "read" + TEAM_PROJECT_ACCESS_CUSTOM = "custom" + + +class ProjectSettingsPermissionType(str, Enum): + """ProjectSettingsPermissionType represents the permissiontype to a project's settings""" + + PROJECT_SETTINGS_PERMISSION_READ = "read" + PROJECT_SETTINGS_PERMISSION_UPDATE = "update" + PROJECT_SETTINGS_PERMISSION_DELETE = "delete" + + +class ProjectTeamsPermissionType(str, Enum): + """ProjectTeamsPermissionType represents the permissiontype to a project's teams""" + + PROJECT_TEAMS_PERMISSION_READ = "read" + PROJECT_TEAMS_PERMISSION_NONE = "none" + PROJECT_TEAMS_PERMISSION_MANAGE = "manage" + + +class ProjectVariableSetsPermissionType(str, Enum): + """ProjectVariableSetsPermissionType represents the permissiontype to a project's variable sets""" + + PROJECT_VARIABLE_SETS_PERMISSION_READ = "read" + PROJECT_VARIABLE_SETS_PERMISSION_WRITE = "write" + PROJECT_VARIABLE_SETS_PERMISSION_NONE = "none" + + +class TeamProjectAccessProjectPermissions(BaseModel): + """ProjectPermissions represents the team's permissions on its project""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + project_settings_permission: ProjectSettingsPermissionType = Field(alias="settings") + project_teams_permission: ProjectTeamsPermissionType = Field(alias="teams") + # ProjectVariableSetsPermission represents read, manage, and no access custom permission for project-level variable sets + project_variable_sets_permission: ProjectVariableSetsPermissionType = Field( + alias="variable-sets" + ) + + +class WorkspaceRunsPermissionType(str, Enum): + """WorkspaceRunsPermissionType represents the permissiontype to project workspaces' runs""" + + WORKSPACE_RUNS_PERMISSION_READ = "read" + WORKSPACE_RUNS_PERMISSION_PLAN = "plan" + WORKSPACE_RUNS_PERMISSION_APPLY = "apply" + + +class WorkspaceSentinelMocksPermissionType(str, Enum): + """WorkspaceSentinelMocksPermissionType represents the permissiontype to project workspaces' sentinel-mocks""" + + WORKSPACE_SENTINEL_MOCKS_PERMISSION_READ = "read" + WORKSPACE_SENTINEL_MOCKS_PERMISSION_NONE = "none" + + +class WorkspaceStateVersionsPermissionType(str, Enum): + """WorkspaceStateVersionsPermissionType represents the permissiontype to project workspaces' state-versions""" + + WORKSPACE_STATE_VERSIONS_PERMISSION_NONE = "none" + WORKSPACE_STATE_VERSIONS_PERMISSION_READ_OUTPUTS = "read-outputs" + WORKSPACE_STATE_VERSIONS_PERMISSION_WRITE = "write" + + +class WorkspaceVariablesPermissionType(str, Enum): + """WorkspaceVariablesPermissionType represents the permissiontype to project workspaces' variables""" + + WORKSPACE_VARIABLES_PERMISSION_NONE = "none" + WORKSPACE_VARIABLES_PERMISSION_READ = "read" + WORKSPACE_VARIABLES_PERMISSION_WRITE = "write" + + +class TeamProjectAccessWorkspacePermissions(BaseModel): + """WorkspacePermissions represents the team's permission on all workspaces in its project""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + runs: WorkspaceRunsPermissionType | None = Field(default=None, alias="runs") + sentinel_mocks: WorkspaceSentinelMocksPermissionType | None = Field( + default=None, alias="sentinel-mocks" + ) + state_versions: WorkspaceStateVersionsPermissionType | None = Field( + default=None, alias="state-versions" + ) + variables: WorkspaceVariablesPermissionType | None = Field( + default=None, alias="variables" + ) + create: bool = Field(default=False, alias="create") + delete: bool = Field(default=False, alias="delete") + locking: bool = Field(default=False, alias="locking") + move: bool = Field(default=False, alias="move") + run_tasks: bool = Field(default=False, alias="run-tasks") + + +class TeamProjectAccess(BaseModel): + """TeamProjectAccess represents a project access for a team""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str + access: TeamProjectAccessType | None = Field(default=None, alias="access") + project_access: TeamProjectAccessProjectPermissions | None = Field( + default=None, alias="project-access" + ) + workspace_access: TeamProjectAccessWorkspacePermissions | None = Field( + default=None, alias="workspace-access" + ) + + # relations + project: Project | None = Field(default=None, alias="project") + team: Team | None = Field(default=None, alias="team") + + +class TeamProjectAccessListOptions(BaseModel): + """TeamProjectAccessListOptions represents the options for listing team project accesses""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + page_size: int | None = Field(default=None, alias="page[size]") + Project_id: str | None = Field(default=None, alias="filter[project][id]") + + @model_validator(mode="after") + def valid(self) -> TeamProjectAccessListOptions: + """Validate the options.""" + if self.Project_id is not None and not valid_string_id(self.Project_id): + raise InvalidProjectIDError() + return self + + +class TeamProjectAccessProjectPermissionsOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + settings: ProjectSettingsPermissionType | None = Field( + default=None, alias="settings" + ) + teams: ProjectTeamsPermissionType | None = Field(default=None, alias="teams") + variable_sets: ProjectVariableSetsPermissionType | None = Field( + default=None, alias="variable-sets" + ) + + +class TeamProjectAccessAddOptions(BaseModel): + """TeamProjectAccessAddOptions represents the options for adding team access for a project""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + access: TeamProjectAccessType = Field(alias="access") + project_access: TeamProjectAccessProjectPermissionsOptions | None = Field( + default=None, alias="project-access" + ) + workspace_access: TeamProjectAccessWorkspacePermissions | None = Field( + default=None, alias="workspace-access" + ) + + # relations + team: Team | None = Field(default=None, alias="team") + project: Project | None = Field(default=None, alias="project") + + @model_validator(mode="after") + def valid(self) -> TeamProjectAccessAddOptions: + """Validate the options.""" + + if self.team is None: + raise RequiredTeamError() + if self.project is None: + raise ValueError(ERR_REQUIRED_PROJECT) + return self + + +class TeamProjectAccessUpdateOptions(BaseModel): + """TeamProjectAccessUpdateOptions represents the options for updating a team project access""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + access: TeamProjectAccessType | None = Field(default=None, alias="access") + project_access: TeamProjectAccessProjectPermissionsOptions | None = Field( + default=None, alias="project-access" + ) + workspace_access: TeamProjectAccessWorkspacePermissions | None = Field( + default=None, alias="workspace-access" + ) From cd218a32bce1ca5dd9c8ffafbdb6dbc4897f3efb Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Fri, 20 Mar 2026 11:26:24 +0530 Subject: [PATCH 09/43] feat(team-project-access): Added resource and examles file --- examples/team_project_access.py | 215 +++++++++++++++++++++ src/pytfe/client.py | 3 + src/pytfe/resources/team_project_access.py | 107 ++++++++++ 3 files changed, 325 insertions(+) create mode 100644 examples/team_project_access.py create mode 100644 src/pytfe/resources/team_project_access.py diff --git a/examples/team_project_access.py b/examples/team_project_access.py new file mode 100644 index 00000000..6ed8af45 --- /dev/null +++ b/examples/team_project_access.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +import argparse +import os + +from pytfe import TFEClient, TFEConfig +from pytfe.models.project import Project +from pytfe.models.team import Team +from pytfe.models.team_project_access import ( + ProjectSettingsPermissionType, + ProjectTeamsPermissionType, + ProjectVariableSetsPermissionType, + TeamProjectAccessAddOptions, + TeamProjectAccessProjectPermissionsOptions, + TeamProjectAccessType, + TeamProjectAccessWorkspacePermissions, + WorkspaceRunsPermissionType, + WorkspaceSentinelMocksPermissionType, + WorkspaceStateVersionsPermissionType, + WorkspaceVariablesPermissionType, +) + + +def _print_header(title: str): + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def main(): + parser = argparse.ArgumentParser( + description="Team Project Access add 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("--team-id", required=True, help="Team ID") + parser.add_argument("--project-id", required=True, help="Project ID") + parser.add_argument( + "--access", + choices=[item.value for item in TeamProjectAccessType], + default=TeamProjectAccessType.TEAM_PROJECT_ACCESS_READ.value, + help="Access level", + ) + + # Optional custom project permissions + parser.add_argument( + "--project-settings", + choices=[item.value for item in ProjectSettingsPermissionType], + default=None, + help="Project settings permission (custom access)", + ) + parser.add_argument( + "--project-teams", + choices=[item.value for item in ProjectTeamsPermissionType], + default=None, + help="Project teams permission (custom access)", + ) + parser.add_argument( + "--project-variable-sets", + choices=[item.value for item in ProjectVariableSetsPermissionType], + default=None, + help="Project variable sets permission (custom access)", + ) + + # Optional custom workspace permissions + parser.add_argument( + "--workspace-runs", + choices=[item.value for item in WorkspaceRunsPermissionType], + default=None, + help="Workspace runs permission (custom access)", + ) + parser.add_argument( + "--workspace-sentinel-mocks", + choices=[item.value for item in WorkspaceSentinelMocksPermissionType], + default=None, + help="Workspace sentinel-mocks permission (custom access)", + ) + parser.add_argument( + "--workspace-state-versions", + choices=[item.value for item in WorkspaceStateVersionsPermissionType], + default=None, + help="Workspace state-versions permission (custom access)", + ) + parser.add_argument( + "--workspace-variables", + choices=[item.value for item in WorkspaceVariablesPermissionType], + default=None, + help="Workspace variables permission (custom access)", + ) + parser.add_argument("--workspace-create", action="store_true") + parser.add_argument("--workspace-delete", action="store_true") + parser.add_argument("--workspace-locking", action="store_true") + parser.add_argument("--workspace-move", action="store_true") + parser.add_argument("--workspace-run-tasks", action="store_true") + + args = parser.parse_args() + + cfg = TFEConfig(address=args.address, token=args.token) + client = TFEClient(cfg) + + project_access = None + if any([args.project_settings, args.project_teams, args.project_variable_sets]): + project_access = TeamProjectAccessProjectPermissionsOptions( + settings=( + ProjectSettingsPermissionType(args.project_settings) + if args.project_settings + else None + ), + teams=( + ProjectTeamsPermissionType(args.project_teams) + if args.project_teams + else None + ), + variable_sets=( + ProjectVariableSetsPermissionType(args.project_variable_sets) + if args.project_variable_sets + else None + ), + ) + + workspace_access = None + if any( + [ + args.workspace_runs, + args.workspace_sentinel_mocks, + args.workspace_state_versions, + args.workspace_variables, + args.workspace_create, + args.workspace_delete, + args.workspace_locking, + args.workspace_move, + args.workspace_run_tasks, + ] + ): + workspace_access = TeamProjectAccessWorkspacePermissions( + runs=( + WorkspaceRunsPermissionType(args.workspace_runs) + if args.workspace_runs + else None + ), + sentinel_mocks=( + WorkspaceSentinelMocksPermissionType(args.workspace_sentinel_mocks) + if args.workspace_sentinel_mocks + else None + ), + state_versions=( + WorkspaceStateVersionsPermissionType(args.workspace_state_versions) + if args.workspace_state_versions + else None + ), + variables=( + WorkspaceVariablesPermissionType(args.workspace_variables) + if args.workspace_variables + else None + ), + create=args.workspace_create, + delete=args.workspace_delete, + locking=args.workspace_locking, + move=args.workspace_move, + run_tasks=args.workspace_run_tasks, + ) + + _print_header("Adding team project access") + options = TeamProjectAccessAddOptions( + access=TeamProjectAccessType(args.access), + team=Team(id=args.team_id), + project=Project(id=args.project_id), + project_access=project_access, + workspace_access=workspace_access, + ) + + result = client.team_project_accesses.add(options) + + print("Created team project access") + print(f"- id: {result.id}") + print(f"- access: {result.access.value if result.access else None}") + print(f"- team_id: {result.team.id if result.team else None}") + print(f"- project_id: {result.project.id if result.project else None}") + + if result.project_access: + print("- project_access:") + print(f" settings={result.project_access.project_settings_permission.value}") + print(f" teams={result.project_access.project_teams_permission.value}") + print( + " variable_sets=" + f"{result.project_access.project_variable_sets_permission.value}" + ) + + if result.workspace_access: + print("- workspace_access:") + print( + f" runs={result.workspace_access.runs.value if result.workspace_access.runs else None}" + ) + print( + " sentinel_mocks=" + f"{result.workspace_access.sentinel_mocks.value if result.workspace_access.sentinel_mocks else None}" + ) + print( + " state_versions=" + f"{result.workspace_access.state_versions.value if result.workspace_access.state_versions else None}" + ) + print( + f" variables={result.workspace_access.variables.value if result.workspace_access.variables else None}" + ) + print(f" create={result.workspace_access.create}") + print(f" delete={result.workspace_access.delete}") + print(f" locking={result.workspace_access.locking}") + print(f" move={result.workspace_access.move}") + print(f" run_tasks={result.workspace_access.run_tasks}") + + +if __name__ == "__main__": + main() diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 30b506b9..a22402a7 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -35,6 +35,7 @@ from .resources.ssh_keys import SSHKeys from .resources.state_version_outputs import StateVersionOutputs from .resources.state_versions import StateVersions +from .resources.team_project_access import TeamProjectAccesses from .resources.variable import Variables from .resources.variable_sets import VariableSets, VariableSetVariables from .resources.workspace_resources import WorkspaceResourcesService @@ -101,6 +102,8 @@ def __init__(self, config: TFEConfig | None = None): # SSH Keys self.ssh_keys = SSHKeys(self._transport) + # Team project access + self.team_project_accesses = TeamProjectAccesses(self._transport) # Reserved Tag Key self.reserved_tag_key = ReservedTagKeys(self._transport) diff --git a/src/pytfe/resources/team_project_access.py b/src/pytfe/resources/team_project_access.py new file mode 100644 index 00000000..dd3217f5 --- /dev/null +++ b/src/pytfe/resources/team_project_access.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from ..models.project import Project +from ..models.team import Team +from ..models.team_project_access import ( + ProjectSettingsPermissionType, + ProjectTeamsPermissionType, + ProjectVariableSetsPermissionType, + TeamProjectAccess, + TeamProjectAccessAddOptions, + TeamProjectAccessProjectPermissions, + TeamProjectAccessType, + TeamProjectAccessWorkspacePermissions, + WorkspaceRunsPermissionType, + WorkspaceSentinelMocksPermissionType, + WorkspaceStateVersionsPermissionType, + WorkspaceVariablesPermissionType, +) +from ._base import _Service + + +class TeamProjectAccesses(_Service): + def add(self, options: TeamProjectAccessAddOptions) -> TeamProjectAccess: + """Add a team access for a project.""" + attributes = options.model_dump( + by_alias=True, exclude_none=True, exclude={"team", "project"} + ) + relationships = { + "team": {"data": {"id": options.team.id, "type": "teams"}} + if options.team + else None, + "project": {"data": {"id": options.project.id, "type": "projects"}} + if options.project + else None, + } + payload = { + "data": { + "attributes": attributes, + "relationships": relationships, + "type": "team-project-access", + } + } + r = self.t.request( + "POST", + path="/api/v2/team-projects", + json_body=payload, + ) + data = r.json().get("data", {}) + return self._team_project_access_from(data) + + def _team_project_access_from(self, data: dict) -> TeamProjectAccess: + attrs = data.get("attributes", {}) + attrs["id"] = data.get("id") + attrs["access"] = ( + TeamProjectAccessType(attrs.get("access")) if attrs.get("access") else None + ) + + if attrs.get("project-access"): + project_access: dict[str, object] = {} + project_access["project_variable_sets_permission"] = ( + ProjectVariableSetsPermissionType( + attrs.get("project-access").get("variable-sets") + ) + ) + project_access["project_settings_permission"] = ( + ProjectSettingsPermissionType( + attrs.get("project-access").get("settings") + ) + ) + project_access["project_teams_permission"] = ProjectTeamsPermissionType( + attrs.get("project-access").get("teams") + ) + attrs["project_access"] = ( + TeamProjectAccessProjectPermissions.model_validate(project_access) + ) + if attrs.get("workspace-access"): + workspace_access: dict[str, object] = {} + workspace_access["runs"] = WorkspaceRunsPermissionType( + attrs.get("workspace-access").get("runs") + ) + workspace_access["sentinel_mocks"] = WorkspaceSentinelMocksPermissionType( + attrs.get("workspace-access").get("sentinel-mocks") + ) + workspace_access["state_versions"] = WorkspaceStateVersionsPermissionType( + attrs.get("workspace-access").get("state-versions") + ) + workspace_access["variables"] = WorkspaceVariablesPermissionType( + attrs.get("workspace-access").get("variables") + ) + workspace_access["run_tasks"] = attrs.get("workspace-access").get( + "run-tasks" + ) + workspace_access["move"] = attrs.get("workspace-access").get("move") + workspace_access["locking"] = attrs.get("workspace-access").get("locking") + workspace_access["delete"] = attrs.get("workspace-access").get("delete") + workspace_access["create"] = attrs.get("workspace-access").get("create") + attrs["workspace_access"] = ( + TeamProjectAccessWorkspacePermissions.model_validate(workspace_access) + ) + + relationships = data.get("relationships", {}) + team_data = relationships.get("team", {}).get("data", {}) + project_data = relationships.get("project", {}).get("data", {}) + attrs["team"] = Team(id=team_data.get("id")) if team_data else None + attrs["project"] = Project(id=project_data.get("id")) if project_data else None + + return TeamProjectAccess.model_validate(attrs) From 446f6db94b1b6ead45960dc9bf886b86348aa09a Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Tue, 14 Apr 2026 19:37:38 +0530 Subject: [PATCH 10/43] feat(team-project-access): Added models for the team project access resource --- src/pytfe/models/team_project_access.py | 38 +++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/src/pytfe/models/team_project_access.py b/src/pytfe/models/team_project_access.py index 82225a67..29aa8cad 100644 --- a/src/pytfe/models/team_project_access.py +++ b/src/pytfe/models/team_project_access.py @@ -78,6 +78,7 @@ class WorkspaceStateVersionsPermissionType(str, Enum): WORKSPACE_STATE_VERSIONS_PERMISSION_NONE = "none" WORKSPACE_STATE_VERSIONS_PERMISSION_READ_OUTPUTS = "read-outputs" WORKSPACE_STATE_VERSIONS_PERMISSION_WRITE = "write" + WORKSPACE_STATE_VERSIONS_PERMISSION_READ = "read" class WorkspaceVariablesPermissionType(str, Enum): @@ -157,6 +158,26 @@ class TeamProjectAccessProjectPermissionsOptions(BaseModel): ) +class TeamProjectAccessWorkspacePermissionsOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + runs: WorkspaceRunsPermissionType | None = Field(default=None, alias="runs") + sentinel_mocks: WorkspaceSentinelMocksPermissionType | None = Field( + default=None, alias="sentinel-mocks" + ) + state_versions: WorkspaceStateVersionsPermissionType | None = Field( + default=None, alias="state-versions" + ) + variables: WorkspaceVariablesPermissionType | None = Field( + default=None, alias="variables" + ) + create: bool | None = Field(default=None, alias="create") + delete: bool | None = Field(default=None, alias="delete") + locking: bool | None = Field(default=None, alias="locking") + move: bool | None = Field(default=None, alias="move") + run_tasks: bool | None = Field(default=None, alias="run-tasks") + + class TeamProjectAccessAddOptions(BaseModel): """TeamProjectAccessAddOptions represents the options for adding team access for a project""" @@ -166,7 +187,7 @@ class TeamProjectAccessAddOptions(BaseModel): project_access: TeamProjectAccessProjectPermissionsOptions | None = Field( default=None, alias="project-access" ) - workspace_access: TeamProjectAccessWorkspacePermissions | None = Field( + workspace_access: TeamProjectAccessWorkspacePermissionsOptions | None = Field( default=None, alias="workspace-access" ) @@ -194,6 +215,19 @@ class TeamProjectAccessUpdateOptions(BaseModel): project_access: TeamProjectAccessProjectPermissionsOptions | None = Field( default=None, alias="project-access" ) - workspace_access: TeamProjectAccessWorkspacePermissions | None = Field( + workspace_access: TeamProjectAccessWorkspacePermissionsOptions | None = Field( default=None, alias="workspace-access" ) + + @model_validator(mode="after") + def valid(self) -> TeamProjectAccessUpdateOptions: + """Validate the options.""" + if ( + self.access is None + and self.project_access is None + and self.workspace_access is None + ): + raise ValueError( + "At least one of access, project_access, or workspace_access must be provided" + ) + return self From 393f0ec7dd0ed2b726e244eca1c8ef91c1c2f14a Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Tue, 14 Apr 2026 19:38:23 +0530 Subject: [PATCH 11/43] feat(team-project-access): Added list, remove, add, update and read methods for the team project access resource --- src/pytfe/resources/team_project_access.py | 57 ++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/src/pytfe/resources/team_project_access.py b/src/pytfe/resources/team_project_access.py index dd3217f5..746fc59f 100644 --- a/src/pytfe/resources/team_project_access.py +++ b/src/pytfe/resources/team_project_access.py @@ -1,5 +1,8 @@ from __future__ import annotations +from collections.abc import Iterator + +from ..errors import InvalidTeamProjectAccessIDError from ..models.project import Project from ..models.team import Team from ..models.team_project_access import ( @@ -8,14 +11,17 @@ ProjectVariableSetsPermissionType, TeamProjectAccess, TeamProjectAccessAddOptions, + TeamProjectAccessListOptions, TeamProjectAccessProjectPermissions, TeamProjectAccessType, + TeamProjectAccessUpdateOptions, TeamProjectAccessWorkspacePermissions, WorkspaceRunsPermissionType, WorkspaceSentinelMocksPermissionType, WorkspaceStateVersionsPermissionType, WorkspaceVariablesPermissionType, ) +from ..utils import valid_string_id from ._base import _Service @@ -105,3 +111,54 @@ def _team_project_access_from(self, data: dict) -> TeamProjectAccess: attrs["project"] = Project(id=project_data.get("id")) if project_data else None return TeamProjectAccess.model_validate(attrs) + + def update( + self, team_project_access_id: str, options: TeamProjectAccessUpdateOptions + ) -> TeamProjectAccess: + """Update a team access for a project.""" + if not valid_string_id(team_project_access_id): + raise InvalidTeamProjectAccessIDError() + attributes = options.model_dump(by_alias=True, exclude_none=True) + payload = { + "data": { + "attributes": attributes, + "type": "team-project-access", + } + } + r = self.t.request( + "PATCH", + path=f"/api/v2/team-projects/{team_project_access_id}", + json_body=payload, + ) + data = r.json().get("data", {}) + return self._team_project_access_from(data) + + def read(self, team_project_access_id: str) -> TeamProjectAccess: + """Read a team access for a project.""" + if not valid_string_id(team_project_access_id): + raise InvalidTeamProjectAccessIDError() + r = self.t.request( + "GET", + path=f"/api/v2/team-projects/{team_project_access_id}", + ) + data = r.json().get("data", {}) + return self._team_project_access_from(data) + + def list( + self, options: TeamProjectAccessListOptions + ) -> Iterator[TeamProjectAccess]: + """List team accesses for projects.""" + params = options.model_dump(by_alias=True, exclude_none=True) + path = "/api/v2/team-projects" + for item in self._list(path, params=params): + yield self._team_project_access_from(item) + + def remove(self, team_project_access_id: str) -> None: + """Remove a team access for a project.""" + if not valid_string_id(team_project_access_id): + raise InvalidTeamProjectAccessIDError() + self.t.request( + "DELETE", + path=f"/api/v2/team-projects/{team_project_access_id}", + ) + return None From 333a68b05a9881f943f4f48834a05bcda1853cd3 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Tue, 14 Apr 2026 19:39:22 +0530 Subject: [PATCH 12/43] feat(team-project-access): Added invalid team project access id error for the team project access resource --- src/pytfe/errors.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index 8c69a607..bd702dfb 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -545,3 +545,10 @@ class RequiredTeamError(RequiredFieldMissing): def __init__(self, message: str = "team is required"): super().__init__(message) + + +class InvalidTeamProjectAccessIDError(InvalidValues): + """Raised when an invalid team project access ID is provided.""" + + def __init__(self, message: str = "invalid value for team project access ID"): + super().__init__(message) From c6fb4fc171e9ea92ce7df5e03655595359a422fd Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Tue, 14 Apr 2026 19:39:53 +0530 Subject: [PATCH 13/43] feat(team-project-access): Added examples for the team project access resource --- examples/team_project_access.py | 200 ++++++++++++++++++++++++-------- 1 file changed, 149 insertions(+), 51 deletions(-) diff --git a/examples/team_project_access.py b/examples/team_project_access.py index 6ed8af45..1f42d175 100644 --- a/examples/team_project_access.py +++ b/examples/team_project_access.py @@ -11,9 +11,11 @@ ProjectTeamsPermissionType, ProjectVariableSetsPermissionType, TeamProjectAccessAddOptions, + TeamProjectAccessListOptions, TeamProjectAccessProjectPermissionsOptions, TeamProjectAccessType, - TeamProjectAccessWorkspacePermissions, + TeamProjectAccessUpdateOptions, + TeamProjectAccessWorkspacePermissionsOptions, WorkspaceRunsPermissionType, WorkspaceSentinelMocksPermissionType, WorkspaceStateVersionsPermissionType, @@ -27,21 +29,75 @@ def _print_header(title: str): print("=" * 80) +def _print_team_project_access(result): + print(f"- id: {result.id}") + print(f"- access: {result.access.value if result.access else None}") + print(f"- team_id: {result.team.id if result.team else None}") + print(f"- project_id: {result.project.id if result.project else None}") + + if result.project_access: + print("- project_access:") + print(f" settings={result.project_access.project_settings_permission.value}") + print(f" teams={result.project_access.project_teams_permission.value}") + print( + " variable_sets=" + f"{result.project_access.project_variable_sets_permission.value}" + ) + + if result.workspace_access: + print("- workspace_access:") + print( + f" runs={result.workspace_access.runs.value if result.workspace_access.runs else None}" + ) + print( + " sentinel_mocks=" + f"{result.workspace_access.sentinel_mocks.value if result.workspace_access.sentinel_mocks else None}" + ) + print( + " state_versions=" + f"{result.workspace_access.state_versions.value if result.workspace_access.state_versions else None}" + ) + print( + f" variables={result.workspace_access.variables.value if result.workspace_access.variables else None}" + ) + print(f" create={result.workspace_access.create}") + print(f" delete={result.workspace_access.delete}") + print(f" locking={result.workspace_access.locking}") + print(f" move={result.workspace_access.move}") + print(f" run_tasks={result.workspace_access.run_tasks}") + + def main(): parser = argparse.ArgumentParser( - description="Team Project Access add demo for python-tfe SDK" + description="Team Project Access operations 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("--team-id", required=True, help="Team ID") - parser.add_argument("--project-id", required=True, help="Project ID") + parser.add_argument( + "--operation", + required=True, + choices=["add", "read", "update", "list", "remove"], + help="Operation to execute", + ) + parser.add_argument("--team-id", help="Team ID (required for add)") + parser.add_argument("--project-id", help="Project ID (required for add/list)") + parser.add_argument( + "--team-project-access-id", + help="Team Project Access ID (required for read/update/remove)", + ) + parser.add_argument( + "--page-size", + type=int, + default=20, + help="Page size for list operation", + ) parser.add_argument( "--access", choices=[item.value for item in TeamProjectAccessType], - default=TeamProjectAccessType.TEAM_PROJECT_ACCESS_READ.value, - help="Access level", + default=None, + help="Access level (required as custom when granular project/workspace permissions are set)", ) # Optional custom project permissions @@ -89,11 +145,11 @@ def main(): default=None, help="Workspace variables permission (custom access)", ) - parser.add_argument("--workspace-create", action="store_true") - parser.add_argument("--workspace-delete", action="store_true") - parser.add_argument("--workspace-locking", action="store_true") - parser.add_argument("--workspace-move", action="store_true") - parser.add_argument("--workspace-run-tasks", action="store_true") + parser.add_argument("--workspace-create", action="store_true", default=None) + parser.add_argument("--workspace-delete", action="store_true", default=None) + parser.add_argument("--workspace-locking", action="store_true", default=None) + parser.add_argument("--workspace-move", action="store_true", default=None) + parser.add_argument("--workspace-run-tasks", action="store_true", default=None) args = parser.parse_args() @@ -134,7 +190,7 @@ def main(): args.workspace_run_tasks, ] ): - workspace_access = TeamProjectAccessWorkspacePermissions( + workspace_access = TeamProjectAccessWorkspacePermissionsOptions( runs=( WorkspaceRunsPermissionType(args.workspace_runs) if args.workspace_runs @@ -162,53 +218,95 @@ def main(): run_tasks=args.workspace_run_tasks, ) - _print_header("Adding team project access") - options = TeamProjectAccessAddOptions( - access=TeamProjectAccessType(args.access), - team=Team(id=args.team_id), - project=Project(id=args.project_id), - project_access=project_access, - workspace_access=workspace_access, - ) + has_granular_permissions = project_access is not None or workspace_access is not None + if has_granular_permissions and args.access and args.access != TeamProjectAccessType.TEAM_PROJECT_ACCESS_CUSTOM.value: + parser.error( + "When custom project/workspace permissions are provided, --access must be 'custom'" + ) - result = client.team_project_accesses.add(options) + if args.operation == "add": + if not args.team_id or not args.project_id: + parser.error("--team-id and --project-id are required for operation=add") - print("Created team project access") - print(f"- id: {result.id}") - print(f"- access: {result.access.value if result.access else None}") - print(f"- team_id: {result.team.id if result.team else None}") - print(f"- project_id: {result.project.id if result.project else None}") + _print_header("Adding team project access") + access_value = args.access + if access_value is None: + access_value = ( + TeamProjectAccessType.TEAM_PROJECT_ACCESS_CUSTOM.value + if has_granular_permissions + else TeamProjectAccessType.TEAM_PROJECT_ACCESS_READ.value + ) - if result.project_access: - print("- project_access:") - print(f" settings={result.project_access.project_settings_permission.value}") - print(f" teams={result.project_access.project_teams_permission.value}") - print( - " variable_sets=" - f"{result.project_access.project_variable_sets_permission.value}" + options = TeamProjectAccessAddOptions( + access=TeamProjectAccessType(access_value), + team=Team(id=args.team_id), + project=Project(id=args.project_id), + project_access=project_access, + workspace_access=workspace_access, ) + result = client.team_project_accesses.add(options) + print("Created team project access") + _print_team_project_access(result) + return - if result.workspace_access: - print("- workspace_access:") - print( - f" runs={result.workspace_access.runs.value if result.workspace_access.runs else None}" - ) - print( - " sentinel_mocks=" - f"{result.workspace_access.sentinel_mocks.value if result.workspace_access.sentinel_mocks else None}" + if args.operation == "read": + if not args.team_project_access_id: + parser.error("--team-project-access-id is required for operation=read") + + _print_header("Reading team project access") + result = client.team_project_accesses.read(args.team_project_access_id) + print("Retrieved team project access") + _print_team_project_access(result) + return + + if args.operation == "update": + if not args.team_project_access_id: + parser.error("--team-project-access-id is required for operation=update") + + _print_header("Updating team project access") + update_access = None + if args.access: + update_access = TeamProjectAccessType(args.access) + elif has_granular_permissions: + update_access = TeamProjectAccessType.TEAM_PROJECT_ACCESS_CUSTOM + + update_options = TeamProjectAccessUpdateOptions( + access=update_access, + project_access=project_access, + workspace_access=workspace_access, ) - print( - " state_versions=" - f"{result.workspace_access.state_versions.value if result.workspace_access.state_versions else None}" + result = client.team_project_accesses.update( + args.team_project_access_id, + update_options, ) - print( - f" variables={result.workspace_access.variables.value if result.workspace_access.variables else None}" + print("Updated team project access") + _print_team_project_access(result) + return + + if args.operation == "list": + if not args.project_id: + parser.error("--project-id is required for operation=list") + + _print_header("Listing team project accesses") + list_options = TeamProjectAccessListOptions( + page_size=args.page_size, + Project_id=args.project_id, ) - print(f" create={result.workspace_access.create}") - print(f" delete={result.workspace_access.delete}") - print(f" locking={result.workspace_access.locking}") - print(f" move={result.workspace_access.move}") - print(f" run_tasks={result.workspace_access.run_tasks}") + results = list(client.team_project_accesses.list(list_options)) + print(f"Found {len(results)} team project access entries") + for item in results: + print("-") + _print_team_project_access(item) + return + + if args.operation == "remove": + if not args.team_project_access_id: + parser.error("--team-project-access-id is required for operation=remove") + + _print_header("Removing team project access") + client.team_project_accesses.remove(args.team_project_access_id) + print(f"Removed team project access: {args.team_project_access_id}") + return if __name__ == "__main__": From eb2f2db37b044891f0857ce0f9eba41102514418 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Tue, 14 Apr 2026 20:02:41 +0530 Subject: [PATCH 14/43] feat(team-project-access): Added unit testcases for the team project access resource --- examples/team_project_access.py | 10 +- tests/units/test_team_project_access.py | 215 ++++++++++++++++++++++++ 2 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 tests/units/test_team_project_access.py diff --git a/examples/team_project_access.py b/examples/team_project_access.py index 1f42d175..b91b1942 100644 --- a/examples/team_project_access.py +++ b/examples/team_project_access.py @@ -218,8 +218,14 @@ def main(): run_tasks=args.workspace_run_tasks, ) - has_granular_permissions = project_access is not None or workspace_access is not None - if has_granular_permissions and args.access and args.access != TeamProjectAccessType.TEAM_PROJECT_ACCESS_CUSTOM.value: + has_granular_permissions = ( + project_access is not None or workspace_access is not None + ) + if ( + has_granular_permissions + and args.access + and args.access != TeamProjectAccessType.TEAM_PROJECT_ACCESS_CUSTOM.value + ): parser.error( "When custom project/workspace permissions are provided, --access must be 'custom'" ) diff --git a/tests/units/test_team_project_access.py b/tests/units/test_team_project_access.py new file mode 100644 index 00000000..75b6a4a6 --- /dev/null +++ b/tests/units/test_team_project_access.py @@ -0,0 +1,215 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the team_project_access module.""" + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import InvalidTeamProjectAccessIDError +from pytfe.models.project import Project +from pytfe.models.team import Team +from pytfe.models.team_project_access import ( + ProjectSettingsPermissionType, + ProjectTeamsPermissionType, + ProjectVariableSetsPermissionType, + TeamProjectAccess, + TeamProjectAccessAddOptions, + TeamProjectAccessListOptions, + TeamProjectAccessProjectPermissionsOptions, + TeamProjectAccessType, + TeamProjectAccessUpdateOptions, + TeamProjectAccessWorkspacePermissionsOptions, + WorkspaceRunsPermissionType, + WorkspaceSentinelMocksPermissionType, + WorkspaceStateVersionsPermissionType, + WorkspaceVariablesPermissionType, +) +from pytfe.resources.team_project_access import TeamProjectAccesses + + +class TestTeamProjectAccesses: + """Test the TeamProjectAccesses service class.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock HTTPTransport.""" + return Mock(spec=HTTPTransport) + + @pytest.fixture + def team_project_accesses_service(self, mock_transport): + """Create a TeamProjectAccesses service with mocked transport.""" + return TeamProjectAccesses(mock_transport) + + @pytest.fixture + def team_project_access_response_data(self): + """Return sample API response data for team project access.""" + return { + "id": "tprj-123", + "attributes": { + "access": "custom", + "project-access": { + "settings": "update", + "teams": "manage", + "variable-sets": "read", + }, + "workspace-access": { + "runs": "plan", + "sentinel-mocks": "none", + "state-versions": "read-outputs", + "variables": "write", + "run-tasks": True, + "move": False, + "locking": True, + "delete": False, + "create": True, + }, + }, + "relationships": { + "team": {"data": {"id": "team-123", "type": "teams"}}, + "project": {"data": {"id": "prj-123", "type": "projects"}}, + }, + } + + def test_add_team_project_access_success( + self, + team_project_accesses_service, + mock_transport, + team_project_access_response_data, + ): + """Test successful add operation.""" + mock_response = Mock() + mock_response.json.return_value = {"data": team_project_access_response_data} + mock_transport.request.return_value = mock_response + + options = TeamProjectAccessAddOptions( + access=TeamProjectAccessType.TEAM_PROJECT_ACCESS_CUSTOM, + team=Team(id="team-123"), + project=Project(id="prj-123"), + project_access=TeamProjectAccessProjectPermissionsOptions( + settings=ProjectSettingsPermissionType.PROJECT_SETTINGS_PERMISSION_UPDATE, + teams=ProjectTeamsPermissionType.PROJECT_TEAMS_PERMISSION_MANAGE, + variable_sets=ProjectVariableSetsPermissionType.PROJECT_VARIABLE_SETS_PERMISSION_READ, + ), + workspace_access=TeamProjectAccessWorkspacePermissionsOptions( + runs=WorkspaceRunsPermissionType.WORKSPACE_RUNS_PERMISSION_PLAN, + sentinel_mocks=WorkspaceSentinelMocksPermissionType.WORKSPACE_SENTINEL_MOCKS_PERMISSION_NONE, + state_versions=WorkspaceStateVersionsPermissionType.WORKSPACE_STATE_VERSIONS_PERMISSION_READ_OUTPUTS, + variables=WorkspaceVariablesPermissionType.WORKSPACE_VARIABLES_PERMISSION_WRITE, + create=True, + delete=False, + locking=True, + move=False, + run_tasks=True, + ), + ) + + result = team_project_accesses_service.add(options) + + mock_transport.request.assert_called_once() + assert isinstance(result, TeamProjectAccess) + assert result.id == "tprj-123" + assert result.access == TeamProjectAccessType.TEAM_PROJECT_ACCESS_CUSTOM + assert result.team.id == "team-123" + assert result.project.id == "prj-123" + + def test_read_team_project_access_success( + self, + team_project_accesses_service, + mock_transport, + team_project_access_response_data, + ): + """Test successful read operation.""" + mock_response = Mock() + mock_response.json.return_value = {"data": team_project_access_response_data} + mock_transport.request.return_value = mock_response + + result = team_project_accesses_service.read("tprj-123") + + mock_transport.request.assert_called_once_with( + "GET", path="/api/v2/team-projects/tprj-123" + ) + assert isinstance(result, TeamProjectAccess) + assert result.id == "tprj-123" + assert result.workspace_access.run_tasks is True + + def test_read_team_project_access_invalid_id(self, team_project_accesses_service): + """Test read operation with invalid team project access ID.""" + with pytest.raises(InvalidTeamProjectAccessIDError): + team_project_accesses_service.read("") + + def test_update_team_project_access_success( + self, + team_project_accesses_service, + mock_transport, + team_project_access_response_data, + ): + """Test successful update operation.""" + mock_response = Mock() + mock_response.json.return_value = {"data": team_project_access_response_data} + mock_transport.request.return_value = mock_response + + options = TeamProjectAccessUpdateOptions( + access=TeamProjectAccessType.TEAM_PROJECT_ACCESS_CUSTOM, + workspace_access=TeamProjectAccessWorkspacePermissionsOptions( + run_tasks=True + ), + ) + + result = team_project_accesses_service.update("tprj-123", options) + + mock_transport.request.assert_called_once() + assert isinstance(result, TeamProjectAccess) + assert result.id == "tprj-123" + + def test_update_team_project_access_invalid_id(self, team_project_accesses_service): + """Test update operation with invalid team project access ID.""" + options = TeamProjectAccessUpdateOptions( + access=TeamProjectAccessType.TEAM_PROJECT_ACCESS_READ + ) + + with pytest.raises(InvalidTeamProjectAccessIDError): + team_project_accesses_service.update("", options) + + def test_list_team_project_accesses_success( + self, + team_project_accesses_service, + team_project_access_response_data, + ): + """Test successful list operation.""" + team_project_accesses_service._list = Mock( + return_value=[team_project_access_response_data] + ) + + options = TeamProjectAccessListOptions(page_size=10, Project_id="prj-123") + + result_iter = team_project_accesses_service.list(options) + items = list(result_iter) + + team_project_accesses_service._list.assert_called_once_with( + "/api/v2/team-projects", + params={"page[size]": 10, "filter[project][id]": "prj-123"}, + ) + assert len(items) == 1 + assert isinstance(items[0], TeamProjectAccess) + assert items[0].id == "tprj-123" + + def test_remove_team_project_access_success( + self, + team_project_accesses_service, + mock_transport, + ): + """Test successful remove operation.""" + result = team_project_accesses_service.remove("tprj-123") + + mock_transport.request.assert_called_once_with( + "DELETE", path="/api/v2/team-projects/tprj-123" + ) + assert result is None + + def test_remove_team_project_access_invalid_id(self, team_project_accesses_service): + """Test remove operation with invalid team project access ID.""" + with pytest.raises(InvalidTeamProjectAccessIDError): + team_project_accesses_service.remove("") From 05f2543c39b5ae62241cf022395d13fc3ed64be1 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Thu, 16 Apr 2026 16:42:14 +0530 Subject: [PATCH 15/43] feat(stacks): Created models and resource for Stack resource --- src/pytfe/client.py | 2 + src/pytfe/models/stack.py | 120 +++++++++++++++++++++++++++++++ src/pytfe/resources/stack.py | 135 +++++++++++++++++++++++++++++++++++ 3 files changed, 257 insertions(+) create mode 100644 src/pytfe/models/stack.py create mode 100644 src/pytfe/resources/stack.py diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 30b506b9..f9f8648c 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -33,6 +33,7 @@ from .resources.run_task import RunTasks from .resources.run_trigger import RunTriggers from .resources.ssh_keys import SSHKeys +from .resources.stack import Stacks from .resources.state_version_outputs import StateVersionOutputs from .resources.state_versions import StateVersions from .resources.variable import Variables @@ -104,6 +105,7 @@ def __init__(self, config: TFEConfig | None = None): # Reserved Tag Key self.reserved_tag_key = ReservedTagKeys(self._transport) + self.stacks = Stacks(self._transport) def close(self) -> None: try: diff --git a/src/pytfe/models/stack.py b/src/pytfe/models/stack.py new file mode 100644 index 00000000..c6378f17 --- /dev/null +++ b/src/pytfe/models/stack.py @@ -0,0 +1,120 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from ..errors import ERR_REQUIRED_NAME, ERR_REQUIRED_PROJECT +from .agent import AgentPool +from .project import Project + + +class StackSortColumn(str, Enum): + """StackSortColumn represents a string that can be used to sort items when using the List method.""" + + STACK_SORT_BY_NAME = "name" + STACK_SORT_BY_UPDATED_AT = "updated-at" + STACK_SORT_BY_NAME_DESC = "-name" + STACK_SORT_BY_UPDATED_AT_DESC = "-updated-at" + + +class StackVcsRepo(BaseModel): + """StackVCSRepo represents the version control system repository for a stack.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + identifier: str = Field(alias="identifier") + branch: str | None = Field(default=None, alias="branch") + gha_installation_id: str | None = Field( + default=None, alias="github-app-installation-id" + ) + oauth_token_id: str | None = Field(default=None, alias="oauth-token-id") + + +class StackVcsRepoOptions(BaseModel): + """StackVCSRepoOptions represents the options for the version control system repository for a stack.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + identifier: str = Field(alias="identifier") + branch: str | None = Field(default=None, alias="branch") + gha_installation_id: str | None = Field( + default=None, alias="github-app-installation-id" + ) + oauth_token_id: str | None = Field(default=None, alias="oauth-token-id") + + +class Stack(BaseModel): + """Stack represents a stack in Terraform Cloud.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str + name: str | None = Field(default=None, alias="name") + description: str | None = Field(default=None, alias="description") + created_at: datetime | None = Field(default=None, alias="created-at") + updated_at: datetime | None = Field(default=None, alias="updated-at") + vcs_repo: StackVcsRepo | None = Field(default=None, alias="vcs-repo") + speculation_enabled: bool | None = Field(default=None, alias="speculation-enabled") + upstream_count: int | None = Field(default=None, alias="upstream-count") + downstream_count: int | None = Field(default=None, alias="downstream-count") + inputs_count: int | None = Field(default=None, alias="inputs-count") + outputs_count: int | None = Field(default=None, alias="outputs-count") + creation_source: str | None = Field(default=None, alias="creation-source") + + # Relations + project: Project | None = Field(default=None, alias="project") + agent_pool: AgentPool | None = Field(default=None, alias="agent-pool") + # latest_stack_configuration: dict[str, Any] | None = Field(default=None, alias="latest-stack-configuration") + + +class StackListOptions(BaseModel): + """StackListOptions represents the options for listing stacks.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + page_size: int | None = Field(default=None, alias="page[size]") + project_id: str | None = Field(default=None, alias="filter[project][id]") + sort: StackSortColumn | None = Field(default=None, alias="sort") + search_by_name: str | None = Field(default=None, alias="search[name]") + + +class StackCreateOptions(BaseModel): + """StackCreateOptions represents the options for creating a stack.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + name: str = Field(alias="name") + migration: bool | None = Field(default=None, alias="migration") + description: str | None = Field(default=None, alias="description") + speculation_enabled: bool | None = Field(default=None, alias="speculation-enabled") + vcs_repo: StackVcsRepoOptions | None = Field(default=None, alias="vcs-repo") + project: Project = Field(alias="project") + agent_pool: AgentPool | None = Field(default=None, alias="agent-pool") + + @model_validator(mode="after") + def valid(self) -> StackCreateOptions: + if self.name == "": + raise ValueError(ERR_REQUIRED_NAME) + + if self.project and self.project.id == "": + raise ValueError(ERR_REQUIRED_PROJECT) + + return self + + +class StackUpdateOptions(BaseModel): + """StackUpdateOptions represents the options for updating a stack.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + name: str | None = Field(default=None, alias="name") + description: str | None = Field(default=None, alias="description") + speculation_enabled: bool | None = Field(default=None, alias="speculation-enabled") + vcs_repo: StackVcsRepoOptions | None = Field(default=None, alias="vcs-repo") + project: Project | None = Field(default=None, alias="project") + agent_pool: AgentPool | None = Field(default=None, alias="agent-pool") diff --git a/src/pytfe/resources/stack.py b/src/pytfe/resources/stack.py new file mode 100644 index 00000000..dac0b1ca --- /dev/null +++ b/src/pytfe/resources/stack.py @@ -0,0 +1,135 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from collections.abc import Iterator + +from pytfe.models import ( + AgentPool, + Project, +) + +from ..models.stack import ( + Stack, + StackCreateOptions, + StackListOptions, + StackUpdateOptions, + StackVcsRepo, +) +from ._base import _Service + + +class Stacks(_Service): + def create(self, options: StackCreateOptions) -> Stack: + """Create a new stack within a project.""" + payload = { + "data": { + "attributes": options.model_dump( + by_alias=True, exclude_none=True, exclude={"project", "agent_pool"} + ), + "type": "stacks", + "relationships": {}, + } + } + relationships = {} + if options.project: + relationships["project"] = { + "data": {"id": options.project.id, "type": "projects"} + } + if options.agent_pool: + relationships["agent-pool"] = { + "data": {"id": options.agent_pool.id, "type": "agent-pools"} + } + payload["data"]["relationships"] = relationships + r = self.t.request( + "POST", + path="/api/v2/stacks", + json_body=payload, + ) + data = r.json().get("data", {}) + return self._stack_from(data) + + def update(self, stack_id: str, options: StackUpdateOptions) -> Stack: + """Update an existing stack.""" + payload = { + "data": { + "attributes": options.model_dump( + by_alias=True, + exclude_none=True, + exclude={"agent_pool", "project"}, + ), + "type": "stacks", + "relationships": {}, + } + } + relationships = {} + if options.project: + relationships.update( + {"project": {"data": {"id": options.project.id, "type": "projects"}}} + ) + if options.agent_pool: + relationships.update( + { + "agent-pool": { + "data": {"id": options.agent_pool.id, "type": "agent-pools"} + } + } + ) + payload["data"]["relationships"] = relationships + r = self.t.request( + "PATCH", + path=f"/api/v2/stacks/{stack_id}", + json_body=payload, + ) + data = r.json().get("data", {}) + return self._stack_from(data) + + def list(self, organization: str, options: StackListOptions) -> Iterator[Stack]: + """List stacks within an organization, with optional filtering by project.""" + params = options.model_dump(by_alias=True, exclude_none=True) + path = f"/api/v2/organizations/{organization}/stacks" + for item in self._list(path, params=params): + yield self._stack_from(item) + + def read(self, stack_id: str) -> Stack: + """Read a stack by ID.""" + r = self.t.request( + "GET", + path=f"/api/v2/stacks/{stack_id}", + ) + data = r.json().get("data", {}) + return self._stack_from(data) + + def delete(self, stack_id: str) -> None: + """Delete a stack by ID.""" + self.t.request( + "DELETE", + path=f"/api/v2/stacks/{stack_id}", + ) + return None + + def force_delete(self, stack_id: str) -> None: + """ForceDelete deletes a stack that still has deployments.""" + self.t.request( + "DELETE", + path=f"/api/v2/stacks/{stack_id}?force=true", + ) + return None + + def _stack_from(self, data: dict) -> Stack: + attrs = data.get("attributes", {}) + attrs["id"] = data.get("id") + relationships = data.get("relationships", {}) + vcs_repo_raw = attrs.get("vcs-repo") + if vcs_repo_raw: + attrs["vcs_repo"] = StackVcsRepo.model_validate(vcs_repo_raw) + else: + attrs["vcs_repo"] = None + project_data = relationships.get("project", {}).get("data", {}) + agent_pool_data = relationships.get("agent-pool", {}).get("data", {}) + if isinstance(project_data, dict) and project_data.get("id"): + attrs["project"] = Project(id=project_data["id"]) + if isinstance(agent_pool_data, dict) and agent_pool_data.get("id"): + attrs["agent_pool"] = AgentPool(id=agent_pool_data["id"]) + return Stack.model_validate(attrs) From b0d25d541165666840fd061a58ecfcd7df017ca3 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Thu, 16 Apr 2026 16:42:54 +0530 Subject: [PATCH 16/43] feat(stacks): Added examples for stack resource --- examples/stack.py | 224 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 examples/stack.py diff --git a/examples/stack.py b/examples/stack.py new file mode 100644 index 00000000..6e8546e8 --- /dev/null +++ b/examples/stack.py @@ -0,0 +1,224 @@ +# 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.agent import AgentPool +from pytfe.models.project import Project +from pytfe.models.stack import ( + StackCreateOptions, + StackListOptions, + StackSortColumn, + StackUpdateOptions, + StackVcsRepoOptions, +) + + +def _print_header(title: str): + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def _print_stack(item): + print(f"- id: {item.id}") + print(f"- name: {item.name}") + print(f"- description: {item.description}") + print(f"- created_at: {item.created_at}") + print(f"- updated_at: {item.updated_at}") + print(f"- speculation_enabled: {item.speculation_enabled}") + print(f"- project_id: {item.project.id if item.project else None}") + print(f"- agent_pool_id: {item.agent_pool.id if item.agent_pool else None}") + + if item.vcs_repo: + print("- vcs_repo:") + print(f" identifier={item.vcs_repo.identifier}") + print(f" branch={item.vcs_repo.branch}") + print(f" github_app_installation_id={item.vcs_repo.gha_installation_id}") + print(f" oauth_token_id={item.vcs_repo.oauth_token_id}") + + +def _build_vcs_repo_options(args) -> StackVcsRepoOptions | None: + if not args.vcs_identifier: + return None + + return StackVcsRepoOptions( + identifier=args.vcs_identifier, + branch=args.vcs_branch, + gha_installation_id=args.vcs_github_app_installation_id, + oauth_token_id=args.vcs_oauth_token_id, + ) + + +def main(): + parser = argparse.ArgumentParser( + description="Stacks operations demo for python-tfe" + ) + 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("--organization", help="Organization name (required for list)") + parser.add_argument( + "--operation", + required=True, + choices=["create", "read", "update", "list", "delete", "force-delete"], + help="Operation to execute", + ) + + parser.add_argument( + "--stack-id", help="Stack ID (required for read/update/delete/force-delete)" + ) + + parser.add_argument("--name", help="Stack name (required for create)") + parser.add_argument("--description", help="Stack description") + parser.add_argument( + "--speculation-enabled", + type=lambda v: str(v).lower() in ("1", "true", "yes", "y"), + default=None, + help="Enable speculation (true/false)", + ) + + parser.add_argument( + "--project-id", + help="Project ID (required for create, optional for list filter)", + ) + parser.add_argument( + "--agent-pool-id", help="Agent pool ID (optional for create/update)" + ) + + parser.add_argument( + "--vcs-identifier", + help="VCS repo identifier (e.g. org/repo), optional for create/update", + ) + parser.add_argument("--vcs-branch", help="VCS branch") + parser.add_argument( + "--vcs-github-app-installation-id", + help="GitHub App installation ID for VCS repo", + ) + parser.add_argument("--vcs-oauth-token-id", help="OAuth token ID for VCS repo") + + parser.add_argument("--page-size", type=int, default=20, help="Page size for list") + parser.add_argument( + "--sort", + choices=[item.value for item in StackSortColumn], + default=None, + help="Sort column for list", + ) + parser.add_argument( + "--search-name", + default=None, + help="Search stacks by name", + ) + + args = parser.parse_args() + + cfg = TFEConfig(address=args.address, token=args.token) + client = TFEClient(cfg) + + if args.operation == "create": + if not args.name: + parser.error("--name is required for operation=create") + if not args.project_id: + parser.error("--project-id is required for operation=create") + + _print_header("Creating stack") + options = StackCreateOptions( + name=args.name, + description=args.description, + speculation_enabled=args.speculation_enabled, + vcs_repo=_build_vcs_repo_options(args), + project=Project(id=args.project_id), + agent_pool=AgentPool(id=args.agent_pool_id) if args.agent_pool_id else None, + ) + result = client.stacks.create(options) + print("Created stack") + _print_stack(result) + return + + if args.operation == "read": + if not args.stack_id: + parser.error("--stack-id is required for operation=read") + + _print_header("Reading stack") + result = client.stacks.read(args.stack_id) + print("Retrieved stack") + _print_stack(result) + return + + if args.operation == "update": + if not args.stack_id: + parser.error("--stack-id is required for operation=update") + if not any( + [ + args.name, + args.description, + args.speculation_enabled is not None, + args.agent_pool_id, + args.vcs_identifier, + args.vcs_branch, + args.vcs_github_app_installation_id, + args.vcs_oauth_token_id, + args.project_id, + ] + ): + parser.error("Provide at least one field to update") + + _print_header("Updating stack") + options = StackUpdateOptions( + name=args.name, + description=args.description, + speculation_enabled=args.speculation_enabled, + vcs_repo=_build_vcs_repo_options(args), + agent_pool=AgentPool(id=args.agent_pool_id) if args.agent_pool_id else None, + project=Project(id=args.project_id) if args.project_id else None, + ) + result = client.stacks.update(args.stack_id, options) + print("Updated stack") + _print_stack(result) + return + + if args.operation == "list": + if not args.organization: + parser.error("--organization is required for operation=list") + + _print_header("Listing stacks") + list_options = StackListOptions( + page_size=args.page_size, + project_id=args.project_id, + sort=StackSortColumn(args.sort) if args.sort else None, + search_by_name=args.search_name, + ) + + items = list(client.stacks.list(args.organization, list_options)) + print(f"Found {len(items)} stacks") + for item in items: + print("-") + _print_stack(item) + return + + if args.operation == "delete": + if not args.stack_id: + parser.error("--stack-id is required for operation=delete") + + _print_header("Deleting stack") + client.stacks.delete(args.stack_id) + print(f"Deleted stack: {args.stack_id}") + return + + if args.operation == "force-delete": + if not args.stack_id: + parser.error("--stack-id is required for operation=force-delete") + + _print_header("Force deleting stack") + client.stacks.force_delete(args.stack_id) + print(f"Force deleted stack: {args.stack_id}") + return + + +if __name__ == "__main__": + main() From f3e8e00f23ea181e2a997b90552287552149075a Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Thu, 16 Apr 2026 16:43:26 +0530 Subject: [PATCH 17/43] feat(stacks): Added unit testcases for stack resource --- tests/units/test_stack.py | 267 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 tests/units/test_stack.py diff --git a/tests/units/test_stack.py b/tests/units/test_stack.py new file mode 100644 index 00000000..996a13da --- /dev/null +++ b/tests/units/test_stack.py @@ -0,0 +1,267 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the stack module.""" + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.models.agent import AgentPool +from pytfe.models.project import Project +from pytfe.models.stack import ( + Stack, + StackCreateOptions, + StackListOptions, + StackSortColumn, + StackUpdateOptions, + StackVcsRepoOptions, +) +from pytfe.resources.stack import Stacks + + +class TestStacks: + """Test the Stacks service class.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock HTTPTransport.""" + return Mock(spec=HTTPTransport) + + @pytest.fixture + def stacks_service(self, mock_transport): + """Create a Stacks service with mocked transport.""" + return Stacks(mock_transport) + + @pytest.fixture + def stack_response_data(self): + """Return sample API response data for a stack.""" + return { + "id": "st-123", + "attributes": { + "name": "demo-stack", + "description": "Stack description", + "speculation-enabled": True, + "vcs-repo": { + "identifier": "hashicorp/terraform", + "branch": "main", + "oauth-token-id": "ot-123", + }, + }, + "relationships": { + "project": {"data": {"id": "prj-123", "type": "projects"}}, + "agent-pool": { + "data": {"id": "apool-123", "type": "agent-pools"} + }, + }, + } + + def test_list_stacks_success(self, stacks_service, stack_response_data): + """Test successful list operation.""" + stacks_service._list = Mock(return_value=[stack_response_data]) + + options = StackListOptions( + page_size=10, + project_id="prj-123", + sort=StackSortColumn.STACK_SORT_BY_NAME, + search_by_name="demo", + ) + + result_iter = stacks_service.list("org-123", options) + items = list(result_iter) + + stacks_service._list.assert_called_once_with( + "/api/v2/organizations/org-123/stacks", + params={ + "page[size]": 10, + "filter[project][id]": "prj-123", + "sort": "name", + "search[name]": "demo", + }, + ) + + assert len(items) == 1 + assert isinstance(items[0], Stack) + assert items[0].id == "st-123" + assert items[0].name == "demo-stack" + + def test_create_stack_success(self, stacks_service, mock_transport, stack_response_data): + """Test successful create operation.""" + mock_response = Mock() + mock_response.json.return_value = {"data": stack_response_data} + mock_transport.request.return_value = mock_response + + options = StackCreateOptions( + name="demo-stack", + description="Stack description", + speculation_enabled=True, + vcs_repo=StackVcsRepoOptions( + identifier="hashicorp/terraform", + branch="main", + oauth_token_id="ot-123", + ), + project=Project(id="prj-123"), + agent_pool=AgentPool(id="apool-123"), + ) + + result = stacks_service.create(options) + + mock_transport.request.assert_called_once_with( + "POST", + path="/api/v2/stacks", + json_body={ + "data": { + "attributes": { + "name": "demo-stack", + "description": "Stack description", + "speculation-enabled": True, + "vcs-repo": { + "identifier": "hashicorp/terraform", + "branch": "main", + "oauth-token-id": "ot-123", + }, + }, + "type": "stacks", + "relationships": { + "project": { + "data": {"id": "prj-123", "type": "projects"} + }, + "agent-pool": { + "data": {"id": "apool-123", "type": "agent-pools"} + }, + }, + } + }, + ) + + assert isinstance(result, Stack) + assert result.id == "st-123" + assert result.project.id == "prj-123" + assert result.agent_pool.id == "apool-123" + + def test_update_stack_success(self, stacks_service, mock_transport, stack_response_data): + """Test successful update operation.""" + mock_response = Mock() + mock_response.json.return_value = {"data": stack_response_data} + mock_transport.request.return_value = mock_response + + options = StackUpdateOptions( + description="Updated description", + vcs_repo=StackVcsRepoOptions( + identifier="hashicorp/terraform", + branch="main", + ), + project=Project(id="prj-123"), + agent_pool=AgentPool(id="apool-123"), + ) + + result = stacks_service.update("st-123", options) + + mock_transport.request.assert_called_once_with( + "PATCH", + path="/api/v2/stacks/st-123", + json_body={ + "data": { + "attributes": { + "description": "Updated description", + "vcs-repo": { + "identifier": "hashicorp/terraform", + "branch": "main", + }, + }, + "type": "stacks", + "relationships": { + "project": { + "data": {"id": "prj-123", "type": "projects"} + }, + "agent-pool": { + "data": {"id": "apool-123", "type": "agent-pools"} + }, + }, + } + }, + ) + + assert isinstance(result, Stack) + assert result.id == "st-123" + + def test_read_stack_success(self, stacks_service, mock_transport, stack_response_data): + """Test successful read operation.""" + mock_response = Mock() + mock_response.json.return_value = {"data": stack_response_data} + mock_transport.request.return_value = mock_response + + result = stacks_service.read("st-123") + + mock_transport.request.assert_called_once_with( + "GET", + path="/api/v2/stacks/st-123", + ) + + assert isinstance(result, Stack) + assert result.id == "st-123" + assert result.name == "demo-stack" + + def test_delete_stack_success(self, stacks_service, mock_transport): + """Test successful delete operation.""" + result = stacks_service.delete("st-123") + + mock_transport.request.assert_called_once_with( + "DELETE", + path="/api/v2/stacks/st-123", + ) + assert result is None + + def test_force_delete_stack_success(self, stacks_service, mock_transport): + """Test successful force-delete operation.""" + result = stacks_service.force_delete("st-123") + + mock_transport.request.assert_called_once_with( + "DELETE", + path="/api/v2/stacks/st-123?force=true", + ) + assert result is None + + def test_stack_from_handles_null_vcs_repo(self, stacks_service): + """Test parsing stack data when vcs-repo is null.""" + data = { + "id": "st-456", + "attributes": { + "name": "no-vcs-stack", + "vcs-repo": None, + }, + "relationships": { + "project": {"data": {"id": "prj-999", "type": "projects"}}, + }, + } + + result = stacks_service._stack_from(data) + + assert isinstance(result, Stack) + assert result.id == "st-456" + assert result.vcs_repo is None + assert result.project is not None + assert result.project.id == "prj-999" + assert result.agent_pool is None + + def test_stack_from_handles_missing_relationships(self, stacks_service): + """Test parsing stack data when relationship data is missing.""" + data = { + "id": "st-789", + "attributes": { + "name": "minimal-stack", + "vcs-repo": None, + }, + "relationships": { + "project": {"data": None}, + "agent-pool": {"data": None}, + }, + } + + result = stacks_service._stack_from(data) + + assert isinstance(result, Stack) + assert result.id == "st-789" + assert result.project is None + assert result.agent_pool is None From 0cce22ba36361c2fce9d9edb7681a9fb15fcbbe8 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Thu, 16 Apr 2026 16:46:55 +0530 Subject: [PATCH 18/43] feat(stacks): Fixed fmt and lints --- tests/units/test_stack.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/units/test_stack.py b/tests/units/test_stack.py index 996a13da..03c9d28f 100644 --- a/tests/units/test_stack.py +++ b/tests/units/test_stack.py @@ -51,9 +51,7 @@ def stack_response_data(self): }, "relationships": { "project": {"data": {"id": "prj-123", "type": "projects"}}, - "agent-pool": { - "data": {"id": "apool-123", "type": "agent-pools"} - }, + "agent-pool": {"data": {"id": "apool-123", "type": "agent-pools"}}, }, } @@ -86,7 +84,9 @@ def test_list_stacks_success(self, stacks_service, stack_response_data): assert items[0].id == "st-123" assert items[0].name == "demo-stack" - def test_create_stack_success(self, stacks_service, mock_transport, stack_response_data): + def test_create_stack_success( + self, stacks_service, mock_transport, stack_response_data + ): """Test successful create operation.""" mock_response = Mock() mock_response.json.return_value = {"data": stack_response_data} @@ -124,9 +124,7 @@ def test_create_stack_success(self, stacks_service, mock_transport, stack_respon }, "type": "stacks", "relationships": { - "project": { - "data": {"id": "prj-123", "type": "projects"} - }, + "project": {"data": {"id": "prj-123", "type": "projects"}}, "agent-pool": { "data": {"id": "apool-123", "type": "agent-pools"} }, @@ -140,7 +138,9 @@ def test_create_stack_success(self, stacks_service, mock_transport, stack_respon assert result.project.id == "prj-123" assert result.agent_pool.id == "apool-123" - def test_update_stack_success(self, stacks_service, mock_transport, stack_response_data): + def test_update_stack_success( + self, stacks_service, mock_transport, stack_response_data + ): """Test successful update operation.""" mock_response = Mock() mock_response.json.return_value = {"data": stack_response_data} @@ -172,9 +172,7 @@ def test_update_stack_success(self, stacks_service, mock_transport, stack_respon }, "type": "stacks", "relationships": { - "project": { - "data": {"id": "prj-123", "type": "projects"} - }, + "project": {"data": {"id": "prj-123", "type": "projects"}}, "agent-pool": { "data": {"id": "apool-123", "type": "agent-pools"} }, @@ -186,7 +184,9 @@ def test_update_stack_success(self, stacks_service, mock_transport, stack_respon assert isinstance(result, Stack) assert result.id == "st-123" - def test_read_stack_success(self, stacks_service, mock_transport, stack_response_data): + def test_read_stack_success( + self, stacks_service, mock_transport, stack_response_data + ): """Test successful read operation.""" mock_response = Mock() mock_response.json.return_value = {"data": stack_response_data} From 2bd04d5a95bb7a5016e259fad41094ecafa50a47 Mon Sep 17 00:00:00 2001 From: jasodeep Date: Sat, 25 Apr 2026 00:38:06 +0530 Subject: [PATCH 19/43] =?UTF-8?q?feat(explorer):=20add=20explorer=20suppor?= =?UTF-8?q?t=20for=20HCP/TFE=20=F0=9F=9A=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 5 + examples/explorer.py | 452 ++++++++++++++++++++++++++++++ src/pytfe/client.py | 4 + src/pytfe/errors.py | 7 + src/pytfe/models/__init__.py | 21 ++ src/pytfe/models/explorer.py | 123 +++++++++ src/pytfe/resources/explorer.py | 469 ++++++++++++++++++++++++++++++++ tests/units/test_explorer.py | 387 ++++++++++++++++++++++++++ 8 files changed, 1468 insertions(+) create mode 100644 examples/explorer.py create mode 100644 src/pytfe/models/explorer.py create mode 100644 src/pytfe/resources/explorer.py create mode 100644 tests/units/test_explorer.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4db63b0d..b6a968c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Unreleased +## Features + +### Explorer API +* Added Explorer resource support with query, CSV export, saved view CRUD, saved view result query, and saved view CSV export endpoints. + # v0.1.3 ## Enhancements diff --git a/examples/explorer.py b/examples/explorer.py new file mode 100644 index 00000000..c75c854a --- /dev/null +++ b/examples/explorer.py @@ -0,0 +1,452 @@ +#!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +""" +================================================================================ + Terraform Explorer API — walkthrough (TFEClient.explorer) +================================================================================ + + https://developer.hashicorp.com/terraform/cloud-docs/api-docs/explorer + + PUBLIC FUNCTIONS + ─────────────────────────────────────────────────── + ┌────────────────────────┬────────────────────────────────────┬──────────────────────────────────────────────────────────────────────────────┬──────────────────────────────┐ + │ Function │ Purpose │ Input parameters │ Returns │ + ├────────────────────────┼────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────┼──────────────────────────────┤ + │ query │ Execute any Explorer query │ organization: str; options: ExplorerQueryOptions │ Iterator[ExplorerRow] │ + │ export_csv │ Export query results as CSV │ organization: str; options: ExplorerQueryOptions │ str (CSV document) │ + │ list_saved_views │ List saved Explorer views │ organization: str │ Iterator[ExplorerSavedView] │ + │ create_saved_view │ Create saved Explorer view │ organization: str; options: ExplorerSavedViewCreateOptions │ ExplorerSavedView │ + │ read_saved_view │ Fetch one saved view by id │ organization: str; view_id: str │ ExplorerSavedView │ + │ update_saved_view │ Update saved view definition │ organization: str; view_id: str; options: ExplorerSavedViewUpdateOptions │ ExplorerSavedView │ + │ delete_saved_view │ Remove saved view by id │ organization: str; view_id: str │ ExplorerSavedView │ + │ saved_view_results │ Execute saved view, stream rows │ organization: str; view_id: str │ Iterator[ExplorerRow] │ + │ saved_view_results_csv │ Saved view results as CSV │ organization: str; view_id: str │ str (CSV; fallbacks) │ + └────────────────────────┴────────────────────────────────────┴──────────────────────────────────────────────────────────────────────────────┴──────────────────────────────┘ + delete_saved_view: if the DELETE response has no JSON body, the client returns a + minimal ExplorerSavedView with the same id. + saved_view_results_csv: tries the saved-view CSV endpoint first; on failure it may + call export_csv after read_saved_view, or build CSV from saved_view_results. + + INPUT AND OUTPUT MODELS (how to pass; allowed values) + ─────────────────────────────────────────────────────── + Full column tables and operator semantics: + https://developer.hashicorp.com/terraform/cloud-docs/api-docs/explorer + + Plain string parameters (no model) + - organization — First argument on every method: org name as str (non-empty; invalid + values raise InvalidOrgError). + - view_id — str for saved-view routes (non-empty; invalid values raise + InvalidExplorerSavedViewIDError). Use the id returned by list_saved_views or + create_saved_view. + + ExplorerQueryOptions — second argument to query(org, options) and export_csv(org, options) + How to pass: build one instance and pass it by name, for example + ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES, sort="-workspace_name", + filters=[ExplorerUrlFilter(...)]). + Required: + - view_type — ExplorerViewType (serialized to HTTP query key type). Allowed strings + per product docs: workspaces, tf_versions, providers, modules. This SDK also + defines resources for APIs that support that view. + Optional: + - sort — Comma-separated snake_case field names for the active view; prefix "-" for + descending order. + - fields — Comma-separated snake_case columns to return (must be valid for the view). + - page_number, page_size — Integers; page_number ≥ 1; page_size between 1 and 100. + - filters — List of ExplorerUrlFilter; combined with logical AND. + + ExplorerUrlFilter — each element of ExplorerQueryOptions.filters + How to pass: ExplorerUrlFilter(index=0, field="workspace_name", operator="contains", + value="prod", value_index=0). + Allowed: + - index — int ≥ 0 (first filter is 0, then 1, 2, …). + - field — snake_case column name for the current view_type (see Explorer doc View Types). + - operator — one of: is, is_not, contains, does not contain, is_empty, is_not_empty, + gt, lt, gteq, lteq, is_before, is_after (use the exact token your API version documents; + each operator only applies to compatible field types). + - value — str; use ISO 8601 timestamps for is_before / is_after when filtering datetimes. + - value_index — must be 0. + + ExplorerSavedViewCreateOptions — second argument to create_saved_view(org, options) + How to pass: ExplorerSavedViewCreateOptions(name="...", query_type=ExplorerViewType...., + query=ExplorerSavedQuery(...)). + Allowed: + - name — non-empty str. + - query_type — same ExplorerViewType set as view_type (JSON body key query-type). + - query — ExplorerSavedQuery (see below). + + ExplorerSavedViewUpdateOptions — third argument to update_saved_view(org, view_id, options) + How to pass: ExplorerSavedViewUpdateOptions(name="...", query=ExplorerSavedQuery(...)). + PATCH replaces the stored query entirely—send a full ExplorerSavedQuery each time. + + ExplorerSavedQuery — nested only inside create/update options + How to pass: ExplorerSavedQuery(query_type=ExplorerViewType.WORKSPACES, filter=[...], + fields=[...], sort=[...]). + Allowed: + - query_type — required; same values as ExplorerQueryOptions.view_type (JSON key type). + - filter — optional list of ExplorerSavedQueryFilter(field=..., operator=..., value=[...]). + - fields — optional list of snake_case column names. + - sort — optional list of field names; leading "-" on an entry means descending. + + ExplorerSavedQueryFilter — one dict-like row inside ExplorerSavedQuery.filter + How to pass: ExplorerSavedQueryFilter(field="workspace_name", operator="contains", + value=["prod"]). + Allowed: field and operator follow the same rules as URL filters; value is always a + list of strings (even for a single operand). + + Output models (return values only; you do not instantiate these for requests) + ExplorerRow — from query(), saved_view_results(): read .id, .row_type, .attributes. + .attributes is a dict of column values; keys may be hyphenated or snake_case depending + on the API field name. + ExplorerSavedView — from create_saved_view, read_saved_view, update_saved_view, + delete_saved_view, list_saved_views: .id, .name, .created_at, .query_type, .query. + str — from export_csv, saved_view_results_csv: raw CSV document body. + Iterator[...] — lazy streams; consume with for-loops or list(...) if you need a list. + + SCRIPT SECTIONS + ─────────────── + Sections 1 through 3 always run (read-only): query, export_csv, list_saved_views. + Sections 4 through 6 run when TFE_EXPLORER_VIEW_ID is set: read_saved_view, + saved_view_results, saved_view_results_csv. + Section 7 runs when TFE_EXPLORER_DEMO_MUTATIONS=1: create_saved_view, + update_saved_view, delete_saved_view. + + HOW TO RUN + ────────── + From the repository root, install in editable mode, then execute this file: + pip install -e . + python examples/explorer.py + + + ENVIRONMENT VARIABLES + ───────────────────── + TFE_TOKEN Required. API token with Explorer access. + TFE_ADDRESS Optional. API base URL; defaults to https://app.terraform.io + TFE_ORGANIZATION Optional. Organization name (the script substitutes a placeholder if unset). + TFE_EXPLORER_VIEW_ID Optional. When set, exercises saved-view read and export paths (sections 4–6). + TFE_EXPLORER_DEMO_MUTATIONS Optional. Allowed value to enable writes: 1 only. + Any other value skips section 7 (create, update, delete). +""" + +from __future__ import annotations + +import os +import sys +import textwrap +import uuid + +from pytfe import TFEClient, TFEConfig +from pytfe.errors import TFEError +from pytfe.models import ( + ExplorerQueryOptions, + ExplorerSavedQuery, + ExplorerSavedQueryFilter, + ExplorerSavedViewCreateOptions, + ExplorerSavedViewUpdateOptions, + ExplorerUrlFilter, + ExplorerViewType, +) + +_LINE = "-" * 72 + + +def _banner(title: str, subtitle: str = "") -> None: + """Print a plain section divider and title for stdout readability.""" + print(f"\n{_LINE}\n{title}") + if subtitle: + print(subtitle) + print(_LINE) + + +def _print_csv_lines(label: str, csv_text: str, max_chars: int, max_lines: int) -> None: + """Print a readable, line-oriented slice of a CSV string without decorative framing.""" + snippet = csv_text[:max_chars] + truncated = len(csv_text) > max_chars + lines = snippet.splitlines() or ([snippet] if snippet else ["(empty)"]) + print(label) + for raw in lines[:max_lines]: + display = raw if len(raw) <= 68 else raw[:67] + "..." + print(f" {display}") + if len(lines) > max_lines: + print( + f" ... ({len(lines) - max_lines} more line(s) not shown in this preview)" + ) + if truncated: + print( + f" (Preview truncated by character limit; full length {len(csv_text):,} chars.)" + ) + + +def main() -> None: + """Execute the Explorer walkthrough; refer to the module docstring for API details.""" + token = os.getenv("TFE_TOKEN") + if not token: + print( + "Error: TFE_TOKEN is not set. Export a valid API token before running this example." + ) + sys.exit(1) + + address = os.getenv("TFE_ADDRESS", "https://app.terraform.io") + org = os.getenv("TFE_ORGANIZATION", "your-org-name") + view_id = os.getenv("TFE_EXPLORER_VIEW_ID") + demo_mutations = os.getenv("TFE_EXPLORER_DEMO_MUTATIONS") == "1" + + # TFEClient is the entry point for all Terraform Enterprise / HCP Terraform API + # access in this SDK. TFEConfig carries the base URL and bearer token; every + # resource (including explorer) uses the same underlying HTTP session. + client = TFEClient(TFEConfig(address=address, token=token)) + + _banner( + "Terraform Explorer API example", + f"Organization: {org!r}\nAPI base URL: {address}", + ) + + # ------------------------------------------------------------------------- + # Step 1: client.explorer.query(organization, options) + # ------------------------------------------------------------------------- + # Runs GET .../organizations/{org}/explorer with query-string parameters derived + # from ExplorerQueryOptions. Here we request the workspaces view, sort by + # workspace_name descending (leading hyphen in sort), and add a single URL-style + # filter (workspace_name contains "42"). The iterator yields ExplorerRow objects + # (id, row_type, attributes dict); we only print the first five rows. + _banner( + "Step 1 of 7: query()", + "Workspaces view, sorted by -workspace_name, filter workspace_name contains '42'.", + ) + query_opts = ExplorerQueryOptions( + view_type=ExplorerViewType.WORKSPACES, + sort="-workspace_name", + filters=[ + ExplorerUrlFilter( + index=0, + field="workspace_name", + operator="contains", + value="42", + ), + ], + ) + try: + count = 0 + for i, row in enumerate(client.explorer.query(org, query_opts)): + if i >= 5: + break + count += 1 + name = row.attributes.get("workspace-name") or row.attributes.get( + "workspace_name" + ) + print(f" Row {count}:") + print(f" id: {row.id}") + print(f" row_type: {row.row_type!r}") + print(f" workspace_name: {name!r}") + print(" ---") + print(f"Summary: printed {count} row(s) (limit 5).") + except TFEError as e: + print(f" API error: {e}") + except Exception as e: + print(f" Error: {e}") + + # ------------------------------------------------------------------------- + # Step 2: client.explorer.export_csv(organization, options) + # ------------------------------------------------------------------------- + # Same query parameters as query(), but the response is a single CSV document + # (full unpaged export per API semantics). We only print an opening slice so the + # terminal stays readable. + _banner( + "Step 2 of 7: export_csv()", + "Workspaces view, no filters; preview first 400 characters / up to 8 lines.", + ) + try: + csv_text = client.explorer.export_csv( + org, ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES) + ) + _print_csv_lines( + "CSV preview (document may be large):", + csv_text, + max_chars=400, + max_lines=8, + ) + print("Summary: export_csv completed.") + except TFEError as e: + print(f" API error: {e}") + except Exception as e: + print(f" Error: {e}") + + # ------------------------------------------------------------------------- + # Step 3: client.explorer.list_saved_views(organization) + # ------------------------------------------------------------------------- + # GET .../organizations/{org}/explorer/views returns every saved Explorer view + # (saved query) in the organization. Each item is an ExplorerSavedView with id, + # name, query, and query_type. + _banner( + "Step 3 of 7: list_saved_views()", + "Iterate all saved views; print id, name, and query_type for each.", + ) + try: + n = 0 + for sv in client.explorer.list_saved_views(org): + n += 1 + print(f" Saved view {n}:") + print(f" id: {sv.id}") + print(f" name: {sv.name!r}") + print(f" query_type: {sv.query_type!r}") + print(" ---") + print(f"Summary: listed {n} saved view(s).") + except TFEError as e: + print(f" API error: {e}") + except Exception as e: + print(f" Error: {e}") + + if view_id: + # --------------------------------------------------------------------- + # Step 4: client.explorer.read_saved_view(organization, view_id) + # --------------------------------------------------------------------- + # GET .../explorer/views/{view_id} fetches one saved view definition (not the + # materialized result rows). view_id must be an id returned by list or create. + _banner( + "Step 4 of 7: read_saved_view()", + f"view_id from TFE_EXPLORER_VIEW_ID: {view_id!r}", + ) + try: + sv = client.explorer.read_saved_view(org, view_id) + print(" Saved view record:") + print(f" id: {sv.id}") + print(f" name: {sv.name!r}") + q_preview = textwrap.shorten(repr(sv.query), width=68, placeholder=" ...") + print(f" query: {q_preview}") + print(f" query_type: {sv.query_type!r}") + print("Summary: read_saved_view completed.") + except TFEError as e: + print(f" API error: {e}") + + # --------------------------------------------------------------------- + # Step 5: client.explorer.saved_view_results(organization, view_id) + # --------------------------------------------------------------------- + # GET .../explorer/views/{view_id}/results re-executes the saved query and + # streams ExplorerRow results (same shape as query()). We print the first three. + _banner( + "Step 5 of 7: saved_view_results()", + "First 3 rows from re-running the saved view query.", + ) + try: + for i, row in enumerate(client.explorer.saved_view_results(org, view_id)): + if i >= 3: + break + print(f" Result row {i + 1}:") + print(f" id: {row.id}") + print(f" row_type: {row.row_type!r}") + print(" ---") + print("Summary: saved_view_results completed (limit 3 rows printed).") + except TFEError as e: + print(f" API error: {e}") + + # --------------------------------------------------------------------- + # Step 6: client.explorer.saved_view_results_csv(organization, view_id) + # --------------------------------------------------------------------- + # Intended to match GET .../explorer/views/{view_id}/csv. This SDK may fall + # back to export_csv after read_saved_view, or synthesize CSV from results, + # when the dedicated CSV route is unavailable. + _banner( + "Step 6 of 7: saved_view_results_csv()", + "Preview first 300 characters / up to 6 lines; fallbacks may apply.", + ) + try: + csv_sv = client.explorer.saved_view_results_csv(org, view_id) + _print_csv_lines( + "CSV preview:", + csv_sv, + max_chars=300, + max_lines=6, + ) + print("Summary: saved_view_results_csv completed.") + except TFEError as e: + print(f" API error: {e}") + note = textwrap.fill( + "Note: A 404 often means the saved view was removed, the id belongs to " + "another organization, or this deployment has no dedicated CSV route. " + "The client retries via export_csv after read_saved_view, then builds " + "CSV from saved_view_results. If step 5 worked, confirm an editable " + "install (pip install -e .).", + width=70, + subsequent_indent=" ", + ) + for line in note.splitlines(): + print(f" {line}") + else: + _banner( + "Steps 4 through 6 skipped", + "Set environment variable TFE_EXPLORER_VIEW_ID to the saved view id to run " + "read_saved_view, saved_view_results, and saved_view_results_csv.", + ) + + if demo_mutations: + suffix = uuid.uuid4().hex[:8] + base_name = f"python-tfe-explorer-example-{suffix}" + _banner( + "Step 7 of 7: create_saved_view, update_saved_view, delete_saved_view", + f"Uses a unique temporary name so reruns do not collide: {base_name!r}", + ) + try: + # ExplorerSavedViewCreateOptions maps to POST .../explorer/views: a display + # name, the primary query_type for the saved definition, and an embedded + # ExplorerSavedQuery (view type, optional filters with list-valued operands). + create_opts = ExplorerSavedViewCreateOptions( + name=base_name, + query_type=ExplorerViewType.WORKSPACES, + query=ExplorerSavedQuery( + query_type=ExplorerViewType.WORKSPACES, + filter=[ + ExplorerSavedQueryFilter( + field="workspace_name", + operator="contains", + value=["test"], + ) + ], + ), + ) + # client.explorer.create_saved_view persists a new saved view; the response + # includes the server-assigned id required for subsequent update/delete. + created = client.explorer.create_saved_view(org, create_opts) + print(f" create_saved_view: new id {created.id}") + + # ExplorerSavedViewUpdateOptions maps to PATCH: at minimum a new name and + # a full replacement ExplorerSavedQuery payload for the stored definition. + update_opts = ExplorerSavedViewUpdateOptions( + name=f"{base_name}-updated", + query=ExplorerSavedQuery( + query_type=ExplorerViewType.WORKSPACES, + filter=[ + ExplorerSavedQueryFilter( + field="workspace_name", + operator="contains", + value=["demo"], + ) + ], + ), + ) + # client.explorer.update_saved_view applies the patch to the id returned + # from create_saved_view in this demonstration sequence. + updated = client.explorer.update_saved_view(org, created.id, update_opts) + print(f" update_saved_view: name is now {updated.name!r}") + + # client.explorer.delete_saved_view removes the saved view; some API + # responses omit JSON, in which case the client still returns a minimal + # ExplorerSavedView carrying the deleted id. + deleted = client.explorer.delete_saved_view(org, created.id) + print(f" delete_saved_view: completed for id {deleted.id}") + print("Summary: mutation sequence finished.") + except TFEError as e: + print(f" API error: {e}") + sys.exit(1) + else: + _banner( + "Step 7 skipped", + "Set TFE_EXPLORER_DEMO_MUTATIONS=1 to run create_saved_view, " + "update_saved_view, and delete_saved_view (writes to your organization).", + ) + + print(f"\n{_LINE}\nExample completed.\n{_LINE}") + + +if __name__ == "__main__": + main() diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 30b506b9..ec7de12a 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -9,6 +9,7 @@ from .resources.agents import Agents, AgentTokens from .resources.apply import Applies from .resources.configuration_version import ConfigurationVersions +from .resources.explorer import Explorer from .resources.notification_configuration import NotificationConfigurations from .resources.oauth_client import OAuthClients from .resources.oauth_token import OAuthTokens @@ -72,6 +73,9 @@ def __init__(self, config: TFEConfig | None = None): self.plans = Plans(self._transport) self.organizations = Organizations(self._transport) self.organization_memberships = OrganizationMemberships(self._transport) + self.explorer = Explorer( + self._transport + ) # org Explorer queries and saved views self.projects = Projects(self._transport) self.variables = Variables(self._transport) diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index e913f6d4..40acad3d 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -372,6 +372,13 @@ def __init__(self, message: str = "invalid value for query run ID"): super().__init__(message) +class InvalidExplorerSavedViewIDError(InvalidValues): + """Raised when a saved view id is missing or blank (Explorer view-scoped routes).""" + + def __init__(self, message: str = "invalid value for explorer saved view ID"): + super().__init__(message) + + class TerraformVersionValidForPlanOnlyError(ValidationError): """Raised when terraform_version is set without plan_only being true.""" diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index 0f1435d8..fb12856b 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -58,6 +58,17 @@ DataRetentionPolicyDontDeleteSetOptions, DataRetentionPolicySetOptions, ) +from .explorer import ( + ExplorerQueryOptions, + ExplorerRow, + ExplorerSavedQuery, + ExplorerSavedQueryFilter, + ExplorerSavedView, + ExplorerSavedViewCreateOptions, + ExplorerSavedViewUpdateOptions, + ExplorerUrlFilter, + ExplorerViewType, +) # ── OAuth ───────────────────────────────────────────────────────────────────── from .oauth_client import ( @@ -484,6 +495,16 @@ "QueryRunStatus", "QueryRunStatusTimestamps", "QueryRunVariable", + # Explorer + "ExplorerQueryOptions", + "ExplorerRow", + "ExplorerSavedQuery", + "ExplorerSavedQueryFilter", + "ExplorerSavedView", + "ExplorerSavedViewCreateOptions", + "ExplorerSavedViewUpdateOptions", + "ExplorerUrlFilter", + "ExplorerViewType", # Core (from old types.py, now split) "Entitlements", "ExecutionMode", diff --git a/src/pytfe/models/explorer.py b/src/pytfe/models/explorer.py new file mode 100644 index 00000000..28335abf --- /dev/null +++ b/src/pytfe/models/explorer.py @@ -0,0 +1,123 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Pydantic models for the Explorer API (query options, rows, saved views). + +Aliases mirror JSON:API and Explorer query-string names (type, page[number], etc.). +""" + +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class ExplorerViewType(str, Enum): + """Explorer `type` / `query-type` discriminator (see product docs for supported views).""" + + WORKSPACES = "workspaces" + TF_VERSIONS = "tf_versions" + PROVIDERS = "providers" + MODULES = "modules" + RESOURCES = "resources" # Present when the deployment exposes a resources view. + + +class ExplorerUrlFilter(BaseModel): + """One slot in ExplorerQueryOptions.filters → filter[i][field][op][idx] query keys.""" + + index: int = Field(..., ge=0, description="Filter index in the query string") + field: str = Field( + ..., min_length=1, description="Explorer field name in snake_case" + ) + operator: str = Field(..., min_length=1, description="Explorer filter operator") + value: str = Field(..., description="Filter value") + value_index: int = Field( + 0, + ge=0, + description="Reserved index for filter value; currently expected as zero", + ) + + +class ExplorerQueryOptions(BaseModel): + """GET /organizations/{org}/explorer (and export/csv) query string as structured fields.""" + + model_config = ConfigDict(populate_by_name=True) + + view_type: ExplorerViewType = Field(..., alias="type") + sort: str | None = Field( + None, + description="Sort field (snake_case); prefix with '-' for descending order", + ) + fields: str | None = Field( + None, + description="Comma-separated list of fields to include in each row", + ) + page_number: int | None = Field(None, alias="page[number]", ge=1) + page_size: int | None = Field(None, alias="page[size]", ge=1, le=100) + filters: list[ExplorerUrlFilter] | None = Field( + None, + description="Expanded filter objects mapped to filter[index][field][operator][value_index]", + ) + + +class ExplorerRow(BaseModel): + """One Explorer result row: json:api id/type plus flat attributes for the view.""" + + model_config = ConfigDict(populate_by_name=True) + + id: str + row_type: str = Field(..., alias="type") + attributes: dict[str, Any] = Field(default_factory=dict) + + +class ExplorerSavedQueryFilter(BaseModel): + """One saved-view filter row (list-valued `value` matches create/update JSON).""" + + field: str = Field(..., min_length=1) + operator: str = Field(..., min_length=1) + value: list[str] = Field(default_factory=list) + + +class ExplorerSavedQuery(BaseModel): + """Nested query on a saved view: view type, filters, optional fields and sort lists.""" + + model_config = ConfigDict(populate_by_name=True) + + query_type: ExplorerViewType = Field(..., alias="type") + filter: list[ExplorerSavedQueryFilter] | None = None + fields: list[str] | None = None + sort: list[str] | None = None + + +class ExplorerSavedView(BaseModel): + """Saved view resource: metadata plus embedded query (response and some request paths).""" + + model_config = ConfigDict(populate_by_name=True) + + id: str + name: str + created_at: datetime | None = Field(None, alias="created-at") + query: ExplorerSavedQuery = Field(...) + query_type: ExplorerViewType = Field(..., alias="query-type") + + +class ExplorerSavedViewCreateOptions(BaseModel): + """POST .../explorer/views attributes: display name, top-level query-type, nested query.""" + + model_config = ConfigDict(populate_by_name=True) + + name: str = Field(..., min_length=1) + query_type: ExplorerViewType = Field(..., alias="query-type") + query: ExplorerSavedQuery + + +class ExplorerSavedViewUpdateOptions(BaseModel): + """PATCH .../explorer/views/{id} attributes: name and full replacement query.""" + + model_config = ConfigDict(populate_by_name=True) + + name: str = Field(..., min_length=1) + query: ExplorerSavedQuery diff --git a/src/pytfe/resources/explorer.py b/src/pytfe/resources/explorer.py new file mode 100644 index 00000000..1d4aad18 --- /dev/null +++ b/src/pytfe/resources/explorer.py @@ -0,0 +1,469 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Explorer API resource. + +Maps organization-scoped Explorer endpoints (ad hoc query, CSV export, saved views) to +typed models. Saved-view create/update reshape filter JSON; read paths normalize API +variants before validation. +""" + +from __future__ import annotations + +import csv +import io +import logging +from collections.abc import Iterator +from typing import Any + +from ..errors import ( + InvalidExplorerSavedViewIDError, + InvalidOrgError, + NotFound, + ServerError, + ValidationError, +) +from ..models.explorer import ( + ExplorerQueryOptions, + ExplorerRow, + ExplorerSavedView, + ExplorerSavedViewCreateOptions, + ExplorerSavedViewUpdateOptions, + ExplorerUrlFilter, +) +from ..utils import valid_string_id +from ._base import _Service + +_log = logging.getLogger(__name__) + + +def _explorer_single_resource_data( + resp: Any, + *, + operation: str, + organization: str, + view_id: str | None = None, +) -> dict[str, Any]: + """Parse json:api envelope for a single Explorer saved view; raise ValidationError if unusable.""" + ctx = f"org={organization!r}" + if view_id is not None: + ctx += f" view_id={view_id!r}" + try: + payload = resp.json() + except ValueError as exc: + _log.warning("explorer.%s: invalid JSON response (%s)", operation, ctx) + raise ValidationError( + f"Explorer {operation}: response body is not valid JSON ({ctx})" + ) from exc + if not isinstance(payload, dict): + _log.warning( + "explorer.%s: top-level JSON is not an object (%s)", operation, ctx + ) + raise ValidationError( + f"Explorer {operation}: expected JSON object at top level ({ctx})" + ) + data = payload.get("data") + if not isinstance(data, dict): + _log.warning( + "explorer.%s: missing or invalid 'data' (type=%s) (%s)", + operation, + type(data).__name__, + ctx, + ) + raise ValidationError( + f"Explorer {operation}: expected json:api 'data' object ({ctx})" + ) + return data + + +def _require_organization(organization: str) -> None: + """Reject blank organization identifiers before building paths.""" + if not valid_string_id(organization): + raise InvalidOrgError() + + +def _require_organization_and_view(organization: str, view_id: str) -> None: + """Validate org and saved-view id for routes under .../explorer/views/{view_id}.""" + _require_organization(organization) + if not valid_string_id(view_id): + raise InvalidExplorerSavedViewIDError() + + +def _write_attributes_with_query_shape( + options: ExplorerSavedViewCreateOptions | ExplorerSavedViewUpdateOptions, +) -> dict[str, Any]: + """Serialize create/update options; map saved-query filters to the map shape POST/PATCH expect.""" + attrs = options.model_dump(by_alias=True, exclude_none=True, mode="json") + raw_query = attrs.get("query") + if isinstance(raw_query, dict): + attrs["query"] = _saved_query_to_api_shape(raw_query) + return attrs + + +def _query_params(options: ExplorerQueryOptions) -> dict[str, Any]: + # mode="json" keeps ExplorerViewType as strings; filters are expanded separately (Explorer URL grammar). + params = options.model_dump( + by_alias=True, + exclude_none=True, + exclude={"filters"}, + mode="json", + ) + if options.filters: + for flt in options.filters: + params[ + f"filter[{flt.index}][{flt.field}][{flt.operator}][{flt.value_index}]" + ] = flt.value + return params + + +def _parse_row(item: dict[str, Any]) -> ExplorerRow: + return ExplorerRow.model_validate(item) + + +def _saved_query_to_api_shape(raw_query: dict[str, Any]) -> dict[str, Any]: + """Map {field, operator, value} filter rows to nested {field: {operator: [...]}} JSON.""" + query = dict(raw_query) + raw_filter = query.get("filter") + if isinstance(raw_filter, list): + mapped_filters: list[dict[str, Any]] = [] + for entry in raw_filter: + if not isinstance(entry, dict): + continue + # Already API-compatible map style. + if "field" not in entry or "operator" not in entry: + mapped_filters.append(entry) + continue + field = str(entry.get("field", "")).replace("-", "_") + operator = str(entry.get("operator", "")) + values = entry.get("value", []) + if not isinstance(values, list): + values = [values] + mapped_filters.append({field: {operator: [str(v) for v in values]}}) + query["filter"] = mapped_filters + return query + + +def _normalize_saved_query( + raw_query: dict[str, Any], raw_query_type: str | None +) -> dict[str, Any]: + """Coerce saved-view query JSON into the flat filter + list fields shape our models use.""" + query = dict(raw_query) + + if "type" not in query and raw_query_type: + query["type"] = raw_query_type + + raw_filter = query.get("filter") + if isinstance(raw_filter, list): + normalized_filters: list[dict[str, Any]] = [] + for entry in raw_filter: + # Variant A (documented): {"field": "...", "operator": "...", "value": [...]} + if isinstance(entry, dict) and "field" in entry and "operator" in entry: + value = entry.get("value") + if value is None: + value = [] + if not isinstance(value, list): + value = [str(value)] + normalized_filters.append( + { + "field": str(entry["field"]).replace("-", "_"), + "operator": str(entry["operator"]), + "value": [str(v) for v in value], + } + ) + continue + + # Variant B (observed): {"workspace-name": {"contains": ["foo"]}} + if isinstance(entry, dict): + for field_name, operators in entry.items(): + if not isinstance(operators, dict): + continue + for operator, values in operators.items(): + vals = values if isinstance(values, list) else [values] + normalized_filters.append( + { + "field": str(field_name).replace("-", "_"), + "operator": str(operator), + "value": [str(v) for v in vals], + } + ) + query["filter"] = normalized_filters + + raw_fields = query.get("fields") + # Some responses return fields as {"workspaces": [...]}. + if isinstance(raw_fields, dict): + list_values: list[str] = [] + for value in raw_fields.values(): + if isinstance(value, list): + list_values.extend(str(v) for v in value) + query["fields"] = list_values + + return query + + +def _parse_saved_view(item: dict[str, Any]) -> ExplorerSavedView: + # json:api envelope: attributes carry name, timestamps, nested query and query-type. + attrs = item.get("attributes", {}) + query_type = attrs.get("query-type") + query = attrs.get("query", {}) + if not isinstance(query, dict): + query = {} + + return ExplorerSavedView.model_validate( + { + "id": item.get("id"), + "name": attrs.get("name"), + "created-at": attrs.get("created-at"), + "query": _normalize_saved_query(query, query_type), + "query-type": query_type, + } + ) + + +def _deleted_saved_view_fallback(view_id: str) -> ExplorerSavedView: + """Build a minimal saved view when delete responses have no body.""" + return ExplorerSavedView.model_validate( + { + "id": view_id, + "name": "", + "query-type": "workspaces", + "query": {"type": "workspaces"}, + } + ) + + +def _query_options_from_saved_view( + saved_view: ExplorerSavedView, +) -> ExplorerQueryOptions: + """Replay a stored saved query as GET /explorer query params (used by CSV fallback).""" + query = saved_view.query + filters: list[ExplorerUrlFilter] = [] + if query.filter: + for idx, flt in enumerate(query.filter): + for value_index, value in enumerate(flt.value or []): + filters.append( + ExplorerUrlFilter( + index=idx, + field=flt.field, + operator=flt.operator, + value=str(value), + value_index=value_index, + ) + ) + return ExplorerQueryOptions.model_validate( + { + "type": saved_view.query_type, + "sort": ",".join(query.sort) if query.sort else None, + "fields": ",".join(query.fields) if query.fields else None, + "filters": filters or None, + } + ) + + +def _rows_to_csv(rows: list[ExplorerRow]) -> str: + """Union of row attribute keys as header; last-resort CSV when /views/.../csv is unavailable.""" + if not rows: + return "" + keys: set[str] = set() + for row in rows: + keys.update(row.attributes.keys()) + fieldnames = sorted(keys) + buf = io.StringIO() + writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction="ignore") + writer.writeheader() + for row in rows: + writer.writerow({k: row.attributes.get(k, "") for k in fieldnames}) + return buf.getvalue() + + +class Explorer(_Service): + """Organization Explorer: ad hoc queries, CSV export, and saved view CRUD.""" + + def query( + self, organization: str, options: ExplorerQueryOptions + ) -> Iterator[ExplorerRow]: + _require_organization(organization) + _log.debug( + "explorer.query org=%r view_type=%s", + organization, + options.view_type.value, + ) + # GET .../explorer — paginated JSON rows for the given view and filters. + path = f"/api/v2/organizations/{organization}/explorer" + for item in self._list(path, params=_query_params(options)): + yield _parse_row(item) + + def export_csv(self, organization: str, options: ExplorerQueryOptions) -> str: + _require_organization(organization) + _log.debug( + "explorer.export_csv org=%r view_type=%s", + organization, + options.view_type.value, + ) + # Same query string as query(); response is a single unpaged CSV document. + path = f"/api/v2/organizations/{organization}/explorer/export/csv" + resp = self.t.request("GET", path, params=_query_params(options)) + return resp.text + + def list_saved_views(self, organization: str) -> Iterator[ExplorerSavedView]: + _require_organization(organization) + _log.debug("explorer.list_saved_views org=%r", organization) + # GET collection of explorer-saved-queries for the org. + path = f"/api/v2/organizations/{organization}/explorer/views" + for item in self._list(path): + yield _parse_saved_view(item) + + def create_saved_view( + self, organization: str, options: ExplorerSavedViewCreateOptions + ) -> ExplorerSavedView: + _require_organization(organization) + # POST json:api explorer-saved-queries; filters rewritten for server expectations. + attrs = _write_attributes_with_query_shape(options) + body = { + "data": { + "type": "explorer-saved-queries", + "attributes": attrs, + } + } + path = f"/api/v2/organizations/{organization}/explorer/views" + resp = self.t.request("POST", path, json_body=body) + data = _explorer_single_resource_data( + resp, operation="create_saved_view", organization=organization + ) + view = _parse_saved_view(data) + _log.info("explorer.create_saved_view org=%r id=%r", organization, view.id) + return view + + def read_saved_view(self, organization: str, view_id: str) -> ExplorerSavedView: + _require_organization_and_view(organization, view_id) + _log.debug( + "explorer.read_saved_view org=%r view_id=%r", + organization, + view_id, + ) + # Returns stored definition only; does not execute the query (see saved_view_results). + path = f"/api/v2/organizations/{organization}/explorer/views/{view_id}" + resp = self.t.request("GET", path) + data = _explorer_single_resource_data( + resp, + operation="read_saved_view", + organization=organization, + view_id=view_id, + ) + return _parse_saved_view(data) + + def update_saved_view( + self, + organization: str, + view_id: str, + options: ExplorerSavedViewUpdateOptions, + ) -> ExplorerSavedView: + _require_organization_and_view(organization, view_id) + attrs = _write_attributes_with_query_shape(options) + # PATCH includes resource id in the envelope per json:api update conventions. + body = { + "data": { + "type": "explorer-saved-queries", + "id": view_id, + "attributes": attrs, + } + } + path = f"/api/v2/organizations/{organization}/explorer/views/{view_id}" + resp = self.t.request("PATCH", path, json_body=body) + data = _explorer_single_resource_data( + resp, + operation="update_saved_view", + organization=organization, + view_id=view_id, + ) + view = _parse_saved_view(data) + _log.info("explorer.update_saved_view org=%r id=%r", organization, view.id) + return view + + def delete_saved_view(self, organization: str, view_id: str) -> ExplorerSavedView: + _require_organization_and_view(organization, view_id) + path = f"/api/v2/organizations/{organization}/explorer/views/{view_id}" + resp = self.t.request("DELETE", path) + # DELETE often returns an empty body; callers still receive a minimal ExplorerSavedView. + raw_text = (resp.text or "").strip() + if not raw_text: + _log.debug( + "explorer.delete_saved_view: empty body, returning stub org=%r id=%r", + organization, + view_id, + ) + return _deleted_saved_view_fallback(view_id) + + try: + payload = resp.json() + except ValueError: + _log.debug( + "explorer.delete_saved_view: non-JSON body, returning stub org=%r id=%r", + organization, + view_id, + ) + return _deleted_saved_view_fallback(view_id) + + if isinstance(payload, dict) and isinstance(payload.get("data"), dict): + return _parse_saved_view(payload["data"]) + _log.debug( + "explorer.delete_saved_view: no data object, returning stub org=%r id=%r", + organization, + view_id, + ) + return _deleted_saved_view_fallback(view_id) + + def saved_view_results( + self, organization: str, view_id: str + ) -> Iterator[ExplorerRow]: + _require_organization_and_view(organization, view_id) + _log.debug( + "explorer.saved_view_results org=%r view_id=%r", + organization, + view_id, + ) + # Re-runs the saved query; rows match ad hoc query() shape (current data only). + path = f"/api/v2/organizations/{organization}/explorer/views/{view_id}/results" + for item in self._list(path): + yield _parse_row(item) + + def saved_view_results_csv(self, organization: str, view_id: str) -> str: + _require_organization_and_view(organization, view_id) + _log.debug( + "explorer.saved_view_results_csv org=%r view_id=%r", + organization, + view_id, + ) + path = f"/api/v2/organizations/{organization}/explorer/views/{view_id}/csv" + try: + resp = self.t.request("GET", path) + return resp.text + except (NotFound, ServerError) as exc: + _log.info( + "explorer.saved_view_results_csv: primary CSV route unavailable (%s); " + "trying export_csv replay org=%r view_id=%r", + exc.__class__.__name__, + organization, + view_id, + ) + + # Fall back: replay saved definition via export_csv, then row materialization if needed. + try: + saved_view = self.read_saved_view(organization, view_id) + options = _query_options_from_saved_view(saved_view) + csv_text = self.export_csv(organization, options) + _log.info( + "explorer.saved_view_results_csv: used export_csv fallback org=%r view_id=%r", + organization, + view_id, + ) + return csv_text + except (NotFound, ServerError) as exc: + _log.warning( + "explorer.saved_view_results_csv: export_csv fallback failed (%s); " + "building CSV from row stream org=%r view_id=%r", + exc.__class__.__name__, + organization, + view_id, + ) + rows = list(self.saved_view_results(organization, view_id)) + return _rows_to_csv(rows) diff --git a/tests/units/test_explorer.py b/tests/units/test_explorer.py new file mode 100644 index 00000000..91094538 --- /dev/null +++ b/tests/units/test_explorer.py @@ -0,0 +1,387 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for Explorer API resource.""" + +from unittest.mock import Mock + +import pytest + +from pytfe.errors import ( + InvalidExplorerSavedViewIDError, + InvalidOrgError, + NotFound, + ValidationError, +) +from pytfe.models import ( + ExplorerQueryOptions, + ExplorerSavedQuery, + ExplorerSavedQueryFilter, + ExplorerSavedViewCreateOptions, + ExplorerSavedViewUpdateOptions, + ExplorerUrlFilter, + ExplorerViewType, +) +from pytfe.resources.explorer import Explorer + + +@pytest.fixture +def mock_transport(): + return Mock() + + +@pytest.fixture +def explorer_service(mock_transport): + return Explorer(mock_transport) + + +def _row_payload(row_id: str) -> dict: + return { + "id": row_id, + "type": "visibility-workspace", + "attributes": {"workspace-name": "demo-workspace"}, + } + + +def _saved_view_payload(view_id: str) -> dict: + return { + "id": view_id, + "type": "explorer-saved-queries", + "attributes": { + "name": "my-view", + "created-at": "2024-10-11T16:18:51.442Z", + "query-type": "workspaces", + "query": { + "type": "workspaces", + "filter": [ + { + "field": "workspace_name", + "operator": "contains", + "value": ["child"], + } + ], + }, + }, + } + + +def _saved_view_payload_live_variant(view_id: str) -> dict: + return { + "id": view_id, + "type": "explorer-saved-queries", + "attributes": { + "name": "my-view", + "created-at": "2024-10-11T16:18:51.442Z", + "query-type": "workspaces", + "query": { + "filter": [{"workspace-name": {"contains": ["r2l7cj4v"]}}], + "fields": {"workspaces": []}, + }, + }, + } + + +class TestExplorerQuery: + def test_query_with_filter_and_pagination(self, explorer_service, mock_transport): + first = Mock() + first.json.return_value = {"data": [_row_payload("ws-1")]} + second = Mock() + second.json.return_value = {"data": []} + mock_transport.request.side_effect = [first, second] + + options = ExplorerQueryOptions( + view_type=ExplorerViewType.WORKSPACES, + sort="-workspace_name", + fields="workspace_name,organization_name", + page_size=1, + filters=[ + ExplorerUrlFilter( + index=0, + field="workspace_name", + operator="contains", + value="test", + ) + ], + ) + + rows = list(explorer_service.query("acme", options)) + assert len(rows) == 1 + assert rows[0].id == "ws-1" + assert rows[0].row_type == "visibility-workspace" + + first_call = mock_transport.request.call_args_list[0] + assert first_call[0][0] == "GET" + assert first_call[0][1] == "/api/v2/organizations/acme/explorer" + params = first_call[1]["params"] + assert params["type"] == "workspaces" + assert params["sort"] == "-workspace_name" + assert params["fields"] == "workspace_name,organization_name" + assert params["page[size]"] == 1 + assert params["filter[0][workspace_name][contains][0]"] == "test" + + def test_query_invalid_org(self, explorer_service): + with pytest.raises(InvalidOrgError): + list( + explorer_service.query( + "", + ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES), + ) + ) + + def test_export_csv(self, explorer_service, mock_transport): + response = Mock() + response.text = "workspace_name\nexample\n" + mock_transport.request.return_value = response + + csv_text = explorer_service.export_csv( + "acme", ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES) + ) + + assert "workspace_name" in csv_text + mock_transport.request.assert_called_once_with( + "GET", + "/api/v2/organizations/acme/explorer/export/csv", + params={"type": "workspaces"}, + ) + + +class TestExplorerSavedViews: + def test_list_saved_views(self, explorer_service, mock_transport): + response = Mock() + response.json.return_value = {"data": [_saved_view_payload("sq-1")]} + mock_transport.request.return_value = response + + views = list(explorer_service.list_saved_views("acme")) + assert len(views) == 1 + assert views[0].id == "sq-1" + assert views[0].query_type == ExplorerViewType.WORKSPACES + assert views[0].query.query_type == ExplorerViewType.WORKSPACES + + def test_create_saved_view(self, explorer_service, mock_transport): + response = Mock() + response.json.return_value = {"data": _saved_view_payload("sq-new")} + mock_transport.request.return_value = response + + options = ExplorerSavedViewCreateOptions( + name="my-view", + query_type=ExplorerViewType.WORKSPACES, + query=ExplorerSavedQuery( + query_type=ExplorerViewType.WORKSPACES, + filter=[ + ExplorerSavedQueryFilter( + field="workspace_name", operator="contains", value=["test"] + ) + ], + ), + ) + view = explorer_service.create_saved_view("acme", options) + + assert view.id == "sq-new" + call = mock_transport.request.call_args + assert call[0][0] == "POST" + assert call[0][1] == "/api/v2/organizations/acme/explorer/views" + body = call[1]["json_body"] + assert body["data"]["type"] == "explorer-saved-queries" + assert body["data"]["attributes"]["query-type"] == "workspaces" + assert body["data"]["attributes"]["query"]["filter"] == [ + {"workspace_name": {"contains": ["test"]}} + ] + + def test_create_saved_view_invalid_json_raises( + self, explorer_service, mock_transport + ): + response = Mock() + response.json.side_effect = ValueError("invalid json") + mock_transport.request.return_value = response + + options = ExplorerSavedViewCreateOptions( + name="my-view", + query_type=ExplorerViewType.WORKSPACES, + query=ExplorerSavedQuery(query_type=ExplorerViewType.WORKSPACES), + ) + with pytest.raises(ValidationError, match="create_saved_view"): + explorer_service.create_saved_view("acme", options) + + def test_read_saved_view_missing_data_object_raises( + self, explorer_service, mock_transport + ): + response = Mock() + response.json.return_value = {"data": []} + mock_transport.request.return_value = response + + with pytest.raises(ValidationError, match="read_saved_view"): + explorer_service.read_saved_view("acme", "sq-1") + + def test_read_saved_view(self, explorer_service, mock_transport): + response = Mock() + response.json.return_value = {"data": _saved_view_payload("sq-1")} + mock_transport.request.return_value = response + + view = explorer_service.read_saved_view("acme", "sq-1") + assert view.id == "sq-1" + + mock_transport.request.assert_called_once_with( + "GET", "/api/v2/organizations/acme/explorer/views/sq-1" + ) + + def test_read_saved_view_with_live_query_shape( + self, explorer_service, mock_transport + ): + response = Mock() + response.json.return_value = {"data": _saved_view_payload_live_variant("sq-2")} + mock_transport.request.return_value = response + + view = explorer_service.read_saved_view("acme", "sq-2") + + assert view.id == "sq-2" + assert view.query.query_type == ExplorerViewType.WORKSPACES + assert view.query.filter is not None + assert view.query.filter[0].field == "workspace_name" + assert view.query.filter[0].operator == "contains" + assert view.query.filter[0].value == ["r2l7cj4v"] + assert view.query.fields == [] + + def test_update_saved_view(self, explorer_service, mock_transport): + response = Mock() + response.json.return_value = {"data": _saved_view_payload("sq-1")} + mock_transport.request.return_value = response + + options = ExplorerSavedViewUpdateOptions( + name="my-view-updated", + query=ExplorerSavedQuery( + query_type=ExplorerViewType.WORKSPACES, + filter=[ + ExplorerSavedQueryFilter( + field="workspace_name", operator="contains", value=["prod"] + ) + ], + ), + ) + view = explorer_service.update_saved_view("acme", "sq-1", options) + + assert view.id == "sq-1" + call = mock_transport.request.call_args + assert call[0][0] == "PATCH" + assert call[0][1] == "/api/v2/organizations/acme/explorer/views/sq-1" + assert call[1]["json_body"]["data"]["id"] == "sq-1" + assert call[1]["json_body"]["data"]["attributes"]["name"] == "my-view-updated" + assert call[1]["json_body"]["data"]["attributes"]["query"]["filter"] == [ + {"workspace_name": {"contains": ["prod"]}} + ] + + def test_delete_saved_view(self, explorer_service, mock_transport): + response = Mock() + response.json.return_value = {"data": _saved_view_payload("sq-1")} + response.text = '{"data":{"id":"sq-1"}}' + mock_transport.request.return_value = response + + view = explorer_service.delete_saved_view("acme", "sq-1") + assert view.id == "sq-1" + + mock_transport.request.assert_called_once_with( + "DELETE", "/api/v2/organizations/acme/explorer/views/sq-1" + ) + + def test_delete_saved_view_empty_response(self, explorer_service, mock_transport): + response = Mock() + response.text = "" + response.json.side_effect = ValueError("No JSON body") + mock_transport.request.return_value = response + + view = explorer_service.delete_saved_view("acme", "sq-1") + assert view.id == "sq-1" + + def test_saved_view_results(self, explorer_service, mock_transport): + first = Mock() + first.json.return_value = {"data": [_row_payload("ws-1")]} + second = Mock() + second.json.return_value = {"data": []} + mock_transport.request.side_effect = [first, second] + + rows = list(explorer_service.saved_view_results("acme", "sq-1")) + assert len(rows) == 1 + assert rows[0].id == "ws-1" + + mock_transport.request.assert_any_call( + "GET", + "/api/v2/organizations/acme/explorer/views/sq-1/results", + params={"page[number]": 1, "page[size]": 100}, + ) + + def test_saved_view_results_csv(self, explorer_service, mock_transport): + response = Mock() + response.text = "workspace_name\nexample\n" + mock_transport.request.return_value = response + + csv_text = explorer_service.saved_view_results_csv("acme", "sq-1") + assert "workspace_name" in csv_text + mock_transport.request.assert_called_once_with( + "GET", "/api/v2/organizations/acme/explorer/views/sq-1/csv" + ) + + def test_saved_view_results_csv_fallback_to_export( + self, explorer_service, mock_transport + ): + first = NotFound("not found", status=404) + read_resp = Mock() + read_resp.json.return_value = {"data": _saved_view_payload("sq-1")} + export_resp = Mock() + export_resp.text = "workspace_name\nfrom-export\n" + mock_transport.request.side_effect = [first, read_resp, export_resp] + + csv_text = explorer_service.saved_view_results_csv("acme", "sq-1") + assert "from-export" in csv_text + + def test_saved_view_results_csv_fallback_to_rows( + self, explorer_service, mock_transport + ): + not_found = NotFound("not found", status=404) + read_resp = Mock() + read_resp.json.return_value = {"data": _saved_view_payload("sq-1")} + first_results = Mock() + first_results.json.return_value = {"data": [_row_payload("ws-1")]} + second_results = Mock() + second_results.json.return_value = {"data": []} + mock_transport.request.side_effect = [ + not_found, # /csv + read_resp, # read saved view + not_found, # export_csv fallback fails + first_results, # saved_view_results page 1 + second_results, # saved_view_results page 2 + ] + + csv_text = explorer_service.saved_view_results_csv("acme", "sq-1") + assert "workspace-name" in csv_text + assert "demo-workspace" in csv_text + + @pytest.mark.parametrize("org", ["", None]) + def test_saved_view_methods_invalid_org(self, explorer_service, org): + with pytest.raises(InvalidOrgError): + list(explorer_service.list_saved_views(org)) + + with pytest.raises(InvalidOrgError): + explorer_service.read_saved_view(org, "sq-1") + + @pytest.mark.parametrize("view_id", ["", None]) + def test_saved_view_methods_invalid_id(self, explorer_service, view_id): + with pytest.raises(InvalidExplorerSavedViewIDError): + explorer_service.read_saved_view("acme", view_id) + + with pytest.raises(InvalidExplorerSavedViewIDError): + explorer_service.update_saved_view( + "acme", + view_id, + ExplorerSavedViewUpdateOptions( + name="updated", + query=ExplorerSavedQuery(query_type=ExplorerViewType.WORKSPACES), + ), + ) + + with pytest.raises(InvalidExplorerSavedViewIDError): + explorer_service.delete_saved_view("acme", view_id) + + with pytest.raises(InvalidExplorerSavedViewIDError): + list(explorer_service.saved_view_results("acme", view_id)) + + with pytest.raises(InvalidExplorerSavedViewIDError): + explorer_service.saved_view_results_csv("acme", view_id) From b95204c38b7a3e7cfad82612ca3c9002a24fe4ca Mon Sep 17 00:00:00 2001 From: jasodeep Date: Tue, 28 Apr 2026 03:51:37 +0530 Subject: [PATCH 20/43] :wrench: explorer fixes - Unused TYPE is removed. - CSV header order fixed as per API response. --- examples/explorer.py | 7 +- src/pytfe/models/explorer.py | 3 +- src/pytfe/resources/explorer.py | 207 ++++++++++++++++++++++++++++++-- tests/units/test_explorer.py | 57 ++++++++- 4 files changed, 251 insertions(+), 23 deletions(-) diff --git a/examples/explorer.py b/examples/explorer.py index c75c854a..fc5cd1cc 100644 --- a/examples/explorer.py +++ b/examples/explorer.py @@ -46,9 +46,8 @@ ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES, sort="-workspace_name", filters=[ExplorerUrlFilter(...)]). Required: - - view_type — ExplorerViewType (serialized to HTTP query key type). Allowed strings - per product docs: workspaces, tf_versions, providers, modules. This SDK also - defines resources for APIs that support that view. + - view_type — ExplorerViewType (serialized to HTTP query key type). Allowed values + match HashiCorp docs: workspaces, tf_versions, providers, modules. Optional: - sort — Comma-separated snake_case field names for the active view; prefix "-" for descending order. @@ -355,7 +354,7 @@ def main() -> None: _print_csv_lines( "CSV preview:", csv_sv, - max_chars=300, + max_chars=500, max_lines=6, ) print("Summary: saved_view_results_csv completed.") diff --git a/src/pytfe/models/explorer.py b/src/pytfe/models/explorer.py index 28335abf..66d73aa2 100644 --- a/src/pytfe/models/explorer.py +++ b/src/pytfe/models/explorer.py @@ -16,13 +16,12 @@ class ExplorerViewType(str, Enum): - """Explorer `type` / `query-type` discriminator (see product docs for supported views).""" + """Explorer `type` / `query-type` discriminator (HashiCorp Explorer API view types only).""" WORKSPACES = "workspaces" TF_VERSIONS = "tf_versions" PROVIDERS = "providers" MODULES = "modules" - RESOURCES = "resources" # Present when the deployment exposes a resources view. class ExplorerUrlFilter(BaseModel): diff --git a/src/pytfe/resources/explorer.py b/src/pytfe/resources/explorer.py index 1d4aad18..9f6d7fc3 100644 --- a/src/pytfe/resources/explorer.py +++ b/src/pytfe/resources/explorer.py @@ -30,6 +30,7 @@ ExplorerSavedViewCreateOptions, ExplorerSavedViewUpdateOptions, ExplorerUrlFilter, + ExplorerViewType, ) from ..utils import valid_string_id from ._base import _Service @@ -259,19 +260,189 @@ def _query_options_from_saved_view( ) -def _rows_to_csv(rows: list[ExplorerRow]) -> str: - """Union of row attribute keys as header; last-resort CSV when /views/.../csv is unavailable.""" +# Column order matches HashiCorp Explorer API docs (view-type field tables and export/csv +# workspaces sample): https://developer.hashicorp.com/terraform/cloud-docs/api-docs/explorer +_EXPLORER_CSV_COLUMNS: dict[ExplorerViewType, tuple[str, ...]] = { + ExplorerViewType.WORKSPACES: ( + "all_checks_succeeded", + "current_rum_count", + "checks_errored", + "checks_failed", + "checks_passed", + "checks_unknown", + "current_run_applied_at", + "current_run_external_id", + "current_run_status", + "drifted", + "external_id", + "module_count", + "modules", + "organization_name", + "project_external_id", + "project_name", + "provider_count", + "providers", + "resources_drifted", + "resources_undrifted", + "state_version_terraform_version", + "vcs_repo_identifier", + "workspace_created_at", + "workspace_name", + "workspace_terraform_version", + "workspace_updated_at", + ), + ExplorerViewType.TF_VERSIONS: ("version", "workspace_count", "workspaces"), + ExplorerViewType.PROVIDERS: ( + "name", + "source", + "version", + "workspace_count", + "workspaces", + ), + ExplorerViewType.MODULES: ( + "name", + "source", + "version", + "workspace_count", + "workspaces", + ), +} + +_ROW_TYPE_TO_VIEW: dict[str, ExplorerViewType] = { + "visibility-workspace": ExplorerViewType.WORKSPACES, +} + + +def _infer_view_type_from_csv_header(header: list[str]) -> ExplorerViewType | None: + """Pick Explorer view type from CSV header names (no extra API call).""" + h = frozenset(header) + candidates: list[tuple[int, int, str, ExplorerViewType]] = [] + for vt, cols in _EXPLORER_CSV_COLUMNS.items(): + colset = frozenset(cols) + overlap = len(h & colset) + if overlap == 0: + continue + # Prefer more matching columns; tie-break to a narrower schema (e.g. tf_versions). + candidates.append((overlap, -len(colset), vt.value, vt)) + if not candidates: + return None + _, _, _, vt = max(candidates) + return vt + + +def _explorer_attribute_value(attrs: dict[str, Any], logical_snake: str) -> Any: + """Resolve API attribute keys (snake_case or kebab-case) for one logical Explorer column.""" + hyphen = logical_snake.replace("_", "-") + if logical_snake in attrs: + return attrs[logical_snake] + if hyphen in attrs: + return attrs[hyphen] + return "" + + +def _csv_fieldnames_for_explorer_rows( + rows: list[ExplorerRow], + view_type: ExplorerViewType | None, +) -> tuple[list[str], frozenset[str]]: + """Doc-ordered columns first; trailing columns for attributes not in the doc schema.""" + all_raw: set[str] = set() + for row in rows: + all_raw.update(row.attributes.keys()) + + order = _EXPLORER_CSV_COLUMNS.get(view_type) if view_type is not None else None + if not order: + seen: set[str] = set() + visit: list[str] = [] + for row in rows: + for k in row.attributes: + if k not in seen: + seen.add(k) + visit.append(k) + return visit, frozenset() + + canonical_set = frozenset(order) + matched_raw: set[str] = set() + for raw in all_raw: + for col in order: + if raw == col or raw == col.replace("_", "-"): + matched_raw.add(raw) + break + + extras: list[str] = [] + seen_extras: set[str] = set() + for row in rows: + for raw in row.attributes: + if raw not in canonical_set and raw not in seen_extras: + seen_extras.add(raw) + extras.append(raw) + return list(order) + extras, canonical_set + + +def _infer_view_type_from_rows(rows: list[ExplorerRow]) -> ExplorerViewType | None: + if not rows: + return None + return _ROW_TYPE_TO_VIEW.get(rows[0].row_type) + + +def _normalize_explorer_csv_column_order( + csv_text: str, view_type: ExplorerViewType | None +) -> str: + """Reorder CSV header/data columns to match Explorer API doc order (GET CSV varies).""" + if not csv_text.strip() or view_type is None: + return csv_text + order = _EXPLORER_CSV_COLUMNS.get(view_type) + if not order: + return csv_text + try: + reader = csv.reader(io.StringIO(csv_text)) + rows = list(reader) + except csv.Error: + return csv_text + if not rows or not rows[0]: + return csv_text + header = rows[0] + idx = {name: i for i, name in enumerate(header)} + order_set = frozenset(order) + canonical = [c for c in order if c in idx] + extras = [h for h in header if h not in order_set] + new_header = canonical + extras + if new_header == header: + return csv_text + perm = [idx[h] for h in new_header] + ncols = len(header) + out_rows: list[list[str]] = [new_header] + for row in rows[1:]: + padded = list(row) + [""] * max(0, ncols - len(row)) + padded = padded[:ncols] + out_rows.append([padded[i] for i in perm]) + buf = io.StringIO() + writer = csv.writer(buf, lineterminator="\n") + writer.writerows(out_rows) + return buf.getvalue() + + +def _rows_to_csv( + rows: list[ExplorerRow], + *, + view_type: ExplorerViewType | None = None, +) -> str: + """Build CSV from result rows; column order follows Explorer API docs when view_type is known.""" if not rows: return "" - keys: set[str] = set() - for row in rows: - keys.update(row.attributes.keys()) - fieldnames = sorted(keys) + vt = view_type if view_type is not None else _infer_view_type_from_rows(rows) + fieldnames, canonical_set = _csv_fieldnames_for_explorer_rows(rows, vt) buf = io.StringIO() writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction="ignore") writer.writeheader() for row in rows: - writer.writerow({k: row.attributes.get(k, "") for k in fieldnames}) + attrs = row.attributes + row_out: dict[str, Any] = {} + for name in fieldnames: + if name in canonical_set: + row_out[name] = _explorer_attribute_value(attrs, name) + else: + row_out[name] = attrs.get(name, "") + writer.writerow(row_out) return buf.getvalue() @@ -436,7 +607,16 @@ def saved_view_results_csv(self, organization: str, view_id: str) -> str: path = f"/api/v2/organizations/{organization}/explorer/views/{view_id}/csv" try: resp = self.t.request("GET", path) - return resp.text + csv_text = resp.text + try: + parsed = list(csv.reader(io.StringIO(csv_text))) + except csv.Error: + return csv_text + if parsed and parsed[0]: + vt = _infer_view_type_from_csv_header(parsed[0]) + if vt is not None: + csv_text = _normalize_explorer_csv_column_order(csv_text, vt) + return csv_text except (NotFound, ServerError) as exc: _log.info( "explorer.saved_view_results_csv: primary CSV route unavailable (%s); " @@ -447,10 +627,14 @@ def saved_view_results_csv(self, organization: str, view_id: str) -> str: ) # Fall back: replay saved definition via export_csv, then row materialization if needed. + saved_for_csv: ExplorerSavedView | None = None try: - saved_view = self.read_saved_view(organization, view_id) - options = _query_options_from_saved_view(saved_view) + saved_for_csv = self.read_saved_view(organization, view_id) + options = _query_options_from_saved_view(saved_for_csv) csv_text = self.export_csv(organization, options) + csv_text = _normalize_explorer_csv_column_order( + csv_text, saved_for_csv.query_type + ) _log.info( "explorer.saved_view_results_csv: used export_csv fallback org=%r view_id=%r", organization, @@ -466,4 +650,5 @@ def saved_view_results_csv(self, organization: str, view_id: str) -> str: view_id, ) rows = list(self.saved_view_results(organization, view_id)) - return _rows_to_csv(rows) + vt = saved_for_csv.query_type if saved_for_csv is not None else None + return _rows_to_csv(rows, view_type=vt) diff --git a/tests/units/test_explorer.py b/tests/units/test_explorer.py index 91094538..db499f73 100644 --- a/tests/units/test_explorer.py +++ b/tests/units/test_explorer.py @@ -15,6 +15,7 @@ ) from pytfe.models import ( ExplorerQueryOptions, + ExplorerRow, ExplorerSavedQuery, ExplorerSavedQueryFilter, ExplorerSavedViewCreateOptions, @@ -22,7 +23,11 @@ ExplorerUrlFilter, ExplorerViewType, ) -from pytfe.resources.explorer import Explorer +from pytfe.resources.explorer import ( + Explorer, + _normalize_explorer_csv_column_order, + _rows_to_csv, +) @pytest.fixture @@ -35,6 +40,36 @@ def explorer_service(mock_transport): return Explorer(mock_transport) +def test_normalize_explorer_csv_column_order_workspaces(): + raw = "workspace_name,all_checks_succeeded\ndemo,true\n" + out = _normalize_explorer_csv_column_order(raw, ExplorerViewType.WORKSPACES) + assert out.splitlines()[0].startswith("all_checks_succeeded,workspace_name") + + +def test_rows_to_csv_workspace_column_order_matches_doc(): + """Fallback CSV header matches Explorer export/csv workspaces sample column order.""" + rows = [ + ExplorerRow.model_validate( + { + "id": "ws-1", + "type": "visibility-workspace", + "attributes": {"workspace-name": "demo-workspace"}, + } + ) + ] + csv_text = _rows_to_csv(rows, view_type=ExplorerViewType.WORKSPACES) + header = csv_text.strip().splitlines()[0] + assert header.startswith( + "all_checks_succeeded,current_rum_count,checks_errored,checks_failed," + "checks_passed,checks_unknown,current_run_applied_at,current_run_external_id," + "current_run_status,drifted,external_id,module_count,modules,organization_name," + "project_external_id,project_name,provider_count,providers,resources_drifted," + "resources_undrifted,state_version_terraform_version,vcs_repo_identifier," + "workspace_created_at,workspace_name,workspace_terraform_version,workspace_updated_at" + ) + assert "demo-workspace" in csv_text + + def _row_payload(row_id: str) -> dict: return { "id": row_id, @@ -309,12 +344,14 @@ def test_saved_view_results(self, explorer_service, mock_transport): ) def test_saved_view_results_csv(self, explorer_service, mock_transport): - response = Mock() - response.text = "workspace_name\nexample\n" - mock_transport.request.return_value = response + csv_resp = Mock() + csv_resp.text = "workspace_name,all_checks_succeeded\ndemo,true\n" + mock_transport.request.return_value = csv_resp csv_text = explorer_service.saved_view_results_csv("acme", "sq-1") - assert "workspace_name" in csv_text + assert csv_text.splitlines()[0].startswith( + "all_checks_succeeded,workspace_name" + ) mock_transport.request.assert_called_once_with( "GET", "/api/v2/organizations/acme/explorer/views/sq-1/csv" ) @@ -351,7 +388,15 @@ def test_saved_view_results_csv_fallback_to_rows( ] csv_text = explorer_service.saved_view_results_csv("acme", "sq-1") - assert "workspace-name" in csv_text + header = csv_text.strip().splitlines()[0] + assert header.startswith( + "all_checks_succeeded,current_rum_count,checks_errored,checks_failed," + "checks_passed,checks_unknown,current_run_applied_at,current_run_external_id," + "current_run_status,drifted,external_id,module_count,modules,organization_name," + "project_external_id,project_name,provider_count,providers,resources_drifted," + "resources_undrifted,state_version_terraform_version,vcs_repo_identifier," + "workspace_created_at,workspace_name,workspace_terraform_version,workspace_updated_at" + ) assert "demo-workspace" in csv_text @pytest.mark.parametrize("org", ["", None]) From 827a7389fce12bfd4728266ac65508eb19580aef Mon Sep 17 00:00:00 2001 From: jasodeep Date: Tue, 28 Apr 2026 15:31:17 +0530 Subject: [PATCH 21/43] =?UTF-8?q?Improved=20overall=20test=20coverage=20fo?= =?UTF-8?q?r=20Explorer=20=F0=9F=94=8D=E2=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/units/test_explorer.py | 240 ++++++++++++++++++++++++++--------- 1 file changed, 183 insertions(+), 57 deletions(-) diff --git a/tests/units/test_explorer.py b/tests/units/test_explorer.py index db499f73..febc6312 100644 --- a/tests/units/test_explorer.py +++ b/tests/units/test_explorer.py @@ -3,7 +3,8 @@ """Unit tests for Explorer API resource.""" -from unittest.mock import Mock +import csv +from unittest.mock import Mock, call import pytest @@ -11,6 +12,7 @@ InvalidExplorerSavedViewIDError, InvalidOrgError, NotFound, + ServerError, ValidationError, ) from pytfe.models import ( @@ -29,6 +31,11 @@ _rows_to_csv, ) +ORG = "acme" +VIEW_ID = "sq-1" +EXPLORER_PATH = f"/api/v2/organizations/{ORG}/explorer" +VIEWS_PATH = f"{EXPLORER_PATH}/views" + @pytest.fixture def mock_transport(): @@ -116,13 +123,34 @@ def _saved_view_payload_live_variant(view_id: str) -> dict: } +def _assert_single_request_call( + mock_transport, method: str, path: str, **kwargs +) -> None: + mock_transport.request.assert_called_once_with(method, path, **kwargs) + + +def _query_request_params(page_number: int) -> dict: + return { + "type": "workspaces", + "sort": "-workspace_name", + "fields": "workspace_name,organization_name", + "page[size]": 1, + "filter[0][workspace_name][contains][0]": "test", + "page[number]": page_number, + } + + class TestExplorerQuery: def test_query_with_filter_and_pagination(self, explorer_service, mock_transport): first = Mock() first.json.return_value = {"data": [_row_payload("ws-1")]} second = Mock() - second.json.return_value = {"data": []} - mock_transport.request.side_effect = [first, second] + second.json.return_value = {"data": [_row_payload("ws-2")]} + third = Mock() + third.json.return_value = {"data": [_row_payload("ws-3")]} + fourth = Mock() + fourth.json.return_value = {"data": []} + mock_transport.request.side_effect = [first, second, third, fourth] options = ExplorerQueryOptions( view_type=ExplorerViewType.WORKSPACES, @@ -139,20 +167,19 @@ def test_query_with_filter_and_pagination(self, explorer_service, mock_transport ], ) - rows = list(explorer_service.query("acme", options)) - assert len(rows) == 1 - assert rows[0].id == "ws-1" - assert rows[0].row_type == "visibility-workspace" - - first_call = mock_transport.request.call_args_list[0] - assert first_call[0][0] == "GET" - assert first_call[0][1] == "/api/v2/organizations/acme/explorer" - params = first_call[1]["params"] - assert params["type"] == "workspaces" - assert params["sort"] == "-workspace_name" - assert params["fields"] == "workspace_name,organization_name" - assert params["page[size]"] == 1 - assert params["filter[0][workspace_name][contains][0]"] == "test" + rows = list(explorer_service.query(ORG, options)) + assert len(rows) == 3 + assert [row.id for row in rows] == ["ws-1", "ws-2", "ws-3"] + assert all(row.row_type == "visibility-workspace" for row in rows) + + expected_calls = [ + call("GET", EXPLORER_PATH, params=_query_request_params(page_number=1)), + call("GET", EXPLORER_PATH, params=_query_request_params(page_number=2)), + call("GET", EXPLORER_PATH, params=_query_request_params(page_number=3)), + call("GET", EXPLORER_PATH, params=_query_request_params(page_number=4)), + ] + mock_transport.request.assert_has_calls(expected_calls) + assert mock_transport.request.call_count == 4 def test_query_invalid_org(self, explorer_service): with pytest.raises(InvalidOrgError): @@ -163,19 +190,28 @@ def test_query_invalid_org(self, explorer_service): ) ) + @pytest.mark.parametrize("org", ["", None]) + def test_export_csv_invalid_org(self, explorer_service, org): + with pytest.raises(InvalidOrgError): + explorer_service.export_csv( + org, + ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES), + ) + def test_export_csv(self, explorer_service, mock_transport): response = Mock() response.text = "workspace_name\nexample\n" mock_transport.request.return_value = response csv_text = explorer_service.export_csv( - "acme", ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES) + ORG, ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES) ) assert "workspace_name" in csv_text - mock_transport.request.assert_called_once_with( + _assert_single_request_call( + mock_transport, "GET", - "/api/v2/organizations/acme/explorer/export/csv", + f"{EXPLORER_PATH}/export/csv", params={"type": "workspaces"}, ) @@ -186,7 +222,7 @@ def test_list_saved_views(self, explorer_service, mock_transport): response.json.return_value = {"data": [_saved_view_payload("sq-1")]} mock_transport.request.return_value = response - views = list(explorer_service.list_saved_views("acme")) + views = list(explorer_service.list_saved_views(ORG)) assert len(views) == 1 assert views[0].id == "sq-1" assert views[0].query_type == ExplorerViewType.WORKSPACES @@ -209,12 +245,12 @@ def test_create_saved_view(self, explorer_service, mock_transport): ], ), ) - view = explorer_service.create_saved_view("acme", options) + view = explorer_service.create_saved_view(ORG, options) assert view.id == "sq-new" call = mock_transport.request.call_args assert call[0][0] == "POST" - assert call[0][1] == "/api/v2/organizations/acme/explorer/views" + assert call[0][1] == VIEWS_PATH body = call[1]["json_body"] assert body["data"]["type"] == "explorer-saved-queries" assert body["data"]["attributes"]["query-type"] == "workspaces" @@ -235,7 +271,7 @@ def test_create_saved_view_invalid_json_raises( query=ExplorerSavedQuery(query_type=ExplorerViewType.WORKSPACES), ) with pytest.raises(ValidationError, match="create_saved_view"): - explorer_service.create_saved_view("acme", options) + explorer_service.create_saved_view(ORG, options) def test_read_saved_view_missing_data_object_raises( self, explorer_service, mock_transport @@ -245,19 +281,17 @@ def test_read_saved_view_missing_data_object_raises( mock_transport.request.return_value = response with pytest.raises(ValidationError, match="read_saved_view"): - explorer_service.read_saved_view("acme", "sq-1") + explorer_service.read_saved_view(ORG, VIEW_ID) def test_read_saved_view(self, explorer_service, mock_transport): response = Mock() response.json.return_value = {"data": _saved_view_payload("sq-1")} mock_transport.request.return_value = response - view = explorer_service.read_saved_view("acme", "sq-1") + view = explorer_service.read_saved_view(ORG, VIEW_ID) assert view.id == "sq-1" - mock_transport.request.assert_called_once_with( - "GET", "/api/v2/organizations/acme/explorer/views/sq-1" - ) + _assert_single_request_call(mock_transport, "GET", f"{VIEWS_PATH}/{VIEW_ID}") def test_read_saved_view_with_live_query_shape( self, explorer_service, mock_transport @@ -266,7 +300,7 @@ def test_read_saved_view_with_live_query_shape( response.json.return_value = {"data": _saved_view_payload_live_variant("sq-2")} mock_transport.request.return_value = response - view = explorer_service.read_saved_view("acme", "sq-2") + view = explorer_service.read_saved_view(ORG, "sq-2") assert view.id == "sq-2" assert view.query.query_type == ExplorerViewType.WORKSPACES @@ -292,17 +326,57 @@ def test_update_saved_view(self, explorer_service, mock_transport): ], ), ) - view = explorer_service.update_saved_view("acme", "sq-1", options) + view = explorer_service.update_saved_view(ORG, VIEW_ID, options) assert view.id == "sq-1" - call = mock_transport.request.call_args - assert call[0][0] == "PATCH" - assert call[0][1] == "/api/v2/organizations/acme/explorer/views/sq-1" - assert call[1]["json_body"]["data"]["id"] == "sq-1" - assert call[1]["json_body"]["data"]["attributes"]["name"] == "my-view-updated" - assert call[1]["json_body"]["data"]["attributes"]["query"]["filter"] == [ - {"workspace_name": {"contains": ["prod"]}} - ] + expected_body = { + "data": { + "type": "explorer-saved-queries", + "id": VIEW_ID, + "attributes": { + "name": "my-view-updated", + "query": { + "type": "workspaces", + "filter": [{"workspace_name": {"contains": ["prod"]}}], + }, + }, + } + } + _assert_single_request_call( + mock_transport, + "PATCH", + f"{VIEWS_PATH}/{VIEW_ID}", + json_body=expected_body, + ) + + def test_update_saved_view_invalid_json_raises( + self, explorer_service, mock_transport + ): + response = Mock() + response.json.side_effect = ValueError("invalid json") + mock_transport.request.return_value = response + + options = ExplorerSavedViewUpdateOptions( + name="my-view-updated", + query=ExplorerSavedQuery(query_type=ExplorerViewType.WORKSPACES), + ) + with pytest.raises(ValidationError, match="update_saved_view"): + explorer_service.update_saved_view(ORG, VIEW_ID, options) + + @pytest.mark.parametrize("payload", [[], "bad-payload", {"data": []}]) + def test_update_saved_view_invalid_data_shape_raises( + self, explorer_service, mock_transport, payload + ): + response = Mock() + response.json.return_value = payload + mock_transport.request.return_value = response + + options = ExplorerSavedViewUpdateOptions( + name="my-view-updated", + query=ExplorerSavedQuery(query_type=ExplorerViewType.WORKSPACES), + ) + with pytest.raises(ValidationError, match="update_saved_view"): + explorer_service.update_saved_view(ORG, VIEW_ID, options) def test_delete_saved_view(self, explorer_service, mock_transport): response = Mock() @@ -310,12 +384,10 @@ def test_delete_saved_view(self, explorer_service, mock_transport): response.text = '{"data":{"id":"sq-1"}}' mock_transport.request.return_value = response - view = explorer_service.delete_saved_view("acme", "sq-1") + view = explorer_service.delete_saved_view(ORG, VIEW_ID) assert view.id == "sq-1" - mock_transport.request.assert_called_once_with( - "DELETE", "/api/v2/organizations/acme/explorer/views/sq-1" - ) + _assert_single_request_call(mock_transport, "DELETE", f"{VIEWS_PATH}/{VIEW_ID}") def test_delete_saved_view_empty_response(self, explorer_service, mock_transport): response = Mock() @@ -323,8 +395,34 @@ def test_delete_saved_view_empty_response(self, explorer_service, mock_transport response.json.side_effect = ValueError("No JSON body") mock_transport.request.return_value = response - view = explorer_service.delete_saved_view("acme", "sq-1") + view = explorer_service.delete_saved_view(ORG, VIEW_ID) + assert view.id == "sq-1" + + def test_delete_saved_view_non_json_body_returns_stub( + self, explorer_service, mock_transport + ): + response = Mock() + response.text = "deleted" + response.json.side_effect = ValueError("No JSON body") + mock_transport.request.return_value = response + + view = explorer_service.delete_saved_view(ORG, VIEW_ID) + assert view.id == "sq-1" + assert view.name == "" + assert view.query_type == ExplorerViewType.WORKSPACES + + def test_delete_saved_view_invalid_data_shape_returns_stub( + self, explorer_service, mock_transport + ): + response = Mock() + response.text = '{"data":[]}' + response.json.return_value = {"data": []} + mock_transport.request.return_value = response + + view = explorer_service.delete_saved_view(ORG, VIEW_ID) assert view.id == "sq-1" + assert view.name == "" + assert view.query_type == ExplorerViewType.WORKSPACES def test_saved_view_results(self, explorer_service, mock_transport): first = Mock() @@ -333,13 +431,13 @@ def test_saved_view_results(self, explorer_service, mock_transport): second.json.return_value = {"data": []} mock_transport.request.side_effect = [first, second] - rows = list(explorer_service.saved_view_results("acme", "sq-1")) + rows = list(explorer_service.saved_view_results(ORG, VIEW_ID)) assert len(rows) == 1 assert rows[0].id == "ws-1" mock_transport.request.assert_any_call( "GET", - "/api/v2/organizations/acme/explorer/views/sq-1/results", + f"{VIEWS_PATH}/{VIEW_ID}/results", params={"page[number]": 1, "page[size]": 100}, ) @@ -348,14 +446,29 @@ def test_saved_view_results_csv(self, explorer_service, mock_transport): csv_resp.text = "workspace_name,all_checks_succeeded\ndemo,true\n" mock_transport.request.return_value = csv_resp - csv_text = explorer_service.saved_view_results_csv("acme", "sq-1") + csv_text = explorer_service.saved_view_results_csv(ORG, VIEW_ID) assert csv_text.splitlines()[0].startswith( "all_checks_succeeded,workspace_name" ) - mock_transport.request.assert_called_once_with( - "GET", "/api/v2/organizations/acme/explorer/views/sq-1/csv" + _assert_single_request_call( + mock_transport, "GET", f"{VIEWS_PATH}/{VIEW_ID}/csv" ) + def test_saved_view_results_csv_invalid_csv_returns_raw( + self, explorer_service, mock_transport, monkeypatch + ): + csv_resp = Mock() + csv_resp.text = "raw-csv" + mock_transport.request.return_value = csv_resp + + def _raise_csv_error(*_args, **_kwargs): + raise csv.Error("invalid csv") + + monkeypatch.setattr("pytfe.resources.explorer.csv.reader", _raise_csv_error) + + csv_text = explorer_service.saved_view_results_csv(ORG, VIEW_ID) + assert csv_text == "raw-csv" + def test_saved_view_results_csv_fallback_to_export( self, explorer_service, mock_transport ): @@ -366,7 +479,20 @@ def test_saved_view_results_csv_fallback_to_export( export_resp.text = "workspace_name\nfrom-export\n" mock_transport.request.side_effect = [first, read_resp, export_resp] - csv_text = explorer_service.saved_view_results_csv("acme", "sq-1") + csv_text = explorer_service.saved_view_results_csv(ORG, VIEW_ID) + assert "from-export" in csv_text + + def test_saved_view_results_csv_server_error_fallback_to_export( + self, explorer_service, mock_transport + ): + first = ServerError("server error", status=500) + read_resp = Mock() + read_resp.json.return_value = {"data": _saved_view_payload("sq-1")} + export_resp = Mock() + export_resp.text = "workspace_name\nfrom-export\n" + mock_transport.request.side_effect = [first, read_resp, export_resp] + + csv_text = explorer_service.saved_view_results_csv(ORG, VIEW_ID) assert "from-export" in csv_text def test_saved_view_results_csv_fallback_to_rows( @@ -387,7 +513,7 @@ def test_saved_view_results_csv_fallback_to_rows( second_results, # saved_view_results page 2 ] - csv_text = explorer_service.saved_view_results_csv("acme", "sq-1") + csv_text = explorer_service.saved_view_results_csv(ORG, VIEW_ID) header = csv_text.strip().splitlines()[0] assert header.startswith( "all_checks_succeeded,current_rum_count,checks_errored,checks_failed," @@ -405,16 +531,16 @@ def test_saved_view_methods_invalid_org(self, explorer_service, org): list(explorer_service.list_saved_views(org)) with pytest.raises(InvalidOrgError): - explorer_service.read_saved_view(org, "sq-1") + explorer_service.read_saved_view(org, VIEW_ID) @pytest.mark.parametrize("view_id", ["", None]) def test_saved_view_methods_invalid_id(self, explorer_service, view_id): with pytest.raises(InvalidExplorerSavedViewIDError): - explorer_service.read_saved_view("acme", view_id) + explorer_service.read_saved_view(ORG, view_id) with pytest.raises(InvalidExplorerSavedViewIDError): explorer_service.update_saved_view( - "acme", + ORG, view_id, ExplorerSavedViewUpdateOptions( name="updated", @@ -423,10 +549,10 @@ def test_saved_view_methods_invalid_id(self, explorer_service, view_id): ) with pytest.raises(InvalidExplorerSavedViewIDError): - explorer_service.delete_saved_view("acme", view_id) + explorer_service.delete_saved_view(ORG, view_id) with pytest.raises(InvalidExplorerSavedViewIDError): - list(explorer_service.saved_view_results("acme", view_id)) + list(explorer_service.saved_view_results(ORG, view_id)) with pytest.raises(InvalidExplorerSavedViewIDError): - explorer_service.saved_view_results_csv("acme", view_id) + explorer_service.saved_view_results_csv(ORG, view_id) From 32b434391b58eb310c3d034c07e8743ab164ae94 Mon Sep 17 00:00:00 2001 From: jasodeep Date: Tue, 28 Apr 2026 16:03:45 +0530 Subject: [PATCH 22/43] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor=20Explorer?= =?UTF-8?q?=20codebase=20for=20improved=20quality=20and=20maintainability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/explorer.py | 8 +- src/pytfe/resources/_base.py | 58 ++++++++++- src/pytfe/resources/explorer.py | 55 ++-------- tests/units/test_explorer.py | 173 +++++++++++++++++++++++++------- 4 files changed, 206 insertions(+), 88 deletions(-) diff --git a/examples/explorer.py b/examples/explorer.py index fc5cd1cc..50f27b57 100644 --- a/examples/explorer.py +++ b/examples/explorer.py @@ -428,11 +428,9 @@ def main() -> None: updated = client.explorer.update_saved_view(org, created.id, update_opts) print(f" update_saved_view: name is now {updated.name!r}") - # client.explorer.delete_saved_view removes the saved view; some API - # responses omit JSON, in which case the client still returns a minimal - # ExplorerSavedView carrying the deleted id. - deleted = client.explorer.delete_saved_view(org, created.id) - print(f" delete_saved_view: completed for id {deleted.id}") + # client.explorer.delete_saved_view removes the saved view and returns None. + client.explorer.delete_saved_view(org, created.id) + print(f" delete_saved_view: completed for id {created.id}") print("Summary: mutation sequence finished.") except TFEError as e: print(f" API error: {e}") diff --git a/src/pytfe/resources/_base.py b/src/pytfe/resources/_base.py index a6e65dd7..b60c17f2 100644 --- a/src/pytfe/resources/_base.py +++ b/src/pytfe/resources/_base.py @@ -9,6 +9,18 @@ from .._http import HTTPTransport +def _to_int(value: Any) -> int | None: + """Best-effort integer coercion for pagination metadata values.""" + if isinstance(value, int): + return value + if isinstance(value, str): + try: + return int(value) + except ValueError: + return None + return None + + class _Service: def __init__(self, t: HTTPTransport) -> None: self.t = t @@ -16,20 +28,60 @@ def __init__(self, t: HTTPTransport) -> None: def _list( self, path: str, *, params: dict | None = None ) -> Iterator[dict[str, Any]]: - page = 1 + base_params = dict(params or {}) + page = int(base_params.get("page[number]", 1)) while True: - p = dict(params or {}) + p = dict(base_params) p["page[number]"] = page p.setdefault("page[size]", 100) r = self.t.request("GET", path, params=p) # Handle cases where r.json() returns None or is not a dict json_response = r.json() - if json_response is None: + if json_response is None or not isinstance(json_response, dict): json_response = {} data = json_response.get("data", []) + if not isinstance(data, list): + data = [] yield from data + if not data: + # Defensive stop: some endpoints can return inconsistent pagination + # metadata while yielding no rows; avoid unbounded follow-up requests. + break + + # Prefer server pagination metadata when available. This avoids + # prematurely terminating when servers clamp requested page sizes. + meta = json_response.get("meta") + pagination = meta.get("pagination", {}) if isinstance(meta, dict) else {} + if isinstance(pagination, dict) and pagination: + next_page = _to_int( + pagination.get("next-page", pagination.get("next_page")) + ) + if next_page is not None and next_page > page: + page = next_page + continue + + current_page = _to_int( + pagination.get("current-page", pagination.get("current_page")) + ) + total_pages = _to_int( + pagination.get("total-pages", pagination.get("total_pages")) + ) + if ( + current_page is not None + and total_pages is not None + and current_page < total_pages + ): + candidate_page = current_page + 1 + if candidate_page > page: + page = candidate_page + continue + + # Metadata present and indicates no next page. + break + + # Fallback for endpoints that do not return pagination metadata. page_size = int(p["page[size]"]) if len(data) < page_size: break diff --git a/src/pytfe/resources/explorer.py b/src/pytfe/resources/explorer.py index 9f6d7fc3..d80c05f0 100644 --- a/src/pytfe/resources/explorer.py +++ b/src/pytfe/resources/explorer.py @@ -121,6 +121,11 @@ def _parse_row(item: dict[str, Any]) -> ExplorerRow: return ExplorerRow.model_validate(item) +def _normalize_filter_field_name(raw_field: Any) -> str: + """Normalize filter field names to SDK model style.""" + return str(raw_field).replace("-", "_") + + def _saved_query_to_api_shape(raw_query: dict[str, Any]) -> dict[str, Any]: """Map {field, operator, value} filter rows to nested {field: {operator: [...]}} JSON.""" query = dict(raw_query) @@ -134,7 +139,7 @@ def _saved_query_to_api_shape(raw_query: dict[str, Any]) -> dict[str, Any]: if "field" not in entry or "operator" not in entry: mapped_filters.append(entry) continue - field = str(entry.get("field", "")).replace("-", "_") + field = _normalize_filter_field_name(entry.get("field", "")) operator = str(entry.get("operator", "")) values = entry.get("value", []) if not isinstance(values, list): @@ -166,7 +171,7 @@ def _normalize_saved_query( value = [str(value)] normalized_filters.append( { - "field": str(entry["field"]).replace("-", "_"), + "field": _normalize_filter_field_name(entry["field"]), "operator": str(entry["operator"]), "value": [str(v) for v in value], } @@ -182,7 +187,7 @@ def _normalize_saved_query( vals = values if isinstance(values, list) else [values] normalized_filters.append( { - "field": str(field_name).replace("-", "_"), + "field": _normalize_filter_field_name(field_name), "operator": str(operator), "value": [str(v) for v in vals], } @@ -220,18 +225,6 @@ def _parse_saved_view(item: dict[str, Any]) -> ExplorerSavedView: ) -def _deleted_saved_view_fallback(view_id: str) -> ExplorerSavedView: - """Build a minimal saved view when delete responses have no body.""" - return ExplorerSavedView.model_validate( - { - "id": view_id, - "name": "", - "query-type": "workspaces", - "query": {"type": "workspaces"}, - } - ) - - def _query_options_from_saved_view( saved_view: ExplorerSavedView, ) -> ExplorerQueryOptions: @@ -550,38 +543,10 @@ def update_saved_view( _log.info("explorer.update_saved_view org=%r id=%r", organization, view.id) return view - def delete_saved_view(self, organization: str, view_id: str) -> ExplorerSavedView: + def delete_saved_view(self, organization: str, view_id: str) -> None: _require_organization_and_view(organization, view_id) path = f"/api/v2/organizations/{organization}/explorer/views/{view_id}" - resp = self.t.request("DELETE", path) - # DELETE often returns an empty body; callers still receive a minimal ExplorerSavedView. - raw_text = (resp.text or "").strip() - if not raw_text: - _log.debug( - "explorer.delete_saved_view: empty body, returning stub org=%r id=%r", - organization, - view_id, - ) - return _deleted_saved_view_fallback(view_id) - - try: - payload = resp.json() - except ValueError: - _log.debug( - "explorer.delete_saved_view: non-JSON body, returning stub org=%r id=%r", - organization, - view_id, - ) - return _deleted_saved_view_fallback(view_id) - - if isinstance(payload, dict) and isinstance(payload.get("data"), dict): - return _parse_saved_view(payload["data"]) - _log.debug( - "explorer.delete_saved_view: no data object, returning stub org=%r id=%r", - organization, - view_id, - ) - return _deleted_saved_view_fallback(view_id) + self.t.request("DELETE", path) def saved_view_results( self, organization: str, view_id: str diff --git a/tests/units/test_explorer.py b/tests/units/test_explorer.py index febc6312..a0b0dd27 100644 --- a/tests/units/test_explorer.py +++ b/tests/units/test_explorer.py @@ -181,6 +181,138 @@ def test_query_with_filter_and_pagination(self, explorer_service, mock_transport mock_transport.request.assert_has_calls(expected_calls) assert mock_transport.request.call_count == 4 + def test_query_uses_pagination_meta_when_server_caps_page_size( + self, explorer_service, mock_transport + ): + first = Mock() + first.json.return_value = { + "data": [_row_payload("ws-1"), _row_payload("ws-2")], + "meta": { + "pagination": { + "current-page": 1, + "page-size": 2, + "next-page": 2, + "total-pages": 2, + } + }, + } + second = Mock() + second.json.return_value = { + "data": [_row_payload("ws-3")], + "meta": { + "pagination": { + "current-page": 2, + "page-size": 2, + "next-page": None, + "total-pages": 2, + } + }, + } + mock_transport.request.side_effect = [first, second] + + options = ExplorerQueryOptions( + view_type=ExplorerViewType.WORKSPACES, + page_size=50, + ) + + rows = list(explorer_service.query(ORG, options)) + assert [row.id for row in rows] == ["ws-1", "ws-2", "ws-3"] + + expected_calls = [ + call( + "GET", + EXPLORER_PATH, + params={"type": "workspaces", "page[size]": 50, "page[number]": 1}, + ), + call( + "GET", + EXPLORER_PATH, + params={"type": "workspaces", "page[size]": 50, "page[number]": 2}, + ), + ] + mock_transport.request.assert_has_calls(expected_calls) + assert mock_transport.request.call_count == 2 + + def test_query_uses_current_and_total_pages_when_next_page_missing( + self, explorer_service, mock_transport + ): + first = Mock() + first.json.return_value = { + "data": [_row_payload("ws-1")], + "meta": {"pagination": {"current-page": 1, "total-pages": 2}}, + } + second = Mock() + second.json.return_value = { + "data": [_row_payload("ws-2")], + "meta": {"pagination": {"current-page": 2, "total-pages": 2}}, + } + mock_transport.request.side_effect = [first, second] + + rows = list( + explorer_service.query( + ORG, ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES) + ) + ) + assert [row.id for row in rows] == ["ws-1", "ws-2"] + + expected_calls = [ + call( + "GET", + EXPLORER_PATH, + params={"type": "workspaces", "page[number]": 1, "page[size]": 100}, + ), + call( + "GET", + EXPLORER_PATH, + params={"type": "workspaces", "page[number]": 2, "page[size]": 100}, + ), + ] + mock_transport.request.assert_has_calls(expected_calls) + assert mock_transport.request.call_count == 2 + + def test_query_stops_when_pagination_meta_does_not_advance( + self, explorer_service, mock_transport + ): + first = Mock() + first.json.return_value = { + "data": [_row_payload("ws-1")], + "meta": {"pagination": {"current-page": 1, "total-pages": 2}}, + } + second = Mock() + second.json.return_value = { + "data": [_row_payload("ws-1")], + "meta": {"pagination": {"current-page": 1, "total-pages": 2}}, + } + mock_transport.request.side_effect = [first, second] + + rows = list( + explorer_service.query( + ORG, ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES) + ) + ) + assert [row.id for row in rows] == ["ws-1", "ws-1"] + assert mock_transport.request.call_count == 2 + + def test_query_stops_on_empty_page_even_if_next_page_present( + self, explorer_service, mock_transport + ): + first = Mock() + first.json.return_value = { + "data": [], + "meta": { + "pagination": {"current-page": 1, "next-page": 2, "total-pages": 5} + }, + } + mock_transport.request.return_value = first + + rows = list( + explorer_service.query( + ORG, ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES) + ) + ) + assert rows == [] + assert mock_transport.request.call_count == 1 + def test_query_invalid_org(self, explorer_service): with pytest.raises(InvalidOrgError): list( @@ -379,50 +511,21 @@ def test_update_saved_view_invalid_data_shape_raises( explorer_service.update_saved_view(ORG, VIEW_ID, options) def test_delete_saved_view(self, explorer_service, mock_transport): - response = Mock() - response.json.return_value = {"data": _saved_view_payload("sq-1")} - response.text = '{"data":{"id":"sq-1"}}' - mock_transport.request.return_value = response - - view = explorer_service.delete_saved_view(ORG, VIEW_ID) - assert view.id == "sq-1" + result = explorer_service.delete_saved_view(ORG, VIEW_ID) + assert result is None _assert_single_request_call(mock_transport, "DELETE", f"{VIEWS_PATH}/{VIEW_ID}") - def test_delete_saved_view_empty_response(self, explorer_service, mock_transport): - response = Mock() - response.text = "" - response.json.side_effect = ValueError("No JSON body") - mock_transport.request.return_value = response - - view = explorer_service.delete_saved_view(ORG, VIEW_ID) - assert view.id == "sq-1" - - def test_delete_saved_view_non_json_body_returns_stub( + def test_delete_saved_view_ignores_response_body( self, explorer_service, mock_transport ): response = Mock() - response.text = "deleted" + response.text = '{"data":{"id":"unexpected"}}' response.json.side_effect = ValueError("No JSON body") mock_transport.request.return_value = response - view = explorer_service.delete_saved_view(ORG, VIEW_ID) - assert view.id == "sq-1" - assert view.name == "" - assert view.query_type == ExplorerViewType.WORKSPACES - - def test_delete_saved_view_invalid_data_shape_returns_stub( - self, explorer_service, mock_transport - ): - response = Mock() - response.text = '{"data":[]}' - response.json.return_value = {"data": []} - mock_transport.request.return_value = response - - view = explorer_service.delete_saved_view(ORG, VIEW_ID) - assert view.id == "sq-1" - assert view.name == "" - assert view.query_type == ExplorerViewType.WORKSPACES + result = explorer_service.delete_saved_view(ORG, VIEW_ID) + assert result is None def test_saved_view_results(self, explorer_service, mock_transport): first = Mock() From 4d3cce20f1e521256edb47502e4db7f888489c0f Mon Sep 17 00:00:00 2001 From: jasodeep Date: Tue, 28 Apr 2026 16:53:26 +0530 Subject: [PATCH 23/43] =?UTF-8?q?=F0=9F=93=9A=E2=9C=A8Improved=20docstring?= =?UTF-8?q?s=20coverage=20better=20clarity=20and=20maintainability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pytfe/resources/explorer.py | 85 +++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/src/pytfe/resources/explorer.py b/src/pytfe/resources/explorer.py index d80c05f0..50d6ae58 100644 --- a/src/pytfe/resources/explorer.py +++ b/src/pytfe/resources/explorer.py @@ -445,6 +445,15 @@ class Explorer(_Service): def query( self, organization: str, options: ExplorerQueryOptions ) -> Iterator[ExplorerRow]: + """Execute an Explorer query and iterate result rows across all pages. + + Args: + organization: Organization slug that owns the Explorer data. + options: Query options including view type, filters, sort, and paging. + + Yields: + ExplorerRow items returned by the Explorer endpoint. + """ _require_organization(organization) _log.debug( "explorer.query org=%r view_type=%s", @@ -457,6 +466,15 @@ def query( yield _parse_row(item) def export_csv(self, organization: str, options: ExplorerQueryOptions) -> str: + """Run an Explorer query and return CSV text from the export endpoint. + + Args: + organization: Organization slug that owns the Explorer data. + options: Query options including view type, filters, sort, and paging. + + Returns: + Raw CSV text returned by the server. + """ _require_organization(organization) _log.debug( "explorer.export_csv org=%r view_type=%s", @@ -469,6 +487,14 @@ def export_csv(self, organization: str, options: ExplorerQueryOptions) -> str: return resp.text def list_saved_views(self, organization: str) -> Iterator[ExplorerSavedView]: + """Iterate all saved Explorer views in an organization. + + Args: + organization: Organization slug that owns the saved views. + + Yields: + ExplorerSavedView resources from the list endpoint. + """ _require_organization(organization) _log.debug("explorer.list_saved_views org=%r", organization) # GET collection of explorer-saved-queries for the org. @@ -479,6 +505,15 @@ def list_saved_views(self, organization: str) -> Iterator[ExplorerSavedView]: def create_saved_view( self, organization: str, options: ExplorerSavedViewCreateOptions ) -> ExplorerSavedView: + """Create a saved Explorer view. + + Args: + organization: Organization slug that owns the saved view. + options: Saved-view name and query definition to persist. + + Returns: + The created ExplorerSavedView as returned by the API. + """ _require_organization(organization) # POST json:api explorer-saved-queries; filters rewritten for server expectations. attrs = _write_attributes_with_query_shape(options) @@ -498,6 +533,15 @@ def create_saved_view( return view def read_saved_view(self, organization: str, view_id: str) -> ExplorerSavedView: + """Read one saved Explorer view by id. + + Args: + organization: Organization slug that owns the saved view. + view_id: Saved-view id (for example, ``sq-...``). + + Returns: + The saved view definition and query metadata. + """ _require_organization_and_view(organization, view_id) _log.debug( "explorer.read_saved_view org=%r view_id=%r", @@ -521,6 +565,16 @@ def update_saved_view( view_id: str, options: ExplorerSavedViewUpdateOptions, ) -> ExplorerSavedView: + """Replace attributes of an existing saved Explorer view. + + Args: + organization: Organization slug that owns the saved view. + view_id: Saved-view id (for example, ``sq-...``). + options: Updated name and full replacement query definition. + + Returns: + The updated ExplorerSavedView as returned by the API. + """ _require_organization_and_view(organization, view_id) attrs = _write_attributes_with_query_shape(options) # PATCH includes resource id in the envelope per json:api update conventions. @@ -544,6 +598,15 @@ def update_saved_view( return view def delete_saved_view(self, organization: str, view_id: str) -> None: + """Delete a saved Explorer view. + + Args: + organization: Organization slug that owns the saved view. + view_id: Saved-view id (for example, ``sq-...``). + + Returns: + None. + """ _require_organization_and_view(organization, view_id) path = f"/api/v2/organizations/{organization}/explorer/views/{view_id}" self.t.request("DELETE", path) @@ -551,6 +614,15 @@ def delete_saved_view(self, organization: str, view_id: str) -> None: def saved_view_results( self, organization: str, view_id: str ) -> Iterator[ExplorerRow]: + """Execute a saved view and iterate result rows across all pages. + + Args: + organization: Organization slug that owns the saved view. + view_id: Saved-view id (for example, ``sq-...``). + + Yields: + ExplorerRow items produced by the saved query. + """ _require_organization_and_view(organization, view_id) _log.debug( "explorer.saved_view_results org=%r view_id=%r", @@ -563,6 +635,19 @@ def saved_view_results( yield _parse_row(item) def saved_view_results_csv(self, organization: str, view_id: str) -> str: + """Return CSV for a saved view with resilient fallback behavior. + + Tries the dedicated saved-view CSV endpoint first, then falls back to replaying + the saved view through ``export_csv`` and finally to materializing rows from the + paginated results endpoint. + + Args: + organization: Organization slug that owns the saved view. + view_id: Saved-view id (for example, ``sq-...``). + + Returns: + CSV text for the saved view results. + """ _require_organization_and_view(organization, view_id) _log.debug( "explorer.saved_view_results_csv org=%r view_id=%r", From 7e21b7157e415b64a937e67782092a7ff4ea311c Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Mon, 4 May 2026 14:26:51 +0530 Subject: [PATCH 24/43] Fixed the fmt and lint --- src/pytfe/client.py | 2 +- src/pytfe/errors.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/pytfe/client.py b/src/pytfe/client.py index b3fcf124..df23e3a8 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -35,8 +35,8 @@ from .resources.ssh_keys import SSHKeys from .resources.state_version_outputs import StateVersionOutputs from .resources.state_versions import StateVersions -from .resources.team_project_access import TeamProjectAccesses from .resources.team import Teams +from .resources.team_project_access import TeamProjectAccesses from .resources.variable import Variables from .resources.variable_sets import VariableSets, VariableSetVariables from .resources.workspace_resources import WorkspaceResourcesService diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index e331825b..4d616a17 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -530,7 +530,7 @@ class InvalidKeyIDError(InvalidValues): def __init__(self, message: str = "invalid value for key-id"): super().__init__(message) - + # Team errors class EmptyTeamNameError(InvalidValues): @@ -567,4 +567,3 @@ class InvalidTeamProjectAccessIDError(InvalidValues): def __init__(self, message: str = "invalid value for team project access ID"): super().__init__(message) - \ No newline at end of file From d420fd2e68ff2b9915c42bfc2eb585fd4e5f4b96 Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Tue, 5 May 2026 17:29:37 +0530 Subject: [PATCH 25/43] Add organization token support with models, resources, examples, and tests --- examples/organization_token.py | 212 +++++++++++++++ src/pytfe/client.py | 12 +- src/pytfe/models/organization_token.py | 72 +++++ src/pytfe/resources/organization_token.py | 220 +++++++++++++++ tests/units/test_organization_token.py | 313 ++++++++++++++++++++++ 5 files changed, 820 insertions(+), 9 deletions(-) create mode 100644 examples/organization_token.py create mode 100644 src/pytfe/models/organization_token.py create mode 100644 src/pytfe/resources/organization_token.py create mode 100644 tests/units/test_organization_token.py diff --git a/examples/organization_token.py b/examples/organization_token.py new file mode 100644 index 00000000..187fac90 --- /dev/null +++ b/examples/organization_token.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +""" +Organization Token Operations Example + +Demonstrates usage of all 6 organization token operations: +1. create() - Create a new organization token, replacing any existing token +2. create_with_options() - Create with options like expiration date and token type +3. read() - Read the organization token +4. read_with_options() - Read with options like token type +5. delete() - Delete the organization token +6. delete_with_options() - Delete with options like token type + +Usage: +- Modify organization names as needed for your environment +- Ensure you have proper TFE credentials and organization access +- Organization tokens are used for organization-level API access + +Prerequisites: +- Set TFE_TOKEN and TFE_ADDRESS environment variables +- You need an existing organization or admin permissions to create one +- Appropriate permissions to manage organization tokens +""" + +from datetime import datetime, timedelta + +# Add the src directory to the path +##sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) +from pytfe import TFEClient, TFEConfig +from pytfe.models import ( + OrganizationTokenCreateOptions, + OrganizationTokenDeleteOptions, + OrganizationTokenReadOptions, + TokenType, +) + + +def redact_token(token_value: str | None) -> str: + """Redact token value for safe display.""" + if not token_value: + return "None" + if len(token_value) <= 8: + return f"{'*' * len(token_value)}" + # Show first 3 and last 3 characters + return f"{token_value[:3]}...{token_value[-3:]}".replace( + token_value[3:-3], "*" * (len(token_value) - 6) + ) + + +def redact_id(id_value: str | None) -> str: + """Redact ID for safe display.""" + if not id_value: + return "None" + if len(id_value) <= 6: + return f"{'*' * len(id_value)}" + # Show first 3 and last 3 characters + return f"{id_value[:3]}...{id_value[-3:]}" + + +def main(): + """Execute organization token operation examples.""" + + print("=" * 80) + print("ORGANIZATION TOKEN OPERATIONS") + print("=" * 80) + + # Initialize the TFE client + client = TFEClient(TFEConfig.from_env()) + organization_name = "prab-sandbox02" + # ===================================================== + # 1. CREATE ORGANIZATION TOKEN (BASIC) + # ===================================================== + print("\n1. create() - Create a new organization token:") + print("-" * 40) + try: + print(f"Creating token for organization: {organization_name}") + token = client.organization_tokens.create(organization_name) + + print("Token created successfully!") + print(f" Token ID: {redact_id(token.id)}") + print(f" Created At: {token.created_at}") + print(f" Description: {token.description}") + print(f" Token Value: {redact_token(token.token)}") + if token.expired_at: + print(f" Expires At: {token.expired_at}") + print() + + except Exception as e: + print(f" Error: {e}") + print() + + # ===================================================== + # 2. CREATE WITH OPTIONS (WITH EXPIRATION) + # ===================================================== + print("2. create_with_options() - Create token with expiration date:") + print("-" * 40) + try: + # Create a token that expires in 30 days + expiry_date = datetime.utcnow() + timedelta(days=30) + options = OrganizationTokenCreateOptions(expired_at=expiry_date) + + print(f"Creating organization token with expiration date: {expiry_date}") + token = client.organization_tokens.create_with_options( + organization_name, options + ) + + print("Token created with options successfully!") + print(f" Token ID: {redact_id(token.id)}") + print(f" Created At: {token.created_at}") + if token.expired_at: + print(f" Expires At: {token.expired_at}") + print() + + except Exception as e: + print(f" Error: {e}") + print() + + # ===================================================== + print("3. create_with_options() - Create audit-trails token:") + print("-" * 40) + try: + options = OrganizationTokenCreateOptions(token_type=TokenType.AUDIT_TRAILS) + + print(f"Creating audit-trails token for organization: {organization_name}") + token = client.organization_tokens.create_with_options( + organization_name, options + ) + + print(" Audit-trails token created successfully!") + print(f" Token ID: {redact_id(token.id)}") + print(f" Token Value: {redact_token(token.token)}") + print() + + except Exception as e: + print(f"Error: {e}") + print() + + # ===================================================== + print("4. read() - Read the organization token:") + print("-" * 40) + try: + print(f"Reading organization token for organization: {organization_name}") + token = client.organization_tokens.read(organization_name) + + print("Token read successfully!") + print(f" Token ID: {redact_id(token.id)}") + print(f" Created At: {token.created_at}") + print(f" Description: {token.description}") + if token.last_used_at: + print(f" Last Used At: {token.last_used_at}") + if token.expired_at: + print(f" Expires At: {token.expired_at}") + print() + + except Exception as e: + print(f" Error: {e}") + print() + + # ===================================================== + print("5. read_with_options() - Read audit-trails token:") + print("-" * 40) + try: + options = OrganizationTokenReadOptions(token_type=TokenType.AUDIT_TRAILS) + + print(f"Reading audit-trails token for organization: {organization_name}") + token = client.organization_tokens.read_with_options(organization_name, options) + + print(" Audit-trails token read successfully!") + print(f" Token ID: {redact_id(token.id)}") + print(f" Token Value: {redact_token(token.token)}") + print() + + except Exception as e: + print(f" Error: {e}") + print() + + # ===================================================== + print("6. delete() - Delete the organization token:") + print("-" * 40) + try: + print(f"Deleting organization token for organization: {organization_name}") + client.organization_tokens.delete(organization_name) + + print(" Token deleted successfully!") + print() + + except Exception as e: + print(f" Error: {e}") + print() + + # ===================================================== + print("7. delete_with_options() - Delete audit-trails token:") + print("-" * 40) + try: + options = OrganizationTokenDeleteOptions(token_type=TokenType.AUDIT_TRAILS) + + print(f"Deleting audit-trails token for organization: {organization_name}") + client.organization_tokens.delete_with_options(organization_name, options) + + print(" Audit-trails token deleted successfully!") + print() + + except Exception as e: + print(f"Error: {e}") + print() + + print("=" * 80) + print("ORGANIZATION TOKEN OPERATIONS COMPLETED") + print("=" * 80) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 31961371..c4d61cec 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_token import OrganizationTokens from .resources.organizations import Organizations from .resources.plan import Plans from .resources.policy import Policies @@ -33,11 +34,8 @@ from .resources.run_task import RunTasks from .resources.run_trigger import RunTriggers from .resources.ssh_keys import SSHKeys -from .resources.stack import Stacks from .resources.state_version_outputs import StateVersionOutputs from .resources.state_versions import StateVersions -from .resources.team import Teams -from .resources.team_project_access import TeamProjectAccesses from .resources.variable import Variables from .resources.variable_sets import VariableSets, VariableSetVariables from .resources.workspace_resources import WorkspaceResourcesService @@ -75,7 +73,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_tokens = OrganizationTokens(self._transport) self.projects = Projects(self._transport) self.variables = Variables(self._transport) self.variable_sets = VariableSets(self._transport) @@ -104,16 +102,12 @@ def __init__(self, config: TFEConfig | None = None): # SSH Keys self.ssh_keys = SSHKeys(self._transport) - # Team project access - self.team_project_accesses = TeamProjectAccesses(self._transport) - self.teams = Teams(self._transport) # Reserved Tag Key self.reserved_tag_key = ReservedTagKeys(self._transport) - self.stacks = Stacks(self._transport) def close(self) -> None: try: self._transport._sync.close() except Exception: - pass + pass \ No newline at end of file diff --git a/src/pytfe/models/organization_token.py b/src/pytfe/models/organization_token.py new file mode 100644 index 00000000..1c4fd59a --- /dev/null +++ b/src/pytfe/models/organization_token.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import TYPE_CHECKING, Any + +from pydantic import BaseModel, ConfigDict, Field + +if TYPE_CHECKING: + pass + + +class TokenType(str, Enum): + """Token type enumeration.""" + + AUDIT_TRAILS = "audit-trails" + + +class OrganizationToken(BaseModel): + """Organization token represents a Terraform Enterprise organization token.""" + + model_config = ConfigDict(extra="forbid") + + id: str = Field(..., description="Organization token ID") + created_at: datetime = Field(..., description="Creation timestamp") + description: str | None = Field(None, description="Token description") + last_used_at: datetime | None = Field(None, description="Last usage timestamp") + token: str | None = Field(None, description="The actual token value") + expired_at: datetime | None = Field(None, description="Token expiration timestamp") + created_by: Any | None = Field( + None, description="The entity that created this token" + ) + + +class OrganizationTokenCreateOptions(BaseModel): + """Options for creating an organization token.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + expired_at: datetime | None = Field( + None, + description="The token's expiration date. Available in TFE release v202305-1 and later", + ) + token_type: TokenType | None = Field( + None, + alias="token", + description="What type of token to create. Only applicable to HCP Terraform", + ) + + +class OrganizationTokenReadOptions(BaseModel): + """Options for reading an organization token.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + token_type: TokenType | None = Field( + None, + alias="token", + description="What type of token to read. Only applicable to HCP Terraform", + ) + + +class OrganizationTokenDeleteOptions(BaseModel): + """Options for deleting an organization token.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + token_type: TokenType | None = Field( + None, + alias="token", + description="What type of token to delete. Only applicable to HCP Terraform", + ) \ No newline at end of file diff --git a/src/pytfe/resources/organization_token.py b/src/pytfe/resources/organization_token.py new file mode 100644 index 00000000..d22da189 --- /dev/null +++ b/src/pytfe/resources/organization_token.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any +from urllib.parse import quote + +from ..errors import ERR_INVALID_ORG +from ..models.organization_token import ( + OrganizationToken, + OrganizationTokenCreateOptions, + OrganizationTokenDeleteOptions, + OrganizationTokenReadOptions, +) +from ..utils import valid_string_id +from ._base import _Service + + +class OrganizationTokens(_Service): + """Organization tokens service for managing TFE organization tokens.""" + + def create(self, organization: str) -> OrganizationToken: + """Create a new organization token, replacing any existing token. + + Args: + organization: The organization name or ID + + Returns: + OrganizationToken: The created organization token + + Raises: + ValueError: If the organization name is invalid + """ + return self.create_with_options(organization) + + def create_with_options( + self, + organization: str, + options: OrganizationTokenCreateOptions | None = None, + ) -> OrganizationToken: + """Create a new organization token with options, replacing any existing token. + + Args: + organization: The organization name or ID + options: Options for creating the token + + Returns: + OrganizationToken: The created organization token + + Raises: + ValueError: If the organization name is invalid + """ + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + + path = f"/api/v2/organizations/{quote(organization)}/authentication-token" + + # Build request body + body: dict[str, Any] = { + "data": { + "type": "authentication-token", + "attributes": {}, + } + } + + # Add optional attributes + if options and options.expired_at is not None: + body["data"]["attributes"]["expired-at"] = options.expired_at.isoformat() + + # Add query parameters for token type if specified + params = {} + if options and options.token_type is not None: + params["token"] = options.token_type.value + + if params: + response = self.t.request("POST", path, json_body=body, params=params) + else: + response = self.t.request("POST", path, json_body=body) + + data = response.json() + + if "data" in data: + return self._parse_organization_token(data["data"]) + + raise ValueError("Invalid response format") + + def read(self, organization: str) -> OrganizationToken: + """Read an organization token. + + Args: + organization: The organization name or ID + + Returns: + OrganizationToken: The organization token + + Raises: + ValueError: If the organization name is invalid + """ + return self.read_with_options(organization, None) + + def read_with_options( + self, + organization: str, + options: OrganizationTokenReadOptions | None = None, + ) -> OrganizationToken: + """Read an organization token with options. + + Args: + organization: The organization name or ID + options: Options for reading the token + + Returns: + OrganizationToken: The organization token + + Raises: + ValueError: If the organization name is invalid + """ + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + + path = f"/api/v2/organizations/{quote(organization)}/authentication-token" + + # Add query parameters for token type if specified + params = {} + if options and options.token_type is not None: + params["token"] = options.token_type.value + + response = self.t.request("GET", path, params=params if params else None) + data = response.json() + + if "data" in data: + return self._parse_organization_token(data["data"]) + + raise ValueError("Invalid response format") + + def delete(self, organization: str) -> None: + """Delete an organization token. + + Args: + organization: The organization name or ID + + Raises: + ValueError: If the organization name is invalid + """ + return self.delete_with_options(organization, None) + + def delete_with_options( + self, + organization: str, + options: OrganizationTokenDeleteOptions | None = None, + ) -> None: + """Delete an organization token with options. + + Args: + organization: The organization name or ID + options: Options for deleting the token + + Raises: + ValueError: If the organization name is invalid + """ + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + + path = f"/api/v2/organizations/{quote(organization)}/authentication-token" + + # Add query parameters for token type if specified + params = {} + if options and options.token_type is not None: + params["token"] = options.token_type.value + + if params: + self.t.request("DELETE", path, params=params) + else: + self.t.request("DELETE", path) + + def _parse_organization_token(self, data: dict[str, Any]) -> OrganizationToken: + """Parse organization token data from API response. + + Args: + data: The token data from the API response + + Returns: + OrganizationToken: The parsed organization token + """ + attributes = data.get("attributes", {}) + + # Parse timestamps + created_at_str = attributes.get("created-at") + created_at = ( + datetime.fromisoformat(created_at_str.replace("Z", "+00:00")) + if created_at_str + else datetime.now() + ) + + last_used_at_str = attributes.get("last-used-at") + last_used_at = ( + datetime.fromisoformat(last_used_at_str.replace("Z", "+00:00")) + if last_used_at_str + else None + ) + + expired_at_str = attributes.get("expired-at") + expired_at = ( + datetime.fromisoformat(expired_at_str.replace("Z", "+00:00")) + if expired_at_str + else None + ) + + # Parse created-by relationship + created_by = None + # For now, just set to None since it's mainly for display + + return OrganizationToken( + id=data.get("id", ""), + created_at=created_at, + description=attributes.get("description", ""), + last_used_at=last_used_at, + token=attributes.get("token", ""), + expired_at=expired_at, + created_by=created_by, + ) \ No newline at end of file diff --git a/tests/units/test_organization_token.py b/tests/units/test_organization_token.py new file mode 100644 index 00000000..146aebe9 --- /dev/null +++ b/tests/units/test_organization_token.py @@ -0,0 +1,313 @@ +"""Unit tests for the organization token module.""" + +from datetime import datetime +from unittest.mock import Mock, patch + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import ERR_INVALID_ORG +from pytfe.models.organization_token import ( + OrganizationToken, + OrganizationTokenCreateOptions, + OrganizationTokenDeleteOptions, + OrganizationTokenReadOptions, + TokenType, +) +from pytfe.resources.organization_token import OrganizationTokens + + +class TestOrganizationTokens: + """Test the OrganizationTokens service class.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock HTTPTransport.""" + return Mock(spec=HTTPTransport) + + @pytest.fixture + def org_tokens_service(self, mock_transport): + """Create an OrganizationTokens service with mocked transport.""" + return OrganizationTokens(mock_transport) + + def test_create_success(self, org_tokens_service): + """Test successful create operation.""" + mock_response_data = { + "data": { + "id": "at-test123", + "attributes": { + "created-at": "2023-01-01T00:00:00Z", + "description": "Test token", + "token": "test-token-value", + }, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + + with patch.object(org_tokens_service, "t") as mock_t: + mock_t.request.return_value = mock_response + + result = org_tokens_service.create("test-org") + + mock_t.request.assert_called_once() + call_args = mock_t.request.call_args + + assert call_args[0][0] == "POST" + assert ( + call_args[0][1] == "/api/v2/organizations/test-org/authentication-token" + ) + assert "json_body" in call_args[1] + assert "data" in call_args[1]["json_body"] + assert "attributes" in call_args[1]["json_body"]["data"] + assert isinstance(result, OrganizationToken) + assert result.id == "at-test123" + assert result.description == "Test token" + + def test_create_validation_errors(self, org_tokens_service): + """Test create with invalid organization name.""" + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + org_tokens_service.create("") + + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + org_tokens_service.create(None) + + def test_create_with_options_expiration_success(self, org_tokens_service): + """Test create with options including expiration date.""" + mock_response_data = { + "data": { + "id": "at-exp-123", + "attributes": { + "created-at": "2023-01-01T00:00:00Z", + "token": "token-value", + "expired-at": "2024-01-01T00:00:00Z", + }, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + + with patch.object(org_tokens_service, "t") as mock_t: + mock_t.request.return_value = mock_response + + expiry = datetime(2024, 1, 1, 0, 0, 0) + options = OrganizationTokenCreateOptions(expired_at=expiry) + + result = org_tokens_service.create_with_options("test-org", options) + + assert isinstance(result, OrganizationToken) + assert result.expired_at is not None + + call_args = mock_t.request.call_args + assert call_args[0][0] == "POST" + assert ( + call_args[0][1] == "/api/v2/organizations/test-org/authentication-token" + ) + body = call_args[1]["json_body"] + assert "expired-at" in body["data"]["attributes"] + assert body["data"]["attributes"]["expired-at"] == "2024-01-01T00:00:00" + + def test_create_with_options_token_type_success(self, org_tokens_service): + """Test create with options including token type.""" + mock_response_data = { + "data": { + "id": "at-audit-123", + "attributes": { + "created-at": "2023-01-01T00:00:00Z", + "token": "audit-token-value", + }, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + + with patch.object(org_tokens_service, "t") as mock_t: + mock_t.request.return_value = mock_response + + options = OrganizationTokenCreateOptions(token_type=TokenType.AUDIT_TRAILS) + result = org_tokens_service.create_with_options("test-org", options) + + assert isinstance(result, OrganizationToken) + call_args = mock_t.request.call_args + assert call_args[0][0] == "POST" + assert ( + call_args[0][1] == "/api/v2/organizations/test-org/authentication-token" + ) + assert "params" in call_args[1] + assert call_args[1]["params"]["token"] == "audit-trails" + assert "json_body" in call_args[1] + + def test_read_success(self, org_tokens_service): + """Test successful read operation.""" + mock_response_data = { + "data": { + "id": "at-read-123", + "attributes": { + "created-at": "2023-01-01T00:00:00Z", + "description": "Read token", + "token": "read-token-value", + }, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + + with patch.object(org_tokens_service, "t") as mock_t: + mock_t.request.return_value = mock_response + + result = org_tokens_service.read("test-org") + + assert isinstance(result, OrganizationToken) + assert result.id == "at-read-123" + + call_args = mock_t.request.call_args + assert call_args[0][0] == "GET" + assert ( + call_args[0][1] == "/api/v2/organizations/test-org/authentication-token" + ) + + def test_read_validation_errors(self, org_tokens_service): + """Test read with invalid organization name.""" + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + org_tokens_service.read("") + + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + org_tokens_service.read(None) + + def test_read_with_options_token_type_success(self, org_tokens_service): + """Test read with options including token type.""" + mock_response_data = { + "data": { + "id": "at-audit-read-123", + "attributes": { + "created-at": "2023-01-01T00:00:00Z", + "token": "audit-read-value", + }, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + + with patch.object(org_tokens_service, "t") as mock_t: + mock_t.request.return_value = mock_response + + options = OrganizationTokenReadOptions(token_type=TokenType.AUDIT_TRAILS) + result = org_tokens_service.read_with_options("test-org", options) + + assert isinstance(result, OrganizationToken) + call_args = mock_t.request.call_args + assert call_args[0][0] == "GET" + assert ( + call_args[0][1] == "/api/v2/organizations/test-org/authentication-token" + ) + assert call_args[1]["params"]["token"] == "audit-trails" + + def test_delete_success(self, org_tokens_service): + """Test successful delete operation.""" + with patch.object(org_tokens_service, "t") as mock_t: + mock_t.request.return_value = Mock() + + result = org_tokens_service.delete("test-org") + + assert result is None + call_args = mock_t.request.call_args + assert call_args[0][0] == "DELETE" + assert ( + call_args[0][1] == "/api/v2/organizations/test-org/authentication-token" + ) + + def test_delete_validation_errors(self, org_tokens_service): + """Test delete with invalid organization name.""" + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + org_tokens_service.delete("") + + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + org_tokens_service.delete(None) + + def test_delete_with_options_token_type_success(self, org_tokens_service): + """Test delete with options including token type.""" + with patch.object(org_tokens_service, "t") as mock_t: + mock_t.request.return_value = Mock() + + options = OrganizationTokenDeleteOptions(token_type=TokenType.AUDIT_TRAILS) + result = org_tokens_service.delete_with_options("test-org", options) + + assert result is None + call_args = mock_t.request.call_args + assert call_args[0][0] == "DELETE" + assert ( + call_args[0][1] == "/api/v2/organizations/test-org/authentication-token" + ) + assert call_args[1]["params"]["token"] == "audit-trails" + + def test_parse_token_minimal(self, org_tokens_service): + """Test parsing token with minimal data.""" + data = { + "id": "at-minimal-123", + "attributes": { + "created-at": "2023-01-01T00:00:00Z", + "description": "Minimal token", + "token": "minimal-value", + }, + "relationships": {}, + } + + result = org_tokens_service._parse_organization_token(data) + + assert result.id == "at-minimal-123" + assert isinstance(result.created_at, datetime) + assert result.description == "Minimal token" + assert result.token == "minimal-value" + assert result.last_used_at is None + assert result.expired_at is None + + def test_parse_token_all_fields(self, org_tokens_service): + """Test parsing token with all fields populated.""" + data = { + "id": "at-full-123", + "attributes": { + "created-at": "2023-01-01T00:00:00Z", + "description": "Full token", + "token": "full-value", + "last-used-at": "2023-01-15T12:30:00Z", + "expired-at": "2024-01-01T00:00:00Z", + }, + "relationships": {}, + } + + result = org_tokens_service._parse_organization_token(data) + + assert result.id == "at-full-123" + assert result.description == "Full token" + assert result.token == "full-value" + assert result.last_used_at is not None + assert result.expired_at is not None + assert isinstance(result.last_used_at, datetime) + assert isinstance(result.expired_at, datetime) + + def test_invalid_response_format_on_create(self, org_tokens_service): + """Test handling of invalid response format when creating.""" + mock_response = Mock() + mock_response.json.return_value = {"error": "Invalid"} + + with patch.object(org_tokens_service, "t") as mock_t: + mock_t.request.return_value = mock_response + + with pytest.raises(ValueError, match="Invalid response format"): + org_tokens_service.create("test-org") + + def test_invalid_response_format_on_read(self, org_tokens_service): + """Test handling of invalid response format when reading.""" + mock_response = Mock() + mock_response.json.return_value = {"error": "Invalid"} + + with patch.object(org_tokens_service, "t") as mock_t: + mock_t.request.return_value = mock_response + + with pytest.raises(ValueError, match="Invalid response format"): + org_tokens_service.read("test-org") \ No newline at end of file From 6ac8ec6b8aafa8116ce4f104c563ea6ffbb81a36 Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Tue, 5 May 2026 17:44:06 +0530 Subject: [PATCH 26/43] feat: update organization token APIs and related files --- examples/organization_token.py | 2 +- src/pytfe/client.py | 2 +- src/pytfe/models/organization_token.py | 2 +- src/pytfe/resources/organization_token.py | 2 +- tests/units/test_organization_token.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/organization_token.py b/examples/organization_token.py index 187fac90..0573ef13 100644 --- a/examples/organization_token.py +++ b/examples/organization_token.py @@ -209,4 +209,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/src/pytfe/client.py b/src/pytfe/client.py index c4d61cec..dc1972ce 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -110,4 +110,4 @@ def close(self) -> None: try: self._transport._sync.close() except Exception: - pass \ No newline at end of file + pass diff --git a/src/pytfe/models/organization_token.py b/src/pytfe/models/organization_token.py index 1c4fd59a..24f1cb0c 100644 --- a/src/pytfe/models/organization_token.py +++ b/src/pytfe/models/organization_token.py @@ -69,4 +69,4 @@ class OrganizationTokenDeleteOptions(BaseModel): None, alias="token", description="What type of token to delete. Only applicable to HCP Terraform", - ) \ No newline at end of file + ) diff --git a/src/pytfe/resources/organization_token.py b/src/pytfe/resources/organization_token.py index d22da189..dcbcfb28 100644 --- a/src/pytfe/resources/organization_token.py +++ b/src/pytfe/resources/organization_token.py @@ -217,4 +217,4 @@ def _parse_organization_token(self, data: dict[str, Any]) -> OrganizationToken: token=attributes.get("token", ""), expired_at=expired_at, created_by=created_by, - ) \ No newline at end of file + ) diff --git a/tests/units/test_organization_token.py b/tests/units/test_organization_token.py index 146aebe9..826f2239 100644 --- a/tests/units/test_organization_token.py +++ b/tests/units/test_organization_token.py @@ -310,4 +310,4 @@ def test_invalid_response_format_on_read(self, org_tokens_service): mock_t.request.return_value = mock_response with pytest.raises(ValueError, match="Invalid response format"): - org_tokens_service.read("test-org") \ No newline at end of file + org_tokens_service.read("test-org") From d0b826c27e9b856651a4f7bbbdb878e45bac614b Mon Sep 17 00:00:00 2001 From: Tanya Singh Date: Wed, 6 May 2026 14:59:19 +0530 Subject: [PATCH 27/43] Add user API support and current-user endpoints --- examples/user.py | 45 ++++++++++ src/pytfe/client.py | 2 + src/pytfe/models/user.py | 47 +++++++--- src/pytfe/resources/user.py | 40 +++++++++ tests/units/test_user.py | 167 ++++++++++++++++++++++++++++++++++++ 5 files changed, 291 insertions(+), 10 deletions(-) create mode 100644 examples/user.py create mode 100644 src/pytfe/resources/user.py create mode 100644 tests/units/test_user.py diff --git a/examples/user.py b/examples/user.py new file mode 100644 index 00000000..8a1dd82c --- /dev/null +++ b/examples/user.py @@ -0,0 +1,45 @@ +"""Example usage of the Users API. + +This example demonstrates how to read a user by ID using the Python TFE SDK. +""" + +import os +import sys + +# Add the src directory to the Python path so we can import the local package. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from pytfe import TFEClient, TFEConfig + + +def main() -> None: + """Read and print user details from Terraform Cloud.""" + user_id = os.getenv("TFE_USER_ID") + + try: + client = TFEClient(TFEConfig.from_env()) + + current_user = client.users.read_current() + print("=== Current Terraform Cloud User ===") + print(f"User ID: {current_user.id}") + print(f"Username: {current_user.username}") + print(f"Email: {current_user.email or 'N/A'}") + print(f"Auth Method: {current_user.auth_method or 'N/A'}") + + if not user_id: + print("\nTFE_USER_ID not set. Skipping client.users.read(user_id).") + return + + user = client.users.read(user_id) + + print("\n=== Terraform Cloud User By ID ===") + print(f"User ID: {user.id}") + print(f"Username: {user.username}") + print(f"Email: {user.email or 'N/A'}") + print(f"Auth Method: {user.auth_method or 'N/A'}") + except Exception as e: + print(f"Error running user example: {e}") + + +if __name__ == "__main__": + main() diff --git a/src/pytfe/client.py b/src/pytfe/client.py index dc1972ce..639111eb 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -36,6 +36,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 @@ -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.users = Users(self._transport) self.organization_tokens = OrganizationTokens(self._transport) self.projects = Projects(self._transport) self.variables = Variables(self._transport) diff --git a/src/pytfe/models/user.py b/src/pytfe/models/user.py index bfa43359..c72d1075 100644 --- a/src/pytfe/models/user.py +++ b/src/pytfe/models/user.py @@ -1,26 +1,53 @@ # Copyright IBM Corp. 2025, 2026 # SPDX-License-Identifier: MPL-2.0 -from __future__ import annotations - from pydantic import BaseModel, ConfigDict, Field +class TwoFactor(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + enabled: bool = Field(default=False, alias="enabled") + verified: bool = Field(default=False, alias="verified") + + +class UserPermissions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + can_create_organizations: bool = Field( + default=False, alias="can-create-organizations" + ) + can_change_email: bool = Field(default=False, alias="can-change-email") + can_change_username: bool = Field(default=False, alias="can-change-username") + can_manage_user_tokens: bool = Field(default=False, alias="can-manage-user-tokens") + can_view_2fa_settings: bool = Field(default=False, alias="can-view2fa-settings") + can_manage_hcp_account: bool = Field(default=False, alias="can-manage-hcp-account") + + class User(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) id: str = Field(..., alias="id") - avatar_url: str = Field(default="", alias="avatar-url") - email: str = Field(default="", alias="email") + auth_method: str | None = Field(default=None, alias="auth-method") + avatar_url: str | None = Field(default=None, alias="avatar-url") + email: str | None = Field(default=None, alias="email") is_service_account: bool = Field(default=False, alias="is-service-account") - two_factor: dict = Field(default_factory=dict, alias="two-factor") - unconfirmed_email: str = Field(default="", alias="unconfirmed-email") + two_factor: TwoFactor | None = Field(default=None, alias="two-factor") + unconfirmed_email: str | None = Field(default=None, alias="unconfirmed-email") username: str = Field(default="", alias="username") v2_only: bool = Field(default=False, alias="v2-only") - is_site_admin: bool = Field(default=False, alias="is-site-admin") # Deprecated - is_admin: bool = Field(default=False, alias="is-admin") - is_sso_login: bool = Field(default=False, alias="is-sso-login") - permissions: dict = Field(default_factory=dict, alias="permissions") + is_site_admin: bool | None = Field( + default=None, alias="is-site-admin" + ) # Deprecated + is_admin: bool | None = Field(default=None, alias="is-admin") + is_sso_login: bool | None = Field(default=None, alias="is-sso-login") + permissions: UserPermissions | None = Field(default=None, alias="permissions") # Relations # authentication_tokens: AuthenticationTokens = Field(..., alias="authentication-tokens") + + +class UserUpdateCurrentOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + username: str | None = Field(default=None, alias="username") + email: str | None = Field(default=None, alias="email") diff --git a/src/pytfe/resources/user.py b/src/pytfe/resources/user.py new file mode 100644 index 00000000..ab5a7e3b --- /dev/null +++ b/src/pytfe/resources/user.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from ..models.user import User, UserUpdateCurrentOptions +from ..utils import valid_string_id +from ._base import _Service + + +class Users(_Service): + def read(self, user_id: str) -> User: + if not valid_string_id(user_id): + raise ValueError("invalid user id") + + r = self.t.request("GET", f"/api/v2/users/{user_id}") + d = r.json()["data"] + attr = d.get("attributes", {}) or {} + user_data = dict(attr) + user_data["id"] = d.get("id") + return User(**user_data) + + def read_current(self) -> User: + r = self.t.request("GET", "/api/v2/account/details") + d = r.json()["data"] + attr = d.get("attributes", {}) or {} + user_data = dict(attr) + user_data["id"] = d.get("id") + return User(**user_data) + + def update_current(self, options: UserUpdateCurrentOptions) -> User: + body = { + "data": { + "type": "users", + "attributes": options.model_dump(exclude_none=True), + } + } + r = self.t.request("PATCH", "/api/v2/account/update", json_body=body) + d = r.json()["data"] + attr = d.get("attributes", {}) or {} + user_data = dict(attr) + user_data["id"] = d.get("id") + return User(**user_data) diff --git a/tests/units/test_user.py b/tests/units/test_user.py new file mode 100644 index 00000000..2be95650 --- /dev/null +++ b/tests/units/test_user.py @@ -0,0 +1,167 @@ +"""Unit tests for the Users resource.""" + +import copy +from unittest.mock import Mock + +import pytest + +from pytfe.models.user import User, UserPermissions, UserUpdateCurrentOptions +from pytfe.resources.user import Users + + +class TestUsers: + """Test suite for user resource operations.""" + + @pytest.fixture + def mock_transport(self): + """Mock HTTP transport.""" + return Mock() + + @pytest.fixture + def users_service(self, mock_transport): + """Create users service with mocked transport.""" + return Users(mock_transport) + + @pytest.fixture + def sample_user_response(self): + """Sample JSON:API response for a user.""" + return { + "data": { + "id": "user-MA4GL63FmYRpSFxa", + "type": "users", + "attributes": { + "username": "admin", + "email": "admin@example.com", + "is-service-account": False, + "auth-method": "hcp_sso", + "avatar-url": "https://example.com/avatar.png", + "v2-only": True, + "permissions": { + "can-create-organizations": False, + "can-change-email": True, + "can-change-username": True, + }, + }, + } + } + + def test_read_user(self, users_service, mock_transport, sample_user_response): + """Test reading a specific user by ID.""" + mock_transport.request.return_value.json.return_value = sample_user_response + + user_id = "user-MA4GL63FmYRpSFxa" + user = users_service.read(user_id) + + mock_transport.request.assert_called_once_with( + "GET", f"/api/v2/users/{user_id}" + ) + assert isinstance(user, User) + assert user.id == user_id + assert user.username == "admin" + assert user.email == "admin@example.com" + assert user.is_service_account is False + assert user.auth_method == "hcp_sso" + assert user.avatar_url == "https://example.com/avatar.png" + assert user.v2_only is True + assert isinstance(user.permissions, UserPermissions) + assert user.permissions is not None + assert user.permissions.can_create_organizations is False + assert user.permissions.can_change_email is True + assert user.permissions.can_change_username is True + assert user.permissions.can_manage_user_tokens is False + assert user.permissions.can_view_2fa_settings is False + assert user.permissions.can_manage_hcp_account is False + + def test_read_user_invalid_id(self, users_service): + """Test reading a user with an invalid user ID.""" + with pytest.raises(ValueError, match="invalid user id"): + users_service.read("") + + def test_read_user_with_null_unconfirmed_email( + self, users_service, mock_transport, sample_user_response + ): + """Test reading a user when unconfirmed-email is null.""" + sample_user_response["data"]["attributes"]["unconfirmed-email"] = None + mock_transport.request.return_value.json.return_value = sample_user_response + + user = users_service.read("user-MA4GL63FmYRpSFxa") + + assert isinstance(user, User) + assert user.unconfirmed_email is None + + def test_read_user_two_factor_parsing( + self, users_service, mock_transport, sample_user_response + ): + """Test reading a user with two-factor data.""" + modified_response = copy.deepcopy(sample_user_response) + modified_response["data"]["attributes"]["two-factor"] = { + "enabled": True, + "verified": False, + } + mock_transport.request.return_value.json.return_value = modified_response + + user_id = "user-MA4GL63FmYRpSFxa" + user = users_service.read(user_id) + + assert user.two_factor is not None + assert user.two_factor.enabled is True + assert user.two_factor.verified is False + + def test_read_user_nullable_bools( + self, users_service, mock_transport, sample_user_response + ): + """Test reading a user when pointer-style boolean fields are null.""" + modified_response = copy.deepcopy(sample_user_response) + modified_response["data"]["attributes"]["is-site-admin"] = None + modified_response["data"]["attributes"]["is-admin"] = None + modified_response["data"]["attributes"]["is-sso-login"] = None + mock_transport.request.return_value.json.return_value = modified_response + + user_id = "user-MA4GL63FmYRpSFxa" + user = users_service.read(user_id) + + assert user.is_site_admin is None + assert user.is_admin is None + assert user.is_sso_login is None + + def test_read_current_user( + self, users_service, mock_transport, sample_user_response + ): + """Test reading the currently authenticated user.""" + mock_transport.request.return_value.json.return_value = sample_user_response + + user = users_service.read_current() + + mock_transport.request.assert_called_once_with("GET", "/api/v2/account/details") + assert isinstance(user, User) + assert user.id == "user-MA4GL63FmYRpSFxa" + assert user.username == "admin" + assert user.email == "admin@example.com" + + def test_update_current_user( + self, users_service, mock_transport, sample_user_response + ): + """Test updating the currently authenticated user.""" + mock_transport.request.return_value.json.return_value = sample_user_response + options = UserUpdateCurrentOptions( + username="new-admin", + email="new-admin@example.com", + ) + + user = users_service.update_current(options) + + mock_transport.request.assert_called_once_with( + "PATCH", + "/api/v2/account/update", + json_body={ + "data": { + "type": "users", + "attributes": { + "username": "new-admin", + "email": "new-admin@example.com", + }, + } + }, + ) + assert isinstance(user, User) + assert user.id == "user-MA4GL63FmYRpSFxa" From 6b926b6ce0a14c6d8467f2f1bfdf6ee586d5163b Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Thu, 7 May 2026 12:14:58 +0530 Subject: [PATCH 28/43] feat(registry-provider-platform): Added the errors and models for the feature --- src/pytfe/client.py | 2 + src/pytfe/errors.py | 60 ++++++++++ src/pytfe/models/__init__.py | 16 +++ .../models/registry_provider_platform.py | 105 ++++++++++++++++++ 4 files changed, 183 insertions(+) create mode 100644 src/pytfe/models/registry_provider_platform.py diff --git a/src/pytfe/client.py b/src/pytfe/client.py index dc1972ce..d60221b4 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -27,6 +27,7 @@ from .resources.query_run import QueryRuns from .resources.registry_module import RegistryModules from .resources.registry_provider import RegistryProviders +from .resources.registry_provider_platform import RegistryProviderPlatforms from .resources.registry_provider_version import RegistryProviderVersions from .resources.reserved_tag_key import ReservedTagKeys from .resources.run import Runs @@ -83,6 +84,7 @@ def __init__(self, config: TFEConfig | None = None): self.registry_modules = RegistryModules(self._transport) self.registry_providers = RegistryProviders(self._transport) self.registry_provider_versions = RegistryProviderVersions(self._transport) + self.registry_provider_platforms = RegistryProviderPlatforms(self._transport) # State and execution resources self.state_versions = StateVersions(self._transport) diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index 4d616a17..f2340af3 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -567,3 +567,63 @@ class InvalidTeamProjectAccessIDError(InvalidValues): def __init__(self, message: str = "invalid value for team project access ID"): super().__init__(message) + + +# Registry Provider Platform errors +class RequiredOSError(RequiredFieldMissing): + """Raised when a required OS field is missing.""" + + def __init__(self, message: str = "os is required"): + super().__init__(message) + + +class RequiredArchError(RequiredFieldMissing): + """Raised when a required architecture field is missing.""" + + def __init__(self, message: str = "arch is required"): + super().__init__(message) + + +class RequiredShasumError(RequiredFieldMissing): + """Raised when a required shasum field is missing.""" + + def __init__(self, message: str = "shasum is required"): + super().__init__(message) + + +class RequiredFilenameError(RequiredFieldMissing): + """Raised when a required filename field is missing.""" + + def __init__(self, message: str = "filename is required"): + super().__init__(message) + + +class InvalidOSError(InvalidValues): + """Raised when an invalid OS field is provided.""" + + def __init__(self, message: str = "invalid value for os"): + super().__init__(message) + + +class InvalidArchError(InvalidValues): + """Raised when an invalid architecture field is provided.""" + + def __init__(self, message: str = "invalid value for arch"): + super().__init__(message) + + +class InvalidNamespaceError(InvalidValues): + """Raised when an invalid namespace field is provided.""" + + def __init__(self, message: str = "invalid value for namespace"): + super().__init__(message) + + +class InvalidRegistryNameError(InvalidValues): + """Raised when an invalid registry name field is provided.""" + + def __init__( + self, + message: str = "invalid value for registry-name. It must be either private or public", + ): + super().__init__(message) diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index f332fbbf..c0d6c773 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -230,6 +230,13 @@ RegistryProviderPermissions, RegistryProviderReadOptions, ) +from .registry_provider_platform import ( + RegistryProviderPlatform, + RegistryProviderPlatformCreateOptions, + RegistryProviderPlatformID, + RegistryProviderPlatformListOptions, + RegistryProviderPlatformPermissions, +) from .registry_provider_version import ( RegistryProviderVersion, RegistryProviderVersionCreateOptions, @@ -500,6 +507,12 @@ "RegistryProviderVersionID", "RegistryProviderVersionListOptions", "RegistryProviderVersionPermissions", + # Registry provider platforms + "RegistryProviderPlatform", + "RegistryProviderPlatformCreateOptions", + "RegistryProviderPlatformID", + "RegistryProviderPlatformListOptions", + "RegistryProviderPlatformPermissions", # Query runs "QueryRun", "QueryRunActions", @@ -706,3 +719,6 @@ # Rebuild models with forward references after all models are loaded PolicyCheck.model_rebuild() +RegistryProvider.model_rebuild() +RegistryProviderVersion.model_rebuild() +RegistryProviderPlatform.model_rebuild() diff --git a/src/pytfe/models/registry_provider_platform.py b/src/pytfe/models/registry_provider_platform.py new file mode 100644 index 00000000..716ac6e4 --- /dev/null +++ b/src/pytfe/models/registry_provider_platform.py @@ -0,0 +1,105 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from ..errors import ( + InvalidArchError, + InvalidOSError, + RequiredArchError, + RequiredFilenameError, + RequiredOSError, + RequiredShasumError, +) +from ..utils import valid_string, valid_string_id +from .registry_provider_version import ( + RegistryProviderVersion, + RegistryProviderVersionID, +) + + +class RegistryProviderPlatformPermissions(BaseModel): + """Registry provider platform permissions.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + can_delete: bool = Field(alias="can-delete") + can_upload_asset: bool = Field(alias="can-upload-asset") + + +class RegistryProviderPlatform(BaseModel): + """Registry provider platform model.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str + os: str = Field(alias="os", default="") + arch: str = Field(alias="arch", default="") + filename: str = Field(alias="filename", default="") + shasum: str = Field(alias="shasum", default="") + provider_binary_uploaded: bool | None = Field( + alias="provider-binary-uploaded", default=None + ) + permissions: RegistryProviderPlatformPermissions | None = None + + # Relations + registry_provider_version: RegistryProviderVersion | None = Field( + alias="registry-provider-version", default=None + ) + + # Links + links: dict[str, Any] | None = None + + +class RegistryProviderPlatformID(RegistryProviderVersionID): + """Registry provider platform identifier. + + Extends RegistryProviderVersionID with OS and arch to uniquely + identify a specific platform of a provider version. + """ + + os: str + arch: str + + @model_validator(mode="after") + def valid_platform_id(self) -> RegistryProviderPlatformID: + if not valid_string_id(self.os): + raise InvalidOSError() + if not valid_string_id(self.arch): + raise InvalidArchError() + return self + + +class RegistryProviderPlatformCreateOptions(BaseModel): + """Options for creating a registry provider platform.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + os: str = Field(alias="os") + arch: str = Field(alias="arch") + shasum: str = Field(alias="shasum") + filename: str = Field(alias="filename") + + @model_validator(mode="after") + def valid(self) -> RegistryProviderPlatformCreateOptions: + if not valid_string(self.os): + raise RequiredOSError() + if not valid_string(self.arch): + raise RequiredArchError() + if not valid_string_id(self.shasum): + raise RequiredShasumError() + if not valid_string_id(self.filename): + raise RequiredFilenameError() + return self + + +class RegistryProviderPlatformListOptions(BaseModel): + """Options for listing registry provider platforms.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + page_size: int | None = Field(alias="page[size]", default=None) From 8f1c80aac69c5beb8d3d064d765fb9f71b36bdad Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Thu, 7 May 2026 12:16:11 +0530 Subject: [PATCH 29/43] feat(registry-provider-platform): Added create, list, read and delete methods in the resource --- .../resources/registry_provider_platform.py | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 src/pytfe/resources/registry_provider_platform.py diff --git a/src/pytfe/resources/registry_provider_platform.py b/src/pytfe/resources/registry_provider_platform.py new file mode 100644 index 00000000..a25c8e17 --- /dev/null +++ b/src/pytfe/resources/registry_provider_platform.py @@ -0,0 +1,106 @@ +# 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 ..models.registry_provider_platform import ( + RegistryProviderPlatform, + RegistryProviderPlatformCreateOptions, + RegistryProviderPlatformID, + RegistryProviderPlatformListOptions, +) +from ..models.registry_provider_version import ( + RegistryProviderVersion, + RegistryProviderVersionID, +) +from ._base import _Service + + +class RegistryProviderPlatforms(_Service): + """Service for managing Terraform registry provider platforms.""" + + def create( + self, + version_id: RegistryProviderVersionID, + options: RegistryProviderPlatformCreateOptions, + ) -> RegistryProviderPlatform: + """Create a registry provider platform""" + path = f"/api/v2/organizations/{version_id.organization_name}/registry-providers/{version_id.registry_name.value}/{version_id.namespace}/{version_id.name}/versions/{version_id.version}/platforms" + attributes = options.model_dump(by_alias=True, exclude_none=True) + payload = { + "data": { + "type": "registry-provider-platforms", + "attributes": attributes, + } + } + r = self.t.request("POST", path=path, json_body=payload) + data = r.json().get("data", {}) + return self._registry_provider_platform_from(data) + + def list( + self, + version_id: RegistryProviderVersionID, + options: RegistryProviderPlatformListOptions | None = None, + ) -> Iterator[RegistryProviderPlatform]: + """List registry provider platforms for a specific version""" + path = ( + f"/api/v2/organizations/{version_id.organization_name}" + f"/registry-providers/{version_id.registry_name.value}" + f"/{version_id.namespace}/{version_id.name}" + f"/versions/{version_id.version}/platforms" + ) + params = options.model_dump(by_alias=True) if options else {} + for item in self._list(path=path, params=params): + yield self._registry_provider_platform_from(item) + + def read(self, platform_id: RegistryProviderPlatformID) -> RegistryProviderPlatform: + """Read a specific registry provider platform""" + path = ( + f"/api/v2/organizations/{platform_id.organization_name}" + f"/registry-providers/{platform_id.registry_name.value}" + f"/{platform_id.namespace}/{platform_id.name}" + f"/versions/{platform_id.version}" + f"/platforms/{platform_id.os}/{platform_id.arch}" + ) + r = self.t.request("GET", path=path) + data = r.json().get("data", {}) + return self._registry_provider_platform_from(data) + + def delete(self, platform_id: RegistryProviderPlatformID) -> None: + """Delete a specific registry provider platform""" + path = ( + f"/api/v2/organizations/{platform_id.organization_name}" + f"/registry-providers/{platform_id.registry_name.value}" + f"/{platform_id.namespace}/{platform_id.name}" + f"/versions/{platform_id.version}" + f"/platforms/{platform_id.os}/{platform_id.arch}" + ) + self.t.request("DELETE", path=path) + return None + + def _registry_provider_platform_from( + self, data: dict[str, Any] + ) -> RegistryProviderPlatform: + """Parse a registry provider platform from API response data.""" + attrs = data.get("attributes", {}) + relationships = data.get("relationships", {}) + attrs["id"] = data.get("id") + + if ( + "registry-provider-version" in relationships + and "data" in relationships["registry-provider-version"] + and relationships["registry-provider-version"]["data"] is not None + ): + attrs["registry-provider-version"] = ( + RegistryProviderVersion.model_construct( + id=relationships["registry-provider-version"]["data"].get("id") + ) + ) + + if "links" in data: + attrs["links"] = data["links"] + + return RegistryProviderPlatform.model_validate(attrs) From 809a77efefa7e77cc62520e8b47db2e95d43e04c Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Thu, 7 May 2026 12:18:30 +0530 Subject: [PATCH 30/43] feat(registry-provider): Removed the validate function, utilized model_validator to validate inputs --- src/pytfe/models/registry_provider.py | 46 ++++++++++++++++++++---- src/pytfe/resources/registry_provider.py | 31 ---------------- 2 files changed, 39 insertions(+), 38 deletions(-) diff --git a/src/pytfe/models/registry_provider.py b/src/pytfe/models/registry_provider.py index 2861acac..5cd57414 100644 --- a/src/pytfe/models/registry_provider.py +++ b/src/pytfe/models/registry_provider.py @@ -7,7 +7,15 @@ from enum import Enum from typing import Any -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator + +from ..errors import ( + InvalidNameError, + InvalidNamespaceError, + InvalidOrgError, + InvalidValues, +) +from ..utils import valid_string_id class RegistryName(Enum): @@ -35,12 +43,14 @@ class RegistryProvider(BaseModel): """Registry provider model.""" id: str - name: str - namespace: str - created_at: datetime = Field(alias="created-at") - updated_at: datetime = Field(alias="updated-at") - registry_name: RegistryName = Field(alias="registry-name") - permissions: RegistryProviderPermissions + name: str = Field(alias="name", default="") + namespace: str = Field(alias="namespace", default="") + created_at: datetime | None = Field(alias="created-at", default=None) + updated_at: datetime | None = Field(alias="updated-at", default=None) + registry_name: RegistryName | None = Field(alias="registry-name", default=None) + permissions: RegistryProviderPermissions | None = Field( + alias="permissions", default=None + ) # Relations organization: dict[str, Any] | None = None @@ -62,6 +72,19 @@ class RegistryProviderID(BaseModel): namespace: str name: str + @model_validator(mode="after") + def valid(self) -> RegistryProviderID: + """Validate the registry provider ID.""" + if not valid_string_id(self.organization_name): + raise InvalidOrgError() + if not valid_string_id(self.name): + raise InvalidNameError() + if not valid_string_id(self.namespace): + raise InvalidNamespaceError() + if not valid_string_id(self.registry_name.value): + raise InvalidValues("invalid value for registry name") + return self + class RegistryProviderCreateOptions(BaseModel): """Options for creating a registry provider.""" @@ -72,6 +95,15 @@ class RegistryProviderCreateOptions(BaseModel): model_config = {"populate_by_name": True} + @model_validator(mode="after") + def valid(self) -> RegistryProviderCreateOptions: + """Validate the create options.""" + if not valid_string_id(self.name): + raise InvalidNameError() + if not valid_string_id(self.namespace): + raise InvalidNamespaceError() + return self + class RegistryProviderReadOptions(BaseModel): """Options for reading a registry provider.""" diff --git a/src/pytfe/resources/registry_provider.py b/src/pytfe/resources/registry_provider.py index d4ae122b..e9f2b48d 100644 --- a/src/pytfe/resources/registry_provider.py +++ b/src/pytfe/resources/registry_provider.py @@ -61,9 +61,6 @@ def create( if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) - if not self._validate_create_options(options): - raise ValueError("Invalid create options") - path = f"/api/v2/organizations/{organization}/registry-providers" # Prepare the data payload @@ -88,9 +85,6 @@ def read( options: RegistryProviderReadOptions | None = None, ) -> RegistryProvider: """Read a specific registry provider.""" - if not self._validate_provider_id(provider_id): - raise ValueError("Invalid provider ID") - path = ( f"/api/v2/organizations/{provider_id.organization_name}/" f"registry-providers/{provider_id.registry_name.value}/" @@ -107,9 +101,6 @@ def read( def delete(self, provider_id: RegistryProviderID) -> None: """Delete a registry provider.""" - if not self._validate_provider_id(provider_id): - raise ValueError("Invalid provider ID") - path = ( f"/api/v2/organizations/{provider_id.organization_name}/" f"registry-providers/{provider_id.registry_name.value}/" @@ -118,28 +109,6 @@ def delete(self, provider_id: RegistryProviderID) -> None: self.t.request("DELETE", path) - def _validate_provider_id(self, provider_id: RegistryProviderID) -> bool: - """Validate a registry provider ID.""" - if not valid_string_id(provider_id.organization_name): - return False - if not valid_string_id(provider_id.name): - return False - if not valid_string_id(provider_id.namespace): - return False - if provider_id.registry_name not in [RegistryName.PRIVATE, RegistryName.PUBLIC]: - return False - return True - - def _validate_create_options(self, options: RegistryProviderCreateOptions) -> bool: - """Validate create options.""" - if not valid_string_id(options.name): - return False - if not valid_string_id(options.namespace): - return False - if options.registry_name not in [RegistryName.PRIVATE, RegistryName.PUBLIC]: - return False - return True - def _parse_registry_provider(self, data: dict[str, Any]) -> RegistryProvider: """Parse a registry provider from API response data.""" if data is None: From 32c3e948e0f5272216e7f4eaa8785f77f92c6518 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Thu, 7 May 2026 12:20:00 +0530 Subject: [PATCH 31/43] feat(registry-provider-version): Removed the validate function, updated model to have id as mandatory attribute --- src/pytfe/models/registry_provider_version.py | 32 ++++++++----- .../resources/registry_provider_version.py | 47 ++++++------------- 2 files changed, 35 insertions(+), 44 deletions(-) diff --git a/src/pytfe/models/registry_provider_version.py b/src/pytfe/models/registry_provider_version.py index e5875051..8ec0d442 100644 --- a/src/pytfe/models/registry_provider_version.py +++ b/src/pytfe/models/registry_provider_version.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import datetime -from typing import Any +from typing import TYPE_CHECKING, Any from pydantic import BaseModel, ConfigDict, Field, model_validator @@ -16,9 +16,13 @@ from ..utils import valid_string_id from .registry_provider import ( RegistryName, + RegistryProvider, RegistryProviderID, ) +if TYPE_CHECKING: + from .registry_provider_platform import RegistryProviderPlatform + class RegistryProviderVersionPermissions(BaseModel): """Registry provider version permissions.""" @@ -35,20 +39,24 @@ class RegistryProviderVersion(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) id: str - version: str - created_at: datetime = Field(alias="created-at") - updated_at: datetime = Field(alias="updated-at") - key_id: str = Field(alias="key-id") - protocols: list[str] - permissions: RegistryProviderVersionPermissions - shasums_uploaded: bool = Field(alias="shasums-uploaded") - shasums_sig_uploaded: bool = Field(alias="shasums-sig-uploaded") + version: str = Field(alias="version", default="") + created_at: datetime | None = Field(alias="created-at", default=None) + updated_at: datetime | None = Field(alias="updated-at", default=None) + key_id: str = Field(alias="key-id", default="") + protocols: list[str] = Field(alias="protocols", default_factory=list) + permissions: RegistryProviderVersionPermissions | None = Field( + alias="permissions", default=None + ) + shasums_uploaded: bool | None = Field(alias="shasums-uploaded", default=None) + shasums_sig_uploaded: bool | None = Field( + alias="shasums-sig-uploaded", default=None + ) # Relations - registry_provider: dict[str, Any] | None = Field( + registry_provider: RegistryProvider | None = Field( alias="registry-provider", default=None ) - registry_provider_platforms: list[dict[str, Any]] | None = Field( + registry_provider_platforms: list[RegistryProviderPlatform] | None = Field( alias="platforms", default=None ) @@ -142,7 +150,7 @@ class RegistryProviderVersionID(RegistryProviderID): version: str @model_validator(mode="after") - def valid(self) -> RegistryProviderVersionID: + def valid_version_id(self) -> RegistryProviderVersionID: if not valid_string_id(self.version): raise InvalidVersionError() if self.registry_name != RegistryName.PRIVATE: diff --git a/src/pytfe/resources/registry_provider_version.py b/src/pytfe/resources/registry_provider_version.py index 08735c48..03156afe 100644 --- a/src/pytfe/resources/registry_provider_version.py +++ b/src/pytfe/resources/registry_provider_version.py @@ -11,15 +11,16 @@ ) from ..models.registry_provider import ( RegistryName, + RegistryProvider, RegistryProviderID, ) +from ..models.registry_provider_platform import RegistryProviderPlatform from ..models.registry_provider_version import ( RegistryProviderVersion, RegistryProviderVersionCreateOptions, RegistryProviderVersionID, RegistryProviderVersionListOptions, ) -from ..utils import valid_string_id from ._base import _Service @@ -32,9 +33,6 @@ def create( options: RegistryProviderVersionCreateOptions, ) -> RegistryProviderVersion: """Create a registry provider version""" - if not self._validate_provider_id(provider_id): - raise ValueError("Invalid provider ID") - if provider_id.registry_name != RegistryName.PRIVATE: raise RequiredPrivateRegistryError() path = f"/api/v2/organizations/{provider_id.organization_name}/registry-providers/{provider_id.registry_name.value}/{provider_id.namespace}/{provider_id.name}/versions" @@ -53,18 +51,6 @@ def create( data = r.json().get("data", {}) return self._registry_provider_version_from(data) - def _validate_provider_id(self, provider_id: RegistryProviderID) -> bool: - """Validate a registry provider ID.""" - if not valid_string_id(provider_id.organization_name): - return False - if not valid_string_id(provider_id.name): - return False - if not valid_string_id(provider_id.namespace): - return False - if provider_id.registry_name not in [RegistryName.PRIVATE, RegistryName.PUBLIC]: - return False - return True - def _registry_provider_version_from( self, data: dict[str, Any] ) -> RegistryProviderVersion: @@ -74,16 +60,22 @@ def _registry_provider_version_from( relationships = data.get("relationships", {}) attrs["id"] = data.get("id") - # Parse relationships + # Parse relationships as typed stubs if "registry-provider" in relationships: - attrs["registry_provider"] = relationships["registry-provider"].get( - "data", {} - ) + rp_data = relationships["registry-provider"].get("data") + if rp_data and rp_data.get("id"): + attrs["registry_provider"] = RegistryProvider.model_construct( + id=rp_data["id"] + ) if "platforms" in relationships: - attrs["registry_provider_platforms"] = relationships["platforms"].get( - "data", [] - ) + platforms_data = relationships["platforms"].get("data", []) + if platforms_data: + attrs["registry_provider_platforms"] = [ + RegistryProviderPlatform.model_construct(id=p["id"]) + for p in platforms_data + if p.get("id") + ] return RegistryProviderVersion.model_validate(attrs) @@ -93,9 +85,6 @@ def list( options: RegistryProviderVersionListOptions | None = None, ) -> Iterator[RegistryProviderVersion]: """List registry provider versions""" - if not self._validate_provider_id(provider_id): - raise ValueError("Invalid provider ID") - path = f"/api/v2/organizations/{provider_id.organization_name}/registry-providers/{provider_id.registry_name.value}/{provider_id.namespace}/{provider_id.name}/versions" params = options.model_dump(by_alias=True) if options else {} for item in self._list(path=path, params=params): @@ -103,9 +92,6 @@ def list( def read(self, version_id: RegistryProviderVersionID) -> RegistryProviderVersion: """Read a specific registry provider version""" - if not self._validate_provider_id(version_id): - raise ValueError("Invalid provider ID") - path = f"/api/v2/organizations/{version_id.organization_name}/registry-providers/{version_id.registry_name.value}/{version_id.namespace}/{version_id.name}/versions/{version_id.version}" r = self.t.request( "GET", @@ -116,9 +102,6 @@ def read(self, version_id: RegistryProviderVersionID) -> RegistryProviderVersion def delete(self, version_id: RegistryProviderVersionID) -> None: """Delete a specific registry provider version""" - if not self._validate_provider_id(version_id): - raise ValueError("Invalid provider ID") - path = f"/api/v2/organizations/{version_id.organization_name}/registry-providers/{version_id.registry_name.value}/{version_id.namespace}/{version_id.name}/versions/{version_id.version}" self.t.request( "DELETE", From 598d4d0c1fb998e7022687c59986cbd0bfd511c0 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Thu, 7 May 2026 12:23:21 +0530 Subject: [PATCH 32/43] feat(registry-provider-platform): Added and updated unit testcases of provider platform and version respectively --- .../units/test_registry_provider_platform.py | 390 ++++++++++++++++++ tests/units/test_registry_provider_version.py | 100 +++-- 2 files changed, 449 insertions(+), 41 deletions(-) create mode 100644 tests/units/test_registry_provider_platform.py diff --git a/tests/units/test_registry_provider_platform.py b/tests/units/test_registry_provider_platform.py new file mode 100644 index 00000000..18a6c153 --- /dev/null +++ b/tests/units/test_registry_provider_platform.py @@ -0,0 +1,390 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the registry_provider_platform module.""" + +from unittest.mock import Mock, patch + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import ( + InvalidArchError, + InvalidNameError, + InvalidNamespaceError, + InvalidOSError, + InvalidOrgError, + InvalidVersionError, + RequiredArchError, + RequiredFilenameError, + RequiredOSError, + RequiredPrivateRegistryError, + RequiredShasumError, +) +from pytfe.models.registry_provider import RegistryName +from pytfe.models.registry_provider_platform import ( + RegistryProviderPlatform, + RegistryProviderPlatformCreateOptions, + RegistryProviderPlatformID, + RegistryProviderPlatformListOptions, +) +from pytfe.models.registry_provider_version import ( + RegistryProviderVersion, + RegistryProviderVersionID, +) +from pytfe.resources.registry_provider_platform import RegistryProviderPlatforms + + +class TestRegistryProviderPlatforms: + """Test the RegistryProviderPlatforms service class.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock HTTPTransport.""" + return Mock(spec=HTTPTransport) + + @pytest.fixture + def platforms_service(self, mock_transport): + """Create a RegistryProviderPlatforms service with mocked transport.""" + return RegistryProviderPlatforms(mock_transport) + + @pytest.fixture + def valid_version_id(self): + """Create a valid version ID.""" + return RegistryProviderVersionID( + organization_name="test-org", + registry_name=RegistryName.PRIVATE, + namespace="test-namespace", + name="test-provider", + version="1.0.0", + ) + + @pytest.fixture + def valid_platform_id(self): + """Create a valid platform ID.""" + return RegistryProviderPlatformID( + organization_name="test-org", + registry_name=RegistryName.PRIVATE, + namespace="test-namespace", + name="test-provider", + version="1.0.0", + os="linux", + arch="amd64", + ) + + @pytest.fixture + def platform_api_data(self): + """Typical API response data for a single platform.""" + return { + "id": "provpltfrm-123", + "type": "registry-provider-platforms", + "attributes": { + "os": "linux", + "arch": "amd64", + "filename": "terraform-provider-test_1.0.0_linux_amd64.zip", + "shasum": "abc123def456", + "provider-binary-uploaded": False, + "permissions": { + "can-delete": True, + "can-upload-asset": True, + }, + }, + "relationships": { + "registry-provider-version": { + "data": { + "id": "provver-456", + "type": "registry-provider-versions", + } + } + }, + "links": { + "provider-binary-upload": "https://example.com/upload", + }, + } + + # ------------------------------------------------------------------------- + # ID validation tests + # ------------------------------------------------------------------------- + + def test_invalid_platform_id_fields(self): + """Test RegistryProviderPlatformID raises correct error for each invalid field.""" + base = { + "organization_name": "test-org", + "registry_name": RegistryName.PRIVATE, + "namespace": "test-namespace", + "name": "test-provider", + "version": "1.0.0", + "os": "linux", + "arch": "amd64", + } + with pytest.raises(InvalidOrgError): + RegistryProviderPlatformID(**{**base, "organization_name": ""}) + with pytest.raises(InvalidOrgError): + RegistryProviderPlatformID(**{**base, "organization_name": " "}) + with pytest.raises(InvalidNameError): + RegistryProviderPlatformID(**{**base, "name": ""}) + with pytest.raises(InvalidNamespaceError): + RegistryProviderPlatformID(**{**base, "namespace": ""}) + with pytest.raises(InvalidVersionError): + RegistryProviderPlatformID(**{**base, "version": ""}) + with pytest.raises(RequiredPrivateRegistryError): + RegistryProviderPlatformID(**{**base, "registry_name": RegistryName.PUBLIC}) + with pytest.raises(InvalidOSError): + RegistryProviderPlatformID(**{**base, "os": ""}) + with pytest.raises(InvalidArchError): + RegistryProviderPlatformID(**{**base, "arch": ""}) + + def test_valid_platform_id(self, valid_platform_id): + """Test RegistryProviderPlatformID with valid data.""" + assert valid_platform_id.organization_name == "test-org" + assert valid_platform_id.registry_name == RegistryName.PRIVATE + assert valid_platform_id.namespace == "test-namespace" + assert valid_platform_id.name == "test-provider" + assert valid_platform_id.version == "1.0.0" + assert valid_platform_id.os == "linux" + assert valid_platform_id.arch == "amd64" + + # ------------------------------------------------------------------------- + # CreateOptions validation tests + # ------------------------------------------------------------------------- + + def test_create_options_invalid_fields(self): + """Test RegistryProviderPlatformCreateOptions raises correct error for each invalid field.""" + base = { + "os": "linux", + "arch": "amd64", + "shasum": "abc123", + "filename": "provider.zip", + } + with pytest.raises(RequiredOSError): + RegistryProviderPlatformCreateOptions(**{**base, "os": ""}) + with pytest.raises(RequiredArchError): + RegistryProviderPlatformCreateOptions(**{**base, "arch": ""}) + with pytest.raises(RequiredShasumError): + RegistryProviderPlatformCreateOptions(**{**base, "shasum": ""}) + with pytest.raises(RequiredFilenameError): + RegistryProviderPlatformCreateOptions(**{**base, "filename": ""}) + + def test_create_options_valid(self): + """Test RegistryProviderPlatformCreateOptions with valid data.""" + options = RegistryProviderPlatformCreateOptions( + os="linux", + arch="amd64", + shasum="abc123def456", + filename="terraform-provider-test_1.0.0_linux_amd64.zip", + ) + assert options.os == "linux" + assert options.arch == "amd64" + assert options.shasum == "abc123def456" + assert options.filename == "terraform-provider-test_1.0.0_linux_amd64.zip" + + # ------------------------------------------------------------------------- + # create() + # ------------------------------------------------------------------------- + + def test_create_platform_success( + self, platforms_service, valid_version_id, mock_transport, platform_api_data + ): + """Test successful create operation.""" + mock_response = Mock() + mock_response.json.return_value = {"data": platform_api_data} + mock_transport.request.return_value = mock_response + + options = RegistryProviderPlatformCreateOptions( + os="linux", + arch="amd64", + shasum="abc123def456", + filename="terraform-provider-test_1.0.0_linux_amd64.zip", + ) + + result = platforms_service.create(valid_version_id, options) + + mock_transport.request.assert_called_once_with( + "POST", + path="/api/v2/organizations/test-org/registry-providers/private/test-namespace/test-provider/versions/1.0.0/platforms", + json_body={ + "data": { + "type": "registry-provider-platforms", + "attributes": { + "os": "linux", + "arch": "amd64", + "shasum": "abc123def456", + "filename": "terraform-provider-test_1.0.0_linux_amd64.zip", + }, + } + }, + ) + + assert isinstance(result, RegistryProviderPlatform) + assert result.id == "provpltfrm-123" + assert result.os == "linux" + assert result.arch == "amd64" + assert result.shasum == "abc123def456" + assert result.provider_binary_uploaded is False + assert result.permissions.can_delete is True + assert result.permissions.can_upload_asset is True + + # ------------------------------------------------------------------------- + # list() + # ------------------------------------------------------------------------- + + def test_list_platforms_success( + self, platforms_service, valid_version_id, platform_api_data + ): + """Test successful list operation.""" + second = {**platform_api_data, "id": "provpltfrm-456"} + second["attributes"] = {**platform_api_data["attributes"], "os": "darwin", "arch": "arm64"} + + with patch.object( + platforms_service, "_list", return_value=[platform_api_data, second] + ): + result = list(platforms_service.list(valid_version_id)) + + assert len(result) == 2 + assert result[0].id == "provpltfrm-123" + assert result[0].os == "linux" + assert result[0].arch == "amd64" + assert result[1].id == "provpltfrm-456" + assert result[1].os == "darwin" + assert result[1].arch == "arm64" + + def test_list_platforms_with_options( + self, platforms_service, valid_version_id, mock_transport, platform_api_data + ): + """Test list operation passes page_size param.""" + mock_response = Mock() + mock_response.json.return_value = {"data": [platform_api_data]} + mock_transport.request.return_value = mock_response + + options = RegistryProviderPlatformListOptions(page_size=10) + + with patch.object( + platforms_service, "_list", return_value=[platform_api_data] + ) as mock_list: + result = list(platforms_service.list(valid_version_id, options)) + mock_list.assert_called_once_with( + path="/api/v2/organizations/test-org/registry-providers/private/test-namespace/test-provider/versions/1.0.0/platforms", + params={"page[size]": 10}, + ) + + assert len(result) == 1 + + def test_list_platforms_empty(self, platforms_service, valid_version_id): + """Test list operation returns empty iterator when no platforms exist.""" + with patch.object(platforms_service, "_list", return_value=[]): + result = list(platforms_service.list(valid_version_id)) + + assert result == [] + + # ------------------------------------------------------------------------- + # read() + # ------------------------------------------------------------------------- + + def test_read_platform_success( + self, platforms_service, valid_platform_id, mock_transport, platform_api_data + ): + """Test successful read operation.""" + mock_response = Mock() + mock_response.json.return_value = {"data": platform_api_data} + mock_transport.request.return_value = mock_response + + result = platforms_service.read(valid_platform_id) + + mock_transport.request.assert_called_once_with( + "GET", + path="/api/v2/organizations/test-org/registry-providers/private/test-namespace/test-provider/versions/1.0.0/platforms/linux/amd64", + ) + + assert isinstance(result, RegistryProviderPlatform) + assert result.id == "provpltfrm-123" + assert result.os == "linux" + assert result.arch == "amd64" + + # ------------------------------------------------------------------------- + # delete() + # ------------------------------------------------------------------------- + + def test_delete_platform_success( + self, platforms_service, valid_platform_id, mock_transport + ): + """Test successful delete operation.""" + result = platforms_service.delete(valid_platform_id) + + mock_transport.request.assert_called_once_with( + "DELETE", + path="/api/v2/organizations/test-org/registry-providers/private/test-namespace/test-provider/versions/1.0.0/platforms/linux/amd64", + ) + + assert result is None + + # ------------------------------------------------------------------------- + # _registry_provider_platform_from() + # ------------------------------------------------------------------------- + + def test_platform_from_full_data(self, platforms_service, platform_api_data): + """Test _registry_provider_platform_from with full API response including relationships and links.""" + result = platforms_service._registry_provider_platform_from(platform_api_data) + + assert isinstance(result, RegistryProviderPlatform) + assert result.id == "provpltfrm-123" + assert result.os == "linux" + assert result.arch == "amd64" + assert result.filename == "terraform-provider-test_1.0.0_linux_amd64.zip" + assert result.shasum == "abc123def456" + assert result.provider_binary_uploaded is False + assert result.permissions.can_delete is True + assert result.permissions.can_upload_asset is True + # registry-provider-version relation parsed as typed stub + assert isinstance(result.registry_provider_version, RegistryProviderVersion) + assert result.registry_provider_version.id == "provver-456" + # links preserved + assert result.links is not None + assert "provider-binary-upload" in result.links + + def test_platform_from_no_relationships(self, platforms_service): + """Test _registry_provider_platform_from when relationships are absent.""" + data = { + "id": "provpltfrm-789", + "type": "registry-provider-platforms", + "attributes": { + "os": "windows", + "arch": "amd64", + "filename": "terraform-provider-test_1.0.0_windows_amd64.zip", + "shasum": "deadbeef", + "provider-binary-uploaded": True, + "permissions": { + "can-delete": False, + "can-upload-asset": False, + }, + }, + } + + result = platforms_service._registry_provider_platform_from(data) + + assert result.id == "provpltfrm-789" + assert result.os == "windows" + assert result.arch == "amd64" + assert result.registry_provider_version is None + assert result.links is None + + def test_platform_from_null_version_relationship(self, platforms_service): + """Test _registry_provider_platform_from when registry-provider-version data is null.""" + data = { + "id": "provpltfrm-abc", + "type": "registry-provider-platforms", + "attributes": { + "os": "linux", + "arch": "arm64", + "filename": "provider.zip", + "shasum": "abc123", + "provider-binary-uploaded": False, + "permissions": {"can-delete": True, "can-upload-asset": True}, + }, + "relationships": { + "registry-provider-version": {"data": None} + }, + } + + result = platforms_service._registry_provider_platform_from(data) + + assert result.registry_provider_version is None diff --git a/tests/units/test_registry_provider_version.py b/tests/units/test_registry_provider_version.py index e291e602..a1239fc0 100644 --- a/tests/units/test_registry_provider_version.py +++ b/tests/units/test_registry_provider_version.py @@ -10,11 +10,15 @@ from pytfe._http import HTTPTransport from pytfe.errors import ( InvalidKeyIDError, + InvalidNameError, + InvalidNamespaceError, + InvalidOrgError, InvalidVersionError, RequiredPrivateRegistryError, ) from pytfe.models.registry_provider import ( RegistryName, + RegistryProvider, RegistryProviderID, ) from pytfe.models.registry_provider_version import ( @@ -59,34 +63,52 @@ def valid_version_id(self): version="1.0.0", ) - def test_validate_provider_id_success(self, versions_service, valid_provider_id): - """Test _validate_provider_id with valid provider ID.""" - result = versions_service._validate_provider_id(valid_provider_id) - assert result is True - - def test_validate_provider_id_invalid_organization( - self, versions_service, valid_provider_id - ): - """Test _validate_provider_id with invalid organization name.""" - valid_provider_id.organization_name = "" - result = versions_service._validate_provider_id(valid_provider_id) - assert result is False - def test_create_version_validations(self, versions_service): - """Test create method validations.""" - # Test with invalid provider ID - invalid_provider_id = RegistryProviderID( - organization_name="", - registry_name=RegistryName.PRIVATE, - namespace="test-namespace", - name="test-provider", - ) - options = RegistryProviderVersionCreateOptions( - version="1.0.0", **{"key-id": "test-key-id"}, protocols=["5.0"] - ) + """Test create method raises error when constructing invalid provider ID.""" + with pytest.raises(InvalidOrgError): + RegistryProviderID( + organization_name="", + registry_name=RegistryName.PRIVATE, + namespace="test-namespace", + name="test-provider", + ) - with pytest.raises(ValueError, match="Invalid provider ID"): - versions_service.create(invalid_provider_id, options) + def test_invalid_provider_id_fields(self): + """Test RegistryProviderID raises correct error for each invalid field.""" + base = { + "organization_name": "test-org", + "registry_name": RegistryName.PRIVATE, + "namespace": "test-namespace", + "name": "test-provider", + } + with pytest.raises(InvalidOrgError): + RegistryProviderID(**{**base, "organization_name": ""}) + with pytest.raises(InvalidOrgError): + RegistryProviderID(**{**base, "organization_name": " "}) + with pytest.raises(InvalidNameError): + RegistryProviderID(**{**base, "name": ""}) + with pytest.raises(InvalidNamespaceError): + RegistryProviderID(**{**base, "namespace": ""}) + + def test_invalid_version_id_fields(self): + """Test RegistryProviderVersionID raises correct error for each invalid field.""" + base = { + "organization_name": "test-org", + "registry_name": RegistryName.PRIVATE, + "namespace": "test-namespace", + "name": "test-provider", + "version": "1.0.0", + } + with pytest.raises(InvalidOrgError): + RegistryProviderVersionID(**{**base, "organization_name": ""}) + with pytest.raises(InvalidNameError): + RegistryProviderVersionID(**{**base, "name": ""}) + with pytest.raises(InvalidNamespaceError): + RegistryProviderVersionID(**{**base, "namespace": ""}) + with pytest.raises(InvalidVersionError): + RegistryProviderVersionID(**{**base, "version": ""}) + with pytest.raises(RequiredPrivateRegistryError): + RegistryProviderVersionID(**{**base, "registry_name": RegistryName.PUBLIC}) def test_create_version_requires_private_registry( self, versions_service, mock_transport @@ -240,17 +262,15 @@ def test_list_versions_success_without_options( assert result[1].shasums_uploaded is True def test_read_version_validations(self, versions_service): - """Test read method with invalid version ID.""" - invalid_version_id = RegistryProviderVersionID( - organization_name="", - registry_name=RegistryName.PRIVATE, - namespace="test-namespace", - name="test-provider", - version="1.0.0", - ) - - with pytest.raises(ValueError, match="Invalid provider ID"): - versions_service.read(invalid_version_id) + """Test read method raises error when constructing invalid version ID.""" + with pytest.raises(InvalidOrgError): + RegistryProviderVersionID( + organization_name="", + registry_name=RegistryName.PRIVATE, + namespace="test-namespace", + name="test-provider", + version="1.0.0", + ) def test_read_version_success( self, versions_service, valid_version_id, mock_transport @@ -359,10 +379,8 @@ def test_registry_provider_version_from_success(self, versions_service): assert result.id == "provver-123" assert result.version == "1.0.0" assert result.key_id == "test-key-id" - assert result.registry_provider == { - "id": "prov-123", - "type": "registry-providers", - } + assert isinstance(result.registry_provider, RegistryProvider) + assert result.registry_provider.id == "prov-123" assert result.registry_provider_platforms is not None assert len(result.registry_provider_platforms) == 2 From 436ade56cb71b43cddf9ff200fdd79b17c49d123 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Thu, 7 May 2026 12:24:15 +0530 Subject: [PATCH 33/43] feat(registry-provider-platform): Added example file for the feature --- examples/registry_provider_platform.py | 236 +++++++++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 examples/registry_provider_platform.py diff --git a/examples/registry_provider_platform.py b/examples/registry_provider_platform.py new file mode 100644 index 00000000..a6cf01ac --- /dev/null +++ b/examples/registry_provider_platform.py @@ -0,0 +1,236 @@ +# 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 ( + RegistryProviderPlatformCreateOptions, + RegistryProviderPlatformID, + RegistryProviderPlatformListOptions, + RegistryProviderVersionID, +) + + +def _print_header(title: str): + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def main(): + parser = argparse.ArgumentParser( + description="Registry Provider Platforms 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("--organization", required=True, help="Organization name") + parser.add_argument( + "--registry-name", + default="private", + help="Registry name (default: private)", + ) + parser.add_argument("--namespace", required=True, help="Provider namespace") + parser.add_argument("--name", required=True, help="Provider name") + parser.add_argument( + "--version", required=True, help="Provider version (e.g., 1.0.0)" + ) + parser.add_argument( + "--page-size", + type=int, + default=100, + help="Page size for listing platforms", + ) + parser.add_argument("--create", action="store_true", help="Create a platform") + parser.add_argument("--read", action="store_true", help="Read a specific platform") + parser.add_argument( + "--delete", action="store_true", help="Delete a specific platform" + ) + parser.add_argument( + "--os", dest="os", help="Operating system (e.g., linux, darwin)" + ) + parser.add_argument("--arch", help="Architecture (e.g., amd64, arm64)") + parser.add_argument("--shasum", help="SHA256 checksum of the provider binary") + parser.add_argument("--filename", help="Filename of the provider binary zip") + args = parser.parse_args() + + cfg = TFEConfig(address=args.address, token=args.token) + client = TFEClient(cfg) + + version_id = RegistryProviderVersionID( + organization_name=args.organization, + registry_name=args.registry_name, + namespace=args.namespace, + name=args.name, + version=args.version, + ) + + # 1) List all platforms for the provider version + _print_header( + f"Listing platforms for {args.registry_name}/{args.namespace}/{args.name} @ {args.version}" + ) + + list_options = RegistryProviderPlatformListOptions(page_size=args.page_size) + + platform_count = 0 + for platform in client.registry_provider_platforms.list( + version_id=version_id, + options=list_options, + ): + platform_count += 1 + print(f"- Platform {platform.os}/{platform.arch} (ID: {platform.id})") + print(f" Filename: {platform.filename}") + print(f" Shasum: {platform.shasum}") + print(f" Provider Binary Uploaded: {platform.provider_binary_uploaded}") + if platform.permissions: + print(" Permissions:") + print(f" Can Delete: {platform.permissions.can_delete}") + print(f" Can Upload Asset: {platform.permissions.can_upload_asset}") + if platform.links: + print(" Links:") + for key, value in platform.links.items(): + print(f" {key}: {value}") + print() + + if platform_count == 0: + print("No platforms found.") + else: + print(f"Total: {platform_count} platforms") + + # 2) Create a new platform (if --create flag is provided) + if args.create: + if not args.os: + print("Error: --os is required for create operation") + return + if not args.arch: + print("Error: --arch is required for create operation") + return + if not args.shasum: + print("Error: --shasum is required for create operation") + return + if not args.filename: + print("Error: --filename is required for create operation") + return + + _print_header(f"Creating platform: {args.os}/{args.arch}") + + create_options = RegistryProviderPlatformCreateOptions( + os=args.os, + arch=args.arch, + shasum=args.shasum, + filename=args.filename, + ) + + new_platform = client.registry_provider_platforms.create( + version_id=version_id, + options=create_options, + ) + + print(f"Created platform: {new_platform.id}") + print(f" OS: {new_platform.os}") + print(f" Arch: {new_platform.arch}") + print(f" Filename: {new_platform.filename}") + print(f" Shasum: {new_platform.shasum}") + print(f" Provider Binary Uploaded: {new_platform.provider_binary_uploaded}") + + if new_platform.links: + print("\n Upload URLs:") + if "provider-binary-upload" in new_platform.links: + print( + f" Provider Binary: {new_platform.links['provider-binary-upload']}" + ) + + # 3) Read a specific platform (if --read flag is provided) + if args.read: + if not args.os: + print("Error: --os is required for read operation") + return + if not args.arch: + print("Error: --arch is required for read operation") + return + + _print_header(f"Reading platform: {args.os}/{args.arch}") + + platform_id = RegistryProviderPlatformID( + organization_name=args.organization, + registry_name=args.registry_name, + namespace=args.namespace, + name=args.name, + version=args.version, + os=args.os, + arch=args.arch, + ) + + platform = client.registry_provider_platforms.read(platform_id) + + print(f"Platform ID: {platform.id}") + print(f" OS: {platform.os}") + print(f" Arch: {platform.arch}") + print(f" Filename: {platform.filename}") + print(f" Shasum: {platform.shasum}") + print(f" Provider Binary Uploaded: {platform.provider_binary_uploaded}") + + if platform.permissions: + print(" Permissions:") + print(f" Can Delete: {platform.permissions.can_delete}") + print(f" Can Upload Asset: {platform.permissions.can_upload_asset}") + + if platform.links: + print(" Links:") + for key, value in platform.links.items(): + print(f" {key}: {value}") + + # 4) Delete a platform (if --delete flag is provided) + if args.delete: + if not args.os: + print("Error: --os is required for delete operation") + return + if not args.arch: + print("Error: --arch is required for delete operation") + return + + _print_header(f"Deleting platform: {args.os}/{args.arch}") + + platform_id = RegistryProviderPlatformID( + organization_name=args.organization, + registry_name=args.registry_name, + namespace=args.namespace, + name=args.name, + version=args.version, + os=args.os, + arch=args.arch, + ) + + try: + platform_to_delete = client.registry_provider_platforms.read(platform_id) + print("Platform to delete:") + print(f" ID: {platform_to_delete.id}") + print(f" OS/Arch: {platform_to_delete.os}/{platform_to_delete.arch}") + print(f" Filename: {platform_to_delete.filename}") + except Exception as e: + print(f"Error reading platform: {e}") + return + + client.registry_provider_platforms.delete(platform_id) + print(f"\n Successfully deleted platform: {args.os}/{args.arch}") + + # List remaining platforms + _print_header("Listing platforms after deletion") + remaining_count = 0 + for platform in client.registry_provider_platforms.list(version_id=version_id): + remaining_count += 1 + print(f"- {platform.os}/{platform.arch} (ID: {platform.id})") + + if remaining_count == 0: + print("No platforms remaining.") + else: + print(f"Total remaining: {remaining_count} platforms") + + +if __name__ == "__main__": + main() From 99c6e7a04e81bb9e0de0b7480611646fd145812e Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Thu, 7 May 2026 12:26:35 +0530 Subject: [PATCH 34/43] Fixed lint and fmt --- tests/units/test_registry_provider_platform.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/units/test_registry_provider_platform.py b/tests/units/test_registry_provider_platform.py index 18a6c153..817157aa 100644 --- a/tests/units/test_registry_provider_platform.py +++ b/tests/units/test_registry_provider_platform.py @@ -12,8 +12,8 @@ InvalidArchError, InvalidNameError, InvalidNamespaceError, - InvalidOSError, InvalidOrgError, + InvalidOSError, InvalidVersionError, RequiredArchError, RequiredFilenameError, @@ -233,7 +233,11 @@ def test_list_platforms_success( ): """Test successful list operation.""" second = {**platform_api_data, "id": "provpltfrm-456"} - second["attributes"] = {**platform_api_data["attributes"], "os": "darwin", "arch": "arm64"} + second["attributes"] = { + **platform_api_data["attributes"], + "os": "darwin", + "arch": "arm64", + } with patch.object( platforms_service, "_list", return_value=[platform_api_data, second] @@ -380,9 +384,7 @@ def test_platform_from_null_version_relationship(self, platforms_service): "provider-binary-uploaded": False, "permissions": {"can-delete": True, "can-upload-asset": True}, }, - "relationships": { - "registry-provider-version": {"data": None} - }, + "relationships": {"registry-provider-version": {"data": None}}, } result = platforms_service._registry_provider_platform_from(data) From 044ba21785a8761ad9c0c63feece30d498e57137 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Fri, 8 May 2026 12:47:07 +0530 Subject: [PATCH 35/43] feat(stack-config): Added models for the stack-config --- src/pytfe/models/__init__.py | 19 +++++ src/pytfe/models/stack_configuration.py | 100 ++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 src/pytfe/models/stack_configuration.py diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index c0d6c773..d2e8648c 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -313,6 +313,16 @@ SSHKeyListOptions, SSHKeyUpdateOptions, ) +from .stack_configuration import ( + StackComponent, + StackConfiguration, + StackConfigurationCreateOptions, + StackConfigurationIncludeOps, + StackConfigurationListOptions, + StackConfigurationReadOptions, + StackConfigurationSource, + StackConfigurationStatus, +) from .state_version import ( StateVersion, StateVersionCreateOptions, @@ -513,6 +523,15 @@ "RegistryProviderPlatformID", "RegistryProviderPlatformListOptions", "RegistryProviderPlatformPermissions", + # Stack Configuration + "StackComponent", + "StackConfiguration", + "StackConfigurationCreateOptions", + "StackConfigurationIncludeOps", + "StackConfigurationListOptions", + "StackConfigurationReadOptions", + "StackConfigurationSource", + "StackConfigurationStatus", # Query runs "QueryRun", "QueryRunActions", diff --git a/src/pytfe/models/stack_configuration.py b/src/pytfe/models/stack_configuration.py new file mode 100644 index 00000000..a3f566a1 --- /dev/null +++ b/src/pytfe/models/stack_configuration.py @@ -0,0 +1,100 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field + +from .configuration_version import IngressAttributes +from .stack import Stack + + +class StackConfigurationStatus(str, Enum): + """StackConfigurationStatus represents the status of a stack configuration.""" + + PENDING = "pending" + QUEUED = "queued" + PREPARING = "preparing" + COMPLETED = "completed" + FAILED = "failed" + + +class StackComponent(BaseModel): + """StackComponent represents a stack component, specified by configuration""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + name: str = Field(alias="name", default="") + correlator: str = Field(alias="correlator", default="") + expanded: bool | None = Field(alias="expanded", default=None) + removed: bool | None = Field(alias="removed", default=None) + + +class StackConfigurationSource(str, Enum): + """StackConfigurationSource controls how configuration content is sourced.""" + + MANUAL = "manual" + FETCH = "fetch" + REUSE = "reuse" + + +class StackConfigurationIncludeOps(str, Enum): + """StackConfigurationIncludeOps represents include options for stack configuration endpoints.""" + + INGRESS_ATTRIBUTES = "ingress_attributes" + STACK_DIAGNOSTICS = "stack_diagnostics" + + +class StackConfiguration(BaseModel): + """StackConfiguration represents a snapshot of a stack's configuration.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str + status: StackConfigurationStatus | None = Field(default=None, alias="status") + sequence_number: int = Field(default=0, alias="sequence-number") + components: list[StackComponent] = Field(default_factory=list, alias="components") + preparing_event_stream_url: str = Field( + default="", alias="preparing-event-stream-url" + ) + created_at: datetime | None = Field(default=None, alias="created-at") + updated_at: datetime | None = Field(default=None, alias="updated-at") + speculative: bool | None = Field(default=None, alias="speculative") + + # Relations + stack: Stack | None = Field(default=None, alias="stack") + ingress_attributes: IngressAttributes | None = Field( + default=None, alias="ingress-attributes" + ) + + +class StackConfigurationCreateOptions(BaseModel): + """Options for creating a stack configuration.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + speculative_enabled: bool = Field(default=False, alias="speculative") + destroy_all: bool = Field(default=False, alias="destroy-all") + selected_deployments: list[str] | None = Field( + default=None, alias="selected-deployments" + ) + + +class StackConfigurationListOptions(BaseModel): + """Options for listing stack configurations.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + page_size: int | None = Field(default=None, alias="page[size]") + include: list[StackConfigurationIncludeOps] | None = None + + +class StackConfigurationReadOptions(BaseModel): + """Options for reading a stack configuration.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + include: list[StackConfigurationIncludeOps] | None = None From 1c8c5af91a3c0311afb11ea1a32106e87c870830 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Fri, 8 May 2026 12:48:22 +0530 Subject: [PATCH 36/43] feat(stack-config): Added create, list and read method for the resource --- src/pytfe/client.py | 12 +++ src/pytfe/errors.py | 15 ++++ src/pytfe/resources/stack_configuration.py | 98 ++++++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 src/pytfe/resources/stack_configuration.py diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 0e88e1ab..4642d9a8 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -35,8 +35,12 @@ from .resources.run_task import RunTasks from .resources.run_trigger import RunTriggers from .resources.ssh_keys import SSHKeys +from .resources.stack import Stacks +from .resources.stack_configuration import StackConfigurations from .resources.state_version_outputs import StateVersionOutputs from .resources.state_versions import StateVersions +from .resources.team import Teams +from .resources.team_project_access import TeamProjectAccesses from .resources.user import Users from .resources.variable import Variables from .resources.variable_sets import VariableSets, VariableSetVariables @@ -88,6 +92,10 @@ def __init__(self, config: TFEConfig | None = None): self.registry_provider_versions = RegistryProviderVersions(self._transport) self.registry_provider_platforms = RegistryProviderPlatforms(self._transport) + # Stack resources + self.stacks = Stacks(self._transport) + self.stack_configurations = StackConfigurations(self._transport) + # State and execution resources self.state_versions = StateVersions(self._transport) self.state_version_outputs = StateVersionOutputs(self._transport) @@ -107,6 +115,10 @@ def __init__(self, config: TFEConfig | None = None): # SSH Keys self.ssh_keys = SSHKeys(self._transport) + # Team project access + self.teams = Teams(self._transport) + self.team_project_accesses = TeamProjectAccesses(self._transport) + # Reserved Tag Key self.reserved_tag_key = ReservedTagKeys(self._transport) diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index f2340af3..113dee9a 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -627,3 +627,18 @@ def __init__( message: str = "invalid value for registry-name. It must be either private or public", ): super().__init__(message) + + +# Stack Configuration errors +class InvalidStackIDError(InvalidValues): + """Raised when an invalid stack ID is provided.""" + + def __init__(self, message: str = "invalid value for stack ID"): + super().__init__(message) + + +class InvalidStackConfigurationIDError(InvalidValues): + """Raised when an invalid stack configuration ID is provided.""" + + def __init__(self, message: str = "invalid value for stack configuration ID"): + super().__init__(message) diff --git a/src/pytfe/resources/stack_configuration.py b/src/pytfe/resources/stack_configuration.py new file mode 100644 index 00000000..3b672b23 --- /dev/null +++ b/src/pytfe/resources/stack_configuration.py @@ -0,0 +1,98 @@ +# 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 pytfe.models.configuration_version import IngressAttributes + +from ..models.stack import Stack +from ..models.stack_configuration import ( + StackConfiguration, + StackConfigurationCreateOptions, + StackConfigurationListOptions, + StackConfigurationReadOptions, + StackConfigurationSource, +) +from ._base import _Service + + +class StackConfigurations(_Service): + """Service for managing Terraform stack configurations.""" + + def create( + self, + stack_id: str, + options: StackConfigurationCreateOptions | None = None, + source: StackConfigurationSource = StackConfigurationSource.MANUAL, + ) -> StackConfiguration: + """Create a stack configuration for the given stack.""" + path = f"/api/v2/stacks/{stack_id}/stack-configurations" + params: dict[str, str] = {} + if source != StackConfigurationSource.MANUAL: + params["source"] = source.value + + attributes: dict[str, Any] = {} + if options: + attributes = options.model_dump(by_alias=True, exclude_none=True) + + payload = { + "data": { + "type": "stack-configurations", + "attributes": attributes, + } + } + r = self.t.request("POST", path=path, json_body=payload, params=params) + data = r.json().get("data", {}) + return self._stack_configuration_from(data) + + def list( + self, + stack_id: str, + options: StackConfigurationListOptions | None = None, + ) -> Iterator[StackConfiguration]: + """List stack configurations for the given stack.""" + path = f"/api/v2/stacks/{stack_id}/stack-configurations" + params: dict[str, Any] = {} + if options: + if options.page_size is not None: + params["page[size]"] = options.page_size + if options.include: + params["include"] = ",".join([i.value for i in options.include]) + for item in self._list(path=path, params=params): + yield self._stack_configuration_from(item) + + def read( + self, + stack_configuration_id: str, + options: StackConfigurationReadOptions | None = None, + ) -> StackConfiguration: + """Read a stack configuration by its ID.""" + path = f"/api/v2/stack-configurations/{stack_configuration_id}" + params: dict[str, str] = {} + if options and options.include: + params["include"] = ",".join([i.value for i in options.include]) + r = self.t.request("GET", path=path, params=params) + data = r.json().get("data", {}) + return self._stack_configuration_from(data) + + def _stack_configuration_from(self, data: dict[str, Any]) -> StackConfiguration: + """Parse a StackConfiguration from API response data.""" + attrs = dict(data.get("attributes", {})) + attrs["id"] = data.get("id") + relationships = data.get("relationships", {}) + + stack_data = relationships.get("stack", {}).get("data") + if stack_data and stack_data.get("id"): + attrs["stack"] = Stack.model_validate({"id": stack_data["id"]}) + ingress_attributes_data = relationships.get("ingress-attributes", {}).get( + "data" + ) + if ingress_attributes_data and ingress_attributes_data.get("id"): + attrs["ingress_attributes"] = IngressAttributes.model_validate( + {"id": ingress_attributes_data["id"]} + ) + + return StackConfiguration.model_validate(attrs) From 9307768fd74e2b0bae565727df46bf0f342b0ed5 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Fri, 8 May 2026 12:49:23 +0530 Subject: [PATCH 37/43] feat(stack-config): Added examples and unit testcases --- examples/stack_configuration.py | 116 ++++++++ tests/units/test_stack_configuration.py | 355 ++++++++++++++++++++++++ 2 files changed, 471 insertions(+) create mode 100644 examples/stack_configuration.py create mode 100644 tests/units/test_stack_configuration.py diff --git a/examples/stack_configuration.py b/examples/stack_configuration.py new file mode 100644 index 00000000..b5d002fb --- /dev/null +++ b/examples/stack_configuration.py @@ -0,0 +1,116 @@ +# 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 ( + StackConfigurationCreateOptions, + StackConfigurationListOptions, +) + + +def _print_header(title: str): + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def main(): + parser = argparse.ArgumentParser( + description="Stack Configurations 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("--stack-id", required=True, help="Stack ID (e.g. st-xxxxx)") + parser.add_argument( + "--page-size", + type=int, + default=100, + help="Page size for listing configurations", + ) + parser.add_argument( + "--create", action="store_true", help="Create a new stack configuration" + ) + parser.add_argument( + "--speculative", + action="store_true", + help="Mark created configuration as speculative", + ) + parser.add_argument( + "--read", action="store_true", help="Read a specific stack configuration" + ) + parser.add_argument( + "--upload-url", + action="store_true", + help="Fetch the upload URL for a stack configuration", + ) + parser.add_argument( + "--fetch-from-vcs", + action="store_true", + help="Trigger fetch of latest config from VCS", + ) + parser.add_argument("--id", help="Stack configuration ID (e.g. stc-xxxxx)") + args = parser.parse_args() + + cfg = TFEConfig(address=args.address, token=args.token) + client = TFEClient(cfg) + + # 1) Always list existing stack configurations + _print_header(f"Listing stack configurations for stack: {args.stack_id}") + options = StackConfigurationListOptions(page_size=args.page_size) + config_count = 0 + for config in client.stack_configurations.list( + stack_id=args.stack_id, options=options + ): + config_count += 1 + print(f"- ID: {config.id}") + print(f" Status: {config.status}") + print(f" Sequence: {config.sequence_number}") + print(f" Speculative: {config.speculative}") + print(f" Created: {config.created_at}") + print(f" Updated: {config.updated_at}") + print() + + if config_count == 0: + print("No stack configurations found.") + else: + print(f"Total: {config_count} stack configurations") + + # 2) Create a new stack configuration + if args.create: + _print_header("Creating a new stack configuration") + create_opts = StackConfigurationCreateOptions( + speculative_enabled=args.speculative + ) + config = client.stack_configurations.create( + stack_id=args.stack_id, options=create_opts + ) + print(f"Created stack configuration: {config.id}") + print(f" Status: {config.status}") + print(f" Speculative: {config.speculative}") + print(f" Sequence: {config.sequence_number}") + print(f" Created: {config.created_at}") + + # 3) Read a specific stack configuration + if args.read: + if not args.id: + print("--id is required for --read") + else: + _print_header(f"Reading stack configuration: {args.id}") + config = client.stack_configurations.read(stack_configuration_id=args.id) + print(f"ID: {config.id}") + print(f"Status: {config.status}") + print(f"Sequence: {config.sequence_number}") + print(f"Speculative: {config.speculative}") + print(f"Created: {config.created_at}") + print(f"Updated: {config.updated_at}") + + +if __name__ == "__main__": + main() diff --git a/tests/units/test_stack_configuration.py b/tests/units/test_stack_configuration.py new file mode 100644 index 00000000..f547bbef --- /dev/null +++ b/tests/units/test_stack_configuration.py @@ -0,0 +1,355 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the stack_configuration module.""" + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.models.configuration_version import IngressAttributes +from pytfe.models.stack import Stack +from pytfe.models.stack_configuration import ( + StackComponent, + StackConfiguration, + StackConfigurationCreateOptions, + StackConfigurationIncludeOps, + StackConfigurationListOptions, + StackConfigurationReadOptions, + StackConfigurationSource, + StackConfigurationStatus, +) +from pytfe.resources.stack_configuration import StackConfigurations + + +class TestStackConfigurations: + """Test the StackConfigurations 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 StackConfigurations service with mocked transport.""" + return StackConfigurations(mock_transport) + + @pytest.fixture + def stack_configuration_api_data(self): + """Typical API response for a single stack configuration.""" + return { + "id": "stc-abc123", + "type": "stack-configurations", + "attributes": { + "status": "completed", + "sequence-number": 3, + "speculative": False, + "destroy-all": False, + "preparing-event-stream-url": "https://example.com/stream", + "created-at": "2026-05-07T11:32:17.031000+00:00", + "updated-at": "2026-05-07T11:32:50.500000+00:00", + "components": [ + { + "name": "simple_default", + "correlator": "simple_default", + "expanded": True, + "removed": False, + } + ], + }, + "relationships": { + "stack": {"data": {"id": "st-xyz789", "type": "stacks"}}, + "ingress-attributes": { + "data": {"id": "ia-111", "type": "ingress-attributes"} + }, + }, + } + + # ── Model tests ────────────────────────────────────────────────────────── + + def test_stack_component_defaults(self): + """StackComponent can be constructed with defaults.""" + comp = StackComponent() + assert comp.name == "" + assert comp.correlator == "" + assert comp.expanded is None + assert comp.removed is None + + def test_stack_component_full(self): + """StackComponent parses all fields.""" + comp = StackComponent.model_validate( + { + "name": "my_stack", + "correlator": "corr-1", + "expanded": True, + "removed": False, + } + ) + assert comp.name == "my_stack" + assert comp.correlator == "corr-1" + assert comp.expanded is True + assert comp.removed is False + + def test_stack_configuration_status_enum(self): + """StackConfigurationStatus values are correct.""" + assert StackConfigurationStatus.PENDING == "pending" + assert StackConfigurationStatus.QUEUED == "queued" + assert StackConfigurationStatus.PREPARING == "preparing" + assert StackConfigurationStatus.COMPLETED == "completed" + assert StackConfigurationStatus.FAILED == "failed" + + def test_stack_configuration_source_enum(self): + """StackConfigurationSource values are correct.""" + assert StackConfigurationSource.MANUAL == "manual" + assert StackConfigurationSource.FETCH == "fetch" + assert StackConfigurationSource.REUSE == "reuse" + + def test_stack_configuration_include_enum(self): + """StackConfigurationIncludeOps values are correct.""" + assert StackConfigurationIncludeOps.INGRESS_ATTRIBUTES == "ingress_attributes" + assert StackConfigurationIncludeOps.STACK_DIAGNOSTICS == "stack_diagnostics" + + def test_create_options_defaults(self): + """StackConfigurationCreateOptions has sane defaults.""" + opts = StackConfigurationCreateOptions() + assert opts.speculative_enabled is False + assert opts.destroy_all is False + assert opts.selected_deployments is None + + def test_create_options_serializes_with_aliases(self): + """StackConfigurationCreateOptions serialises with API aliases.""" + opts = StackConfigurationCreateOptions( + speculative_enabled=True, + destroy_all=True, + selected_deployments=["dep-a", "dep-b"], + ) + dumped = opts.model_dump(by_alias=True, exclude_none=True) + assert dumped["speculative"] is True + assert dumped["destroy-all"] is True + assert dumped["selected-deployments"] == ["dep-a", "dep-b"] + + def test_list_options_serialization(self): + """StackConfigurationListOptions serialises page[size] alias.""" + opts = StackConfigurationListOptions( + page_size=50, + include=[StackConfigurationIncludeOps.INGRESS_ATTRIBUTES], + ) + assert opts.page_size == 50 + assert opts.include == [StackConfigurationIncludeOps.INGRESS_ATTRIBUTES] + + def test_read_options(self): + """StackConfigurationReadOptions stores include list.""" + opts = StackConfigurationReadOptions( + include=[ + StackConfigurationIncludeOps.INGRESS_ATTRIBUTES, + StackConfigurationIncludeOps.STACK_DIAGNOSTICS, + ] + ) + assert len(opts.include) == 2 + + # ── Parser tests ───────────────────────────────────────────────────────── + + def test_stack_configuration_from_full_data( + self, service, stack_configuration_api_data + ): + """_stack_configuration_from parses all attributes and relations.""" + result = service._stack_configuration_from(stack_configuration_api_data) + + assert isinstance(result, StackConfiguration) + assert result.id == "stc-abc123" + assert result.status == StackConfigurationStatus.COMPLETED + assert result.sequence_number == 3 + assert result.speculative is False + assert result.preparing_event_stream_url == "https://example.com/stream" + assert result.created_at is not None + assert result.updated_at is not None + + # Components + assert len(result.components) == 1 + assert result.components[0].name == "simple_default" + assert result.components[0].expanded is True + + # Relations + assert isinstance(result.stack, Stack) + assert result.stack.id == "st-xyz789" + assert isinstance(result.ingress_attributes, IngressAttributes) + + def test_stack_configuration_from_no_relationships(self, service): + """_stack_configuration_from handles missing relationship data gracefully.""" + data = { + "id": "stc-min", + "attributes": { + "status": "pending", + "sequence-number": 1, + }, + "relationships": {}, + } + result = service._stack_configuration_from(data) + + assert result.id == "stc-min" + assert result.status == StackConfigurationStatus.PENDING + assert result.stack is None + assert result.ingress_attributes is None + + def test_stack_configuration_from_null_relationship_data(self, service): + """_stack_configuration_from handles null data inside relationship.""" + data = { + "id": "stc-null", + "attributes": {"status": "queued"}, + "relationships": { + "stack": {"data": None}, + "ingress-attributes": {"data": None}, + }, + } + result = service._stack_configuration_from(data) + + assert result.id == "stc-null" + assert result.stack is None + assert result.ingress_attributes is None + + def test_stack_configuration_from_empty_components(self, service): + """_stack_configuration_from handles empty components list.""" + data = { + "id": "stc-empty", + "attributes": {"status": "completed", "components": []}, + "relationships": {}, + } + result = service._stack_configuration_from(data) + assert result.components == [] + + # ── Resource method tests ───────────────────────────────────────────────── + + def test_create_success( + self, service, mock_transport, stack_configuration_api_data + ): + """create() POSTs the correct payload and returns a StackConfiguration.""" + mock_response = Mock() + mock_response.json.return_value = {"data": stack_configuration_api_data} + mock_transport.request.return_value = mock_response + + opts = StackConfigurationCreateOptions(speculative_enabled=True) + result = service.create(stack_id="st-xyz789", options=opts) + + mock_transport.request.assert_called_once_with( + "POST", + path="/api/v2/stacks/st-xyz789/stack-configurations", + json_body={ + "data": { + "type": "stack-configurations", + "attributes": {"speculative": True, "destroy-all": False}, + } + }, + params={}, + ) + assert isinstance(result, StackConfiguration) + assert result.id == "stc-abc123" + + def test_create_with_fetch_source( + self, service, mock_transport, stack_configuration_api_data + ): + """create() passes source param when not MANUAL.""" + mock_response = Mock() + mock_response.json.return_value = {"data": stack_configuration_api_data} + mock_transport.request.return_value = mock_response + + service.create(stack_id="st-xyz789", source=StackConfigurationSource.FETCH) + + _, kwargs = mock_transport.request.call_args + assert kwargs["params"] == {"source": "fetch"} + + def test_create_no_options( + self, service, mock_transport, stack_configuration_api_data + ): + """create() sends empty attributes when no options provided.""" + mock_response = Mock() + mock_response.json.return_value = {"data": stack_configuration_api_data} + mock_transport.request.return_value = mock_response + + service.create(stack_id="st-xyz789") + + _, kwargs = mock_transport.request.call_args + assert kwargs["json_body"]["data"]["attributes"] == {} + + def test_list_success(self, service, stack_configuration_api_data): + """list() yields StackConfiguration objects from paginated results.""" + service._list = Mock(return_value=[stack_configuration_api_data]) + + opts = StackConfigurationListOptions(page_size=20) + results = list(service.list(stack_id="st-xyz789", options=opts)) + + service._list.assert_called_once_with( + path="/api/v2/stacks/st-xyz789/stack-configurations", + params={"page[size]": 20}, + ) + assert len(results) == 1 + assert isinstance(results[0], StackConfiguration) + assert results[0].id == "stc-abc123" + + def test_list_with_include(self, service, stack_configuration_api_data): + """list() passes include param as comma-separated string.""" + service._list = Mock(return_value=[stack_configuration_api_data]) + + opts = StackConfigurationListOptions( + include=[StackConfigurationIncludeOps.INGRESS_ATTRIBUTES] + ) + list(service.list(stack_id="st-xyz789", options=opts)) + + _, kwargs = service._list.call_args + assert kwargs["params"]["include"] == "ingress_attributes" + + def test_list_empty(self, service): + """list() returns empty iterator when no items returned.""" + service._list = Mock(return_value=[]) + + results = list(service.list(stack_id="st-xyz789")) + assert results == [] + + def test_list_no_options(self, service, stack_configuration_api_data): + """list() works correctly when no options are given.""" + service._list = Mock(return_value=[stack_configuration_api_data]) + + results = list(service.list(stack_id="st-xyz789")) + + service._list.assert_called_once_with( + path="/api/v2/stacks/st-xyz789/stack-configurations", + params={}, + ) + assert len(results) == 1 + + def test_read_success(self, service, mock_transport, stack_configuration_api_data): + """read() GETs the correct path and returns a StackConfiguration.""" + mock_response = Mock() + mock_response.json.return_value = {"data": stack_configuration_api_data} + mock_transport.request.return_value = mock_response + + result = service.read(stack_configuration_id="stc-abc123") + + mock_transport.request.assert_called_once_with( + "GET", + path="/api/v2/stack-configurations/stc-abc123", + params={}, + ) + assert isinstance(result, StackConfiguration) + assert result.id == "stc-abc123" + assert result.status == StackConfigurationStatus.COMPLETED + + def test_read_with_include( + self, service, mock_transport, stack_configuration_api_data + ): + """read() appends include query param.""" + mock_response = Mock() + mock_response.json.return_value = {"data": stack_configuration_api_data} + mock_transport.request.return_value = mock_response + + opts = StackConfigurationReadOptions( + include=[ + StackConfigurationIncludeOps.INGRESS_ATTRIBUTES, + StackConfigurationIncludeOps.STACK_DIAGNOSTICS, + ] + ) + service.read(stack_configuration_id="stc-abc123", options=opts) + + _, kwargs = mock_transport.request.call_args + assert kwargs["params"]["include"] == "ingress_attributes,stack_diagnostics" From 64df6d4e53511195ae7fe8e62882f0829a2d658e Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Fri, 8 May 2026 12:50:13 +0530 Subject: [PATCH 38/43] feat(stack): Added fetch latest stack from vcs method in the stack and updated testcase --- src/pytfe/resources/stack.py | 7 ++++ tests/units/test_stack.py | 72 ++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/src/pytfe/resources/stack.py b/src/pytfe/resources/stack.py index dac0b1ca..f683fd17 100644 --- a/src/pytfe/resources/stack.py +++ b/src/pytfe/resources/stack.py @@ -117,6 +117,13 @@ def force_delete(self, stack_id: str) -> None: ) return None + def fetch_latest_from_vcs(self, stack_id: str) -> Stack: + """FetchLatestFromVcs updates the configuration of a stack, triggering stack preparation.""" + path = f"/api/v2/stacks/{stack_id}/fetch-latest-from-vcs" + r = self.t.request("POST", path=path) + data = r.json().get("data", {}) + return self._stack_from(data) + def _stack_from(self, data: dict) -> Stack: attrs = data.get("attributes", {}) attrs["id"] = data.get("id") diff --git a/tests/units/test_stack.py b/tests/units/test_stack.py index 03c9d28f..40f57c2a 100644 --- a/tests/units/test_stack.py +++ b/tests/units/test_stack.py @@ -265,3 +265,75 @@ def test_stack_from_handles_missing_relationships(self, stacks_service): assert result.id == "st-789" assert result.project is None assert result.agent_pool is None + + def test_fetch_latest_from_vcs_success( + self, stacks_service, mock_transport, stack_response_data + ): + """Test successful fetch-latest-from-vcs operation.""" + mock_response = Mock() + mock_response.json.return_value = {"data": stack_response_data} + mock_transport.request.return_value = mock_response + + result = stacks_service.fetch_latest_from_vcs("st-123") + + mock_transport.request.assert_called_once_with( + "POST", + path="/api/v2/stacks/st-123/fetch-latest-from-vcs", + ) + assert isinstance(result, Stack) + assert result.id == "st-123" + + def test_create_stack_invalid_name(self): + """StackCreateOptions raises when name is empty.""" + with pytest.raises(ValueError): + StackCreateOptions( + name="", + project=Project(id="prj-123"), + ) + + def test_create_stack_invalid_project_id(self): + """StackCreateOptions raises when project id is empty.""" + with pytest.raises(ValueError): + StackCreateOptions( + name="demo-stack", + project=Project(id=""), + ) + + def test_list_stacks_no_options(self, stacks_service): + """list() works correctly with minimal options (no filter/sort).""" + stacks_service._list = Mock(return_value=[]) + + results = list( + stacks_service.list( + "org-123", + StackListOptions(), + ) + ) + + stacks_service._list.assert_called_once_with( + "/api/v2/organizations/org-123/stacks", + params={}, + ) + assert results == [] + + def test_stack_from_with_vcs_repo(self, stacks_service): + """_stack_from parses vcs-repo fields correctly.""" + data = { + "id": "st-vcs", + "attributes": { + "name": "vcs-stack", + "vcs-repo": { + "identifier": "hashicorp/terraform", + "branch": "main", + "oauth-token-id": "ot-abc", + }, + }, + "relationships": {}, + } + + result = stacks_service._stack_from(data) + + assert result.vcs_repo is not None + assert result.vcs_repo.identifier == "hashicorp/terraform" + assert result.vcs_repo.branch == "main" + assert result.vcs_repo.oauth_token_id == "ot-abc" From e2032c7d996b4d171fdde82cb26fca90a8692b0a Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Fri, 8 May 2026 13:00:00 +0530 Subject: [PATCH 39/43] feat(stack-config): updated models and example for stack config --- examples/stack_configuration.py | 6 +++--- src/pytfe/models/stack_configuration.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/stack_configuration.py b/examples/stack_configuration.py index b5d002fb..d51ace60 100644 --- a/examples/stack_configuration.py +++ b/examples/stack_configuration.py @@ -70,7 +70,7 @@ def main(): ): config_count += 1 print(f"- ID: {config.id}") - print(f" Status: {config.status}") + print(f" Status: {config.status.value if config.status else None}") print(f" Sequence: {config.sequence_number}") print(f" Speculative: {config.speculative}") print(f" Created: {config.created_at}") @@ -92,7 +92,7 @@ def main(): stack_id=args.stack_id, options=create_opts ) print(f"Created stack configuration: {config.id}") - print(f" Status: {config.status}") + print(f" Status: {config.status.value if config.status else None}") print(f" Speculative: {config.speculative}") print(f" Sequence: {config.sequence_number}") print(f" Created: {config.created_at}") @@ -105,7 +105,7 @@ def main(): _print_header(f"Reading stack configuration: {args.id}") config = client.stack_configurations.read(stack_configuration_id=args.id) print(f"ID: {config.id}") - print(f"Status: {config.status}") + print(f"Status: {config.status.value if config.status else None}") print(f"Sequence: {config.sequence_number}") print(f"Speculative: {config.speculative}") print(f"Created: {config.created_at}") diff --git a/src/pytfe/models/stack_configuration.py b/src/pytfe/models/stack_configuration.py index a3f566a1..b92931a9 100644 --- a/src/pytfe/models/stack_configuration.py +++ b/src/pytfe/models/stack_configuration.py @@ -55,7 +55,7 @@ class StackConfiguration(BaseModel): id: str status: StackConfigurationStatus | None = Field(default=None, alias="status") - sequence_number: int = Field(default=0, alias="sequence-number") + sequence_number: int | None = Field(default=None, alias="sequence-number") components: list[StackComponent] = Field(default_factory=list, alias="components") preparing_event_stream_url: str = Field( default="", alias="preparing-event-stream-url" From 9900d944b60d910b481eeca9824483a721db49bd Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Tue, 12 May 2026 19:27:51 +0530 Subject: [PATCH 40/43] feat(comment): Updated models for comments and created errors --- src/pytfe/errors.py | 15 +++++++++++++++ src/pytfe/models/__init__.py | 7 +++++++ src/pytfe/models/comment.py | 19 ++++++++++++++++++- 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index 113dee9a..75bd9165 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -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) diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index d2e8648c..354bdf16 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -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. @@ -642,6 +646,9 @@ "RunEventList", "RunEventListOptions", "RunEventReadOptions", + # Comments + "Comment", + "CommentCreateOptions", # Run tasks "RunTask", "RunTaskIncludeOptions", diff --git a/src/pytfe/models/comment.py b/src/pytfe/models/comment.py index 19cc25ca..8bc0d110 100644 --- a/src/pytfe/models/comment.py +++ b/src/pytfe/models/comment.py @@ -3,7 +3,10 @@ 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): @@ -11,3 +14,17 @@ class Comment(BaseModel): 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 From f1f6a772722793f3dbede6ac96859d3e09bb15d3 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Tue, 12 May 2026 19:28:36 +0530 Subject: [PATCH 41/43] feat(comment): Added list, read and create methods for comment feature --- src/pytfe/client.py | 2 ++ src/pytfe/resources/comment.py | 54 ++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 src/pytfe/resources/comment.py diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 4642d9a8..4cb37fb0 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -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 @@ -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) diff --git a/src/pytfe/resources/comment.py b/src/pytfe/resources/comment.py new file mode 100644 index 00000000..e079366a --- /dev/null +++ b/src/pytfe/resources/comment.py @@ -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) From 962b178d8f525edde3666f511343cdaf25f7819d Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Tue, 12 May 2026 19:29:45 +0530 Subject: [PATCH 42/43] feat(comment): Added unit tests and examples files --- examples/comment.py | 72 ++++++++++++++++ tests/units/test_comment.py | 164 ++++++++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 examples/comment.py create mode 100644 tests/units/test_comment.py diff --git a/examples/comment.py b/examples/comment.py new file mode 100644 index 00000000..59626ba6 --- /dev/null +++ b/examples/comment.py @@ -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() diff --git a/tests/units/test_comment.py b/tests/units/test_comment.py new file mode 100644 index 00000000..8e6d5b9a --- /dev/null +++ b/tests/units/test_comment.py @@ -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) From 1b7880c03a5661c3eb97192da8260bd27d8c95dc Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Thu, 21 May 2026 15:10:50 +0530 Subject: [PATCH 43/43] feat(state-versions): add upload functionality support --- examples/state_versions.py | 242 ++++++++++++++++++++------ src/pytfe/models/state_version.py | 10 ++ src/pytfe/resources/state_versions.py | 74 +++++++- tests/units/test_state_version.py | 86 ++++++++- 4 files changed, 345 insertions(+), 67 deletions(-) diff --git a/examples/state_versions.py b/examples/state_versions.py index 61187619..8efdfd52 100644 --- a/examples/state_versions.py +++ b/examples/state_versions.py @@ -4,6 +4,8 @@ from __future__ import annotations import argparse +import hashlib +import json import os from pathlib import Path @@ -14,7 +16,9 @@ StateVersionCurrentOptions, StateVersionListOptions, StateVersionOutputsListOptions, + StateVersionReadOptions, ) +from pytfe.models.workspace import WorkspaceLockOptions def _print_header(title: str): @@ -23,6 +27,44 @@ def _print_header(title: str): print("=" * 80) +def _install_debug_hook(client: TFEClient, token: str) -> None: + """ + Wrap the transport's request() to print every URL and its headers. + The Authorization token value is masked so it is safe to share output. + """ + transport = client.state_versions.t + original_request = transport.request + + def _debug_request(method, path, **kwargs): + use_defaults = kwargs.get("use_default_headers", True) + extra_headers = kwargs.get("headers") or {} + + # Reconstruct exactly what the transport will send + if use_defaults: + sent_headers = dict(transport.headers) + sent_headers.update(extra_headers) + else: + sent_headers = dict(extra_headers) + + # Mask the bearer token so it is safe to print + display_headers = {} + for k, v in sent_headers.items(): + if k.lower() == "authorization": + masked = v[:14] + "***" + v[-4:] if len(v) > 18 else "***" + display_headers[k] = masked + else: + display_headers[k] = v + + url = transport._build_url(path) + print(f"\n [DEBUG] {method} {url}") + for k, v in display_headers.items(): + print(f" {k}: {v}") + + return original_request(method, path, **kwargs) + + transport.request = _debug_request + + def main(): parser = argparse.ArgumentParser( description="State Versions demo for python-tfe SDK" @@ -34,55 +76,80 @@ def main(): parser.add_argument("--org", required=True, help="Organization name") parser.add_argument("--workspace", required=True, help="Workspace name") parser.add_argument("--workspace-id", required=True, help="Workspace ID") - parser.add_argument("--download", help="Path to save downloaded current state") - parser.add_argument("--upload", help="Path to a .tfstate (or JSON state) to upload") + parser.add_argument( + "--download", help="Optional path to save downloaded current state" + ) + parser.add_argument( + "--upload", + help="Optional path to a .tfstate JSON to upload (defaults to current state with serial bumped by 1)", + ) + parser.add_argument( + "--skip-upload", + action="store_true", + help="Skip the upload demo (upload requires locking the workspace).", + ) + parser.add_argument( + "--demo-backing-data", + action="store_true", + help="Exercise TFE-only soft_delete/restore backing-data actions on the newly uploaded SV.", + ) parser.add_argument("--page-size", type=int, default=10) + parser.add_argument( + "--debug", + action="store_true", + help="Print every request URL and headers (token masked).", + ) args = parser.parse_args() cfg = TFEConfig(address=args.address, token=args.token) client = TFEClient(cfg) - options = StateVersionListOptions( - page_size=args.page_size, - organization=args.org, - workspace=args.workspace, - ) + if args.debug: + _install_debug_hook(client, args.token) - sv_list = list(client.state_versions.list(options)) - print(f"Total state versions: {len(sv_list)}") - print() - - for sv in sv_list: - print(f"- {sv.id} | status={sv.status} | created_at={sv.created_at}") - - # 1) List all state versions across org and workspace filters - _print_header("Org-scoped listing via /api/v2/state-versions (first page)") - all_sv = client.state_versions.list( - StateVersionListOptions( - organization=args.org, workspace=args.workspace, page_size=args.page_size + # 1) List state versions filtered by org + workspace + _print_header("Listing state versions (filter[organization]+filter[workspace])") + sv_list = list( + client.state_versions.list( + StateVersionListOptions( + page_size=args.page_size, + organization=args.org, + workspace=args.workspace, + ) ) ) - for sv in all_sv: + print(f"Total state versions returned: {len(sv_list)}") + for sv in sv_list: print(f"- {sv.id} | status={sv.status} | created_at={sv.created_at}") - # 2) Read the current state version (with outputs included if you want) - _print_header("Reading current state version") + # 2) Read the current state version with include=outputs + _print_header("read_current_with_options(include=outputs)") current = client.state_versions.read_current_with_options( args.workspace_id, StateVersionCurrentOptions(include=["outputs"]) ) - print( - f"Current SV: {current.id} status={current.status} durl={current.hosted_state_download_url}" + print(f"Current SV: {current.id} status={current.status}") + print(f" download_url: {current.hosted_state_download_url}") + print(f" json_download_url: {current.hosted_json_state_download_url}") + + # 3) Read by ID, with and without include options + _print_header("read(sv_id) and read_with_options(sv_id, include=[run,outputs])") + sv_read = client.state_versions.read(current.id) + print(f"read(): id={sv_read.id} serial={sv_read.serial}") + sv_read_opts = client.state_versions.read_with_options( + current.id, StateVersionReadOptions(include=["run", "outputs"]) ) + print(f"read_with_options(): id={sv_read_opts.id} serial={sv_read_opts.serial}") - # 3) (Optional) Download the current state (optional) + # 4) Download current state bytes + _print_header("download(current_sv_id)") + raw_current = client.state_versions.download(current.id) + print(f"Downloaded {len(raw_current)} bytes of state") if args.download: - _print_header(f"Downloading current state to: {args.download}") - raw = client.state_versions.download(current.id) - Path(args.download).write_bytes(raw) - print(f"Wrote {len(raw)} bytes to {args.download}") + Path(args.download).write_bytes(raw_current) + print(f" wrote bytes to {args.download}") - # 4) List outputs for the current state version (paged) - _print_header("Listing outputs (current state version)") + # 5) List outputs (by SV and via workspace shortcut) + _print_header("list_outputs(current_sv_id)") outs = list( client.state_versions.list_outputs( current.id, options=StateVersionOutputsListOptions(page_size=50) @@ -91,40 +158,101 @@ def main(): if not outs: print("No outputs found.") for o in outs: - # Sensitive outputs will have value = None print(f"- {o.name}: sensitive={o.sensitive} type={o.type} value={o.value}") - if args.workspace_id: - # 4b) List outputs for the current state version via workspace endpoint - _print_header("Listing outputs via workspace endpoint") - outs2 = list( - client.state_version_outputs.read_current( - args.workspace_id, options=StateVersionOutputsListOptions(page_size=50) - ) + _print_header("state_version_outputs.read_current(workspace_id)") + outs2 = list( + client.state_version_outputs.read_current( + args.workspace_id, options=StateVersionOutputsListOptions(page_size=50) ) - if not outs2: - print("No outputs found.") - for o in outs2: - print(f"- {o.name}: sensitive={o.sensitive} type={o.type} value={o.value}") + ) + if not outs2: + print("No outputs found.") + for o in outs2: + print(f"- {o.name}: sensitive={o.sensitive} type={o.type} value={o.value}") + + # 6) Upload demo: requires the workspace to be locked. + if args.skip_upload: + _print_header("Skipping upload demo (--skip-upload)") + return - # 5) (Optional) Upload a new state file + _print_header("upload(workspace_id, raw_state=..., options=...)") if args.upload: - _print_header(f"Uploading new state from: {args.upload}") payload = Path(args.upload).read_bytes() + print(f"Using user-provided payload from {args.upload} ({len(payload)} bytes)") + else: + try: + state_obj = json.loads(raw_current.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as e: + print(f"Could not parse current state as JSON; skip upload: {e}") + return + state_obj["serial"] = int(state_obj.get("serial", 0)) + 1 + payload = json.dumps(state_obj).encode("utf-8") + print( + f"Synthesized payload from current state with serial bumped to " + f"{state_obj['serial']} ({len(payload)} bytes)" + ) + + try: + state_obj = json.loads(payload.decode("utf-8")) + serial = int(state_obj["serial"]) + lineage = state_obj.get("lineage") + except (KeyError, ValueError, json.JSONDecodeError) as e: + print(f"Upload input must be valid Terraform state JSON with a serial: {e}") + return + + md5 = hashlib.md5(payload).hexdigest() # nosec B324 + + locked = False + try: + client.workspaces.lock( + args.workspace_id, + WorkspaceLockOptions(reason="python-tfe state_versions example"), + ) + locked = True + print(f"Locked workspace {args.workspace_id}") + except Exception as e: + print(f"Could not lock workspace (continuing without lock): {e}") + + new_sv = None + try: + new_sv = client.state_versions.upload( + args.workspace_id, + raw_state=payload, + options=StateVersionCreateOptions( + serial=serial, + md5=md5, + lineage=lineage, + ), + ) + print( + f"Uploaded new SV: {new_sv.id} status={new_sv.status} serial={new_sv.serial}" + ) + except ErrStateVersionUploadNotSupported as e: + print(f"Upload not supported on this server: {e}") + except Exception as e: + print(f"Upload failed: {e}") + finally: + if locked: + try: + client.workspaces.unlock(args.workspace_id) + print(f"Unlocked workspace {args.workspace_id}") + except Exception as e: + print(f"Failed to unlock workspace: {e}") + + # 7) Optional: exercise TFE-only backing data actions on the new SV + if args.demo_backing_data and new_sv is not None: + _print_header("TFE-only backing data actions on the new SV") try: - # If your server supports signed uploads, this will: - # a) create SV (to get upload URL) - # b) PUT bytes to the signed URL - # c) read back the SV to return a hydrated object - new_sv = client.state_versions.upload( - args.workspace_id, - raw_state=payload, - options=StateVersionCreateOptions(), + client.state_versions.soft_delete_backing_data(new_sv.id) + print("soft_delete_backing_data: OK") + client.state_versions.restore_backing_data(new_sv.id) + print("restore_backing_data: OK") + print("(skipping permanently_delete_backing_data — irreversible)") + except Exception as e: + print( + f"Backing-data actions not available (likely HCP Terraform, not TFE): {e}" ) - print(f"Uploaded new SV: {new_sv.id} status={new_sv.status}") - except ErrStateVersionUploadNotSupported as e: - # Some older/self-hosted versions don’t support direct upload - print(f"Upload not supported on this server: {e}") if __name__ == "__main__": diff --git a/src/pytfe/models/state_version.py b/src/pytfe/models/state_version.py index dab42619..dbbe06f2 100644 --- a/src/pytfe/models/state_version.py +++ b/src/pytfe/models/state_version.py @@ -36,8 +36,18 @@ class StateVersion(BaseModel): hosted_state_download_url: str | None = Field( None, alias="hosted-state-download-url" ) + hosted_json_state_download_url: str | None = Field( + None, alias="hosted-json-state-download-url" + ) hosted_state_upload_url: str | None = Field(None, alias="hosted-state-upload-url") + hosted_json_state_upload_url: str | None = Field( + None, alias="hosted-json-state-upload-url" + ) status: StateVersionStatus | None = Field(None, alias="status") + serial: int | None = Field(None, alias="serial") + size: int | None = Field(None, alias="size") + terraform_version: str | None = Field(None, alias="terraform-version") + state_version: int | None = Field(None, alias="state-version") # Optional/advanced fields (present on newer servers; keep loose) resources_processed: bool | None = Field(None, alias="resources-processed") diff --git a/src/pytfe/resources/state_versions.py b/src/pytfe/resources/state_versions.py index e98e13bb..8ff07403 100644 --- a/src/pytfe/resources/state_versions.py +++ b/src/pytfe/resources/state_versions.py @@ -7,7 +7,9 @@ from typing import Any from urllib.parse import urlencode +from ..errors import ErrStateVersionUploadNotSupported from ..errors import NotFound +from ..errors import TFEError # Pydantic models for this feature from ..models.state_version import ( @@ -193,18 +195,66 @@ def create( **{k.replace("-", "_"): v for k, v in attr.items()}, ) - """ def upload( self, workspace: str, *, - raw_state: bytes | None = None, + raw_state: bytes | None, raw_json_state: bytes | None = None, - options: Optional[StateVersionCreateOptions] = None, - organization: Optional[str] = None, + options: StateVersionCreateOptions, + organization: str | None = None, ) -> StateVersion: - # TBD: Implements Upload State Functionality - """ + """ + Create a state version and upload state bytes to signed Archivist URLs. + + This mirrors Terraform's recommended workflow: + 1. POST /workspaces/:id/state-versions with serial+md5 and no inline state + 2. PUT raw state bytes to hosted-state-upload-url + 3. Optional PUT JSON state bytes to hosted-json-state-upload-url + 4. Read the state version again and return the refreshed object + """ + if raw_state is None: + raise ValueError("raw_state is required") + if options.state is not None or options.json_state is not None: + raise ValueError( + "options.state and options.json_state must be omitted when using upload" + ) + + try: + sv = self.create(workspace, options, organization=organization) + except TFEError as exc: + # Older servers can reject the create-without-inline-state flow. + if "param is missing or the value is empty: state" in str(exc): + raise ErrStateVersionUploadNotSupported( + "state version upload is not supported by this server" + ) from exc + raise + + if not sv.hosted_state_upload_url: + raise ErrStateVersionUploadNotSupported( + "hosted-state-upload-url not returned by server" + ) + + self.t.request( + "PUT", + sv.hosted_state_upload_url, + data=raw_state, + headers={"Content-Type": "application/octet-stream"}, + ) + + if raw_json_state is not None: + if not sv.hosted_json_state_upload_url: + raise ErrStateVersionUploadNotSupported( + "hosted-json-state-upload-url not returned by server" + ) + self.t.request( + "PUT", + sv.hosted_json_state_upload_url, + data=raw_json_state, + headers={"Content-Type": "application/octet-stream"}, + ) + + return self.read(sv.id) def download(self, state_version_id: str) -> bytes: """ @@ -226,9 +276,12 @@ def download(self, state_version_id: str) -> bytes: raise NotFound("download url not available for this state version") # Download the bytes from the signed Archivist URL (follow redirects). - # Avoid JSON:API headers here; Accept */* is fine. + # Avoid API default headers here; Accept */* is fine. resp = self.t.request( - "GET", url, allow_redirects=True, headers={"Accept": "application/json"} + "GET", + url, + allow_redirects=True, + headers={"Accept": "*/*"}, ) return resp.content @@ -244,7 +297,10 @@ def download_current(self, workspace_id: str) -> bytes: raise NotFound("download url not available for current state") resp = self.t.request( - "GET", url, allow_redirects=True, headers={"Accept": "*/*"} + "GET", + url, + allow_redirects=True, + headers={"Accept": "*/*"}, ) return resp.content diff --git a/tests/units/test_state_version.py b/tests/units/test_state_version.py index 11f67c8f..385fdd7e 100644 --- a/tests/units/test_state_version.py +++ b/tests/units/test_state_version.py @@ -5,7 +5,9 @@ import pytest from pytfe._http import HTTPTransport +from pytfe.errors import ErrStateVersionUploadNotSupported from pytfe.errors import NotFound +from pytfe.errors import TFEError from pytfe.models.state_version import ( StateVersion, StateVersionCreateOptions, @@ -131,6 +133,7 @@ def test_read_state_version_success(self, state_versions_service, mock_transport ) assert result.id == "sv-read-1" assert result.status == StateVersionStatus.FINALIZED + assert result.serial == 9 assert result.hosted_state_download_url == "https://example.com/download" def test_read_with_options_success(self, state_versions_service, mock_transport): @@ -204,6 +207,7 @@ def test_read_current_with_options_success( params={"include": "created_by"}, ) assert result.id == "sv-current-1" + assert result.serial == 9 def test_create_state_version_success(self, state_versions_service, mock_transport): """Test successful create() operation.""" @@ -247,6 +251,86 @@ def test_create_state_version_success(self, state_versions_service, mock_transpo assert result.id == "sv-new-1" assert result.status == StateVersionStatus.PENDING + def test_upload_state_version_success(self, state_versions_service, mock_transport): + """Test upload() creates, uploads raw bytes, and re-reads state version.""" + created_sv = StateVersion( + id="sv-upload-1", + created_at="2024-01-01T00:00:00Z", + status=StateVersionStatus.PENDING, + hosted_state_upload_url="https://example.com/upload-raw", + hosted_json_state_upload_url="https://example.com/upload-json", + ) + final_sv = StateVersion( + id="sv-upload-1", + created_at="2024-01-01T00:00:00Z", + status=StateVersionStatus.FINALIZED, + hosted_state_download_url="https://example.com/download-raw", + ) + options = StateVersionCreateOptions(serial=10, md5="abc123") + + with patch.object(state_versions_service, "create", return_value=created_sv): + with patch.object(state_versions_service, "read", return_value=final_sv): + result = state_versions_service.upload( + "ws-123", + raw_state=b"raw-state", + raw_json_state=b"json-state", + options=options, + ) + + assert result.id == "sv-upload-1" + assert result.status == StateVersionStatus.FINALIZED + assert mock_transport.request.call_count == 2 + mock_transport.request.assert_any_call( + "PUT", + "https://example.com/upload-raw", + data=b"raw-state", + headers={"Content-Type": "application/octet-stream"}, + ) + mock_transport.request.assert_any_call( + "PUT", + "https://example.com/upload-json", + data=b"json-state", + headers={"Content-Type": "application/octet-stream"}, + ) + + def test_upload_state_version_unsupported_on_create_error( + self, state_versions_service + ): + """Test upload() maps legacy create error text to typed unsupported error.""" + options = StateVersionCreateOptions(serial=10, md5="abc123") + legacy_err = TFEError("param is missing or the value is empty: state") + + with patch.object(state_versions_service, "create", side_effect=legacy_err): + with pytest.raises(ErrStateVersionUploadNotSupported): + state_versions_service.upload( + "ws-123", raw_state=b"raw-state", options=options + ) + + def test_upload_state_version_requires_signed_url(self, state_versions_service): + """Test upload() raises when server does not return hosted-state-upload-url.""" + created_sv = StateVersion( + id="sv-upload-2", + created_at="2024-01-01T00:00:00Z", + status=StateVersionStatus.PENDING, + hosted_state_upload_url=None, + ) + options = StateVersionCreateOptions(serial=10, md5="abc123") + + with patch.object(state_versions_service, "create", return_value=created_sv): + with pytest.raises(ErrStateVersionUploadNotSupported): + state_versions_service.upload( + "ws-123", raw_state=b"raw-state", options=options + ) + + def test_upload_state_version_rejects_inline_state(self, state_versions_service): + """Test upload() enforces omission of inline state/json-state in options.""" + options = StateVersionCreateOptions(serial=10, md5="abc123", state="abc") + + with pytest.raises(ValueError, match="must be omitted"): + state_versions_service.upload( + "ws-123", raw_state=b"raw-state", options=options + ) + def test_download_state_version_not_found_when_url_missing( self, state_versions_service ): @@ -283,7 +367,7 @@ def test_download_state_version_success( "GET", "https://example.com/signed-download", allow_redirects=True, - headers={"Accept": "application/json"}, + headers={"Accept": "*/*"}, ) assert result == b"{}"