From 32a9abca1b0f3c85f0f34f9e9c255f991dd10499 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Mon, 16 Mar 2026 17:51:55 +0530 Subject: [PATCH 01/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] =?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/95] :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/95] =?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/95] =?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/95] =?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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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/95] 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 c82b45495089531e79c1204b2cd6c116c1d9a79d Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Thu, 7 May 2026 13:03:41 +0530 Subject: [PATCH 35/95] feat: add organization tags API (list, add_workspaces, delete) with models, resources, and example --- examples/organization_tags.py | 83 ++++++++++++ src/pytfe/client.py | 2 + src/pytfe/models/organization_tags.py | 65 ++++++++++ src/pytfe/resources/organization_tags.py | 122 +++++++++++++++++ tests/units/test_organization_tags.py | 158 +++++++++++++++++++++++ 5 files changed, 430 insertions(+) create mode 100644 examples/organization_tags.py create mode 100644 src/pytfe/models/organization_tags.py create mode 100644 src/pytfe/resources/organization_tags.py create mode 100644 tests/units/test_organization_tags.py diff --git a/examples/organization_tags.py b/examples/organization_tags.py new file mode 100644 index 00000000..96ef0df6 --- /dev/null +++ b/examples/organization_tags.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Organization tags operations example. + +Demonstrates: +1. list() - list tags in an organization + +This phase intentionally uses only organization-level parameters. +Tag IDs and workspace IDs can be passed in a later phase. +""" + +import os + +from pytfe import TFEClient, TFEConfig +from pytfe.errors import TFEError +from pytfe.models.organization_tags import ( + AddWorkspacesToTagOptions, + OrganizationTagsDeleteOptions, +) + + +def main() -> None: + client = TFEClient(TFEConfig.from_env()) + + organization_name = os.getenv("TFE_ORG", "example-org") + tag_id = os.getenv("TFE_TAG_ID", "") + workspace_id = os.getenv("TFE_WORKSPACE_ID", "") + operation = "list" + + try: + print("[LIST] Listing organization tags") + print(f"[LIST] organization={organization_name}") + tags = client.organization_tags.list(organization_name) + print(f"[LIST] total_tags={len(tags.items)}") + for item in tags.items: + print( + f"[LIST] id={item.id}, name={item.name}, instance_count={item.instance_count}" + ) + + # Guard: ensure env vars are set + if not tag_id or not workspace_id: + print("Skipping add/delete: set TFE_TAG_ID and TFE_WORKSPACE_ID first.") + return + + # ---- Add workspace ---- + operation = "add_workspaces" + print("[ADD_WORKSPACES] Associating a workspace to a tag") + print( + f"[ADD_WORKSPACES] organization={organization_name}, tag_id={tag_id}, workspace_id={workspace_id}" + ) + try: + client.organization_tags.add_workspaces( + organization_name, + tag_id, + AddWorkspacesToTagOptions(workspace_ids=[workspace_id]), + ) + print("[ADD_WORKSPACES] workspace associated") + except TFEError as exc: + print(f"[ADD_WORKSPACES] API error: {exc}") + print(f"[ADD_WORKSPACES] failed operation={operation}") + + # ---- Delete tag ---- + operation = "delete" + print("[DELETE] Deleting a tag from the organization") + print(f"[DELETE] organization={organization_name}, tag_id={tag_id}") + try: + client.organization_tags.delete( + organization_name, + OrganizationTagsDeleteOptions(ids=[tag_id]), + ) + print("[DELETE] tag deleted") + except TFEError as exc: + print(f"[DELETE] API error: {exc}") + print(f"[DELETE] failed operation={operation}") + except TFEError as exc: + print(f"API error: {exc}") + print(f"Failed during operation: {operation}") + print("Check TFE_TOKEN, TFE_ADDRESS, and organization/tag/workspace IDs.") + finally: + client.close() + + +if __name__ == "__main__": + main() diff --git a/src/pytfe/client.py b/src/pytfe/client.py index dc1972ce..69759f41 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -13,6 +13,7 @@ from .resources.oauth_client import OAuthClients from .resources.oauth_token import OAuthTokens from .resources.organization_membership import OrganizationMemberships +from .resources.organization_tags import OrganizationTags from .resources.organization_token import OrganizationTokens from .resources.organizations import Organizations from .resources.plan import Plans @@ -73,6 +74,7 @@ def __init__(self, config: TFEConfig | None = None): self.plans = Plans(self._transport) self.organizations = Organizations(self._transport) self.organization_memberships = OrganizationMemberships(self._transport) + self.organization_tags = OrganizationTags(self._transport) self.organization_tokens = OrganizationTokens(self._transport) self.projects = Projects(self._transport) self.variables = Variables(self._transport) diff --git a/src/pytfe/models/organization_tags.py b/src/pytfe/models/organization_tags.py new file mode 100644 index 00000000..957e5384 --- /dev/null +++ b/src/pytfe/models/organization_tags.py @@ -0,0 +1,65 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +from .common import Pagination +from .organization import Organization + + +class OrganizationTag(BaseModel): + """Terraform Enterprise organization tag.""" + + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + id: str = Field(..., description="Tag ID") + name: str | None = Field(None, description="Tag name") + instance_count: int | None = Field( + None, + alias="instance-count", + description="Number of workspaces that have this tag", + ) + organization: Organization | None = Field( + None, + description="Organization this tag belongs to", + ) + + +class OrganizationTagsList(BaseModel): + """Represents a list response for organization tags.""" + + model_config = ConfigDict(extra="forbid") + + pagination: Pagination | None = Field(None) + items: list[OrganizationTag] = Field(default_factory=list) + + +class OrganizationTagsListOptions(BaseModel): + """Options for listing organization tags.""" + + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + filter: str | None = Field(None, alias="filter[exclude][taggable][id]") + query: str | None = Field( + None, + alias="q", + description="Search query string for tag name likeness", + ) + + +class OrganizationTagsDeleteOptions(BaseModel): + """Options for deleting tags from an organization.""" + + model_config = ConfigDict(extra="forbid") + + ids: list[str] = Field(default_factory=list) + + +class AddWorkspacesToTagOptions(BaseModel): + """Options for associating workspaces with a tag.""" + + model_config = ConfigDict(extra="forbid") + + workspace_ids: list[str] = Field(default_factory=list) diff --git a/src/pytfe/resources/organization_tags.py b/src/pytfe/resources/organization_tags.py new file mode 100644 index 00000000..9ec24c0f --- /dev/null +++ b/src/pytfe/resources/organization_tags.py @@ -0,0 +1,122 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from typing import Any +from urllib.parse import quote + +from ..errors import ( + ERR_INVALID_ORG, +) +from ..models.common import Pagination +from ..models.organization import Organization +from ..models.organization_tags import ( + AddWorkspacesToTagOptions, + OrganizationTag, + OrganizationTagsDeleteOptions, + OrganizationTagsList, + OrganizationTagsListOptions, +) +from ..utils import valid_string_id +from ._base import _Service + +ERR_INVALID_TAG = "invalid value for tag" +ERR_REQUIRED_TAG_ID = "tag ID is required" +ERR_REQUIRED_TAG_WORKSPACE_ID = "workspace ID is required" + + +class OrganizationTags(_Service): + """Organization tags service for Terraform Enterprise.""" + + def list( + self, + organization: str, + options: OrganizationTagsListOptions | None = None, + ) -> OrganizationTagsList: + """List all tags within an organization.""" + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + + path = f"/api/v2/organizations/{quote(organization)}/tags" + params = ( + options.model_dump(by_alias=True, exclude_none=True) if options else None + ) + + response = self.t.request("GET", path, params=params) + payload = response.json() or {} + + items = [self._parse_organization_tag(item) for item in payload.get("data", [])] + + pagination = None + meta = payload.get("meta", {}) + pagination_data = meta.get("pagination", {}) if isinstance(meta, dict) else {} + if pagination_data: + pagination = Pagination( + current_page=pagination_data.get("current-page", 1), + total_count=pagination_data.get("total-count", len(items)), + previous_page=pagination_data.get("previous-page"), + next_page=pagination_data.get("next-page"), + total_pages=pagination_data.get("total-pages"), + ) + + return OrganizationTagsList(pagination=pagination, items=items) + + def delete( + self, + organization: str, + options: OrganizationTagsDeleteOptions, + ) -> None: + """Delete tags from an organization.""" + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + + if len(options.ids) == 0: + raise ValueError(ERR_REQUIRED_TAG_ID) + + for tag_id in options.ids: + if not valid_string_id(tag_id): + raise ValueError(f"{tag_id} is not a valid id value") + + body = {"data": [{"type": "tags", "id": tag_id} for tag_id in options.ids]} + path = f"/api/v2/organizations/{quote(organization)}/tags" + self.t.request("DELETE", path, json_body=body) + + def add_workspaces(self, organization: str, tag: str, options: AddWorkspacesToTagOptions) -> None: + """Associate workspaces with an organization tag.""" + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + if not valid_string_id(tag): + raise ValueError(ERR_INVALID_TAG) + + if len(options.workspace_ids) == 0: + raise ValueError(ERR_REQUIRED_TAG_WORKSPACE_ID) + + for workspace_id in options.workspace_ids: + if not valid_string_id(workspace_id): + raise ValueError(f"{workspace_id} is not a valid id value") + + body = { + "data": [ + {"type": "workspaces", "id": workspace_id} + for workspace_id in options.workspace_ids + ] + } + path = f"/api/v2/tags/{quote(tag)}/relationships/workspaces" + self.t.request("POST", path, json_body=body) + + def _parse_organization_tag(self, data: dict[str, Any]) -> OrganizationTag: + attributes = data.get("attributes", {}) + relationships = data.get("relationships", {}) + + org = None + org_data = relationships.get("organization", {}).get("data") + if org_data and isinstance(org_data, dict): + org = Organization(id=org_data.get("id")) + + return OrganizationTag( + id=data.get("id", ""), + name=attributes.get("name"), + instance_count=attributes.get("instance-count"), + organization=org, + ) diff --git a/tests/units/test_organization_tags.py b/tests/units/test_organization_tags.py new file mode 100644 index 00000000..30ca2c26 --- /dev/null +++ b/tests/units/test_organization_tags.py @@ -0,0 +1,158 @@ +"""Unit tests for the organization tags module.""" + +import os +import sys +from unittest.mock import Mock, patch + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) + +from pytfe._http import HTTPTransport +from pytfe.errors import ( + ERR_INVALID_ORG, +) +from pytfe.models.organization_tags import ( + AddWorkspacesToTagOptions, + OrganizationTagsDeleteOptions, + OrganizationTagsList, + OrganizationTagsListOptions, +) +from pytfe.resources.organization_tags import OrganizationTags + +ERR_INVALID_TAG = "invalid value for tag" +ERR_REQUIRED_TAG_ID = "tag ID is required" +ERR_REQUIRED_TAG_WORKSPACE_ID = "workspace ID is required" + + +class TestOrganizationTags: + """Test the OrganizationTags service class.""" + + @pytest.fixture + def mock_transport(self): + return Mock(spec=HTTPTransport) + + @pytest.fixture + def organization_tags_service(self, mock_transport): + return OrganizationTags(mock_transport) + + def test_list_success(self, organization_tags_service): + mock_response_data = { + "data": [ + { + "id": "tag-1", + "attributes": { + "name": "env:dev", + "instance-count": 2, + }, + "relationships": { + "organization": {"data": {"id": "org-1", "type": "organizations"}} + }, + } + ], + "meta": { + "pagination": { + "current-page": 1, + "total-count": 1, + "next-page": None, + "previous-page": None, + "total-pages": 1, + } + }, + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + + with patch.object(organization_tags_service, "t") as mock_t: + mock_t.request.return_value = mock_response + + options = OrganizationTagsListOptions(query="env") + result = organization_tags_service.list("test-org", options) + + assert isinstance(result, OrganizationTagsList) + assert len(result.items) == 1 + assert result.items[0].id == "tag-1" + assert result.items[0].name == "env:dev" + assert result.items[0].instance_count == 2 + assert result.items[0].organization is not None + assert result.items[0].organization.id == "org-1" + assert result.pagination is not None + assert result.pagination.current_page == 1 + assert result.pagination.total_count == 1 + + call_args = mock_t.request.call_args + assert call_args[0][0] == "GET" + assert call_args[0][1] == "/api/v2/organizations/test-org/tags" + assert call_args[1]["params"]["q"] == "env" + + def test_list_validation_errors(self, organization_tags_service): + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + organization_tags_service.list("") + + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + organization_tags_service.list(None) + + def test_delete_success(self, organization_tags_service): + with patch.object(organization_tags_service, "t") as mock_t: + mock_t.request.return_value = Mock() + + options = OrganizationTagsDeleteOptions(ids=["tag-1", "tag-2"]) + organization_tags_service.delete("test-org", options) + + call_args = mock_t.request.call_args + assert call_args[0][0] == "DELETE" + assert call_args[0][1] == "/api/v2/organizations/test-org/tags" + assert call_args[1]["json_body"] == { + "data": [ + {"type": "tags", "id": "tag-1"}, + {"type": "tags", "id": "tag-2"}, + ] + } + + def test_delete_validation_errors(self, organization_tags_service): + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + organization_tags_service.delete( + "", OrganizationTagsDeleteOptions(ids=["tag-1"]) + ) + + with pytest.raises(ValueError, match=ERR_REQUIRED_TAG_ID): + organization_tags_service.delete("test-org", OrganizationTagsDeleteOptions()) + + with pytest.raises(ValueError, match="is not a valid id value"): + organization_tags_service.delete( + "test-org", OrganizationTagsDeleteOptions(ids=[""]) + ) + + def test_add_workspaces_success(self, organization_tags_service): + with patch.object(organization_tags_service, "t") as mock_t: + mock_t.request.return_value = Mock() + + options = AddWorkspacesToTagOptions(workspace_ids=["ws-1", "ws-2"]) + organization_tags_service.add_workspaces("tag-1", options) + + call_args = mock_t.request.call_args + assert call_args[0][0] == "POST" + assert call_args[0][1] == "/api/v2/tags/tag-1/relationships/workspaces" + assert call_args[1]["json_body"] == { + "data": [ + {"type": "workspaces", "id": "ws-1"}, + {"type": "workspaces", "id": "ws-2"}, + ] + } + + def test_add_workspaces_validation_errors(self, organization_tags_service): + with pytest.raises(ValueError, match=ERR_INVALID_TAG): + organization_tags_service.add_workspaces( + "", AddWorkspacesToTagOptions(workspace_ids=["ws-1"]) + ) + + with pytest.raises(ValueError, match=ERR_REQUIRED_TAG_WORKSPACE_ID): + organization_tags_service.add_workspaces( + "tag-1", AddWorkspacesToTagOptions() + ) + + with pytest.raises(ValueError, match="is not a valid id value"): + organization_tags_service.add_workspaces( + "tag-1", AddWorkspacesToTagOptions(workspace_ids=[""]) + ) From a006562e94c253f1d562bb67155a0c96a2169673 Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Thu, 7 May 2026 13:11:50 +0530 Subject: [PATCH 36/95] test: update unit tests for organization tags --- tests/units/test_organization_tags.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/units/test_organization_tags.py b/tests/units/test_organization_tags.py index 30ca2c26..2a876e26 100644 --- a/tests/units/test_organization_tags.py +++ b/tests/units/test_organization_tags.py @@ -129,7 +129,7 @@ def test_add_workspaces_success(self, organization_tags_service): mock_t.request.return_value = Mock() options = AddWorkspacesToTagOptions(workspace_ids=["ws-1", "ws-2"]) - organization_tags_service.add_workspaces("tag-1", options) + organization_tags_service.add_workspaces("test-org", "tag-1", options) call_args = mock_t.request.call_args assert call_args[0][0] == "POST" @@ -142,17 +142,22 @@ def test_add_workspaces_success(self, organization_tags_service): } def test_add_workspaces_validation_errors(self, organization_tags_service): + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + organization_tags_service.add_workspaces( + "", "tag-1", AddWorkspacesToTagOptions(workspace_ids=["ws-1"]) + ) + with pytest.raises(ValueError, match=ERR_INVALID_TAG): organization_tags_service.add_workspaces( - "", AddWorkspacesToTagOptions(workspace_ids=["ws-1"]) + "test-org", "", AddWorkspacesToTagOptions(workspace_ids=["ws-1"]) ) with pytest.raises(ValueError, match=ERR_REQUIRED_TAG_WORKSPACE_ID): organization_tags_service.add_workspaces( - "tag-1", AddWorkspacesToTagOptions() + "test-org", "tag-1", AddWorkspacesToTagOptions() ) with pytest.raises(ValueError, match="is not a valid id value"): organization_tags_service.add_workspaces( - "tag-1", AddWorkspacesToTagOptions(workspace_ids=[""]) + "test-org", "tag-1", AddWorkspacesToTagOptions(workspace_ids=[""]) ) From cec2d8be0e547dd8da188f78b7b2d8319776ca2b Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Thu, 7 May 2026 14:18:17 +0530 Subject: [PATCH 37/95] fix: apply ruff formatting --- src/pytfe/resources/organization_tags.py | 4 +++- tests/units/test_organization_tags.py | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/pytfe/resources/organization_tags.py b/src/pytfe/resources/organization_tags.py index 9ec24c0f..1516522c 100644 --- a/src/pytfe/resources/organization_tags.py +++ b/src/pytfe/resources/organization_tags.py @@ -82,7 +82,9 @@ def delete( path = f"/api/v2/organizations/{quote(organization)}/tags" self.t.request("DELETE", path, json_body=body) - def add_workspaces(self, organization: str, tag: str, options: AddWorkspacesToTagOptions) -> None: + def add_workspaces( + self, organization: str, tag: str, options: AddWorkspacesToTagOptions + ) -> None: """Associate workspaces with an organization tag.""" if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) diff --git a/tests/units/test_organization_tags.py b/tests/units/test_organization_tags.py index 2a876e26..68ebf9fd 100644 --- a/tests/units/test_organization_tags.py +++ b/tests/units/test_organization_tags.py @@ -46,7 +46,9 @@ def test_list_success(self, organization_tags_service): "instance-count": 2, }, "relationships": { - "organization": {"data": {"id": "org-1", "type": "organizations"}} + "organization": { + "data": {"id": "org-1", "type": "organizations"} + } }, } ], @@ -117,7 +119,9 @@ def test_delete_validation_errors(self, organization_tags_service): ) with pytest.raises(ValueError, match=ERR_REQUIRED_TAG_ID): - organization_tags_service.delete("test-org", OrganizationTagsDeleteOptions()) + organization_tags_service.delete( + "test-org", OrganizationTagsDeleteOptions() + ) with pytest.raises(ValueError, match="is not a valid id value"): organization_tags_service.delete( From c02c8362e3dd525fa601b3ae098b18aa6ba54fdb Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Thu, 7 May 2026 14:45:05 +0530 Subject: [PATCH 38/95] client.py is modified --- src/pytfe/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pytfe/client.py b/src/pytfe/client.py index ed5ff4e6..1d73e3ac 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -38,7 +38,6 @@ from .resources.ssh_keys import SSHKeys from .resources.state_version_outputs import StateVersionOutputs from .resources.state_versions import StateVersions -from .resources.user import Users from .resources.variable import Variables from .resources.variable_sets import VariableSets, VariableSetVariables from .resources.workspace_resources import WorkspaceResourcesService From 3b05117b730bda5593180812e3ad8a2715bbd9f3 Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Thu, 7 May 2026 14:51:32 +0530 Subject: [PATCH 39/95] fixing lint issues --- src/pytfe/resources/organization_tags.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/pytfe/resources/organization_tags.py b/src/pytfe/resources/organization_tags.py index 1516522c..51eae9ea 100644 --- a/src/pytfe/resources/organization_tags.py +++ b/src/pytfe/resources/organization_tags.py @@ -116,9 +116,11 @@ def _parse_organization_tag(self, data: dict[str, Any]) -> OrganizationTag: if org_data and isinstance(org_data, dict): org = Organization(id=org_data.get("id")) - return OrganizationTag( - id=data.get("id", ""), - name=attributes.get("name"), - instance_count=attributes.get("instance-count"), - organization=org, + return OrganizationTag.model_validate( + { + "id": data.get("id", ""), + "name": attributes.get("name"), + "instance-count": attributes.get("instance-count"), + "organization": org, + } ) From 044ba21785a8761ad9c0c63feece30d498e57137 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Fri, 8 May 2026 12:47:07 +0530 Subject: [PATCH 40/95] 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 41/95] 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 42/95] 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 43/95] 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 44/95] 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 4630aa6d1c67dfae3cc9fecffc141fda0f901b5b Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Fri, 8 May 2026 13:20:59 +0530 Subject: [PATCH 45/95] self.Users is added --- src/pytfe/client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 1d73e3ac..585b0f79 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -75,6 +75,7 @@ def __init__(self, config: TFEConfig | None = None): self.plans = Plans(self._transport) self.organizations = Organizations(self._transport) self.organization_memberships = OrganizationMemberships(self._transport) + self.users = Users(self._transport) self.organization_tags = OrganizationTags(self._transport) self.organization_tokens = OrganizationTokens(self._transport) self.projects = Projects(self._transport) From 518455e8fce89ab81eb9cf1a22d86dcc6a76cc22 Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Fri, 8 May 2026 13:25:25 +0530 Subject: [PATCH 46/95] self.Users is added again --- src/pytfe/client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 585b0f79..4f22961f 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -38,6 +38,7 @@ from .resources.ssh_keys import SSHKeys from .resources.state_version_outputs import StateVersionOutputs from .resources.state_versions import StateVersions +from .resources.user import Users from .resources.variable import Variables from .resources.variable_sets import VariableSets, VariableSetVariables from .resources.workspace_resources import WorkspaceResourcesService From 0abad7b0ed705366cbb86c988501e92e61efbef6 Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Mon, 11 May 2026 12:23:50 +0530 Subject: [PATCH 47/95] Add organization audit configuration API support --- examples/organization_audit_configuration.py | 54 ++++++ src/pytfe/client.py | 4 + .../organization_audit_configuration.py | 129 +++++++++++++++ .../organization_audit_configuration.py | 101 ++++++++++++ .../test_organization_audit_configuration.py | 155 ++++++++++++++++++ 5 files changed, 443 insertions(+) create mode 100644 examples/organization_audit_configuration.py create mode 100644 src/pytfe/models/organization_audit_configuration.py create mode 100644 src/pytfe/resources/organization_audit_configuration.py create mode 100644 tests/units/test_organization_audit_configuration.py diff --git a/examples/organization_audit_configuration.py b/examples/organization_audit_configuration.py new file mode 100644 index 00000000..3153510e --- /dev/null +++ b/examples/organization_audit_configuration.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Organization audit configuration operations example. + +Demonstrates: +1. read() - read organization audit configuration +2. test() - send a test audit event +3. update() - update organization audit configuration +""" + +import os + +from pytfe import TFEClient, TFEConfig +from pytfe.errors import TFEError +from pytfe.models.organization_audit_configuration import ( + OrganizationAuditConfigAuditTrails, + OrganizationAuditConfigurationOptions, +) + + +def main() -> None: + client = TFEClient(TFEConfig.from_env()) + + organization_name = os.getenv("TFE_ORG", "example-org") + + try: + print("[READ] Reading organization audit configuration") + read_result = client.organization_audit_configurations.read(organization_name) + print(f"[READ] id={read_result.id}, updated_at={read_result.updated_at}") + if read_result.audit_trails is not None: + print(f"[READ] audit_trails_enabled={read_result.audit_trails.enabled}") + + print("[TEST] Sending test audit event") + test_result = client.organization_audit_configurations.test(organization_name) + print(f"[TEST] request_id={test_result.request_id}") + + print("[UPDATE] Updating organization audit configuration") + options = OrganizationAuditConfigurationOptions( + audit_trails=OrganizationAuditConfigAuditTrails(enabled=True) + ) + update_result = client.organization_audit_configurations.update( + organization_name, + options, + ) + print(f"[UPDATE] id={update_result.id}, updated_at={update_result.updated_at}") + + except TFEError as exc: + print(f"API error: {exc}") + print("Check TFE_TOKEN, TFE_ADDRESS, and TFE_ORG.") + finally: + client.close() + + +if __name__ == "__main__": + main() diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 0e88e1ab..c12ad2b9 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -12,6 +12,7 @@ from .resources.notification_configuration import NotificationConfigurations from .resources.oauth_client import OAuthClients from .resources.oauth_token import OAuthTokens +from .resources.organization_audit_configuration import OrganizationAuditConfigurations from .resources.organization_membership import OrganizationMemberships from .resources.organization_token import OrganizationTokens from .resources.organizations import Organizations @@ -75,6 +76,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.organization_audit_configurations = OrganizationAuditConfigurations( + self._transport + ) self.users = Users(self._transport) self.organization_tokens = OrganizationTokens(self._transport) self.projects = Projects(self._transport) diff --git a/src/pytfe/models/organization_audit_configuration.py b/src/pytfe/models/organization_audit_configuration.py new file mode 100644 index 00000000..a045396a --- /dev/null +++ b/src/pytfe/models/organization_audit_configuration.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + +from .organization import Organization + + +class OrganizationAuditConfigAuditTrails(BaseModel): + """Audit Trails configuration.""" + + model_config = ConfigDict(extra="forbid") + + enabled: bool = Field(..., description="Whether Audit Trails is enabled") + + +class OrganizationAuditConfigAuditStreaming(BaseModel): + """HCP Audit Log Streaming configuration.""" + + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + enabled: bool = Field(..., description="Whether HCP Audit Log Streaming is enabled") + organization_id: str | None = Field(None, alias="organization-id") + use_default_organization: bool = Field( + ..., + alias="use-default-organization", + ) + + +class OrganizationAuditConfigPermissions(BaseModel): + """Permissions for managing audit configuration.""" + + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + can_enable_hcp_audit_log_streaming: bool = Field( + ..., + alias="can-enable-hcp-audit-log-streaming", + ) + can_set_hcp_audit_log_streaming_organization: bool = Field( + ..., + alias="can-set-hcp-audit-log-streaming-organization-id", + ) + can_use_default_audit_log_streaming_organization: bool = Field( + ..., + alias="can-use-default-audit-log-streaming-organization", + ) + + +class OrganizationAuditConfigTimestamps(BaseModel): + """Timestamp fields for organization audit configuration events.""" + + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + audit_trails_disabled_at: datetime | None = Field( + None, + alias="audit-trails-disabled-at", + ) + audit_trails_enabled_at: datetime | None = Field( + None, + alias="audit-trails-enabled-at", + ) + audit_trails_last_failure: datetime | None = Field( + None, + alias="audit-trails-last-failure", + ) + audit_trails_last_success: datetime | None = Field( + None, + alias="audit-trails-last-success", + ) + hcp_audit_log_streaming_disabled_at: datetime | None = Field( + None, + alias="hcp-audit-log-streaming-disabled-at", + ) + hcp_audit_log_streaming_enabled_at: datetime | None = Field( + None, + alias="hcp-audit-log-streaming-enabled-at", + ) + hcp_audit_log_streaming_last_failure: datetime | None = Field( + None, + alias="hcp-audit-log-streaming-last-failure", + ) + hcp_audit_log_streaming_last_success: datetime | None = Field( + None, + alias="hcp-audit-log-streaming-last-success", + ) + + +class OrganizationAuditConfiguration(BaseModel): + """Organization audit configuration resource.""" + + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + id: str + audit_trails: OrganizationAuditConfigAuditTrails | None = Field( + None, + alias="audit-trails", + ) + hcp_audit_log_streaming: OrganizationAuditConfigAuditStreaming | None = Field( + None, + alias="hcp-audit-log-streaming", + ) + permissions: OrganizationAuditConfigPermissions | None = None + timestamps: OrganizationAuditConfigTimestamps | None = None + updated_at: datetime | None = Field(None, alias="updated-at") + organization: Organization | None = None + + +class OrganizationAuditConfigurationTest(BaseModel): + """Result payload for sending a test audit event.""" + + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + request_id: str | None = Field(None, alias="request-id") + + +class OrganizationAuditConfigurationOptions(BaseModel): + """Options for updating organization audit configuration.""" + + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + audit_trails: OrganizationAuditConfigAuditTrails | None = Field( + None, + alias="audit-trails", + ) + hcp_audit_log_streaming: OrganizationAuditConfigAuditStreaming | None = Field( + None, + alias="hcp-audit-log-streaming", + ) diff --git a/src/pytfe/resources/organization_audit_configuration.py b/src/pytfe/resources/organization_audit_configuration.py new file mode 100644 index 00000000..327d7ec6 --- /dev/null +++ b/src/pytfe/resources/organization_audit_configuration.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from typing import Any +from urllib.parse import quote + +from ..errors import ERR_INVALID_ORG +from ..models.organization import Organization +from ..models.organization_audit_configuration import ( + OrganizationAuditConfiguration, + OrganizationAuditConfigurationOptions, + OrganizationAuditConfigurationTest, +) +from ..utils import valid_string_id +from ._base import _Service + + +class OrganizationAuditConfigurations(_Service): + """Organization audit configuration service.""" + + def read(self, organization: str) -> OrganizationAuditConfiguration: + """Read an organization's audit configuration by organization name.""" + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + + path = f"/api/v2/organizations/{quote(organization)}/audit-configuration" + response = self.t.request("GET", path) + payload = response.json() or {} + data = payload.get("data") + if not isinstance(data, dict): + raise ValueError("Invalid response format") + + return self._parse_audit_configuration(data) + + def test(self, organization: str) -> OrganizationAuditConfigurationTest: + """Send a test audit event for an organization.""" + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + + path = f"/api/v2/organizations/{quote(organization)}/audit-configuration/test" + response = self.t.request("POST", path) + payload = response.json() or {} + + if isinstance(payload, dict) and "request-id" in payload: + return OrganizationAuditConfigurationTest.model_validate(payload) + + data = payload.get("data") if isinstance(payload, dict) else None + if isinstance(data, dict): + if "request-id" in data: + return OrganizationAuditConfigurationTest.model_validate(data) + attrs = data.get("attributes") + if isinstance(attrs, dict) and "request-id" in attrs: + return OrganizationAuditConfigurationTest.model_validate(attrs) + + return OrganizationAuditConfigurationTest(request_id=None) + + def update( + self, + organization: str, + options: OrganizationAuditConfigurationOptions, + ) -> OrganizationAuditConfiguration: + """Update an organization's audit configuration.""" + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + + attrs = options.model_dump(by_alias=True, exclude_none=True) + body: dict[str, Any] = { + "data": { + "type": "audit-configurations", + "attributes": attrs, + } + } + + path = f"/api/v2/organizations/{quote(organization)}/audit-configuration" + response = self.t.request("PATCH", path, json_body=body) + payload = response.json() or {} + data = payload.get("data") + if not isinstance(data, dict): + raise ValueError("Invalid response format") + + return self._parse_audit_configuration(data) + + def _parse_audit_configuration(self, data: dict[str, Any]) -> OrganizationAuditConfiguration: + attrs = data.get("attributes", {}) + relationships = data.get("relationships", {}) + + org = None + org_data = relationships.get("organization", {}).get("data") + if isinstance(org_data, dict): + org = Organization(id=org_data.get("id")) + + return OrganizationAuditConfiguration.model_validate( + { + "id": data.get("id", ""), + "audit-trails": attrs.get("audit-trails"), + "hcp-audit-log-streaming": attrs.get("hcp-audit-log-streaming"), + "permissions": attrs.get("permissions"), + "timestamps": attrs.get("timestamps"), + "updated-at": attrs.get("updated-at"), + "organization": org, + } + ) diff --git a/tests/units/test_organization_audit_configuration.py b/tests/units/test_organization_audit_configuration.py new file mode 100644 index 00000000..49d45cb3 --- /dev/null +++ b/tests/units/test_organization_audit_configuration.py @@ -0,0 +1,155 @@ +"""Unit tests for organization audit configuration service.""" + +import os +import sys +from unittest.mock import Mock, patch + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) + +from pytfe._http import HTTPTransport +from pytfe.errors import ERR_INVALID_ORG +from pytfe.models.organization_audit_configuration import ( + OrganizationAuditConfigAuditStreaming, + OrganizationAuditConfigAuditTrails, + OrganizationAuditConfiguration, + OrganizationAuditConfigurationOptions, + OrganizationAuditConfigurationTest, +) +from pytfe.resources.organization_audit_configuration import ( + OrganizationAuditConfigurations, +) + + +class TestOrganizationAuditConfigurations: + @pytest.fixture + def mock_transport(self): + return Mock(spec=HTTPTransport) + + @pytest.fixture + def service(self, mock_transport): + return OrganizationAuditConfigurations(mock_transport) + + def test_read_success(self, service): + mock_response_data = { + "data": { + "id": "acfg-123", + "attributes": { + "audit-trails": {"enabled": True}, + "hcp-audit-log-streaming": { + "enabled": False, + "organization-id": "org-123", + "use-default-organization": True, + }, + "updated-at": "2025-01-01T00:00:00Z", + }, + "relationships": { + "organization": {"data": {"id": "org-123", "type": "organizations"}} + }, + } + } + mock_response = Mock() + mock_response.json.return_value = mock_response_data + + with patch.object(service, "t") as mock_t: + mock_t.request.return_value = mock_response + result = service.read("test-org") + + assert isinstance(result, OrganizationAuditConfiguration) + assert result.id == "acfg-123" + assert result.audit_trails is not None + assert result.audit_trails.enabled is True + assert result.organization is not None + assert result.organization.id == "org-123" + + call_args = mock_t.request.call_args + assert call_args[0][0] == "GET" + assert call_args[0][1] == "/api/v2/organizations/test-org/audit-configuration" + + def test_read_validation_errors(self, service): + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + service.read("") + + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + service.read(None) + + def test_test_success(self, service): + mock_response = Mock() + mock_response.json.return_value = {"request-id": "req-123"} + + with patch.object(service, "t") as mock_t: + mock_t.request.return_value = mock_response + result = service.test("test-org") + + assert isinstance(result, OrganizationAuditConfigurationTest) + assert result.request_id == "req-123" + + call_args = mock_t.request.call_args + assert call_args[0][0] == "POST" + assert ( + call_args[0][1] + == "/api/v2/organizations/test-org/audit-configuration/test" + ) + + def test_test_validation_errors(self, service): + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + service.test("") + + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + service.test(None) + + def test_update_success(self, service): + mock_response_data = { + "data": { + "id": "acfg-123", + "attributes": { + "audit-trails": {"enabled": True}, + "hcp-audit-log-streaming": { + "enabled": True, + "organization-id": "org-123", + "use-default-organization": False, + }, + "updated-at": "2025-01-01T00:00:00Z", + }, + "relationships": { + "organization": {"data": {"id": "org-123", "type": "organizations"}} + }, + } + } + mock_response = Mock() + mock_response.json.return_value = mock_response_data + + with patch.object(service, "t") as mock_t: + mock_t.request.return_value = mock_response + + options = OrganizationAuditConfigurationOptions( + audit_trails=OrganizationAuditConfigAuditTrails(enabled=True), + hcp_audit_log_streaming=OrganizationAuditConfigAuditStreaming( + enabled=True, + organization_id="org-123", + use_default_organization=False, + ), + ) + + result = service.update("test-org", options) + assert isinstance(result, OrganizationAuditConfiguration) + + call_args = mock_t.request.call_args + assert call_args[0][0] == "PATCH" + assert call_args[0][1] == "/api/v2/organizations/test-org/audit-configuration" + assert call_args[1]["json_body"]["data"]["type"] == "audit-configurations" + attrs = call_args[1]["json_body"]["data"]["attributes"] + assert attrs["audit-trails"]["enabled"] is True + assert attrs["hcp-audit-log-streaming"]["organization-id"] == "org-123" + + def test_update_validation_errors(self, service): + options = OrganizationAuditConfigurationOptions( + audit_trails=OrganizationAuditConfigAuditTrails(enabled=False) + ) + + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + service.update("", options) + + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + service.update(None, options) From 1d6c5a5c1137fefbaec2f9a640faa3c43dd1bee1 Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Tue, 12 May 2026 11:33:57 +0530 Subject: [PATCH 48/95] fix: apply ruff formatting --- src/pytfe/resources/organization_audit_configuration.py | 4 +++- tests/units/test_organization_audit_configuration.py | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/pytfe/resources/organization_audit_configuration.py b/src/pytfe/resources/organization_audit_configuration.py index 327d7ec6..5a7267c0 100644 --- a/src/pytfe/resources/organization_audit_configuration.py +++ b/src/pytfe/resources/organization_audit_configuration.py @@ -79,7 +79,9 @@ def update( return self._parse_audit_configuration(data) - def _parse_audit_configuration(self, data: dict[str, Any]) -> OrganizationAuditConfiguration: + def _parse_audit_configuration( + self, data: dict[str, Any] + ) -> OrganizationAuditConfiguration: attrs = data.get("attributes", {}) relationships = data.get("relationships", {}) diff --git a/tests/units/test_organization_audit_configuration.py b/tests/units/test_organization_audit_configuration.py index 49d45cb3..56033358 100644 --- a/tests/units/test_organization_audit_configuration.py +++ b/tests/units/test_organization_audit_configuration.py @@ -65,7 +65,9 @@ def test_read_success(self, service): call_args = mock_t.request.call_args assert call_args[0][0] == "GET" - assert call_args[0][1] == "/api/v2/organizations/test-org/audit-configuration" + assert ( + call_args[0][1] == "/api/v2/organizations/test-org/audit-configuration" + ) def test_read_validation_errors(self, service): with pytest.raises(ValueError, match=ERR_INVALID_ORG): @@ -137,7 +139,9 @@ def test_update_success(self, service): call_args = mock_t.request.call_args assert call_args[0][0] == "PATCH" - assert call_args[0][1] == "/api/v2/organizations/test-org/audit-configuration" + assert ( + call_args[0][1] == "/api/v2/organizations/test-org/audit-configuration" + ) assert call_args[1]["json_body"]["data"]["type"] == "audit-configurations" attrs = call_args[1]["json_body"]["data"]["attributes"] assert attrs["audit-trails"]["enabled"] is True From 15c21de67f6d980d539609797f77c7f248f5aa32 Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Tue, 12 May 2026 11:41:11 +0530 Subject: [PATCH 49/95] fix: add alias for request_id field --- src/pytfe/resources/organization_audit_configuration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pytfe/resources/organization_audit_configuration.py b/src/pytfe/resources/organization_audit_configuration.py index 5a7267c0..f656da1b 100644 --- a/src/pytfe/resources/organization_audit_configuration.py +++ b/src/pytfe/resources/organization_audit_configuration.py @@ -51,7 +51,7 @@ def test(self, organization: str) -> OrganizationAuditConfigurationTest: if isinstance(attrs, dict) and "request-id" in attrs: return OrganizationAuditConfigurationTest.model_validate(attrs) - return OrganizationAuditConfigurationTest(request_id=None) + return OrganizationAuditConfigurationTest() def update( self, From 056d3ababc20353f62ae0bb6471e87eb98aecfaa Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Tue, 12 May 2026 12:14:50 +0530 Subject: [PATCH 50/95] fix: enable populate_by_name for request_id alias --- src/pytfe/resources/organization_audit_configuration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pytfe/resources/organization_audit_configuration.py b/src/pytfe/resources/organization_audit_configuration.py index f656da1b..ee51ef59 100644 --- a/src/pytfe/resources/organization_audit_configuration.py +++ b/src/pytfe/resources/organization_audit_configuration.py @@ -51,7 +51,7 @@ def test(self, organization: str) -> OrganizationAuditConfigurationTest: if isinstance(attrs, dict) and "request-id" in attrs: return OrganizationAuditConfigurationTest.model_validate(attrs) - return OrganizationAuditConfigurationTest() + return OrganizationAuditConfigurationTest.model_validate({}) def update( self, From 9900d944b60d910b481eeca9824483a721db49bd Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Tue, 12 May 2026 19:27:51 +0530 Subject: [PATCH 51/95] 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 52/95] 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 53/95] 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 1555ed8797a6abe2808326a1593bc88677e96e8e Mon Sep 17 00:00:00 2001 From: Tanya Singh Date: Tue, 12 May 2026 20:32:39 +0530 Subject: [PATCH 54/95] feat: add Task Result API support with example and unit tests --- examples/task_result.py | 34 ++++++++ src/pytfe/client.py | 2 + src/pytfe/models/task_result.py | 69 ++++++++++++++++ src/pytfe/models/task_stage.py | 2 +- src/pytfe/resources/task_result.py | 31 +++++++ tests/units/test_task_results.py | 126 +++++++++++++++++++++++++++++ 6 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 examples/task_result.py create mode 100644 src/pytfe/models/task_result.py create mode 100644 src/pytfe/resources/task_result.py create mode 100644 tests/units/test_task_results.py diff --git a/examples/task_result.py b/examples/task_result.py new file mode 100644 index 00000000..483d329f --- /dev/null +++ b/examples/task_result.py @@ -0,0 +1,34 @@ +import os +from pytfe import TFEClient + + +def main(): + token = os.getenv("TFE_TOKEN") + task_result_id = os.getenv("TFE_TASK_RESULT_ID") + + if not token: + print("Set TFE_TOKEN") + return + + if not task_result_id: + print("Set TFE_TASK_RESULT_ID") + return + + client = TFEClient() + + try: + result = client.task_results.read(task_result_id) + + print("=== Task Result ===") + print(f"ID: {result.id}") + print(f"Status: {result.status}") + print(f"Message: {result.message}") + print(f"Task Name: {result.task_name}") + print(f"URL: {result.url}") + + except Exception as e: + print(f"Error: {e}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 0e88e1ab..1d218002 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -37,6 +37,7 @@ from .resources.ssh_keys import SSHKeys from .resources.state_version_outputs import StateVersionOutputs from .resources.state_versions import StateVersions +from .resources.task_result import TaskResults from .resources.user import Users from .resources.variable import Variables from .resources.variable_sets import VariableSets, VariableSetVariables @@ -76,6 +77,7 @@ def __init__(self, config: TFEConfig | None = None): self.organizations = Organizations(self._transport) self.organization_memberships = OrganizationMemberships(self._transport) self.users = Users(self._transport) + self.task_results = TaskResults(self._transport) self.organization_tokens = OrganizationTokens(self._transport) self.projects = Projects(self._transport) self.variables = Variables(self._transport) diff --git a/src/pytfe/models/task_result.py b/src/pytfe/models/task_result.py new file mode 100644 index 00000000..2b2cf7d9 --- /dev/null +++ b/src/pytfe/models/task_result.py @@ -0,0 +1,69 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + +# Reuse, do NOT duplicate +from pytfe.models.task_stage import TaskStage + + +class TaskResultStatus(str, Enum): + passed = "passed" + failed = "failed" + pending = "pending" + running = "running" + unreachable = "unreachable" + errored = "errored" + + +class TaskEnforcementLevel(str, Enum): + advisory = "advisory" + mandatory = "mandatory" + + +class TaskResultStatusTimestamps(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + errored_at: Optional[datetime] = Field(None, alias="errored-at") + running_at: Optional[datetime] = Field(None, alias="running-at") + canceled_at: Optional[datetime] = Field(None, alias="canceled-at") + failed_at: Optional[datetime] = Field(None, alias="failed-at") + passed_at: Optional[datetime] = Field(None, alias="passed-at") + + +class TaskResult(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + id: str + + status: Optional[TaskResultStatus] = Field(None, alias="status") + message: Optional[str] = Field(None, alias="message") + + status_timestamps: Optional[TaskResultStatusTimestamps] = Field( + None, alias="status-timestamps" + ) + + url: Optional[str] = Field(None, alias="url") + + created_at: Optional[datetime] = Field(None, alias="created-at") + updated_at: Optional[datetime] = Field(None, alias="updated-at") + + task_id: Optional[str] = Field(None, alias="task-id") + task_name: Optional[str] = Field(None, alias="task-name") + task_url: Optional[str] = Field(None, alias="task-url") + + workspace_task_id: Optional[str] = Field(None, alias="workspace-task-id") + workspace_task_enforcement_level: Optional[TaskEnforcementLevel] = Field( + None, alias="workspace-task-enforcement-level" + ) + + agent_pool_id: Optional[str] = Field(None, alias="agent-pool-id") + + # Relation (matches Go: *TaskStage) + task_stage: Optional[TaskStage] = Field(None, alias="task-stage") \ No newline at end of file diff --git a/src/pytfe/models/task_stage.py b/src/pytfe/models/task_stage.py index 54b9346b..bae0766b 100644 --- a/src/pytfe/models/task_stage.py +++ b/src/pytfe/models/task_stage.py @@ -8,7 +8,7 @@ # TaskStage represents a HCP Terraform or Terraform Enterprise run's stage where run tasks can occur class TaskStage(BaseModel): - model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + model_config = ConfigDict(populate_by_name=True) id: str # stage: Stage = Field(..., alias="stage") diff --git a/src/pytfe/resources/task_result.py b/src/pytfe/resources/task_result.py new file mode 100644 index 00000000..2882f39b --- /dev/null +++ b/src/pytfe/resources/task_result.py @@ -0,0 +1,31 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from typing import Any + +from pytfe.models.task_result import TaskResult +from pytfe.utils import valid_string_id +from ._base import _Service + + +class TaskResults(_Service): + def read(self, task_result_id: str) -> TaskResult: + if not valid_string_id(task_result_id): + raise ValueError("Invalid task_result_id") + + path = f"/api/v2/task-results/{task_result_id}" + + response = self.t.request("GET", path) + data = response.json() + + if "data" not in data: + raise ValueError("Invalid response format") + + return self._parse_task_result(data["data"]) + + def _parse_task_result(self, data: dict[str, Any]) -> TaskResult: + attributes = data.get("attributes", {}) + + attributes["id"] = data.get("id") + + return TaskResult(**attributes) \ No newline at end of file diff --git a/tests/units/test_task_results.py b/tests/units/test_task_results.py new file mode 100644 index 00000000..11c57f29 --- /dev/null +++ b/tests/units/test_task_results.py @@ -0,0 +1,126 @@ +import pytest +from unittest.mock import Mock + +from pytfe.resources.task_result import TaskResults +from pytfe.models.task_result import TaskResult + + +class TestTaskResults: + @pytest.fixture + def mock_transport(self): + return Mock() + + @pytest.fixture + def service(self, mock_transport): + return TaskResults(mock_transport) + + def test_read_success(self, service, mock_transport): + response = Mock() + response.json.return_value = { + "data": { + "id": "tr-123", + "attributes": { + "status": "passed", + "message": "ok", + "status-timestamps": {}, + "url": "url", + "created-at": "2024-01-01T00:00:00Z", + "updated-at": "2024-01-01T00:00:00Z", + "task-id": "t1", + "task-name": "name", + "task-url": "url", + "workspace-task-id": "wt1", + "workspace-task-enforcement-level": "advisory", + "agent-pool-id": None, + }, + } + } + mock_transport.request.return_value = response + + result = service.read("tr-123") + + assert isinstance(result, TaskResult) + assert result.id == "tr-123" + assert result.status == "passed" + + def test_invalid_id(self, service): + with pytest.raises(ValueError): + service.read("") + + def test_missing_data(self, service, mock_transport): + response = Mock() + response.json.return_value = {} + + mock_transport.request.return_value = response + + with pytest.raises(ValueError): + service.read("tr-123") + + def test_missing_attributes(self, service, mock_transport): + response = Mock() + response.json.return_value = { + "data": {"id": "tr-123"} + } + + mock_transport.request.return_value = response + + result = service.read("tr-123") + + assert result.id == "tr-123" + + def test_optional_fields(self, service, mock_transport): + response = Mock() + response.json.return_value = { + "data": { + "id": "tr-123", + "attributes": { + "status": "passed", + "message": None, + }, + } + } + + mock_transport.request.return_value = response + + result = service.read("tr-123") + + assert result.message is None + + def test_status_enum(self, service, mock_transport): + response = Mock() + response.json.return_value = { + "data": { + "id": "tr-123", + "attributes": { + "status": "failed", + "message": "fail", + }, + } + } + + mock_transport.request.return_value = response + + result = service.read("tr-123") + + assert result.status == "failed" + + def test_timestamps_parsing(self, service, mock_transport): + response = Mock() + response.json.return_value = { + "data": { + "id": "tr-123", + "attributes": { + "status": "passed", + "message": "ok", + "status-timestamps": { + "passed-at": "2024-01-01T00:00:00Z" + }, + }, + } + } + + mock_transport.request.return_value = response + + result = service.read("tr-123") + + assert result.status_timestamps is not None \ No newline at end of file From 94261825c285b946f61171a01825d7c9449ccd99 Mon Sep 17 00:00:00 2001 From: Tanya Singh Date: Tue, 12 May 2026 21:23:57 +0530 Subject: [PATCH 55/95] fix: address lint issues in task result implementation --- examples/task_result.py | 3 ++- src/pytfe/models/task_result.py | 37 +++++++++++++++--------------- src/pytfe/resources/task_result.py | 3 ++- tests/units/test_task_results.py | 15 +++++------- 4 files changed, 28 insertions(+), 30 deletions(-) diff --git a/examples/task_result.py b/examples/task_result.py index 483d329f..c24619b8 100644 --- a/examples/task_result.py +++ b/examples/task_result.py @@ -1,4 +1,5 @@ import os + from pytfe import TFEClient @@ -31,4 +32,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/src/pytfe/models/task_result.py b/src/pytfe/models/task_result.py index 2b2cf7d9..516da0aa 100644 --- a/src/pytfe/models/task_result.py +++ b/src/pytfe/models/task_result.py @@ -5,7 +5,6 @@ from datetime import datetime from enum import Enum -from typing import Optional from pydantic import BaseModel, ConfigDict, Field @@ -30,11 +29,11 @@ class TaskEnforcementLevel(str, Enum): class TaskResultStatusTimestamps(BaseModel): model_config = ConfigDict(populate_by_name=True) - errored_at: Optional[datetime] = Field(None, alias="errored-at") - running_at: Optional[datetime] = Field(None, alias="running-at") - canceled_at: Optional[datetime] = Field(None, alias="canceled-at") - failed_at: Optional[datetime] = Field(None, alias="failed-at") - passed_at: Optional[datetime] = Field(None, alias="passed-at") + errored_at: datetime | None = Field(None, alias="errored-at") + running_at: datetime | None = Field(None, alias="running-at") + canceled_at: datetime | None = Field(None, alias="canceled-at") + failed_at: datetime | None = Field(None, alias="failed-at") + passed_at: datetime | None = Field(None, alias="passed-at") class TaskResult(BaseModel): @@ -42,28 +41,28 @@ class TaskResult(BaseModel): id: str - status: Optional[TaskResultStatus] = Field(None, alias="status") - message: Optional[str] = Field(None, alias="message") + status: TaskResultStatus | None = Field(None, alias="status") + message: str | None = Field(None, alias="message") - status_timestamps: Optional[TaskResultStatusTimestamps] = Field( + status_timestamps: TaskResultStatusTimestamps | None = Field( None, alias="status-timestamps" ) - url: Optional[str] = Field(None, alias="url") + url: str | None = Field(None, alias="url") - created_at: Optional[datetime] = Field(None, alias="created-at") - updated_at: Optional[datetime] = Field(None, alias="updated-at") + created_at: datetime | None = Field(None, alias="created-at") + updated_at: datetime | None = Field(None, alias="updated-at") - task_id: Optional[str] = Field(None, alias="task-id") - task_name: Optional[str] = Field(None, alias="task-name") - task_url: Optional[str] = Field(None, alias="task-url") + task_id: str | None = Field(None, alias="task-id") + task_name: str | None = Field(None, alias="task-name") + task_url: str | None = Field(None, alias="task-url") - workspace_task_id: Optional[str] = Field(None, alias="workspace-task-id") - workspace_task_enforcement_level: Optional[TaskEnforcementLevel] = Field( + workspace_task_id: str | None = Field(None, alias="workspace-task-id") + workspace_task_enforcement_level: TaskEnforcementLevel | None = Field( None, alias="workspace-task-enforcement-level" ) - agent_pool_id: Optional[str] = Field(None, alias="agent-pool-id") + agent_pool_id: str | None = Field(None, alias="agent-pool-id") # Relation (matches Go: *TaskStage) - task_stage: Optional[TaskStage] = Field(None, alias="task-stage") \ No newline at end of file + task_stage: TaskStage | None = Field(None, alias="task-stage") diff --git a/src/pytfe/resources/task_result.py b/src/pytfe/resources/task_result.py index 2882f39b..7640d018 100644 --- a/src/pytfe/resources/task_result.py +++ b/src/pytfe/resources/task_result.py @@ -5,6 +5,7 @@ from pytfe.models.task_result import TaskResult from pytfe.utils import valid_string_id + from ._base import _Service @@ -28,4 +29,4 @@ def _parse_task_result(self, data: dict[str, Any]) -> TaskResult: attributes["id"] = data.get("id") - return TaskResult(**attributes) \ No newline at end of file + return TaskResult(**attributes) diff --git a/tests/units/test_task_results.py b/tests/units/test_task_results.py index 11c57f29..ff134fc9 100644 --- a/tests/units/test_task_results.py +++ b/tests/units/test_task_results.py @@ -1,8 +1,9 @@ -import pytest from unittest.mock import Mock -from pytfe.resources.task_result import TaskResults +import pytest + from pytfe.models.task_result import TaskResult +from pytfe.resources.task_result import TaskResults class TestTaskResults: @@ -58,9 +59,7 @@ def test_missing_data(self, service, mock_transport): def test_missing_attributes(self, service, mock_transport): response = Mock() - response.json.return_value = { - "data": {"id": "tr-123"} - } + response.json.return_value = {"data": {"id": "tr-123"}} mock_transport.request.return_value = response @@ -112,9 +111,7 @@ def test_timestamps_parsing(self, service, mock_transport): "attributes": { "status": "passed", "message": "ok", - "status-timestamps": { - "passed-at": "2024-01-01T00:00:00Z" - }, + "status-timestamps": {"passed-at": "2024-01-01T00:00:00Z"}, }, } } @@ -123,4 +120,4 @@ def test_timestamps_parsing(self, service, mock_transport): result = service.read("tr-123") - assert result.status_timestamps is not None \ No newline at end of file + assert result.status_timestamps is not None From 00afd13da0b6fa23c33be978c83efc363ac41689 Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Thu, 14 May 2026 12:46:16 +0530 Subject: [PATCH 56/95] refactor organization tags list API to iterator pattern --- examples/organization_tags.py | 123 ++++++++++++++--------- src/pytfe/models/organization_tags.py | 10 -- src/pytfe/resources/organization_tags.py | 36 ++----- tests/units/test_organization_tags.py | 79 +++++---------- 4 files changed, 114 insertions(+), 134 deletions(-) diff --git a/examples/organization_tags.py b/examples/organization_tags.py index 96ef0df6..553cdfc7 100644 --- a/examples/organization_tags.py +++ b/examples/organization_tags.py @@ -1,82 +1,113 @@ #!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Organization tags operations example. Demonstrates: -1. list() - list tags in an organization +1. list() - list tags in an organization +2. add_workspaces() - associate a workspace with a tag +3. delete() - delete a tag from an organization -This phase intentionally uses only organization-level parameters. -Tag IDs and workspace IDs can be passed in a later phase. +Usage: + python examples/organization_tags.py --org my-org + python examples/organization_tags.py --org my-org --tag-id tag-abc123 --workspace-id ws-xyz """ +from __future__ import annotations + +import argparse import os from pytfe import TFEClient, TFEConfig from pytfe.errors import TFEError -from pytfe.models.organization_tags import ( - AddWorkspacesToTagOptions, - OrganizationTagsDeleteOptions, -) +from pytfe.models.organization_tags import AddWorkspacesToTagOptions, OrganizationTagsDeleteOptions def main() -> None: - client = TFEClient(TFEConfig.from_env()) + parser = argparse.ArgumentParser(description="Organization Tags demo for python-tfe SDK") + parser.add_argument( + "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") + ) + parser.add_argument("--token", default=os.getenv("TFE_TOKEN", "")) + parser.add_argument( + "--org", + default=os.getenv("TFE_ORG", ""), + help="Organization name", + ) + parser.add_argument( + "--tag-id", + default=os.getenv("TFE_TAG_ID", ""), + help="Tag ID for add/delete operations", + ) + parser.add_argument( + "--workspace-id", + default=os.getenv("TFE_WORKSPACE_ID", ""), + help="Workspace ID to associate with tag", + ) + args = parser.parse_args() + + if not args.token: + print("Error: TFE_TOKEN environment variable or --token required") + return - organization_name = os.getenv("TFE_ORG", "example-org") - tag_id = os.getenv("TFE_TAG_ID", "") - workspace_id = os.getenv("TFE_WORKSPACE_ID", "") - operation = "list" + if not args.org: + print("Error: TFE_ORG environment variable or --org required") + return + cfg = TFEConfig(address=args.address, token=args.token) + client = TFEClient(cfg) + + # 1) List tags try: print("[LIST] Listing organization tags") - print(f"[LIST] organization={organization_name}") - tags = client.organization_tags.list(organization_name) - print(f"[LIST] total_tags={len(tags.items)}") - for item in tags.items: + print(f"[LIST] organization={args.org}") + tags = list(client.organization_tags.list(args.org)) + print(f"[LIST] total_tags={len(tags)}") + for tag in tags: print( - f"[LIST] id={item.id}, name={item.name}, instance_count={item.instance_count}" + f"[LIST] id={tag.id}, name={tag.name}, instance_count={tag.instance_count}" ) + if not tags: + print("[LIST] no tags found") + except TFEError as exc: + print(f"[LIST] API error: {exc}") + return - # Guard: ensure env vars are set - if not tag_id or not workspace_id: - print("Skipping add/delete: set TFE_TAG_ID and TFE_WORKSPACE_ID first.") - return + if not args.tag_id: + print("[ADD_WORKSPACES] skipped: set --tag-id or TFE_TAG_ID") + print("[DELETE] skipped: set --tag-id or TFE_TAG_ID") + return - # ---- Add workspace ---- - operation = "add_workspaces" + # 2) Add workspace to tag + if args.workspace_id: print("[ADD_WORKSPACES] Associating a workspace to a tag") print( - f"[ADD_WORKSPACES] organization={organization_name}, tag_id={tag_id}, workspace_id={workspace_id}" + f"[ADD_WORKSPACES] organization={args.org}, tag_id={args.tag_id}, workspace_id={args.workspace_id}" ) try: client.organization_tags.add_workspaces( - organization_name, - tag_id, - AddWorkspacesToTagOptions(workspace_ids=[workspace_id]), + args.org, + args.tag_id, + AddWorkspacesToTagOptions(workspace_ids=[args.workspace_id]), ) print("[ADD_WORKSPACES] workspace associated") except TFEError as exc: print(f"[ADD_WORKSPACES] API error: {exc}") - print(f"[ADD_WORKSPACES] failed operation={operation}") + else: + print("[ADD_WORKSPACES] skipped: set --workspace-id or TFE_WORKSPACE_ID") - # ---- Delete tag ---- - operation = "delete" - print("[DELETE] Deleting a tag from the organization") - print(f"[DELETE] organization={organization_name}, tag_id={tag_id}") - try: - client.organization_tags.delete( - organization_name, - OrganizationTagsDeleteOptions(ids=[tag_id]), - ) - print("[DELETE] tag deleted") - except TFEError as exc: - print(f"[DELETE] API error: {exc}") - print(f"[DELETE] failed operation={operation}") + # 3) Delete tag + print("[DELETE] Deleting a tag from the organization") + print(f"[DELETE] organization={args.org}, tag_id={args.tag_id}") + try: + client.organization_tags.delete( + args.org, + OrganizationTagsDeleteOptions(ids=[args.tag_id]), + ) + print("[DELETE] tag deleted") except TFEError as exc: - print(f"API error: {exc}") - print(f"Failed during operation: {operation}") - print("Check TFE_TOKEN, TFE_ADDRESS, and organization/tag/workspace IDs.") - finally: - client.close() + print(f"[DELETE] API error: {exc}") if __name__ == "__main__": diff --git a/src/pytfe/models/organization_tags.py b/src/pytfe/models/organization_tags.py index 957e5384..1bb1e477 100644 --- a/src/pytfe/models/organization_tags.py +++ b/src/pytfe/models/organization_tags.py @@ -5,7 +5,6 @@ from pydantic import BaseModel, ConfigDict, Field -from .common import Pagination from .organization import Organization @@ -27,15 +26,6 @@ class OrganizationTag(BaseModel): ) -class OrganizationTagsList(BaseModel): - """Represents a list response for organization tags.""" - - model_config = ConfigDict(extra="forbid") - - pagination: Pagination | None = Field(None) - items: list[OrganizationTag] = Field(default_factory=list) - - class OrganizationTagsListOptions(BaseModel): """Options for listing organization tags.""" diff --git a/src/pytfe/resources/organization_tags.py b/src/pytfe/resources/organization_tags.py index 51eae9ea..2537c22d 100644 --- a/src/pytfe/resources/organization_tags.py +++ b/src/pytfe/resources/organization_tags.py @@ -3,19 +3,18 @@ from __future__ import annotations +from collections.abc import Iterator from typing import Any from urllib.parse import quote from ..errors import ( ERR_INVALID_ORG, ) -from ..models.common import Pagination from ..models.organization import Organization from ..models.organization_tags import ( AddWorkspacesToTagOptions, OrganizationTag, OrganizationTagsDeleteOptions, - OrganizationTagsList, OrganizationTagsListOptions, ) from ..utils import valid_string_id @@ -33,34 +32,21 @@ def list( self, organization: str, options: OrganizationTagsListOptions | None = None, - ) -> OrganizationTagsList: + ) -> Iterator[OrganizationTag]: """List all tags within an organization.""" if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) + return self._iter_tags(organization, options) + def _iter_tags( + self, + organization: str, + options: OrganizationTagsListOptions | None = None, + ) -> Iterator[OrganizationTag]: path = f"/api/v2/organizations/{quote(organization)}/tags" - params = ( - options.model_dump(by_alias=True, exclude_none=True) if options else None - ) - - response = self.t.request("GET", path, params=params) - payload = response.json() or {} - - items = [self._parse_organization_tag(item) for item in payload.get("data", [])] - - pagination = None - meta = payload.get("meta", {}) - pagination_data = meta.get("pagination", {}) if isinstance(meta, dict) else {} - if pagination_data: - pagination = Pagination( - current_page=pagination_data.get("current-page", 1), - total_count=pagination_data.get("total-count", len(items)), - previous_page=pagination_data.get("previous-page"), - next_page=pagination_data.get("next-page"), - total_pages=pagination_data.get("total-pages"), - ) - - return OrganizationTagsList(pagination=pagination, items=items) + params = options.model_dump(by_alias=True, exclude_none=True) if options else {} + for item in self._list(path, params=params): + yield self._parse_organization_tag(item) def delete( self, diff --git a/tests/units/test_organization_tags.py b/tests/units/test_organization_tags.py index 68ebf9fd..e9bab48b 100644 --- a/tests/units/test_organization_tags.py +++ b/tests/units/test_organization_tags.py @@ -1,13 +1,12 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + """Unit tests for the organization tags module.""" -import os -import sys from unittest.mock import Mock, patch import pytest -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) - from pytfe._http import HTTPTransport from pytfe.errors import ( ERR_INVALID_ORG, @@ -15,7 +14,6 @@ from pytfe.models.organization_tags import ( AddWorkspacesToTagOptions, OrganizationTagsDeleteOptions, - OrganizationTagsList, OrganizationTagsListOptions, ) from pytfe.resources.organization_tags import OrganizationTags @@ -37,56 +35,31 @@ def organization_tags_service(self, mock_transport): return OrganizationTags(mock_transport) def test_list_success(self, organization_tags_service): - mock_response_data = { - "data": [ - { - "id": "tag-1", - "attributes": { - "name": "env:dev", - "instance-count": 2, - }, - "relationships": { - "organization": { - "data": {"id": "org-1", "type": "organizations"} - } - }, - } - ], - "meta": { - "pagination": { - "current-page": 1, - "total-count": 1, - "next-page": None, - "previous-page": None, - "total-pages": 1, - } - }, - } - - mock_response = Mock() - mock_response.json.return_value = mock_response_data - - with patch.object(organization_tags_service, "t") as mock_t: - mock_t.request.return_value = mock_response + mock_items = [ + { + "id": "tag-1", + "attributes": { + "name": "env:dev", + "instance-count": 2, + }, + "relationships": { + "organization": { + "data": {"id": "org-1", "type": "organizations"} + } + }, + } + ] + with patch.object(organization_tags_service, "_list", return_value=iter(mock_items)): options = OrganizationTagsListOptions(query="env") - result = organization_tags_service.list("test-org", options) - - assert isinstance(result, OrganizationTagsList) - assert len(result.items) == 1 - assert result.items[0].id == "tag-1" - assert result.items[0].name == "env:dev" - assert result.items[0].instance_count == 2 - assert result.items[0].organization is not None - assert result.items[0].organization.id == "org-1" - assert result.pagination is not None - assert result.pagination.current_page == 1 - assert result.pagination.total_count == 1 - - call_args = mock_t.request.call_args - assert call_args[0][0] == "GET" - assert call_args[0][1] == "/api/v2/organizations/test-org/tags" - assert call_args[1]["params"]["q"] == "env" + result = list(organization_tags_service.list("test-org", options)) + + assert len(result) == 1 + assert result[0].id == "tag-1" + assert result[0].name == "env:dev" + assert result[0].instance_count == 2 + assert result[0].organization is not None + assert result[0].organization.id == "org-1" def test_list_validation_errors(self, organization_tags_service): with pytest.raises(ValueError, match=ERR_INVALID_ORG): From 6c9f838571b094c84d8a630106afea6035684aad Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Thu, 14 May 2026 12:48:58 +0530 Subject: [PATCH 57/95] format organization tags files --- examples/organization_tags.py | 9 +++++++-- tests/units/test_organization_tags.py | 8 ++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/examples/organization_tags.py b/examples/organization_tags.py index 553cdfc7..c562f573 100644 --- a/examples/organization_tags.py +++ b/examples/organization_tags.py @@ -21,11 +21,16 @@ from pytfe import TFEClient, TFEConfig from pytfe.errors import TFEError -from pytfe.models.organization_tags import AddWorkspacesToTagOptions, OrganizationTagsDeleteOptions +from pytfe.models.organization_tags import ( + AddWorkspacesToTagOptions, + OrganizationTagsDeleteOptions, +) def main() -> None: - parser = argparse.ArgumentParser(description="Organization Tags demo for python-tfe SDK") + parser = argparse.ArgumentParser( + description="Organization Tags demo for python-tfe SDK" + ) parser.add_argument( "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") ) diff --git a/tests/units/test_organization_tags.py b/tests/units/test_organization_tags.py index e9bab48b..af5a1103 100644 --- a/tests/units/test_organization_tags.py +++ b/tests/units/test_organization_tags.py @@ -43,14 +43,14 @@ def test_list_success(self, organization_tags_service): "instance-count": 2, }, "relationships": { - "organization": { - "data": {"id": "org-1", "type": "organizations"} - } + "organization": {"data": {"id": "org-1", "type": "organizations"}} }, } ] - with patch.object(organization_tags_service, "_list", return_value=iter(mock_items)): + with patch.object( + organization_tags_service, "_list", return_value=iter(mock_items) + ): options = OrganizationTagsListOptions(query="env") result = list(organization_tags_service.list("test-org", options)) From 70d25f948ea1c3d0c13fc2b0d9c5089eb6fe39ed Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Thu, 14 May 2026 16:43:16 +0530 Subject: [PATCH 58/95] move organization tag errors to shared errors module --- src/pytfe/errors.py | 5 +++++ src/pytfe/resources/organization_tags.py | 7 +++---- tests/units/test_organization_tags.py | 7 +++---- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index f2340af3..e9992da0 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -121,6 +121,11 @@ class ErrStateVersionUploadNotSupported(TFEError): ... ERR_REQUIRED_TAG_KEY = "tag key is required" ERR_INVALID_TAG_KEY = "invalid tag key" +# Organization Tag Error Constants +ERR_INVALID_TAG = "invalid value for tag" +ERR_REQUIRED_TAG_ID = "tag ID is required" +ERR_REQUIRED_TAG_WORKSPACE_ID = "workspace ID is required" + class WorkspaceNotFound(NotFound): ... diff --git a/src/pytfe/resources/organization_tags.py b/src/pytfe/resources/organization_tags.py index 2537c22d..a0927959 100644 --- a/src/pytfe/resources/organization_tags.py +++ b/src/pytfe/resources/organization_tags.py @@ -9,6 +9,9 @@ from ..errors import ( ERR_INVALID_ORG, + ERR_INVALID_TAG, + ERR_REQUIRED_TAG_ID, + ERR_REQUIRED_TAG_WORKSPACE_ID, ) from ..models.organization import Organization from ..models.organization_tags import ( @@ -20,10 +23,6 @@ from ..utils import valid_string_id from ._base import _Service -ERR_INVALID_TAG = "invalid value for tag" -ERR_REQUIRED_TAG_ID = "tag ID is required" -ERR_REQUIRED_TAG_WORKSPACE_ID = "workspace ID is required" - class OrganizationTags(_Service): """Organization tags service for Terraform Enterprise.""" diff --git a/tests/units/test_organization_tags.py b/tests/units/test_organization_tags.py index af5a1103..ee23affc 100644 --- a/tests/units/test_organization_tags.py +++ b/tests/units/test_organization_tags.py @@ -10,6 +10,9 @@ from pytfe._http import HTTPTransport from pytfe.errors import ( ERR_INVALID_ORG, + ERR_INVALID_TAG, + ERR_REQUIRED_TAG_ID, + ERR_REQUIRED_TAG_WORKSPACE_ID, ) from pytfe.models.organization_tags import ( AddWorkspacesToTagOptions, @@ -18,10 +21,6 @@ ) from pytfe.resources.organization_tags import OrganizationTags -ERR_INVALID_TAG = "invalid value for tag" -ERR_REQUIRED_TAG_ID = "tag ID is required" -ERR_REQUIRED_TAG_WORKSPACE_ID = "workspace ID is required" - class TestOrganizationTags: """Test the OrganizationTags service class.""" From 528ea2ad993b45455be3f0aa1fc01d9c8110d5df Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Fri, 15 May 2026 12:32:58 +0530 Subject: [PATCH 59/95] feat(team-token): Added models for the team token resource --- src/pytfe/models/__init__.py | 11 +++++++ src/pytfe/models/team_token.py | 56 ++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 src/pytfe/models/team_token.py diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index fd263c1d..764c0dab 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -358,6 +358,12 @@ TeamPermissions, TeamUpdateOptions, ) +from .team_token import ( + CreatedByChoice, + TeamToken, + TeamTokenCreateOptions, + TeamTokenListOptions, +) # Variables from .variable import ( @@ -588,6 +594,11 @@ "TeamIncludeOpt", "TeamListOptions", "TeamUpdateOptions", + # Team Tokens + "CreatedByChoice", + "TeamToken", + "TeamTokenCreateOptions", + "TeamTokenListOptions", "Project", "ProjectAddTagBindingsOptions", "ProjectCreateOptions", diff --git a/src/pytfe/models/team_token.py b/src/pytfe/models/team_token.py new file mode 100644 index 00000000..b8d23df7 --- /dev/null +++ b/src/pytfe/models/team_token.py @@ -0,0 +1,56 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + +from .organization import Organization +from .team import Team +from .user import User + + +class TeamToken(BaseModel): + """TeamToken represents a Terraform Enterprise team token.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str + created_at: datetime | None = Field(default=None, alias="created-at") + last_used_at: datetime | None = Field(default=None, alias="last-used-at") + description: str | None = Field(default=None, alias="description") + token: str | None = Field(default=None, alias="token") + expired_at: datetime | None = Field(default=None, alias="expired-at") + + # Relations + team: Team | None = None + created_by: CreatedByChoice | None = Field(default=None, alias="created-by") + + +class TeamTokenCreateOptions(BaseModel): + """TeamTokenCreateOptions contains the options for creating a team token.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + description: str | None = Field(default=None, alias="description") + expired_at: datetime | None = Field(default=None, alias="expired-at") + + +class TeamTokenListOptions(BaseModel): + """TeamTokenListOptions contains the options for listing team tokens.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + page_size: int | None = Field(default=None, alias="page[size]") + query: str | None = Field(default=None, alias="q") + sort: str | None = Field(default=None, alias="sort") + + +class CreatedByChoice(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + organization: Organization | None = None + user: User | None = None + team: Team | None = None From 70086462238cb3fe202195e57e840ea720d99ad3 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Fri, 15 May 2026 12:34:24 +0530 Subject: [PATCH 60/95] feat(team-token): Added list, read, create and delete methods for both legacy and new multi-team tokens --- src/pytfe/client.py | 2 + src/pytfe/errors.py | 8 ++ src/pytfe/resources/team_token.py | 153 ++++++++++++++++++++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 src/pytfe/resources/team_token.py diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 4aead338..1e71bdee 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -43,6 +43,7 @@ from .resources.state_versions import StateVersions from .resources.team import Teams from .resources.team_project_access import TeamProjectAccesses +from .resources.team_token import TeamTokens from .resources.user import Users from .resources.variable import Variables from .resources.variable_sets import VariableSets, VariableSetVariables @@ -125,6 +126,7 @@ def __init__(self, config: TFEConfig | None = None): # Team project access self.teams = Teams(self._transport) self.team_project_accesses = TeamProjectAccesses(self._transport) + self.team_tokens = TeamTokens(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 1209a552..efc6305f 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -664,3 +664,11 @@ class RequiredCommentBodyError(TFEError): def __init__(self, message: str = "comment body is required"): super().__init__(message) + + +# Team Token errors +class InvalidTokenIDError(InvalidValues): + """Raised when an invalid authentication token ID is provided.""" + + def __init__(self, message: str = "invalid value for token ID"): + super().__init__(message) diff --git a/src/pytfe/resources/team_token.py b/src/pytfe/resources/team_token.py new file mode 100644 index 00000000..e4ab0808 --- /dev/null +++ b/src/pytfe/resources/team_token.py @@ -0,0 +1,153 @@ +# 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 InvalidOrgError, InvalidTeamIDError, InvalidTokenIDError +from ..models.organization import Organization +from ..models.team import Team +from ..models.team_token import ( + CreatedByChoice, + TeamToken, + TeamTokenCreateOptions, + TeamTokenListOptions, +) +from ..models.user import User +from ..utils import valid_string_id +from ._base import _Service + + +class TeamTokens(_Service): + """Service for managing team authentication tokens.""" + + def create(self, team_id: str) -> TeamToken: + """ + Create a new team token using the legacy creation behavior, which creates a token without a description + or regenerates the existing, descriptionless token. + """ + return self.create_with_options(team_id=team_id) + + def create_with_options( + self, + team_id: str, + options: TeamTokenCreateOptions | None = None, + ) -> TeamToken: + """ + CreateWithOptions creates a team token, with options. If no description is provided, it uses the legacy + creation behavior, which regenerates the descriptionless token if it already exists. Otherwise, it create + a new token with the given unique description, allowing for the creation of multiple team tokens. + """ + if not valid_string_id(team_id): + raise InvalidTeamIDError() + + opts = options or TeamTokenCreateOptions() + + if opts.description: + # New multi-token endpoint + path = f"/api/v2/teams/{team_id}/authentication-tokens" + payload_type = "authentication-tokens" + else: + # Legacy single-token endpoint + path = f"/api/v2/teams/{team_id}/authentication-token" + payload_type = "authentication-token" + + attributes: dict[str, Any] = opts.model_dump( + by_alias=True, + exclude_none=True, + exclude={"description"} if not opts.description else set(), + mode="json", + ) + + payload = { + "data": { + "type": payload_type, + "attributes": attributes, + } + } + r = self.t.request("POST", path=path, json_body=payload) + data = r.json().get("data", {}) + return self._team_token_from(data) + + + + def read(self, team_id: str) -> TeamToken: + """Read the legacy (descriptionless) team token by team ID.""" + if not valid_string_id(team_id): + raise InvalidTeamIDError() + r = self.t.request("GET", path=f"/api/v2/teams/{team_id}/authentication-token") + data = r.json().get("data", {}) + return self._team_token_from(data) + + def read_by_id(self, token_id: str) -> TeamToken: + """Read a team token by its token ID.""" + if not valid_string_id(token_id): + raise InvalidTokenIDError() + r = self.t.request("GET", path=f"/api/v2/authentication-tokens/{token_id}") + data = r.json().get("data", {}) + return self._team_token_from(data) + + def list( + self, + organization: str, + options: TeamTokenListOptions | None = None, + ) -> Iterator[TeamToken]: + """List all team tokens for the given organization.""" + if not valid_string_id(organization): + raise InvalidOrgError() + path = f"/api/v2/organizations/{organization}/team-tokens" + params: dict[str, Any] = {} + if options: + if options.page_size is not None: + params["page[size]"] = options.page_size + if options.query: + params["q"] = options.query + if options.sort: + params["sort"] = options.sort + for item in self._list(path=path, params=params): + yield self._team_token_from(item) + + def delete(self, team_id: str) -> None: + """Delete the legacy team token by team ID.""" + if not valid_string_id(team_id): + raise InvalidTeamIDError() + self.t.request("DELETE", path=f"/api/v2/teams/{team_id}/authentication-token") + return None + + def delete_by_id(self, token_id: str) -> None: + """Delete a team token by its token ID.""" + if not valid_string_id(token_id): + raise InvalidTokenIDError() + self.t.request("DELETE", path=f"/api/v2/authentication-tokens/{token_id}") + return None + + def _team_token_from(self, data: dict[str, Any]) -> TeamToken: + """Parse a TeamToken from API response data.""" + attrs = dict(data.get("attributes", {})) + attrs["id"] = data.get("id") + relationships = data.get("relationships", {}) + + team_data = relationships.get("team", {}).get("data") + if team_data and team_data.get("id"): + attrs["team"] = Team.model_construct( + id=team_data["id"], + ) + + created_by_data = relationships.get("created-by", {}).get("data") + if created_by_data and created_by_data.get("id"): + if created_by_data.get("type") == "users": + attrs["created-by"] = CreatedByChoice( + user=User.model_construct(id=created_by_data["id"]) + ) + elif created_by_data.get("type") == "teams": + attrs["created-by"] = CreatedByChoice( + team=Team.model_construct(id=created_by_data["id"]) + ) + elif created_by_data.get("type") == "organizations": + attrs["created-by"] = CreatedByChoice( + organization=Organization.model_construct(id=created_by_data["id"]) + ) + + return TeamToken.model_validate(attrs) From 45b5f1b93a88a6ba1998d01f533951a1e9e46b94 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Fri, 15 May 2026 12:34:58 +0530 Subject: [PATCH 61/95] feat(team-token): Added examples and unit tests for team token --- examples/team_token.py | 151 +++++++++++++++ tests/units/test_team_token.py | 334 +++++++++++++++++++++++++++++++++ 2 files changed, 485 insertions(+) create mode 100644 examples/team_token.py create mode 100644 tests/units/test_team_token.py diff --git a/examples/team_token.py b/examples/team_token.py new file mode 100644 index 00000000..f932a672 --- /dev/null +++ b/examples/team_token.py @@ -0,0 +1,151 @@ +# 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 TeamTokenCreateOptions, TeamTokenListOptions + + +def _print_header(title: str): + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def _print_token(token): + print(f"- ID: {token.id}") + if token.description: + print(f" Description: {token.description}") + print(f" Created At: {token.created_at}") + print(f" Last Used At: {token.last_used_at}") + print(f" Expired At: {token.expired_at}") + if token.team: + print(f" Team ID: {token.team.id}") + if token.created_by: + if token.created_by.user: + print(f" Created By (user): {token.created_by.user.id}") + elif token.created_by.team: + print(f" Created By (team): {token.created_by.team.id}") + elif token.created_by.organization: + print(f" Created By (org): {token.created_by.organization.id}") + print() + + +def main(): + parser = argparse.ArgumentParser(description="Team Tokens 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("--team-id", help="Team ID (e.g. team-xxxxx)") + parser.add_argument("--create", action="store_true", help="Create a team token") + parser.add_argument( + "--description", help="Token description (creates a named multi-token)" + ) + parser.add_argument( + "--expired-at", + help="Expiry datetime in ISO 8601 (e.g. 2026-12-31T00:00:00Z)", + ) + parser.add_argument( + "--read", action="store_true", help="Read the legacy token for --team-id" + ) + parser.add_argument( + "--read-by-id", action="store_true", help="Read a token by --token-id" + ) + parser.add_argument("--token-id", help="Token ID (e.g. at-xxxxx)") + parser.add_argument( + "--delete", action="store_true", help="Delete the legacy token for --team-id" + ) + parser.add_argument( + "--delete-by-id", + action="store_true", + help="Delete a token by --token-id", + ) + args = parser.parse_args() + + cfg = TFEConfig(address=args.address, token=args.token) + client = TFEClient(cfg) + + # 1) Always list tokens for the organization + _print_header(f"Listing team tokens for organization: {args.organization}") + list_opts = TeamTokenListOptions() + token_count = 0 + for t in client.team_tokens.list(organization=args.organization, options=list_opts): + token_count += 1 + _print_token(t) + + if token_count == 0: + print("No team tokens found.") + else: + print(f"Total: {token_count} team tokens") + + # 2) Create a team token + if args.create: + if not args.team_id: + print("--team-id is required for --create") + else: + from datetime import datetime + + if args.description or args.expired_at: + _print_header(f"Creating named team token for team: {args.team_id}") + create_opts = TeamTokenCreateOptions( + description=args.description, + expired_at=datetime.fromisoformat(args.expired_at) + if args.expired_at + else None, + ) + t = client.team_tokens.create_with_options( + team_id=args.team_id, options=create_opts + ) + else: + _print_header( + f"Creating legacy team token for team: {args.team_id}" + ) + t = client.team_tokens.create(team_id=args.team_id) + print("Created team token:") + _print_token(t) + + # 3) Read legacy token by team ID + if args.read: + if not args.team_id: + print("--team-id is required for --read") + else: + _print_header(f"Reading legacy token for team: {args.team_id}") + t = client.team_tokens.read(team_id=args.team_id) + _print_token(t) + + # 4) Read token by token ID + if args.read_by_id: + if not args.token_id: + print("--token-id is required for --read-by-id") + else: + _print_header(f"Reading token by ID: {args.token_id}") + t = client.team_tokens.read_by_id(token_id=args.token_id) + _print_token(t) + + # 5) Delete legacy token by team ID + if args.delete: + if not args.team_id: + print("--team-id is required for --delete") + else: + _print_header(f"Deleting legacy token for team: {args.team_id}") + client.team_tokens.delete(team_id=args.team_id) + print("Deleted.") + + # 6) Delete token by token ID + if args.delete_by_id: + if not args.token_id: + print("--token-id is required for --delete-by-id") + else: + _print_header(f"Deleting token by ID: {args.token_id}") + client.team_tokens.delete_by_id(token_id=args.token_id) + print("Deleted.") + + +if __name__ == "__main__": + main() diff --git a/tests/units/test_team_token.py b/tests/units/test_team_token.py new file mode 100644 index 00000000..57678671 --- /dev/null +++ b/tests/units/test_team_token.py @@ -0,0 +1,334 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the team_token module.""" + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import InvalidOrgError, InvalidTeamIDError, InvalidTokenIDError +from pytfe.models.team import Team +from pytfe.models.team_token import ( + CreatedByChoice, + TeamToken, + TeamTokenCreateOptions, + TeamTokenListOptions, +) +from pytfe.models.user import User +from pytfe.resources.team_token import TeamTokens + + +class TestTeamTokens: + """Test the TeamTokens 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 TeamTokens service with mocked transport.""" + return TeamTokens(mock_transport) + + @pytest.fixture + def token_api_data(self): + """Typical API response for a single team token (user created-by).""" + return { + "id": "at-abc123", + "type": "authentication-tokens", + "attributes": { + "created-at": "2026-05-01T10:00:00.000Z", + "last-used-at": None, + "description": "My token", + "token": "secret-token-value", + "expired-at": "2027-05-01T10:00:00.000Z", + }, + "relationships": { + "team": {"data": {"id": "team-xyz789", "type": "teams"}}, + "created-by": {"data": {"id": "user-111", "type": "users"}}, + }, + } + + @pytest.fixture + def token_api_data_team_creator(self): + """API response where the token was created by a team.""" + return { + "id": "at-team001", + "type": "authentication-tokens", + "attributes": { + "created-at": "2026-05-01T10:00:00.000Z", + "last-used-at": None, + "description": None, + "token": None, + "expired-at": None, + }, + "relationships": { + "team": {"data": {"id": "team-xyz789", "type": "teams"}}, + "created-by": {"data": {"id": "team-abc", "type": "teams"}}, + }, + } + + # ── Model tests ────────────────────────────────────────────────────────── + + def test_team_token_model_fields(self): + """TeamToken model stores all fields.""" + t = TeamToken(id="at-abc123", description="My token", token="secret") + assert t.id == "at-abc123" + assert t.description == "My token" + assert t.token == "secret" + + def test_team_token_defaults(self): + """TeamToken optional fields default to None.""" + t = TeamToken(id="at-min") + assert t.description is None + assert t.token is None + assert t.expired_at is None + assert t.last_used_at is None + assert t.team is None + assert t.created_by is None + + def test_create_options_defaults(self): + """TeamTokenCreateOptions defaults all fields to None.""" + opts = TeamTokenCreateOptions() + assert opts.description is None + assert opts.expired_at is None + + def test_create_options_with_description(self): + """TeamTokenCreateOptions stores description.""" + opts = TeamTokenCreateOptions(description="CI token") + assert opts.description == "CI token" + + def test_create_options_serializes_with_aliases(self): + """TeamTokenCreateOptions serialises with API aliases.""" + from datetime import datetime, timezone + + expiry = datetime(2027, 1, 1, tzinfo=timezone.utc) + opts = TeamTokenCreateOptions(description="Test", expired_at=expiry) + dumped = opts.model_dump(by_alias=True, exclude_none=True) + assert dumped["description"] == "Test" + assert "expired-at" in dumped + + def test_list_options(self): + """TeamTokenListOptions stores pagination and filter params.""" + opts = TeamTokenListOptions(page_size=10, query="my-team", sort="expired-at") + assert opts.page_size == 10 + assert opts.query == "my-team" + assert opts.sort == "expired-at" + + def test_created_by_choice_user(self): + """CreatedByChoice can hold a User.""" + u = User(id="user-123") + choice = CreatedByChoice(user=u) + assert choice.user.id == "user-123" + assert choice.team is None + assert choice.organization is None + + def test_created_by_choice_team(self): + """CreatedByChoice can hold a Team.""" + t = Team(id="team-abc") + choice = CreatedByChoice(team=t) + assert choice.team.id == "team-abc" + assert choice.user is None + + # ── Parser tests ───────────────────────────────────────────────────────── + + def test_team_token_from_full_data(self, service, token_api_data): + """_team_token_from parses attributes and typed relation stubs.""" + result = service._team_token_from(token_api_data) + + assert isinstance(result, TeamToken) + assert result.id == "at-abc123" + assert result.description == "My token" + assert result.token == "secret-token-value" + + # team relation is a typed Team stub + assert isinstance(result.team, Team) + assert result.team.id == "team-xyz789" + + # created_by is a CreatedByChoice wrapping a User stub + assert isinstance(result.created_by, CreatedByChoice) + assert isinstance(result.created_by.user, User) + assert result.created_by.user.id == "user-111" + + def test_team_token_from_team_creator(self, service, token_api_data_team_creator): + """_team_token_from handles team-type created-by relation.""" + result = service._team_token_from(token_api_data_team_creator) + + assert isinstance(result.team, Team) + assert isinstance(result.created_by, CreatedByChoice) + assert isinstance(result.created_by.team, Team) + assert result.created_by.team.id == "team-abc" + + def test_team_token_from_no_relationships(self, service): + """_team_token_from handles missing relationship data.""" + data = { + "id": "at-min", + "attributes": {"description": None, "token": None}, + "relationships": {}, + } + result = service._team_token_from(data) + + assert result.id == "at-min" + assert result.team is None + assert result.created_by is None + + def test_team_token_from_null_relationship_data(self, service): + """_team_token_from handles null data inside relationship.""" + data = { + "id": "at-null", + "attributes": {}, + "relationships": { + "team": {"data": None}, + "created-by": {"data": None}, + }, + } + result = service._team_token_from(data) + assert result.team is None + assert result.created_by is None + + # ── Resource method tests ───────────────────────────────────────────────── + + def test_create_legacy_success(self, service, mock_transport, token_api_data): + """create() without description uses legacy endpoint.""" + mock_response = Mock() + mock_response.json.return_value = {"data": token_api_data} + mock_transport.request.return_value = mock_response + + result = service.create(team_id="team-xyz789") + + args, kwargs = mock_transport.request.call_args + assert args[0] == "POST" + assert kwargs["path"] == "/api/v2/teams/team-xyz789/authentication-token" + assert kwargs["json_body"]["data"]["type"] == "authentication-token" + assert isinstance(result, TeamToken) + assert result.id == "at-abc123" + + def test_create_with_description_uses_new_endpoint( + self, service, mock_transport, token_api_data + ): + """create_with_options() with description uses the multi-token endpoint.""" + mock_response = Mock() + mock_response.json.return_value = {"data": token_api_data} + mock_transport.request.return_value = mock_response + + opts = TeamTokenCreateOptions(description="CI token") + service.create_with_options(team_id="team-xyz789", options=opts) + + args, kwargs = mock_transport.request.call_args + assert kwargs["path"] == "/api/v2/teams/team-xyz789/authentication-tokens" + assert kwargs["json_body"]["data"]["type"] == "authentication-tokens" + + def test_create_with_options_invalid_team_id(self, service): + """create_with_options() raises InvalidTeamIDError for a bad team ID.""" + with pytest.raises(InvalidTeamIDError): + service.create_with_options(team_id="not valid!") + + def test_create_invalid_team_id(self, service): + """create() raises InvalidTeamIDError for a bad team ID.""" + with pytest.raises(InvalidTeamIDError): + service.create(team_id="not valid!") + + def test_read_success(self, service, mock_transport, token_api_data): + """read() GETs the legacy endpoint and returns a TeamToken.""" + mock_response = Mock() + mock_response.json.return_value = {"data": token_api_data} + mock_transport.request.return_value = mock_response + + result = service.read(team_id="team-xyz789") + + mock_transport.request.assert_called_once_with( + "GET", path="/api/v2/teams/team-xyz789/authentication-token" + ) + assert isinstance(result, TeamToken) + assert result.id == "at-abc123" + + def test_read_invalid_team_id(self, service): + """read() raises InvalidTeamIDError for a bad team ID.""" + with pytest.raises(InvalidTeamIDError): + service.read(team_id="bad id") + + def test_read_by_id_success(self, service, mock_transport, token_api_data): + """read_by_id() GETs the correct path and returns a TeamToken.""" + mock_response = Mock() + mock_response.json.return_value = {"data": token_api_data} + mock_transport.request.return_value = mock_response + + result = service.read_by_id(token_id="at-abc123") + + mock_transport.request.assert_called_once_with( + "GET", path="/api/v2/authentication-tokens/at-abc123" + ) + assert isinstance(result, TeamToken) + + def test_read_by_id_invalid_token_id(self, service): + """read_by_id() raises InvalidTokenIDError for a bad token ID.""" + with pytest.raises(InvalidTokenIDError): + service.read_by_id(token_id="not valid!") + + def test_list_success(self, service, token_api_data): + """list() yields TeamToken objects from paginated results.""" + service._list = Mock(return_value=[token_api_data]) + + results = list(service.list(organization="my-org")) + + service._list.assert_called_once_with( + path="/api/v2/organizations/my-org/team-tokens", + params={}, + ) + assert len(results) == 1 + assert isinstance(results[0], TeamToken) + assert results[0].id == "at-abc123" + + def test_list_with_options(self, service, token_api_data): + """list() passes pagination and filter params.""" + service._list = Mock(return_value=[token_api_data]) + + opts = TeamTokenListOptions(page_size=5, query="my-team", sort="expired-at") + list(service.list(organization="my-org", options=opts)) + + _, kwargs = service._list.call_args + assert kwargs["params"]["page[size]"] == 5 + assert kwargs["params"]["q"] == "my-team" + assert kwargs["params"]["sort"] == "expired-at" + + def test_list_empty(self, service): + """list() returns empty iterator when no tokens exist.""" + service._list = Mock(return_value=[]) + results = list(service.list(organization="my-org")) + assert results == [] + + def test_list_invalid_org(self, service): + """list() raises InvalidOrgError for a bad organization name.""" + with pytest.raises(InvalidOrgError): + list(service.list(organization="not valid!")) + + def test_delete_success(self, service, mock_transport): + """delete() DELETEs the legacy token endpoint.""" + result = service.delete(team_id="team-xyz789") + + mock_transport.request.assert_called_once_with( + "DELETE", path="/api/v2/teams/team-xyz789/authentication-token" + ) + assert result is None + + def test_delete_invalid_team_id(self, service): + """delete() raises InvalidTeamIDError for a bad team ID.""" + with pytest.raises(InvalidTeamIDError): + service.delete(team_id="bad id") + + def test_delete_by_id_success(self, service, mock_transport): + """delete_by_id() DELETEs by token ID.""" + result = service.delete_by_id(token_id="at-abc123") + + mock_transport.request.assert_called_once_with( + "DELETE", path="/api/v2/authentication-tokens/at-abc123" + ) + assert result is None + + def test_delete_by_id_invalid_token_id(self, service): + """delete_by_id() raises InvalidTokenIDError for a bad token ID.""" + with pytest.raises(InvalidTokenIDError): + service.delete_by_id(token_id="not valid!") From ab076fb323e4a2979a8889422e6bccbe927c3574 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Fri, 15 May 2026 12:38:01 +0530 Subject: [PATCH 62/95] Fixed links and fmt checks --- examples/team_token.py | 4 +--- src/pytfe/resources/team_token.py | 2 -- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/examples/team_token.py b/examples/team_token.py index f932a672..5c71bfd1 100644 --- a/examples/team_token.py +++ b/examples/team_token.py @@ -103,9 +103,7 @@ def main(): team_id=args.team_id, options=create_opts ) else: - _print_header( - f"Creating legacy team token for team: {args.team_id}" - ) + _print_header(f"Creating legacy team token for team: {args.team_id}") t = client.team_tokens.create(team_id=args.team_id) print("Created team token:") _print_token(t) diff --git a/src/pytfe/resources/team_token.py b/src/pytfe/resources/team_token.py index e4ab0808..fd971a68 100644 --- a/src/pytfe/resources/team_token.py +++ b/src/pytfe/resources/team_token.py @@ -70,8 +70,6 @@ def create_with_options( r = self.t.request("POST", path=path, json_body=payload) data = r.json().get("data", {}) return self._team_token_from(data) - - def read(self, team_id: str) -> TeamToken: """Read the legacy (descriptionless) team token by team ID.""" From 180db0d6bc133e1ed6b8731914c6c13be21e191a Mon Sep 17 00:00:00 2001 From: Tanya Singh Date: Mon, 18 May 2026 12:28:40 +0530 Subject: [PATCH 63/95] fix: map task stage relationship in task result resource --- examples/task_result.py | 1 + src/pytfe/models/task_result.py | 47 ++++++++++++++++++++++++++---- src/pytfe/resources/task_result.py | 25 +++++++++++++++- 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/examples/task_result.py b/examples/task_result.py index c24619b8..ca229213 100644 --- a/examples/task_result.py +++ b/examples/task_result.py @@ -26,6 +26,7 @@ def main(): print(f"Message: {result.message}") print(f"Task Name: {result.task_name}") print(f"URL: {result.url}") + print(f"Task Stage: {result.task_stage.id if result.task_stage else None}") except Exception as e: print(f"Error: {e}") diff --git a/src/pytfe/models/task_result.py b/src/pytfe/models/task_result.py index 516da0aa..8ba41e07 100644 --- a/src/pytfe/models/task_result.py +++ b/src/pytfe/models/task_result.py @@ -5,11 +5,14 @@ from datetime import datetime from enum import Enum +from typing import TYPE_CHECKING, Any from pydantic import BaseModel, ConfigDict, Field -# Reuse, do NOT duplicate -from pytfe.models.task_stage import TaskStage +if TYPE_CHECKING: + # Imported only for type checking to avoid a circular import: + # task_stage.py already imports TaskResult. + from pytfe.models.task_stage import TaskStage class TaskResultStatus(str, Enum): @@ -45,7 +48,8 @@ class TaskResult(BaseModel): message: str | None = Field(None, alias="message") status_timestamps: TaskResultStatusTimestamps | None = Field( - None, alias="status-timestamps" + None, + alias="status-timestamps", ) url: str | None = Field(None, alias="url") @@ -58,11 +62,44 @@ class TaskResult(BaseModel): task_url: str | None = Field(None, alias="task-url") workspace_task_id: str | None = Field(None, alias="workspace-task-id") + workspace_task_enforcement_level: TaskEnforcementLevel | None = Field( - None, alias="workspace-task-enforcement-level" + None, + alias="workspace-task-enforcement-level", ) agent_pool_id: str | None = Field(None, alias="agent-pool-id") - # Relation (matches Go: *TaskStage) + # Relationships + # Forward-referenced to avoid circular import; resolved lazily below. task_stage: TaskStage | None = Field(None, alias="task-stage") + run: dict | None = None + workspace: dict | None = None + + policy_evaluations: list[dict] | None = None + + @classmethod + def model_validate(cls, *args: Any, **kwargs: Any) -> TaskResult: + # Ensure the TaskStage forward reference is resolved before validating. + # The import-time rebuild may run while task_stage.py is still + # partially loaded (circular import), in which case we retry here. + if not getattr(cls, "__pydantic_complete__", True): + _rebuild_task_result_model() + return super().model_validate(*args, **kwargs) + + +def _rebuild_task_result_model() -> None: + # Resolve the TaskStage forward reference once both modules are loaded. + try: + from pytfe.models.task_stage import TaskStage + + TaskResult.model_rebuild( + raise_errors=False, + _types_namespace={"TaskStage": TaskStage}, + ) + except Exception: + # TaskStage not yet importable during partial init; safe to skip. + pass + + +_rebuild_task_result_model() diff --git a/src/pytfe/resources/task_result.py b/src/pytfe/resources/task_result.py index 7640d018..b2ba1d75 100644 --- a/src/pytfe/resources/task_result.py +++ b/src/pytfe/resources/task_result.py @@ -4,6 +4,7 @@ from typing import Any from pytfe.models.task_result import TaskResult +from pytfe.models.task_stage import TaskStage from pytfe.utils import valid_string_id from ._base import _Service @@ -29,4 +30,26 @@ def _parse_task_result(self, data: dict[str, Any]) -> TaskResult: attributes["id"] = data.get("id") - return TaskResult(**attributes) + relationships = data.get("relationships", {}) + + # Map task-stage relationship into the TaskStage SDK model so callers + # get a typed object rather than a raw {id, type} dict. + if "task-stage" in relationships: + task_stage_data = relationships["task-stage"].get("data") + if task_stage_data: + attributes["task_stage"] = TaskStage.model_validate(task_stage_data) + else: + attributes["task_stage"] = None + + if "run" in relationships: + attributes["run"] = relationships["run"].get("data") + + if "workspace" in relationships: + attributes["workspace"] = relationships["workspace"].get("data") + + if "policy-evaluations" in relationships: + attributes["policy_evaluations"] = relationships["policy-evaluations"].get( + "data" + ) + + return TaskResult.model_validate(attributes) From 31d148623c531b91a96446c5242ea4f2b5a8a053 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Mon, 18 May 2026 21:35:09 +0530 Subject: [PATCH 64/95] feat(terraform-actions): Added invoke action address to Run and RunCreateOptions models --- src/pytfe/models/run.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pytfe/models/run.py b/src/pytfe/models/run.py index ab305a55..01185e89 100644 --- a/src/pytfe/models/run.py +++ b/src/pytfe/models/run.py @@ -125,6 +125,7 @@ class Run(BaseModel): terraform_version: str | None = Field(None, alias="terraform-version") trigger_reason: str | None = Field(None, alias="trigger-reason") variables: list[RunVariableAttr] | None = Field(None, alias="variables") + invoke_action_addrs: list[str] | None = Field(None, alias="invoke-action-addrs") # Relations apply: Apply | None = Field(None, alias="apply") @@ -292,6 +293,7 @@ class RunCreateOptions(BaseModel): policy_paths: list[str] | None = Field(None, alias="policy-paths") auto_apply: bool | None = Field(None, alias="auto-apply") variables: list[RunVariable] | None = Field(None, alias="variables") + invoke_action_addrs: list[str] | None = Field(None, alias="invoke-action-addrs") class RunReadOptions(BaseModel): From 6627c1a4314ada6d96b3a233f97cc40a6a3499d2 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Mon, 18 May 2026 21:50:51 +0530 Subject: [PATCH 65/95] feat(terraform-actions): Updated examples with invoke-action flag for terraform actions --- examples/run.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/examples/run.py b/examples/run.py index 1a6735df..c7a609ef 100644 --- a/examples/run.py +++ b/examples/run.py @@ -43,6 +43,14 @@ def main(): parser.add_argument( "--run-actions", action="store_true", help="Demo run actions (safe mode)" ) + parser.add_argument( + "--invoke-action", + metavar="ACTION_ADDR", + help=( + "Invoke a Terraform Action by its address, e.g. " + "'action.aws_lambda_invoke.api_handler'. Requires --workspace-id." + ), + ) args = parser.parse_args() if not args.token: @@ -57,6 +65,10 @@ def main(): print("Error: --create-run requires --workspace-id") return + if args.invoke_action and not args.workspace_id: + print("Error: --invoke-action requires --workspace-id") + return + cfg = TFEConfig(address=args.address, token=args.token) client = TFEClient(cfg) @@ -257,6 +269,32 @@ def main(): print("\n Note: These actions are commented out for safety.") print("Uncomment and use them carefully in your own code.") + # 6) Invoke a Terraform Action + if args.invoke_action and args.workspace_id: + _print_header(f"Invoking Terraform Action: {args.invoke_action}") + + try: + workspace = Workspace(id=args.workspace_id) + + create_options = RunCreateOptions( + workspace=workspace, + message=f"Invoking {args.invoke_action} via python-tfe SDK", + invoke_action_addrs=[args.invoke_action], + ) + + run = client.runs.create(create_options) + + print(f"Run ID : {run.id}") + print(f"Status : {run.status}") + print(f"invoke-action-addrs: {run.invoke_action_addrs}") + print(f"Message : {run.message}") + + except Exception as e: + print(f"Error invoking action: {e}") + import traceback + + traceback.print_exc() + if __name__ == "__main__": main() From 20390c929dda5e454e69ba26f79aa1a20d88f964 Mon Sep 17 00:00:00 2001 From: Tanya Singh Date: Wed, 20 May 2026 02:51:56 +0530 Subject: [PATCH 66/95] Add Run Task integration callback support --- examples/run_task_integration.py | 130 +++++ examples/run_task_webhook_server.py | 102 ++++ src/pytfe/client.py | 2 + src/pytfe/errors.py | 18 + src/pytfe/models/run_task_integration.py | 108 ++++ src/pytfe/resources/run_task_integration.py | 42 ++ tests/units/test_run_task_integration.py | 516 ++++++++++++++++++++ 7 files changed, 918 insertions(+) create mode 100644 examples/run_task_integration.py create mode 100644 examples/run_task_webhook_server.py create mode 100644 src/pytfe/models/run_task_integration.py create mode 100644 src/pytfe/resources/run_task_integration.py create mode 100644 tests/units/test_run_task_integration.py diff --git a/examples/run_task_integration.py b/examples/run_task_integration.py new file mode 100644 index 00000000..71b43b8c --- /dev/null +++ b/examples/run_task_integration.py @@ -0,0 +1,130 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Example Run Task callback integration. + +This example sends a callback result back to Terraform after a Run Task +webhook is received. + +Required environment variables: + +- TFE_ADDRESS + Terraform address (for example: https://app.terraform.io) + +- TFE_TOKEN + Your Terraform API token used to initialize the SDK client. + +- TFE_CALLBACK_URL + The task_result_callback_url received in the Run Task webhook payload. + +- TFE_CALLBACK_TOKEN + The access_token received in the same webhook payload. + This token is used for the callback request and is different from + your regular Terraform API token. + +Local testing flow: + +1. Start the webhook server: + + uvicorn examples.run_task_webhook_server:app --reload --port 8000 + +2. Expose the server publicly: + + ngrok http 8000 + +3. Create a Run Task in Terraform Cloud / Enterprise using the ngrok URL. + +4. Attach the Run Task to a workspace and trigger a run. + +5. The webhook payload will include values similar to: + + { + "task_result_callback_url": "https://app.terraform.io/...", + "access_token": "v1.xxxxx..." + } + +6. Export those values locally and run this example script, + or call client.run_task_integrations.callback(...) directly + inside your webhook handler. + +Example: + + export TFE_ADDRESS=https://app.terraform.io + export TFE_TOKEN= + export TFE_CALLBACK_URL= + export TFE_CALLBACK_TOKEN= + + python examples/run_task_integration.py +""" + +from __future__ import annotations + +import os + +from pytfe import TFEClient, TFEConfig +from pytfe.models.run_task_integration import ( + TaskResultCallbackRequestOptions, + TaskResultOutcome, + TaskResultStatus, + TaskResultTag, +) + + +def main() -> None: + callback_url = os.getenv("TFE_CALLBACK_URL") + access_token = os.getenv("TFE_CALLBACK_TOKEN") + + if not callback_url or not access_token: + print("Missing TFE_CALLBACK_URL or TFE_CALLBACK_TOKEN") + return + + # TFE_ADDRESS and TFE_TOKEN are loaded from the environment. + # The callback request itself uses the short-lived webhook token. + client = TFEClient(TFEConfig.from_env()) + + outcome = TaskResultOutcome( + description="Example outcome", + body="All checks passed successfully", + tags={"severity": [TaskResultTag(label="low", level="info")]}, + ) + + # Example status values: + # + # - passed: marks the run task as successful + # - failed: fails the run task + # - running: reports progress before sending a final result + # + # Example: send an in-progress update + # + # options = TaskResultCallbackRequestOptions( + # status=TaskResultStatus.running, + # message="Security scan in progress", + # ) + # + # Example: report a failure + # + # options = TaskResultCallbackRequestOptions( + # status=TaskResultStatus.failed, + # message="Found critical vulnerabilities", + # ) + + options = TaskResultCallbackRequestOptions( + status=TaskResultStatus.passed, + message="Run task completed successfully", + url="https://example.com/results", + outcomes=[outcome], + ) + + print(f"Sending callback to: {callback_url}") + + client.run_task_integrations.callback( + callback_url=callback_url, + access_token=access_token, + options=options, + ) + + print("Run task callback sent successfully") + + +if __name__ == "__main__": + main() diff --git a/examples/run_task_webhook_server.py b/examples/run_task_webhook_server.py new file mode 100644 index 00000000..bbaf943d --- /dev/null +++ b/examples/run_task_webhook_server.py @@ -0,0 +1,102 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Minimal FastAPI webhook server for Terraform Run Tasks. + +This example receives a Run Task webhook, extracts the callback URL +and access token from the payload, and sends a callback result back +to Terraform using the SDK. + +Setup: + +1. Install dependencies: + + pip install fastapi uvicorn + +2. Configure environment variables: + + export TFE_ADDRESS=https://app.terraform.io + export TFE_TOKEN= + +3. Start the server from the repository root: + + uvicorn examples.run_task_webhook_server:app --reload --port 8000 + +4. Expose the server publicly with ngrok: + + ngrok http 8000 + +5. In Terraform Cloud / Enterprise: + + - Create a Run Task using the ngrok URL + - Attach the Run Task to a workspace + - Trigger a Terraform run + +The webhook payload will include values like: + + { + "task_result_callback_url": "...", + "access_token": "..." + } + +This example prints the payload locally and sends a successful +callback response back to Terraform. +""" + +from __future__ import annotations + +import json + +from fastapi import FastAPI, Request + +from pytfe import TFEClient, TFEConfig +from pytfe.models.run_task_integration import ( + TaskResultCallbackRequestOptions, + TaskResultStatus, +) + +app = FastAPI() +client = TFEClient(TFEConfig.from_env()) + + +@app.post("/") +async def receive_webhook(request: Request) -> dict[str, bool]: + try: + payload = await request.json() + except Exception: + # Terraform verification requests may not include a JSON payload. + return {"ok": True} + + print("\n=== FULL PAYLOAD ===") + print(json.dumps(payload, indent=2)) + + callback_url = payload.get("task_result_callback_url") + access_token = payload.get("access_token") + + print("\n=== EXTRACTED VALUES ===") + print("callback_url:", callback_url) + print("access_token:", access_token) + + if not callback_url or not access_token: + # Verification requests do not include callback information. + return {"ok": True} + + options = TaskResultCallbackRequestOptions( + status=TaskResultStatus.passed, + message="Webhook received and processed", + ) + + print(f"Sending callback to: {callback_url}") + + try: + client.run_task_integrations.callback( + callback_url=callback_url, + access_token=access_token, + options=options, + ) + print("Run task callback sent successfully") + except Exception as exc: + print(f"Callback failed: {exc!r}") + return {"ok": False} + + return {"ok": True} diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 4642d9a8..59a8c47f 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -33,6 +33,7 @@ from .resources.run import Runs from .resources.run_event import RunEvents from .resources.run_task import RunTasks +from .resources.run_task_integration import RunTaskIntegrations from .resources.run_trigger import RunTriggers from .resources.ssh_keys import SSHKeys from .resources.stack import Stacks @@ -100,6 +101,7 @@ def __init__(self, config: TFEConfig | None = None): self.state_versions = StateVersions(self._transport) self.state_version_outputs = StateVersionOutputs(self._transport) self.run_tasks = RunTasks(self._transport) + self.run_task_integrations = RunTaskIntegrations(self._transport) self.run_triggers = RunTriggers(self._transport) self.runs = Runs(self._transport) self.query_runs = QueryRuns(self._transport) diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index 113dee9a..73b0e848 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -58,6 +58,24 @@ class RequiredFieldMissing(TFEError): ... class ErrStateVersionUploadNotSupported(TFEError): ... +class InvalidCallbackURLError(TFEError): + def __init__(self, message: str = "Invalid callback URL") -> None: + super().__init__(message) + + +class InvalidAccessTokenError(TFEError): + def __init__(self, message: str = "Invalid access token") -> None: + super().__init__(message) + + +class InvalidTaskResultsCallbackStatusError(TFEError): + def __init__( + self, + message: str = "Invalid task result callback status; must be one of: passed, failed, running", + ) -> None: + super().__init__(message) + + # Generic error constants ERR_UNAUTHORIZED = "unauthorized" ERR_RESOURCE_NOT_FOUND = "resource not found" diff --git a/src/pytfe/models/run_task_integration.py b/src/pytfe/models/run_task_integration.py new file mode 100644 index 00000000..ee3b4c00 --- /dev/null +++ b/src/pytfe/models/run_task_integration.py @@ -0,0 +1,108 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from enum import Enum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from ..errors import InvalidTaskResultsCallbackStatusError + + +class TaskResultStatus(str, Enum): + """Statuses accepted by the Run Task callback endpoint. + + Mirrors the Go SDK's accepted callback statuses (passed, failed, running). + """ + + passed = "passed" + failed = "failed" + running = "running" + + +class TaskResultTag(BaseModel): + """Tag attached to a Run Task outcome to enrich the result display in the UI.""" + + model_config = ConfigDict(populate_by_name=True) + + label: str = Field(..., alias="label") + level: str | None = Field(None, alias="level") + + def _to_payload(self) -> dict[str, Any]: + payload: dict[str, Any] = {"label": self.label} + if self.level is not None: + payload["level"] = self.level + return payload + + +class TaskResultOutcome(BaseModel): + """Detailed Run Task outcome.""" + + model_config = ConfigDict(populate_by_name=True) + + outcome_id: str | None = Field(None, alias="outcome-id") + description: str | None = Field(None, alias="description") + body: str | None = Field(None, alias="body") + url: str | None = Field(None, alias="url") + tags: dict[str, list[TaskResultTag]] | None = Field(None, alias="tags") + + def _to_payload(self) -> dict[str, Any]: + attributes: dict[str, Any] = {} + if self.outcome_id is not None: + attributes["outcome-id"] = self.outcome_id + if self.description is not None: + attributes["description"] = self.description + if self.body is not None: + attributes["body"] = self.body + if self.url is not None: + attributes["url"] = self.url + if self.tags is not None: + attributes["tags"] = { + key: [tag._to_payload() for tag in tags] + for key, tags in self.tags.items() + } + return {"type": "task-result-outcomes", "attributes": attributes} + + +class TaskResultCallbackRequestOptions(BaseModel): + """Payload options for sending a Run Task callback result.""" + + model_config = ConfigDict(populate_by_name=True) + + status: TaskResultStatus = Field(..., alias="status") + message: str | None = Field(None, alias="message") + url: str | None = Field(None, alias="url") + outcomes: list[TaskResultOutcome] | None = Field(None, alias="outcomes") + + def _validate(self) -> None: + """Validate callback status.""" + if not isinstance(self.status, TaskResultStatus): + raise InvalidTaskResultsCallbackStatusError() + + def to_payload(self) -> dict[str, Any]: + """Return the JSON:API payload for the callback PATCH request.""" + self._validate() + + attributes: dict[str, Any] = {"status": self.status.value} + if self.message is not None: + attributes["message"] = self.message + if self.url is not None: + attributes["url"] = self.url + + payload: dict[str, Any] = { + "data": { + "type": "task-results", + "attributes": attributes, + } + } + + if self.outcomes: + payload["data"]["relationships"] = { + "outcomes": { + "data": [outcome._to_payload() for outcome in self.outcomes] + } + } + + return payload diff --git a/src/pytfe/resources/run_task_integration.py b/src/pytfe/resources/run_task_integration.py new file mode 100644 index 00000000..165dbbf2 --- /dev/null +++ b/src/pytfe/resources/run_task_integration.py @@ -0,0 +1,42 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from ..errors import InvalidAccessTokenError, InvalidCallbackURLError +from ..models.run_task_integration import TaskResultCallbackRequestOptions +from ._base import _Service + + +class RunTaskIntegrations(_Service): + """Run Tasks Integration Callback API. + + See: + https://developer.hashicorp.com/terraform/enterprise/api-docs/run-tasks/run-tasks-integration + """ + + def callback( + self, + callback_url: str, + access_token: str, + options: TaskResultCallbackRequestOptions, + ) -> None: + """Send a Run Task result back to the Terraform callback URL. + + The PATCH request must use the access token from the originating + Run Task webhook (not the SDK client's API token). + """ + if not callback_url or not callback_url.strip(): + raise InvalidCallbackURLError() + if not access_token or not access_token.strip(): + raise InvalidAccessTokenError() + + self.t.request( + "PATCH", + callback_url, + json_body=options.to_payload(), + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/vnd.api+json", + }, + ) diff --git a/tests/units/test_run_task_integration.py b/tests/units/test_run_task_integration.py new file mode 100644 index 00000000..f5145efc --- /dev/null +++ b/tests/units/test_run_task_integration.py @@ -0,0 +1,516 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import ( + InvalidAccessTokenError, + InvalidCallbackURLError, + InvalidTaskResultsCallbackStatusError, + TFEError, +) +from pytfe.models.run_task_integration import ( + TaskResultCallbackRequestOptions, + TaskResultOutcome, + TaskResultStatus, + TaskResultTag, +) +from pytfe.resources._base import _Service +from pytfe.resources.run_task_integration import RunTaskIntegrations + +CALLBACK_URL = "https://app.terraform.io/api/v2/task-results/taskrs-abc/callback" +ACCESS_TOKEN = "v1.callback-token" + + +@pytest.fixture +def transport() -> Mock: + return Mock(spec=HTTPTransport) + + +@pytest.fixture +def service(transport: Mock) -> RunTaskIntegrations: + return RunTaskIntegrations(transport) + + +def _basic_options() -> TaskResultCallbackRequestOptions: + return TaskResultCallbackRequestOptions( + status=TaskResultStatus.passed, + message="All good", + url="https://example.com/details", + ) + + +# ─── Architectural sanity ───────────────────────────────────────────────────── + + +def test_service_extends_base_service(): + assert issubclass(RunTaskIntegrations, _Service) + + +def test_service_uses_transport(transport, service): + assert service.t is transport + + +def test_typed_errors_subclass_tfe_error(): + assert issubclass(InvalidCallbackURLError, TFEError) + assert issubclass(InvalidAccessTokenError, TFEError) + assert issubclass(InvalidTaskResultsCallbackStatusError, TFEError) + + +# ─── Validation: callback URL ───────────────────────────────────────────────── + + +@pytest.mark.parametrize("bad_url", ["", " ", "\t\n", None]) +def test_callback_invalid_url_raises_typed_error(service, bad_url): + with pytest.raises(InvalidCallbackURLError): + service.callback(bad_url, ACCESS_TOKEN, _basic_options()) # type: ignore[arg-type] + + +# ─── Validation: access token ───────────────────────────────────────────────── + + +@pytest.mark.parametrize("bad_token", ["", " ", "\t\n", None]) +def test_callback_invalid_token_raises_typed_error(service, bad_token): + with pytest.raises(InvalidAccessTokenError): + service.callback(CALLBACK_URL, bad_token, _basic_options()) # type: ignore[arg-type] + + +# ─── Validation: status ─────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "good_status", + [TaskResultStatus.passed, TaskResultStatus.failed, TaskResultStatus.running], +) +def test_callback_accepts_all_valid_statuses(service, transport, good_status): + options = TaskResultCallbackRequestOptions(status=good_status) + service.callback(CALLBACK_URL, ACCESS_TOKEN, options) + attrs = transport.request.call_args.kwargs["json_body"]["data"]["attributes"] + assert attrs["status"] == good_status.value + + +@pytest.mark.parametrize( + "bad_status", + ["pending", "errored", "unreachable", "", "PASSED", "unknown", None, 123], +) +def test_callback_rejects_invalid_statuses(service, bad_status): + options = TaskResultCallbackRequestOptions(status=TaskResultStatus.passed) + options.status = bad_status # type: ignore[assignment] + with pytest.raises(InvalidTaskResultsCallbackStatusError): + service.callback(CALLBACK_URL, ACCESS_TOKEN, options) + + +# ─── Transport invocation ───────────────────────────────────────────────────── + + +def test_callback_invokes_transport_with_exact_args(service, transport): + expected_payload = { + "data": { + "type": "task-results", + "attributes": { + "status": "passed", + "message": "All good", + "url": "https://example.com/details", + }, + } + } + service.callback(CALLBACK_URL, ACCESS_TOKEN, _basic_options()) + + transport.request.assert_called_once_with( + "PATCH", + CALLBACK_URL, + json_body=expected_payload, + headers={ + "Authorization": f"Bearer {ACCESS_TOKEN}", + "Content-Type": "application/vnd.api+json", + }, + ) + + +def test_callback_passes_absolute_url_unchanged(service, transport): + service.callback(CALLBACK_URL, ACCESS_TOKEN, _basic_options()) + args, _ = transport.request.call_args + assert args[0] == "PATCH" + assert args[1] == CALLBACK_URL + assert args[1].startswith("https://") + + +def test_authorization_header_uses_callback_token(service, transport): + service.callback(CALLBACK_URL, ACCESS_TOKEN, _basic_options()) + kwargs = transport.request.call_args.kwargs + assert kwargs["headers"] == { + "Authorization": f"Bearer {ACCESS_TOKEN}", + "Content-Type": "application/vnd.api+json", + } + + +def test_callback_does_not_call_transport_on_validation_failure(service, transport): + with pytest.raises(InvalidCallbackURLError): + service.callback("", ACCESS_TOKEN, _basic_options()) + transport.request.assert_not_called() + + +# ─── Payload serialization: exact JSON:API shape ────────────────────────────── + + +def test_payload_basic_exact_shape(service, transport): + service.callback(CALLBACK_URL, ACCESS_TOKEN, _basic_options()) + assert transport.request.call_args.kwargs["json_body"] == { + "data": { + "type": "task-results", + "attributes": { + "status": "passed", + "message": "All good", + "url": "https://example.com/details", + }, + } + } + + +def test_payload_status_only_exact_shape(service, transport): + options = TaskResultCallbackRequestOptions(status=TaskResultStatus.running) + service.callback(CALLBACK_URL, ACCESS_TOKEN, options) + assert transport.request.call_args.kwargs["json_body"] == { + "data": { + "type": "task-results", + "attributes": {"status": "running"}, + } + } + + +def test_payload_with_outcomes_and_tags_exact_shape(service, transport): + outcome = TaskResultOutcome( + outcome_id="o-1", + description="desc", + body="body", + url="https://example.com/o1", + tags={ + "severity": [ + TaskResultTag(label="high", level="error"), + TaskResultTag(label="cve"), + ] + }, + ) + options = TaskResultCallbackRequestOptions( + status=TaskResultStatus.failed, outcomes=[outcome] + ) + service.callback(CALLBACK_URL, ACCESS_TOKEN, options) + + assert transport.request.call_args.kwargs["json_body"] == { + "data": { + "type": "task-results", + "attributes": {"status": "failed"}, + "relationships": { + "outcomes": { + "data": [ + { + "type": "task-result-outcomes", + "attributes": { + "outcome-id": "o-1", + "description": "desc", + "body": "body", + "url": "https://example.com/o1", + "tags": { + "severity": [ + {"label": "high", "level": "error"}, + {"label": "cve"}, + ] + }, + }, + } + ] + } + }, + } + } + + +# ─── Omission (`omitempty` parity) ──────────────────────────────────────────── + + +def test_message_omitted_when_none(service, transport): + options = TaskResultCallbackRequestOptions( + status=TaskResultStatus.passed, url="https://x" + ) + service.callback(CALLBACK_URL, ACCESS_TOKEN, options) + attrs = transport.request.call_args.kwargs["json_body"]["data"]["attributes"] + assert "message" not in attrs + + +def test_url_omitted_when_none(service, transport): + options = TaskResultCallbackRequestOptions( + status=TaskResultStatus.passed, message="m" + ) + service.callback(CALLBACK_URL, ACCESS_TOKEN, options) + attrs = transport.request.call_args.kwargs["json_body"]["data"]["attributes"] + assert "url" not in attrs + + +def test_relationships_omitted_when_outcomes_none(service, transport): + options = TaskResultCallbackRequestOptions(status=TaskResultStatus.passed) + service.callback(CALLBACK_URL, ACCESS_TOKEN, options) + body = transport.request.call_args.kwargs["json_body"] + assert "relationships" not in body["data"] + + +def test_relationships_omitted_when_outcomes_empty_list(service, transport): + options = TaskResultCallbackRequestOptions( + status=TaskResultStatus.passed, outcomes=[] + ) + service.callback(CALLBACK_URL, ACCESS_TOKEN, options) + body = transport.request.call_args.kwargs["json_body"] + assert "relationships" not in body["data"] + + +def test_outcome_attributes_omit_none_fields(service, transport): + options = TaskResultCallbackRequestOptions( + status=TaskResultStatus.passed, outcomes=[TaskResultOutcome()] + ) + service.callback(CALLBACK_URL, ACCESS_TOKEN, options) + entry = transport.request.call_args.kwargs["json_body"]["data"]["relationships"][ + "outcomes" + ]["data"][0] + assert entry == {"type": "task-result-outcomes", "attributes": {}} + + +def test_tag_level_omitted_when_none(service, transport): + outcome = TaskResultOutcome(tags={"category": [TaskResultTag(label="only")]}) + options = TaskResultCallbackRequestOptions( + status=TaskResultStatus.passed, outcomes=[outcome] + ) + service.callback(CALLBACK_URL, ACCESS_TOKEN, options) + tags = transport.request.call_args.kwargs["json_body"]["data"]["relationships"][ + "outcomes" + ]["data"][0]["attributes"]["tags"] + assert tags == {"category": [{"label": "only"}]} + + +def test_outcome_tags_omitted_when_none(service, transport): + outcome = TaskResultOutcome(description="no tags here") + options = TaskResultCallbackRequestOptions( + status=TaskResultStatus.passed, outcomes=[outcome] + ) + service.callback(CALLBACK_URL, ACCESS_TOKEN, options) + attrs = transport.request.call_args.kwargs["json_body"]["data"]["relationships"][ + "outcomes" + ]["data"][0]["attributes"] + assert attrs == {"description": "no tags here"} + + +# ─── Edge cases ─────────────────────────────────────────────────────────────── + + +def test_multiple_outcomes_preserve_order(service, transport): + options = TaskResultCallbackRequestOptions( + status=TaskResultStatus.passed, + outcomes=[ + TaskResultOutcome(outcome_id="o-1", description="first"), + TaskResultOutcome(outcome_id="o-2", description="second"), + TaskResultOutcome(outcome_id="o-3", description="third"), + ], + ) + service.callback(CALLBACK_URL, ACCESS_TOKEN, options) + outcomes = transport.request.call_args.kwargs["json_body"]["data"]["relationships"][ + "outcomes" + ]["data"] + assert [o["attributes"]["outcome-id"] for o in outcomes] == ["o-1", "o-2", "o-3"] + + +def test_multiple_tags_per_category(service, transport): + outcome = TaskResultOutcome( + tags={ + "severity": [ + TaskResultTag(label="critical", level="error"), + TaskResultTag(label="high", level="error"), + TaskResultTag(label="medium", level="warning"), + ], + "compliance": [TaskResultTag(label="pci-dss")], + } + ) + options = TaskResultCallbackRequestOptions( + status=TaskResultStatus.failed, outcomes=[outcome] + ) + service.callback(CALLBACK_URL, ACCESS_TOKEN, options) + tags = transport.request.call_args.kwargs["json_body"]["data"]["relationships"][ + "outcomes" + ]["data"][0]["attributes"]["tags"] + assert tags == { + "severity": [ + {"label": "critical", "level": "error"}, + {"label": "high", "level": "error"}, + {"label": "medium", "level": "warning"}, + ], + "compliance": [{"label": "pci-dss"}], + } + + +def test_unicode_message_and_body(service, transport): + outcome = TaskResultOutcome(body="✓ all good — 通过") + options = TaskResultCallbackRequestOptions( + status=TaskResultStatus.passed, + message="résumé 🎉", + outcomes=[outcome], + ) + service.callback(CALLBACK_URL, ACCESS_TOKEN, options) + body = transport.request.call_args.kwargs["json_body"] + assert body["data"]["attributes"]["message"] == "résumé 🎉" + assert ( + body["data"]["relationships"]["outcomes"]["data"][0]["attributes"]["body"] + == "✓ all good — 通过" + ) + + +def test_markdown_body_preserved_verbatim(service, transport): + md = "## Results\n\n- [link](https://x)\n- **bold**\n\n```py\nprint('ok')\n```" + outcome = TaskResultOutcome(body=md) + options = TaskResultCallbackRequestOptions( + status=TaskResultStatus.passed, outcomes=[outcome] + ) + service.callback(CALLBACK_URL, ACCESS_TOKEN, options) + serialized = transport.request.call_args.kwargs["json_body"]["data"][ + "relationships" + ]["outcomes"]["data"][0]["attributes"]["body"] + assert serialized == md + + +def test_status_serialized_as_plain_string(service, transport): + service.callback(CALLBACK_URL, ACCESS_TOKEN, _basic_options()) + status = transport.request.call_args.kwargs["json_body"]["data"]["attributes"][ + "status" + ] + assert isinstance(status, str) + assert status == "passed" + + +# ─── Pydantic alias / model behavior ────────────────────────────────────────── + + +def test_outcome_accepts_alias_input(): + outcome = TaskResultOutcome.model_validate({"outcome-id": "o-1"}) + assert outcome.outcome_id == "o-1" + + +def test_options_accepts_string_status(): + options = TaskResultCallbackRequestOptions.model_validate({"status": "passed"}) + assert options.status == TaskResultStatus.passed + + +# ─── SDK client wiring ──────────────────────────────────────────────────────── + + +def test_client_wires_run_task_integrations(): + """The TFEClient must expose `run_task_integrations` as a RunTaskIntegrations + bound to the client transport. Catches accidental rename / unwiring.""" + from pytfe import TFEClient, TFEConfig + + client = TFEClient( + TFEConfig(address="https://app.terraform.io", token="dummy-token") + ) + assert isinstance(client.run_task_integrations, RunTaskIntegrations) + assert client.run_task_integrations.t is client._transport + + +# ─── Return value & idempotency ─────────────────────────────────────────────── + + +def test_callback_returns_none(service, transport): + transport.request.return_value = {"data": {"id": "ignored"}} + assert service.callback(CALLBACK_URL, ACCESS_TOKEN, _basic_options()) is None + + +def test_to_payload_is_idempotent(): + """Calling to_payload twice must produce equal dicts and must not mutate + the options instance — important because callers may inspect/log payloads.""" + options = TaskResultCallbackRequestOptions( + status=TaskResultStatus.passed, + message="hi", + outcomes=[ + TaskResultOutcome( + outcome_id="o-1", + tags={"sev": [TaskResultTag(label="high", level="error")]}, + ) + ], + ) + first = options.to_payload() + second = options.to_payload() + assert first == second + # Mutating the returned payload must not affect the next serialization. + first["data"]["attributes"]["status"] = "mutated" + assert options.to_payload()["data"]["attributes"]["status"] == "passed" + + +def test_to_payload_is_json_serializable(): + """The transport ultimately json.dumps the body; the payload must contain + only JSON-native types (no Enum, no Pydantic models).""" + import json + + options = TaskResultCallbackRequestOptions( + status=TaskResultStatus.failed, + outcomes=[ + TaskResultOutcome( + outcome_id="o-1", + tags={"sev": [TaskResultTag(label="high", level="error")]}, + ) + ], + ) + encoded = json.dumps(options.to_payload()) + assert json.loads(encoded) == options.to_payload() + + +# ─── Transport-side errors ──────────────────────────────────────────────────── + + +def test_transport_exception_propagates(service, transport): + """If the transport raises (e.g. network/HTTP error), the SDK must not + swallow it — callers need the failure to retry/log.""" + transport.request.side_effect = RuntimeError("boom") + with pytest.raises(RuntimeError, match="boom"): + service.callback(CALLBACK_URL, ACCESS_TOKEN, _basic_options()) + + +def test_sequential_callbacks_are_independent(service, transport): + """Two callbacks on the same service must produce two distinct requests.""" + service.callback(CALLBACK_URL, ACCESS_TOKEN, _basic_options()) + service.callback( + CALLBACK_URL, + "v1.other-token", + TaskResultCallbackRequestOptions(status=TaskResultStatus.failed), + ) + assert transport.request.call_count == 2 + assert transport.request.call_args_list[0].kwargs["headers"] == { + "Authorization": f"Bearer {ACCESS_TOKEN}", + "Content-Type": "application/vnd.api+json", + } + assert transport.request.call_args_list[1].kwargs["headers"] == { + "Authorization": "Bearer v1.other-token", + "Content-Type": "application/vnd.api+json", + } + assert ( + transport.request.call_args_list[1].kwargs["json_body"]["data"]["attributes"][ + "status" + ] + == "failed" + ) + + +# ─── Current-behavior pins for empty-collection edge cases ──────────────────── + + +def test_outcome_with_empty_tags_dict_emits_empty_object(service, transport): + """Document current behavior: tags={} serializes as an empty object rather + than being omitted. Go SDK ``omitempty`` would drop it; if parity is + desired later, update both the model and this test together.""" + outcome = TaskResultOutcome(tags={}) + options = TaskResultCallbackRequestOptions( + status=TaskResultStatus.passed, outcomes=[outcome] + ) + service.callback(CALLBACK_URL, ACCESS_TOKEN, options) + attrs = transport.request.call_args.kwargs["json_body"]["data"]["relationships"][ + "outcomes" + ]["data"][0]["attributes"] + assert attrs == {"tags": {}} From 3e830e7698e1d8abd8e89574c51c1808c75a1e1b Mon Sep 17 00:00:00 2001 From: Tanya Singh Date: Wed, 20 May 2026 14:52:40 +0530 Subject: [PATCH 67/95] Improve Run Task integration examples and callback flow --- examples/run_task_webhook_server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/run_task_webhook_server.py b/examples/run_task_webhook_server.py index bbaf943d..5bb09797 100644 --- a/examples/run_task_webhook_server.py +++ b/examples/run_task_webhook_server.py @@ -84,6 +84,7 @@ async def receive_webhook(request: Request) -> dict[str, bool]: options = TaskResultCallbackRequestOptions( status=TaskResultStatus.passed, message="Webhook received and processed", + url="https://github.com/hashicorp/python-tfe", ) print(f"Sending callback to: {callback_url}") From e116ac3e367a18674c15430b3ac2f84d6e59136e Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Fri, 22 May 2026 13:03:42 +0530 Subject: [PATCH 68/95] Bump pytfe project version from 0.1.5 to 1.0.0 --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4f311fc4..d1936cc1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pytfe" -version = "0.1.5" +version = "1.0.0" description = "Official Python SDK for HashiCorp Terraform Cloud / Terraform Enterprise (TFE) API v2" readme = "README.md" license = { text = "MPL-2.0" } @@ -116,4 +116,4 @@ module = "tests.*" disallow_untyped_defs = false [tool.hatch.build.targets.sdist] -include = ["src/**", "README.md", "LICENSE", "docs/**"] \ No newline at end of file +include = ["src/**", "README.md", "LICENSE", "docs/**"] From edd52d7e6d6c5ca730afa8034a818b18e5f0b082 Mon Sep 17 00:00:00 2001 From: Tanya Singh Date: Fri, 22 May 2026 14:44:21 +0530 Subject: [PATCH 69/95] fix: map task result relationships into typed SDK models --- src/pytfe/models/__init__.py | 28 +++++ src/pytfe/models/task_result.py | 32 ++++-- src/pytfe/resources/task_result.py | 62 +++++++---- tests/units/test_task_results.py | 162 +++++++++++++++++++++++++++++ 4 files changed, 254 insertions(+), 30 deletions(-) diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index d2e8648c..293cf51c 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -334,6 +334,17 @@ StateVersionOutput, StateVersionOutputsListOptions, ) + +# ── Task Result ─────────────────────────────────────────────────────────────── +from .task_result import ( + TaskEnforcementLevel as TaskResultEnforcementLevel, +) +from .task_result import ( + TaskResult, + TaskResultStatus, + TaskResultStatusTimestamps, +) +from .task_stage import TaskStage from .team import ( OrganizationAccess, Team, @@ -654,6 +665,12 @@ "RunTaskCreateOptions", "RunTaskUpdateOptions", "RunTaskReadOptions", + # Task Result + "TaskResult", + "TaskResultEnforcementLevel", + "TaskResultStatus", + "TaskResultStatusTimestamps", + "TaskStage", # Run triggers "RunTrigger", "RunTriggerCreateOptions", @@ -741,3 +758,14 @@ RegistryProvider.model_rebuild() RegistryProviderVersion.model_rebuild() RegistryProviderPlatform.model_rebuild() + +# Rebuild TaskResult to resolve Run, Workspace, PolicyEvaluation, TaskStage refs +TaskResult.model_rebuild( + raise_errors=False, + _types_namespace={ + "PolicyEvaluation": PolicyEvaluation, + "Run": Run, + "TaskStage": TaskStage, + "Workspace": Workspace, + }, +) diff --git a/src/pytfe/models/task_result.py b/src/pytfe/models/task_result.py index 8ba41e07..588dc9b7 100644 --- a/src/pytfe/models/task_result.py +++ b/src/pytfe/models/task_result.py @@ -10,9 +10,11 @@ from pydantic import BaseModel, ConfigDict, Field if TYPE_CHECKING: - # Imported only for type checking to avoid a circular import: - # task_stage.py already imports TaskResult. + # Imported only for type checking to avoid circular imports. + from pytfe.models.policy_evaluation import PolicyEvaluation + from pytfe.models.run import Run from pytfe.models.task_stage import TaskStage + from pytfe.models.workspace import Workspace class TaskResultStatus(str, Enum): @@ -71,12 +73,14 @@ class TaskResult(BaseModel): agent_pool_id: str | None = Field(None, alias="agent-pool-id") # Relationships - # Forward-referenced to avoid circular import; resolved lazily below. + # Forward-referenced to avoid circular imports; resolved lazily below. task_stage: TaskStage | None = Field(None, alias="task-stage") - run: dict | None = None - workspace: dict | None = None - - policy_evaluations: list[dict] | None = None + run: Run | None = Field(None, alias="run") + workspace: Workspace | None = Field(None, alias="workspace") + policy_evaluations: list[PolicyEvaluation] | None = Field( + None, + alias="policy-evaluations", + ) @classmethod def model_validate(cls, *args: Any, **kwargs: Any) -> TaskResult: @@ -89,16 +93,24 @@ def model_validate(cls, *args: Any, **kwargs: Any) -> TaskResult: def _rebuild_task_result_model() -> None: - # Resolve the TaskStage forward reference once both modules are loaded. + # Resolve all forward references once all modules are loaded. try: + from pytfe.models.policy_evaluation import PolicyEvaluation + from pytfe.models.run import Run from pytfe.models.task_stage import TaskStage + from pytfe.models.workspace import Workspace TaskResult.model_rebuild( raise_errors=False, - _types_namespace={"TaskStage": TaskStage}, + _types_namespace={ + "PolicyEvaluation": PolicyEvaluation, + "Run": Run, + "TaskStage": TaskStage, + "Workspace": Workspace, + }, ) except Exception: - # TaskStage not yet importable during partial init; safe to skip. + # One or more models not yet importable during partial init; safe to skip. pass diff --git a/src/pytfe/resources/task_result.py b/src/pytfe/resources/task_result.py index b2ba1d75..ed6a7b61 100644 --- a/src/pytfe/resources/task_result.py +++ b/src/pytfe/resources/task_result.py @@ -3,8 +3,11 @@ from typing import Any +from pytfe.models.policy_evaluation import PolicyEvaluation +from pytfe.models.run import Run from pytfe.models.task_result import TaskResult from pytfe.models.task_stage import TaskStage +from pytfe.models.workspace import Workspace from pytfe.utils import valid_string_id from ._base import _Service @@ -26,30 +29,49 @@ def read(self, task_result_id: str) -> TaskResult: return self._parse_task_result(data["data"]) def _parse_task_result(self, data: dict[str, Any]) -> TaskResult: - attributes = data.get("attributes", {}) + # Ensure forward references in TaskResult are resolved before use. + TaskResult.model_rebuild( + raise_errors=False, + _types_namespace={ + "PolicyEvaluation": PolicyEvaluation, + "Run": Run, + "TaskStage": TaskStage, + "Workspace": Workspace, + }, + ) + attributes = data.get("attributes", {}) attributes["id"] = data.get("id") relationships = data.get("relationships", {}) - # Map task-stage relationship into the TaskStage SDK model so callers - # get a typed object rather than a raw {id, type} dict. - if "task-stage" in relationships: - task_stage_data = relationships["task-stage"].get("data") - if task_stage_data: - attributes["task_stage"] = TaskStage.model_validate(task_stage_data) - else: - attributes["task_stage"] = None - - if "run" in relationships: - attributes["run"] = relationships["run"].get("data") - - if "workspace" in relationships: - attributes["workspace"] = relationships["workspace"].get("data") - - if "policy-evaluations" in relationships: - attributes["policy_evaluations"] = relationships["policy-evaluations"].get( - "data" - ) + # Map task-stage relationship into the TaskStage SDK model. + task_stage_data = relationships.get("task-stage", {}).get("data") + if task_stage_data: + attributes["task-stage"] = TaskStage.model_validate(task_stage_data) + else: + attributes["task-stage"] = None + + # Map run relationship into the Run SDK model. + run_data = relationships.get("run", {}).get("data") + if run_data: + attributes["run"] = Run.model_validate(run_data) + else: + attributes["run"] = None + + # Map workspace relationship into the Workspace SDK model. + workspace_data = relationships.get("workspace", {}).get("data") + if workspace_data: + attributes["workspace"] = Workspace.model_validate(workspace_data) + else: + attributes["workspace"] = None + + # Map policy-evaluations relationship into a list of PolicyEvaluation models. + policy_evaluations_data = relationships.get("policy-evaluations", {}).get( + "data", [] + ) + attributes["policy-evaluations"] = [ + PolicyEvaluation.model_validate(pe) for pe in policy_evaluations_data + ] return TaskResult.model_validate(attributes) diff --git a/tests/units/test_task_results.py b/tests/units/test_task_results.py index ff134fc9..13d0ba98 100644 --- a/tests/units/test_task_results.py +++ b/tests/units/test_task_results.py @@ -2,7 +2,11 @@ import pytest +from pytfe.models.policy_evaluation import PolicyEvaluation +from pytfe.models.run import Run from pytfe.models.task_result import TaskResult +from pytfe.models.task_stage import TaskStage +from pytfe.models.workspace import Workspace from pytfe.resources.task_result import TaskResults @@ -121,3 +125,161 @@ def test_timestamps_parsing(self, service, mock_transport): result = service.read("tr-123") assert result.status_timestamps is not None + + # ── Relationship mapping tests ───────────────────────────────────────────── + + def test_task_stage_relationship_mapped(self, service, mock_transport): + response = Mock() + response.json.return_value = { + "data": { + "id": "tr-123", + "attributes": {"status": "passed"}, + "relationships": { + "task-stage": {"data": {"id": "ts-456", "type": "task-stages"}} + }, + } + } + mock_transport.request.return_value = response + + result = service.read("tr-123") + + assert isinstance(result.task_stage, TaskStage) + assert result.task_stage.id == "ts-456" + + def test_task_stage_relationship_null(self, service, mock_transport): + response = Mock() + response.json.return_value = { + "data": { + "id": "tr-123", + "attributes": {"status": "passed"}, + "relationships": {"task-stage": {"data": None}}, + } + } + mock_transport.request.return_value = response + + result = service.read("tr-123") + + assert result.task_stage is None + + def test_run_relationship_mapped(self, service, mock_transport): + response = Mock() + response.json.return_value = { + "data": { + "id": "tr-123", + "attributes": {"status": "passed"}, + "relationships": {"run": {"data": {"id": "run-789", "type": "runs"}}}, + } + } + mock_transport.request.return_value = response + + result = service.read("tr-123") + + assert isinstance(result.run, Run) + assert result.run.id == "run-789" + + def test_run_relationship_null(self, service, mock_transport): + response = Mock() + response.json.return_value = { + "data": { + "id": "tr-123", + "attributes": {"status": "passed"}, + "relationships": {"run": {"data": None}}, + } + } + mock_transport.request.return_value = response + + result = service.read("tr-123") + + assert result.run is None + + def test_workspace_relationship_mapped(self, service, mock_transport): + response = Mock() + response.json.return_value = { + "data": { + "id": "tr-123", + "attributes": {"status": "passed"}, + "relationships": { + "workspace": {"data": {"id": "ws-abc", "type": "workspaces"}} + }, + } + } + mock_transport.request.return_value = response + + result = service.read("tr-123") + + assert isinstance(result.workspace, Workspace) + assert result.workspace.id == "ws-abc" + + def test_workspace_relationship_null(self, service, mock_transport): + response = Mock() + response.json.return_value = { + "data": { + "id": "tr-123", + "attributes": {"status": "passed"}, + "relationships": {"workspace": {"data": None}}, + } + } + mock_transport.request.return_value = response + + result = service.read("tr-123") + + assert result.workspace is None + + def test_policy_evaluations_relationship_mapped(self, service, mock_transport): + response = Mock() + response.json.return_value = { + "data": { + "id": "tr-123", + "attributes": {"status": "passed"}, + "relationships": { + "policy-evaluations": { + "data": [ + {"id": "pe-001", "type": "policy-evaluations"}, + {"id": "pe-002", "type": "policy-evaluations"}, + ] + } + }, + } + } + mock_transport.request.return_value = response + + result = service.read("tr-123") + + assert isinstance(result.policy_evaluations, list) + assert len(result.policy_evaluations) == 2 + assert all(isinstance(pe, PolicyEvaluation) for pe in result.policy_evaluations) + assert result.policy_evaluations[0].id == "pe-001" + assert result.policy_evaluations[1].id == "pe-002" + + def test_policy_evaluations_relationship_empty(self, service, mock_transport): + response = Mock() + response.json.return_value = { + "data": { + "id": "tr-123", + "attributes": {"status": "passed"}, + "relationships": {"policy-evaluations": {"data": []}}, + } + } + mock_transport.request.return_value = response + + result = service.read("tr-123") + + assert result.policy_evaluations == [] + + def test_no_relationships_key(self, service, mock_transport): + """When 'relationships' is absent, all relationship fields stay None.""" + response = Mock() + response.json.return_value = { + "data": { + "id": "tr-123", + "attributes": {"status": "passed"}, + } + } + mock_transport.request.return_value = response + + result = service.read("tr-123") + + assert result.task_stage is None + assert result.run is None + assert result.workspace is None + assert result.policy_evaluations == [] From a7afebe16dab95fe2edb0a4cf911e49300ffdb89 Mon Sep 17 00:00:00 2001 From: Tanya Singh Date: Fri, 22 May 2026 16:26:53 +0530 Subject: [PATCH 70/95] Add run task integration model exports --- src/pytfe/models/__init__.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index d2e8648c..3aafb44f 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -296,6 +296,14 @@ Stage, TaskEnforcementLevel, ) +from .run_task_integration import ( + TaskResultCallbackRequestOptions, + TaskResultOutcome, + TaskResultTag, +) +from .run_task_integration import ( + TaskResultStatus as TaskResultCallbackStatus, +) from .run_trigger import ( RunTrigger, RunTriggerCreateOptions, @@ -654,6 +662,11 @@ "RunTaskCreateOptions", "RunTaskUpdateOptions", "RunTaskReadOptions", + # Run task integration (callback) + "TaskResultCallbackRequestOptions", + "TaskResultCallbackStatus", + "TaskResultOutcome", + "TaskResultTag", # Run triggers "RunTrigger", "RunTriggerCreateOptions", From 1e039d46a3348416108ab5e7ed9d840bdc954848 Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Fri, 22 May 2026 17:55:36 +0530 Subject: [PATCH 71/95] updated resources --- .../resources/organization_audit_configuration.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/pytfe/resources/organization_audit_configuration.py b/src/pytfe/resources/organization_audit_configuration.py index ee51ef59..2962ea28 100644 --- a/src/pytfe/resources/organization_audit_configuration.py +++ b/src/pytfe/resources/organization_audit_configuration.py @@ -39,19 +39,10 @@ def test(self, organization: str) -> OrganizationAuditConfigurationTest: path = f"/api/v2/organizations/{quote(organization)}/audit-configuration/test" response = self.t.request("POST", path) payload = response.json() or {} + if not isinstance(payload, dict): + raise ValueError("Invalid response format") - if isinstance(payload, dict) and "request-id" in payload: - return OrganizationAuditConfigurationTest.model_validate(payload) - - data = payload.get("data") if isinstance(payload, dict) else None - if isinstance(data, dict): - if "request-id" in data: - return OrganizationAuditConfigurationTest.model_validate(data) - attrs = data.get("attributes") - if isinstance(attrs, dict) and "request-id" in attrs: - return OrganizationAuditConfigurationTest.model_validate(attrs) - - return OrganizationAuditConfigurationTest.model_validate({}) + return OrganizationAuditConfigurationTest.model_validate(payload) def update( self, From d5e76bd43f68bdfacfce8c43840a0355ee768d60 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Sat, 23 May 2026 13:30:30 +0530 Subject: [PATCH 72/95] feat: add workspace run task resource and models --- examples/workspace_run_task.py | 159 +++++++++++++++++ src/pytfe/client.py | 2 + src/pytfe/errors.py | 7 + src/pytfe/models/__init__.py | 18 ++ src/pytfe/models/run_task.py | 21 ++- src/pytfe/models/run_task_request.py | 119 +++++++++++++ src/pytfe/models/workspace_run_task.py | 63 ++++++- src/pytfe/resources/run_task.py | 25 --- src/pytfe/resources/workspace_run_task.py | 141 +++++++++++++++ tests/units/test_run_task.py | 4 +- tests/units/test_workspace_run_task.py | 207 ++++++++++++++++++++++ 11 files changed, 729 insertions(+), 37 deletions(-) create mode 100644 examples/workspace_run_task.py create mode 100644 src/pytfe/models/run_task_request.py create mode 100644 src/pytfe/resources/workspace_run_task.py create mode 100644 tests/units/test_workspace_run_task.py diff --git a/examples/workspace_run_task.py b/examples/workspace_run_task.py new file mode 100644 index 00000000..e93077f7 --- /dev/null +++ b/examples/workspace_run_task.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +""" +Workspace Run Task Operations Example + +Demonstrates workspace run task operations: +1. create() - Attach a run task to a workspace +2. list() - List all workspace run tasks for a workspace +3. read() - Read a workspace run task by ID +4. update() - Update enforcement/stage settings +5. delete() - Delete a workspace run task + +Prerequisites: +- Set TFE_TOKEN and TFE_ADDRESS environment variables +- Use valid workspace and run task IDs +""" + +import argparse + +from pytfe import TFEClient, TFEConfig +from pytfe.models import ( + RunTask, + WorkspaceRunTaskCreateOptions, + WorkspaceRunTaskUpdateOptions, +) + + +def _find_matching_workspace_run_task(items, run_task_id: str): + for item in items: + if item.run_task and item.run_task.id == run_task_id: + return item + + if len(items) == 1: + return items[0] + + return None + + +def main(): + parser = argparse.ArgumentParser( + description="Workspace run task operations demo for python-tfe SDK" + ) + parser.add_argument( + "--workspace-id", + required=True, + help="Workspace ID (example: ws-abc123)", + ) + parser.add_argument( + "--run-task-id", + required=True, + help="Run task ID (example: task-abc123)", + ) + parser.add_argument( + "--delete-existing", + action="store_true", + help="Allow delete() to remove a workspace run task that existed before this example ran", + ) + args = parser.parse_args() + + client = TFEClient(TFEConfig.from_env()) + + workspace_id = args.workspace_id + run_task_id = args.run_task_id + + if workspace_id == "ws-xxxxxxxx" or run_task_id == "task-xxxxxxxx": + print("Please provide real IDs for --workspace-id and --run-task-id") + return + + print("=" * 80) + print("WORKSPACE RUN TASK OPERATIONS") + print("=" * 80) + + workspace_task = None + created_in_run = False + + print("\n1. create()") + try: + create_options = WorkspaceRunTaskCreateOptions( + enforcement_level="advisory", + run_task=RunTask(id=run_task_id), + stages=["post_plan"], + ) + workspace_task = client.workspace_run_tasks.create(workspace_id, create_options) + created_in_run = True + print(f"Created workspace run task: {workspace_task.id}") + except Exception as exc: + print(f"Create result: {exc}") + + print("\n2. list()") + items = [] + try: + items = list(client.workspace_run_tasks.list(workspace_id)) + print(f"Found {len(items)} workspace run task(s)") + for item in items: + run_task = item.run_task.id if item.run_task else None + print( + f"- {item.id} run_task={run_task} enforcement={item.enforcement_level} stages={item.stages}" + ) + + if workspace_task is None: + workspace_task = _find_matching_workspace_run_task(items, run_task_id) + if workspace_task is not None: + print(f"Using existing workspace run task: {workspace_task.id}") + except Exception as exc: + print(f"List failed: {exc}") + + print("\n3. read()") + if workspace_task is None: + print("Read skipped: no workspace run task ID available") + else: + try: + workspace_task = client.workspace_run_tasks.read( + workspace_id, workspace_task.id + ) + print( + f"Read workspace run task: {workspace_task.id} enforcement={workspace_task.enforcement_level} stages={workspace_task.stages}" + ) + except Exception as exc: + print(f"Read failed: {exc}") + + print("\n4. update()") + if workspace_task is None: + print("Update skipped: no workspace run task ID available") + else: + try: + update_options = WorkspaceRunTaskUpdateOptions( + # enforcement_level="mandatory", + stages=["post_plan", "pre_plan"], + ) + workspace_task = client.workspace_run_tasks.update( + workspace_id, workspace_task.id, update_options + ) + print( + f"Updated workspace run task: {workspace_task.id} enforcement={workspace_task.enforcement_level} stages={workspace_task.stages}" + ) + except Exception as exc: + print(f"Update failed: {exc}") + + print("\n5. delete()") + if workspace_task is None: + print("Delete skipped: no workspace run task ID available") + elif not created_in_run and not args.delete_existing: + print( + "Delete skipped: workspace run task existed before this example. " + "Re-run with --delete-existing to delete it." + ) + else: + try: + client.workspace_run_tasks.delete(workspace_id, workspace_task.id) + print(f"Deleted workspace run task: {workspace_task.id}") + except Exception as exc: + print(f"Delete failed: {exc}") + + print("=" * 80) + print("WORKSPACE RUN TASK OPERATIONS COMPLETED") + print("=" * 80) + + +if __name__ == "__main__": + main() diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 409a36c3..63f9286c 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -52,6 +52,7 @@ from .resources.variable import Variables from .resources.variable_sets import VariableSets, VariableSetVariables from .resources.workspace_resources import WorkspaceResourcesService +from .resources.workspace_run_task import WorkspaceRunTasks from .resources.workspaces import Workspaces @@ -103,6 +104,7 @@ def __init__(self, config: TFEConfig | None = None): self.variable_set_variables = VariableSetVariables(self._transport) self.workspaces = Workspaces(self._transport) self.workspace_resources = WorkspaceResourcesService(self._transport) + self.workspace_run_tasks = WorkspaceRunTasks(self._transport) self.registry_modules = RegistryModules(self._transport) self.registry_providers = RegistryProviders(self._transport) self.registry_provider_versions = RegistryProviderVersions(self._transport) diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index 4cfa6013..832bb092 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -338,6 +338,13 @@ def __init__(self, message: str = 'category must be "task"'): super().__init__(message) +class InvalidWorkspaceRunTaskIDError(InvalidValues): + """Raised when an invalid workspace run task ID is provided.""" + + def __init__(self, message: str = "invalid value for workspace run task ID"): + super().__init__(message) + + # Run Trigger errors class RequiredRunTriggerListOpsError(RequiredFieldMissing): """Raised when required run trigger list options are missing.""" diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index f7cda576..a6263aee 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -328,6 +328,10 @@ from .run_task_integration import ( TaskResultStatus as TaskResultCallbackStatus, ) +from .run_task_request import ( + RunTaskRequest, + RunTaskRequestCapabilitites, +) from .run_trigger import ( RunTrigger, RunTriggerCreateOptions, @@ -456,6 +460,12 @@ WorkspaceResource, WorkspaceResourceListOptions, ) +from .workspace_run_task import ( + WorkspaceRunTask, + WorkspaceRunTaskCreateOptions, + WorkspaceRunTaskListOptions, + WorkspaceRunTaskUpdateOptions, +) # ── Public surface ──────────────────────────────────────────────────────────── __all__ = [ @@ -684,6 +694,11 @@ # Workspace Resources "WorkspaceResource", "WorkspaceResourceListOptions", + # Workspace Run Tasks + "WorkspaceRunTask", + "WorkspaceRunTaskListOptions", + "WorkspaceRunTaskCreateOptions", + "WorkspaceRunTaskUpdateOptions", "RunQueue", "ReadRunQueueOptions", # Runs @@ -728,6 +743,9 @@ "RunTaskCreateOptions", "RunTaskUpdateOptions", "RunTaskReadOptions", + # Run Task Request + "RunTaskRequest", + "RunTaskRequestCapabilitites", # Task Result "TaskResult", "TaskResultEnforcementLevel", diff --git a/src/pytfe/models/run_task.py b/src/pytfe/models/run_task.py index 11c2bea3..0132ba34 100644 --- a/src/pytfe/models/run_task.py +++ b/src/pytfe/models/run_task.py @@ -4,23 +4,26 @@ from __future__ import annotations from enum import Enum +from typing import TYPE_CHECKING from pydantic import BaseModel, Field from ..models.common import Pagination from .agent import AgentPool from .organization import Organization -from .workspace_run_task import WorkspaceRunTask + +if TYPE_CHECKING: + from .workspace_run_task import WorkspaceRunTask class RunTask(BaseModel): id: str - name: str + name: str | None = None description: str | None = None - url: str - category: str + url: str | None = None + category: str | None = None hmac_key: str | None = None - enabled: bool + enabled: bool | None = None global_configuration: GlobalRunTask | None = None agent_pool: AgentPool | None = None @@ -41,10 +44,10 @@ class GlobalRunTaskOptions(BaseModel): class Stage(str, Enum): - PRE_PLAN = "pre-plan" - POST_PLAN = "post-plan" - PRE_APPLY = "pre-apply" - POST_APPLY = "post-apply" + PRE_PLAN = "pre_plan" + POST_PLAN = "post_plan" + PRE_APPLY = "pre_apply" + POST_APPLY = "post_apply" class TaskEnforcementLevel(str, Enum): diff --git a/src/pytfe/models/run_task_request.py b/src/pytfe/models/run_task_request.py new file mode 100644 index 00000000..b2510460 --- /dev/null +++ b/src/pytfe/models/run_task_request.py @@ -0,0 +1,119 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class RunTaskRequestCapabilitites(BaseModel): + """Defines the capabilities that the caller supports.""" + + model_config = ConfigDict(populate_by_name=True) + + outcomes: bool = Field(..., description="Whether the caller supports outcomes") + + +class RunTaskRequest(BaseModel): + """Payload object that TFC/E sends to the Run Task's URL. + + https://developer.hashicorp.com/terraform/enterprise/api-docs/run-tasks/run-tasks-integration#common-properties + """ + + model_config = ConfigDict(populate_by_name=True) + + access_token: str = Field( + ..., alias="access_token", description="The access token for the run task" + ) + capabilitites: RunTaskRequestCapabilitites = Field( + default_factory=lambda: RunTaskRequestCapabilitites(outcomes=False), + alias="capabilitites", + description="The capabilities that the caller supports", + ) + configuration_version_download_url: str | None = Field( + None, + alias="configuration_version_download_url", + description="The URL to download the configuration version", + ) + configuration_version_id: str | None = Field( + None, + alias="configuration_version_id", + description="The ID of the configuration version", + ) + is_speculative: bool = Field( + ..., alias="is_speculative", description="Whether the run is speculative" + ) + organization_name: str = Field( + ..., alias="organization_name", description="The name of the organization" + ) + payload_version: int = Field( + ..., alias="payload_version", description="The version of the payload format" + ) + plan_json_api_url: str | None = Field( + None, + alias="plan_json_api_url", + description="URL to the plan JSON API (specific to post_plan, pre_apply or post_apply stage)", + ) + run_app_url: str = Field( + ..., alias="run_app_url", description="The URL to the run in the TFC/E UI" + ) + run_created_at: datetime = Field( + ..., alias="run_created_at", description="The time the run was created" + ) + run_created_by: str = Field( + ..., alias="run_created_by", description="The user who created the run" + ) + run_id: str = Field(..., alias="run_id", description="The ID of the run") + run_message: str = Field( + ..., alias="run_message", description="The message associated with the run" + ) + stage: str = Field(..., alias="stage", description="The stage of the run task") + task_result_callback_url: str = Field( + ..., + alias="task_result_callback_url", + description="The URL to call with the task result", + ) + task_result_enforcement_level: str = Field( + ..., + alias="task_result_enforcement_level", + description="The enforcement level of the task result", + ) + task_result_id: str = Field( + ..., alias="task_result_id", description="The ID of the task result" + ) + vcs_branch: str | None = Field( + None, alias="vcs_branch", description="The VCS branch associated with the run" + ) + vcs_commit_url: str | None = Field( + None, + alias="vcs_commit_url", + description="The URL of the VCS commit associated with the run", + ) + vcs_pull_request_url: str | None = Field( + None, + alias="vcs_pull_request_url", + description="The URL of the VCS pull request associated with the run", + ) + vcs_repo_url: str | None = Field( + None, + alias="vcs_repo_url", + description="The URL of the VCS repository associated with the run", + ) + workspace_app_url: str = Field( + ..., + alias="workspace_app_url", + description="The URL to the workspace in the TFC/E UI", + ) + workspace_id: str = Field( + ..., alias="workspace_id", description="The ID of the workspace" + ) + workspace_name: str = Field( + ..., alias="workspace_name", description="The name of the workspace" + ) + workspace_working_directory: str | None = Field( + None, + alias="workspace_working_directory", + description="The working directory configured for the workspace", + ) diff --git a/src/pytfe/models/workspace_run_task.py b/src/pytfe/models/workspace_run_task.py index f29695ed..d92fb328 100644 --- a/src/pytfe/models/workspace_run_task.py +++ b/src/pytfe/models/workspace_run_task.py @@ -3,8 +3,69 @@ from __future__ import annotations -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from ..errors import InvalidRunTaskIDError +from .run_task import ( + RunTask, + Stage, + TaskEnforcementLevel, +) +from .workspace import Workspace class WorkspaceRunTask(BaseModel): + """Workspace run task model.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + id: str + enforcement_level: TaskEnforcementLevel | None = Field( + default=None, validation_alias="enforcement-level" + ) + stages: list[Stage] = Field(default_factory=list, alias="stages") + run_task: RunTask | None = Field(default=None, alias="task") + workspace: Workspace | None = Field(default=None, alias="workspace") + + +class WorkspaceRunTaskListOptions(BaseModel): + """Options for listing workspace run tasks.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + page_size: int | None = Field(default=None, alias="page[size]") + + +class WorkspaceRunTaskCreateOptions(BaseModel): + """Options for creating a workspace run task.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + type: str = Field(default="workspace-tasks") + enforcement_level: TaskEnforcementLevel = Field(..., alias="enforcement-level") + run_task: RunTask = Field(..., alias="task") + stages: list[Stage] | None = Field(default=None, alias="stages") + + @model_validator(mode="after") + def valid(self) -> WorkspaceRunTaskCreateOptions: + """Validate the options for creating a workspace run task.""" + if not self.run_task.id: + raise InvalidRunTaskIDError() + return self + + +class WorkspaceRunTaskUpdateOptions(BaseModel): + """Options for updating a workspace run task.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + type: str = Field(default="workspace-tasks") + enforcement_level: TaskEnforcementLevel | None = Field( + default=None, alias="enforcement-level" + ) + stages: list[Stage] | None = Field(default=None, alias="stages") + + +# WorkspaceRunTask is now fully defined; rebuild RunTask so Pydantic can +# resolve the forward reference in RunTask.workspace_run_tasks. +RunTask.model_rebuild() diff --git a/src/pytfe/resources/run_task.py b/src/pytfe/resources/run_task.py index 952ca653..69a34f68 100644 --- a/src/pytfe/resources/run_task.py +++ b/src/pytfe/resources/run_task.py @@ -262,28 +262,3 @@ def delete(self, run_task_id: str) -> None: if not valid_string_id(run_task_id): raise InvalidRunTaskIDError() self.t.request("DELETE", f"/api/v2/tasks/{run_task_id}") - - def attach_to_workspace( - self, - workspace_id: str, - run_task_id: str, - enforcement_level: TaskEnforcementLevel, - ) -> WorkspaceRunTask: - """ - Attach a run task to a workspace. - - This is a convenience method that creates a workspace run task relationship. - """ - # This would typically delegate to workspace_run_tasks.create() - # For now, we'll create a placeholder implementation - # In a real implementation, this would call: - """ - create_options = WorkspaceRunTaskCreateOptions( - enforcement_level=enforcement_level, - run_task=RunTask(id=run_task_id, name="", url="", category="task", enabled=True) - ) - return workspace_run_tasks.create(workspace_id, create_options) - """ - - # TODO: Implement actual workspace run task creation - raise NotImplementedError("attach_to_workspace method needs to be implemented") diff --git a/src/pytfe/resources/workspace_run_task.py b/src/pytfe/resources/workspace_run_task.py new file mode 100644 index 00000000..7d20a868 --- /dev/null +++ b/src/pytfe/resources/workspace_run_task.py @@ -0,0 +1,141 @@ +# 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 urllib.parse import quote + +from ..errors import ( + InvalidWorkspaceIDError, + InvalidWorkspaceRunTaskIDError, +) +from ..models.run_task import RunTask +from ..models.workspace import Workspace +from ..models.workspace_run_task import ( + WorkspaceRunTask, + WorkspaceRunTaskCreateOptions, + WorkspaceRunTaskListOptions, + WorkspaceRunTaskUpdateOptions, +) +from ..utils import _safe_str, valid_string_id +from ._base import _Service + + +def _workspace_run_task_from(data: dict[str, Any]) -> WorkspaceRunTask: + """Convert API response data to WorkspaceRunTask model.""" + attributes = data.get("attributes", {}) + relationships = data.get("relationships", {}) + attributes["id"] = data.get("id") + + run_task_data = relationships.get("task", {}).get("data") + if isinstance(run_task_data, dict) and run_task_data.get("id"): + attributes["run_task"] = RunTask.model_construct( + id=_safe_str(run_task_data.get("id")) + ) + workspace_data = relationships.get("workspace", {}).get("data") + if isinstance(workspace_data, dict) and workspace_data.get("id"): + attributes["workspace"] = Workspace.model_construct( + id=_safe_str(workspace_data.get("id")) + ) + + return WorkspaceRunTask.model_validate(attributes) + + +class WorkspaceRunTasks(_Service): + """Workspace run tasks service.""" + + def create( + self, workspace_id: str, options: WorkspaceRunTaskCreateOptions + ) -> WorkspaceRunTask: + """Attach a run task to a workspace.""" + if not valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + + body: dict[str, Any] = { + "data": { + "type": "workspace-tasks", + "attributes": { + "enforcement-level": options.enforcement_level, + }, + "relationships": { + "task": {"data": {"type": "tasks", "id": options.run_task.id}} + }, + } + } + + if options.stages is not None: + body["data"]["attributes"]["stages"] = options.stages + + path = f"/api/v2/workspaces/{quote(workspace_id)}/tasks" + response = self.t.request("POST", path, json_body=body) + return _workspace_run_task_from(response.json()["data"]) + + def list( + self, + workspace_id: str, + options: WorkspaceRunTaskListOptions | None = None, + ) -> Iterator[WorkspaceRunTask]: + """List all workspace run tasks for a workspace.""" + if not valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + + params = options.model_dump(by_alias=True, exclude_none=True) if options else {} + path = f"/api/v2/workspaces/{quote(workspace_id)}/tasks" + for item in self._list(path, params=params): + yield _workspace_run_task_from(item) + + def read(self, workspace_id: str, workspace_task_id: str) -> WorkspaceRunTask: + """Read a workspace run task by ID.""" + if not valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + if not valid_string_id(workspace_task_id): + raise InvalidWorkspaceRunTaskIDError() + + path = ( + f"/api/v2/workspaces/{quote(workspace_id)}/tasks/{quote(workspace_task_id)}" + ) + response = self.t.request("GET", path) + return _workspace_run_task_from(response.json()["data"]) + + def update( + self, + workspace_id: str, + workspace_task_id: str, + options: WorkspaceRunTaskUpdateOptions, + ) -> WorkspaceRunTask: + """Update a workspace run task by ID.""" + if not valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + if not valid_string_id(workspace_task_id): + raise InvalidWorkspaceRunTaskIDError() + + attributes = options.model_dump( + by_alias=True, exclude_none=True, exclude={"type"} + ) + body: dict[str, Any] = { + "data": { + "type": "workspace-tasks", + "id": workspace_task_id, + "attributes": attributes, + } + } + + path = ( + f"/api/v2/workspaces/{quote(workspace_id)}/tasks/{quote(workspace_task_id)}" + ) + response = self.t.request("PATCH", path, json_body=body) + return _workspace_run_task_from(response.json()["data"]) + + def delete(self, workspace_id: str, workspace_task_id: str) -> None: + """Delete a workspace run task by ID.""" + if not valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + if not valid_string_id(workspace_task_id): + raise InvalidWorkspaceRunTaskIDError() + + path = ( + f"/api/v2/workspaces/{quote(workspace_id)}/tasks/{quote(workspace_task_id)}" + ) + self.t.request("DELETE", path) diff --git a/tests/units/test_run_task.py b/tests/units/test_run_task.py index cb197e3a..16173705 100644 --- a/tests/units/test_run_task.py +++ b/tests/units/test_run_task.py @@ -45,7 +45,7 @@ def test_run_task_from_comprehensive(self): "enabled": True, "global-configuration": { "enabled": True, - "stages": ["pre-plan", "post-apply"], + "stages": ["pre_plan", "post_apply"], "enforcement-level": "mandatory", }, }, @@ -224,7 +224,7 @@ def test_create_run_task(self, run_tasks_service): "hmac_key": "secret-key-123", "global-configuration": { "enabled": True, - "stages": ["pre-plan", "post-plan"], + "stages": ["pre_plan", "post_plan"], "enforcement-level": "mandatory", }, }, diff --git a/tests/units/test_workspace_run_task.py b/tests/units/test_workspace_run_task.py new file mode 100644 index 00000000..9cd6ac57 --- /dev/null +++ b/tests/units/test_workspace_run_task.py @@ -0,0 +1,207 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for workspace run tasks.""" + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import InvalidWorkspaceIDError, InvalidWorkspaceRunTaskIDError +from pytfe.models import ( + WorkspaceRunTask, + WorkspaceRunTaskCreateOptions, + WorkspaceRunTaskListOptions, + WorkspaceRunTaskUpdateOptions, +) +from pytfe.resources.workspace_run_task import ( + WorkspaceRunTasks, + _workspace_run_task_from, +) + + +class TestWorkspaceRunTaskFrom: + def test_workspace_run_task_from_full(self): + data = { + "id": "wst-123", + "attributes": { + "enforcement-level": "mandatory", + "stage": "post_plan", + "stages": ["post_plan", "pre_apply"], + }, + "relationships": { + "task": {"data": {"id": "task-123", "type": "tasks"}}, + "workspace": {"data": {"id": "ws-123", "type": "workspaces"}}, + }, + } + + result = _workspace_run_task_from(data) + + assert isinstance(result, WorkspaceRunTask) + assert result.id == "wst-123" + assert result.enforcement_level == "mandatory" + assert result.stages == ["post_plan", "pre_apply"] + assert result.run_task is not None + assert result.run_task.id == "task-123" + assert result.workspace is not None + assert result.workspace.id == "ws-123" + + +class TestWorkspaceRunTasks: + @pytest.fixture + def mock_transport(self): + return Mock(spec=HTTPTransport) + + @pytest.fixture + def workspace_run_tasks_service(self, mock_transport): + return WorkspaceRunTasks(mock_transport) + + def test_create_success(self, workspace_run_tasks_service, mock_transport): + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "id": "wst-1", + "attributes": { + "enforcement-level": "advisory", + "stages": ["post_plan"], + }, + "relationships": { + "task": {"data": {"id": "task-1", "type": "tasks"}}, + "workspace": {"data": {"id": "ws-1", "type": "workspaces"}}, + }, + } + } + mock_transport.request.return_value = mock_response + + options = WorkspaceRunTaskCreateOptions( + enforcement_level="advisory", + run_task={"id": "task-1"}, + stages=["post_plan"], + ) + + result = workspace_run_tasks_service.create("ws-1", options) + + assert isinstance(result, WorkspaceRunTask) + assert result.id == "wst-1" + call_args = mock_transport.request.call_args + assert call_args[0][0] == "POST" + assert call_args[0][1] == "/api/v2/workspaces/ws-1/tasks" + + def test_create_validation_errors(self, workspace_run_tasks_service): + options = WorkspaceRunTaskCreateOptions( + enforcement_level="advisory", + run_task={"id": "task-1"}, + ) + + with pytest.raises(InvalidWorkspaceIDError): + workspace_run_tasks_service.create("", options) + + def test_list_success(self, workspace_run_tasks_service): + workspace_run_tasks_service._list = Mock( + return_value=[ + { + "id": "wst-1", + "attributes": {"enforcement-level": "advisory", "stages": []}, + "relationships": {}, + }, + { + "id": "wst-2", + "attributes": { + "enforcement-level": "mandatory", + "stages": ["pre_apply"], + }, + "relationships": {}, + }, + ] + ) + + options = WorkspaceRunTaskListOptions(page_size=10) + items = list(workspace_run_tasks_service.list("ws-1", options)) + + workspace_run_tasks_service._list.assert_called_once_with( + "/api/v2/workspaces/ws-1/tasks", + params={"page[size]": 10}, + ) + assert len(items) == 2 + assert items[0].id == "wst-1" + assert items[1].id == "wst-2" + + def test_list_validation_error(self, workspace_run_tasks_service): + with pytest.raises(InvalidWorkspaceIDError): + list(workspace_run_tasks_service.list("")) + + def test_read_success(self, workspace_run_tasks_service, mock_transport): + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "id": "wst-1", + "attributes": { + "enforcement-level": "advisory", + "stages": ["post_plan"], + }, + "relationships": {}, + } + } + mock_transport.request.return_value = mock_response + + result = workspace_run_tasks_service.read("ws-1", "wst-1") + + assert isinstance(result, WorkspaceRunTask) + assert result.id == "wst-1" + mock_transport.request.assert_called_once_with( + "GET", "/api/v2/workspaces/ws-1/tasks/wst-1" + ) + + def test_read_validation_error(self, workspace_run_tasks_service): + with pytest.raises(InvalidWorkspaceIDError): + workspace_run_tasks_service.read("", "wst-1") + with pytest.raises(InvalidWorkspaceRunTaskIDError): + workspace_run_tasks_service.read("ws-1", "") + + def test_update_success(self, workspace_run_tasks_service, mock_transport): + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "id": "wst-1", + "attributes": { + "enforcement-level": "mandatory", + "stages": ["post_plan", "pre_apply"], + }, + "relationships": {}, + } + } + mock_transport.request.return_value = mock_response + + options = WorkspaceRunTaskUpdateOptions( + enforcement_level="mandatory", + stages=["post_plan", "pre_apply"], + ) + result = workspace_run_tasks_service.update("ws-1", "wst-1", options) + + assert isinstance(result, WorkspaceRunTask) + assert result.enforcement_level == "mandatory" + call_args = mock_transport.request.call_args + assert call_args[0][0] == "PATCH" + assert call_args[0][1] == "/api/v2/workspaces/ws-1/tasks/wst-1" + + def test_update_validation_error(self, workspace_run_tasks_service): + options = WorkspaceRunTaskUpdateOptions(enforcement_level="mandatory") + + with pytest.raises(InvalidWorkspaceIDError): + workspace_run_tasks_service.update("", "wst-1", options) + with pytest.raises(InvalidWorkspaceRunTaskIDError): + workspace_run_tasks_service.update("ws-1", "", options) + + def test_delete_success(self, workspace_run_tasks_service, mock_transport): + workspace_run_tasks_service.delete("ws-1", "wst-1") + + mock_transport.request.assert_called_once_with( + "DELETE", "/api/v2/workspaces/ws-1/tasks/wst-1" + ) + + def test_delete_validation_error(self, workspace_run_tasks_service): + with pytest.raises(InvalidWorkspaceIDError): + workspace_run_tasks_service.delete("", "wst-1") + with pytest.raises(InvalidWorkspaceRunTaskIDError): + workspace_run_tasks_service.delete("ws-1", "") From f8f762785f5de5a3d6e2105dcbadebff15fe3eac Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Sat, 23 May 2026 14:59:15 +0530 Subject: [PATCH 73/95] feat(task-stage): add TaskStage resource and models --- examples/task_stage_example.py | 60 +++++ src/pytfe/client.py | 2 + src/pytfe/errors.py | 2 +- src/pytfe/models/__init__.py | 22 +- src/pytfe/models/run.py | 11 +- src/pytfe/models/task_result.py | 61 +---- src/pytfe/models/task_stage.py | 87 ++++++-- src/pytfe/models/workspace.py | 1 + src/pytfe/resources/task_result.py | 50 +---- src/pytfe/resources/task_stage.py | 107 +++++++++ tests/test_task_stage.py | 343 +++++++++++++++++++++++++++++ tests/units/test_task_results.py | 113 +--------- 12 files changed, 624 insertions(+), 235 deletions(-) create mode 100644 examples/task_stage_example.py create mode 100644 src/pytfe/resources/task_stage.py create mode 100644 tests/test_task_stage.py diff --git a/examples/task_stage_example.py b/examples/task_stage_example.py new file mode 100644 index 00000000..d16f6373 --- /dev/null +++ b/examples/task_stage_example.py @@ -0,0 +1,60 @@ +""" +Example usage of TaskStages API + +Demonstrates: +- Read a task stage +- List task stages for a run +- Override a task stage +""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from pytfe import TFEClient, TFEConfig + + +def main(): + client = TFEClient(TFEConfig.from_env()) + + task_stage_id = os.getenv("TFE_TASK_STAGE_ID") + run_id = os.getenv("TFE_RUN_ID") + + if not task_stage_id or not run_id: + print("Please set TFE_TASK_STAGE_ID and TFE_RUN_ID") + return + + print("=== TaskStages Example ===") + + # READ + print("\nReading task stage...") + try: + stage = client.task_stages.read(task_stage_id) + print(f"ID: {stage.id}") + print(f"Stage: {stage.stage}") + print(f"Status: {stage.status}") + print(f"Run: {stage.run.id if stage.run else None}") + except Exception as e: + print(f"Read failed: {e}") + + # LIST + print("\nListing task stages...") + try: + stages = list(client.task_stages.list(run_id)) + for s in stages: + print(f"{s.id} - {s.status}") + except Exception as e: + print(f"List failed: {e}") + + # OVERRIDE + print("\nOverriding task stage...") + try: + client.task_stages.override(task_stage_id, comment="Approved") + print("Override successful") + except Exception as e: + print(f"Override failed: {e}") + + +if __name__ == "__main__": + main() diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 409a36c3..8f3fd53d 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -45,6 +45,7 @@ from .resources.state_version_outputs import StateVersionOutputs from .resources.state_versions import StateVersions from .resources.task_result import TaskResults +from .resources.task_stage import TaskStages from .resources.team import Teams from .resources.team_project_access import TeamProjectAccesses from .resources.team_token import TeamTokens @@ -119,6 +120,7 @@ def __init__(self, config: TFEConfig | None = None): self.run_task_integrations = RunTaskIntegrations(self._transport) self.run_triggers = RunTriggers(self._transport) self.runs = Runs(self._transport) + self.task_stages = TaskStages(self._transport) self.query_runs = QueryRuns(self._transport) self.run_events = RunEvents(self._transport) self.comments = Comments(self._transport) diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index 4cfa6013..5f1ebd84 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -490,7 +490,7 @@ def __init__(self, message: str = "must provide at least one policy"): # Policy Evaluation errors class InvalidTaskStageIDError(InvalidValues): - """Raised when an invalid task stage ID is provided.""" + """Raised when a task stage ID is invalid.""" def __init__(self, message: str = "invalid value for task stage ID"): super().__init__(message) diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index f7cda576..9156572b 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -371,6 +371,8 @@ from .task_result import ( TaskEnforcementLevel as TaskResultEnforcementLevel, ) + +# ── Task Stage & Task Result ───────────────────────────────────────────────── from .task_result import ( TaskResult, TaskResultStatus, @@ -713,6 +715,9 @@ "RunEventList", "RunEventListOptions", "RunEventReadOptions", + # Task Stage & Task Result + "TaskStage", + "TaskResult", # Comments "Comment", "CommentCreateOptions", @@ -826,14 +831,21 @@ RegistryProvider.model_rebuild() RegistryProviderVersion.model_rebuild() RegistryProviderPlatform.model_rebuild() - -# Rebuild TaskResult to resolve Run, Workspace, PolicyEvaluation, TaskStage refs -TaskResult.model_rebuild( +Run.model_rebuild( + raise_errors=False, + _types_namespace={"TaskStage": TaskStage}, +) +TaskStage.model_rebuild( raise_errors=False, _types_namespace={ - "PolicyEvaluation": PolicyEvaluation, "Run": Run, + "TaskResult": TaskResult, + "PolicyEvaluation": PolicyEvaluation, + }, +) +TaskResult.model_rebuild( + raise_errors=False, + _types_namespace={ "TaskStage": TaskStage, - "Workspace": Workspace, }, ) diff --git a/src/pytfe/models/run.py b/src/pytfe/models/run.py index 01185e89..5a82f7fa 100644 --- a/src/pytfe/models/run.py +++ b/src/pytfe/models/run.py @@ -5,6 +5,7 @@ from datetime import datetime from enum import Enum +from typing import TYPE_CHECKING from pydantic import BaseModel, ConfigDict, Field @@ -15,10 +16,12 @@ from .plan import Plan from .policy_check import PolicyCheck from .run_event import RunEvent -from .task_stage import TaskStage from .user import User from .workspace import Workspace +if TYPE_CHECKING: + from .task_stage import TaskStage + class RunSource(str, Enum): """RunSource represents a source type of a run.""" @@ -327,6 +330,6 @@ class RunDiscardOptions(BaseModel): # Rebuild models to resolve forward references -Run.model_rebuild() -RunList.model_rebuild() -OrganizationRunList.model_rebuild() +Run.model_rebuild(raise_errors=False) +RunList.model_rebuild(raise_errors=False) +OrganizationRunList.model_rebuild(raise_errors=False) diff --git a/src/pytfe/models/task_result.py b/src/pytfe/models/task_result.py index 588dc9b7..3e2af561 100644 --- a/src/pytfe/models/task_result.py +++ b/src/pytfe/models/task_result.py @@ -5,16 +5,12 @@ from datetime import datetime from enum import Enum -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from pydantic import BaseModel, ConfigDict, Field if TYPE_CHECKING: - # Imported only for type checking to avoid circular imports. - from pytfe.models.policy_evaluation import PolicyEvaluation - from pytfe.models.run import Run from pytfe.models.task_stage import TaskStage - from pytfe.models.workspace import Workspace class TaskResultStatus(str, Enum): @@ -32,7 +28,7 @@ class TaskEnforcementLevel(str, Enum): class TaskResultStatusTimestamps(BaseModel): - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) errored_at: datetime | None = Field(None, alias="errored-at") running_at: datetime | None = Field(None, alias="running-at") @@ -42,7 +38,7 @@ class TaskResultStatusTimestamps(BaseModel): class TaskResult(BaseModel): - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) id: str @@ -50,8 +46,7 @@ class TaskResult(BaseModel): message: str | None = Field(None, alias="message") status_timestamps: TaskResultStatusTimestamps | None = Field( - None, - alias="status-timestamps", + None, alias="status-timestamps" ) url: str | None = Field(None, alias="url") @@ -64,54 +59,10 @@ class TaskResult(BaseModel): task_url: str | None = Field(None, alias="task-url") workspace_task_id: str | None = Field(None, alias="workspace-task-id") - workspace_task_enforcement_level: TaskEnforcementLevel | None = Field( - None, - alias="workspace-task-enforcement-level", + None, alias="workspace-task-enforcement-level" ) agent_pool_id: str | None = Field(None, alias="agent-pool-id") - - # Relationships - # Forward-referenced to avoid circular imports; resolved lazily below. + # relations task_stage: TaskStage | None = Field(None, alias="task-stage") - run: Run | None = Field(None, alias="run") - workspace: Workspace | None = Field(None, alias="workspace") - policy_evaluations: list[PolicyEvaluation] | None = Field( - None, - alias="policy-evaluations", - ) - - @classmethod - def model_validate(cls, *args: Any, **kwargs: Any) -> TaskResult: - # Ensure the TaskStage forward reference is resolved before validating. - # The import-time rebuild may run while task_stage.py is still - # partially loaded (circular import), in which case we retry here. - if not getattr(cls, "__pydantic_complete__", True): - _rebuild_task_result_model() - return super().model_validate(*args, **kwargs) - - -def _rebuild_task_result_model() -> None: - # Resolve all forward references once all modules are loaded. - try: - from pytfe.models.policy_evaluation import PolicyEvaluation - from pytfe.models.run import Run - from pytfe.models.task_stage import TaskStage - from pytfe.models.workspace import Workspace - - TaskResult.model_rebuild( - raise_errors=False, - _types_namespace={ - "PolicyEvaluation": PolicyEvaluation, - "Run": Run, - "TaskStage": TaskStage, - "Workspace": Workspace, - }, - ) - except Exception: - # One or more models not yet importable during partial init; safe to skip. - pass - - -_rebuild_task_result_model() diff --git a/src/pytfe/models/task_stage.py b/src/pytfe/models/task_stage.py index bae0766b..a5f338ef 100644 --- a/src/pytfe/models/task_stage.py +++ b/src/pytfe/models/task_stage.py @@ -3,23 +3,82 @@ from __future__ import annotations -from pydantic import BaseModel, ConfigDict +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field + +from pytfe.models.policy_evaluation import PolicyEvaluation +from pytfe.models.run import Run +from pytfe.models.task_result import TaskResult + + +class Stage(str, Enum): + pre_plan = "pre_plan" + post_plan = "post_plan" + pre_apply = "pre_apply" + post_apply = "post_apply" + + +class TaskStageStatus(str, Enum): + pending = "pending" + running = "running" + passed = "passed" + failed = "failed" + awaiting_override = "awaiting_override" + canceled = "canceled" + errored = "errored" + unreachable = "unreachable" + + +class TaskStageStatusTimestamps(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + errored_at: datetime | None = Field(None, alias="errored-at") + running_at: datetime | None = Field(None, alias="running-at") + canceled_at: datetime | None = Field(None, alias="canceled-at") + failed_at: datetime | None = Field(None, alias="failed-at") + passed_at: datetime | None = Field(None, alias="passed-at") + + +class Permissions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + can_override_policy: bool | None = Field(None, alias="can-override-policy") + can_override_tasks: bool | None = Field(None, alias="can-override-tasks") + can_override: bool | None = Field(None, alias="can-override") + + +class Actions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + is_overridable: bool | None = Field(None, alias="is-overridable") -# TaskStage represents a HCP Terraform or Terraform Enterprise run's stage where run tasks can occur class TaskStage(BaseModel): model_config = ConfigDict(populate_by_name=True) id: str - # stage: Stage = Field(..., alias="stage") - # status: TaskStageStatus = Field(..., alias="status") - # status_timestamps: TaskStageStatusTimestamps = Field(..., alias="status-timestamps") - # created_at: datetime = Field(..., alias="created-at") - # updated_at: datetime = Field(..., alias="updated-at") - # permissions: Permissions = Field(..., alias="permissions") - # actions: Actions = Field(..., alias="actions") - - # # Relations - # run: Run = Field(..., alias="run") - # task_results: list[TaskResult] = Field(..., alias="task-results") - # policy_evaluations: list[PolicyEvaluation] = Field(..., alias="policy-evaluations") + + stage: Stage | None = Field(None, alias="stage") + status: TaskStageStatus | None = Field(None, alias="status") + status_timestamps: TaskStageStatusTimestamps | None = Field( + None, alias="status-timestamps" + ) + created_at: datetime | None = Field(None, alias="created-at") + updated_at: datetime | None = Field(None, alias="updated-at") + permissions: Permissions | None = Field(None, alias="permissions") + actions: Actions | None = Field(None, alias="actions") + + # Relationships + run: Run | None = Field(None, alias="run") + task_results: list[TaskResult] | None = Field(None, alias="task-results") + policy_evaluations: list[PolicyEvaluation] | None = Field( + None, alias="policy-evaluations" + ) + + +class TaskStageListOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + page_size: int | None = Field(None, alias="page[size]") diff --git a/src/pytfe/models/workspace.py b/src/pytfe/models/workspace.py index e0be77a7..ee067f43 100644 --- a/src/pytfe/models/workspace.py +++ b/src/pytfe/models/workspace.py @@ -526,6 +526,7 @@ def _rebuild_workspace_model() -> None: """Rebuild Workspace model to resolve forward references.""" try: from .run import Run # noqa: F401 + from .task_stage import TaskStage # noqa: F401 Workspace.model_rebuild() except ImportError: diff --git a/src/pytfe/resources/task_result.py b/src/pytfe/resources/task_result.py index ed6a7b61..737008f0 100644 --- a/src/pytfe/resources/task_result.py +++ b/src/pytfe/resources/task_result.py @@ -3,11 +3,8 @@ from typing import Any -from pytfe.models.policy_evaluation import PolicyEvaluation -from pytfe.models.run import Run from pytfe.models.task_result import TaskResult from pytfe.models.task_stage import TaskStage -from pytfe.models.workspace import Workspace from pytfe.utils import valid_string_id from ._base import _Service @@ -21,57 +18,22 @@ def read(self, task_result_id: str) -> TaskResult: path = f"/api/v2/task-results/{task_result_id}" response = self.t.request("GET", path) - data = response.json() + data = response.json().get("data", {}) - if "data" not in data: - raise ValueError("Invalid response format") - - return self._parse_task_result(data["data"]) + return self._parse_task_result(data) def _parse_task_result(self, data: dict[str, Any]) -> TaskResult: - # Ensure forward references in TaskResult are resolved before use. - TaskResult.model_rebuild( - raise_errors=False, - _types_namespace={ - "PolicyEvaluation": PolicyEvaluation, - "Run": Run, - "TaskStage": TaskStage, - "Workspace": Workspace, - }, - ) attributes = data.get("attributes", {}) attributes["id"] = data.get("id") relationships = data.get("relationships", {}) - # Map task-stage relationship into the TaskStage SDK model. + # Map task-stage relationship into the TaskStage model. task_stage_data = relationships.get("task-stage", {}).get("data") if task_stage_data: - attributes["task-stage"] = TaskStage.model_validate(task_stage_data) - else: - attributes["task-stage"] = None - - # Map run relationship into the Run SDK model. - run_data = relationships.get("run", {}).get("data") - if run_data: - attributes["run"] = Run.model_validate(run_data) - else: - attributes["run"] = None - - # Map workspace relationship into the Workspace SDK model. - workspace_data = relationships.get("workspace", {}).get("data") - if workspace_data: - attributes["workspace"] = Workspace.model_validate(workspace_data) - else: - attributes["workspace"] = None - - # Map policy-evaluations relationship into a list of PolicyEvaluation models. - policy_evaluations_data = relationships.get("policy-evaluations", {}).get( - "data", [] - ) - attributes["policy-evaluations"] = [ - PolicyEvaluation.model_validate(pe) for pe in policy_evaluations_data - ] + attributes["task-stage"] = TaskStage.model_construct( + id=task_stage_data["id"] + ) return TaskResult.model_validate(attributes) diff --git a/src/pytfe/resources/task_stage.py b/src/pytfe/resources/task_stage.py new file mode 100644 index 00000000..526c035e --- /dev/null +++ b/src/pytfe/resources/task_stage.py @@ -0,0 +1,107 @@ +# 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 InvalidRunIDError, InvalidTaskStageIDError +from ..models.policy_evaluation import PolicyEvaluation +from ..models.run import Run +from ..models.task_result import TaskResult +from ..models.task_stage import TaskStage, TaskStageListOptions +from ..utils import _safe_str, valid_string_id +from ._base import _Service + + +class TaskStages(_Service): + """TaskStages provides access to task stage endpoints.""" + + def _parse_task_stage(self, data: dict[str, Any]) -> TaskStage: + attributes = data.get("attributes", {}) + + attributes["id"] = _safe_str(data.get("id")) + + relationships = data.get("relationships", {}) + + run_data = relationships.get("run", {}).get("data") + if run_data: + attributes["run"] = Run.model_construct(id=run_data["id"]) + + task_results_data = relationships.get("task-results", {}).get( + "data", + [], + ) + + attributes["task-results"] = [ + TaskResult.model_construct(id=task_result["id"]) + for task_result in task_results_data + ] + + policy_evaluations_data = relationships.get( + "policy-evaluations", + {}, + ).get( + "data", + [], + ) + + attributes["policy-evaluations"] = [ + PolicyEvaluation.model_construct(id=policy_evaluation["id"]) + for policy_evaluation in policy_evaluations_data + ] + + return TaskStage.model_validate(attributes) + + # Read + def read(self, task_stage_id: str) -> TaskStage: + if not valid_string_id(task_stage_id): + raise InvalidTaskStageIDError() + + response = self.t.request( + "GET", + f"/api/v2/task-stages/{task_stage_id}", + ) + + data = response.json().get("data", {}) + + return self._parse_task_stage(data) + + # List + def list( + self, run_id: str, options: TaskStageListOptions | None = None + ) -> Iterator[TaskStage]: + if not valid_string_id(run_id): + raise InvalidRunIDError() + + path = f"/api/v2/runs/{run_id}/task-stages" + kwargs = {"params": options.model_dump(by_alias=True)} if options else {} + + for item in self._list(path, **kwargs): + yield self._parse_task_stage(item) + + # Override + def override( + self, + task_stage_id: str, + comment: str | None = None, + ) -> TaskStage: + """ + **Note: This function is still in BETA and subject to change.** + Override a task stage for a run. + """ + if not valid_string_id(task_stage_id): + raise InvalidTaskStageIDError() + + body: dict[str, Any] | None = {"comment": comment} if comment else None + + response = self.t.request( + "POST", + f"/api/v2/task-stages/{task_stage_id}/actions/override", + json_body=body, + ) + + data = response.json().get("data", {}) + + return self._parse_task_stage(data) diff --git a/tests/test_task_stage.py b/tests/test_task_stage.py new file mode 100644 index 00000000..8e7fc738 --- /dev/null +++ b/tests/test_task_stage.py @@ -0,0 +1,343 @@ +import pytest + +from pytfe.client import TFEClient +from pytfe.errors import InvalidTaskStageIDError +from pytfe.models.task_stage import ( + Stage, + TaskStage, + TaskStageStatus, +) +from pytfe.resources.task_stage import TaskStages + +# Basic existence tests + + +def test_task_stage_service_exists(): + client = TFEClient() + assert hasattr(client, "task_stages") + + +def test_task_stage_methods_exist(): + client = TFEClient() + + assert hasattr(client.task_stages, "read") + assert hasattr(client.task_stages, "list") + assert hasattr(client.task_stages, "override") + + +# InvalidTaskStageIDError tests + + +def test_invalid_task_stage_id_error_is_raised(): + """InvalidTaskStageIDError should be raised for blank IDs.""" + client = TFEClient() + + with pytest.raises(InvalidTaskStageIDError): + client.task_stages.read("") + + with pytest.raises(InvalidTaskStageIDError): + client.task_stages.override("") + + +def test_invalid_task_stage_id_error_message(): + err = InvalidTaskStageIDError() + assert "task stage" in str(err).lower() + + +# TaskStage optional fields / stub tests + + +def test_task_stage_stub_with_only_id(): + """TaskStage should be constructable with only `id` — all other fields optional.""" + ts = TaskStage(id="ts-stub-123") + assert ts.id == "ts-stub-123" + assert ts.stage is None + assert ts.status is None + assert ts.status_timestamps is None + assert ts.created_at is None + assert ts.updated_at is None + assert ts.permissions is None + assert ts.actions is None + assert ts.run is None + assert ts.task_results is None + assert ts.policy_evaluations is None + + +def test_task_stage_partial_payload(): + """TaskStage should parse a payload with only some fields populated.""" + ts = TaskStage.model_validate( + {"id": "ts-456", "stage": "pre_plan", "status": "pending"} + ) + assert ts.id == "ts-456" + assert ts.stage == Stage.pre_plan + assert ts.status == TaskStageStatus.pending + assert ts.status_timestamps is None + assert ts.created_at is None + assert ts.run is None + + +def test_task_stage_full_payload(): + """TaskStage should parse a complete attributes payload.""" + ts = TaskStage.model_validate( + { + "id": "ts-789", + "stage": "post_plan", + "status": "passed", + "status-timestamps": {"passed-at": "2024-06-01T12:00:00Z"}, + "created-at": "2024-01-01T00:00:00Z", + "updated-at": "2024-06-01T12:00:00Z", + "permissions": {"can-override": True}, + "actions": {"is-overridable": False}, + } + ) + assert ts.stage == Stage.post_plan + assert ts.status == TaskStageStatus.passed + assert ts.permissions is not None + assert ts.permissions.can_override is True + assert ts.actions is not None + assert ts.actions.is_overridable is False + + +# Read method tests + + +def test_read_raises_error_when_id_missing(): + client = TFEClient() + + with pytest.raises(InvalidTaskStageIDError): + client.task_stages.read("") + + +def test_read_calls_request_correctly(mocker): + mock_transport = mocker.Mock() + + mock_response = mocker.Mock() + mock_response.json.return_value = { + "data": { + "id": "ts-123", + "attributes": { + "stage": "pre_plan", + "status": "pending", + "status-timestamps": {}, + "created-at": "2024-01-01T00:00:00Z", + "updated-at": "2024-01-01T00:00:00Z", + }, + } + } + + mock_transport.request.return_value = mock_response + + service = TaskStages(mock_transport) + + result = service.read("ts-123") + + assert isinstance(result, TaskStage) + + mock_transport.request.assert_called_once_with( + "GET", + "/api/v2/task-stages/ts-123", + ) + + +def test_read_stub_payload(mocker): + """read() should succeed when API returns only an id (stub/relationship payload).""" + mock_transport = mocker.Mock() + mock_response = mocker.Mock() + mock_response.json.return_value = {"data": {"id": "ts-stub-001", "attributes": {}}} + mock_transport.request.return_value = mock_response + + service = TaskStages(mock_transport) + result = service.read("ts-stub-001") + + assert isinstance(result, TaskStage) + assert result.id == "ts-stub-001" + assert result.stage is None + + +# List method tests + + +def test_list_with_valid_id_does_not_raise(mocker): + mock_transport = mocker.Mock() + + service = TaskStages(mock_transport) + + service._list = mocker.Mock(return_value=[]) + + result = list(service.list("run-123")) + + assert result == [] + + +def test_list_calls_internal_list(mocker): + mock_transport = mocker.Mock() + + service = TaskStages(mock_transport) + + service._list = mocker.Mock( + return_value=[ + { + "id": "ts-1", + "attributes": { + "stage": "pre_plan", + "status": "pending", + "status-timestamps": {}, + "created-at": "2024-01-01T00:00:00Z", + "updated-at": "2024-01-01T00:00:00Z", + }, + } + ] + ) + + result = list(service.list("run-123")) + + assert len(result) == 1 + assert isinstance(result[0], TaskStage) + + service._list.assert_called_once_with("/api/v2/runs/run-123/task-stages") + + +# Override method tests + + +def test_override_raises_error_when_id_missing(): + client = TFEClient() + + with pytest.raises(InvalidTaskStageIDError): + client.task_stages.override("") + + +def test_override_calls_request_without_comment(mocker): + mock_transport = mocker.Mock() + + mock_response = mocker.Mock() + mock_response.json.return_value = { + "data": { + "id": "ts-123", + "attributes": { + "stage": "pre_plan", + "status": "pending", + "status-timestamps": {}, + "created-at": "2024-01-01T00:00:00Z", + "updated-at": "2024-01-01T00:00:00Z", + }, + } + } + + mock_transport.request.return_value = mock_response + + service = TaskStages(mock_transport) + + result = service.override("ts-123") + + assert isinstance(result, TaskStage) + + mock_transport.request.assert_called_once_with( + "POST", + "/api/v2/task-stages/ts-123/actions/override", + json_body=None, + ) + + +def test_override_calls_request_with_comment(mocker): + mock_transport = mocker.Mock() + + mock_response = mocker.Mock() + mock_response.json.return_value = { + "data": { + "id": "ts-123", + "attributes": { + "stage": "pre_plan", + "status": "pending", + "status-timestamps": {}, + "created-at": "2024-01-01T00:00:00Z", + "updated-at": "2024-01-01T00:00:00Z", + }, + } + } + + mock_transport.request.return_value = mock_response + + service = TaskStages(mock_transport) + + result = service.override("ts-123", comment="approved") + + assert isinstance(result, TaskStage) + + mock_transport.request.assert_called_once_with( + "POST", + "/api/v2/task-stages/ts-123/actions/override", + json_body={"comment": "approved"}, + ) + + +# Relationship parsing tests + + +def test_parse_task_stage_with_run_relationship(mocker): + """_parse_task_stage should attach a Run stub from relationships.""" + mock_transport = mocker.Mock() + service = TaskStages(mock_transport) + + data = { + "id": "ts-rel-001", + "attributes": {"stage": "pre_plan", "status": "running"}, + "relationships": { + "run": {"data": {"id": "run-abc", "type": "runs"}}, + "task-results": {"data": []}, + "policy-evaluations": {"data": []}, + }, + } + + result = service._parse_task_stage(data) + + assert isinstance(result, TaskStage) + assert result.run is not None + assert result.run.id == "run-abc" + assert result.task_results == [] + assert result.policy_evaluations == [] + + +def test_parse_task_stage_with_task_results_relationship(mocker): + """_parse_task_stage should parse task-results from relationships.""" + mock_transport = mocker.Mock() + service = TaskStages(mock_transport) + + data = { + "id": "ts-rel-002", + "attributes": {}, + "relationships": { + "task-results": { + "data": [ + {"id": "tr-1", "type": "task-results"}, + {"id": "tr-2", "type": "task-results"}, + ] + }, + "policy-evaluations": {"data": []}, + }, + } + + result = service._parse_task_stage(data) + + assert result.task_results is not None + assert len(result.task_results) == 2 + assert result.task_results[0].id == "tr-1" + assert result.task_results[1].id == "tr-2" + + +def test_parse_task_stage_with_no_relationships(mocker): + """_parse_task_stage should handle missing relationships gracefully.""" + mock_transport = mocker.Mock() + service = TaskStages(mock_transport) + + data = { + "id": "ts-no-rel", + "attributes": {}, + } + + result = service._parse_task_stage(data) + + assert isinstance(result, TaskStage) + assert result.run is None + assert result.task_results == [] + assert result.policy_evaluations == [] diff --git a/tests/units/test_task_results.py b/tests/units/test_task_results.py index 13d0ba98..43f89889 100644 --- a/tests/units/test_task_results.py +++ b/tests/units/test_task_results.py @@ -2,11 +2,8 @@ import pytest -from pytfe.models.policy_evaluation import PolicyEvaluation -from pytfe.models.run import Run from pytfe.models.task_result import TaskResult from pytfe.models.task_stage import TaskStage -from pytfe.models.workspace import Workspace from pytfe.resources.task_result import TaskResults @@ -161,113 +158,8 @@ def test_task_stage_relationship_null(self, service, mock_transport): assert result.task_stage is None - def test_run_relationship_mapped(self, service, mock_transport): - response = Mock() - response.json.return_value = { - "data": { - "id": "tr-123", - "attributes": {"status": "passed"}, - "relationships": {"run": {"data": {"id": "run-789", "type": "runs"}}}, - } - } - mock_transport.request.return_value = response - - result = service.read("tr-123") - - assert isinstance(result.run, Run) - assert result.run.id == "run-789" - - def test_run_relationship_null(self, service, mock_transport): - response = Mock() - response.json.return_value = { - "data": { - "id": "tr-123", - "attributes": {"status": "passed"}, - "relationships": {"run": {"data": None}}, - } - } - mock_transport.request.return_value = response - - result = service.read("tr-123") - - assert result.run is None - - def test_workspace_relationship_mapped(self, service, mock_transport): - response = Mock() - response.json.return_value = { - "data": { - "id": "tr-123", - "attributes": {"status": "passed"}, - "relationships": { - "workspace": {"data": {"id": "ws-abc", "type": "workspaces"}} - }, - } - } - mock_transport.request.return_value = response - - result = service.read("tr-123") - - assert isinstance(result.workspace, Workspace) - assert result.workspace.id == "ws-abc" - - def test_workspace_relationship_null(self, service, mock_transport): - response = Mock() - response.json.return_value = { - "data": { - "id": "tr-123", - "attributes": {"status": "passed"}, - "relationships": {"workspace": {"data": None}}, - } - } - mock_transport.request.return_value = response - - result = service.read("tr-123") - - assert result.workspace is None - - def test_policy_evaluations_relationship_mapped(self, service, mock_transport): - response = Mock() - response.json.return_value = { - "data": { - "id": "tr-123", - "attributes": {"status": "passed"}, - "relationships": { - "policy-evaluations": { - "data": [ - {"id": "pe-001", "type": "policy-evaluations"}, - {"id": "pe-002", "type": "policy-evaluations"}, - ] - } - }, - } - } - mock_transport.request.return_value = response - - result = service.read("tr-123") - - assert isinstance(result.policy_evaluations, list) - assert len(result.policy_evaluations) == 2 - assert all(isinstance(pe, PolicyEvaluation) for pe in result.policy_evaluations) - assert result.policy_evaluations[0].id == "pe-001" - assert result.policy_evaluations[1].id == "pe-002" - - def test_policy_evaluations_relationship_empty(self, service, mock_transport): - response = Mock() - response.json.return_value = { - "data": { - "id": "tr-123", - "attributes": {"status": "passed"}, - "relationships": {"policy-evaluations": {"data": []}}, - } - } - mock_transport.request.return_value = response - - result = service.read("tr-123") - - assert result.policy_evaluations == [] - def test_no_relationships_key(self, service, mock_transport): - """When 'relationships' is absent, all relationship fields stay None.""" + """When 'relationships' is absent, task_stage stays None.""" response = Mock() response.json.return_value = { "data": { @@ -280,6 +172,3 @@ def test_no_relationships_key(self, service, mock_transport): result = service.read("tr-123") assert result.task_stage is None - assert result.run is None - assert result.workspace is None - assert result.policy_evaluations == [] From 13fb423e56dc65820316dbc761f5064b8130024c Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Sat, 23 May 2026 15:07:27 +0530 Subject: [PATCH 74/95] moved test_task_stage unit tests into units folder --- tests/{ => units}/test_task_stage.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{ => units}/test_task_stage.py (100%) diff --git a/tests/test_task_stage.py b/tests/units/test_task_stage.py similarity index 100% rename from tests/test_task_stage.py rename to tests/units/test_task_stage.py From 173bec5a17ff8f174647859688b7f633d776aeea Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Sat, 23 May 2026 19:54:02 +0530 Subject: [PATCH 75/95] State version upload (#163) --- examples/state_versions.py | 47 +++++++++++++-- src/pytfe/models/state_version.py | 10 ++++ src/pytfe/resources/state_versions.py | 64 +++++++++++++++++--- tests/units/test_state_version.py | 84 ++++++++++++++++++++++++++- 4 files changed, 189 insertions(+), 16 deletions(-) diff --git a/examples/state_versions.py b/examples/state_versions.py index 61187619..e9a98d3d 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 @@ -15,6 +17,7 @@ StateVersionListOptions, StateVersionOutputsListOptions, ) +from pytfe.models.workspace import WorkspaceLockOptions def _print_header(title: str): @@ -110,18 +113,50 @@ def main(): # 5) (Optional) Upload a new state file if args.upload: _print_header(f"Uploading new state from: {args.upload}") - payload = Path(args.upload).read_bytes() try: + payload = Path(args.upload).read_bytes() + state_obj = json.loads(payload.decode("utf-8")) + serial = int(state_obj["serial"]) + lineage = state_obj.get("lineage") + md5 = hashlib.md5(payload).hexdigest() # nosec B324 + locked_workspace = False + + try: + client.workspaces.lock( + args.workspace_id, + WorkspaceLockOptions( + reason="python-tfe state_versions upload example" + ), + ) + locked_workspace = True + except Exception: + # Continue in case the workspace is already locked by the caller. + pass + # 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(), - ) + try: + new_sv = client.state_versions.upload( + args.workspace_id, + raw_state=payload, + options=StateVersionCreateOptions( + serial=serial, + md5=md5, + lineage=lineage, + ), + ) + finally: + if locked_workspace: + client.workspaces.unlock(args.workspace_id) print(f"Uploaded new SV: {new_sv.id} status={new_sv.status}") + except FileNotFoundError: + print(f"Upload file not found: {args.upload}") + except (KeyError, ValueError, json.JSONDecodeError): + print( + "Upload input must be a valid Terraform state JSON containing at least a serial value." + ) except ErrStateVersionUploadNotSupported as e: # Some older/self-hosted versions don’t support direct upload print(f"Upload not supported on this server: {e}") 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..c1014dae 100644 --- a/src/pytfe/resources/state_versions.py +++ b/src/pytfe/resources/state_versions.py @@ -7,9 +7,7 @@ from typing import Any from urllib.parse import urlencode -from ..errors import NotFound - -# Pydantic models for this feature +from ..errors import ErrStateVersionUploadNotSupported, NotFound, TFEError from ..models.state_version import ( StateVersion, StateVersionCreateOptions, @@ -193,18 +191,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: """ diff --git a/tests/units/test_state_version.py b/tests/units/test_state_version.py index 11f67c8f..e8717bb2 100644 --- a/tests/units/test_state_version.py +++ b/tests/units/test_state_version.py @@ -5,7 +5,7 @@ import pytest from pytfe._http import HTTPTransport -from pytfe.errors import NotFound +from pytfe.errors import ErrStateVersionUploadNotSupported, NotFound, TFEError from pytfe.models.state_version import ( StateVersion, StateVersionCreateOptions, @@ -131,6 +131,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 +205,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 +249,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 ): From 9666b98ab514f3252e7afe9ffedb2ad9687cca8c Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Sat, 23 May 2026 20:41:00 +0530 Subject: [PATCH 76/95] Fix Auth leak in state version upload --- src/pytfe/_http.py | 3 ++ src/pytfe/resources/state_versions.py | 2 ++ tests/units/test_state_version.py | 41 +++++++++++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/src/pytfe/_http.py b/src/pytfe/_http.py index 4c25358e..22b29b3f 100644 --- a/src/pytfe/_http.py +++ b/src/pytfe/_http.py @@ -78,9 +78,12 @@ def request( data: bytes | None = None, headers: dict[str, str] | None = None, allow_redirects: bool = True, + include_auth: bool = True, ) -> httpx.Response: url = self._build_url(path) hdrs = dict(self.headers) + if not include_auth: + hdrs.pop("Authorization", None) if headers: hdrs.update(headers) attempt = 0 diff --git a/src/pytfe/resources/state_versions.py b/src/pytfe/resources/state_versions.py index c1014dae..260ec5a1 100644 --- a/src/pytfe/resources/state_versions.py +++ b/src/pytfe/resources/state_versions.py @@ -236,6 +236,7 @@ def upload( sv.hosted_state_upload_url, data=raw_state, headers={"Content-Type": "application/octet-stream"}, + include_auth=False, ) if raw_json_state is not None: @@ -248,6 +249,7 @@ def upload( sv.hosted_json_state_upload_url, data=raw_json_state, headers={"Content-Type": "application/octet-stream"}, + include_auth=False, ) return self.read(sv.id) diff --git a/tests/units/test_state_version.py b/tests/units/test_state_version.py index e8717bb2..a3e3659d 100644 --- a/tests/units/test_state_version.py +++ b/tests/units/test_state_version.py @@ -2,6 +2,7 @@ from unittest.mock import Mock, patch +import httpx import pytest from pytfe._http import HTTPTransport @@ -283,14 +284,54 @@ def test_upload_state_version_success(self, state_versions_service, mock_transpo "https://example.com/upload-raw", data=b"raw-state", headers={"Content-Type": "application/octet-stream"}, + include_auth=False, ) mock_transport.request.assert_any_call( "PUT", "https://example.com/upload-json", data=b"json-state", headers={"Content-Type": "application/octet-stream"}, + include_auth=False, ) + def test_upload_state_version_presigned_put_omits_authorization_header(self): + """Test upload() does not send the TFE token to presigned upload URLs.""" + seen_authorization_headers: list[str | None] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen_authorization_headers.append(request.headers.get("authorization")) + return httpx.Response(200) + + transport = HTTPTransport( + "https://app.terraform.io", + "secret-token", + timeout=5, + verify_tls=True, + user_agent_suffix=None, + max_retries=0, + backoff_base=0, + backoff_cap=0, + backoff_jitter=False, + http2=False, + proxies=None, + ca_bundle=None, + ) + transport._sync = httpx.Client(transport=httpx.MockTransport(handler)) + service = StateVersions(transport) + created_sv = StateVersion( + id="sv-upload-1", + status=StateVersionStatus.PENDING, + hosted_state_upload_url="https://archivist.terraform.io/upload-raw", + ) + final_sv = StateVersion(id="sv-upload-1", status=StateVersionStatus.FINALIZED) + options = StateVersionCreateOptions(serial=10, md5="abc123") + + with patch.object(service, "create", return_value=created_sv): + with patch.object(service, "read", return_value=final_sv): + service.upload("ws-123", raw_state=b"raw-state", options=options) + + assert seen_authorization_headers == [None] + def test_upload_state_version_unsupported_on_create_error( self, state_versions_service ): From 111012ed1fe84a44727761c0e1612ce9cb01a36e Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Sat, 23 May 2026 22:04:24 +0530 Subject: [PATCH 77/95] refactor(agent-pool): Updated models to include project_ids, workspace_ids for allowed and excluded, removed allowed_workspace_policy in models, updated relationships for AgentPool, updated listOptions with parameters --- src/pytfe/models/agent.py | 139 +++++++++++++++++++++++++------------- 1 file changed, 93 insertions(+), 46 deletions(-) diff --git a/src/pytfe/models/agent.py b/src/pytfe/models/agent.py index d0751ea1..76c02f3c 100644 --- a/src/pytfe/models/agent.py +++ b/src/pytfe/models/agent.py @@ -11,9 +11,20 @@ from datetime import datetime from enum import Enum -from typing import Any +from typing import TYPE_CHECKING -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from ..errors import ( + InvalidNameError, + RequiredNameError, +) +from ..utils import valid_string, valid_string_id +from .organization import Organization +from .workspace import Workspace + +if TYPE_CHECKING: + from .project import Project class AgentStatus(str, Enum): @@ -24,13 +35,6 @@ class AgentStatus(str, Enum): UNKNOWN = "unknown" -class AgentPoolAllowedWorkspacePolicy(str, Enum): - """Agent pool allowed workspace policy enumeration.""" - - ALL_WORKSPACES = "all-workspaces" - SPECIFIC_WORKSPACES = "specific-workspaces" - - class Agent(BaseModel): """Agent represents a Terraform Enterprise agent.""" @@ -48,72 +52,112 @@ class Agent(BaseModel): class AgentPool(BaseModel): """Agent Pool represents a Terraform Enterprise agent pool.""" + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + id: str - name: str | None = None - created_at: datetime | None = None - organization_scoped: bool | None = None - allowed_workspace_policy: AgentPoolAllowedWorkspacePolicy | None = None - agent_count: int = 0 + name: str | None = Field(default=None, alias="name") + created_at: datetime | None = Field(default=None, alias="created-at") + organization_scoped: bool | None = Field(default=None, alias="organization-scoped") + agent_count: int | None = Field(default=None, alias="agent-count") # Relations - organization: Any | None = None # Organization type from main types - workspaces: list[Any] = Field(default_factory=list) # Workspace types + organization: Organization | None = Field(default=None, alias="organization") + workspaces: list[Workspace] = Field(default_factory=list, alias="workspaces") agents: list[Agent] = Field(default_factory=list) + allowed_workspaces: list[Workspace] = Field( + default_factory=list, alias="allowed-workspaces" + ) + excluded_workspaces: list[Workspace] = Field( + default_factory=list, alias="excluded-workspaces" + ) + allowed_projects: list[Project] = Field( + default_factory=list, alias="allowed-projects" + ) -# Agent Pool Options +class AgentPoolIncludeOpt(str, Enum): + AGENT_POOL_WORKSPACES = "workspaces" class AgentPoolListOptions(BaseModel): """Options for listing agent pools.""" - # Pagination options - page_number: int | None = None - page_size: int | None = None + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + page_size: int | None = Field(default=None, alias="page[size]") # Optional: Include related resources - include: list[str] | None = None - # Optional: Filter by allowed workspace policy - allowed_workspace_policy: AgentPoolAllowedWorkspacePolicy | None = None + include: list[AgentPoolIncludeOpt] | None = Field(default=None, alias="include") + query: str | None = Field(default=None, alias="q") + allowed_workspace_name: str | None = Field( + default=None, alias="filter[allowed_workspaces][name]" + ) + allowed_project_name: str | None = Field( + default=None, alias="filter[allowed_projects][name]" + ) + sort: str | None = Field(default=None, alias="sort") class AgentPoolCreateOptions(BaseModel): """Options for creating an agent pool.""" + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + # Required: A name to identify the agent pool - name: str - # Optional: Whether the agent pool is organization scoped - organization_scoped: bool | None = None - # Optional: Allowed workspace policy - allowed_workspace_policy: AgentPoolAllowedWorkspacePolicy | None = None - # Optional: IDs of workspaces allowed to use this pool (sent as relationships.allowed-workspaces) - allowed_workspace_ids: list[str] = Field(default_factory=list) - # Optional: IDs of workspaces excluded from this pool (sent as relationships.excluded-workspaces) - excluded_workspace_ids: list[str] = Field(default_factory=list) + name: str = Field(alias="name") + organization_scoped: bool | None = Field(default=None, alias="organization-scoped") + allowed_workspace_ids: list[str] | None = Field( + default=None, alias="allowed-workspaces" + ) + excluded_workspace_ids: list[str] | None = Field( + default=None, alias="excluded-workspaces" + ) + allowed_project_ids: list[str] | None = Field( + default=None, alias="allowed-projects" + ) + + @model_validator(mode="after") + def valid(self) -> AgentPoolCreateOptions: + """Validate the options for creating an agent pool.""" + if not valid_string(self.name): + raise RequiredNameError() + if not valid_string_id(self.name): + raise InvalidNameError() + + return self class AgentPoolUpdateOptions(BaseModel): """Options for updating an agent pool.""" + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + # Optional: A name to identify the agent pool - name: str | None = None - # Optional: Whether the agent pool is organization scoped - organization_scoped: bool | None = None - # Optional: Allowed workspace policy - allowed_workspace_policy: AgentPoolAllowedWorkspacePolicy | None = None - # Optional: Full replacement list of workspace IDs allowed to use this pool - allowed_workspace_ids: list[str] = Field(default_factory=list) - # Optional: Full replacement list of workspace IDs excluded from this pool - excluded_workspace_ids: list[str] = Field(default_factory=list) + name: str | None = Field(default=None, alias="name") + organization_scoped: bool | None = Field(default=None, alias="organization-scoped") + allowed_workspace_ids: list[str] | None = Field( + default=None, alias="allowed-workspaces" + ) + excluded_workspace_ids: list[str] | None = Field( + default=None, alias="excluded-workspaces" + ) + allowed_project_ids: list[str] | None = Field( + default=None, alias="allowed-projects" + ) + + @model_validator(mode="after") + def valid(self) -> AgentPoolUpdateOptions: + """Validate the options for updating an agent pool.""" + if self.name is not None and not valid_string_id(self.name): + raise InvalidNameError() + + return self class AgentPoolReadOptions(BaseModel): """Options for reading an agent pool.""" # Optional: Include related resources - include: list[str] | None = None - - -# Agent Pool Workspace Assignment Options + include: list[AgentPoolIncludeOpt] | None = Field(default=None, alias="include") class AgentPoolAssignToWorkspacesOptions(BaseModel): @@ -128,7 +172,10 @@ class AgentPoolRemoveFromWorkspacesOptions(BaseModel): workspace_ids: list[str] = Field(default_factory=list) -# Agent Options +class AgentPoolAssignToProjectsOptions(BaseModel): + """Options for assigning an agent pool to projects.""" + + project_ids: list[str] = Field(default_factory=list) class AgentListOptions(BaseModel): From 86e96f75badfeeeffeaad9a09223807aa7a08cc9 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Sat, 23 May 2026 22:09:31 +0530 Subject: [PATCH 78/95] refactor(agent-pool): Added assign_to_project method to the agent-pool resource to add projects, added _parse_agent_pool_from method, removed allowed_workspace_policy in the methods, added valid options in create/update in the models --- src/pytfe/resources/agent_pools.py | 400 +++++++++++------------------ 1 file changed, 145 insertions(+), 255 deletions(-) diff --git a/src/pytfe/resources/agent_pools.py b/src/pytfe/resources/agent_pools.py index 47ffc8bf..726368c7 100644 --- a/src/pytfe/resources/agent_pools.py +++ b/src/pytfe/resources/agent_pools.py @@ -10,11 +10,24 @@ from __future__ import annotations from collections.abc import Iterator -from typing import Any, cast - +from typing import Any + +from pytfe.models.organization import Organization +from pytfe.models.project import Project +from pytfe.models.workspace import Workspace + +from ..errors import ( + InvalidAgentPoolIDError, + InvalidOrgError, + InvalidProjectIDError, + InvalidWorkspaceIDError, + RequiredProjectError, + RequiredWorkspaceError, +) from ..models.agent import ( + Agent, AgentPool, - AgentPoolAllowedWorkspacePolicy, + AgentPoolAssignToProjectsOptions, AgentPoolAssignToWorkspacesOptions, AgentPoolCreateOptions, AgentPoolListOptions, @@ -22,91 +35,10 @@ AgentPoolRemoveFromWorkspacesOptions, AgentPoolUpdateOptions, ) -from ..utils import valid_string, valid_string_id +from ..utils import valid_string_id from ._base import _Service -def valid_agent_pool_name(name: str) -> bool: - """Validate agent pool name format.""" - if not valid_string(name): - return False - # Agent pool names must be between 1 and 90 characters - # and can contain letters, numbers, spaces, hyphens, and underscores - if len(name) > 90: - return False - return True - - -def validate_agent_pool_create_options(organization: str, name: str) -> None: - """Validate agent pool creation parameters.""" - if not valid_string(organization): - raise ValueError("Organization name is required and must be valid") - - if not valid_string(name): - raise ValueError("Agent pool name is required") - - if not valid_agent_pool_name(name): - raise ValueError("Agent pool name contains invalid characters or is too long") - - -def validate_agent_pool_update_options( - agent_pool_id: str, name: str | None = None -) -> None: - """Validate agent pool update parameters.""" - if not valid_string_id(agent_pool_id): - raise ValueError("Agent pool ID is required and must be valid") - - if name is not None: - if not valid_string(name): - raise ValueError("Agent pool name must be a valid string") - if not valid_agent_pool_name(name): - raise ValueError( - "Agent pool name contains invalid characters or is too long" - ) - - -def _safe_str(value: Any, default: str = "") -> str: - """Safely convert a value to string with optional default.""" - if value is None: - return default - return str(value) - - -def _safe_int(value: Any, default: int = 0) -> int: - """Safely convert a value to an integer.""" - if value is None: - return default - if isinstance(value, int): - return value - try: - return int(value) - except (ValueError, TypeError): - return default - - -def _safe_bool(value: Any) -> bool | None: - """Safely convert a value to a boolean.""" - if value is None: - return None - if isinstance(value, bool): - return value - if isinstance(value, str): - return value.lower() in ("true", "1", "yes", "on") - return bool(value) - - -def _safe_workspace_policy(value: Any) -> AgentPoolAllowedWorkspacePolicy | None: - """Safely convert a value to an AgentPoolAllowedWorkspacePolicy enum.""" - if value is None: - return None - if isinstance(value, AgentPoolAllowedWorkspacePolicy): - return value - try: - return AgentPoolAllowedWorkspacePolicy(str(value)) - except (ValueError, TypeError): - return None - - class AgentPools(_Service): """Agent Pools service for managing Terraform Enterprise agent pools.""" @@ -126,58 +58,29 @@ def list( ValueError: If organization name is invalid TFEError: If API request fails """ - if not valid_string(organization): - raise ValueError("Organization name is required and must be valid") + if not valid_string_id(organization): + raise InvalidOrgError() path = f"/api/v2/organizations/{organization}/agent-pools" params: dict[str, str | int] = {} if options: - if options.page_number is not None: - params["page[number]"] = options.page_number if options.page_size is not None: params["page[size]"] = options.page_size if options.include: params["include"] = ",".join(options.include) - if options.allowed_workspace_policy: - params["filter[allowed_workspace_policy]"] = ( - options.allowed_workspace_policy.value + if options.query: + params["q"] = options.query + if options.allowed_workspace_name: + params["filter[allowed_workspaces][name]"] = ( + options.allowed_workspace_name ) - - items_iter = self._list(path, params=params) - - for item in items_iter: - # Extract agent pool data from API response - attr = item.get("attributes", {}) or {} - relationships = item.get("relationships", {}) or {} - - # Note: organization and workspace relationships available but not currently used - - # Extract agents from relationships - agents_data = relationships.get("agents", {}).get("data", []) - agent_count = ( - len(agents_data) if agents_data else attr.get("agent-count", 0) - ) - - agent_pool_data = { - "id": _safe_str(item.get("id")), - "name": _safe_str(attr.get("name")), - "created_at": attr.get("created-at"), - "organization_scoped": attr.get("organization-scoped"), - "allowed_workspace_policy": attr.get("allowed-workspace-policy"), - "agent_count": agent_count, - } - - yield AgentPool( - id=_safe_str(agent_pool_data["id"]) or "", - name=_safe_str(agent_pool_data["name"]), - created_at=cast(Any, agent_pool_data["created_at"]), - organization_scoped=_safe_bool(agent_pool_data["organization_scoped"]), - allowed_workspace_policy=_safe_workspace_policy( - agent_pool_data["allowed_workspace_policy"] - ), - agent_count=_safe_int(agent_pool_data["agent_count"]), - ) + if options.allowed_project_name: + params["filter[allowed_projects][name]"] = options.allowed_project_name + if options.sort: + params["sort"] = options.sort + for item in self._list(path, params=params): + yield self._parse_agent_pool_from(item) def create(self, organization: str, options: AgentPoolCreateOptions) -> AgentPool: """Create a new agent pool in an organization. @@ -193,7 +96,8 @@ def create(self, organization: str, options: AgentPoolCreateOptions) -> AgentPoo ValueError: If parameters are invalid TFEError: If API request fails """ - validate_agent_pool_create_options(organization, options.name) + if not valid_string_id(organization): + raise InvalidOrgError() path = f"/api/v2/organizations/{organization}/agent-pools" attributes: dict[str, Any] = {"name": options.name} @@ -201,11 +105,6 @@ def create(self, organization: str, options: AgentPoolCreateOptions) -> AgentPoo if options.organization_scoped is not None: attributes["organization-scoped"] = options.organization_scoped - if options.allowed_workspace_policy is not None: - attributes["allowed-workspace-policy"] = ( - options.allowed_workspace_policy.value - ) - relationships: dict[str, Any] = {} if options.allowed_workspace_ids: relationships["allowed-workspaces"] = { @@ -221,6 +120,13 @@ def create(self, organization: str, options: AgentPoolCreateOptions) -> AgentPoo for ws_id in options.excluded_workspace_ids ] } + if options.allowed_project_ids: + relationships["allowed-projects"] = { + "data": [ + {"type": "projects", "id": proj_id} + for proj_id in options.allowed_project_ids + ] + } payload: dict[str, Any] = { "data": {"type": "agent-pools", "attributes": attributes} @@ -231,27 +137,7 @@ def create(self, organization: str, options: AgentPoolCreateOptions) -> AgentPoo response = self.t.request("POST", path, json_body=payload) data = response.json()["data"] - # Extract agent pool data from response - attr = data.get("attributes", {}) or {} - agent_pool_data = { - "id": _safe_str(data.get("id")), - "name": _safe_str(attr.get("name")), - "created_at": attr.get("created-at"), - "organization_scoped": attr.get("organization-scoped"), - "allowed_workspace_policy": attr.get("allowed-workspace-policy"), - "agent_count": attr.get("agent-count", 0), - } - - return AgentPool( - id=_safe_str(agent_pool_data["id"]) or "", - name=_safe_str(agent_pool_data["name"]), - created_at=cast(Any, agent_pool_data["created_at"]), - organization_scoped=_safe_bool(agent_pool_data["organization_scoped"]), - allowed_workspace_policy=_safe_workspace_policy( - agent_pool_data["allowed_workspace_policy"] - ), - agent_count=_safe_int(agent_pool_data["agent_count"]), - ) + return self._parse_agent_pool_from(data) def read( self, agent_pool_id: str, options: AgentPoolReadOptions | None = None @@ -270,7 +156,7 @@ def read( TFEError: If API request fails """ if not valid_string_id(agent_pool_id): - raise ValueError("Agent pool ID is required and must be valid") + raise InvalidAgentPoolIDError() path = f"/api/v2/agent-pools/{agent_pool_id}" params: dict[str, str] = {} @@ -285,33 +171,7 @@ def read( data = response.json()["data"] - # Extract agent pool data from response - attr = data.get("attributes", {}) or {} - relationships = data.get("relationships", {}) or {} - - # Extract agents count - agents_data = relationships.get("agents", {}).get("data", []) - agent_count = len(agents_data) if agents_data else attr.get("agent-count", 0) - - agent_pool_data = { - "id": _safe_str(data.get("id")), - "name": _safe_str(attr.get("name")), - "created_at": attr.get("created-at"), - "organization_scoped": attr.get("organization-scoped"), - "allowed_workspace_policy": attr.get("allowed-workspace-policy"), - "agent_count": agent_count, - } - - return AgentPool( - id=_safe_str(agent_pool_data["id"]) or "", - name=_safe_str(agent_pool_data["name"]), - created_at=cast(Any, agent_pool_data["created_at"]), - organization_scoped=_safe_bool(agent_pool_data["organization_scoped"]), - allowed_workspace_policy=_safe_workspace_policy( - agent_pool_data["allowed_workspace_policy"] - ), - agent_count=_safe_int(agent_pool_data["agent_count"]), - ) + return self._parse_agent_pool_from(data) def update(self, agent_pool_id: str, options: AgentPoolUpdateOptions) -> AgentPool: """Update an agent pool's properties. @@ -327,7 +187,9 @@ def update(self, agent_pool_id: str, options: AgentPoolUpdateOptions) -> AgentPo ValueError: If parameters are invalid TFEError: If API request fails """ - validate_agent_pool_update_options(agent_pool_id, options.name) + + if not valid_string_id(agent_pool_id): + raise InvalidAgentPoolIDError() path = f"/api/v2/agent-pools/{agent_pool_id}" attributes: dict[str, Any] = {} @@ -338,11 +200,6 @@ def update(self, agent_pool_id: str, options: AgentPoolUpdateOptions) -> AgentPo if options.organization_scoped is not None: attributes["organization-scoped"] = options.organization_scoped - if options.allowed_workspace_policy is not None: - attributes["allowed-workspace-policy"] = ( - options.allowed_workspace_policy.value - ) - relationships: dict[str, Any] = {} if options.allowed_workspace_ids: relationships["allowed-workspaces"] = { @@ -358,6 +215,13 @@ def update(self, agent_pool_id: str, options: AgentPoolUpdateOptions) -> AgentPo for ws_id in options.excluded_workspace_ids ] } + if options.allowed_project_ids: + relationships["allowed-projects"] = { + "data": [ + {"type": "projects", "id": proj_id} + for proj_id in options.allowed_project_ids + ] + } payload: dict[str, Any] = { "data": { @@ -372,27 +236,7 @@ def update(self, agent_pool_id: str, options: AgentPoolUpdateOptions) -> AgentPo response = self.t.request("PATCH", path, json_body=payload) data = response.json()["data"] - # Extract agent pool data from response - attr = data.get("attributes", {}) or {} - agent_pool_data = { - "id": _safe_str(data.get("id")), - "name": _safe_str(attr.get("name")), - "created_at": attr.get("created-at"), - "organization_scoped": attr.get("organization-scoped"), - "allowed_workspace_policy": attr.get("allowed-workspace-policy"), - "agent_count": attr.get("agent-count", 0), - } - - return AgentPool( - id=_safe_str(agent_pool_data["id"]) or "", - name=_safe_str(agent_pool_data["name"]), - created_at=cast(Any, agent_pool_data["created_at"]), - organization_scoped=_safe_bool(agent_pool_data["organization_scoped"]), - allowed_workspace_policy=_safe_workspace_policy( - agent_pool_data["allowed_workspace_policy"] - ), - agent_count=_safe_int(agent_pool_data["agent_count"]), - ) + return self._parse_agent_pool_from(data) def delete(self, agent_pool_id: str) -> None: """Delete an agent pool. @@ -405,7 +249,7 @@ def delete(self, agent_pool_id: str) -> None: TFEError: If API request fails """ if not valid_string_id(agent_pool_id): - raise ValueError("Agent pool ID is required and must be valid") + raise InvalidAgentPoolIDError() path = f"/api/v2/agent-pools/{agent_pool_id}" self.t.request("DELETE", path) @@ -431,14 +275,14 @@ def assign_to_workspaces( TFEError: If API request fails """ if not valid_string_id(agent_pool_id): - raise ValueError("Agent pool ID is required and must be valid") + raise InvalidAgentPoolIDError() if not options.workspace_ids: - raise ValueError("At least one workspace ID is required") + raise RequiredWorkspaceError() for workspace_id in options.workspace_ids: if not valid_string_id(workspace_id): - raise ValueError(f"Invalid workspace ID: {workspace_id}") + raise InvalidWorkspaceIDError(f"Invalid workspace ID: {workspace_id}") path = f"/api/v2/agent-pools/{agent_pool_id}" payload: dict[str, Any] = { @@ -459,27 +303,7 @@ def assign_to_workspaces( response = self.t.request("PATCH", path, json_body=payload) data = response.json()["data"] - # Extract agent pool data from response - attr = data.get("attributes", {}) or {} - agent_pool_data = { - "id": _safe_str(data.get("id")), - "name": _safe_str(attr.get("name")), - "created_at": attr.get("created-at"), - "organization_scoped": attr.get("organization-scoped"), - "allowed_workspace_policy": attr.get("allowed-workspace-policy"), - "agent_count": attr.get("agent-count", 0), - } - - return AgentPool( - id=_safe_str(agent_pool_data["id"]) or "", - name=_safe_str(agent_pool_data["name"]), - created_at=cast(Any, agent_pool_data["created_at"]), - organization_scoped=_safe_bool(agent_pool_data["organization_scoped"]), - allowed_workspace_policy=_safe_workspace_policy( - agent_pool_data["allowed_workspace_policy"] - ), - agent_count=_safe_int(agent_pool_data["agent_count"]), - ) + return self._parse_agent_pool_from(data) def remove_from_workspaces( self, agent_pool_id: str, options: AgentPoolRemoveFromWorkspacesOptions @@ -503,14 +327,14 @@ def remove_from_workspaces( TFEError: If API request fails """ if not valid_string_id(agent_pool_id): - raise ValueError("Agent pool ID is required and must be valid") + raise InvalidAgentPoolIDError() if not options.workspace_ids: - raise ValueError("At least one workspace ID is required") + raise RequiredWorkspaceError() for workspace_id in options.workspace_ids: if not valid_string_id(workspace_id): - raise ValueError(f"Invalid workspace ID: {workspace_id}") + raise InvalidWorkspaceIDError(f"Invalid workspace ID: {workspace_id}") path = f"/api/v2/agent-pools/{agent_pool_id}" payload: dict[str, Any] = { @@ -531,24 +355,90 @@ def remove_from_workspaces( response = self.t.request("PATCH", path, json_body=payload) data = response.json()["data"] - # Extract agent pool data from response - attr = data.get("attributes", {}) or {} - agent_pool_data = { - "id": _safe_str(data.get("id")), - "name": _safe_str(attr.get("name")), - "created_at": attr.get("created-at"), - "organization_scoped": attr.get("organization-scoped"), - "allowed_workspace_policy": attr.get("allowed-workspace-policy"), - "agent_count": attr.get("agent-count", 0), + return self._parse_agent_pool_from(data) + + def assign_to_projects( + self, agent_pool_id: str, options: AgentPoolAssignToProjectsOptions + ) -> AgentPool: + """Assign an agent pool to projects by updating the allowed-projects + relationship via PATCH /agent-pools/:id. + + The provided project IDs become the new complete list of allowed + projects for this pool (full replacement, not append). + + Args: + agent_pool_id: Agent pool ID + options: Assignment options containing project IDs + """ + if not valid_string_id(agent_pool_id): + raise InvalidAgentPoolIDError() + + if not options.project_ids: + raise RequiredProjectError() + + for project_id in options.project_ids: + if not valid_string_id(project_id): + raise InvalidProjectIDError(f"Invalid project ID: {project_id}") + + path = f"/api/v2/agent-pools/{agent_pool_id}" + payload: dict[str, Any] = { + "data": { + "type": "agent-pools", + "id": agent_pool_id, + "attributes": {}, + "relationships": { + "allowed-projects": { + "data": [ + {"type": "projects", "id": project_id} + for project_id in options.project_ids + ] + } + }, + } } + response = self.t.request("PATCH", path, json_body=payload) + data = response.json()["data"] + + return self._parse_agent_pool_from(data) + + def _parse_agent_pool_from(self, data: dict[str, Any]) -> AgentPool: + """Helper method to parse agent pool data from API response.""" + attr = data.get("attributes", {}) + relationships = data.get("relationships", {}) + attr["id"] = data.get("id") + + # Extract agents count + agents_data = relationships.get("agents", {}).get("data", []) + attr["agents"] = [Agent(id=agent["id"]) for agent in agents_data] + + org_data = relationships.get("organization", {}).get("data") + attr["organization"] = Organization(id=org_data["id"]) if org_data else None - return AgentPool( - id=_safe_str(agent_pool_data["id"]) or "", - name=_safe_str(agent_pool_data["name"]), - created_at=cast(Any, agent_pool_data["created_at"]), - organization_scoped=_safe_bool(agent_pool_data["organization_scoped"]), - allowed_workspace_policy=_safe_workspace_policy( - agent_pool_data["allowed_workspace_policy"] - ), - agent_count=_safe_int(agent_pool_data["agent_count"]), + workspaces_data = relationships.get("workspaces", {}).get("data", []) + attr["workspaces"] = [ + Workspace.model_validate({"id": ws["id"]}) for ws in workspaces_data + ] + + allowed_workspaces_data = relationships.get("allowed-workspaces", {}).get( + "data", [] ) + attr["allowed_workspaces"] = [ + Workspace.model_validate({"id": ws["id"]}) for ws in allowed_workspaces_data + ] + + excluded_workspaces_data = relationships.get("excluded-workspaces", {}).get( + "data", [] + ) + attr["excluded_workspaces"] = [ + Workspace.model_validate({"id": ws["id"]}) + for ws in excluded_workspaces_data + ] + + allowed_projects_data = relationships.get("allowed-projects", {}).get( + "data", [] + ) + attr["allowed_projects"] = [ + Project.model_validate({"id": proj["id"]}) for proj in allowed_projects_data + ] + + return AgentPool.model_validate(attr) From 4597aa54029f4ee1769e08743ab6660d07630233 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Sat, 23 May 2026 22:11:15 +0530 Subject: [PATCH 79/95] refactor(agent-pool): added agent-pool errors, added models in init, updated examples and testcases --- examples/agent_pool.py | 15 +++++++++++--- src/pytfe/errors.py | 15 ++++++++++++++ src/pytfe/models/__init__.py | 20 +++++++++++++++++-- tests/units/test_agent_pools.py | 35 --------------------------------- 4 files changed, 45 insertions(+), 40 deletions(-) diff --git a/examples/agent_pool.py b/examples/agent_pool.py index bcb04ae3..880a86d7 100644 --- a/examples/agent_pool.py +++ b/examples/agent_pool.py @@ -27,13 +27,13 @@ from pytfe import TFEClient, TFEConfig from pytfe.errors import NotFound from pytfe.models import ( - AgentPoolAllowedWorkspacePolicy, AgentPoolAssignToWorkspacesOptions, AgentPoolCreateOptions, AgentPoolListOptions, AgentPoolRemoveFromWorkspacesOptions, AgentPoolUpdateOptions, AgentTokenCreateOptions, + AgentPoolAssignToProjectsOptions, ) @@ -46,6 +46,9 @@ def main(): workspace_id = os.environ.get( "TFE_WORKSPACE_ID" ) # optional, for workspace assignment + project_id = os.environ.get( + "TFE_PROJECT_ID" + ) # optional, for project assignment if not token: print("TFE_TOKEN environment variable is required") @@ -81,7 +84,6 @@ def main(): create_options = AgentPoolCreateOptions( name=unique_name, organization_scoped=True, # Optional parameter - allowed_workspace_policy=AgentPoolAllowedWorkspacePolicy.ALL_WORKSPACES, # Optional ) new_pool = client.agent_pools.create(org, create_options) @@ -92,7 +94,6 @@ def main(): pool_details = client.agent_pools.read(new_pool.id) print(f"Name: {pool_details.name}") print(f"Organization Scoped: {pool_details.organization_scoped}") - print(f"Policy: {pool_details.allowed_workspace_policy}") print(f"Agent Count: {pool_details.agent_count}") # Example 4: Update the agent pool @@ -125,6 +126,14 @@ def main(): else: print("\n Skipping workspace assignment (set TFE_WORKSPACE_ID to test)") + if project_id: + print("\n Assigning project to agent pool...") + updated_pool = client.agent_pools.assign_to_projects( + new_pool.id, + AgentPoolAssignToProjectsOptions(project_ids=[project_id]), + ) + print(f" Assigned project {project_id} to pool {updated_pool.name}") + # Example 6: Create an agent token print("\n Creating agent token...") token_options = AgentTokenCreateOptions( diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index 45615818..dbf4ba38 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -702,3 +702,18 @@ class InvalidTokenIDError(InvalidValues): def __init__(self, message: str = "invalid value for token ID"): super().__init__(message) + + +# Agent Pool errors +class InvalidAgentPoolIDError(InvalidValues): + """Raised when an invalid agent pool ID is provided.""" + + def __init__(self, message: str = "invalid value for agent pool ID"): + super().__init__(message) + + +class RequiredProjectError(RequiredFieldMissing): + """Raised when a required project field is missing.""" + + def __init__(self, message: str = "project is required"): + super().__init__(message) diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index 15e0c4d6..bdbf42d4 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -8,8 +8,8 @@ Agent, AgentListOptions, AgentPool, - AgentPoolAllowedWorkspacePolicy, AgentPoolAssignToWorkspacesOptions, + AgentPoolAssignToProjectsOptions, AgentPoolCreateOptions, AgentPoolListOptions, AgentPoolReadOptions, @@ -509,8 +509,8 @@ # Agent & pools "Agent", "AgentPool", - "AgentPoolAllowedWorkspacePolicy", "AgentPoolAssignToWorkspacesOptions", + "AgentPoolAssignToProjectsOptions", "AgentPoolCreateOptions", "AgentPoolListOptions", "AgentPoolReadOptions", @@ -867,3 +867,19 @@ "TaskStage": TaskStage, }, ) +AgentPool.model_rebuild( + raise_errors=False, + _types_namespace={"Project": Project}, +) +Project.model_rebuild( + raise_errors=False, + _types_namespace={"AgentPool": AgentPool}, +) +RunTask.model_rebuild( + raise_errors=False, + _types_namespace={"WorkspaceRunTask": WorkspaceRunTask, "AgentPool": AgentPool}, +) +Workspace.model_rebuild( + raise_errors=False, + _types_namespace={"AgentPool": AgentPool, "Run": Run, "TaskStage": TaskStage}, +) diff --git a/tests/units/test_agent_pools.py b/tests/units/test_agent_pools.py index f797f612..9b55fe99 100644 --- a/tests/units/test_agent_pools.py +++ b/tests/units/test_agent_pools.py @@ -22,7 +22,6 @@ from pytfe.errors import AuthError, NotFound, ValidationError from pytfe.models.agent import ( AgentPool, - AgentPoolAllowedWorkspacePolicy, AgentPoolAssignToWorkspacesOptions, AgentPoolCreateOptions, AgentPoolListOptions, @@ -42,54 +41,23 @@ def test_agent_pool_model_basic(self): name="test-pool", created_at="2023-01-01T00:00:00Z", organization_scoped=True, - allowed_workspace_policy=AgentPoolAllowedWorkspacePolicy.ALL_WORKSPACES, agent_count=0, ) assert agent_pool.id == "apool-123456789abcdef0" assert agent_pool.name == "test-pool" assert agent_pool.organization_scoped is True - assert ( - agent_pool.allowed_workspace_policy - == AgentPoolAllowedWorkspacePolicy.ALL_WORKSPACES - ) assert agent_pool.agent_count == 0 - def test_agent_pool_allowed_workspace_policy_enum(self): - """Test AgentPoolAllowedWorkspacePolicy enum values""" - assert AgentPoolAllowedWorkspacePolicy.ALL_WORKSPACES == "all-workspaces" - assert ( - AgentPoolAllowedWorkspacePolicy.SPECIFIC_WORKSPACES == "specific-workspaces" - ) - - agent_pool = AgentPool( - id="apool-123456789abcdef0", - name="test-pool", - created_at="2023-01-01T00:00:00Z", - organization_scoped=False, - allowed_workspace_policy=AgentPoolAllowedWorkspacePolicy.SPECIFIC_WORKSPACES, - agent_count=3, - ) - - assert ( - agent_pool.allowed_workspace_policy - == AgentPoolAllowedWorkspacePolicy.SPECIFIC_WORKSPACES - ) - def test_agent_pool_create_options(self): """Test AgentPoolCreateOptions model""" options = AgentPoolCreateOptions( name="test-pool", organization_scoped=True, - allowed_workspace_policy=AgentPoolAllowedWorkspacePolicy.SPECIFIC_WORKSPACES, ) assert options.name == "test-pool" assert options.organization_scoped is True - assert ( - options.allowed_workspace_policy - == AgentPoolAllowedWorkspacePolicy.SPECIFIC_WORKSPACES - ) def test_agent_pool_create_options_workspace_ids(self): """Test AgentPoolCreateOptions with allowed/excluded workspace IDs (bug fix)""" @@ -165,7 +133,6 @@ def test_list_agent_pools_with_options(self, agent_pools_service, mock_transport options = AgentPoolListOptions( page_size=10, - allowed_workspace_policy=AgentPoolAllowedWorkspacePolicy.ALL_WORKSPACES, ) list(agent_pools_service.list("test-org", options)) @@ -176,7 +143,6 @@ def test_list_agent_pools_with_options(self, agent_pools_service, mock_transport params = call_args[1]["params"] assert params["page[number]"] == 1 assert params["page[size]"] == 10 - assert params["filter[allowed_workspace_policy]"] == "all-workspaces" def test_create_agent_pool(self, agent_pools_service, mock_transport): """Test creating an agent pool""" @@ -198,7 +164,6 @@ def test_create_agent_pool(self, agent_pools_service, mock_transport): options = AgentPoolCreateOptions( name="new-pool", organization_scoped=True, - allowed_workspace_policy=AgentPoolAllowedWorkspacePolicy.ALL_WORKSPACES, ) agent_pool = agent_pools_service.create("test-org", options) From 2da9da1081c02e4df3a95f2faecd72a01f43d238 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Sat, 23 May 2026 22:12:17 +0530 Subject: [PATCH 80/95] Updated models to handle circular import issues --- src/pytfe/models/project.py | 6 +++++- src/pytfe/models/workspace.py | 3 ++- src/pytfe/models/workspace_run_task.py | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/pytfe/models/project.py b/src/pytfe/models/project.py index b67d3c9b..535a0f59 100644 --- a/src/pytfe/models/project.py +++ b/src/pytfe/models/project.py @@ -3,12 +3,16 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from pydantic import BaseModel, ConfigDict, Field -from .agent import AgentPool from .common import TagBinding from .organization import Organization +if TYPE_CHECKING: + from .agent import AgentPool + class Project(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) diff --git a/src/pytfe/models/workspace.py b/src/pytfe/models/workspace.py index ee067f43..79e5113e 100644 --- a/src/pytfe/models/workspace.py +++ b/src/pytfe/models/workspace.py @@ -21,7 +21,6 @@ UnsupportedOperationsError, ) from ..utils import has_tags_regex_defined, is_valid_workspace_name, valid_string -from .agent import AgentPool from .common import EffectiveTagBinding, Tag, TagBinding from .configuration_version import ConfigurationVersion from .data_retention_policy import DataRetentionPolicyChoice @@ -32,6 +31,7 @@ from .variable import Variable if TYPE_CHECKING: + from .agent import AgentPool from .run import Run @@ -525,6 +525,7 @@ class VCSRepoOptions(BaseModel): def _rebuild_workspace_model() -> None: """Rebuild Workspace model to resolve forward references.""" try: + from .agent import AgentPool # noqa: F401 from .run import Run # noqa: F401 from .task_stage import TaskStage # noqa: F401 diff --git a/src/pytfe/models/workspace_run_task.py b/src/pytfe/models/workspace_run_task.py index d92fb328..f775f016 100644 --- a/src/pytfe/models/workspace_run_task.py +++ b/src/pytfe/models/workspace_run_task.py @@ -68,4 +68,4 @@ class WorkspaceRunTaskUpdateOptions(BaseModel): # WorkspaceRunTask is now fully defined; rebuild RunTask so Pydantic can # resolve the forward reference in RunTask.workspace_run_tasks. -RunTask.model_rebuild() +RunTask.model_rebuild(raise_errors=False) From cec7be0413dbc9b728ceb0deefed2a58ac4d8a20 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Sat, 23 May 2026 22:15:58 +0530 Subject: [PATCH 81/95] fixed fmt and lint --- examples/agent_pool.py | 6 ++---- src/pytfe/models/__init__.py | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/examples/agent_pool.py b/examples/agent_pool.py index 880a86d7..3fa086ad 100644 --- a/examples/agent_pool.py +++ b/examples/agent_pool.py @@ -27,13 +27,13 @@ from pytfe import TFEClient, TFEConfig from pytfe.errors import NotFound from pytfe.models import ( + AgentPoolAssignToProjectsOptions, AgentPoolAssignToWorkspacesOptions, AgentPoolCreateOptions, AgentPoolListOptions, AgentPoolRemoveFromWorkspacesOptions, AgentPoolUpdateOptions, AgentTokenCreateOptions, - AgentPoolAssignToProjectsOptions, ) @@ -46,9 +46,7 @@ def main(): workspace_id = os.environ.get( "TFE_WORKSPACE_ID" ) # optional, for workspace assignment - project_id = os.environ.get( - "TFE_PROJECT_ID" - ) # optional, for project assignment + project_id = os.environ.get("TFE_PROJECT_ID") # optional, for project assignment if not token: print("TFE_TOKEN environment variable is required") diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index bdbf42d4..04c9aaa7 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -8,8 +8,8 @@ Agent, AgentListOptions, AgentPool, - AgentPoolAssignToWorkspacesOptions, AgentPoolAssignToProjectsOptions, + AgentPoolAssignToWorkspacesOptions, AgentPoolCreateOptions, AgentPoolListOptions, AgentPoolReadOptions, From a6ce901d86dab253a8e7f8b3bc343884236033da Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Sun, 24 May 2026 14:07:02 +0530 Subject: [PATCH 82/95] Fix typos and move enum stage to avoid conflict (#167) --- src/pytfe/models/__init__.py | 4 +-- src/pytfe/models/run_task_request.py | 8 +++--- src/pytfe/models/task_stage.py | 8 +----- src/pytfe/resources/task_result.py | 1 - tests/units/test_run_task_request.py | 40 ++++++++++++++++++++++++++++ tests/units/test_task_stage.py | 11 ++++++-- 6 files changed, 56 insertions(+), 16 deletions(-) create mode 100644 tests/units/test_run_task_request.py diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index 04c9aaa7..730e2068 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -330,7 +330,7 @@ ) from .run_task_request import ( RunTaskRequest, - RunTaskRequestCapabilitites, + RunTaskRequestCapabilities, ) from .run_trigger import ( RunTrigger, @@ -750,7 +750,7 @@ "RunTaskReadOptions", # Run Task Request "RunTaskRequest", - "RunTaskRequestCapabilitites", + "RunTaskRequestCapabilities", # Task Result "TaskResult", "TaskResultEnforcementLevel", diff --git a/src/pytfe/models/run_task_request.py b/src/pytfe/models/run_task_request.py index b2510460..673cbc60 100644 --- a/src/pytfe/models/run_task_request.py +++ b/src/pytfe/models/run_task_request.py @@ -8,7 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field -class RunTaskRequestCapabilitites(BaseModel): +class RunTaskRequestCapabilities(BaseModel): """Defines the capabilities that the caller supports.""" model_config = ConfigDict(populate_by_name=True) @@ -27,9 +27,9 @@ class RunTaskRequest(BaseModel): access_token: str = Field( ..., alias="access_token", description="The access token for the run task" ) - capabilitites: RunTaskRequestCapabilitites = Field( - default_factory=lambda: RunTaskRequestCapabilitites(outcomes=False), - alias="capabilitites", + capabilities: RunTaskRequestCapabilities = Field( + default_factory=lambda: RunTaskRequestCapabilities(outcomes=False), + alias="capabilities", description="The capabilities that the caller supports", ) configuration_version_download_url: str | None = Field( diff --git a/src/pytfe/models/task_stage.py b/src/pytfe/models/task_stage.py index a5f338ef..a2b76bdc 100644 --- a/src/pytfe/models/task_stage.py +++ b/src/pytfe/models/task_stage.py @@ -10,16 +10,10 @@ from pytfe.models.policy_evaluation import PolicyEvaluation from pytfe.models.run import Run +from pytfe.models.run_task import Stage from pytfe.models.task_result import TaskResult -class Stage(str, Enum): - pre_plan = "pre_plan" - post_plan = "post_plan" - pre_apply = "pre_apply" - post_apply = "post_apply" - - class TaskStageStatus(str, Enum): pending = "pending" running = "running" diff --git a/src/pytfe/resources/task_result.py b/src/pytfe/resources/task_result.py index 737008f0..8f6c05d7 100644 --- a/src/pytfe/resources/task_result.py +++ b/src/pytfe/resources/task_result.py @@ -23,7 +23,6 @@ def read(self, task_result_id: str) -> TaskResult: return self._parse_task_result(data) def _parse_task_result(self, data: dict[str, Any]) -> TaskResult: - attributes = data.get("attributes", {}) attributes["id"] = data.get("id") diff --git a/tests/units/test_run_task_request.py b/tests/units/test_run_task_request.py new file mode 100644 index 00000000..0715cee0 --- /dev/null +++ b/tests/units/test_run_task_request.py @@ -0,0 +1,40 @@ +"""Unit tests for run task webhook request models.""" + +from pytfe.models import RunTaskRequest, RunTaskRequestCapabilities + + +def _run_task_request_payload() -> dict: + return { + "access_token": "token", + "configuration_version_download_url": "https://example.com/cv", + "configuration_version_id": "cv-123", + "is_speculative": False, + "organization_name": "example-org", + "payload_version": 1, + "plan_json_api_url": "https://example.com/plan-json", + "run_app_url": "https://example.com/run", + "run_created_at": "2024-01-01T00:00:00Z", + "run_created_by": "user-123", + "run_id": "run-123", + "run_message": "Queued manually", + "stage": "post_plan", + "task_result_callback_url": "https://example.com/callback", + "task_result_enforcement_level": "mandatory", + "task_result_id": "taskrs-123", + "workspace_app_url": "https://example.com/workspace", + "workspace_id": "ws-123", + "workspace_name": "example-workspace", + } + + +def test_run_task_request_parses_capabilities_from_live_payload(): + payload = _run_task_request_payload() + payload["capabilities"] = {"outcomes": True} + + request = RunTaskRequest.model_validate(payload) + + assert isinstance(request.capabilities, RunTaskRequestCapabilities) + assert request.capabilities.outcomes is True + dumped = request.model_dump(by_alias=True) + assert dumped["capabilities"] == {"outcomes": True} + assert "capabilitites" not in dumped diff --git a/tests/units/test_task_stage.py b/tests/units/test_task_stage.py index 8e7fc738..135af35d 100644 --- a/tests/units/test_task_stage.py +++ b/tests/units/test_task_stage.py @@ -2,6 +2,8 @@ from pytfe.client import TFEClient from pytfe.errors import InvalidTaskStageIDError +from pytfe.models import Stage as ExportedStage +from pytfe.models.run_task import Stage as RunTaskStage from pytfe.models.task_stage import ( Stage, TaskStage, @@ -25,6 +27,11 @@ def test_task_stage_methods_exist(): assert hasattr(client.task_stages, "override") +def test_task_stage_uses_canonical_stage_enum(): + assert Stage is RunTaskStage + assert Stage is ExportedStage + + # InvalidTaskStageIDError tests @@ -69,7 +76,7 @@ def test_task_stage_partial_payload(): {"id": "ts-456", "stage": "pre_plan", "status": "pending"} ) assert ts.id == "ts-456" - assert ts.stage == Stage.pre_plan + assert ts.stage == Stage.PRE_PLAN assert ts.status == TaskStageStatus.pending assert ts.status_timestamps is None assert ts.created_at is None @@ -90,7 +97,7 @@ def test_task_stage_full_payload(): "actions": {"is-overridable": False}, } ) - assert ts.stage == Stage.post_plan + assert ts.stage == Stage.POST_PLAN assert ts.status == TaskStageStatus.passed assert ts.permissions is not None assert ts.permissions.can_override is True From 0b671236d4d45828194426f5730487b97d8e83a9 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Sun, 24 May 2026 22:04:15 +0530 Subject: [PATCH 83/95] Add surrounding features - Agents Documentations with Other features (#168) --- AGENTS.md | 136 ++++++ README.md | 31 +- docs/ITERATORS.md | 187 ++++++++ docs/MODELS.md | 263 +++++++++++ docs/RESOURCE.md | 469 +++++++++++++++++++ examples/apply.py | 30 ++ examples/configuration_version.py | 36 ++ examples/plan.py | 43 +- examples/policy_set.py | 84 ++++ examples/project.py | 76 ++- examples/registry_module.py | 3 +- examples/state_versions.py | 35 ++ examples/team.py | 66 +++ examples/team_workspace_access.py | 147 ++++++ examples/workspace.py | 52 +- src/pytfe/_http.py | 6 +- src/pytfe/client.py | 2 + src/pytfe/models/__init__.py | 26 + src/pytfe/models/assessment_result.py | 29 ++ src/pytfe/models/policy_set.py | 16 + src/pytfe/models/run_task_integration.py | 5 +- src/pytfe/models/team_workspace_access.py | 96 ++++ src/pytfe/resources/apply.py | 32 ++ src/pytfe/resources/configuration_version.py | 34 ++ src/pytfe/resources/plan.py | 94 +++- src/pytfe/resources/policy_set.py | 81 +++- src/pytfe/resources/projects.py | 39 +- src/pytfe/resources/registry_module.py | 27 +- src/pytfe/resources/state_versions.py | 55 ++- src/pytfe/resources/team.py | 140 ++++++ src/pytfe/resources/team_workspace_access.py | 125 +++++ src/pytfe/resources/workspaces.py | 42 ++ tests/units/test_plan.py | 36 +- tests/units/test_project.py | 20 +- tests/units/test_state_version.py | 43 +- 35 files changed, 2488 insertions(+), 118 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/ITERATORS.md create mode 100644 docs/MODELS.md create mode 100644 docs/RESOURCE.md create mode 100644 examples/team_workspace_access.py create mode 100644 src/pytfe/models/assessment_result.py create mode 100644 src/pytfe/models/team_workspace_access.py create mode 100644 src/pytfe/resources/team_workspace_access.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..792fdf3d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,136 @@ +# AGENTS.md — guide for AI agents working in this repository + +If you are an AI coding agent (Claude Code, Codex, Cursor, GitHub Copilot Workspace, etc.) about to make changes to this repository, read this file first. It will save you from generating code that diverges from the codebase's conventions. + +If you are a human contributor, the same conventions apply to you — but the more comprehensive [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) and the deep-dive references linked below are written for you specifically. + +## What this repo is + +`pytfe` is the official Python SDK for the HCP Terraform and Terraform Enterprise V2 API. It wraps roughly 50 resource services (workspaces, runs, policies, teams, agents, …) and is consumed by downstream projects. Source layout: + +``` +src/pytfe/ + client.py # TFEClient — composition root, wires every resource + config.py # TFEConfig — auth, timeout, retry, proxy settings + _http.py # HTTPTransport — request, retry, redirects, auth + _jsonapi.py # JSON:API envelope helpers + errors.py # Typed exception hierarchy (TFEError + ~80 subclasses) + utils.py # Validation + small helpers + models/ # Pydantic v2 models, one file per resource + resources/ # Service classes, one file per resource + +tests/units/ # Pytest unit tests with mocked transport, one file per resource +examples/ # Runnable CLI demos, one file per resource (or extended) +docs/ # Internal reference (see below) +``` + +## Required reading before generating code + +These three documents define the patterns this codebase already uses. Generating code without consulting them will produce inconsistent output: + +| Topic | Doc | +|---|---| +| `list_*` methods, pagination, iterator vs list, the `_list` helper | [`docs/ITERATORS.md`](docs/ITERATORS.md) | +| Pydantic model conventions: `ConfigDict`, aliases, validators, relationships, exporting | [`docs/MODELS.md`](docs/MODELS.md) | +| Resource service patterns: method shape, JSON:API envelopes, client wiring, examples | [`docs/RESOURCE.md`](docs/RESOURCE.md) | + +Each doc ends with a checklist. Use those checklists; they encode the rules a reviewer will look for. + +## Source verification for API shape + +Before adding a new resource, endpoint, enum, or non-obvious response parser, verify the wire contract against primary sources: + +- Official HCP Terraform API docs: https://developer.hashicorp.com/terraform/cloud-docs/api-docs +- go-tfe implementation: https://github.com/hashicorp/go-tfe +- OpenAPI specs or live probes when the public docs/go-tfe are missing, beta, or ambiguous + +Use the official docs and go-tfe as the first sources of truth. OpenAPI and live probes are supporting evidence, especially for endpoints that are newly released or not fully documented yet. When behavior is surprising, note the source you checked in the PR description, test name, example header, or a short code comment. + +## The cardinal rules + +A handful of conventions are pervasive enough that you'll regret breaking them. In rough order of "how loudly it breaks at review time": + +1. **`list_*` methods return `Iterator[X]`.** Not `list[X]`, not `Iterable[X]`, not a custom `Pager`. Use `for item in self._list(path): yield ...` inside the method body. ([ITERATORS.md](docs/ITERATORS.md)) + +2. **JSON:API attribute names go through `Field(alias="...")`.** The API sends `created-at`, Python uses `created_at`. Pair with `model_config = ConfigDict(populate_by_name=True, validate_by_name=True)` on the model. ([MODELS.md](docs/MODELS.md)) + +3. **`model_dump(by_alias=True, exclude_none=True)` for write payloads.** Without `by_alias=True` you'll send snake_case to the API and it will silently drop the fields. Add `mode="json"` if the options contain enums. + +4. **For new public APIs, prefer typed `TFEError` subclasses.** The error hierarchy in `errors.py` is part of the public API, and downstream consumers often `except TFEError:` once. Existing methods still expose many `ValueError` paths; do not change those established exceptions unless the breaking-change impact is explicitly accepted. + +5. **Validate IDs at the top of every method.** Use `valid_string_id` from `utils.py`. New methods should prefer typed `InvalidIDError` errors; existing resources may already use `ValueError` and should keep that public behavior unless a breaking change is intentional. + +6. **Wire every new resource into `client.py`.** A resource not added to `TFEClient.__init__` is unreachable. Same for new models in `models/__init__.py`. + +7. **Use the standard verb names: `list`, `read`, `create`, `update`, `delete`.** Plus `add_*` / `remove_*` for relationship modifications. Argument order is always *identifiers first, options last*. + +## Things that look reasonable but are actually wrong here + +These are mistakes a competent Python developer would make if they hadn't read the conventions. Avoid them: + +- **Don't catch `httpx` errors directly.** The transport already translates them into `TFEError` subclasses. Catching `httpx.HTTPError` in a resource means the typed error never propagates. +- **Always send the bearer token, even to absolute URLs returned by the API.** Endpoints like `hosted_state_download_url`, `hosted_state_upload_url`, plan `json-output`, and apply `errored-state` redirect to `archivist.terraform.io` — which is HashiCorp infrastructure that *requires* the bearer. go-tfe does the same (see `state_version.go::Download` + `tfe.go::NewRequest`). Stripping the bearer breaks downstream consumers (notably the Ansible collection's statefile + dynamic-inventory flows). `HTTPTransport.request` accepts `include_auth=False` only as an opt-out for the hypothetical case of calling a genuinely non-HashiCorp host; do not use it for Archivist URLs. +- **Don't write a custom page loop.** `self._list(path, params=...)` handles pagination + non-paginated endpoints transparently. Rolling your own loop will diverge from the rest of the codebase. +- **Don't reuse generators.** Iterators returned by `list_*` are single-use. If you need to traverse twice, `materialized = list(client.foo.list_bars(...))` first. +- **Don't add features beyond what was asked.** This codebase is approaching v1.0.0. Adding "while I'm here" refactors or speculative abstractions slows reviews and risks breaking the Ansible collection. +- **Don't assume every successful response is `{"data": ...}`.** Check the docs/go-tfe/spec for each endpoint: some return a JSON:API envelope, some return a bare resource object, `204 No Content`, `null`, raw bytes, or a redirect to a blob URL. Add tests for non-standard shapes. +- **Don't use bare `list[...]` annotations inside a resource class after defining `def list(...)`.** In class scope, mypy can resolve `list` to the method instead of the builtin. Use `builtins.list[...]`, `Sequence[...]`, or another unshadowed type. + +## Known cross-dependencies you should not break + +| Consumer | What they depend on | +|---|---| +| `hashicorp/terraform-ansible-collection` | The pytfe public API — resource methods, model fields, exception classes. Any signature change here is a breaking change. In particular, `client.projects.list_tag_bindings` is consumed with an `isinstance(response, list)` check; it intentionally still returns `list[TagBinding]` (see [ITERATORS.md](docs/ITERATORS.md) — Known exceptions). | +| Downstream user code generally | Method signatures, return types, model fields, and exception types. New errors should subclass an existing parent so `except TFEError:` continues to work, but existing `ValueError` behavior should not be changed casually. | + +When in doubt about whether a change is breaking: check `gh search code '' --owner hashicorp` to see if the Ansible repo uses it. + +## How to make a change + +This is the workflow that produces low-friction reviews. Follow it. + +1. **Understand the scope first.** If the task is "add resource X", read `docs/RESOURCE.md` end-to-end. If it's "fix bug in Y", read `Y`'s current implementation and tests before touching anything. +2. **Check official API docs and go-tfe for the canonical API shape.** `pytfe` mirrors the HCP Terraform API and often follows go-tfe's surface. URL paths, method names, payload shapes, response shapes, enum values, and redirect behavior should be verified against https://developer.hashicorp.com/terraform/cloud-docs/api-docs and https://github.com/hashicorp/go-tfe before designing anything. +3. **Add models first** (`src/pytfe/models/.py`), then the resource (`src/pytfe/resources/.py`), then wire both into the respective `__init__.py` / `client.py`. +4. **Write tests.** Mock `HTTPTransport`. One test per method, plus an invalid-id case for every public method. See `tests/units/test_comment.py` as a small reference. +5. **Run `make test` and `make lint`.** Both must pass. `pytest tests/units/` runs the suite directly; it should be < 2 seconds. +6. **Add or extend an example.** Real engineers will copy-paste it; make it work end-to-end. Use env vars (`TFE_TOKEN`, `TFE_ORG`) for auth, never hard-code credentials. +7. **If the change is non-trivial, verify live.** The repo doesn't run integration tests in CI, so the only way to catch a wrong URL or a typo in an attribute alias is to run the example against a real organization. + +## Things to never do + +- **Never put a token, password, or other credential in any file.** Use environment variables. The user will rotate them after; you don't need to know them. +- **Never use `git push --force` or `git reset --hard` without explicit instruction.** Same for `--no-verify`, force-push to `main`, or rebasing public commits. +- **Never commit `.env`, `credentials.json`, `*.tfstate`, or anything with secrets.** Match against the existing `.gitignore` if unsure. +- **Never bypass pre-commit hooks.** If a hook fails, fix the underlying issue. +- **Never run an example that creates real resources against production without explicit user confirmation.** Sandbox orgs are safe; user's actual workspace is not. + +## Style + +The codebase uses [ruff](https://docs.astral.sh/ruff/) for both formatting and linting and [mypy](https://mypy.readthedocs.io/) for type checking. Type hints are required on every public method's signature. Docstrings are required on every public method — keep them to one or two lines unless the behavior is genuinely non-obvious. + +Comments are minimal by design. A comment should explain *why* something non-obvious is true, not *what* the code does. The names and types should be enough to convey "what". + +```python +# ❌ Don't +# Increment the counter by 1 +counter += 1 + +# ✅ Do (only when the why is non-obvious) +# Run task stages are wire values, not Python names. If the API/go-tfe says +# "pre-plan", keep the hyphen; do not "normalize" it to snake_case. +stage_value = raw_value +``` + +## When you're done + +A reasonable PR includes: + +- Code (resource + models) +- Tests covering every public method +- An updated or new example +- A short `CHANGELOG.md` entry under `# v.0 (Unreleased)` describing the user-visible change + +Open the PR with a description that explains *why* the change is needed, links to any HCP Terraform API docs or go-tfe code referenced, and notes any behavior changes a downstream consumer might see. + +The reviewer's checklist will be the union of the checklists in [`docs/ITERATORS.md`](docs/ITERATORS.md), [`docs/MODELS.md`](docs/MODELS.md), and [`docs/RESOURCE.md`](docs/RESOURCE.md). Pre-running them yourself is the fastest way to a merge. diff --git a/README.md b/README.md index 45c869f0..2a507001 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,7 @@ config = TFEConfig( client = TFEClient(config) -orgs = client.organizations.list() -for org in orgs.items: +for org in client.organizations.list(): print(org.name) ``` @@ -57,8 +56,7 @@ from pytfe import TFEClient, TFEConfig # Equivalent to providing no values; falls back to env vars if set. client = TFEClient(TFEConfig()) -orgs = client.organizations.list() -for org in orgs.items: +for org in client.organizations.list(): print(org.name) ``` @@ -69,11 +67,32 @@ from pytfe import TFEClient, TFEConfig config = TFEConfig(address="", token="") client = TFEClient(config) -orgs = client.organizations.list() -for org in orgs.items: +for org in client.organizations.list(): print(org.name) ``` +## Listing resources + +Anything named `list` or `list_*` on a resource service returns an **iterator**, not a Python `list`. Pagination is handled for you under the hood — the iterator keeps fetching pages from the API until there are no more. This mirrors the underlying HCP Terraform API, where every list endpoint is paginated (`page[number]` / `page[size]`), and keeps memory flat even when an organization has thousands of workspaces or runs. + +You'll use it one of two ways: + +```python +# Stream — handy when you might break early or when results are large +for ws in client.workspaces.list("my-org"): + if ws.name.startswith("prod-"): + print(ws.id, ws.name) + +# Materialize — when you actually want a list to index, len(), or pass around +workspaces = list(client.workspaces.list("my-org")) +print(f"found {len(workspaces)} workspaces") +``` + +A couple of things worth knowing: + +- The iterator is **single-use**. Once you've walked it, iterating again gives you nothing. Capture it with `list(...)` first if you need to reuse the result. +- Filters and page size live on the `*ListOptions` model for each resource — e.g. `WorkspaceListOptions(search="prod", page_size=50)`. Pagination still happens transparently; `page_size` only controls how big each underlying API page is. + ## Documentation - API reference and guides (SDK): **coming soon** diff --git a/docs/ITERATORS.md b/docs/ITERATORS.md new file mode 100644 index 00000000..46634a24 --- /dev/null +++ b/docs/ITERATORS.md @@ -0,0 +1,187 @@ +# Iterators and pagination in pyTFE + +This is internal reference for anyone — human or AI — adding a new resource to the SDK or auditing existing ones. Keep this file up to date as the conventions evolve. + +## The one-line rule + +> Any method named `list` or `list_*` on a resource service returns `Iterator[X]`. Never `list[X]`, never `LazyList`, never a custom `*Pager`. Just `Iterator[X]`. + +The matching import is `from collections.abc import Iterator`, not `typing.Iterator` (which is deprecated alias since 3.9). + +This rule applies to public resource service methods. Private parsing helpers may return concrete lists when they are just internal implementation details and are not part of the SDK public surface. + +## Why iterators + +The HCP Terraform API paginates **every** list endpoint. The contract is uniform: pass `page[number]` and `page[size]`, read pagination metadata out of the response envelope, and follow links until there are no more. Materialising the entire result set up front would mean fetching every page synchronously before the caller sees the first row — fine for ten workspaces, painful for ten thousand. Iterators let the SDK do the right thing by default: lazy under the hood, simple at the call site. + +Even when an endpoint isn't actually paginated (some single-shot relationship reads like `effective-tag-bindings`), we still use the iterator signature. The reason is purely consistency — a future contributor (or an LLM generating new resources by analogy) should never have to think about which list method is which shape. If it's named `list_*`, it returns `Iterator[X]`. + +## How callers use them + +There are two idioms. Both are normal and expected. + +```python +# 1. Stream — handy when results are large, or you can break early. +for ws in client.workspaces.list("my-org"): + if ws.name.startswith("prod-"): + print(ws.id) + break + +# 2. Materialize — when you actually want a list to len(), index, or hand off. +workspaces = list(client.workspaces.list("my-org")) +print(f"{len(workspaces)} workspaces") +``` + +Two things every caller needs to know: + +- **Iterators are single-use.** Iterating an already-walked iterator yields nothing. If you need to traverse the same result more than once, capture it with `list(...)` first. +- **Iterators are always truthy.** `if iterator:` is True even when the iterator is empty. Use `materialized = list(...); if materialized:` if you need a non-empty check. + +## How to implement a new `list_*` method + +There is **one canonical pattern** in the codebase, and it works for both paginated and non-paginated endpoints. Use it unless you have a specific reason not to. + +### The canonical pattern: `self._list(...)` + `yield` + +```python +def list( + self, organization: str, options: WorkspaceListOptions | None = None +) -> Iterator[Workspace]: + if not valid_string_id(organization): + raise InvalidOrgError() + + params = options.model_dump(by_alias=True, exclude_none=True, mode="json") if options else {} + path = f"/api/v2/organizations/{organization}/workspaces" + for item in self._list(path, params=params): + yield self._workspace_from(item) +``` + +That's it. `self._list()` lives in `_base.py` and handles `page[number]` / `page[size]` and follow-through automatically. It is also robust to endpoints that **don't paginate** — if the response has no pagination metadata and the returned data is smaller than the requested page size, the helper just breaks after one round-trip. So you do not need a different code path for relationship reads like `GET /workspaces/{id}/tag-bindings` (single response) versus list endpoints like `GET /organizations/{org}/workspaces` (paginated). The same `for item in self._list(path): yield ...` works for both. + + +### Note on lazy validation + +A Python generator function defers its entire body until the caller calls `next()`. That means `if not valid_string_id(...): raise ...` only fires on first iteration, not at call time. Practically, callers iterate immediately so this is fine — but tests need to materialize before asserting that a `ValueError` is raised: + +```python +# tests/units/test_*.py — invalid-id case +with pytest.raises(InvalidOrgError): + list(client.workspaces.list("")) # wrap with list() to force iteration +``` + +If you genuinely need eager validation (raised from the call expression itself, not the first `for` loop), use the wrapper pattern: + +```python +def list(self, organization: str, ...) -> Iterator[Workspace]: + if not valid_string_id(organization): + raise InvalidOrgError() # eager + + params = ... + path = ... + def _gen() -> Iterator[Workspace]: + for item in self._list(path, params=params): + yield self._workspace_from(item) + return _gen() +``` + +Both forms appear in the codebase (`policy_set.list` uses the wrapper; most others don't). Pick the wrapper only when eager-error behavior is important to a specific resource. + +### Mypy note: `def list` shadows `list[...]` + +Inside a class that defines a method named `list`, mypy can resolve later bare annotations like `list[str]` to the method instead of the builtin type. If the same resource class has helper methods after `def list(...)`, avoid bare `list[...]` in those later signatures or annotations. Use one of these instead: + +```python +import builtins +from collections.abc import Sequence + + +def add_users(self, team_id: str, usernames: builtins.list[str]) -> None: ... +def add_users(self, team_id: str, usernames: Sequence[str]) -> None: ... +``` + +### The one deviation: `return iter(list)` for endpoints with fallback logic + +There is exactly one situation where neither the plain generator nor the wrapper pattern works well: when the method has a **try/except fallback that calls a different endpoint** after the primary one fails. A pure generator could yield items from the primary endpoint, fail partway through, then switch to the fallback and yield duplicates. + +For this case — and this case only — fetch eagerly and return `iter(materialized_list)`: + +```python +def list_versions( + self, module_id: RegistryModuleID +) -> Iterator[RegistryModuleVersion]: + if not self._validate_module_id(module_id): + raise ValueError("Invalid module ID") + + try: + versions = [...] # primary endpoint + return iter(versions) + except Exception: + try: + versions = [...] # fallback: different endpoint + return iter(versions) + except Exception: + return iter([]) +``` + +`registry_module.list_versions` is the only method in the codebase that does this. Add a docstring note explaining the reason if you find yourself reaching for this pattern, so future readers don't mistake it for something to copy. + +Do **not** reach for `iter(list)` just because the endpoint is non-paginated. Use `self._list()` for those — that's the convention. + +### Shape that does **not** match the convention (don't do this) + +```python +# ❌ Returns Iterable instead of Iterator — looks similar, isn't. +def list(...) -> Iterable[Workspace]: ... + +# ❌ Returns Pager / LazyList / custom wrapper. +def list(...) -> WorkspaceList: ... + +# ❌ Returns concrete list. The type is a public contract; consumers will +# rely on len(), indexing, and isinstance(result, list). See "Known +# exceptions" below for the one method where this is documented. +def list_widgets(...) -> list[Widget]: ... +``` + +## Known exceptions (and why) + +A handful of methods deliberately diverge from the convention. They're tracked here so future audits don't try to "fix" them and silently break a downstream consumer. + +| Method | Returns | Why we left it | +|---|---|---| +| `projects.list_tag_bindings` | `list[TagBinding]` | Downstream code checks `isinstance(response, list)`, so changing this would be a breaking change. | +| `registry_module.list_commits` | `CommitList` | The endpoint returns a typed envelope with metadata fields beyond just the list of commits. A custom return type is appropriate here. | +| `registry_module.list_versions` | `Iterator[X]` via `iter(list)` | Has a try/except fallback path that calls a different endpoint on failure. See the deviation pattern above. | + +Anything public and not in that table should follow the canonical pattern. If you find one that doesn't, either fix it or add it to the table with the compatibility reason. + +## How to test a `list_*` method + +Mock the transport, then materialize with `list(...)` to assert: + +```python +def test_list_workspaces(self): + mock_response = Mock() + mock_response.json.return_value = { + "data": [{"id": "ws-1", "attributes": {"name": "first"}}], + "meta": {"pagination": {"current-page": 1, "total-pages": 1}}, + } + self.mock_transport.request.return_value = mock_response + + result = list(self.workspaces_service.list("my-org")) + + assert len(result) == 1 + assert result[0].id == "ws-1" +``` + +Don't assert `isinstance(result, list)` against the raw return — that asserts on the *idiom* the caller chose, not on the SDK contract. If you want to assert iterator semantics, use `isinstance(result, Iterator)` from `collections.abc`. + +## Quick checklist when reviewing a new resource PR + +- [ ] Every `list*` method returns `Iterator[X]`, not `list[X]` or `Iterable[X]` +- [ ] `Iterator` is imported from `collections.abc`, not `typing` +- [ ] The body uses the canonical `for item in self._list(path, params=params): yield ...` pattern — including for non-paginated single-shot endpoints +- [ ] Hand-rolled `iter(materialized_list)` only appears if the method has a try/except fallback to a different endpoint (extremely rare — has a docstring note explaining why) +- [ ] If a class defines `def list(...)`, later annotations in that class avoid bare `list[...]` so mypy does not resolve `list` to the method +- [ ] Examples that call the method use `list(client.foo.list_bars(...))` (or stream with a `for` loop) — never assume list semantics on the bare return +- [ ] Unit tests materialize with `list(...)` before asserting length/indexing; invalid-id tests also wrap with `list(...)` to force iteration +- [ ] The README's `## Listing resources` section is still accurate after your change diff --git a/docs/MODELS.md b/docs/MODELS.md new file mode 100644 index 00000000..2c036090 --- /dev/null +++ b/docs/MODELS.md @@ -0,0 +1,263 @@ +# Models — Pydantic conventions in pyTFE + +This is internal reference for adding or editing Pydantic models in `src/pytfe/models/`. The patterns below are what the codebase already does; follow them so new resources line up with what's there. + +All models inherit from `pydantic.BaseModel` and target Pydantic v2. The `from __future__ import annotations` line is at the top of every model file so forward references and type hints work without runtime imports. + +## Layout of a model file + +One model file per resource, named after the resource (`workspace.py`, `agent.py`, `team.py`). Each file usually contains: + +1. **Enums** for fixed string sets the API uses (status, type, kind). +2. **The main resource model** (the thing you get back from a `read`/`list` — e.g. `Workspace`, `Team`). +3. **`*CreateOptions`** for `POST` requests. +4. **`*UpdateOptions`** for `PATCH` requests. +5. **`*ListOptions`** for `GET` collection requests (filters, pagination, includes). +6. **`*ReadOptions`** for `GET` single-resource requests that take `include[]` (only when needed). + +Use a single file unless the model surface is large enough that splitting helps. There's no "package per resource" pattern here — one file is the default. + +## `ConfigDict` + +New or touched `BaseModel` classes should set `model_config = ConfigDict(...)` unless you are deliberately preserving a local legacy pattern. Several older models predate this convention; do not mass-refactor them just to satisfy this rule because changing validation/coercion behavior can be a public API change. The conventions for new work are: + +| Setting | When to use | +|---|---| +| `populate_by_name=True` | **Always.** Lets callers pass either the field name (`created_at=...`) or the alias (`{"created-at": ...}`) when constructing. | +| `validate_by_name=True` | Use on models that are parsed *from* API responses **or** constructed by callers via field names. Pair with `populate_by_name=True`. | +| `extra="forbid"` | Use on `*CreateOptions` / `*UpdateOptions` / option models where you want a typo (`workspce_id=...`) to fail loudly instead of being silently dropped. Don't put it on response models — the API can add fields and we don't want that to break parsing. | +| `arbitrary_types_allowed=True` | Only when you genuinely have a non-Pydantic type in a field (rare). | + +The standard line you'll write 90% of the time: + +```python +class Foo(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + ... +``` + +## Field aliases: JSON:API hyphens → Python snake_case + +HCP Terraform speaks JSON:API, which uses hyphenated attribute names (`created-at`, `auto-apply`, `state-versions`). Python uses snake_case. Bridge with `Field(alias=...)`: + +```python +from datetime import datetime +from pydantic import BaseModel, ConfigDict, Field + + +class Run(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str + has_changes: bool | None = Field(None, alias="has-changes") + is_destroy: bool | None = Field(None, alias="is-destroy") + auto_apply: bool | None = Field(None, alias="auto-apply") + created_at: datetime | None = Field(None, alias="created-at") + canceled_at: datetime | None = Field(None, alias="canceled-at") +``` + +Rules: + +- **Every multi-word JSON:API attribute** gets an alias. Don't try to invent a snake_case-to-hyphen mapper — be explicit per field. +- **Page params** use the JSON:API square-bracket form: `Field(None, alias="page[number]")`, `Field(None, alias="page[size]")`. +- **Filter params** use the same convention: `Field(None, alias="filter[workspace][name]")`. +- **`include`** is a comma-separated string on the wire but exposed as `list[SomeEnum] | None` in Python; the resource layer dumps options with `mode="json"` and joins the resulting values (`",".join(params["include"])`). See the `policy_set.read_with_options` pattern. + +## Optional vs required vs default fields + +The codebase is conservative about which fields are required. The pattern: + +- **Resource models** (parsed from API responses): almost everything except `id` is `field: T | None = Field(None, alias="...")`. The API may omit fields depending on permissions or include params, so being permissive avoids brittle parsing. +- **`*CreateOptions`**: required fields use `field: T = Field(..., description="...")` (Pydantic's "required" sentinel). Optional fields use `field: T | None = None`. +- **`*UpdateOptions`**: **everything** is optional (`field: T | None = None`). `PATCH` semantics — only set fields are sent. +- **Collection fields**: prefer `default_factory=list` over `= []` (avoids the mutable-default trap). For maps, `default_factory=dict`. + +Example: + +```python +class WorkspaceCreateOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + name: str = Field(..., description="Workspace name") + description: str | None = None + auto_apply: bool | None = Field(None, alias="auto-apply") + project: dict | None = None # relationship — see "Relationships" below + + +class WorkspaceUpdateOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + name: str | None = None + description: str | None = None + auto_apply: bool | None = Field(None, alias="auto-apply") +``` + +## Enums + +String enums with explicit string values, mirroring what the API returns: + +```python +from enum import Enum + + +class RunStatus(str, Enum): + PENDING = "pending" + PLANNING = "planning" + PLANNED = "planned" + APPLIED = "applied" + ERRORED = "errored" + DISCARDED = "discarded" +``` + +A few conventions: + +- **`str, Enum`** so the value is JSON-serialisable without `.value` indirection (pydantic handles this with `mode="json"` on `model_dump`). +- **`SCREAMING_SNAKE`** member names. Values mirror the wire string exactly — usually lowercase, sometimes with underscores. Don't change the wire value to "look nicer". +- When the API uses hyphenated values (`"pre-plan"`, `"post-plan"`), keep the hyphens in the value string. Verify enum values against the official HCP Terraform API docs, go-tfe, or live API if unsure — there have been past bugs where underscore values diverged from what the server actually returns. +- Put enums **above** the model that uses them in the same file. + +## Validators + +Two flavours, both Pydantic v2: + +### `model_validator(mode="after")` for option models + +Use on `*CreateOptions` / `*UpdateOptions` to enforce required-name / valid-ID rules at construction time. For new public APIs, prefer a typed `TFEError` subclass from `pytfe.errors`. For existing option models that already raise `ValueError`, preserve that behavior unless the breaking-change impact is explicitly accepted: + +```python +from pydantic import model_validator +from ..errors import InvalidNameError, RequiredNameError +from ..utils import valid_string, valid_string_id + + +class AgentPoolCreateOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + name: str = Field(..., alias="name") + + @model_validator(mode="after") + def valid(self) -> AgentPoolCreateOptions: + if not valid_string(self.name): + raise RequiredNameError() + if not valid_string_id(self.name): + raise InvalidNameError() + return self +``` + +### `field_validator` for per-field coercion or normalisation + +Use sparingly — only when you need to massage input before Pydantic's default coercion, or when a single field has a non-trivial rule: + +```python +from pydantic import field_validator + + +class NotificationConfiguration(BaseModel): + @field_validator("triggers", mode="before") + @classmethod + def _coerce_triggers(cls, v): + ... +``` + +`mode="before"` runs on the raw input; `mode="after"` runs on the already-validated value. Default to `"after"` unless you need pre-validation cleanup. + +## Relationships + +JSON:API responses include a `relationships` block separate from `attributes`. Two ways to model relationship references on the resource: + +### Option 1 — ID stub on the related model + +When you only need the related id, use a typed stub. The resource layer fills it in via `Model.model_construct(id=...)`: + +```python +class TaskStage(BaseModel): + model_config = ConfigDict(populate_by_name=True) + id: str + run: Run | None = Field(None, alias="run") # only .id populated + task_results: list[TaskResult] | None = Field(None, alias="task-results") +``` + +Use `model_construct` (not `model_validate`) in the resource for these stubs — it skips validation, which is correct because you only have `{id, type}`: + +```python +attributes["run"] = Run.model_construct(id=run_data["id"]) +``` + +### Option 2 — Flat `*_id` field + +When the relationship is "owned" by this resource and just one id matters, expose it as a flat `*_id` field (with hyphen alias if needed). Less plumbing, fine when you don't need the related model object: + +```python +class TeamWorkspaceAccess(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + id: str + team_id: str | None = Field(default=None, alias="team-id") + workspace_id: str | None = Field(default=None, alias="workspace-id") +``` + +The resource layer reads from `relationships.team.data.id` and stuffs it into `attributes["team-id"]` before calling `model_validate`. See `resources/team_workspace_access.py:_parse`. + +Pick Option 1 when callers may want to traverse the relationship further (e.g. `task_stage.run.id`). Pick Option 2 when the id is all you'll ever need. + +## Forward references and `model_rebuild` + +If a model A references model B and B references A (or A is defined before B), Pydantic can't resolve the forward ref at class-definition time. The fix: leave the annotation as a string in the model file, then call `Model.model_rebuild()` from `models/__init__.py` once everything is imported. + +The block at the bottom of `models/__init__.py` is where this happens: + +```python +Run.model_rebuild( + raise_errors=False, + _types_namespace={"TaskStage": TaskStage}, +) +Workspace.model_rebuild( + raise_errors=False, + _types_namespace={"AgentPool": AgentPool, "Run": Run, "TaskStage": TaskStage}, +) +``` + +`raise_errors=False` is the project default — failure to resolve a forward ref shouldn't crash the SDK at import time. Add your new model's rebuild call there if it has forward-referenced relations. + +## Exporting + +Two things to update when you add a model: + +1. **Imports** at the top of `models/__init__.py` — add your new classes alphabetically within their section. +2. **`__all__`** at the bottom — add the names that should be importable as `from pytfe.models import Foo`. + +Don't forget option models, enums, and any include-opt enums. The `__all__` list is what users see in `pytfe.models` — if it's not there, it's not part of the public API. + +## What NOT to do + +```python +# ❌ Don't use bare strings for the alias when the field has multiple words. +created_at: datetime | None = None # parses "created_at", misses "created-at" + +# ❌ Don't reach for arbitrary_types_allowed unless you actually have one. + +# ❌ Don't use mutable default values directly. +tags: list[str] = [] # all instances share the same list +tags: list[str] = Field(default_factory=list) # ✅ + +# Prefer a typed TFEError subclass for new public APIs. +raise ValueError("name required") # existing APIs may still do this +raise RequiredNameError() # preferred for new APIs + +# ❌ Don't model relationships as raw dicts when there's a typed stub option. +workspace: dict | None = None # loses type information +workspace: Workspace | None = None # ✅ (filled via model_construct in resource) +``` + +## Checklist when adding a new model + +- [ ] `from __future__ import annotations` at the top +- [ ] New or touched classes use `model_config = ConfigDict(populate_by_name=True, validate_by_name=True)` unless preserving a local legacy pattern +- [ ] Hyphenated JSON:API attribute names → `Field(alias="...")` +- [ ] Response model fields default to `T | None = Field(None, alias="...")` +- [ ] `*CreateOptions` uses `Field(...)` for required fields, `T | None = None` for optional +- [ ] `*UpdateOptions` is fully optional +- [ ] Enums are `str, Enum` with SCREAMING_SNAKE member names and wire-faithful values +- [ ] New validators prefer typed `TFEError` subclasses; existing `ValueError` behavior is not changed without an explicit compatibility decision +- [ ] Collections use `default_factory=list` / `default_factory=dict` +- [ ] Added to `models/__init__.py` imports + `__all__` +- [ ] If you used forward references, added a `model_rebuild()` call at the bottom of `models/__init__.py` diff --git a/docs/RESOURCE.md b/docs/RESOURCE.md new file mode 100644 index 00000000..b3be73cd --- /dev/null +++ b/docs/RESOURCE.md @@ -0,0 +1,469 @@ +# Resources — adding a new resource to pyTFE + +This is internal reference for adding or editing resource services in `src/pytfe/resources/`. A "resource" here is a service class like `Workspaces`, `Comments`, `TeamWorkspaceAccesses` — it wraps a related set of HCP Terraform API endpoints. The patterns below reflect what the codebase already does. Follow them. + +Companion docs you'll need alongside this one: + +- [MODELS.md](MODELS.md) — how to define the Pydantic models the resource takes and returns +- [ITERATORS.md](ITERATORS.md) — how `list_*` methods are shaped +- The [examples/](../examples) directory — runnable demos for each resource + +## What a resource file looks like + +One file per resource in `src/pytfe/resources/`, named after the resource (`workspaces.py`, `policy_set.py`, `team_workspace_access.py`). The class inside is the plural form (`Workspaces`, `PolicySets`, `TeamWorkspaceAccesses`). + +Every resource class inherits from `_Service` (in `_base.py`), which gives it: + +- `self.t` — the `HTTPTransport` for making requests +- `self._list(path, params=...)` — the paginated iterator helper (see [ITERATORS.md](ITERATORS.md)) + +Standard file scaffolding: + +```python +# 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 InvalidWorkspaceIDError, InvalidOrgError +from ..models.widget import Widget, WidgetCreateOptions, WidgetListOptions +from ..utils import valid_string_id +from ._base import _Service + + +class Widgets(_Service): + """Service for managing widgets.""" + + def list(...) -> Iterator[Widget]: ... + def read(...) -> Widget: ... + def create(...) -> Widget: ... + def update(...) -> Widget: ... + def delete(...) -> None: ... + + def _widget_from(self, data: dict[str, Any]) -> Widget: ... +``` + +The private `_widget_from(data)` helper at the bottom is convention — every resource that returns a model has one, used to translate a JSON:API resource object into the Pydantic model. + +## Method conventions + +The verbs are stable across the codebase. Use these names exactly. + +| Verb | Signature | HTTP | Returns | +|---|---|---|---| +| `list(...)` | `(parent_id, options=None)` | `GET` collection endpoint | `Iterator[Widget]` | +| `read(id)` | `(widget_id)` | `GET /widgets/{id}` | `Widget` | +| `read_with_options(id, options)` | `(widget_id, options)` | `GET /widgets/{id}?include=...` | `Widget` | +| `create(parent_id, options)` | parent first, options last | `POST` | `Widget` | +| `update(id, options)` | `(widget_id, options)` | `PATCH /widgets/{id}` | `Widget` | +| `delete(id)` | `(widget_id)` | `DELETE /widgets/{id}` | `None` | + +Argument-order rule: **identifiers first, options last**. `create(organization, options)`, `update(widget_id, options)`. Never reverse this; downstream callers rely on it. + +For relationship endpoints (`POST /widgets/{id}/relationships/foos`), use verbs like: + +- `add_*(id, options)` / `remove_*(id, options)` — modifies an unordered set +- `update_*(id, options)` — replaces the entire set +- `attach_*` / `detach_*` — pair-style operations +- `assign_*` — when there's a single relation being set (e.g. `assign_ssh_key`) + +Pick the verb that mirrors what go-tfe and the API docs use — consistency across SDKs matters when users are reading both. + +## Source verification + +Before implementing a new endpoint, check the primary sources for the exact contract: + +- Official HCP Terraform API docs: https://developer.hashicorp.com/terraform/cloud-docs/api-docs +- go-tfe implementation: https://github.com/hashicorp/go-tfe +- OpenAPI specs or live probes when docs/go-tfe are missing, beta, or ambiguous + +Verify URL path, HTTP method, request envelope, enum values, response shape, redirects, and whether the feature is generally available. If the implementation depends on a surprising behavior, record the source in the PR description, test name, example header, or a short code comment. + +## Validation: eager, with typed errors + +Validate every ID/name argument at the top of every method. Two helpers from `utils.py`: + +- `valid_string(s)` — non-empty string +- `valid_string_id(s)` — non-empty string with no `/` or whitespace (the JSON:API id contract) + +For new public APIs, prefer a typed `TFEError` subclass from `pytfe/errors.py`. Existing resources still expose many `ValueError` validation paths; do not change those established exception types unless the breaking-change impact is explicitly accepted. + +```python +from ..errors import InvalidWidgetIDError +from ..utils import valid_string_id + +def read(self, widget_id: str) -> Widget: + if not valid_string_id(widget_id): + raise InvalidWidgetIDError() + r = self.t.request("GET", f"/api/v2/widgets/{widget_id}") + return self._widget_from(r.json().get("data", {})) +``` + +If the typed error class you need doesn't exist yet, add it to `errors.py`. Follow the existing naming: + +- `InvalidIDError(InvalidValues)` — the id is missing or malformed +- `RequiredError(InvalidValues)` — a field that must be set wasn't +- `NotFoundError(NotFound)` — the API returned 404 (use sparingly; usually the transport raises `NotFound` already) + +Subclass from a sensible parent (`InvalidValues`, `WorkspaceValidationError`, etc.) so consumers can `except TFEError:` once and catch new errors. When touching existing methods, preserve their historical exception behavior unless the change is intentionally breaking. + +## Building JSON:API request payloads + +Most resource write requests use the JSON:API envelope: + +```python +payload = { + "data": { + "type": "widgets", + "attributes": options.model_dump(by_alias=True, exclude_none=True), + } +} +self.t.request("POST", "/api/v2/...", json_body=payload) +``` + +Key arguments to `model_dump`: + +- **`by_alias=True`** — emit the hyphenated JSON:API attribute names, not the Python snake_case field names. Without this you'll send `auto_apply` instead of `auto-apply` and the API will silently ignore it. +- **`exclude_none=True`** — don't send fields the caller didn't set. `PATCH` semantics depend on this. +- **`mode="json"`** — when your options contain enums, including query params such as `include`. Without `mode="json"`, an enum field serialises as `EnumClass.MEMBER` (the repr) instead of the wire value. The bug is silent — the API returns 400 with "Invalid parameter". + +So for option models that contain enum fields: + +```python +params = options.model_dump(by_alias=True, exclude_none=True, mode="json") +if isinstance(params.get("include"), list): + params["include"] = ",".join(params["include"]) +``` + +For relationships, use the JSON:API identifier-object shape: + +```python +payload = { + "data": { + "type": "team-workspaces", + "attributes": attrs, + "relationships": { + "team": {"data": {"type": "teams", "id": team_id}}, + "workspace": {"data": {"type": "workspaces", "id": workspace_id}}, + }, + } +} +``` + +For *replace-many* relationships, pass an array of identifiers: + +```python +payload = { + "data": [ + {"type": "workspaces", "id": wid} for wid in workspace_ids + ] +} +self.t.request("POST", f"/api/v2/projects/{project_id}/relationships/workspaces", json_body=payload) +``` + +## Parsing responses + +Do not assume every successful response is a JSON:API envelope. Check the official docs/go-tfe/spec before writing the parser. Common shapes in this SDK include: + +- JSON:API envelope: `{"data": {...}}` or `{"data": [{...}]}` +- Bare resource object with top-level `attributes` +- `204 No Content` +- `null` +- Raw bytes +- `3xx` redirect to a presigned blob URL + +Add unit tests for every non-standard shape a method supports. The common `_widget_from(data)` helper takes a single JSON:API `data` object (already unwrapped from the envelope by the caller) and returns the Pydantic model: + +```python +def _widget_from(self, data: dict[str, Any]) -> Widget: + attrs = dict(data.get("attributes") or {}) + attrs["id"] = data.get("id") + return Widget.model_validate(attrs) +``` + +If the model has relationships, pull them from `data["relationships"]` and either: + +1. **Embed an id-stub** using `Model.model_construct(id=...)` — use this when the model defines the relation as `OtherModel | None`. `model_construct` skips validation, which is correct for partial `{id, type}` data: + + ```python + relationships = data.get("relationships", {}) + run_data = relationships.get("run", {}).get("data") + if run_data: + attributes["run"] = Run.model_construct(id=run_data["id"]) + ``` + +2. **Flatten to `*_id`** when the model exposes a flat `team_id: str | None` field: + + ```python + team_data = (relationships.get("team") or {}).get("data") or {} + if team_data.get("id"): + attributes["team-id"] = team_data["id"] + ``` + +Always defensively coalesce with `or {}` — relationships may be missing from sparse responses. + +## Presigned URLs and redirects + +The TFE bearer token must not be forwarded to Archivist, S3, or other presigned blob hosts. Signed upload/download URLs already carry their own credentials. + +- Direct signed URL: `self.t.request("GET", url, include_auth=False)` +- API endpoint that returns a redirect: call the API path with `allow_redirects=False`, read the `Location` header, then fetch that URL with `include_auth=False` +- Add a unit test that asserts the blob URL call uses `include_auth=False` + +This applies to state upload/download, plan JSON output/schema, apply errored state, and any future blob-backed endpoint. + +## Pagination — use `self._list`, don't roll your own + +`_Service._list(path, params=...)` is the universal helper. It yields raw `dict` items from the `data` array, transparently following pagination. It gracefully handles single-shot non-paginated endpoints too — see [ITERATORS.md](ITERATORS.md) for the full breakdown. + +Don't write your own page loop. If you find yourself doing it, you're solving a problem `_list()` already handled. + +## URL paths + +Always start with `/api/v2/...`. The base URL is set on the transport, but the path includes the API version prefix: + +```python +"/api/v2/organizations/{organization}/widgets" # collection scoped to org +"/api/v2/widgets/{widget_id}" # single resource +"/api/v2/widgets/{widget_id}/relationships/foos" # JSON:API relationship route +"/api/v2/widgets/{widget_id}/actions/lock" # action endpoint +``` + +Use f-strings to interpolate ids — they've been validated by `valid_string_id` above. URL-quote organization names with `urllib.parse.quote` only when the API explicitly requires it (most don't). + +## Errors raised by the transport + +`HTTPTransport.request` raises typed errors from `pytfe.errors` based on status code: + +- `AuthError` for 401/403 +- `NotFound` for 404 +- `RateLimited` for 429 (with `.retry_after`) +- `ServerError` for 5xx +- `TFEError` for everything else 4xx + +You usually don't need to catch these — let them propagate to the caller. Catch only when: + +- You want to translate to a more specific error (`except TFEError as e: if "rate-limit" in str(e): raise ...`) +- The "error" is actually an expected outcome — like a `NotFound` meaning "no current assessment yet": + + ```python + try: + r = self.t.request("GET", f"/api/v2/workspaces/{ws_id}/current-assessment-result") + except NotFound: + return None + ``` + +## Wiring into the client + +Two places to update: + +### `src/pytfe/client.py` + +1. Add the import alphabetically within its section. +2. Add `self.widgets = Widgets(self._transport)` to `TFEClient.__init__`, grouped with related resources. + +```python +from .resources.widget import Widgets +... +self.widgets = Widgets(self._transport) +``` + +The attribute name on the client is **plural snake_case** (`workspaces`, `team_tokens`, `team_workspace_accesses`). It must match the class name's lowercased plural. + +## Typing gotcha: `def list` shadows `list[...]` + +If a resource class defines `def list(...)`, mypy can resolve later annotations in the same class like `list[str]` to the method instead of the builtin type. For helper methods defined after `list`, avoid bare `list[...]`. Use `builtins.list[...]`, `Sequence[...]`, or another unshadowed collection type. + +### `src/pytfe/models/__init__.py` + +If you added new models (almost always yes), wire them through: + +1. Import them alphabetically in the right section block. +2. Add their names to the `__all__` list at the bottom. + +If your models use forward references, add a `Model.model_rebuild(...)` call at the bottom — see [MODELS.md](MODELS.md). + +## Tests + +One test file per resource: `tests/units/test_widget.py`. Mirror the structure of `tests/units/test_comment.py` (small and clean) or `tests/units/test_workspaces.py` (large). + +The structure: + +```python +import pytest +from unittest.mock import Mock + +from pytfe._http import HTTPTransport +from pytfe.errors import InvalidWidgetIDError +from pytfe.models.widget import Widget, WidgetCreateOptions +from pytfe.resources.widget import Widgets + + +class TestWidgets: + @pytest.fixture + def mock_transport(self): + return Mock(spec=HTTPTransport) + + @pytest.fixture + def service(self, mock_transport): + return Widgets(mock_transport) + + def test_read_widget_invalid_id(self, service): + with pytest.raises(InvalidWidgetIDError): + service.read("") + + def test_list_widgets(self, service, mock_transport): + mock_response = Mock() + mock_response.json.return_value = { + "data": [{"id": "wid-1", "type": "widgets", "attributes": {...}}], + "meta": {"pagination": {"current-page": 1, "total-pages": 1}}, + } + mock_transport.request.return_value = mock_response + + result = list(service.list("my-org")) # materialize Iterator + + assert len(result) == 1 + assert result[0].id == "wid-1" +``` + +Two things test reviewers always check: + +- **Invalid-ID tests** for list methods wrap with `list(...)` to force iteration — generator-based methods defer validation. See [ITERATORS.md](ITERATORS.md). +- **JSON:API path assertions** match exactly. If you're constructing `/api/v2/widgets/{id}` with f-string interpolation, the test asserts the exact path. Don't be tempted to leave wildcards. + +`make test` runs everything; `make lint` runs ruff + mypy. Both must pass. + +## Examples — when and how + +New resources should get an example file in `examples/`, or an existing example should be extended if that is the natural home. The purpose isn't comprehensive coverage — it's "a real engineer landing on this repo can copy-paste this and have a working demo in 60 seconds". Prefer the current style for new examples, but do not churn older examples only to rename helpers or match prose. + +### When to make a new example file vs extend an existing one + +- **New resource, no existing example for the parent surface** → new file (`examples/widget.py`) +- **New method on an existing resource** → extend the existing example file, gate the new section behind a flag like `--demo-foo` or `--show-bar` +- **Single related feature spread across multiple resources** → pick the most natural home; don't duplicate + +We've already consolidated some examples into existing ones — see `examples/apply.py --recover-errored-state` for the pattern. When in doubt, extend rather than fragment. + +### Example file structure + +```python +# 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 WidgetCreateOptions + + +def _print_header(title: str) -> None: + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Widgets 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", default=os.getenv("TFE_ORG", "")) + parser.add_argument("--widget-id", help="Widget id for read/update/delete") + parser.add_argument("--list", action="store_true", help="List widgets") + parser.add_argument("--create", action="store_true", help="Create a widget") + args = parser.parse_args() + + if not args.token: + print("TFE_TOKEN is not set") + return 2 + + client = TFEClient(TFEConfig(address=args.address, token=args.token)) + + if args.list: + _print_header(f"Listing widgets for {args.organization}") + for w in client.widgets.list(args.organization): + print(f" - {w.id} {w.name}") + + if args.create: + _print_header("Creating a widget") + w = client.widgets.create( + args.organization, WidgetCreateOptions(name="example") + ) + print(f" created {w.id}") + # If the example creates resources, also clean them up at the end. + try: + client.widgets.delete(w.id) + print(f" cleaned up {w.id}") + except Exception as e: + print(f" WARN: cleanup failed: {e}") + + client.close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) +``` + +### Rules examples follow + +- **Auth via env vars by default** — `TFE_TOKEN`, `TFE_ADDRESS`, `TFE_ORG`. Never hard-code a token. +- **`argparse` for CLI**, with sensible `--` flags so users can opt into specific demos. +- **`_print_header(title)` helper** for visual separation between sections in new examples. Older examples vary; do not churn them only for naming consistency. +- **Cleanup what you create.** If the example creates scratch resources, delete them in a `try/finally`. Warn but don't fail on cleanup errors. +- **Exit codes**: `return 0` on success, `return 2` for missing config, raise on unexpected SDK errors so the user sees the traceback. +- **`client.close()` at the end** when you opened a client. + +## What NOT to do + +```python +# ❌ Reaching directly through self.t for paginated endpoints — use self._list +data = self.t.request("GET", path).json()["data"] # one page only, no pagination +for item in self._list(path): ... # ✅ + +# ❌ Letting the JSON:API envelope leak out +return r.json() # raw dict with "data"/"included"/etc. +return self._widget_from(r.json()["data"]) # ✅ + +# ❌ Sending snake_case attrs to the API +options.model_dump(exclude_none=True) # workspace_id → wrong on the wire +options.model_dump(by_alias=True, exclude_none=True) # ✅ + +# ❌ Forgetting mode="json" with enums — silent 400 from the API +options.model_dump(by_alias=True, exclude_none=True) # enum becomes 'EnumClass.MEMBER' +options.model_dump(by_alias=True, exclude_none=True, mode="json") # ✅ + +# Prefer typed errors for new public APIs. Preserve existing ValueError +# behavior unless the compatibility impact is explicitly accepted. +raise ValueError("invalid widget id") # existing APIs may do this +raise InvalidWidgetIDError() # preferred for new APIs + +# ❌ Forgetting to wire the resource into the client +# Just add `self.widgets = Widgets(self._transport)` in client.py — the +# resource is otherwise unreachable from TFEClient. +``` + +## Checklist when adding a new resource + +- [ ] New file `src/pytfe/resources/widget.py` with `class Widgets(_Service)` +- [ ] Standard verbs (`list`, `read`, `create`, `update`, `delete`) with the standard signatures +- [ ] Every method validates IDs; new public APIs prefer typed `TFEError` subclasses, while established `ValueError` behavior is preserved unless intentionally changed +- [ ] Write requests use the JSON:API envelope; `model_dump` uses `by_alias=True, exclude_none=True`, plus `mode="json"` if there are enums +- [ ] `list*` returns `Iterator[X]` via `self._list(...)` (see [ITERATORS.md](ITERATORS.md)) +- [ ] Response parsing helper `_widget_from(data)` translates JSON:API → Pydantic +- [ ] Non-standard response shapes (`204`, `null`, bare resources, raw bytes, redirects) are verified against docs/go-tfe/spec and covered by tests +- [ ] Presigned upload/download/blob URLs are fetched with `include_auth=False` +- [ ] Classes with `def list(...)` avoid later bare `list[...]` annotations +- [ ] Models added per [MODELS.md](MODELS.md), wired in `models/__init__.py` +- [ ] Resource wired into `client.py` (import + `self.widgets = Widgets(...)`) +- [ ] Unit tests in `tests/units/test_widget.py`, including invalid-id cases and at least one happy-path per method +- [ ] Example in `examples/widget.py` (or extension to existing file), with env-var auth, cleanup, and `_print_header` +- [ ] `make test` and `make lint` both pass diff --git a/examples/apply.py b/examples/apply.py index ea72dfa8..8c32dc07 100644 --- a/examples/apply.py +++ b/examples/apply.py @@ -22,6 +22,15 @@ def main(): ) parser.add_argument("--token", default=os.getenv("TFE_TOKEN", "")) parser.add_argument("--apply-id", required=True, help="Apply ID to work with") + parser.add_argument( + "--recover-errored-state", + action="store_true", + help="Fetch the failed-upload state via /applies/{id}/errored-state", + ) + parser.add_argument( + "--out", + help="When recovering errored state, write the bytes to this path", + ) args = parser.parse_args() cfg = TFEConfig(address=args.address, token=args.token) @@ -49,6 +58,27 @@ def main(): print(f"Error reading apply: {e}") return 1 + if args.recover_errored_state: + from pytfe.errors import NotFound + + _print_header("Recovering errored state (GET /applies/{id}/errored-state)") + try: + data = client.applies.errored_state(args.apply_id) + except NotFound: + print( + "No errored state available — apply did not fail during state " + "upload, or storage retention has elapsed." + ) + else: + print(f"Recovered {len(data)} bytes of errored state") + if args.out: + with open(args.out, "wb") as f: + f.write(data) + print(f"Wrote {args.out}") + else: + preview = data[:256].decode("utf-8", errors="replace") + print(f"--- preview ---\n{preview}\n--- end preview ---") + print("\n" + "=" * 80) print("Apply demo completed successfully!") print("=" * 80) diff --git a/examples/configuration_version.py b/examples/configuration_version.py index b894c116..5488a58b 100644 --- a/examples/configuration_version.py +++ b/examples/configuration_version.py @@ -859,6 +859,42 @@ def main(): print("Functions 10: Enterprise backing data operations") print("=" * 80) + # ===================================================== + # TEST 11: INGRESS ATTRIBUTES (VCS metadata) + # ===================================================== + print("\n11. Testing ingress_attributes() function:") + cv_for_ingress = uploadable_cv_id or created_cv_id + if cv_for_ingress: + try: + ingress = client.configuration_versions.ingress_attributes(cv_for_ingress) + if ingress is None: + print( + f"CV {cv_for_ingress} has no ingress attributes " + "(non-VCS-backed configuration version)." + ) + else: + print(f"Ingress attributes for {cv_for_ingress}:") + for field in ( + "branch", + "clone_url", + "commit_sha", + "commit_message", + "commit_url", + "identifier", + "is_pull_request", + "pull_request_number", + "pull_request_title", + "tag", + "sender_username", + ): + value = getattr(ingress, field, None) + if value is not None: + print(f" {field}: {value}") + except Exception as e: + print(f"Failed to read ingress attributes: {e}") + else: + print("Skipped — no CV was created in this run.") + # Close client client.close() diff --git a/examples/plan.py b/examples/plan.py index 8910bd07..a95e318c 100644 --- a/examples/plan.py +++ b/examples/plan.py @@ -22,13 +22,27 @@ def main(): "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") ) parser.add_argument("--token", default=os.getenv("TFE_TOKEN", "")) - parser.add_argument("--plan-id", required=True, help="Plan ID to work with") + parser.add_argument("--plan-id", required=False, help="Plan ID to work with") + parser.add_argument( + "--run-id", + help="Run ID — fetches the plan and JSON output via the run instead " + "of needing the plan id", + ) parser.add_argument("--save-json", help="Path to save JSON output") args = parser.parse_args() + if not args.plan_id and not args.run_id: + parser.error("provide --plan-id and/or --run-id") cfg = TFEConfig(address=args.address, token=args.token) client = TFEClient(cfg) + # If we were given only --run-id, resolve the plan via the run. + if not args.plan_id and args.run_id: + _print_header(f"Reading plan for run {args.run_id}") + plan_for_run = client.plans.read_for_run(args.run_id) + args.plan_id = plan_for_run.id + print(f"Resolved plan id: {args.plan_id}") + # 1) Read the plan details _print_header("Reading Plan Details") try: @@ -81,6 +95,33 @@ def main(): except Exception as e: print(f"Error reading JSON output: {e}") + # 3) Run-id-based endpoints + if args.run_id: + _print_header(f"Reading JSON output via run id ({args.run_id})") + try: + json_for_run = client.plans.read_json_output_for_run(args.run_id) + if json_for_run is None: + print("Plan has not yet completed (HTTP 204).") + else: + print( + f"JSON keys: {sorted(json_for_run.keys())[:8]} " + f"(total {len(json_for_run)})" + ) + except Exception as e: + print(f"Error: {e}") + + _print_header(f"Reading provider JSON schema via run id ({args.run_id})") + try: + schema = client.plans.read_json_schema_for_run(args.run_id) + if schema is None: + print("Plan has not yet completed (HTTP 204).") + elif isinstance(schema, dict): + print(f"Schema keys: {sorted(schema.keys())[:8]}") + else: + print(f"Schema type: {type(schema).__name__}") + except Exception as e: + print(f"Error: {e}") + print("\n" + "=" * 80) print("Plan demo completed successfully!") print("=" * 80) diff --git a/examples/policy_set.py b/examples/policy_set.py index 69ca88e2..9acdc30b 100644 --- a/examples/policy_set.py +++ b/examples/policy_set.py @@ -11,15 +11,20 @@ Policy, PolicyKind, PolicySetAddPoliciesOptions, + PolicySetAddProjectExclusionsOptions, PolicySetAddProjectsOptions, PolicySetAddWorkspacesOptions, PolicySetCreateOptions, + PolicySetIncludeOpt, PolicySetListOptions, + PolicySetReadOptions, PolicySetRemovePoliciesOptions, + PolicySetRemoveProjectExclusionsOptions, PolicySetRemoveProjectsOptions, PolicySetRemoveWorkspacesOptions, PolicySetUpdateOptions, Project, + ProjectCreateOptions, Workspace, ) @@ -133,6 +138,12 @@ def main(): parser.add_argument("--search", help="Search policy sets by name") parser.add_argument("--page", type=int, default=1) parser.add_argument("--page-size", type=int, default=20) + parser.add_argument( + "--demo-project-exclusions", + action="store_true", + help="End-to-end demo: create a scratch global policy set + project, " + "add the project to exclusions, then remove it and clean up.", + ) args = parser.parse_args() if not args.token: @@ -443,6 +454,79 @@ def main(): except Exception as e: print(f"Error deleting policy set: {e}") + # 12) Demo: project-exclusions lifecycle (creates scratch resources) + if args.demo_project_exclusions: + import time + + _print_header("Project-exclusions lifecycle demo (scratch resources)") + stamp = int(time.time()) + created_ps_id = None + created_proj_id = None + try: + ps = client.policy_sets.create( + args.org, + PolicySetCreateOptions(name=f"pytfe-pe-{stamp}", Global=True), + ) + created_ps_id = ps.id + print(f"created policy set: {ps.id} ({ps.name}, global=True)") + + proj = client.projects.create( + args.org, + ProjectCreateOptions(name=f"pytfe-pe-proj-{stamp}"), + ) + created_proj_id = proj.id + print(f"created project: {proj.id} ({proj.name})") + + print(f"\nadding project {proj.id} to exclusions of {ps.id}") + client.policy_sets.add_project_exclusions( + ps.id, + PolicySetAddProjectExclusionsOptions( + project_exclusions=[Project(id=proj.id)] + ), + ) + print("added") + + ps_after = client.policy_sets.read_with_options( + ps.id, + PolicySetReadOptions( + include=[PolicySetIncludeOpt.POLICY_SET_PROJECT_EXCLUSIONS] + ), + ) + excluded_ids = [p.id for p in (ps_after.project_exclusions or [])] + print(f"current excluded projects: {excluded_ids}") + + print(f"\nremoving project {proj.id} from exclusions") + client.policy_sets.remove_project_exclusions( + ps.id, + PolicySetRemoveProjectExclusionsOptions( + project_exclusions=[Project(id=proj.id)] + ), + ) + print("removed") + ps_final = client.policy_sets.read_with_options( + ps.id, + PolicySetReadOptions( + include=[PolicySetIncludeOpt.POLICY_SET_PROJECT_EXCLUSIONS] + ), + ) + print( + "final excluded projects: " + f"{[p.id for p in (ps_final.project_exclusions or [])]}" + ) + finally: + if created_proj_id: + try: + client.projects.delete(created_proj_id) + print(f"cleaned up project {created_proj_id}") + except Exception as e: + print(f"WARN: could not clean up project: {e}") + if created_ps_id: + try: + client.policy_sets.delete(created_ps_id) + print(f"cleaned up policy set {created_ps_id}") + except Exception as e: + print(f"WARN: could not clean up policy set: {e}") + if __name__ == "__main__": main() diff --git a/examples/project.py b/examples/project.py index f6f500bf..83db6119 100644 --- a/examples/project.py +++ b/examples/project.py @@ -15,6 +15,7 @@ ProjectSettingOverwrites, ProjectUpdateOptions, TagBinding, + WorkspaceCreateOptions, ) @@ -125,6 +126,19 @@ def main() -> None: action="store_true", help="Append a short random suffix to --name for create", ) + parser.add_argument( + "--move-workspace-id", + action="append", + default=[], + help="Workspace id to move into --project-id (repeatable). Requires " + "--project-id.", + ) + parser.add_argument( + "--demo-move", + action="store_true", + help="End-to-end demo: create scratch projects + workspace, move the " + "workspace between projects, clean up.", + ) args = parser.parse_args() @@ -278,7 +292,7 @@ def main() -> None: # 7) List effective tag bindings if args.list_effective_tag_bindings: _print_header(f"Listing effective tag bindings for project: {args.project_id}") - bindings = client.projects.list_effective_tag_bindings(args.project_id) + bindings = list(client.projects.list_effective_tag_bindings(args.project_id)) if not bindings: print("No effective tag bindings found.") @@ -308,6 +322,66 @@ def main() -> None: client.projects.delete_tag_bindings(args.project_id) print("Deleted all project tag bindings") + # 10) Move workspaces into the given project (additive, not destructive) + if args.move_workspace_id: + if not args.project_id: + raise SystemExit("--project-id is required for --move-workspace-id") + _print_header( + f"Moving {len(args.move_workspace_id)} workspace(s) into " + f"project {args.project_id}" + ) + client.projects.move_workspaces(args.project_id, args.move_workspace_id) + print("done") + + # 11) End-to-end demo: create scratch resources, move, cleanup + if args.demo_move: + import time + + _print_header("project.move_workspaces end-to-end demo (scratch resources)") + stamp = int(time.time()) + created: dict[str, str] = {} + try: + src = client.projects.create( + args.organization, + ProjectCreateOptions(name=f"pytfe-move-src-{stamp}"), + ) + created["src_project"] = src.id + print(f"created source project: {src.id} ({src.name})") + dst = client.projects.create( + args.organization, + ProjectCreateOptions(name=f"pytfe-move-dst-{stamp}"), + ) + created["dst_project"] = dst.id + print(f"created target project: {dst.id} ({dst.name})") + ws = client.workspaces.create( + args.organization, + WorkspaceCreateOptions( + name=f"pytfe-move-ws-{stamp}", project={"id": src.id} + ), + ) + created["workspace"] = ws.id + print(f"created workspace: {ws.id} in {src.id}") + client.projects.move_workspaces(dst.id, [ws.id]) + ws2 = client.workspaces.read_by_id(ws.id) + moved = ws2.project.id if ws2.project else "?" + print(f"workspace now belongs to: {moved}") + assert moved == dst.id + print("OK") + finally: + if "workspace" in created: + try: + client.workspaces.delete_by_id(created["workspace"]) + print(f"cleaned up workspace {created['workspace']}") + except Exception as e: + print(f"WARN: workspace cleanup failed: {e}") + for key in ("dst_project", "src_project"): + if key in created: + try: + client.projects.delete(created[key]) + print(f"cleaned up project {created[key]}") + except Exception as e: + print(f"WARN: project {key} cleanup failed: {e}") + if __name__ == "__main__": main() diff --git a/examples/registry_module.py b/examples/registry_module.py index bf8e7ab6..8a7bc088 100644 --- a/examples/registry_module.py +++ b/examples/registry_module.py @@ -304,8 +304,7 @@ def main(): registry_name=RegistryName.PRIVATE, ) - versions = client.registry_modules.list_versions(module_id) - versions_list = list(versions) if hasattr(versions, "__iter__") else [] + versions_list = list(client.registry_modules.list_versions(module_id)) print(f"Found {len(versions_list)} versions") for i, version in enumerate(versions_list[:3], 1): diff --git a/examples/state_versions.py b/examples/state_versions.py index e9a98d3d..dcafcd00 100644 --- a/examples/state_versions.py +++ b/examples/state_versions.py @@ -40,6 +40,16 @@ def main(): 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("--page-size", type=int, default=10) + parser.add_argument( + "--rollback-to", + help="State version id to roll the workspace back to. The workspace " + "will be locked, rolled back, then unlocked.", + ) + parser.add_argument( + "--rollback-dry-run", + action="store_true", + help="With --rollback-to, print the plan without performing the rollback.", + ) args = parser.parse_args() cfg = TFEConfig(address=args.address, token=args.token) @@ -161,6 +171,31 @@ def main(): # Some older/self-hosted versions don’t support direct upload print(f"Upload not supported on this server: {e}") + # 6) (Optional) Roll back to a previous state version + if args.rollback_to: + _print_header( + f"Rolling {args.workspace_id} back to state version {args.rollback_to}" + ) + if args.rollback_dry_run: + print("--rollback-dry-run set; not locking or rolling back") + else: + print("locking workspace ...") + client.workspaces.lock( + args.workspace_id, + WorkspaceLockOptions(reason="python-tfe rollback demo"), + ) + try: + new_sv = client.state_versions.rollback( + args.workspace_id, args.rollback_to + ) + print( + f"rollback succeeded — new state version: {new_sv.id} " + f"(serial={new_sv.serial})" + ) + finally: + print("unlocking workspace ...") + client.workspaces.unlock(args.workspace_id) + if __name__ == "__main__": main() diff --git a/examples/team.py b/examples/team.py index 5615a8b8..2be9df2f 100644 --- a/examples/team.py +++ b/examples/team.py @@ -101,6 +101,35 @@ def main(): default=None, help="Team ID for read/update/delete operation", ) + parser.add_argument( + "--add-user", + action="append", + default=[], + help="HCP Terraform username to add to --team-id (repeatable)", + ) + parser.add_argument( + "--remove-user", + action="append", + default=[], + help="HCP Terraform username to remove from --team-id (repeatable)", + ) + parser.add_argument( + "--add-ou", + action="append", + default=[], + help="Organization membership id (ou-…) to add to --team-id (repeatable)", + ) + parser.add_argument( + "--remove-ou", + action="append", + default=[], + help="Organization membership id to remove from --team-id (repeatable)", + ) + parser.add_argument( + "--list-members", + action="store_true", + help="List the team's current users and organization memberships", + ) args = parser.parse_args() cfg = TFEConfig(address=args.address, token=args.token) @@ -179,6 +208,43 @@ def main(): ) print() + # Team membership management (runs before the list output below) + membership_requested = ( + args.add_user + or args.remove_user + or args.add_ou + or args.remove_ou + or args.list_members + ) + if membership_requested: + if not args.team_id: + print("Error: --team-id is required for membership operations") + return + if args.add_user: + _print_header(f"Adding users to {args.team_id}: {args.add_user}") + client.teams.add_users(args.team_id, args.add_user) + if args.remove_user: + _print_header(f"Removing users from {args.team_id}: {args.remove_user}") + client.teams.remove_users(args.team_id, args.remove_user) + if args.add_ou: + _print_header(f"Adding org memberships to {args.team_id}: {args.add_ou}") + client.teams.add_organization_memberships(args.team_id, args.add_ou) + if args.remove_ou: + _print_header( + f"Removing org memberships from {args.team_id}: {args.remove_ou}" + ) + client.teams.remove_organization_memberships(args.team_id, args.remove_ou) + if args.list_members: + _print_header(f"Listing members of team {args.team_id}") + users = list(client.teams.list_users(args.team_id)) + print(f"users ({len(users)}):") + for u in users: + print(f" - {u.id} {getattr(u, 'username', '')}") + ous = list(client.teams.list_organization_memberships(args.team_id)) + print(f"organization memberships ({len(ous)}):") + for m in ous: + print(f" - {m.id} {getattr(m, 'email', '')}") + if args.delete: if not args.team_id: print("Error: --team-id is required when using --delete") diff --git a/examples/team_workspace_access.py b/examples/team_workspace_access.py new file mode 100644 index 00000000..81b0e7c9 --- /dev/null +++ b/examples/team_workspace_access.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Team-workspace access example. + +Demonstrates the new ``client.team_workspace_accesses`` resource (the +go-tfe equivalent is ``TeamAccesses`` — ``/api/v2/team-workspaces``):: + + client.team_workspace_accesses.add(options) + client.team_workspace_accesses.list(workspace_id) + client.team_workspace_accesses.read(team_workspace_access_id) + client.team_workspace_accesses.update(id, options) + client.team_workspace_accesses.remove(id) + +By default the script creates a scratch team and a scratch workspace, +grants the team read access on the workspace, escalates the grant to +``custom`` (and tweaks the per-resource permissions), then removes the +grant and tears down the scratch resources. + +Usage:: + + TFE_TOKEN=... TFE_ORG=prab-sandbox02 \\ + python examples/team_workspace_access.py +""" + +from __future__ import annotations + +import argparse +import os +import time + +from pytfe import TFEClient, TFEConfig +from pytfe.models import ( + TeamCreateOptions, + TeamWorkspaceAccessAddOptions, + TeamWorkspaceAccessType, + TeamWorkspaceAccessUpdateOptions, + TeamWorkspaceRunsPermission, + TeamWorkspaceStateVersionsPermission, + TeamWorkspaceVariablesPermission, + WorkspaceCreateOptions, +) + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument( + "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") + ) + p.add_argument("--token", default=os.getenv("TFE_TOKEN", "")) + p.add_argument("--organization", default=os.getenv("TFE_ORG", "")) + p.add_argument("--team-id") + p.add_argument("--workspace-id") + args = p.parse_args() + if not args.token or not args.organization: + print("set TFE_TOKEN and TFE_ORG") + return 2 + + client = TFEClient(TFEConfig(address=args.address, token=args.token)) + + created: dict[str, str] = {} + grant_id: str | None = None + try: + team_id = args.team_id + workspace_id = args.workspace_id + + if not team_id: + stamp = int(time.time()) + t = client.teams.create( + args.organization, + TeamCreateOptions(name=f"pytfe-twa-{stamp}", visibility="secret"), + ) + created["team"] = t.id + team_id = t.id + print(f"created team: {t.id} ({t.name})") + + if not workspace_id: + stamp = int(time.time()) + ws = client.workspaces.create( + args.organization, + WorkspaceCreateOptions(name=f"pytfe-twa-ws-{stamp}"), + ) + created["workspace"] = ws.id + workspace_id = ws.id + print(f"created workspace: {ws.id} ({ws.name})") + + print(f"\nlisting existing grants on workspace {workspace_id} ...") + existing = list(client.team_workspace_accesses.list(workspace_id)) + print(f" {len(existing)} existing grant(s)") + for g in existing: + print(f" - {g.id} team-access={g.access}") + + print(f"\ngranting team {team_id} READ access on workspace {workspace_id}") + grant = client.team_workspace_accesses.add( + TeamWorkspaceAccessAddOptions( + team_id=team_id, + workspace_id=workspace_id, + access=TeamWorkspaceAccessType.READ, + ) + ) + grant_id = grant.id + print(f" created grant {grant.id} access={grant.access}") + + print("\nreading grant back") + readback = client.team_workspace_accesses.read(grant.id) + print(f" access={readback.access}") + + print("\nupgrading grant to CUSTOM (apply runs + write vars + write state)") + updated = client.team_workspace_accesses.update( + grant.id, + TeamWorkspaceAccessUpdateOptions( + access=TeamWorkspaceAccessType.CUSTOM, + runs=TeamWorkspaceRunsPermission.APPLY, + variables=TeamWorkspaceVariablesPermission.WRITE, + state_versions=TeamWorkspaceStateVersionsPermission.WRITE, + workspace_locking=True, + ), + ) + print( + f" access={updated.access} runs={updated.runs} " + f"variables={updated.variables} state_versions={updated.state_versions} " + f"workspace_locking={updated.workspace_locking}" + ) + + return 0 + finally: + if grant_id: + try: + client.team_workspace_accesses.remove(grant_id) + print(f"cleaned up grant {grant_id}") + except Exception as e: + print(f"WARN: could not remove grant: {e}") + if "workspace" in created: + try: + client.workspaces.delete_by_id(created["workspace"]) + print(f"cleaned up workspace {created['workspace']}") + except Exception as e: + print(f"WARN: could not clean up workspace: {e}") + if "team" in created: + try: + client.teams.delete(created["team"]) + print(f"cleaned up team {created['team']}") + except Exception as e: + print(f"WARN: could not clean up team: {e}") + client.close() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/workspace.py b/examples/workspace.py index 54f68715..b12e7b2a 100644 --- a/examples/workspace.py +++ b/examples/workspace.py @@ -131,6 +131,12 @@ def main(): ) parser.add_argument("--wildcard-name", help="Filter by wildcard name matching") parser.add_argument("--project-id", help="Filter by project ID") + parser.add_argument( + "--show-assessment", + action="store_true", + help="Show the workspace's current health-assessment result and the " + "variable sets applicable to it", + ) args = parser.parse_args() cfg = TFEConfig(address=args.address, token=args.token) @@ -497,7 +503,51 @@ def main(): print(f"readme result: {e}") print("(Expected if workspace has no README)") - # 16) Delete workspace if requested (should be last operation) + # 16) Show health assessment + applicable variable sets (read-only) + if args.show_assessment: + if not args.workspace_id: + print( + "--show-assessment requires --workspace-id (uses workspace-id " + "based endpoints)" + ) + else: + _print_header(f"Current assessment result for {args.workspace_id}") + result = client.workspaces.current_assessment_result(args.workspace_id) + if result is None: + print( + "no assessment result yet — assessments may be disabled, " + "or none have run." + ) + else: + for field in ( + "id", + "succeeded", + "all_checks_succeeded", + "drifted", + "resources_drifted", + "resources_undrifted", + "checks_passed", + "checks_failed", + "checks_errored", + "created_at", + "error_message", + ): + value = getattr(result, field, None) + if value is not None: + print(f" {field}: {value}") + + _print_header(f"Applicable variable sets for {args.workspace_id}") + count = 0 + for vs in client.workspaces.list_applicable_varsets(args.workspace_id): + count += 1 + print( + f" - {vs.get('id'):<24} {vs.get('name'):<30} " + f"global={vs.get('global')} vars={vs.get('var-count')}" + ) + if count == 0: + print(" (none)") + + # 17) Delete workspace if requested (should be last operation) if args.delete and args.workspace: _print_header(f"Deleting workspace: {args.workspace}") diff --git a/src/pytfe/_http.py b/src/pytfe/_http.py index 22b29b3f..ad1e6fd2 100644 --- a/src/pytfe/_http.py +++ b/src/pytfe/_http.py @@ -110,7 +110,11 @@ def request( self._sleep(attempt, retry_after) attempt += 1 continue - # print(resp) + # When the caller explicitly opted out of redirect-following, + # surface 3xx responses to them (so they can read Location) + # rather than treating them as errors. + if not allow_redirects and 300 <= resp.status_code < 400: + return resp self._raise_if_error(resp) return resp diff --git a/src/pytfe/client.py b/src/pytfe/client.py index f011ee0f..2655c1fc 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -49,6 +49,7 @@ from .resources.team import Teams from .resources.team_project_access import TeamProjectAccesses from .resources.team_token import TeamTokens +from .resources.team_workspace_access import TeamWorkspaceAccesses from .resources.user import Users from .resources.variable import Variables from .resources.variable_sets import VariableSets, VariableSetVariables @@ -141,6 +142,7 @@ def __init__(self, config: TFEConfig | None = None): self.teams = Teams(self._transport) self.team_project_accesses = TeamProjectAccesses(self._transport) self.team_tokens = TeamTokens(self._transport) + self.team_workspace_accesses = TeamWorkspaceAccesses(self._transport) # Reserved Tag Key self.reserved_tag_key = ReservedTagKeys(self._transport) diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index 730e2068..a75e978c 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -21,6 +21,7 @@ AgentTokenCreateOptions, AgentTokenListOptions, ) +from .assessment_result import AssessmentResult from .comment import ( Comment, CommentCreateOptions, @@ -164,6 +165,7 @@ from .policy_set import ( PolicySet, PolicySetAddPoliciesOptions, + PolicySetAddProjectExclusionsOptions, PolicySetAddProjectsOptions, PolicySetAddWorkspaceExclusionsOptions, PolicySetAddWorkspacesOptions, @@ -173,6 +175,7 @@ PolicySetListOptions, PolicySetReadOptions, PolicySetRemovePoliciesOptions, + PolicySetRemoveProjectExclusionsOptions, PolicySetRemoveProjectsOptions, PolicySetRemoveWorkspaceExclusionsOptions, PolicySetRemoveWorkspacesOptions, @@ -398,6 +401,16 @@ TeamTokenCreateOptions, TeamTokenListOptions, ) +from .team_workspace_access import ( + TeamWorkspaceAccess, + TeamWorkspaceAccessAddOptions, + TeamWorkspaceAccessType, + TeamWorkspaceAccessUpdateOptions, + TeamWorkspaceRunsPermission, + TeamWorkspaceSentinelMocksPermission, + TeamWorkspaceStateVersionsPermission, + TeamWorkspaceVariablesPermission, +) # Variables from .variable import ( @@ -646,6 +659,17 @@ "TeamToken", "TeamTokenCreateOptions", "TeamTokenListOptions", + # Team Workspace Access + "TeamWorkspaceAccess", + "TeamWorkspaceAccessAddOptions", + "TeamWorkspaceAccessType", + "TeamWorkspaceAccessUpdateOptions", + "TeamWorkspaceRunsPermission", + "TeamWorkspaceSentinelMocksPermission", + "TeamWorkspaceStateVersionsPermission", + "TeamWorkspaceVariablesPermission", + # Assessment Result + "AssessmentResult", "Project", "ProjectAddTagBindingsOptions", "ProjectCreateOptions", @@ -801,6 +825,7 @@ "PolicySetAddProjectsOptions", "PolicySetAddWorkspacesOptions", "PolicySetAddWorkspaceExclusionsOptions", + "PolicySetAddProjectExclusionsOptions", "PolicySetCreateOptions", "PolicySetListOptions", "PolicySetReadOptions", @@ -808,6 +833,7 @@ "PolicySetRemoveWorkspacesOptions", "PolicySetRemoveWorkspaceExclusionsOptions", "PolicySetRemoveProjectsOptions", + "PolicySetRemoveProjectExclusionsOptions", "PolicySetUpdateOptions", # Policy Set Parameters "PolicySetParameter", diff --git a/src/pytfe/models/assessment_result.py b/src/pytfe/models/assessment_result.py new file mode 100644 index 00000000..0303bbf4 --- /dev/null +++ b/src/pytfe/models/assessment_result.py @@ -0,0 +1,29 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class AssessmentResult(BaseModel): + """Result of a workspace health assessment (drift detection).""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str + succeeded: bool | None = Field(default=None, alias="succeeded") + all_checks_succeeded: bool | None = Field( + default=None, alias="all-checks-succeeded" + ) + checks_errored: int | None = Field(default=None, alias="checks-errored") + checks_failed: int | None = Field(default=None, alias="checks-failed") + checks_passed: int | None = Field(default=None, alias="checks-passed") + checks_unknown: int | None = Field(default=None, alias="checks-unknown") + created_at: datetime | None = Field(default=None, alias="created-at") + drifted: bool | None = Field(default=None, alias="drifted") + error_message: str | None = Field(default=None, alias="error-message") + resources_drifted: int | None = Field(default=None, alias="resources-drifted") + resources_undrifted: int | None = Field(default=None, alias="resources-undrifted") diff --git a/src/pytfe/models/policy_set.py b/src/pytfe/models/policy_set.py index 6bd7ca0c..e35257d8 100644 --- a/src/pytfe/models/policy_set.py +++ b/src/pytfe/models/policy_set.py @@ -23,6 +23,7 @@ class PolicySetIncludeOpt(str, Enum): POLICY_SET_NEWEST_VERSION = "newest_version" POLICY_SET_CURRENT_VERSION = "current_version" POLICY_SET_WORKSPACE_EXCLUSIONS = "workspace_exclusions" + POLICY_SET_PROJECT_EXCLUSIONS = "project_exclusions" class PolicySet(BaseModel): @@ -56,6 +57,9 @@ class PolicySet(BaseModel): workspace_exclusions: list[Workspace] = Field( default_factory=list, alias="workspace-exclusions" ) + project_exclusions: list[Project] = Field( + default_factory=list, alias="project-exclusions" + ) class PolicySetList(BaseModel): @@ -165,3 +169,15 @@ class PolicySetRemoveProjectsOptions(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) projects: list[Project] = Field(default_factory=list) + + +class PolicySetAddProjectExclusionsOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + project_exclusions: list[Project] = Field(default_factory=list) + + +class PolicySetRemoveProjectExclusionsOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + project_exclusions: list[Project] = Field(default_factory=list) diff --git a/src/pytfe/models/run_task_integration.py b/src/pytfe/models/run_task_integration.py index ee3b4c00..6eecf5bc 100644 --- a/src/pytfe/models/run_task_integration.py +++ b/src/pytfe/models/run_task_integration.py @@ -12,10 +12,7 @@ class TaskResultStatus(str, Enum): - """Statuses accepted by the Run Task callback endpoint. - - Mirrors the Go SDK's accepted callback statuses (passed, failed, running). - """ + """Statuses accepted by the Run Task callback endpoint.""" passed = "passed" failed = "failed" diff --git a/src/pytfe/models/team_workspace_access.py b/src/pytfe/models/team_workspace_access.py new file mode 100644 index 00000000..8e1593c0 --- /dev/null +++ b/src/pytfe/models/team_workspace_access.py @@ -0,0 +1,96 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field + + +class TeamWorkspaceAccessType(str, Enum): + READ = "read" + PLAN = "plan" + WRITE = "write" + ADMIN = "admin" + CUSTOM = "custom" + + +class TeamWorkspaceRunsPermission(str, Enum): + READ = "read" + PLAN = "plan" + APPLY = "apply" + + +class TeamWorkspaceVariablesPermission(str, Enum): + NONE = "none" + READ = "read" + WRITE = "write" + + +class TeamWorkspaceStateVersionsPermission(str, Enum): + NONE = "none" + READ_OUTPUTS = "read-outputs" + READ = "read" + WRITE = "write" + + +class TeamWorkspaceSentinelMocksPermission(str, Enum): + NONE = "none" + READ = "read" + + +class TeamWorkspaceAccess(BaseModel): + """A team's access grant on a workspace (`/api/v2/team-workspaces/{id}`).""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str + access: TeamWorkspaceAccessType | None = None + runs: TeamWorkspaceRunsPermission | None = None + variables: TeamWorkspaceVariablesPermission | None = None + state_versions: TeamWorkspaceStateVersionsPermission | None = Field( + default=None, alias="state-versions" + ) + sentinel_mocks: TeamWorkspaceSentinelMocksPermission | None = Field( + default=None, alias="sentinel-mocks" + ) + workspace_locking: bool | None = Field(default=None, alias="workspace-locking") + run_tasks: bool | None = Field(default=None, alias="run-tasks") + policy_overrides: bool | None = Field(default=None, alias="policy-overrides") + + # Relationships (populated from the JSON:API ``relationships`` block). + team_id: str | None = Field(default=None, alias="team-id") + workspace_id: str | None = Field(default=None, alias="workspace-id") + + +class TeamWorkspaceAccessAddOptions(BaseModel): + """Options for adding a team access grant on a workspace.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + team_id: str + workspace_id: str + access: TeamWorkspaceAccessType + runs: TeamWorkspaceRunsPermission | None = None + variables: TeamWorkspaceVariablesPermission | None = None + state_versions: TeamWorkspaceStateVersionsPermission | None = None + sentinel_mocks: TeamWorkspaceSentinelMocksPermission | None = None + workspace_locking: bool | None = None + run_tasks: bool | None = None + policy_overrides: bool | None = None + + +class TeamWorkspaceAccessUpdateOptions(BaseModel): + """Options for updating an existing team access grant.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + access: TeamWorkspaceAccessType | None = None + runs: TeamWorkspaceRunsPermission | None = None + variables: TeamWorkspaceVariablesPermission | None = None + state_versions: TeamWorkspaceStateVersionsPermission | None = None + sentinel_mocks: TeamWorkspaceSentinelMocksPermission | None = None + workspace_locking: bool | None = None + run_tasks: bool | None = None + policy_overrides: bool | None = None diff --git a/src/pytfe/resources/apply.py b/src/pytfe/resources/apply.py index 621a8181..42747d1b 100644 --- a/src/pytfe/resources/apply.py +++ b/src/pytfe/resources/apply.py @@ -53,3 +53,35 @@ def _done(self, apply_id: str) -> tuple[bool, Exception | None]: return is_complete, None except Exception as e: return False, e + + def errored_state(self, apply_id: str) -> bytes: + """Recover the raw state bytes from an apply that failed during state upload. + + The TFE endpoint returns a 307 redirect to a signed object-storage URL. + We follow it manually so the API bearer token is not forwarded to the + third-party blob host. + + Raises NotFound if the apply has no recoverable errored state. + """ + if not valid_string_id(apply_id): + raise InvalidApplyIDError() + + # Do not auto-follow: the redirect target is presigned and must not + # receive our Authorization header. + resp = self.t.request( + "GET", + f"/api/v2/applies/{apply_id}/errored-state", + allow_redirects=False, + ) + if resp.status_code in (301, 302, 303, 307, 308): + location = resp.headers.get("Location") or resp.headers.get("location") + if not location: + from ..errors import TFEError + + raise TFEError( + "errored-state redirect did not include a Location header" + ) + blob = self.t.request("GET", location) + return blob.content + # 2xx body case (some servers may return inline); honour it + return resp.content diff --git a/src/pytfe/resources/configuration_version.py b/src/pytfe/resources/configuration_version.py index 6a7d0477..c64e693b 100644 --- a/src/pytfe/resources/configuration_version.py +++ b/src/pytfe/resources/configuration_version.py @@ -20,6 +20,7 @@ ConfigurationVersionCreateOptions, ConfigurationVersionListOptions, ConfigurationVersionReadOptions, + IngressAttributes, ) from ..utils import pack_contents, valid_string_id from ._base import _Service @@ -198,6 +199,39 @@ def download(self, cv_id: str) -> bytes: response = self.t.request("GET", path) return response.content + def ingress_attributes(self, cv_id: str) -> IngressAttributes | None: + """Get the VCS ingress attributes for a configuration version. + + Returns ``None`` if the configuration version was not created from a + VCS connection (so has no ingress data). The API responds with + ``null`` for API-driven CVs and with 404 for some older TFE + instances. + """ + if not valid_string_id(cv_id): + raise ValueError(ERR_INVALID_CONFIG_VERSION_ID) + try: + response = self.t.request( + "GET", + f"/api/v2/configuration-versions/{cv_id}/ingress-attributes", + ) + except NotFound: + return None + body = response.json() + if body is None: + return None + if not isinstance(body, dict): + return None + # The OpenAPI spec describes the response as the bare + # `ingress-attributes` resource, but the live API wraps it in the + # standard JSON:API envelope. Accept both shapes. + data = body.get("data", body) + if not isinstance(data, dict) or not data: + return None + attributes = data.get("attributes") + if not isinstance(attributes, dict): + return None + return IngressAttributes.model_validate(attributes) + def soft_delete_backing_data(self, cv_id: str) -> None: """Soft delete backing data for a configuration version (Enterprise only).""" self._manage_backing_data(cv_id, "soft_delete_backing_data") diff --git a/src/pytfe/resources/plan.py b/src/pytfe/resources/plan.py index 7f7d39a8..6e3f982c 100644 --- a/src/pytfe/resources/plan.py +++ b/src/pytfe/resources/plan.py @@ -5,7 +5,7 @@ from typing import Any -from ..errors import InvalidPlanIDError +from ..errors import InvalidPlanIDError, InvalidRunIDError from ..models.plan import ( Plan, PlanStatus, @@ -14,6 +14,15 @@ from ._base import _Service +def _plan_from_jsonapi(d: dict[str, Any]) -> Plan: + attr = d.get("attributes", {}) or {} + plan_id = str(d.get("id") or "") + return Plan( + id=plan_id, + **{k.replace("-", "_"): v for k, v in attr.items()}, + ) + + class Plans(_Service): def read(self, plan_id: str) -> Plan: """Read a specific plan by its ID.""" @@ -24,12 +33,14 @@ def read(self, plan_id: str) -> Plan: "GET", f"/api/v2/plans/{plan_id}", ) - d = r.json()["data"] - attr = d.get("attributes", {}) or {} - return Plan( - id=d.get("id"), - **{k.replace("-", "_"): v for k, v in attr.items()}, - ) + return _plan_from_jsonapi(r.json()["data"]) + + def read_for_run(self, run_id: str) -> Plan: + """Read the plan belonging to a run, via the run id.""" + if not valid_string_id(run_id): + raise InvalidRunIDError() + r = self.t.request("GET", f"/api/v2/runs/{run_id}/plan") + return _plan_from_jsonapi(r.json()["data"]) def logs(self, plan_id: str) -> str: """Get logs for a specific plan. @@ -54,29 +65,70 @@ def logs(self, plan_id: str) -> str: # Placeholder implementation - in future this would stream logs return "" - def read_json_output(self, plan_id: str) -> dict[str, Any]: + def _follow_json_output_redirect(self, path: str) -> dict[str, Any] | None: + """Fetch a json-output endpoint that returns 307 → presigned blob URL. + + The redirect target is a presigned object-storage URL; the API bearer + token must not be forwarded to it. + + Returns ``None`` if the API responds with 204 ("plan JSON supported, + but plan has not yet completed"). Callers should check the plan's + ``status`` before retrying. + """ + resp = self.t.request("GET", path, allow_redirects=False) + if resp.status_code == 204: + return None + if resp.status_code in (301, 302, 303, 307, 308): + location = resp.headers.get("Location") or resp.headers.get("location") + if not location: + from ..errors import TFEError + + raise TFEError("json-output redirect did not include a Location header") + blob = self.t.request("GET", location) + data = blob.json() + else: + # Defensive: 2xx body case (some servers may return inline) + try: + data = resp.json() + except Exception: + return None + if data is None: + return None + if isinstance(data, dict): + return data + return {"data": data} + + def read_json_output(self, plan_id: str) -> dict[str, Any] | None: """Get the JSON execution plan for a specific plan by its ID. Returns the JSON representation of the Terraform execution plan, - which includes detailed information about planned changes. + or ``None`` if the plan has not yet completed (HTTP 204). """ if not valid_string_id(plan_id): raise InvalidPlanIDError() + return self._follow_json_output_redirect(f"/api/v2/plans/{plan_id}/json-output") - r = self.t.request( - "GET", - f"/api/v2/plans/{plan_id}/json-output", + def read_json_output_for_run(self, run_id: str) -> dict[str, Any] | None: + """Get the JSON execution plan for a run, via the run id. + + Returns ``None`` if the plan has not yet completed (HTTP 204). + """ + if not valid_string_id(run_id): + raise InvalidRunIDError() + return self._follow_json_output_redirect( + f"/api/v2/runs/{run_id}/plan/json-output" ) - # Return the raw JSON data - this endpoint returns JSON directly - # not wrapped in a JSON:API format - json_data = r.json() - # Ensure we return a dictionary, not Any - if isinstance(json_data, dict): - return json_data - else: - # If somehow the response isn't a dict, wrap it - return {"data": json_data} + def read_json_schema_for_run(self, run_id: str) -> dict[str, Any] | None: + """Get the provider JSON schema corresponding to a plan, via the run id. + + Returns ``None`` if the plan has not yet completed (HTTP 204). + """ + if not valid_string_id(run_id): + raise InvalidRunIDError() + return self._follow_json_output_redirect( + f"/api/v2/runs/{run_id}/plan/json-schema" + ) def _done(self, plan_id: str) -> bool: """Create a done function for plan log reading.""" diff --git a/src/pytfe/resources/policy_set.py b/src/pytfe/resources/policy_set.py index 64d2ea0d..222cc7b9 100644 --- a/src/pytfe/resources/policy_set.py +++ b/src/pytfe/resources/policy_set.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Iterator +from typing import Any from ..errors import ( InvalidNameError, @@ -18,6 +19,7 @@ from ..models.policy_set import ( PolicySet, PolicySetAddPoliciesOptions, + PolicySetAddProjectExclusionsOptions, PolicySetAddProjectsOptions, PolicySetAddWorkspaceExclusionsOptions, PolicySetAddWorkspacesOptions, @@ -25,6 +27,7 @@ PolicySetListOptions, PolicySetReadOptions, PolicySetRemovePoliciesOptions, + PolicySetRemoveProjectExclusionsOptions, PolicySetRemoveProjectsOptions, PolicySetRemoveWorkspaceExclusionsOptions, PolicySetRemoveWorkspacesOptions, @@ -48,8 +51,16 @@ def list( raise InvalidOrgError() # Build params from options but do not pass page[number] — let _list handle pagination. - params = options.model_dump(by_alias=True, exclude_none=True) if options else {} + # mode="json" ensures enums (e.g. PolicySetIncludeOpt) serialize to + # their string values rather than `'PolicySetIncludeOpt.FOO'` reprs. + params = ( + options.model_dump(by_alias=True, exclude_none=True, mode="json") + if options + else {} + ) params.pop("page[number]", None) + if isinstance(params.get("include"), list): + params["include"] = ",".join(params["include"]) path = f"/api/v2/organizations/{organization}/policy-sets" @@ -65,6 +76,11 @@ def _gen() -> Iterator[PolicySet]: .get("workspace-exclusions", {}) .get("data", []) ) + attrs["project_exclusions"] = ( + d.get("relationships", {}) + .get("project-exclusions", {}) + .get("data", []) + ) attrs["workspaces"] = ( d.get("relationships", {}).get("workspaces", {}).get("data", []) ) @@ -143,6 +159,9 @@ def create(self, organization: str, options: PolicySetCreateOptions) -> PolicySe attrs["workspace_exclusions"] = relationships_data.get( "workspace-exclusions", {} ).get("data", []) + attrs["project_exclusions"] = relationships_data.get( + "project-exclusions", {} + ).get("data", []) attrs["workspaces"] = relationships_data.get("workspaces", {}).get("data", []) attrs["projects"] = relationships_data.get("projects", {}).get("data", []) attrs["policies"] = relationships_data.get("policies", {}).get("data", []) @@ -160,9 +179,11 @@ def read_with_options( if not valid_string_id(policy_set_id): raise InvalidPolicySetIDError() - params = ( - options.model_dump(by_alias=True, exclude_none=True) if options else None - ) + params: dict[str, Any] | None = None + if options is not None: + params = options.model_dump(by_alias=True, exclude_none=True, mode="json") + if isinstance(params.get("include"), list): + params["include"] = ",".join(params["include"]) r = self.t.request( "GET", @@ -180,6 +201,9 @@ def read_with_options( attrs["workspace_exclusions"] = relationships_data.get( "workspace-exclusions", {} ).get("data", []) + attrs["project_exclusions"] = relationships_data.get( + "project-exclusions", {} + ).get("data", []) attrs["workspaces"] = relationships_data.get("workspaces", {}).get("data", []) attrs["projects"] = relationships_data.get("projects", {}).get("data", []) attrs["policies"] = relationships_data.get("policies", {}).get("data", []) @@ -219,6 +243,9 @@ def update(self, policy_set_id: str, options: PolicySetUpdateOptions) -> PolicyS attrs["workspace_exclusions"] = relationships_data.get( "workspace-exclusions", {} ).get("data", []) + attrs["project_exclusions"] = relationships_data.get( + "project-exclusions", {} + ).get("data", []) attrs["workspaces"] = relationships_data.get("workspaces", {}).get("data", []) attrs["projects"] = relationships_data.get("projects", {}).get("data", []) attrs["policies"] = relationships_data.get("policies", {}).get("data", []) @@ -385,6 +412,52 @@ def remove_workspace_exclusions( ) return None + def add_project_exclusions( + self, + policy_set_id: str, + options: PolicySetAddProjectExclusionsOptions, + ) -> None: + """Add project exclusions to a policy set.""" + if not valid_string_id(policy_set_id): + raise InvalidPolicySetIDError() + if not options.project_exclusions: + raise ValueError("project_exclusions is required") + payload = { + "data": [ + {"id": project.id, "type": "projects"} + for project in options.project_exclusions + ] + } + self.t.request( + "POST", + f"/api/v2/policy-sets/{policy_set_id}/relationships/project-exclusions", + json_body=payload, + ) + return None + + def remove_project_exclusions( + self, + policy_set_id: str, + options: PolicySetRemoveProjectExclusionsOptions, + ) -> None: + """Remove project exclusions from a policy set.""" + if not valid_string_id(policy_set_id): + raise InvalidPolicySetIDError() + if not options.project_exclusions: + raise ValueError("project_exclusions is required") + payload = { + "data": [ + {"id": project.id, "type": "projects"} + for project in options.project_exclusions + ] + } + self.t.request( + "DELETE", + f"/api/v2/policy-sets/{policy_set_id}/relationships/project-exclusions", + json_body=payload, + ) + return None + def add_projects( self, policy_set_id: str, options: PolicySetAddProjectsOptions ) -> None: diff --git a/src/pytfe/resources/projects.py b/src/pytfe/resources/projects.py index 335b1314..44fb63a1 100644 --- a/src/pytfe/resources/projects.py +++ b/src/pytfe/resources/projects.py @@ -268,6 +268,29 @@ def delete(self, project_id: str) -> None: path = f"/api/v2/projects/{project_id}" self.t.request("DELETE", path) + def move_workspaces( + self, project_id: str, workspace_ids: builtins.list[str] + ) -> None: + """Move one or more workspaces into a project. + + The caller must have permission to move each workspace out of its + current project and into the target project. + """ + if not valid_string_id(project_id): + raise ValueError("Project ID is required and must be valid") + if not workspace_ids: + raise ValueError("at least one workspace id is required") + for wid in workspace_ids: + if not valid_string_id(wid): + raise ValueError(f"invalid workspace id: {wid!r}") + payload = {"data": [{"id": wid, "type": "workspaces"} for wid in workspace_ids]} + self.t.request( + "POST", + f"/api/v2/projects/{project_id}/relationships/workspaces", + json_body=payload, + ) + return None + def list_tag_bindings(self, project_id: str) -> builtins.list[TagBinding]: """List tag bindings for a project""" # Validate inputs @@ -292,29 +315,21 @@ def list_tag_bindings(self, project_id: str) -> builtins.list[TagBinding]: def list_effective_tag_bindings( self, project_id: str - ) -> builtins.list[EffectiveTagBinding]: - """List effective tag bindings for a project""" - # Validate inputs + ) -> Iterator[EffectiveTagBinding]: + """List effective tag bindings for a project.""" if not valid_string_id(project_id): raise ValueError("Project ID is required and must be valid") path = f"/api/v2/projects/{project_id}/effective-tag-bindings" - response = self.t.request("GET", path) - data = response.json()["data"] - - effective_tag_bindings = [] - for item in data: + for item in self._list(path): attr = item.get("attributes", {}) or {} links = item.get("links", {}) or {} - effective_tag_binding = EffectiveTagBinding( + yield EffectiveTagBinding( id=_safe_str(item.get("id")), key=_safe_str(attr.get("key")), value=_safe_str(attr.get("value")), links=links, ) - effective_tag_bindings.append(effective_tag_binding) - - return effective_tag_bindings def add_tag_bindings( self, project_id: str, options: ProjectAddTagBindingsOptions diff --git a/src/pytfe/resources/registry_module.py b/src/pytfe/resources/registry_module.py index 651471fb..3945f8a4 100644 --- a/src/pytfe/resources/registry_module.py +++ b/src/pytfe/resources/registry_module.py @@ -217,8 +217,23 @@ def read_version( return self._parse_registry_module_version(data) - def list_versions(self, module_id: RegistryModuleID) -> list[RegistryModuleVersion]: # type: ignore[valid-type] - """List all versions of a registry module.""" + def list_versions( + self, module_id: RegistryModuleID + ) -> Iterator[RegistryModuleVersion]: + """List all versions of a registry module. + + This method intentionally fetches eagerly and returns ``iter(list)`` + instead of the canonical ``for x in self._list(...): yield ...`` + pattern used elsewhere in the SDK. The reason is the fallback path: + if the primary ``/versions`` endpoint is unavailable, we fall back + to reading the module and extracting versions from + ``version_statuses``. A pure generator could yield items from the + primary endpoint, fail partway, then switch to the fallback and + yield duplicates. Eager materialization avoids that risk. + + See ``docs/ITERATORS.md`` for the convention and when it's OK to + deviate from it. + """ if not self._validate_module_id(module_id): raise ValueError("Invalid module ID") @@ -241,12 +256,12 @@ def list_versions(self, module_id: RegistryModuleID) -> list[RegistryModuleVersi # Handle the case where data might be None or empty data = response_data.get("data", []) if response_data else [] - versions = [] + versions: list[RegistryModuleVersion] = [] for item in data: if item: # Skip None items versions.append(self._parse_registry_module_version(item)) - return versions + return iter(versions) except Exception: # Fallback: If the API endpoint doesn't exist, try to get versions from the module itself @@ -270,9 +285,9 @@ def list_versions(self, module_id: RegistryModuleID) -> list[RegistryModuleVersi } versions.append(self._parse_registry_module_version(version_data)) - return versions + return iter(versions) except Exception: - return [] # Return empty list if all methods fail + return iter([]) # Return empty iterator if all methods fail def read_terraform_registry_module( self, module_id: RegistryModuleID, version: str diff --git a/src/pytfe/resources/state_versions.py b/src/pytfe/resources/state_versions.py index 260ec5a1..3f7ec760 100644 --- a/src/pytfe/resources/state_versions.py +++ b/src/pytfe/resources/state_versions.py @@ -236,7 +236,6 @@ def upload( sv.hosted_state_upload_url, data=raw_state, headers={"Content-Type": "application/octet-stream"}, - include_auth=False, ) if raw_json_state is not None: @@ -249,7 +248,6 @@ def upload( sv.hosted_json_state_upload_url, data=raw_json_state, headers={"Content-Type": "application/octet-stream"}, - include_auth=False, ) return self.read(sv.id) @@ -273,10 +271,14 @@ 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. + # Download the bytes from the signed Archivist URL. The presigned URL + # already carries its own credentials, so the TFE bearer token must + # NOT be forwarded. resp = self.t.request( - "GET", url, allow_redirects=True, headers={"Accept": "application/json"} + "GET", + url, + allow_redirects=True, + headers={"Accept": "*/*"}, ) return resp.content @@ -292,7 +294,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 @@ -353,3 +358,41 @@ def permanently_delete_backing_data(self, state_version_id: str) -> None: f"/api/v2/state-versions/{state_version_id}/actions/permanently_delete_backing_data", ) return None + + def rollback( + self, + workspace_id: str, + rollback_state_version_id: str, + ) -> StateVersion: + """Roll a workspace back to a previous state version. + + Duplicates the named state version and sets the copy as the workspace's + current state version. The workspace must be locked by the caller + before invoking this operation, otherwise the API returns 409. + """ + if not valid_string_id(workspace_id): + raise ValueError("invalid workspace id") + if not valid_string_id(rollback_state_version_id): + raise ValueError("invalid rollback state version id") + body = { + "data": { + "type": "state-versions", + "relationships": { + "rollback-state-version": { + "data": { + "type": "state-versions", + "id": rollback_state_version_id, + } + } + }, + } + } + resp = self.t.request( + "PATCH", + f"/api/v2/workspaces/{workspace_id}/state-versions", + json_body=body, + ) + data = (resp.json() or {}).get("data") or {} + attributes = dict(data.get("attributes") or {}) + attributes["id"] = data.get("id", "") + return StateVersion.model_validate(attributes) diff --git a/src/pytfe/resources/team.py b/src/pytfe/resources/team.py index 37df9876..9996bcff 100644 --- a/src/pytfe/resources/team.py +++ b/src/pytfe/resources/team.py @@ -1,5 +1,6 @@ from __future__ import annotations +import builtins from collections.abc import Iterator from ..errors import ( @@ -106,3 +107,142 @@ def delete(self, team_id: str) -> None: path=f"/api/v2/teams/{team_id}", ) return None + + # ------------------------------------------------------------------ + # Team membership management + # ------------------------------------------------------------------ + + def add_users(self, team_id: str, usernames: builtins.list[str]) -> None: + """Add users to a team by username.""" + if not valid_string_id(team_id): + raise InvalidTeamIDError() + if not usernames: + raise ValueError("at least one username is required") + if any(not isinstance(u, str) or not u.strip() for u in usernames): + raise ValueError("usernames must be non-empty strings") + payload = {"data": [{"type": "users", "id": u} for u in usernames]} + self.t.request( + "POST", + path=f"/api/v2/teams/{team_id}/relationships/users", + json_body=payload, + ) + return None + + def remove_users(self, team_id: str, usernames: builtins.list[str]) -> None: + """Remove users from a team by username.""" + if not valid_string_id(team_id): + raise InvalidTeamIDError() + if not usernames: + raise ValueError("at least one username is required") + if any(not isinstance(u, str) or not u.strip() for u in usernames): + raise ValueError("usernames must be non-empty strings") + payload = {"data": [{"type": "users", "id": u} for u in usernames]} + self.t.request( + "DELETE", + path=f"/api/v2/teams/{team_id}/relationships/users", + json_body=payload, + ) + return None + + def add_organization_memberships( + self, team_id: str, organization_membership_ids: builtins.list[str] + ) -> None: + """Add users to a team by organization membership id.""" + if not valid_string_id(team_id): + raise InvalidTeamIDError() + if not organization_membership_ids: + raise ValueError("at least one organization membership id is required") + if any(not valid_string_id(i) for i in organization_membership_ids): + raise ValueError("invalid organization membership id") + payload = { + "data": [ + {"type": "organization-memberships", "id": i} + for i in organization_membership_ids + ] + } + self.t.request( + "POST", + path=f"/api/v2/teams/{team_id}/relationships/organization-memberships", + json_body=payload, + ) + return None + + def remove_organization_memberships( + self, team_id: str, organization_membership_ids: builtins.list[str] + ) -> None: + """Remove users from a team by organization membership id.""" + if not valid_string_id(team_id): + raise InvalidTeamIDError() + if not organization_membership_ids: + raise ValueError("at least one organization membership id is required") + if any(not valid_string_id(i) for i in organization_membership_ids): + raise ValueError("invalid organization membership id") + payload = { + "data": [ + {"type": "organization-memberships", "id": i} + for i in organization_membership_ids + ] + } + self.t.request( + "DELETE", + path=f"/api/v2/teams/{team_id}/relationships/organization-memberships", + json_body=payload, + ) + return None + + def list_users(self, team_id: str) -> Iterator[User]: + """List the users that belong to a team. + + Implemented via ``GET /teams/{id}?include=users`` — the API has no + dedicated paginated endpoint for team users, so all results arrive + in a single response. The signature still returns an iterator to + stay consistent with the other ``list_*`` methods in the SDK; wrap + the result in ``list(...)`` if you need a materialized list. + """ + if not valid_string_id(team_id): + raise InvalidTeamIDError() + r = self.t.request( + "GET", + path=f"/api/v2/teams/{team_id}", + params={"include": "users"}, + ) + payload = r.json() or {} + included = payload.get("included") or [] + for inc in included: + if inc.get("type") != "users": + continue + attrs = dict(inc.get("attributes") or {}) + attrs["id"] = inc.get("id") + yield User.model_validate(attrs) + + def list_organization_memberships( + self, + team_id: str, + *, + status: str | None = None, + is_service_account: bool | None = None, + sort: str | None = None, + ) -> Iterator[OrganizationMembership]: + """List the organization memberships that belong to a team. + + Uses the dedicated paginated endpoint + ``GET /teams/{id}/relationships/organization-memberships`` so + callers get server-side pagination, filtering by status / + service-account flag, and sort. + """ + if not valid_string_id(team_id): + raise InvalidTeamIDError() + params: dict[str, str] = {} + if status is not None: + params["filter[status]"] = status + if is_service_account is not None: + params["filter[is_service_account]"] = ( + "true" if is_service_account else "false" + ) + if sort is not None: + params["sort"] = sort + path = f"/api/v2/teams/{team_id}/relationships/organization-memberships" + for item in self._list(path, params=params): + attrs = dict(item.get("attributes") or {}) + attrs["id"] = item.get("id") + yield OrganizationMembership.model_validate(attrs) diff --git a/src/pytfe/resources/team_workspace_access.py b/src/pytfe/resources/team_workspace_access.py new file mode 100644 index 00000000..8f37c21b --- /dev/null +++ b/src/pytfe/resources/team_workspace_access.py @@ -0,0 +1,125 @@ +# 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 InvalidTeamIDError, InvalidWorkspaceIDError, TFEError +from ..models.team_workspace_access import ( + TeamWorkspaceAccess, + TeamWorkspaceAccessAddOptions, + TeamWorkspaceAccessUpdateOptions, +) +from ..utils import valid_string_id +from ._base import _Service + + +class InvalidTeamWorkspaceAccessIDError(TFEError): + """Raised when a team-workspace access id is missing or malformed.""" + + def __init__(self, message: str = "invalid team workspace access id"): + super().__init__(message) + + +def _parse(data: dict[str, Any]) -> TeamWorkspaceAccess: + attributes = dict(data.get("attributes") or {}) + attributes["id"] = data.get("id", "") + relationships = data.get("relationships") or {} + team_data = (relationships.get("team") or {}).get("data") or {} + workspace_data = (relationships.get("workspace") or {}).get("data") or {} + if team_data.get("id"): + attributes["team-id"] = team_data["id"] + if workspace_data.get("id"): + attributes["workspace-id"] = workspace_data["id"] + return TeamWorkspaceAccess.model_validate(attributes) + + +def _attributes_payload(model_dict: dict[str, Any]) -> dict[str, Any]: + """Hyphenate snake_case attribute keys for JSON:API.""" + return {k.replace("_", "-"): v for k, v in model_dict.items() if v is not None} + + +class TeamWorkspaceAccesses(_Service): + """Manage team access grants on workspaces (`/api/v2/team-workspaces`).""" + + def list(self, workspace_id: str) -> Iterator[TeamWorkspaceAccess]: + """List team access grants for a workspace.""" + if not valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + path = "/api/v2/team-workspaces" + params = {"filter[workspace][id]": workspace_id} + for item in self._list(path, params=params): + yield _parse(item) + + def read(self, team_workspace_access_id: str) -> TeamWorkspaceAccess: + """Read a single team-workspace access grant by id.""" + if not valid_string_id(team_workspace_access_id): + raise InvalidTeamWorkspaceAccessIDError() + r = self.t.request("GET", f"/api/v2/team-workspaces/{team_workspace_access_id}") + return _parse((r.json() or {}).get("data") or {}) + + def add(self, options: TeamWorkspaceAccessAddOptions) -> TeamWorkspaceAccess: + """Add a team access grant to a workspace.""" + if not valid_string_id(options.team_id): + raise InvalidTeamIDError() + if not valid_string_id(options.workspace_id): + raise InvalidWorkspaceIDError() + attrs = _attributes_payload( + options.model_dump( + by_alias=False, + exclude={"team_id", "workspace_id"}, + exclude_none=True, + mode="json", + ) + ) + payload = { + "data": { + "type": "team-workspaces", + "attributes": attrs, + "relationships": { + "team": {"data": {"type": "teams", "id": options.team_id}}, + "workspace": { + "data": {"type": "workspaces", "id": options.workspace_id} + }, + }, + } + } + r = self.t.request("POST", "/api/v2/team-workspaces", json_body=payload) + return _parse((r.json() or {}).get("data") or {}) + + def update( + self, + team_workspace_access_id: str, + options: TeamWorkspaceAccessUpdateOptions, + ) -> TeamWorkspaceAccess: + """Update an existing team-workspace access grant.""" + if not valid_string_id(team_workspace_access_id): + raise InvalidTeamWorkspaceAccessIDError() + attrs = _attributes_payload( + options.model_dump(by_alias=False, exclude_none=True, mode="json") + ) + payload = { + "data": { + "type": "team-workspaces", + "id": team_workspace_access_id, + "attributes": attrs, + } + } + r = self.t.request( + "PATCH", + f"/api/v2/team-workspaces/{team_workspace_access_id}", + json_body=payload, + ) + return _parse((r.json() or {}).get("data") or {}) + + def remove(self, team_workspace_access_id: str) -> None: + """Remove (delete) a team-workspace access grant.""" + if not valid_string_id(team_workspace_access_id): + raise InvalidTeamWorkspaceAccessIDError() + self.t.request( + "DELETE", + f"/api/v2/team-workspaces/{team_workspace_access_id}", + ) + return None diff --git a/src/pytfe/resources/workspaces.py b/src/pytfe/resources/workspaces.py index 1a6ac6cb..a2d419ec 100644 --- a/src/pytfe/resources/workspaces.py +++ b/src/pytfe/resources/workspaces.py @@ -22,6 +22,7 @@ WorkspaceRequiredError, ) from ..models.agent import AgentPool +from ..models.assessment_result import AssessmentResult from ..models.common import ( EffectiveTagBinding, Tag, @@ -1015,3 +1016,44 @@ def readme(self, workspace_id: str) -> str | None: return (inc.get("attributes") or {}).get("raw-markdown") return None + + def current_assessment_result(self, workspace_id: str) -> AssessmentResult | None: + """Get the current health-assessment (drift detection) result for a workspace. + + Returns ``None`` if the workspace has no assessment result yet (assessments + may be disabled, or no assessment has run). + """ + if not valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + try: + r = self.t.request( + "GET", + f"/api/v2/workspaces/{workspace_id}/current-assessment-result", + ) + except Exception as exc: + from ..errors import NotFound + + if isinstance(exc, NotFound): + return None + raise + data = (r.json() or {}).get("data") or {} + attributes = dict(data.get("attributes") or {}) + attributes["id"] = data.get("id", "") + return AssessmentResult.model_validate(attributes) + + def list_applicable_varsets(self, workspace_id: str) -> Iterator[dict[str, Any]]: + """List variable sets that apply to a workspace, including inherited ones. + + Returns raw varset attribute dicts (id/name/global/var-count/etc.). The + endpoint summarises varsets rather than returning the full relationship + graph, so it is exposed as plain dicts to avoid the heavier + ``VariableSet`` parsing path. Callers wanting the full model can pass + each ``id`` to ``client.variable_sets.read``. + """ + if not valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + path = f"/api/v2/workspaces/{workspace_id}/applicable-varsets" + for item in self._list(path): + attrs = dict(item.get("attributes") or {}) + attrs["id"] = item.get("id", "") + yield attrs diff --git a/tests/units/test_plan.py b/tests/units/test_plan.py index 36c3f7ee..cbfe111c 100644 --- a/tests/units/test_plan.py +++ b/tests/units/test_plan.py @@ -101,7 +101,7 @@ def test_logs_success(self, plans_service): assert result == "" def test_read_json_output_success(self, plans_service): - """Test successful read_json_output operation.""" + """Test successful read_json_output operation (200 response).""" mock_json_data = { "format_version": "1.1", @@ -124,19 +124,47 @@ def test_read_json_output_success(self, plans_service): with patch.object(plans_service, "t") as mock_transport: mock_response = Mock() + mock_response.status_code = 200 mock_response.json.return_value = mock_json_data mock_transport.request.return_value = mock_response result = plans_service.read_json_output("plan-123") - # Verify request was made correctly mock_transport.request.assert_called_once_with( - "GET", "/api/v2/plans/plan-123/json-output" + "GET", "/api/v2/plans/plan-123/json-output", allow_redirects=False ) - # Verify JSON data is returned assert result == mock_json_data assert result["format_version"] == "1.1" assert result["terraform_version"] == "1.5.0" assert len(result["resource_changes"]) == 1 assert result["resource_changes"][0]["change"]["actions"] == ["create"] + + def test_read_json_output_follows_redirect(self, plans_service): + """The 307 redirect target is followed manually and its body returned.""" + mock_json_data = {"format_version": "1.1"} + + with patch.object(plans_service, "t") as mock_transport: + redirect_resp = Mock() + redirect_resp.status_code = 307 + redirect_resp.headers = { + "Location": "https://archivist.example/blob?sig=abc" + } + blob_resp = Mock() + blob_resp.status_code = 200 + blob_resp.json.return_value = mock_json_data + mock_transport.request.side_effect = [redirect_resp, blob_resp] + + result = plans_service.read_json_output("plan-123") + + assert result == mock_json_data + assert mock_transport.request.call_count == 2 + first_call = mock_transport.request.call_args_list[0] + second_call = mock_transport.request.call_args_list[1] + assert first_call.args == ("GET", "/api/v2/plans/plan-123/json-output") + assert first_call.kwargs == {"allow_redirects": False} + assert second_call.args == ( + "GET", + "https://archivist.example/blob?sig=abc", + ) + assert second_call.kwargs == {} diff --git a/tests/units/test_project.py b/tests/units/test_project.py index c42fb3af..2f4a3321 100644 --- a/tests/units/test_project.py +++ b/tests/units/test_project.py @@ -364,7 +364,7 @@ def test_list_tag_bindings_invalid_project_id(self): def test_list_effective_tag_bindings_success(self): """Test successful listing of effective tag bindings""" - # Mock API response + # Mock API response — single page, no pagination metadata. mock_response = Mock() mock_response.json.return_value = { "data": [ @@ -380,8 +380,10 @@ def test_list_effective_tag_bindings_success(self): } self.mock_transport.request.return_value = mock_response - # Call the method - result = self.projects_service.list_effective_tag_bindings(self.project_id) + # Call the method (returns Iterator — materialize to assert) + result = list( + self.projects_service.list_effective_tag_bindings(self.project_id) + ) # Assertions assert len(result) == 1 @@ -392,19 +394,23 @@ def test_list_effective_tag_bindings_success(self): assert result[0].value == "production" assert "self" in result[0].links - # Verify API call - self.mock_transport.request.assert_called_once_with( - "GET", f"/api/v2/projects/{self.project_id}/effective-tag-bindings" + # Verify the request was issued against the right path (params include + # page[number]/page[size] from _list — assert on path only). + call = self.mock_transport.request.call_args + assert call.args == ( + "GET", + f"/api/v2/projects/{self.project_id}/effective-tag-bindings", ) def test_list_effective_tag_bindings_invalid_project_id(self): """Test listing effective tag bindings with invalid project ID""" import pytest + # Generator-based list methods validate on first iteration. with pytest.raises( ValueError, match="Project ID is required and must be valid" ): - self.projects_service.list_effective_tag_bindings(None) + list(self.projects_service.list_effective_tag_bindings(None)) def test_add_tag_bindings_success(self): """Test successful addition of tag bindings""" diff --git a/tests/units/test_state_version.py b/tests/units/test_state_version.py index a3e3659d..f9e3cd9b 100644 --- a/tests/units/test_state_version.py +++ b/tests/units/test_state_version.py @@ -2,7 +2,6 @@ from unittest.mock import Mock, patch -import httpx import pytest from pytfe._http import HTTPTransport @@ -284,54 +283,14 @@ def test_upload_state_version_success(self, state_versions_service, mock_transpo "https://example.com/upload-raw", data=b"raw-state", headers={"Content-Type": "application/octet-stream"}, - include_auth=False, ) mock_transport.request.assert_any_call( "PUT", "https://example.com/upload-json", data=b"json-state", headers={"Content-Type": "application/octet-stream"}, - include_auth=False, ) - def test_upload_state_version_presigned_put_omits_authorization_header(self): - """Test upload() does not send the TFE token to presigned upload URLs.""" - seen_authorization_headers: list[str | None] = [] - - def handler(request: httpx.Request) -> httpx.Response: - seen_authorization_headers.append(request.headers.get("authorization")) - return httpx.Response(200) - - transport = HTTPTransport( - "https://app.terraform.io", - "secret-token", - timeout=5, - verify_tls=True, - user_agent_suffix=None, - max_retries=0, - backoff_base=0, - backoff_cap=0, - backoff_jitter=False, - http2=False, - proxies=None, - ca_bundle=None, - ) - transport._sync = httpx.Client(transport=httpx.MockTransport(handler)) - service = StateVersions(transport) - created_sv = StateVersion( - id="sv-upload-1", - status=StateVersionStatus.PENDING, - hosted_state_upload_url="https://archivist.terraform.io/upload-raw", - ) - final_sv = StateVersion(id="sv-upload-1", status=StateVersionStatus.FINALIZED) - options = StateVersionCreateOptions(serial=10, md5="abc123") - - with patch.object(service, "create", return_value=created_sv): - with patch.object(service, "read", return_value=final_sv): - service.upload("ws-123", raw_state=b"raw-state", options=options) - - assert seen_authorization_headers == [None] - def test_upload_state_version_unsupported_on_create_error( self, state_versions_service ): @@ -406,7 +365,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"{}" From c95a3ed853e81d37c1ed1a2e8c500b6a34b8712b Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Mon, 25 May 2026 00:20:46 +0530 Subject: [PATCH 84/95] fix and optimise explorer (#170) --- examples/explorer.py | 22 +- src/pytfe/models/explorer.py | 33 +- src/pytfe/resources/explorer.py | 688 +++++++------------------------- tests/units/test_explorer.py | 684 ++++++++----------------------- 4 files changed, 350 insertions(+), 1077 deletions(-) diff --git a/examples/explorer.py b/examples/explorer.py index 50f27b57..f3d74fb9 100644 --- a/examples/explorer.py +++ b/examples/explorer.py @@ -20,14 +20,12 @@ │ 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 │ + │ delete_saved_view │ Remove saved view by id │ organization: str; view_id: str │ None │ │ 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) │ + │ saved_view_results_csv │ Saved view results as CSV │ organization: str; view_id: str │ str (CSV) │ └────────────────────────┴────────────────────────────────────┴──────────────────────────────────────────────────────────────────────────────┴──────────────────────────────┘ - 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. + saved_view_results_csv: hits the dedicated saved-view CSV endpoint + (``/explorer/views/{id}/csv``) and returns the response text verbatim. INPUT AND OUTPUT MODELS (how to pass; allowed values) ─────────────────────────────────────────────────────── @@ -95,9 +93,9 @@ 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. + ExplorerRow — from query(), saved_view_results(): read .id, .type, .attributes. + .attributes is a dict of column values; keys are normalised to snake_case at + parse time so callers can index ``row.attributes["workspace_name"]`` directly. 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. @@ -208,7 +206,7 @@ def main() -> None: # 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. + # (id, 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'.", @@ -236,7 +234,7 @@ def main() -> None: ) print(f" Row {count}:") print(f" id: {row.id}") - print(f" row_type: {row.row_type!r}") + print(f" type: {row.type!r}") print(f" workspace_name: {name!r}") print(" ---") print(f"Summary: printed {count} row(s) (limit 5).") @@ -333,7 +331,7 @@ def main() -> None: break print(f" Result row {i + 1}:") print(f" id: {row.id}") - print(f" row_type: {row.row_type!r}") + print(f" type: {row.type!r}") print(" ---") print("Summary: saved_view_results completed (limit 3 rows printed).") except TFEError as e: diff --git a/src/pytfe/models/explorer.py b/src/pytfe/models/explorer.py index 66d73aa2..732b3216 100644 --- a/src/pytfe/models/explorer.py +++ b/src/pytfe/models/explorer.py @@ -27,6 +27,8 @@ class ExplorerViewType(str, Enum): class ExplorerUrlFilter(BaseModel): """One slot in ExplorerQueryOptions.filters → filter[i][field][op][idx] query keys.""" + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + 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" @@ -43,7 +45,7 @@ class ExplorerUrlFilter(BaseModel): class ExplorerQueryOptions(BaseModel): """GET /organizations/{org}/explorer (and export/csv) query string as structured fields.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) view_type: ExplorerViewType = Field(..., alias="type") sort: str | None = Field( @@ -63,18 +65,25 @@ class ExplorerQueryOptions(BaseModel): class ExplorerRow(BaseModel): - """One Explorer result row: json:api id/type plus flat attributes for the view.""" + """One Explorer result row: JSON:API id/type plus flat attributes for the view. + + Attribute keys are normalised to snake_case at parse time so callers can + index ``row.attributes["workspace_name"]`` rather than juggling hyphen vs + snake variants. + """ - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) id: str - row_type: str = Field(..., alias="type") + type: str attributes: dict[str, Any] = Field(default_factory=dict) class ExplorerSavedQueryFilter(BaseModel): """One saved-view filter row (list-valued `value` matches create/update JSON).""" + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + field: str = Field(..., min_length=1) operator: str = Field(..., min_length=1) value: list[str] = Field(default_factory=list) @@ -83,7 +92,7 @@ class ExplorerSavedQueryFilter(BaseModel): class ExplorerSavedQuery(BaseModel): """Nested query on a saved view: view type, filters, optional fields and sort lists.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) query_type: ExplorerViewType = Field(..., alias="type") filter: list[ExplorerSavedQueryFilter] | None = None @@ -92,9 +101,15 @@ class ExplorerSavedQuery(BaseModel): class ExplorerSavedView(BaseModel): - """Saved view resource: metadata plus embedded query (response and some request paths).""" + """Saved view resource: metadata plus embedded query. + + The HCP Terraform API returns ``query-type`` at the view level *and* + ``type`` nested inside ``query``. They are always equal in practice; the + SDK surfaces both because they appear in different positions in the + request/response payload. + """ - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) id: str name: str @@ -106,7 +121,7 @@ class ExplorerSavedView(BaseModel): class ExplorerSavedViewCreateOptions(BaseModel): """POST .../explorer/views attributes: display name, top-level query-type, nested query.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) name: str = Field(..., min_length=1) query_type: ExplorerViewType = Field(..., alias="query-type") @@ -116,7 +131,7 @@ class ExplorerSavedViewCreateOptions(BaseModel): class ExplorerSavedViewUpdateOptions(BaseModel): """PATCH .../explorer/views/{id} attributes: name and full replacement query.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(populate_by_name=True, validate_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 index 50d6ae58..9b171758 100644 --- a/src/pytfe/resources/explorer.py +++ b/src/pytfe/resources/explorer.py @@ -3,25 +3,19 @@ """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. +Maps organization-scoped Explorer endpoints (ad-hoc query, CSV export, saved +views) to typed models. Saved-view create/update reshape filter JSON to the +nested ``{field: {operator: [values]}}`` form the server expects. """ 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, @@ -29,80 +23,15 @@ ExplorerSavedView, ExplorerSavedViewCreateOptions, ExplorerSavedViewUpdateOptions, - ExplorerUrlFilter, - ExplorerViewType, ) 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). + """Serialise ExplorerQueryOptions to query-string params, expanding filters.""" + # mode="json" keeps ExplorerViewType as strings; filters are expanded + # separately into the Explorer URL grammar. params = options.model_dump( by_alias=True, exclude_none=True, @@ -111,393 +40,177 @@ def _query_params(options: ExplorerQueryOptions) -> dict[str, Any]: ) if options.filters: for flt in options.filters: - params[ - f"filter[{flt.index}][{flt.field}][{flt.operator}][{flt.value_index}]" - ] = flt.value + key = f"filter[{flt.index}][{flt.field}][{flt.operator}][{flt.value_index}]" + params[key] = flt.value return params -def _parse_row(item: dict[str, Any]) -> ExplorerRow: - return ExplorerRow.model_validate(item) +def _normalize_attribute_keys(attrs: dict[str, Any]) -> dict[str, Any]: + """Normalise JSON:API hyphen attribute keys to Python snake_case.""" + return {k.replace("-", "_"): v for k, v in attrs.items()} -def _normalize_filter_field_name(raw_field: Any) -> str: - """Normalize filter field names to SDK model style.""" - return str(raw_field).replace("-", "_") +def _parse_row(item: dict[str, Any]) -> ExplorerRow: + return ExplorerRow.model_validate( + { + "id": item.get("id", ""), + "type": item.get("type", ""), + "attributes": _normalize_attribute_keys(item.get("attributes") or {}), + } + ) 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.""" + """Map the model's flat filter rows and field list into the shapes the + API expects on create/update: + + * ``filter`` rows: ``{field, operator, value}`` → ``{field: {operator: [values]}}`` + * ``fields``: ``[col, ...]`` → ``{view_type: [col, ...]}`` + """ query = dict(raw_query) + raw_filter = query.get("filter") if isinstance(raw_filter, list): - mapped_filters: list[dict[str, Any]] = [] + mapped: list[dict[str, Any]] = [] for entry in raw_filter: if not isinstance(entry, dict): continue - # Already API-compatible map style. + # Already in API map shape — pass through. if "field" not in entry or "operator" not in entry: - mapped_filters.append(entry) + mapped.append(entry) continue - field = _normalize_filter_field_name(entry.get("field", "")) + 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 + mapped.append({field: {operator: [str(v) for v in values]}}) + query["filter"] = mapped + + # The API stores `fields` as `{view_type: [columns]}`. Wrap a flat list. + raw_fields = query.get("fields") + view_type = query.get("type") + if isinstance(raw_fields, list) and isinstance(view_type, str): + query["fields"] = {view_type: list(raw_fields)} + return query -def _normalize_saved_query( - raw_query: dict[str, Any], raw_query_type: str | None +def _write_attributes( + options: ExplorerSavedViewCreateOptions | ExplorerSavedViewUpdateOptions, ) -> dict[str, Any]: - """Coerce saved-view query JSON into the flat filter + list fields shape our models use.""" - query = dict(raw_query) + """Serialise create/update options with filters reshaped for the API.""" + 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 _saved_query_from_api(raw_query: dict[str, Any]) -> dict[str, Any]: + """Coerce a saved query's API response into the flat model shape. + + The Explorer API can return filter rows in either of two shapes: + + * Documented flat shape: ``{"field": ..., "operator": ..., "value": [...]}`` + * Operator-map shape: ``{field_name: {operator: [values]}}`` - if "type" not in query and raw_query_type: - query["type"] = raw_query_type + We accept both. Dropping either shape silently loses filter data and + risks consumers overwriting saved-view criteria on an update round-trip. + + ``fields`` arrives as ``{view_type: [...]}`` and is flattened to a list. + """ + query = dict(raw_query) raw_filter = query.get("filter") if isinstance(raw_filter, list): - normalized_filters: list[dict[str, Any]] = [] + flat: 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 not isinstance(entry, dict): + continue + # Variant A: flat shape with explicit field/operator/value keys. + if "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( + value = [value] + flat.append( { - "field": _normalize_filter_field_name(entry["field"]), + "field": str(entry["field"]).replace("-", "_"), "operator": str(entry["operator"]), "value": [str(v) for v in value], } ) continue + # Variant B: operator-map shape — what the live API actually returns. + 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] + flat.append( + { + "field": str(field_name).replace("-", "_"), + "operator": str(operator), + "value": [str(v) for v in vals], + } + ) + query["filter"] = flat - # 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": _normalize_filter_field_name(field_name), - "operator": str(operator), - "value": [str(v) for v in vals], - } - ) - query["filter"] = normalized_filters - + # `fields` arrives as {view_type: [...]}; flatten to a single list. raw_fields = query.get("fields") - # Some responses return fields as {"workspaces": [...]}. if isinstance(raw_fields, dict): - list_values: list[str] = [] + flat_fields: 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 + flat_fields.extend(str(v) for v in value) + query["fields"] = flat_fields 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", {}) + attrs = item.get("attributes") or {} + query = attrs.get("query") or {} 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 _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, + "query": _saved_query_from_api(query), + "query-type": attrs.get("query-type"), } ) -# 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 "" - 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: - 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() - - class Explorer(_Service): - """Organization Explorer: ad hoc queries, CSV export, and saved view CRUD.""" + """Organization Explorer: ad-hoc queries, CSV export, and saved view CRUD.""" 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", - organization, - options.view_type.value, - ) - # GET .../explorer — paginated JSON rows for the given view and filters. + """Execute an Explorer query and iterate result rows across all pages.""" + if not valid_string_id(organization): + raise InvalidOrgError() 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: - """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", - organization, - options.view_type.value, - ) - # Same query string as query(); response is a single unpaged CSV document. + """Run an Explorer query and return CSV text from the export endpoint.""" + if not valid_string_id(organization): + raise InvalidOrgError() 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]: - """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. + """Iterate all saved Explorer views in an organization.""" + if not valid_string_id(organization): + raise InvalidOrgError() path = f"/api/v2/organizations/{organization}/explorer/views" for item in self._list(path): yield _parse_saved_view(item) @@ -505,58 +218,29 @@ 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) + """Create a saved Explorer view.""" + if not valid_string_id(organization): + raise InvalidOrgError() body = { "data": { "type": "explorer-saved-queries", - "attributes": attrs, + "attributes": _write_attributes(options), } } 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 + data = (resp.json() or {}).get("data") or {} + return _parse_saved_view(data) 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", - organization, - view_id, - ) - # Returns stored definition only; does not execute the query (see saved_view_results). + """Read one saved Explorer view by id.""" + if not valid_string_id(organization): + raise InvalidOrgError() + if not valid_string_id(view_id): + raise InvalidExplorerSavedViewIDError() 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, - ) + data = (resp.json() or {}).get("data") or {} return _parse_saved_view(data) def update_saved_view( @@ -565,140 +249,52 @@ 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. + """Replace attributes of an existing saved Explorer view.""" + if not valid_string_id(organization): + raise InvalidOrgError() + if not valid_string_id(view_id): + raise InvalidExplorerSavedViewIDError() body = { "data": { "type": "explorer-saved-queries", "id": view_id, - "attributes": attrs, + "attributes": _write_attributes(options), } } 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 + data = (resp.json() or {}).get("data") or {} + return _parse_saved_view(data) 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) + """Delete a saved Explorer view.""" + if not valid_string_id(organization): + raise InvalidOrgError() + if not valid_string_id(view_id): + raise InvalidExplorerSavedViewIDError() path = f"/api/v2/organizations/{organization}/explorer/views/{view_id}" self.t.request("DELETE", path) 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", - organization, - view_id, - ) - # Re-runs the saved query; rows match ad hoc query() shape (current data only). + """Execute a saved view and iterate result rows across all pages.""" + if not valid_string_id(organization): + raise InvalidOrgError() + if not valid_string_id(view_id): + raise InvalidExplorerSavedViewIDError() 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: - """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", - organization, - view_id, + """Return CSV text for a saved view from the dedicated export endpoint.""" + if not valid_string_id(organization): + raise InvalidOrgError() + if not valid_string_id(view_id): + raise InvalidExplorerSavedViewIDError() + path = ( + f"/api/v2/organizations/{organization}/explorer/views/{view_id}/export/csv" ) - path = f"/api/v2/organizations/{organization}/explorer/views/{view_id}/csv" - try: - resp = self.t.request("GET", path) - 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); " - "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. - saved_for_csv: ExplorerSavedView | None = None - try: - 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, - 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)) - vt = saved_for_csv.query_type if saved_for_csv is not None else None - return _rows_to_csv(rows, view_type=vt) + resp = self.t.request("GET", path) + return resp.text diff --git a/tests/units/test_explorer.py b/tests/units/test_explorer.py index a0b0dd27..2bb8676c 100644 --- a/tests/units/test_explorer.py +++ b/tests/units/test_explorer.py @@ -1,23 +1,18 @@ # Copyright IBM Corp. 2025, 2026 # SPDX-License-Identifier: MPL-2.0 -"""Unit tests for Explorer API resource.""" +"""Unit tests for the Explorer API resource.""" -import csv -from unittest.mock import Mock, call +from unittest.mock import Mock import pytest from pytfe.errors import ( InvalidExplorerSavedViewIDError, InvalidOrgError, - NotFound, - ServerError, - ValidationError, ) from pytfe.models import ( ExplorerQueryOptions, - ExplorerRow, ExplorerSavedQuery, ExplorerSavedQueryFilter, ExplorerSavedViewCreateOptions, @@ -25,11 +20,7 @@ ExplorerUrlFilter, ExplorerViewType, ) -from pytfe.resources.explorer import ( - Explorer, - _normalize_explorer_csv_column_order, - _rows_to_csv, -) +from pytfe.resources.explorer import Explorer ORG = "acme" VIEW_ID = "sq-1" @@ -47,37 +38,8 @@ 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: + """Server-shaped row: id + type + hyphen-keyed attributes.""" return { "id": row_id, "type": "visibility-workspace", @@ -86,6 +48,7 @@ def _row_payload(row_id: str) -> dict: def _saved_view_payload(view_id: str) -> dict: + """Server-shaped saved view (matches live API: nested filter map shape).""" return { "id": view_id, "type": "explorer-saved-queries", @@ -95,567 +58,268 @@ def _saved_view_payload(view_id: str) -> dict: "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"]}}], + "filter": [{"workspace_name": {"contains": ["child"]}}], "fields": {"workspaces": []}, + "sort": [], }, }, } -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, +def _single_page_response(items): + """Mimic the transport response for a one-page _list iteration.""" + resp = Mock() + resp.json.return_value = { + "data": items, + "meta": { + "pagination": { + "current-page": 1, + "total-pages": 1, + "next-page": None, + } + }, } + return resp 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": [_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, - sort="-workspace_name", - fields="workspace_name,organization_name", - page_size=1, - filters=[ - ExplorerUrlFilter( - index=0, - field="workspace_name", - operator="contains", - value="test", - ) - ], + def test_query_emits_expanded_filter_params(self, explorer_service, mock_transport): + mock_transport.request.return_value = _single_page_response( + [_row_payload("ws-1"), _row_payload("ws-2")] ) - 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_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( + opts = ExplorerQueryOptions( view_type=ExplorerViewType.WORKSPACES, page_size=50, + filters=[ + ExplorerUrlFilter( + index=0, field="workspace_name", operator="contains", value="demo" + ), + ], ) + rows = list(explorer_service.query(ORG, opts)) - 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 + assert [r.id for r in rows] == ["ws-1", "ws-2"] + assert rows[0].type == "visibility-workspace" + # Hyphen attribute keys must be normalised to snake_case at parse time. + assert rows[0].attributes == {"workspace_name": "demo-workspace"} - rows = list( - explorer_service.query( - ORG, ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES) - ) - ) - assert rows == [] - assert mock_transport.request.call_count == 1 + # The transport was called against the right path with the expanded + # filter key in the params. + call = mock_transport.request.call_args + assert call.args == ("GET", EXPLORER_PATH) + params = call.kwargs["params"] + assert params["type"] == "workspaces" + assert params["page[size]"] == 50 + assert params["filter[0][workspace_name][contains][0]"] == "demo" def test_query_invalid_org(self, explorer_service): with pytest.raises(InvalidOrgError): list( explorer_service.query( - "", - ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES), + "", ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES) ) ) - @pytest.mark.parametrize("org", ["", None]) + def test_export_csv_returns_text(self, explorer_service, mock_transport): + resp = Mock() + resp.text = "workspace_name\ndemo\n" + mock_transport.request.return_value = resp + + opts = ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES) + out = explorer_service.export_csv(ORG, opts) + assert out == "workspace_name\ndemo\n" + + call = mock_transport.request.call_args + assert call.args == ("GET", f"{EXPLORER_PATH}/export/csv") + assert call.kwargs["params"]["type"] == "workspaces" + + @pytest.mark.parametrize("org", ["", "bad/org"]) def test_export_csv_invalid_org(self, explorer_service, org): with pytest.raises(InvalidOrgError): explorer_service.export_csv( - org, - ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES), + 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( - ORG, ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES) - ) - - assert "workspace_name" in csv_text - _assert_single_request_call( - mock_transport, - "GET", - f"{EXPLORER_PATH}/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 + mock_transport.request.return_value = _single_page_response( + [_saved_view_payload(VIEW_ID), _saved_view_payload("sq-2")] + ) views = list(explorer_service.list_saved_views(ORG)) - assert len(views) == 1 - assert views[0].id == "sq-1" + assert [v.id for v in views] == [VIEW_ID, "sq-2"] + assert views[0].name == "my-view" assert views[0].query_type == ExplorerViewType.WORKSPACES assert views[0].query.query_type == ExplorerViewType.WORKSPACES + # Filter must be flattened from server's map shape to the flat model. + assert len(views[0].query.filter) == 1 + flt = views[0].query.filter[0] + assert flt.field == "workspace_name" + assert flt.operator == "contains" + assert flt.value == ["child"] + + def test_read_saved_view_accepts_documented_flat_filter_shape( + self, explorer_service, mock_transport + ): + """Filters returned in the documented `{field, operator, value}` shape + must round-trip too — dropping them would silently lose criteria on + update. See the HCP API docs for the response shape: + https://developer.hashicorp.com/terraform/cloud-docs/api-docs/explorer + """ + flat_view = { + "id": VIEW_ID, + "type": "explorer-saved-queries", + "attributes": { + "name": "my-view", + "query-type": "workspaces", + "query": { + "type": "workspaces", + "filter": [ + { + "field": "workspace_name", + "operator": "contains", + "value": ["test"], + } + ], + "fields": {"workspaces": []}, + "sort": [], + }, + }, + } + resp = Mock() + resp.json.return_value = {"data": flat_view} + mock_transport.request.return_value = resp - 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 + view = explorer_service.read_saved_view(ORG, VIEW_ID) + assert len(view.query.filter) == 1 + flt = view.query.filter[0] + assert flt.field == "workspace_name" + assert flt.operator == "contains" + assert flt.value == ["test"] - options = ExplorerSavedViewCreateOptions( + def test_create_saved_view_reshapes_filter_for_api( + self, explorer_service, mock_transport + ): + resp = Mock() + resp.json.return_value = {"data": _saved_view_payload(VIEW_ID)} + mock_transport.request.return_value = resp + + opts = ExplorerSavedViewCreateOptions( name="my-view", query_type=ExplorerViewType.WORKSPACES, query=ExplorerSavedQuery( query_type=ExplorerViewType.WORKSPACES, filter=[ ExplorerSavedQueryFilter( - field="workspace_name", operator="contains", value=["test"] + field="workspace_name", + operator="contains", + value=["child"], ) ], ), ) - view = explorer_service.create_saved_view(ORG, options) + view = explorer_service.create_saved_view(ORG, opts) + assert view.id == VIEW_ID - assert view.id == "sq-new" call = mock_transport.request.call_args - assert call[0][0] == "POST" - assert call[0][1] == VIEWS_PATH - body = call[1]["json_body"] + assert call.args == ("POST", VIEWS_PATH) + body = call.kwargs["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(ORG, 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(ORG, VIEW_ID) + attrs = body["data"]["attributes"] + assert attrs["name"] == "my-view" + assert attrs["query-type"] == "workspaces" + # Filter rows in the request body must be in the nested map shape. + assert attrs["query"]["filter"] == [{"workspace_name": {"contains": ["child"]}}] 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 + resp = Mock() + resp.json.return_value = {"data": _saved_view_payload(VIEW_ID)} + mock_transport.request.return_value = resp view = explorer_service.read_saved_view(ORG, VIEW_ID) - assert view.id == "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 - ): - 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(ORG, "sq-2") - - assert view.id == "sq-2" + assert view.id == VIEW_ID 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 == [] + + call = mock_transport.request.call_args + assert call.args == ("GET", f"{VIEWS_PATH}/{VIEW_ID}") 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 + resp = Mock() + resp.json.return_value = {"data": _saved_view_payload(VIEW_ID)} + mock_transport.request.return_value = resp - options = ExplorerSavedViewUpdateOptions( - name="my-view-updated", + opts = ExplorerSavedViewUpdateOptions( + name="renamed", query=ExplorerSavedQuery( query_type=ExplorerViewType.WORKSPACES, filter=[ ExplorerSavedQueryFilter( - field="workspace_name", operator="contains", value=["prod"] + field="workspace_name", + operator="contains", + value=["abc"], ) ], ), ) - view = explorer_service.update_saved_view(ORG, VIEW_ID, options) - - assert view.id == "sq-1" - 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 + view = explorer_service.update_saved_view(ORG, VIEW_ID, opts) + assert view.id == VIEW_ID - 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) + call = mock_transport.request.call_args + assert call.args == ("PATCH", f"{VIEWS_PATH}/{VIEW_ID}") + body = call.kwargs["json_body"] + assert body["data"]["id"] == VIEW_ID + assert body["data"]["attributes"]["name"] == "renamed" + # Same nested-map reshape on update. + assert body["data"]["attributes"]["query"]["filter"] == [ + {"workspace_name": {"contains": ["abc"]}} + ] def test_delete_saved_view(self, explorer_service, mock_transport): - 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_ignores_response_body( - self, explorer_service, mock_transport - ): - response = Mock() - response.text = '{"data":{"id":"unexpected"}}' - response.json.side_effect = ValueError("No JSON body") - mock_transport.request.return_value = response - - result = explorer_service.delete_saved_view(ORG, VIEW_ID) - assert result is None + mock_transport.request.return_value = Mock() + explorer_service.delete_saved_view(ORG, VIEW_ID) + call = mock_transport.request.call_args + assert call.args == ("DELETE", f"{VIEWS_PATH}/{VIEW_ID}") 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] + mock_transport.request.return_value = _single_page_response( + [_row_payload("ws-1")] + ) rows = list(explorer_service.saved_view_results(ORG, VIEW_ID)) - assert len(rows) == 1 - assert rows[0].id == "ws-1" + assert [r.id for r in rows] == ["ws-1"] - mock_transport.request.assert_any_call( - "GET", - f"{VIEWS_PATH}/{VIEW_ID}/results", - params={"page[number]": 1, "page[size]": 100}, - ) + call = mock_transport.request.call_args + assert call.args == ("GET", f"{VIEWS_PATH}/{VIEW_ID}/results") def test_saved_view_results_csv(self, explorer_service, mock_transport): - 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(ORG, VIEW_ID) - assert csv_text.splitlines()[0].startswith( - "all_checks_succeeded,workspace_name" - ) - _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 - ): - 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(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] + resp = Mock() + resp.text = "id,name\nws-1,demo\n" + mock_transport.request.return_value = resp - csv_text = explorer_service.saved_view_results_csv(ORG, VIEW_ID) - assert "from-export" in csv_text + out = explorer_service.saved_view_results_csv(ORG, VIEW_ID) + assert out == "id,name\nws-1,demo\n" - 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(ORG, VIEW_ID) - 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 + call = mock_transport.request.call_args + assert call.args == ("GET", f"{VIEWS_PATH}/{VIEW_ID}/export/csv") - @pytest.mark.parametrize("org", ["", None]) + @pytest.mark.parametrize("org", ["", "bad/org"]) 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, VIEW_ID) + with pytest.raises(InvalidOrgError): + explorer_service.delete_saved_view(org, VIEW_ID) - @pytest.mark.parametrize("view_id", ["", None]) + @pytest.mark.parametrize("view_id", ["", "bad/view"]) def test_saved_view_methods_invalid_id(self, explorer_service, view_id): with pytest.raises(InvalidExplorerSavedViewIDError): explorer_service.read_saved_view(ORG, view_id) - - with pytest.raises(InvalidExplorerSavedViewIDError): - explorer_service.update_saved_view( - ORG, - view_id, - ExplorerSavedViewUpdateOptions( - name="updated", - query=ExplorerSavedQuery(query_type=ExplorerViewType.WORKSPACES), - ), - ) - with pytest.raises(InvalidExplorerSavedViewIDError): explorer_service.delete_saved_view(ORG, view_id) - with pytest.raises(InvalidExplorerSavedViewIDError): list(explorer_service.saved_view_results(ORG, view_id)) - with pytest.raises(InvalidExplorerSavedViewIDError): explorer_service.saved_view_results_csv(ORG, view_id) From 5637c74cc975e0bd12ef72f4f41bd5db94e13410 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Mon, 25 May 2026 01:12:31 +0530 Subject: [PATCH 85/95] Add logger for the sdk (#171) --- AGENTS.md | 2 + README.md | 63 ++++++ docs/LOGGING.md | 150 +++++++++++++++ src/pytfe/__init__.py | 10 +- src/pytfe/_http.py | 39 +++- src/pytfe/_logging.py | 317 ++++++++++++++++++++++++++++++ tests/units/test_logging.py | 372 ++++++++++++++++++++++++++++++++++++ 7 files changed, 951 insertions(+), 2 deletions(-) create mode 100644 docs/LOGGING.md create mode 100644 src/pytfe/_logging.py create mode 100644 tests/units/test_logging.py diff --git a/AGENTS.md b/AGENTS.md index 792fdf3d..f64aae2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,7 @@ These three documents define the patterns this codebase already uses. Generating | `list_*` methods, pagination, iterator vs list, the `_list` helper | [`docs/ITERATORS.md`](docs/ITERATORS.md) | | Pydantic model conventions: `ConfigDict`, aliases, validators, relationships, exporting | [`docs/MODELS.md`](docs/MODELS.md) | | Resource service patterns: method shape, JSON:API envelopes, client wiring, examples | [`docs/RESOURCE.md`](docs/RESOURCE.md) | +| Logging: namespace, redaction, env-var setup, debug round-trip traces | [`docs/LOGGING.md`](docs/LOGGING.md) | Each doc ends with a checklist. Use those checklists; they encode the rules a reviewer will look for. @@ -75,6 +76,7 @@ These are mistakes a competent Python developer would make if they hadn't read t - **Don't add features beyond what was asked.** This codebase is approaching v1.0.0. Adding "while I'm here" refactors or speculative abstractions slows reviews and risks breaking the Ansible collection. - **Don't assume every successful response is `{"data": ...}`.** Check the docs/go-tfe/spec for each endpoint: some return a JSON:API envelope, some return a bare resource object, `204 No Content`, `null`, raw bytes, or a redirect to a blob URL. Add tests for non-standard shapes. - **Don't use bare `list[...]` annotations inside a resource class after defining `def list(...)`.** In class scope, mypy can resolve `list` to the method instead of the builtin. Use `builtins.list[...]`, `Sequence[...]`, or another unshadowed type. +- **Don't `print()` or use ad-hoc `logging.getLogger(__name__)` calls in library code.** The SDK has a structured logging framework — use `pytfe._logging.transport_logger` for HTTP traffic, or `pytfe._logging.logger` (the `pytfe` root) for higher-level events. Everything from that namespace is silent by default (NullHandler) and respects the user's `setup_logging()` or stdlib configuration. See [LOGGING.md](docs/LOGGING.md) for redaction rules — bearer tokens and `token`/`secret`/`password` keys are auto-redacted by `RoundTrip`, but only inside that formatter. Never `log.info(token)` directly. ## Known cross-dependencies you should not break diff --git a/README.md b/README.md index 2a507001..94728d15 100644 --- a/README.md +++ b/README.md @@ -93,10 +93,73 @@ A couple of things worth knowing: - The iterator is **single-use**. Once you've walked it, iterating again gives you nothing. Capture it with `list(...)` first if you need to reuse the result. - Filters and page size live on the `*ListOptions` model for each resource — e.g. `WorkspaceListOptions(search="prod", page_size=50)`. Pagination still happens transparently; `page_size` only controls how big each underlying API page is. +## Logging + +pyTFE integrates with Python's standard `logging` module and is **silent by default** — nothing is emitted unless you opt in. The library publishes two loggers: + +- `pytfe` — root namespace; rarely emits directly +- `pytfe.transport` — HTTP request/response and retry trace + +### Turn it on with an environment variable + +The quickest way is to set `PYTFE_LOG`: + +```bash +PYTFE_LOG=debug python my_script.py +``` + +`setup_logging()` is invoked automatically on package import, so the env var alone is enough — no code change required. Use the programmatic call only when you need to (re)apply env vars set after import (e.g. in a REPL or test): + +```python +import pytfe +pytfe.setup_logging() +``` + +Levels: `debug` shows every request/response, `info` shows retry decisions only. + +### Sample output + +``` +[2026-05-25 14:12:26 pytfe.transport DEBUG] +> GET /api/v2/organizations/acme/workspaces?page[number]=1&page[size]=100 +< 200 OK +< { +< "data": [ +< { "id": "ws-...", "type": "workspaces", ... } +< ] +< } +``` + +### Safe by default + +Bearer tokens and other credentials are redacted **before** they reach the logger: + +- Sensitive headers (`Authorization`, `Cookie`, anything containing `token` / `secret` / `password` / `api-key`) are replaced with `**REDACTED**`. Headers are off by default; even when you turn them on with `PYTFE_LOG_HEADERS=true`, redaction still applies. +- JSON bodies have sensitive keys (`token`, `access_token`, `refresh_token`, `secret`, `password`, `private_key`, `client_secret`) replaced recursively. +- Large bodies are truncated to `PYTFE_LOG_TRUNCATE_BYTES` (default `1024`). Long arrays are clipped with `"... (N additional elements)"`. +- Binary responses (state-version downloads, configuration-version tarballs, etc.) render as `[raw stream]` — the bytes are never decoded into the log. + +### Compose with your existing logging + +Because pyTFE uses stdlib `logging`, all the standard knobs work: + +```python +import logging + +# Just the HTTP traffic, at DEBUG +logging.getLogger("pytfe.transport").setLevel(logging.DEBUG) + +# Send pyTFE logs to your existing handler instead of stderr +logging.getLogger("pytfe").addHandler(my_json_handler) +``` + +For full details — environment variables, redaction guarantees, and how to add log statements to new SDK code — see [`docs/LOGGING.md`](./docs/LOGGING.md). + ## Documentation - API reference and guides (SDK): **coming soon** - Terraform Enterprise API: https://developer.hashicorp.com/terraform/enterprise/api-docs +- Internal reference: [`docs/ITERATORS.md`](./docs/ITERATORS.md), [`docs/MODELS.md`](./docs/MODELS.md), [`docs/RESOURCE.md`](./docs/RESOURCE.md), [`docs/LOGGING.md`](./docs/LOGGING.md) ## Examples diff --git a/docs/LOGGING.md b/docs/LOGGING.md new file mode 100644 index 00000000..3cb0fcab --- /dev/null +++ b/docs/LOGGING.md @@ -0,0 +1,150 @@ +# Logging in pyTFE + +Internal reference for the SDK's logging framework. Companion to [`ITERATORS.md`](ITERATORS.md), [`MODELS.md`](MODELS.md), [`RESOURCE.md`](RESOURCE.md). + +The framework is designed to be **silent by default** (library best practice — no logs unless the caller opts in), **integrated with stdlib `logging`** (so it composes with the user's existing setup), and **safe** (bearer tokens and other credentials are redacted before they ever reach a handler). + +## The one-line quickstart for users + +```bash +PYTFE_LOG=debug python my_script.py +``` + +`setup_logging()` is invoked automatically when the `pytfe` package is imported, so the env var is the entire user surface — no code change required. Programmatic equivalent (handy in tests or in REPLs where the env var was set after import): + +```python +import pytfe +pytfe.setup_logging() +``` + +`setup_logging()` is idempotent — calling it more than once is safe. + +That's it. Anything between `DEBUG`-level HTTP request/response traces and `INFO`-level retry decisions will show up on stderr with a per-line format like: + +``` +[2026-05-25 14:12:26 pytfe.transport DEBUG] +> GET /api/v2/organizations/acme/workspaces?page[number]=1&page[size]=100 +< 200 OK +< { +< "data": [ +< { "id": "ws-...", "type": "workspaces", ... } +< ] +< } +``` + +## Logger namespace + +| Logger | What it logs | +|---|---| +| `pytfe` | Root namespace; rarely emits directly. Use it to dial **everything** pytfe says up or down at once. | +| `pytfe.transport` | HTTP request/response (DEBUG), retry decisions (INFO), transport exceptions (DEBUG). The noisy one. | + +There is no `pytfe.resource.*` per-service logger. Resource methods do not log; if a caller needs visibility into "the SDK is calling `client.workspaces.read('ws-abc')'" they get it via the transport log right below it. + +Standard stdlib selectors apply: + +```python +import logging +logging.getLogger("pytfe").setLevel(logging.INFO) # everything +logging.getLogger("pytfe.transport").setLevel(logging.DEBUG) # just HTTP +``` + +## Configuration knobs + +The framework has three environment variables. All are optional. + +| Variable | Default | Effect | +|---|---|---| +| `PYTFE_LOG` | unset | `debug` or `info` (case-insensitive) configures stdlib `logging` for you. Anything else is ignored. | +| `PYTFE_LOG_HEADERS` | `false` | When truthy, include request/response headers in `RoundTrip` output. Sensitive ones are still redacted; this just turns on the `> * Header: value` lines at all. | +| `PYTFE_LOG_TRUNCATE_BYTES` | `1024` | Truncation budget for any single string in a logged body. Values below 96 are clamped up. | + +All three are read at call time, not at import — switching `PYTFE_LOG_HEADERS=true` in the middle of a long-running process takes effect on the next request. + +## Redaction guarantees + +The `RoundTrip` formatter (in [`src/pytfe/_logging.py`](../src/pytfe/_logging.py)) redacts before formatting, so the redacted value never reaches the logger: + +**Headers** — replaced with `**REDACTED**` when matched. Names matched case-insensitively: + +``` +authorization +cookie +set-cookie +proxy-authorization +x-tfc-task-signature +``` + +Plus any header whose name contains the substring `token`, `secret`, `password`, `api-key`, or `apikey`. + +**JSON bodies** — when the response body is JSON, these top-level *and nested* keys have their values replaced (case-insensitive key match): + +``` +token, access_token, refresh_token, +secret, password, +private_key, client_secret +``` + +This is structural: a value can be redacted even if it's deep inside an array of nested objects. **String values themselves are not scanned for tokens** — only the keys are matched. If you stuff a bearer token into a field named `"description"`, it will appear in the log. + +The `**REDACTED**` constant is exported from `pytfe._logging` if you ever need to assert on it in a test. + +## Truncation behavior + +Bodies are formatted, not echoed: + +- JSON arrays beyond the budget are clipped with `"... (N additional elements)"`. +- JSON string values longer than the per-string budget are clipped with `"... (N more bytes)"`. +- Non-JSON bodies are shown verbatim (after the same per-string truncation). +- Binary bodies (state-version downloads, CV tarballs, anything with a non-text/non-JSON `Content-Type`) are rendered as `[raw stream]` — the body is not decoded or formatted. + +This keeps a `--list` over a 10,000-workspace organization to one screen of log output instead of 10MB. + +## How the transport uses it + +[`src/pytfe/_http.py`](../src/pytfe/_http.py) emits: + +| Event | Logger | Level | Cost when disabled | +|---|---|---|---| +| Every HTTP request/response round-trip | `pytfe.transport` | DEBUG | Zero — guarded by `isEnabledFor(DEBUG)`. The `RoundTrip` object is only constructed when the level is enabled. | +| Retry decisions (`429`, `5xx`, `Retry-After`) | `pytfe.transport` | INFO | One conditional + format-string evaluation. | +| Transport exceptions during retry loop | `pytfe.transport` | DEBUG | Zero (same guard pattern). | + +There is no DEBUG cost when logging is off, even on 10k-request workloads. + +## How to use it in new SDK code + +If you're adding code under `src/pytfe/`, prefer the framework over `print` or ad-hoc `logging.getLogger(__name__)`: + +```python +# resources/something.py +from .._logging import logger + +def some_operation(self, foo): + if logger.isEnabledFor(logging.INFO): + logger.info("performing some_operation on %s", foo) + ... +``` + +Two rules: + +1. **Always guard non-trivial log argument construction** with `isEnabledFor`. Don't pay format/serialize cost when the level is off. +2. **Never log a token, password, or other secret yourself.** Only `RoundTrip` knows how to redact, and it only redacts what it knows about. If you're tempted to write `logger.info("got token %s", token)` — don't. + +For low-level transport additions, use `transport_logger` (also exported from `pytfe._logging`). + +## What this isn't + +- **Not a metrics framework.** No counters, gauges, timing histograms. If you want metrics, wrap the client. +- **Not an audit log.** Logs are for debugging, not for compliance trails. +- **Not a tracing framework.** No correlation IDs, no OpenTelemetry spans. Standard stdlib `logging` only. +- **Not Ansible-aware.** When the Ansible collection imports pytfe, the `pytfe` logger inherits from Ansible's root logger like any other library — which means it stays silent unless the Ansible user explicitly raises the level. No special integration is needed or provided. + +## Checklist when reviewing log-touching code + +- [ ] New library log calls use `pytfe._logging.logger` or `pytfe._logging.transport_logger`, not `logging.getLogger(__name__)` ad hoc +- [ ] Anything more expensive than a literal format string is guarded with `isEnabledFor(...)` +- [ ] No raw tokens, passwords, or other credentials in any log call +- [ ] If logging a header dict, it goes through `redact_headers(...)` +- [ ] If logging a request/response, it uses `RoundTrip(resp).generate()` so the standard redaction + truncation applies +- [ ] Logger default level is unchanged (i.e. NullHandler still active for callers who don't opt in) diff --git a/src/pytfe/__init__.py b/src/pytfe/__init__.py index 9d518c48..82e9b788 100644 --- a/src/pytfe/__init__.py +++ b/src/pytfe/__init__.py @@ -5,6 +5,7 @@ from importlib.metadata import version as _pkg_version from . import errors, models +from ._logging import setup_logging from .client import TFEClient from .config import TFEConfig @@ -13,4 +14,11 @@ except PackageNotFoundError: # running from a source checkout without install __version__ = "0.0.0+unknown" -__all__ = ["TFEConfig", "TFEClient", "errors", "models", "__version__"] +__all__ = [ + "TFEConfig", + "TFEClient", + "errors", + "models", + "setup_logging", + "__version__", +] diff --git a/src/pytfe/_http.py b/src/pytfe/_http.py index ad1e6fd2..c151694f 100644 --- a/src/pytfe/_http.py +++ b/src/pytfe/_http.py @@ -3,6 +3,7 @@ from __future__ import annotations +import logging import re import time from collections.abc import Mapping @@ -12,6 +13,7 @@ import httpx from ._jsonapi import build_headers, parse_error_payload +from ._logging import RoundTrip, transport_logger from .errors import ( AuthError, NotFound, @@ -87,7 +89,6 @@ def request( if headers: hdrs.update(headers) attempt = 0 - # print(method, url, params, json_body, hdrs) while True: try: resp = self._sync.request( @@ -100,6 +101,13 @@ def request( follow_redirects=allow_redirects, ) except httpx.HTTPError as e: + transport_logger.debug( + "transport exception on %s %s (attempt %d): %s", + method, + url, + attempt, + e, + ) if attempt >= self.max_retries: raise ServerError(str(e)) from e self._sleep(attempt, None) @@ -107,6 +115,14 @@ def request( continue if resp.status_code in _RETRY_STATUSES and attempt < self.max_retries: retry_after = _parse_retry_after(resp) + transport_logger.info( + "retrying %s %s after %s (status=%d, attempt=%d)", + method, + url, + f"{retry_after:.2f}s" if retry_after else "backoff", + resp.status_code, + attempt, + ) self._sleep(attempt, retry_after) attempt += 1 continue @@ -114,10 +130,31 @@ def request( # surface 3xx responses to them (so they can read Location) # rather than treating them as errors. if not allow_redirects and 300 <= resp.status_code < 400: + self._log_round_trip(resp) return resp + self._log_round_trip(resp) self._raise_if_error(resp) return resp + def _log_round_trip(self, resp: httpx.Response) -> None: + """Emit a DEBUG-level request/response trace when enabled. + + Cheap when disabled: ``isEnabledFor(DEBUG)`` short-circuits before any + body decoding or JSON parsing happens. + """ + if not transport_logger.isEnabledFor(logging.DEBUG): + return + # Treat binary content types as raw streams so we don't try to JSON + # parse a state-version download or a CV tarball. + ct = (resp.headers.get("content-type") or "").lower() + raw = not ( + "json" in ct + or ct.startswith("text/") + or ct == "" + or "application/vnd.api+json" in ct + ) + transport_logger.debug("\n%s", RoundTrip(resp, raw=raw).generate()) + def _sleep(self, attempt: int, retry_after: float | None) -> None: if retry_after is not None: time.sleep(retry_after) diff --git a/src/pytfe/_logging.py b/src/pytfe/_logging.py new file mode 100644 index 00000000..98297101 --- /dev/null +++ b/src/pytfe/_logging.py @@ -0,0 +1,317 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Logging primitives for the pytfe SDK. + +Design notes +------------ +The SDK integrates with Python's standard ``logging`` module. By default the +``pytfe`` logger has a ``NullHandler`` attached, so the library is silent +unless the caller opts in — either by configuring ``logging`` themselves +or by calling :func:`setup_logging`, which honours the ``PYTFE_LOG`` +environment variable (``debug`` or ``info``). + +Logger namespace +~~~~~~~~~~~~~~~~ + + * ``pytfe`` — root namespace; rarely emits directly + * ``pytfe.transport`` — HTTP transport request/response/retry trace + +Anything sensitive (the ``Authorization`` bearer token, any header that +looks like a credential, common JSON keys such as ``token`` / ``password`` +/ ``secret``) is replaced with ``**REDACTED**`` before being handed to the +logger. Bodies are truncated to ``PYTFE_LOG_TRUNCATE_BYTES`` (default +``1024``) so DEBUG-level traffic doesn't fill a TTY when listing 10,000 +workspaces. + +""" + +from __future__ import annotations + +import json +import logging +import os +from collections.abc import Mapping +from typing import Any + +import httpx + +__all__ = [ + "logger", + "transport_logger", + "setup_logging", + "RoundTrip", + "redact_headers", + "REDACTED", +] + +REDACTED = "**REDACTED**" + +# Per-namespace loggers. Library code uses these directly; users wire +# handlers/levels onto them. +logger: logging.Logger = logging.getLogger("pytfe") +transport_logger: logging.Logger = logging.getLogger("pytfe.transport") + +# Library best practice: install a NullHandler so the absence of caller +# configuration doesn't trigger "No handlers could be found" warnings or +# bubble logs up to the root logger. +if not any(isinstance(h, logging.NullHandler) for h in logger.handlers): + logger.addHandler(logging.NullHandler()) + + +# Header names that should never appear in plain text. Matched +# case-insensitively against incoming/outgoing headers. +_SENSITIVE_HEADER_NAMES = frozenset( + { + "authorization", + "cookie", + "set-cookie", + "proxy-authorization", + "x-tfc-task-signature", + } +) + +# Header-name substrings that imply sensitivity even when not in the +# explicit list above (third-party run-task webhooks, custom signing +# headers, etc.). +_SENSITIVE_HEADER_SUBSTRINGS = ("token", "secret", "password", "api-key", "apikey") + +# JSON keys whose values are redacted recursively in body dumps. +_SENSITIVE_JSON_KEYS = frozenset( + { + "token", + "access_token", + "refresh_token", + "secret", + "password", + "private_key", + "client_secret", + } +) + + +def _env_int(name: str, default: int) -> int: + raw = os.environ.get(name) + if not raw: + return default + try: + return max(int(raw), 96) + except ValueError: + return default + + +def _env_bool(name: str, default: bool) -> bool: + raw = os.environ.get(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _is_sensitive_header(name: str) -> bool: + n = name.lower() + if n in _SENSITIVE_HEADER_NAMES: + return True + return any(s in n for s in _SENSITIVE_HEADER_SUBSTRINGS) + + +def redact_headers(headers: Mapping[str, str]) -> dict[str, str]: + """Return a copy of ``headers`` with sensitive values replaced.""" + return {k: (REDACTED if _is_sensitive_header(k) else v) for k, v in headers.items()} + + +def setup_logging() -> None: + """Convenience configurator driven by environment variables. + + Honours: + + * ``PYTFE_LOG`` — ``debug`` or ``info`` (case-insensitive). + Anything else is ignored. + * ``PYTFE_LOG_HTTPX`` — if truthy, also raise ``httpx`` to the + same level so low-level connection + activity is visible. + + Calls ``logging.basicConfig`` with a one-line format if no handlers are + already configured on the root logger. Idempotent and safe to call + multiple times. + """ + env = (os.environ.get("PYTFE_LOG") or "").strip().lower() + if env not in {"debug", "info"}: + return + + level = logging.DEBUG if env == "debug" else logging.INFO + + # Only configure handlers if the root logger has none — don't fight + # callers who've already set up their own logging. + if not logging.getLogger().handlers: + logging.basicConfig( + format="[%(asctime)s %(name)s %(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + logger.setLevel(level) + + if _env_bool("PYTFE_LOG_HTTPX", default=False): + logging.getLogger("httpx").setLevel(level) + + +class RoundTrip: + """Format an httpx request/response pair for debug logging. + + Parameters + ---------- + response: + The ``httpx.Response`` returned by the transport. Its ``request`` + attribute supplies the outbound side. + debug_headers: + When ``True``, include request/response headers in the formatted + output. Defaults to the value of ``PYTFE_LOG_HEADERS`` (false). + debug_truncate_bytes: + Per-string truncation budget. Defaults to ``PYTFE_LOG_TRUNCATE_BYTES`` + (``1024``). Values below ``96`` are clamped up. + raw: + When ``True``, mark the bodies as ``[raw stream]`` and skip body + formatting. Use this for binary content (state-version downloads, + configuration-version tarballs, etc.). + """ + + def __init__( + self, + response: httpx.Response, + *, + debug_headers: bool | None = None, + debug_truncate_bytes: int | None = None, + raw: bool = False, + ) -> None: + self._response = response + self._raw = raw + self._debug_headers = ( + debug_headers + if debug_headers is not None + else _env_bool("PYTFE_LOG_HEADERS", default=False) + ) + self._debug_truncate_bytes = max( + debug_truncate_bytes + if debug_truncate_bytes is not None + else _env_int("PYTFE_LOG_TRUNCATE_BYTES", 1024), + 96, + ) + + # ------------------------------------------------------------------ + # Public formatting + # ------------------------------------------------------------------ + + def generate(self) -> str: + request = self._response.request + # httpx.URL has .path / .query (bytes) — render in a way matching + # what the wire saw, but with query unquoted for human reading. + from urllib.parse import unquote, urlparse + + url = urlparse(str(request.url)) + query = f"?{unquote(url.query)}" if url.query else "" + path = unquote(url.path) or "/" + + sb: list[str] = [f"> {request.method} {path}{query}"] + + if self._debug_headers: + for k, v in redact_headers(dict(request.headers)).items(): + sb.append(f"> * {k}: {self._only_n_bytes(v)}") + + if self._raw and request.content: + sb.append("> [raw stream]") + elif request.content: + sb.append(self._redacted_dump("> ", request.content)) + + sb.append( + f"< {self._response.status_code} {self._response.reason_phrase or ''}".rstrip() + ) + + if self._debug_headers: + for k, v in redact_headers(dict(self._response.headers)).items(): + sb.append(f"< * {k}: {self._only_n_bytes(v)}") + + if self._raw: + sb.append("< [raw stream]") + else: + try: + content = self._response.content + except Exception: + content = b"" + if content: + sb.append(self._redacted_dump("< ", content)) + + return "\n".join(sb) + + def __str__(self) -> str: # pragma: no cover - trivial + return self.generate() + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _only_n_bytes(self, s: str) -> str: + encoded = s.encode("utf-8", errors="replace") + if len(encoded) <= self._debug_truncate_bytes: + return s + truncated = encoded[: self._debug_truncate_bytes].decode( + "utf-8", errors="replace" + ) + return ( + f"{truncated}... ({len(encoded) - self._debug_truncate_bytes} more bytes)" + ) + + def _redacted_dump(self, prefix: str, body: bytes | str) -> str: + if isinstance(body, bytes): + try: + body = body.decode("utf-8") + except UnicodeDecodeError: + body = repr(body[:64]) + " ...binary..." + if not body: + return "" + try: + parsed = json.loads(body) + except (json.JSONDecodeError, TypeError): + return "\n".join( + f"{prefix}{line}" for line in self._only_n_bytes(body).splitlines() + ) + marshalled = self._recursive_marshal(parsed, self._debug_truncate_bytes) + rendered = json.dumps(marshalled, indent=2, sort_keys=True) + return "\n".join(f"{prefix}{line}" for line in rendered.splitlines()) + + def _recursive_marshal(self, v: Any, budget: int) -> Any: + if isinstance(v, dict): + out: dict[str, Any] = {} + for k in sorted(v.keys()): + if isinstance(k, str) and k.lower() in _SENSITIVE_JSON_KEYS: + out[k] = REDACTED + continue + marshalled = self._recursive_marshal(v[k], budget) + out[k] = marshalled + budget -= len(str(marshalled)) + return out + if isinstance(v, list): + out_list: list[Any] = [] + for i, item in enumerate(v): + if i > 0 and budget <= 0: + out_list.append( + f"... ({len(v) - len(out_list)} additional elements)" + ) + break + marshalled = self._recursive_marshal(item, budget) + out_list.append(marshalled) + budget -= len(str(marshalled)) + return out_list + if isinstance(v, str): + return self._only_n_bytes(v) + return v + + +# Auto-apply environment configuration at import. This is what makes +# ``PYTFE_LOG=debug python my_script.py`` work without the caller adding +# any code. Safe by design: +# +# * No-op unless ``PYTFE_LOG`` is set to ``debug`` or ``info``. +# * Only calls ``logging.basicConfig`` if the root logger has no +# handlers, so existing caller configuration is preserved. +# * Only sets the level on the ``pytfe`` namespace; the root logger +# and other libraries are untouched. +setup_logging() diff --git a/tests/units/test_logging.py b/tests/units/test_logging.py new file mode 100644 index 00000000..b66cd19b --- /dev/null +++ b/tests/units/test_logging.py @@ -0,0 +1,372 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the pytfe._logging framework.""" + +from __future__ import annotations + +import json +import logging +from unittest.mock import patch + +import httpx +import pytest + +from pytfe._logging import ( + REDACTED, + RoundTrip, + logger, + redact_headers, + setup_logging, + transport_logger, +) + + +def _make_response( + *, + method: str = "GET", + url: str = "https://app.terraform.io/api/v2/organizations/acme/workspaces", + request_headers: dict[str, str] | None = None, + request_content: bytes | None = None, + status: int = 200, + response_headers: dict[str, str] | None = None, + response_content: bytes = b"", +) -> httpx.Response: + req = httpx.Request( + method, url, headers=request_headers or {}, content=request_content + ) + return httpx.Response( + status_code=status, + headers=response_headers or {"content-type": "application/vnd.api+json"}, + content=response_content, + request=req, + ) + + +class TestNamespace: + def test_logger_is_named_pytfe(self): + assert logger.name == "pytfe" + assert transport_logger.name == "pytfe.transport" + # transport_logger inherits from the pytfe root. + assert transport_logger.parent is logger + + def test_null_handler_attached_by_default(self): + """Library must not emit anything until the caller opts in.""" + assert any(isinstance(h, logging.NullHandler) for h in logger.handlers), ( + "the pytfe logger must ship with a NullHandler so library use does " + "not trigger 'No handlers could be found' or bleed into the root logger" + ) + + +class TestRedactHeaders: + @pytest.mark.parametrize( + "header_name", + [ + "Authorization", + "authorization", + "Cookie", + "Set-Cookie", + "Proxy-Authorization", + "X-Tfc-Task-Signature", + "X-Some-Token", + "X-API-Key", + "X-MY-PASSWORD-header", + "x-secret-thing", + ], + ) + def test_redacts_known_sensitive_headers(self, header_name): + out = redact_headers({header_name: "supersecret"}) + assert out[header_name] == REDACTED + + @pytest.mark.parametrize( + "header_name", + ["Content-Type", "Accept", "User-Agent", "X-Request-Id"], + ) + def test_does_not_redact_normal_headers(self, header_name): + out = redact_headers({header_name: "demo"}) + assert out[header_name] == "demo" + + +class TestSetupLogging: + @pytest.fixture(autouse=True) + def _reset_logger(self): + # Each test starts with a clean level for the pytfe logger. + original = logger.level + yield + logger.setLevel(original) + + def test_no_env_no_change(self, monkeypatch): + """Calling setup_logging with no env var must be a no-op.""" + monkeypatch.delenv("PYTFE_LOG", raising=False) + logger.setLevel(logging.WARNING) + setup_logging() + assert logger.level == logging.WARNING + + def test_pytfe_log_debug_sets_debug(self, monkeypatch): + monkeypatch.setenv("PYTFE_LOG", "debug") + setup_logging() + assert logger.level == logging.DEBUG + + def test_pytfe_log_info_sets_info(self, monkeypatch): + monkeypatch.setenv("PYTFE_LOG", "info") + setup_logging() + assert logger.level == logging.INFO + + def test_pytfe_log_garbage_is_ignored(self, monkeypatch): + monkeypatch.setenv("PYTFE_LOG", "verbose-please") + logger.setLevel(logging.WARNING) + setup_logging() + assert logger.level == logging.WARNING + + def test_env_var_alone_activates_logging_at_import(self): + """``PYTFE_LOG=debug python script.py`` must work without the script + calling ``setup_logging()`` explicitly. Verified by spawning a fresh + Python with the env var and looking at stderr. + """ + import subprocess + import sys + + script = ( + "import logging\n" + "from pytfe._logging import logger\n" + # If auto-invoke at import worked, logger.level is DEBUG. + "print('LEVEL', logging.getLevelName(logger.level))\n" + ) + result = subprocess.run( + [sys.executable, "-c", script], + env={"PYTFE_LOG": "debug", "PATH": "/usr/bin:/bin"}, + capture_output=True, + text=True, + check=True, + ) + assert "LEVEL DEBUG" in result.stdout, ( + f"expected logger.level==DEBUG after import with PYTFE_LOG=debug;" + f" stdout={result.stdout!r} stderr={result.stderr!r}" + ) + + def test_pytfe_log_httpx_lifts_httpx_logger(self, monkeypatch): + monkeypatch.setenv("PYTFE_LOG", "info") + monkeypatch.setenv("PYTFE_LOG_HTTPX", "true") + httpx_logger = logging.getLogger("httpx") + original = httpx_logger.level + try: + setup_logging() + assert httpx_logger.level == logging.INFO + finally: + httpx_logger.setLevel(original) + + +class TestRoundTripBasics: + def test_request_and_response_lines(self): + resp = _make_response( + response_content=b'{"data": [{"id": "ws-1", "type": "workspaces"}]}' + ) + out = RoundTrip(resp).generate() + # Request prefix and response prefix. + assert out.startswith("> GET /api/v2/organizations/acme/workspaces") + assert "< 200 OK" in out + + def test_headers_hidden_by_default(self): + resp = _make_response( + request_headers={"Authorization": "Bearer s3cr3t", "Accept": "*/*"}, + response_content=b"{}", + ) + out = RoundTrip(resp).generate() + # No header lines at all unless debug_headers=True. + assert "Accept" not in out + assert "Authorization" not in out + + def test_headers_when_enabled_are_redacted(self): + resp = _make_response( + request_headers={ + "Authorization": "Bearer the-actual-token-please-redact", + "User-Agent": "pytfe/test", + }, + response_content=b"{}", + ) + out = RoundTrip(resp, debug_headers=True).generate() + # Auth value never appears in the log. + assert "the-actual-token-please-redact" not in out + assert REDACTED in out + # Non-sensitive header is fine. httpx lowercases header names. + assert "user-agent: pytfe/test" in out.lower() + + +class TestRoundTripBodyRedaction: + def test_json_body_redacts_sensitive_keys(self): + body = json.dumps( + { + "data": { + "type": "team-tokens", + "attributes": { + "token": "super-secret-token-value", + "description": "harmless", + }, + } + } + ).encode() + resp = _make_response(response_content=body) + out = RoundTrip(resp).generate() + assert "super-secret-token-value" not in out + assert REDACTED in out + assert '"description"' in out # non-sensitive keys still present + + def test_nested_sensitive_key_is_redacted(self): + body = json.dumps( + { + "data": [ + { + "attributes": { + "secret": "nested-secret", + "name": "team-1", + } + } + ] + } + ).encode() + out = RoundTrip(_make_response(response_content=body)).generate() + assert "nested-secret" not in out + assert REDACTED in out + + def test_non_json_body_is_logged_verbatim_after_truncation(self): + resp = _make_response( + response_headers={"content-type": "text/csv"}, + response_content=b"workspace_name,id\nfoo,ws-1\n", + ) + out = RoundTrip(resp).generate() + assert "workspace_name,id" in out + assert "foo,ws-1" in out + + +class TestRoundTripTruncation: + def test_long_string_in_json_is_truncated(self): + big = "x" * 5000 + body = json.dumps({"description": big}).encode() + out = RoundTrip( + _make_response(response_content=body), debug_truncate_bytes=200 + ).generate() + assert "more bytes" in out + # Original full payload must NOT survive. + assert "x" * 5000 not in out + + def test_long_array_is_clipped(self): + items = [{"i": i, "v": "x" * 50} for i in range(500)] + body = json.dumps(items).encode() + out = RoundTrip( + _make_response(response_content=body), debug_truncate_bytes=200 + ).generate() + assert "additional elements" in out + + def test_raw_body_marked_as_stream(self): + # state-version download style — binary content + resp = _make_response( + response_headers={"content-type": "application/octet-stream"}, + response_content=b"\x00\x01\x02" * 1000, + ) + out = RoundTrip(resp, raw=True).generate() + assert "[raw stream]" in out + assert "\x00\x01\x02" not in out + + +class TestTransportIntegration: + """End-to-end: the HTTPTransport must emit one DEBUG round-trip per request + when the pytfe.transport logger is at DEBUG, and zero log records when off.""" + + def _make_transport(self, handler): + from pytfe._http import HTTPTransport + + t = HTTPTransport( + address="https://app.terraform.io", + token="bearer-token-do-not-log", + timeout=5, + verify_tls=True, + user_agent_suffix=None, + max_retries=0, + backoff_base=0, + backoff_cap=0, + backoff_jitter=False, + http2=False, + proxies=None, + ca_bundle=None, + ) + t._sync = httpx.Client(transport=httpx.MockTransport(handler)) + return t + + def test_no_logs_at_default_level(self, caplog): + """With logging at WARNING (default), the transport must say nothing.""" + + def handler(request): + return httpx.Response(200, json={"data": []}) + + t = self._make_transport(handler) + # Ensure default level + with patch.object(transport_logger, "level", logging.NOTSET): + transport_logger.setLevel(logging.WARNING) + with caplog.at_level(logging.WARNING, logger="pytfe.transport"): + t.request("GET", "/api/v2/organizations/acme/workspaces") + assert caplog.records == [] + + def test_debug_emits_round_trip(self, caplog): + """At DEBUG, exactly one round-trip log record per request.""" + + def handler(request): + return httpx.Response( + 200, + json={"data": [{"id": "ws-1", "type": "workspaces"}]}, + headers={"content-type": "application/vnd.api+json"}, + ) + + t = self._make_transport(handler) + original = transport_logger.level + try: + with caplog.at_level(logging.DEBUG, logger="pytfe.transport"): + t.request("GET", "/api/v2/organizations/acme/workspaces") + assert len(caplog.records) == 1 + msg = caplog.records[0].getMessage() + assert "GET /api/v2/organizations/acme/workspaces" in msg + assert "< 200" in msg + assert '"id"' in msg or "ws-1" in msg + # And critically — the bearer token does NOT appear in the formatted + # output because headers are off by default. + assert "bearer-token-do-not-log" not in msg + finally: + transport_logger.setLevel(original) + + def test_retry_logs_at_info(self, caplog): + """5xx that triggers a retry must produce an INFO line.""" + call_count = {"n": 0} + + def handler(request): + call_count["n"] += 1 + if call_count["n"] == 1: + return httpx.Response(503) + return httpx.Response(200, json={"data": []}) + + from pytfe._http import HTTPTransport + + t = HTTPTransport( + address="https://app.terraform.io", + token="x", + timeout=5, + verify_tls=True, + user_agent_suffix=None, + max_retries=2, + backoff_base=0, + backoff_cap=0, + backoff_jitter=False, + http2=False, + proxies=None, + ca_bundle=None, + ) + t._sync = httpx.Client(transport=httpx.MockTransport(handler)) + + original = transport_logger.level + try: + with caplog.at_level(logging.INFO, logger="pytfe.transport"): + t.request("GET", "/api/v2/organizations/acme/workspaces") + info_records = [r for r in caplog.records if r.levelno == logging.INFO] + assert any("retrying" in r.getMessage() for r in info_records), ( + "expected the transport to emit an INFO retry decision on 503" + ) + finally: + transport_logger.setLevel(original) From 02c6b52a92fcfac44a11585d0ba50542def07157 Mon Sep 17 00:00:00 2001 From: Nimisha Shrivastava Date: Mon, 25 May 2026 14:45:53 +0530 Subject: [PATCH 86/95] fixed import issues --- src/pytfe/models/__init__.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index a75e978c..0c91210c 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -134,6 +134,13 @@ OrganizationMembershipStatus, OrgMembershipIncludeOpt, ) +from .organization_token import ( + OrganizationToken, + OrganizationTokenCreateOptions, + OrganizationTokenDeleteOptions, + OrganizationTokenReadOptions, + TokenType, +) from .policy import ( Policy, PolicyCreateOptions, @@ -647,6 +654,12 @@ "OrganizationMembershipReadOptions", "OrganizationMembershipStatus", "OrgMembershipIncludeOpt", + # Organization Tokens + "OrganizationToken", + "OrganizationTokenCreateOptions", + "OrganizationTokenDeleteOptions", + "OrganizationTokenReadOptions", + "TokenType", "OrganizationAccess", "Team", "TeamPermissions", From 5d7cec1934e571153d7c8c278a8f3f23836f4c91 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Mon, 25 May 2026 15:05:54 +0530 Subject: [PATCH 87/95] Updated Changelog --- CHANGELOG.md | 94 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad0ee002..68f7768c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,100 @@ ## Features +### Teams +* Added Teams resource with full CRUD operations (list, create, read, update, delete) by @isivaselvan [#118](https://github.com/hashicorp/python-tfe/pull/118) +* Added add_users, remove_users, add_organization_memberships, remove_organization_memberships, list_users and list_organization_memberships methods by @iam404 [#171](https://github.com/hashicorp/python-tfe/pull/168) + +### Team Project Access +* Added Team Project Access resource with list, add, read, update, and remove methods by @isivaselvan [#127](https://github.com/hashicorp/python-tfe/pull/127) + +### Stacks +* Added Stack resource with create, update, list, read, delete, force_delete and fetch_latest_from_vcs methods by @isivaselvan [#128](https://github.com/hashicorp/python-tfe/pull/128) + ### Explorer API -* Added Explorer resource support with query, CSV export, saved view CRUD, saved view result query, and saved view CSV export endpoints. +* Added Explorer resource support with query, CSV export, list saved view, create saced view, read saved view, update saved view, delete saved view, saved view result query, and saved view CSV export endpoints by @jasodeep [#136](https://github.com/hashicorp/python-tfe/pull/136) + +### Organization Tokens +* Added Organization Token resource with full Create, read, delete and create/read/delete with options operations by @NimishaShrivastava-dev [#141](https://github.com/hashicorp/python-tfe/pull/141) + +### Users +* Added User resource with read, read_current, and update_current methods by @TanyaSingh369-svg [#144](https://github.com/hashicorp/python-tfe/pull/144) + +### Registry Provider Platform +* Added Registry Provider Platform resource with create, list, read, and delete methods by @isivaselvan [#145](https://github.com/hashicorp/python-tfe/pull/145) + +### Organization Tags +* Added Organization Tags resource with list, add_workspaces, and delete methods by @NimishaShrivastava-dev [#146](https://github.com/hashicorp/python-tfe/pull/146) + +### Stack Configuration +* Added Stack Configuration resource with create, list, and read methods by @isivaselvan [#147](https://github.com/hashicorp/python-tfe/pull/147) + +### Organization Audit Configurations +* Added Organization Audit Configuration resource with list, read and update support by @NimishaShrivastava-dev [#154](https://github.com/hashicorp/python-tfe/pull/154) + +### Comments +* Added Comment resource with list, read, and create methods by @isivaselvan [#155](https://github.com/hashicorp/python-tfe/pull/155) + +### Task Result +* Added Task Result resource with read method, typed models, and unit tests by @TanyaSingh369-svg [#156](https://github.com/hashicorp/python-tfe/pull/156) + +### Team Tokens +* Added Team Token resource with list, read, create, and delete methods supporting both legacy and new multi-team token APIs by @isivaselvan [#157](https://github.com/hashicorp/python-tfe/pull/157) + +### Run Task Integration +* Added Run Task Integration resource with callback support for sending run task results back to Terraform, including callback payload models and webhook server example by @TanyaSingh369-svg [#160](https://github.com/hashicorp/python-tfe/pull/160) + +### State Version Upload +* Added state version upload support with presigned URL flow and improved examples by @NimishaShrivastava-dev [#163](https://github.com/hashicorp/python-tfe/pull/163) + +### Workspace Run Task +* Added Workspace Run Task resource CRUD operation with models for managing run tasks associated with a workspace by @isivaselvan [#164](https://github.com/hashicorp/python-tfe/pull/164) + +### Task Stage +* Added Task Stage resource and models for interacting with run task stages by @isivaselvan [#165](https://github.com/hashicorp/python-tfe/pull/165) + +### Team Workspace Access +* Added Team Workspace Access resource list, read, add, update and remove methods along with models and examples for managing team access to workspaces by @iam404 [#168](https://github.com/hashicorp/python-tfe/pull/168) + +## Enhancements + +### Terraform Actions +* Added invoke action address field to Run and RunCreateOptions models to support Terraform action invocations by @isivaselvan [#158](https://github.com/hashicorp/python-tfe/pull/158) + +### Agent Pool +* Updated Agent Pool models to include project_ids and workspace_ids (allowed and excluded) fields by @isivaselvan [#166](https://github.com/hashicorp/python-tfe/pull/166) +* Added assign_to_project method to the Agent Pool resource for associating agent pools with projects by @isivaselvan [#166](https://github.com/hashicorp/python-tfe/pull/166) +* Added typed agent pool error classes (InvalidAgentPoolIDError and related) by @isivaselvan [#166](https://github.com/hashicorp/python-tfe/pull/166) +* Updated AgentPoolListOptions with new filter parameters for list method by @isivaselvan [#166](https://github.com/hashicorp/python-tfe/pull/166) + +### Existing Resource Improvements +* Updated Apply resource with errored_state method which uses additional read endpoints and log download support by @iam404 [#168](https://github.com/hashicorp/python-tfe/pull/168) +* Updated Configuration Version resource with ingress_attributes method which uses additional endpoints for uploaded configuration handling by @iam404 [#168](https://github.com/hashicorp/python-tfe/pull/168) +* Updated Plan resource with additional read_for_run, read_json_output_for_run, read_json_schema_for_run and follow_json_output_redirect methods by @iam404 [#168](https://github.com/hashicorp/python-tfe/pull/168) +* Updated Policy Set resource with new add_project_exclusions, remove_project_exclusions methods to support project exclusions by @iam404 [#168](https://github.com/hashicorp/python-tfe/pull/168) +* Updated Projects resource with new move_workspaces (into project) method iterator conversion of list_effective_tag_bindings operations by @iam404 [#168](https://github.com/hashicorp/python-tfe/pull/168) +* Updated Registry Module resource with iterator conversion of list_version method by @iam404 [#168](https://github.com/hashicorp/python-tfe/pull/168) +* Updated State Version resource with new rollback method by @iam404 [#168](https://github.com/hashicorp/python-tfe/pull/168) +* Updated Workspaces resource with additional current_assessment_result and list_applicable_varsets methods by @iam404 [#168](https://github.com/hashicorp/python-tfe/pull/168) + +### SDK Logging +* Added pytfe._logging module with structured stdlib-based logging framework by @iam404 [#171](https://github.com/hashicorp/python-tfe/pull/171) +* Added setup_logging() function to configure the pytfe logger namespace with optional level and format control by @iam404 [#171](https://github.com/hashicorp/python-tfe/pull/171) +* Added HTTP transport tracing via RoundTrip formatter with request/response logging, header/body redaction, and configurable truncation by @iam404 [#171](https://github.com/hashicorp/python-tfe/pull/171) +* Added PYTFE_LOG, PYTFE_LOG_HEADERS, and PYTFE_LOG_TRUNCATE_BYTES environment variables for runtime log configuration by @iam404 [#171](https://github.com/hashicorp/python-tfe/pull/171) + +## Breaking Changes + +### Agent Pool +* Removed allowed_workspace_policy attribute from Agent Pool models and methods by @isivaselvan [#166](https://github.com/hashicorp/python-tfe/pull/166) +* Updated AgentPool relationship model structure — consumers referencing the old relationship fields must update to the new project_ids/workspace_ids shape by @isivaselvan [#166](https://github.com/hashicorp/python-tfe/pull/166) + +## Bug Fixes +* Fixed bearer token not being forwarded during state version upload to the presigned URL, which caused upload failures by @iam404 [#168](https://github.com/hashicorp/python-tfe/pull/168) +* Fixed Explorer API query handling and optimized response parsing by @iam404 [#170](https://github.com/hashicorp/python-tfe/pull/170) +* Fixed typos and moved RunStage enum to avoid naming conflict with the new TaskStage resource by @iam404 [#167](https://github.com/hashicorp/python-tfe/pull/167) +* Fixed task result relationships to map into typed SDK models instead of raw JSON by @TanyaSingh369-svg [#156](https://github.com/hashicorp/python-tfe/pull/156) +* Fixed task stage relationship mapping in the task result resource by @TanyaSingh369-svg [#156](https://github.com/hashicorp/python-tfe/pull/156) # v0.1.5 From 6c2497a9ea09732a40ed7d6b1921f6e6bbdedac9 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Mon, 25 May 2026 18:23:05 +0530 Subject: [PATCH 88/95] upd changelog --- CHANGELOG.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68f7768c..4bc5d914 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,9 +95,6 @@ * Updated AgentPool relationship model structure — consumers referencing the old relationship fields must update to the new project_ids/workspace_ids shape by @isivaselvan [#166](https://github.com/hashicorp/python-tfe/pull/166) ## Bug Fixes -* Fixed bearer token not being forwarded during state version upload to the presigned URL, which caused upload failures by @iam404 [#168](https://github.com/hashicorp/python-tfe/pull/168) -* Fixed Explorer API query handling and optimized response parsing by @iam404 [#170](https://github.com/hashicorp/python-tfe/pull/170) -* Fixed typos and moved RunStage enum to avoid naming conflict with the new TaskStage resource by @iam404 [#167](https://github.com/hashicorp/python-tfe/pull/167) * Fixed task result relationships to map into typed SDK models instead of raw JSON by @TanyaSingh369-svg [#156](https://github.com/hashicorp/python-tfe/pull/156) * Fixed task stage relationship mapping in the task result resource by @TanyaSingh369-svg [#156](https://github.com/hashicorp/python-tfe/pull/156) From 457d27e240cf28774ec0a8d14c7ba95224ce5324 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Tue, 26 May 2026 16:08:14 +0530 Subject: [PATCH 89/95] Add release workflow to pypi --- .github/workflows/release.yml | 175 ++++++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..f14857a1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,175 @@ +name: release + +# Publishes the package to PyPI / TestPyPI via manual dispatch only. +# +# Flow: +# 1. Cut and push a git tag (e.g. `v1.0.0`) on a commit merged into main. +# 2. Actions → release → Run workflow → enter the tag, choose target=testpypi. +# 3. Verify the TestPyPI release looks correct. +# 4. Re-run the workflow with the same tag and target=pypi. +# + + +on: + workflow_dispatch: + inputs: + target: + description: "Where to publish" + required: true + default: testpypi + type: choice + options: [testpypi, pypi] + tag: + description: "Git tag to publish (e.g. v1.0.0). Must exist on origin and be reachable from main." + required: true + +permissions: + contents: read + +jobs: + # --------------------------------------------------------------------------- + # 1. Validate that the inputs are sane and the source commit is releasable. + # - tag input is a bare name (no path separators / refspec tricks) + # - the ref we end up on is genuinely a git tag, not a branch with + # the same name (resolved via refs/tags/$TAG) + # - the tag commit is reachable from origin/main (caught even when + # pyproject version was bumped on an un-merged branch) + # - tag string matches pyproject.toml's version + # --------------------------------------------------------------------------- + validate: + name: Validate release source + runs-on: ubuntu-latest + outputs: + tag-ref: ${{ steps.resolve.outputs.tag-ref }} + steps: + - name: Sanitise and qualify the tag input + id: resolve + env: + TAG_INPUT: ${{ inputs.tag }} + run: | + set -euo pipefail + # Reject inputs that look like refspecs, paths, or anything other + # than a bare tag name. Stops attempts like 'heads/main' or + # '../../etc/passwd' or 'refs/tags/v1.0.0' (which would be + # double-prefixed below). + if [[ "$TAG_INPUT" != "$(printf '%s' "$TAG_INPUT" | tr -d '\n')" ]]; then + echo "::error::tag input must not contain newlines."; exit 1 + fi + case "$TAG_INPUT" in + ""|*/*|*..*|.*|*' '*) echo "::error::tag input '$TAG_INPUT' must be a bare git tag name (no '/', '..', leading '.', or whitespace)."; exit 1 ;; + esac + echo "tag-ref=refs/tags/$TAG_INPUT" >> "$GITHUB_OUTPUT" + + - name: Checkout the fully-qualified tag ref + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 + with: + # ref must be 'refs/tags/' (not just '') so a branch + # with the same name cannot win git's dwim resolution. + ref: ${{ steps.resolve.outputs.tag-ref }} + fetch-depth: 0 + + - name: Verify HEAD actually points at the requested tag + env: + TAG_INPUT: ${{ inputs.tag }} + run: | + set -euo pipefail + # Belt-and-braces: even though we asked for refs/tags/$TAG, + # confirm git resolved it to a real tag pointing at HEAD. + if ! git tag --points-at HEAD | grep -Fxq "$TAG_INPUT"; then + echo "::error::HEAD does not match tag '$TAG_INPUT'. Checkout resolved to a different ref." + exit 1 + fi + echo "HEAD is exactly tag '$TAG_INPUT' (commit $(git rev-parse --short HEAD))" + + - name: Verify HEAD is reachable from origin/main + run: | + set -euo pipefail + git fetch --no-tags origin main:origin-main + if ! git merge-base --is-ancestor HEAD origin-main; then + echo "::error::Tag commit is not reachable from origin/main." + echo "::error::This catches tags placed on branches that were never merged, and tags whose commits were later force-pushed off main." + exit 1 + fi + echo "tag commit is on main — OK" + + - name: Set up Python + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c + with: + python-version: "3.12" + + - name: Verify tag matches pyproject.toml version + env: + TAG_INPUT: ${{ inputs.tag }} + run: | + set -euo pipefail + py_ver=$(python -c "import tomllib, pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_bytes())['project']['version'])") + if [[ "$TAG_INPUT" != "v${py_ver}" && "$TAG_INPUT" != "$py_ver" ]]; then + echo "::error::Tag '$TAG_INPUT' does not match pyproject.toml version '$py_ver' (expected 'v$py_ver' or '$py_ver')." + exit 1 + fi + echo "tag '$TAG_INPUT' matches pyproject.toml version '$py_ver'" + + # --------------------------------------------------------------------------- + # 2. Run lint + tests across every supported Python version as a release + # gate. Catches "tag exists but main is broken on 3.10" scenarios even + # if branch protection didn't catch it. + # --------------------------------------------------------------------------- + test: + name: Test (py${{ matrix.python-version }}) + needs: validate + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 + with: + ref: ${{ needs.validate.outputs.tag-ref }} + + - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c + with: + python-version: ${{ matrix.python-version }} + + - name: Install dev deps and run lint + tests + run: | + python -m pip install --upgrade pip + make dev-install + make lint + make test + + # --------------------------------------------------------------------------- + # 3. Build and upload. Uses bin/publish-pypi.sh verbatim so the local and + # CI publish paths stay identical (single source of truth for build + # flags, twine invocation, and upload URL selection). + # + # The environment binding (pypi or testpypi) determines which + # PYPI_API_TOKEN secret resolves at runtime; each environment owns + # its own copy. The same is true for required reviewers — configure + # them per environment in repo Settings. + # --------------------------------------------------------------------------- + publish: + name: Publish to ${{ inputs.target }} + needs: [validate, test] + runs-on: ubuntu-latest + environment: + name: ${{ inputs.target }} + # GitHub Environments display URL — distinct from the upload URL, + # which lives in bin/publish-pypi.sh. Point it at the project page + # of whichever index we are publishing to so the "View deployment" + # link in the Actions UI doesn't mislead approvers. + url: ${{ (inputs.target == 'testpypi' && 'https://test.pypi.org/project/pytfe/') || 'https://pypi.org/project/pytfe/' }} + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 + with: + ref: ${{ needs.validate.outputs.tag-ref }} + + - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c + with: + python-version: "3.12" + + - name: Publish via bin/publish-pypi.sh + env: + PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + PYPI_REPO: ${{ inputs.target }} + run: bash bin/publish-pypi.sh From 85913233bae3b50c8c7a088ff77bab8f7bbe7015 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Tue, 26 May 2026 18:10:59 +0530 Subject: [PATCH 90/95] Added stack, team-project-access models at init and renamed TaskResultStatus into TaskResultCallbackStatus --- examples/organization_tags.py | 2 +- examples/run_task_integration.py | 6 +-- examples/stack.py | 6 +-- examples/team_project_access.py | 6 +-- src/pytfe/models/__init__.py | 68 ++++++++++++++++++++++-- src/pytfe/models/run_task_integration.py | 6 +-- src/pytfe/models/team.py | 2 +- src/pytfe/models/team_project_access.py | 4 +- tests/units/test_run_task_integration.py | 48 +++++++++-------- tests/units/test_team_project_access.py | 2 +- 10 files changed, 108 insertions(+), 42 deletions(-) diff --git a/examples/organization_tags.py b/examples/organization_tags.py index c562f573..6c9afb1c 100644 --- a/examples/organization_tags.py +++ b/examples/organization_tags.py @@ -21,7 +21,7 @@ from pytfe import TFEClient, TFEConfig from pytfe.errors import TFEError -from pytfe.models.organization_tags import ( +from pytfe.models import ( AddWorkspacesToTagOptions, OrganizationTagsDeleteOptions, ) diff --git a/examples/run_task_integration.py b/examples/run_task_integration.py index 71b43b8c..cf82fde4 100644 --- a/examples/run_task_integration.py +++ b/examples/run_task_integration.py @@ -62,10 +62,10 @@ import os from pytfe import TFEClient, TFEConfig -from pytfe.models.run_task_integration import ( +from pytfe.models import ( TaskResultCallbackRequestOptions, + TaskResultCallbackStatus, TaskResultOutcome, - TaskResultStatus, TaskResultTag, ) @@ -109,7 +109,7 @@ def main() -> None: # ) options = TaskResultCallbackRequestOptions( - status=TaskResultStatus.passed, + status=TaskResultCallbackStatus.passed, message="Run task completed successfully", url="https://example.com/results", outcomes=[outcome], diff --git a/examples/stack.py b/examples/stack.py index 6e8546e8..551d25e2 100644 --- a/examples/stack.py +++ b/examples/stack.py @@ -7,9 +7,9 @@ import os from pytfe import TFEClient, TFEConfig -from pytfe.models.agent import AgentPool -from pytfe.models.project import Project -from pytfe.models.stack import ( +from pytfe.models import ( + AgentPool, + Project, StackCreateOptions, StackListOptions, StackSortColumn, diff --git a/examples/team_project_access.py b/examples/team_project_access.py index b91b1942..ce1cee1c 100644 --- a/examples/team_project_access.py +++ b/examples/team_project_access.py @@ -4,12 +4,12 @@ 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 ( +from pytfe.models import ( + Project, ProjectSettingsPermissionType, ProjectTeamsPermissionType, ProjectVariableSetsPermissionType, + Team, TeamProjectAccessAddOptions, TeamProjectAccessListOptions, TeamProjectAccessProjectPermissionsOptions, diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index 0c91210c..c3c6f56b 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -134,6 +134,12 @@ OrganizationMembershipStatus, OrgMembershipIncludeOpt, ) +from .organization_tags import ( + AddWorkspacesToTagOptions, + OrganizationTag, + OrganizationTagsDeleteOptions, + OrganizationTagsListOptions, +) from .organization_token import ( OrganizationToken, OrganizationTokenCreateOptions, @@ -332,12 +338,10 @@ ) from .run_task_integration import ( TaskResultCallbackRequestOptions, + TaskResultCallbackStatus, TaskResultOutcome, TaskResultTag, ) -from .run_task_integration import ( - TaskResultStatus as TaskResultCallbackStatus, -) from .run_task_request import ( RunTaskRequest, RunTaskRequestCapabilities, @@ -359,6 +363,15 @@ SSHKeyListOptions, SSHKeyUpdateOptions, ) +from .stack import ( + Stack, + StackCreateOptions, + StackListOptions, + StackSortColumn, + StackUpdateOptions, + StackVcsRepo, + StackVcsRepoOptions, +) from .stack_configuration import ( StackComponent, StackConfiguration, @@ -402,6 +415,21 @@ TeamPermissions, TeamUpdateOptions, ) +from .team_project_access import ( + ProjectSettingsPermissionType, + ProjectTeamsPermissionType, + ProjectVariableSetsPermissionType, + TeamProjectAccessAddOptions, + TeamProjectAccessListOptions, + TeamProjectAccessProjectPermissionsOptions, + TeamProjectAccessType, + TeamProjectAccessUpdateOptions, + TeamProjectAccessWorkspacePermissionsOptions, + WorkspaceRunsPermissionType, + WorkspaceSentinelMocksPermissionType, + WorkspaceStateVersionsPermissionType, + WorkspaceVariablesPermissionType, +) from .team_token import ( CreatedByChoice, TeamToken, @@ -418,6 +446,10 @@ TeamWorkspaceStateVersionsPermission, TeamWorkspaceVariablesPermission, ) +from .user import ( + User, + UserUpdateCurrentOptions, +) # Variables from .variable import ( @@ -881,6 +913,36 @@ # State Version Outputs "StateVersionOutput", "StateVersionOutputsListOptions", + # Team project access + "ProjectSettingsPermissionType", + "ProjectTeamsPermissionType", + "ProjectVariableSetsPermissionType", + "TeamProjectAccessAddOptions", + "TeamProjectAccessListOptions", + "TeamProjectAccessProjectPermissionsOptions", + "TeamProjectAccessType", + "TeamProjectAccessUpdateOptions", + "TeamProjectAccessWorkspacePermissionsOptions", + "WorkspaceRunsPermissionType", + "WorkspaceSentinelMocksPermissionType", + "WorkspaceStateVersionsPermissionType", + "WorkspaceVariablesPermissionType", + # User + "User", + "UserUpdateCurrentOptions", + # Organization tags + "OrganizationTag", + "OrganizationTagsListOptions", + "AddWorkspacesToTagOptions", + "OrganizationTagsDeleteOptions", + # Stack + "StackCreateOptions", + "StackListOptions", + "StackSortColumn", + "StackUpdateOptions", + "StackVcsRepoOptions", + "StackVcsRepo", + "Stack", ] # Rebuild models with forward references after all models are loaded diff --git a/src/pytfe/models/run_task_integration.py b/src/pytfe/models/run_task_integration.py index 6eecf5bc..a7e1fec8 100644 --- a/src/pytfe/models/run_task_integration.py +++ b/src/pytfe/models/run_task_integration.py @@ -11,7 +11,7 @@ from ..errors import InvalidTaskResultsCallbackStatusError -class TaskResultStatus(str, Enum): +class TaskResultCallbackStatus(str, Enum): """Statuses accepted by the Run Task callback endpoint.""" passed = "passed" @@ -68,14 +68,14 @@ class TaskResultCallbackRequestOptions(BaseModel): model_config = ConfigDict(populate_by_name=True) - status: TaskResultStatus = Field(..., alias="status") + status: TaskResultCallbackStatus = Field(..., alias="status") message: str | None = Field(None, alias="message") url: str | None = Field(None, alias="url") outcomes: list[TaskResultOutcome] | None = Field(None, alias="outcomes") def _validate(self) -> None: """Validate callback status.""" - if not isinstance(self.status, TaskResultStatus): + if not isinstance(self.status, TaskResultCallbackStatus): raise InvalidTaskResultsCallbackStatusError() def to_payload(self) -> dict[str, Any]: diff --git a/src/pytfe/models/team.py b/src/pytfe/models/team.py index 5d9772ae..61e8f9be 100644 --- a/src/pytfe/models/team.py +++ b/src/pytfe/models/team.py @@ -131,7 +131,7 @@ class TeamCreateOptions(BaseModel): organization_access: OrganizationAccessOptions | None = Field( default=None, alias="organization-access" ) - visibility: str | None = Field(alias="visibility") + visibility: str | None = Field(default=None, alias="visibility") allow_member_token_management: bool | None = Field( default=None, alias="allow-member-token-management" ) diff --git a/src/pytfe/models/team_project_access.py b/src/pytfe/models/team_project_access.py index 29aa8cad..d99d9f2d 100644 --- a/src/pytfe/models/team_project_access.py +++ b/src/pytfe/models/team_project_access.py @@ -136,12 +136,12 @@ class TeamProjectAccessListOptions(BaseModel): 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]") + 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): + if self.project_id is not None and not valid_string_id(self.project_id): raise InvalidProjectIDError() return self diff --git a/tests/units/test_run_task_integration.py b/tests/units/test_run_task_integration.py index f5145efc..56fa37a5 100644 --- a/tests/units/test_run_task_integration.py +++ b/tests/units/test_run_task_integration.py @@ -16,8 +16,8 @@ ) from pytfe.models.run_task_integration import ( TaskResultCallbackRequestOptions, + TaskResultCallbackStatus, TaskResultOutcome, - TaskResultStatus, TaskResultTag, ) from pytfe.resources._base import _Service @@ -39,7 +39,7 @@ def service(transport: Mock) -> RunTaskIntegrations: def _basic_options() -> TaskResultCallbackRequestOptions: return TaskResultCallbackRequestOptions( - status=TaskResultStatus.passed, + status=TaskResultCallbackStatus.passed, message="All good", url="https://example.com/details", ) @@ -85,7 +85,11 @@ def test_callback_invalid_token_raises_typed_error(service, bad_token): @pytest.mark.parametrize( "good_status", - [TaskResultStatus.passed, TaskResultStatus.failed, TaskResultStatus.running], + [ + TaskResultCallbackStatus.passed, + TaskResultCallbackStatus.failed, + TaskResultCallbackStatus.running, + ], ) def test_callback_accepts_all_valid_statuses(service, transport, good_status): options = TaskResultCallbackRequestOptions(status=good_status) @@ -99,7 +103,7 @@ def test_callback_accepts_all_valid_statuses(service, transport, good_status): ["pending", "errored", "unreachable", "", "PASSED", "unknown", None, 123], ) def test_callback_rejects_invalid_statuses(service, bad_status): - options = TaskResultCallbackRequestOptions(status=TaskResultStatus.passed) + options = TaskResultCallbackRequestOptions(status=TaskResultCallbackStatus.passed) options.status = bad_status # type: ignore[assignment] with pytest.raises(InvalidTaskResultsCallbackStatusError): service.callback(CALLBACK_URL, ACCESS_TOKEN, options) @@ -173,7 +177,7 @@ def test_payload_basic_exact_shape(service, transport): def test_payload_status_only_exact_shape(service, transport): - options = TaskResultCallbackRequestOptions(status=TaskResultStatus.running) + options = TaskResultCallbackRequestOptions(status=TaskResultCallbackStatus.running) service.callback(CALLBACK_URL, ACCESS_TOKEN, options) assert transport.request.call_args.kwargs["json_body"] == { "data": { @@ -197,7 +201,7 @@ def test_payload_with_outcomes_and_tags_exact_shape(service, transport): }, ) options = TaskResultCallbackRequestOptions( - status=TaskResultStatus.failed, outcomes=[outcome] + status=TaskResultCallbackStatus.failed, outcomes=[outcome] ) service.callback(CALLBACK_URL, ACCESS_TOKEN, options) @@ -235,7 +239,7 @@ def test_payload_with_outcomes_and_tags_exact_shape(service, transport): def test_message_omitted_when_none(service, transport): options = TaskResultCallbackRequestOptions( - status=TaskResultStatus.passed, url="https://x" + status=TaskResultCallbackStatus.passed, url="https://x" ) service.callback(CALLBACK_URL, ACCESS_TOKEN, options) attrs = transport.request.call_args.kwargs["json_body"]["data"]["attributes"] @@ -244,7 +248,7 @@ def test_message_omitted_when_none(service, transport): def test_url_omitted_when_none(service, transport): options = TaskResultCallbackRequestOptions( - status=TaskResultStatus.passed, message="m" + status=TaskResultCallbackStatus.passed, message="m" ) service.callback(CALLBACK_URL, ACCESS_TOKEN, options) attrs = transport.request.call_args.kwargs["json_body"]["data"]["attributes"] @@ -252,7 +256,7 @@ def test_url_omitted_when_none(service, transport): def test_relationships_omitted_when_outcomes_none(service, transport): - options = TaskResultCallbackRequestOptions(status=TaskResultStatus.passed) + options = TaskResultCallbackRequestOptions(status=TaskResultCallbackStatus.passed) service.callback(CALLBACK_URL, ACCESS_TOKEN, options) body = transport.request.call_args.kwargs["json_body"] assert "relationships" not in body["data"] @@ -260,7 +264,7 @@ def test_relationships_omitted_when_outcomes_none(service, transport): def test_relationships_omitted_when_outcomes_empty_list(service, transport): options = TaskResultCallbackRequestOptions( - status=TaskResultStatus.passed, outcomes=[] + status=TaskResultCallbackStatus.passed, outcomes=[] ) service.callback(CALLBACK_URL, ACCESS_TOKEN, options) body = transport.request.call_args.kwargs["json_body"] @@ -269,7 +273,7 @@ def test_relationships_omitted_when_outcomes_empty_list(service, transport): def test_outcome_attributes_omit_none_fields(service, transport): options = TaskResultCallbackRequestOptions( - status=TaskResultStatus.passed, outcomes=[TaskResultOutcome()] + status=TaskResultCallbackStatus.passed, outcomes=[TaskResultOutcome()] ) service.callback(CALLBACK_URL, ACCESS_TOKEN, options) entry = transport.request.call_args.kwargs["json_body"]["data"]["relationships"][ @@ -281,7 +285,7 @@ def test_outcome_attributes_omit_none_fields(service, transport): def test_tag_level_omitted_when_none(service, transport): outcome = TaskResultOutcome(tags={"category": [TaskResultTag(label="only")]}) options = TaskResultCallbackRequestOptions( - status=TaskResultStatus.passed, outcomes=[outcome] + status=TaskResultCallbackStatus.passed, outcomes=[outcome] ) service.callback(CALLBACK_URL, ACCESS_TOKEN, options) tags = transport.request.call_args.kwargs["json_body"]["data"]["relationships"][ @@ -293,7 +297,7 @@ def test_tag_level_omitted_when_none(service, transport): def test_outcome_tags_omitted_when_none(service, transport): outcome = TaskResultOutcome(description="no tags here") options = TaskResultCallbackRequestOptions( - status=TaskResultStatus.passed, outcomes=[outcome] + status=TaskResultCallbackStatus.passed, outcomes=[outcome] ) service.callback(CALLBACK_URL, ACCESS_TOKEN, options) attrs = transport.request.call_args.kwargs["json_body"]["data"]["relationships"][ @@ -307,7 +311,7 @@ def test_outcome_tags_omitted_when_none(service, transport): def test_multiple_outcomes_preserve_order(service, transport): options = TaskResultCallbackRequestOptions( - status=TaskResultStatus.passed, + status=TaskResultCallbackStatus.passed, outcomes=[ TaskResultOutcome(outcome_id="o-1", description="first"), TaskResultOutcome(outcome_id="o-2", description="second"), @@ -333,7 +337,7 @@ def test_multiple_tags_per_category(service, transport): } ) options = TaskResultCallbackRequestOptions( - status=TaskResultStatus.failed, outcomes=[outcome] + status=TaskResultCallbackStatus.failed, outcomes=[outcome] ) service.callback(CALLBACK_URL, ACCESS_TOKEN, options) tags = transport.request.call_args.kwargs["json_body"]["data"]["relationships"][ @@ -352,7 +356,7 @@ def test_multiple_tags_per_category(service, transport): def test_unicode_message_and_body(service, transport): outcome = TaskResultOutcome(body="✓ all good — 通过") options = TaskResultCallbackRequestOptions( - status=TaskResultStatus.passed, + status=TaskResultCallbackStatus.passed, message="résumé 🎉", outcomes=[outcome], ) @@ -369,7 +373,7 @@ def test_markdown_body_preserved_verbatim(service, transport): md = "## Results\n\n- [link](https://x)\n- **bold**\n\n```py\nprint('ok')\n```" outcome = TaskResultOutcome(body=md) options = TaskResultCallbackRequestOptions( - status=TaskResultStatus.passed, outcomes=[outcome] + status=TaskResultCallbackStatus.passed, outcomes=[outcome] ) service.callback(CALLBACK_URL, ACCESS_TOKEN, options) serialized = transport.request.call_args.kwargs["json_body"]["data"][ @@ -397,7 +401,7 @@ def test_outcome_accepts_alias_input(): def test_options_accepts_string_status(): options = TaskResultCallbackRequestOptions.model_validate({"status": "passed"}) - assert options.status == TaskResultStatus.passed + assert options.status == TaskResultCallbackStatus.passed # ─── SDK client wiring ──────────────────────────────────────────────────────── @@ -427,7 +431,7 @@ def test_to_payload_is_idempotent(): """Calling to_payload twice must produce equal dicts and must not mutate the options instance — important because callers may inspect/log payloads.""" options = TaskResultCallbackRequestOptions( - status=TaskResultStatus.passed, + status=TaskResultCallbackStatus.passed, message="hi", outcomes=[ TaskResultOutcome( @@ -450,7 +454,7 @@ def test_to_payload_is_json_serializable(): import json options = TaskResultCallbackRequestOptions( - status=TaskResultStatus.failed, + status=TaskResultCallbackStatus.failed, outcomes=[ TaskResultOutcome( outcome_id="o-1", @@ -479,7 +483,7 @@ def test_sequential_callbacks_are_independent(service, transport): service.callback( CALLBACK_URL, "v1.other-token", - TaskResultCallbackRequestOptions(status=TaskResultStatus.failed), + TaskResultCallbackRequestOptions(status=TaskResultCallbackStatus.failed), ) assert transport.request.call_count == 2 assert transport.request.call_args_list[0].kwargs["headers"] == { @@ -507,7 +511,7 @@ def test_outcome_with_empty_tags_dict_emits_empty_object(service, transport): desired later, update both the model and this test together.""" outcome = TaskResultOutcome(tags={}) options = TaskResultCallbackRequestOptions( - status=TaskResultStatus.passed, outcomes=[outcome] + status=TaskResultCallbackStatus.passed, outcomes=[outcome] ) service.callback(CALLBACK_URL, ACCESS_TOKEN, options) attrs = transport.request.call_args.kwargs["json_body"]["data"]["relationships"][ diff --git a/tests/units/test_team_project_access.py b/tests/units/test_team_project_access.py index 75b6a4a6..97a700f6 100644 --- a/tests/units/test_team_project_access.py +++ b/tests/units/test_team_project_access.py @@ -183,7 +183,7 @@ def test_list_team_project_accesses_success( return_value=[team_project_access_response_data] ) - options = TeamProjectAccessListOptions(page_size=10, Project_id="prj-123") + options = TeamProjectAccessListOptions(page_size=10, project_id="prj-123") result_iter = team_project_accesses_service.list(options) items = list(result_iter) From 33e27b82e4535b7bbb6869cccc521c05a30f8e58 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Tue, 26 May 2026 22:04:16 +0530 Subject: [PATCH 91/95] Documentations - Add scenario, api docs and fix variable_set model (#173) --- AGENTS.md | 1 + CHANGELOG.md | 1 + README.md | 16 +- docs/ITERATORS.md | 18 +- docs/RESOURCE.md | 54 ++-- docs/TESTS.md | 19 +- docs/api/index.md | 105 +++++++ docs/api/policies.md | 69 +++++ docs/api/run-tasks.md | 103 +++++++ docs/api/runs-plans-applies.md | 105 +++++++ docs/api/state-versions.md | 97 ++++++ docs/api/teams-and-access.md | 76 +++++ docs/api/variables-and-variable-sets.md | 76 +++++ docs/api/workspaces.md | 96 ++++++ docs/authentication.md | 145 +++++++++ docs/errors.md | 99 ++++++ docs/getting-started.md | 107 +++++++ docs/pagination.md | 95 ++++++ docs/scenarios/agent-pool-setup.md | 170 +++++++++++ docs/scenarios/api-driven-run.md | 137 +++++++++ docs/scenarios/errored-state-recovery.md | 167 ++++++++++ docs/scenarios/manage-workspace-variables.md | 124 ++++++++ .../scenarios/migrate-workspaces-and-state.md | 284 ++++++++++++++++++ docs/scenarios/notification-configurations.md | 192 ++++++++++++ docs/scenarios/policy-enforcement.md | 107 +++++++ docs/scenarios/run-task-integration.md | 101 +++++++ docs/scenarios/state-management.md | 101 +++++++ docs/scenarios/team-access-onboarding.md | 120 ++++++++ docs/terraform-enterprise.md | 95 ++++++ docs/troubleshooting.md | 123 ++++++++ examples/variable_sets.py | 36 +-- src/pytfe/models/variable_set.py | 8 +- 32 files changed, 2977 insertions(+), 70 deletions(-) create mode 100644 docs/api/index.md create mode 100644 docs/api/policies.md create mode 100644 docs/api/run-tasks.md create mode 100644 docs/api/runs-plans-applies.md create mode 100644 docs/api/state-versions.md create mode 100644 docs/api/teams-and-access.md create mode 100644 docs/api/variables-and-variable-sets.md create mode 100644 docs/api/workspaces.md create mode 100644 docs/authentication.md create mode 100644 docs/errors.md create mode 100644 docs/getting-started.md create mode 100644 docs/pagination.md create mode 100644 docs/scenarios/agent-pool-setup.md create mode 100644 docs/scenarios/api-driven-run.md create mode 100644 docs/scenarios/errored-state-recovery.md create mode 100644 docs/scenarios/manage-workspace-variables.md create mode 100644 docs/scenarios/migrate-workspaces-and-state.md create mode 100644 docs/scenarios/notification-configurations.md create mode 100644 docs/scenarios/policy-enforcement.md create mode 100644 docs/scenarios/run-task-integration.md create mode 100644 docs/scenarios/state-management.md create mode 100644 docs/scenarios/team-access-onboarding.md create mode 100644 docs/terraform-enterprise.md create mode 100644 docs/troubleshooting.md diff --git a/AGENTS.md b/AGENTS.md index f64aae2a..1b0caee2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ src/pytfe/ config.py # TFEConfig — auth, timeout, retry, proxy settings _http.py # HTTPTransport — request, retry, redirects, auth _jsonapi.py # JSON:API envelope helpers + _logging.py. # Logging primitives for the pytfe SDK errors.py # Typed exception hierarchy (TFEError + ~80 subclasses) utils.py # Validation + small helpers models/ # Pydantic v2 models, one file per resource diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bc5d914..8f3d333e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,6 +97,7 @@ ## Bug Fixes * Fixed task result relationships to map into typed SDK models instead of raw JSON by @TanyaSingh369-svg [#156](https://github.com/hashicorp/python-tfe/pull/156) * Fixed task stage relationship mapping in the task result resource by @TanyaSingh369-svg [#156](https://github.com/hashicorp/python-tfe/pull/156) +* Updated variable set models to support ``global_`` inputs. Since ``global`` is a Python reserved word, callers previously had to use ``model_validate`` as a workaround; existing ``global`` alias usage continues to work unchanged. # v0.1.5 diff --git a/README.md b/README.md index 94728d15..0bd386ed 100644 --- a/README.md +++ b/README.md @@ -157,9 +157,19 @@ For full details — environment variables, redaction guarantees, and how to add ## Documentation -- API reference and guides (SDK): **coming soon** -- Terraform Enterprise API: https://developer.hashicorp.com/terraform/enterprise/api-docs -- Internal reference: [`docs/ITERATORS.md`](./docs/ITERATORS.md), [`docs/MODELS.md`](./docs/MODELS.md), [`docs/RESOURCE.md`](./docs/RESOURCE.md), [`docs/LOGGING.md`](./docs/LOGGING.md) +Start with [Getting started](./docs/getting-started.md), then use the +[API index](./docs/api/index.md) to find resource-specific guides, examples, +and upstream HCP Terraform API docs. + +| Need | Start here | +|---|---| +| Configure the SDK | [Authentication](./docs/authentication.md), [Pagination](./docs/pagination.md), [Logging](./docs/LOGGING.md) | +| API guides | [API index](./docs/api/index.md), [Workspaces](./docs/api/workspaces.md), [Runs/plans/applies](./docs/api/runs-plans-applies.md), [State versions](./docs/api/state-versions.md) | +| Scenario guides | [API-driven run](./docs/scenarios/api-driven-run.md), [State management](./docs/scenarios/state-management.md), [Migrate workspaces and state](./docs/scenarios/migrate-workspaces-and-state.md), [Team access onboarding](./docs/scenarios/team-access-onboarding.md) | +| Operations guides | [Troubleshooting](./docs/troubleshooting.md), [Errors](./docs/errors.md), [Terraform Enterprise](./docs/terraform-enterprise.md) | +| Contribute to the SDK | [CONTRIBUTING](./docs/CONTRIBUTING.md), [ITERATORS](./docs/ITERATORS.md), [MODELS](./docs/MODELS.md), [RESOURCE](./docs/RESOURCE.md) | + +Upstream API reference: https://developer.hashicorp.com/terraform/cloud-docs/api-docs ## Examples diff --git a/docs/ITERATORS.md b/docs/ITERATORS.md index 46634a24..e49c16ed 100644 --- a/docs/ITERATORS.md +++ b/docs/ITERATORS.md @@ -72,15 +72,21 @@ with pytest.raises(InvalidOrgError): If you genuinely need eager validation (raised from the call expression itself, not the first `for` loop), use the wrapper pattern: ```python -def list(self, organization: str, ...) -> Iterator[Workspace]: +def list( + self, + organization: str, + options: WorkspaceListOptions | None = None, +) -> Iterator[Workspace]: if not valid_string_id(organization): raise InvalidOrgError() # eager - params = ... - path = ... + params = options.model_dump(by_alias=True, exclude_none=True, mode="json") if options else {} + path = f"/api/v2/organizations/{organization}/workspaces" + def _gen() -> Iterator[Workspace]: for item in self._list(path, params=params): yield self._workspace_from(item) + return _gen() ``` @@ -131,15 +137,15 @@ Do **not** reach for `iter(list)` just because the endpoint is non-paginated. Us ```python # ❌ Returns Iterable instead of Iterator — looks similar, isn't. -def list(...) -> Iterable[Workspace]: ... +def list(self) -> Iterable[Workspace]: ... # ❌ Returns Pager / LazyList / custom wrapper. -def list(...) -> WorkspaceList: ... +def list(self) -> WorkspaceList: ... # ❌ Returns concrete list. The type is a public contract; consumers will # rely on len(), indexing, and isinstance(result, list). See "Known # exceptions" below for the one method where this is documented. -def list_widgets(...) -> list[Widget]: ... +def list_widgets(self) -> list[Widget]: ... ``` ## Known exceptions (and why) diff --git a/docs/RESOURCE.md b/docs/RESOURCE.md index b3be73cd..3ffb34b6 100644 --- a/docs/RESOURCE.md +++ b/docs/RESOURCE.md @@ -37,11 +37,11 @@ from ._base import _Service class Widgets(_Service): """Service for managing widgets.""" - def list(...) -> Iterator[Widget]: ... - def read(...) -> Widget: ... - def create(...) -> Widget: ... - def update(...) -> Widget: ... - def delete(...) -> None: ... + def list(self) -> Iterator[Widget]: ... + def read(self, widget_id: str) -> Widget: ... + def create(self, organization: str, options: WidgetCreateOptions) -> Widget: ... + def update(self, widget_id: str, options: WidgetUpdateOptions) -> Widget: ... + def delete(self, widget_id: str) -> None: ... def _widget_from(self, data: dict[str, Any]) -> Widget: ... ``` @@ -188,32 +188,23 @@ If the model has relationships, pull them from `data["relationships"]` and eithe 1. **Embed an id-stub** using `Model.model_construct(id=...)` — use this when the model defines the relation as `OtherModel | None`. `model_construct` skips validation, which is correct for partial `{id, type}` data: - ```python - relationships = data.get("relationships", {}) - run_data = relationships.get("run", {}).get("data") - if run_data: - attributes["run"] = Run.model_construct(id=run_data["id"]) - ``` +```python +relationships = data.get("relationships", {}) +run_data = relationships.get("run", {}).get("data") +if run_data: + attributes["run"] = Run.model_construct(id=run_data["id"]) +``` 2. **Flatten to `*_id`** when the model exposes a flat `team_id: str | None` field: - ```python - team_data = (relationships.get("team") or {}).get("data") or {} - if team_data.get("id"): - attributes["team-id"] = team_data["id"] - ``` +```python +team_data = (relationships.get("team") or {}).get("data") or {} +if team_data.get("id"): + attributes["team-id"] = team_data["id"] +``` Always defensively coalesce with `or {}` — relationships may be missing from sparse responses. -## Presigned URLs and redirects - -The TFE bearer token must not be forwarded to Archivist, S3, or other presigned blob hosts. Signed upload/download URLs already carry their own credentials. - -- Direct signed URL: `self.t.request("GET", url, include_auth=False)` -- API endpoint that returns a redirect: call the API path with `allow_redirects=False`, read the `Location` header, then fetch that URL with `include_auth=False` -- Add a unit test that asserts the blob URL call uses `include_auth=False` - -This applies to state upload/download, plan JSON output/schema, apply errored state, and any future blob-backed endpoint. ## Pagination — use `self._list`, don't roll your own @@ -249,12 +240,12 @@ You usually don't need to catch these — let them propagate to the caller. Catc - You want to translate to a more specific error (`except TFEError as e: if "rate-limit" in str(e): raise ...`) - The "error" is actually an expected outcome — like a `NotFound` meaning "no current assessment yet": - ```python - try: - r = self.t.request("GET", f"/api/v2/workspaces/{ws_id}/current-assessment-result") - except NotFound: - return None - ``` +```python +try: + r = self.t.request("GET", f"/api/v2/workspaces/{ws_id}/current-assessment-result") +except NotFound: + return None +``` ## Wiring into the client @@ -460,7 +451,6 @@ raise InvalidWidgetIDError() # preferred for new APIs - [ ] `list*` returns `Iterator[X]` via `self._list(...)` (see [ITERATORS.md](ITERATORS.md)) - [ ] Response parsing helper `_widget_from(data)` translates JSON:API → Pydantic - [ ] Non-standard response shapes (`204`, `null`, bare resources, raw bytes, redirects) are verified against docs/go-tfe/spec and covered by tests -- [ ] Presigned upload/download/blob URLs are fetched with `include_auth=False` - [ ] Classes with `def list(...)` avoid later bare `list[...]` annotations - [ ] Models added per [MODELS.md](MODELS.md), wired in `models/__init__.py` - [ ] Resource wired into `client.py` (import + `self.widgets = Widgets(...)`) diff --git a/docs/TESTS.md b/docs/TESTS.md index 9ddc2af0..ea886f7d 100644 --- a/docs/TESTS.md +++ b/docs/TESTS.md @@ -136,8 +136,8 @@ def test_create_workspace(self, client): client._transport.request = MagicMock(return_value=mock_response) # Execute the operation - options = WorkspaceCreateOptions(name="new-workspace", organization="test-org") - workspace = client.workspaces.create(options) + options = WorkspaceCreateOptions(name="new-workspace") + workspace = client.workspaces.create("test-org", options) # Assertions assert workspace.id == "ws-new" @@ -156,15 +156,20 @@ Always test validation and error handling: ```python def test_create_workspace_invalid_org(self, client): - """Test creating workspace with invalid organization.""" + """Test creating workspace with an empty organization name.""" + options = WorkspaceCreateOptions(name="test") with pytest.raises(InvalidOrgError): - options = WorkspaceCreateOptions(name="test", organization="") - client.workspaces.create(options) + client.workspaces.create("", options) def test_read_workspace_invalid_id(self, client): - """Test reading workspace with invalid ID.""" + """Test read_by_id with an empty workspace ID.""" with pytest.raises(InvalidWorkspaceIDError): - client.workspaces.read(workspace_id="") + client.workspaces.read_by_id("") + +def test_read_workspace_invalid_name(self, client): + """Test read with an empty workspace name.""" + with pytest.raises(InvalidWorkspaceValueError): + client.workspaces.read("", organization="valid-org") ``` ### 4. Test Pagination diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 00000000..f13ec141 --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,105 @@ +# API index + +This page maps `TFEClient` attributes to pyTFE resource services, examples, and +upstream HCP Terraform or Terraform Enterprise API docs. It is intentionally a +high-signal map, not a duplicate of every method signature. + +For complete wire-level behavior, use the upstream API docs linked in the last +column. + +## Core organization and workspace resources + +| Client attribute | Resource class | Common methods | Example | Upstream API docs | +|---|---|---|---|---| +| `client.organizations` | `Organizations` | `list`, `read`, `create`, `update`, `delete`, capacity, entitlements, data retention | [org.py](../../examples/org.py) | [Organizations](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/organizations) | +| `client.projects` | `Projects` | `list`, `read`, `create`, `update`, `delete`, `move_workspaces`, tag bindings | [project.py](../../examples/project.py) | [Projects](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/projects) | +| `client.workspaces` | `Workspaces` | `list`, `read`, `create`, `update`, `delete`, lock/unlock, tags, remote state consumers, data retention | [workspace.py](../../examples/workspace.py) | [Workspaces](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/workspaces) | +| `client.workspace_resources` | `WorkspaceResourcesService` | `list` | [workspace_resources.py](../../examples/workspace_resources.py) | [Workspace resources](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/workspace-resources) | +| `client.ssh_keys` | `SSHKeys` | `list`, `read`, `create`, `update`, `delete` | [ssh_keys.py](../../examples/ssh_keys.py) | [SSH keys](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/ssh-keys) | +| `client.reserved_tag_key` | `ReservedTagKeys` | `list`, `create`, `update`, `delete` | [reserved_tag_key.py](../../examples/reserved_tag_key.py) | [Reserved tag keys](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/reserved-tag-keys) | + +## Runs, plans, applies, and state + +| Client attribute | Resource class | Common methods | Example | Upstream API docs | +|---|---|---|---|---| +| `client.configuration_versions` | `ConfigurationVersions` | `list`, `read`, `create`, `upload`, `download`, backing-data actions | [configuration_version.py](../../examples/configuration_version.py) | [Configuration versions](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/configuration-versions) | +| `client.runs` | `Runs` | `list`, `list_for_organization`, `read`, `create`, `apply`, `cancel`, `force_cancel`, `force_execute`, `discard` | [run.py](../../examples/run.py) | [Runs](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/run) | +| `client.plans` | `Plans` | `read`, `read_for_run`, `logs`, `read_json_output`, `read_json_output_for_run`, `read_json_schema_for_run` | [plan.py](../../examples/plan.py) | [Plans](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/plans) | +| `client.applies` | `Applies` | `read`, `logs`, `errored_state` | [apply.py](../../examples/apply.py) | [Applies](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/applies) | +| `client.run_events` | `RunEvents` | `list`, `read`, `read_with_options` | [run_events.py](../../examples/run_events.py) | [Runs](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/run) | +| `client.query_runs` | `QueryRuns` | `list`, `read`, `create`, `logs`, `cancel`, `force_cancel` | [query_run.py](../../examples/query_run.py) | [Query runs](https://developer.hashicorp.com/terraform/enterprise/api-docs/queries) | +| `client.state_versions` | `StateVersions` | `list`, `read`, `read_current`, `create`, `upload`, `download`, `rollback`, backing-data actions | [state_versions.py](../../examples/state_versions.py) | [State versions](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/state-versions) | +| `client.state_version_outputs` | `StateVersionOutputs` | `read`, `read_current` | [state_versions.py](../../examples/state_versions.py) | [State version outputs](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/state-version-outputs) | + +## Variables and variable sets + +| Client attribute | Resource class | Common methods | Example | Upstream API docs | +|---|---|---|---|---| +| `client.variables` | `Variables` | `list`, `list_all`, `read`, `create`, `update`, `delete` | [variables.py](../../examples/variables.py) | [Workspace variables](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/workspace-variables) | +| `client.variable_sets` | `VariableSets` | `list`, `list_for_workspace`, `list_for_project`, `read`, `create`, `update`, `delete`, apply/remove relationships | [variable_sets.py](../../examples/variable_sets.py) | [Variable sets](https://developer.hashicorp.com/terraform/enterprise/api-docs/variable-sets) | +| `client.variable_set_variables` | `VariableSetVariables` | `list`, `read`, `create`, `update`, `delete` | [variable_sets.py](../../examples/variable_sets.py) | [Variable sets](https://developer.hashicorp.com/terraform/enterprise/api-docs/variable-sets) | + +## Teams, users, and access + +| Client attribute | Resource class | Common methods | Example | Upstream API docs | +|---|---|---|---|---| +| `client.users` | `Users` | `read`, `read_current`, `update_current` | [user.py](../../examples/user.py) | [Users](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/users) | +| `client.teams` | `Teams` | `list`, `read`, `create`, `update`, `delete`, membership helpers | [team.py](../../examples/team.py) | [Teams](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/teams) | +| `client.team_workspace_accesses` | `TeamWorkspaceAccesses` | `list`, `read`, `add`, `update`, `remove` | [team_workspace_access.py](../../examples/team_workspace_access.py) | [Team access](https://developer.hashicorp.com/terraform/enterprise/api-docs/team-access) | +| `client.team_project_accesses` | `TeamProjectAccesses` | `list`, `read`, `add`, `update`, `remove` | [team_project_access.py](../../examples/team_project_access.py) | [Project team access](https://developer.hashicorp.com/terraform/enterprise/api-docs/project-team-access) | +| `client.team_tokens` | `TeamTokens` | `list`, `read`, `create`, `delete` | [team_token.py](../../examples/team_token.py) | [Team tokens](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/team-tokens) | +| `client.organization_memberships` | `OrganizationMemberships` | `list`, `read`, `create`, `delete` | [organization_membership.py](../../examples/organization_membership.py) | [Organization memberships](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/organization-memberships) | +| `client.organization_tokens` | `OrganizationTokens` | `read`, `create`, `delete` | [organization_token.py](../../examples/organization_token.py) | [Organization tokens](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/organization-tokens) | + +## Policies and policy results + +| Client attribute | Resource class | Common methods | Example | Upstream API docs | +|---|---|---|---|---| +| `client.policies` | `Policies` | `list`, `read`, `create`, `update`, `delete`, `upload`, `download` | [policy.py](../../examples/policy.py) | [Policies](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policies) | +| `client.policy_sets` | `PolicySets` | `list`, `read`, `create`, `update`, `delete`, add/remove policies, projects, workspaces, exclusions | [policy_set.py](../../examples/policy_set.py) | [Policy sets](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policy-sets) | +| `client.policy_set_parameters` | `PolicySetParameters` | `list`, `read`, `create`, `update`, `delete` | [policy_set_parameter.py](../../examples/policy_set_parameter.py) | [Policy sets](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policy-sets) | +| `client.policy_set_versions` | `PolicySetVersions` | `create`, `read`, `upload` | [policy_set.py](../../examples/policy_set.py) | [Policy set versions](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policy-sets) | +| `client.policy_set_outcomes` | `PolicySetOutcomes` | `list`, `read` | [policy_set.py](../../examples/policy_set.py) | [Policy evaluations](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policy-evaluations) | +| `client.policy_checks` | `PolicyChecks` | `list`, `read`, `override`, `logs` | [policy_check.py](../../examples/policy_check.py) | [Policy checks](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policy-checks) | +| `client.policy_evaluations` | `PolicyEvaluations` | `list` | [policy_evaluation.py](../../examples/policy_evaluation.py) | [Policy evaluations](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policy-evaluations) | + +## Run tasks + +| Client attribute | Resource class | Common methods | Example | Upstream API docs | +|---|---|---|---|---| +| `client.run_tasks` | `RunTasks` | `list`, `read`, `create`, `update`, `delete` | [run_task.py](../../examples/run_task.py) | [Run tasks](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/run-tasks/run-tasks) | +| `client.workspace_run_tasks` | `WorkspaceRunTasks` | `list`, `read`, `create`, `update`, `delete` | [workspace_run_task.py](../../examples/workspace_run_task.py) | [Run tasks](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/run-tasks/run-tasks) | +| `client.run_task_integrations` | `RunTaskIntegrations` | `callback` | [run_task_integration.py](../../examples/run_task_integration.py) | [Run task integration](https://developer.hashicorp.com/terraform/enterprise/api-docs/run-tasks/run-tasks-integration) | +| `client.task_stages` | `TaskStages` | `list`, `read`, `override` | [task_stage_example.py](../../examples/task_stage_example.py) | [Run task stages and results](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/run-tasks/run-task-stages-and-results) | +| `client.task_results` | `TaskResults` | `read` | [task_result.py](../../examples/task_result.py) | [Run task stages and results](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/run-tasks/run-task-stages-and-results) | + +## Agents, registry, integrations, and other resources + +| Client attribute | Resource class | Common methods | Example | Upstream API docs | +|---|---|---|---|---| +| `client.agent_pools` | `AgentPools` | `list`, `read`, `create`, `update`, `delete`, assign/remove workspaces/projects | [agent_pool.py](../../examples/agent_pool.py) | [Agents](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/agents) | +| `client.agents` | `Agents` | `list`, `read`, `delete` | [agent.py](../../examples/agent.py) | [Agents](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/agents) | +| `client.agent_tokens` | `AgentTokens` | `list`, `read`, `create`, `delete` | [agent.py](../../examples/agent.py) | [Agent tokens](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/agent-tokens) | +| `client.registry_modules` | `RegistryModules` | `list`, `read`, `create`, `update`, `delete`, version and upload helpers | [registry_module.py](../../examples/registry_module.py) | [Registry modules](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/modules) | +| `client.registry_providers` | `RegistryProviders` | `list`, `read`, `create`, `delete` | [registry_provider.py](../../examples/registry_provider.py) | [Registry providers](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/providers) | +| `client.registry_provider_versions` | `RegistryProviderVersions` | `list`, `read`, `create`, `delete` | [registry_provider_version.py](../../examples/registry_provider_version.py) | [Registry providers](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/providers) | +| `client.registry_provider_platforms` | `RegistryProviderPlatforms` | `list`, `read`, `create`, `delete` | [registry_provider_platform.py](../../examples/registry_provider_platform.py) | [Registry providers](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/providers) | +| `client.oauth_clients` | `OAuthClients` | `list`, `read`, `create`, `update`, `delete`, project relationships | [oauth_client.py](../../examples/oauth_client.py) | [OAuth clients](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/oauth-clients) | +| `client.oauth_tokens` | `OAuthTokens` | `list`, `read`, `update`, `delete` | [oauth_token.py](../../examples/oauth_token.py) | [OAuth tokens](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/oauth-tokens) | +| `client.notification_configurations` | `NotificationConfigurations` | `list`, `read`, `create`, `update`, `delete`, `verify` | [notification_configuration.py](../../examples/notification_configuration.py) | [Notification configurations](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/notification-configurations) | +| `client.organization_audit_configurations` | `OrganizationAuditConfigurations` | `read`, `test`, `update` | [organization_audit_configuration.py](../../examples/organization_audit_configuration.py) | [Audit trail](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/audit-trails) | +| `client.organization_tags` | `OrganizationTags` | `list`, `delete`, `add_workspaces` | [organization_tags.py](../../examples/organization_tags.py) | [Organization tags](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/organization-tags) | +| `client.comments` | `Comments` | `list`, `read`, `create` | [comment.py](../../examples/comment.py) | [Comments](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/comments) | +| `client.explorer` | `Explorer` | query and saved-view helpers | [explorer.py](../../examples/explorer.py) | [Explorer](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/explorer) | +| `client.stacks` | `Stacks` | `list`, `read`, `create`, `update`, `delete`, `force_delete`, VCS fetch | [stack.py](../../examples/stack.py) | [Stacks](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/stacks) | +| `client.stack_configurations` | `StackConfigurations` | `list`, `read`, `create` | [stack_configuration.py](../../examples/stack_configuration.py) | [Stacks](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/stacks) | + +## Focused guides + +- [workspaces.md](workspaces.md) +- [runs-plans-applies.md](runs-plans-applies.md) +- [state-versions.md](state-versions.md) +- [variables-and-variable-sets.md](variables-and-variable-sets.md) +- [teams-and-access.md](teams-and-access.md) +- [policies.md](policies.md) +- [run-tasks.md](run-tasks.md) diff --git a/docs/api/policies.md b/docs/api/policies.md new file mode 100644 index 00000000..7a0f93ac --- /dev/null +++ b/docs/api/policies.md @@ -0,0 +1,69 @@ +# Policies + +pyTFE supports policy libraries, policy sets, policy set parameters, policy set +versions, policy checks, policy evaluations, and policy set outcomes. + +Upstream docs: + +- Policies: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policies +- Policy sets: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policy-sets +- Policy checks: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policy-checks +- Policy evaluations: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policy-evaluations + +Examples: + +- [policy.py](../../examples/policy.py) +- [policy_set.py](../../examples/policy_set.py) +- [policy_check.py](../../examples/policy_check.py) +- [policy_evaluation.py](../../examples/policy_evaluation.py) + +## Policies + +| Method | Purpose | +|---|---| +| `client.policies.list(organization, options=None)` | Iterate policies. | +| `client.policies.read(policy_id)` | Read a policy. | +| `client.policies.create(organization, options)` | Create a policy. | +| `client.policies.update(policy_id, options)` | Update a policy. | +| `client.policies.delete(policy_id)` | Delete a policy. | +| `client.policies.upload(policy_id, content)` | Upload policy content. | +| `client.policies.download(policy_id)` | Download policy content. | + +## Policy sets + +| Method | Purpose | +|---|---| +| `client.policy_sets.list(organization, options=None)` | Iterate policy sets. | +| `client.policy_sets.read(policy_set_id)` | Read a policy set. | +| `client.policy_sets.read_with_options(policy_set_id, options)` | Read with includes. | +| `client.policy_sets.create(organization, options)` | Create a policy set. | +| `client.policy_sets.update(policy_set_id, options)` | Update a policy set. | +| `client.policy_sets.delete(policy_set_id)` | Delete a policy set. | +| `client.policy_sets.add_policies(...)` / `remove_policies(...)` | Attach or remove policies. | +| `client.policy_sets.add_workspaces(...)` / `remove_workspaces(...)` | Attach or remove workspaces. | +| `client.policy_sets.add_projects(...)` / `remove_projects(...)` | Attach or remove projects. | +| `client.policy_sets.add_workspace_exclusions(...)` / `remove_workspace_exclusions(...)` | Manage workspace exclusions. | +| `client.policy_sets.add_project_exclusions(...)` / `remove_project_exclusions(...)` | Manage project exclusions. | + +## Policy checks + +Policy checks are attached to runs: + +```python +for check in client.policy_checks.list("run-abc123"): + print(check.id, check.status) +``` + +Override a policy check only when your token has permission: + +```python +client.policy_checks.override("polchk-abc123") +``` + +## Policy set parameters and versions + +- `client.policy_set_parameters` manages parameter values for policy sets. +- `client.policy_set_versions` creates and uploads policy set versions. +- `client.policy_set_outcomes` reads outcome data. +- `client.policy_evaluations` lists policy evaluations. + diff --git a/docs/api/run-tasks.md b/docs/api/run-tasks.md new file mode 100644 index 00000000..bc473c73 --- /dev/null +++ b/docs/api/run-tasks.md @@ -0,0 +1,103 @@ +# Run tasks + +Run tasks integrate external systems into the run lifecycle. pyTFE supports +organization run tasks, workspace run task attachments, run task webhook +callbacks, task stages, and task results. + +Upstream docs: + +- Run tasks: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/run-tasks/run-tasks +- Run task stages and results: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/run-tasks/run-task-stages-and-results +- Run task integration: https://developer.hashicorp.com/terraform/enterprise/api-docs/run-tasks/run-tasks-integration + +Examples: + +- [run_task.py](../../examples/run_task.py) +- [workspace_run_task.py](../../examples/workspace_run_task.py) +- [run_task_integration.py](../../examples/run_task_integration.py) +- [task_stage_example.py](../../examples/task_stage_example.py) +- [task_result.py](../../examples/task_result.py) + +## Organization run tasks + +| Method | Purpose | +|---|---| +| `client.run_tasks.list(organization, options=None)` | Iterate run tasks in an organization. | +| `client.run_tasks.read(task_id)` | Read a run task. | +| `client.run_tasks.read_with_options(task_id, options)` | Read with included relationships. | +| `client.run_tasks.create(organization, options)` | Create a run task. | +| `client.run_tasks.update(task_id, options)` | Update a run task. | +| `client.run_tasks.delete(task_id)` | Delete a run task. | + +```python +from pytfe.models import RunTaskCreateOptions + +task = client.run_tasks.create( + "my-organization", + RunTaskCreateOptions( + name="security-check", + url="https://example.com/tfc/run-task", + category="task", + enabled=True, + ), +) + +print(task.id) +``` + +## Workspace run task attachments + +Attach an organization run task to a workspace with +`client.workspace_run_tasks`: + +```python +from pytfe.models import ( + RunTask, + Stage, + TaskEnforcementLevel, + WorkspaceRunTaskCreateOptions, +) + +attachment = client.workspace_run_tasks.create( + "ws-abc123", + WorkspaceRunTaskCreateOptions( + enforcement_level=TaskEnforcementLevel.MANDATORY, + run_task=RunTask(id="task-abc123"), + stages=[Stage.PRE_PLAN], + ), +) + +print(attachment.id) +``` + +Stage enum values must match the API wire values. Do not convert documented +stage names to a different spelling. + +## Integration callbacks + +Run task callback handling is exposed through `client.run_task_integrations`. +The callback URL and token come from a run task webhook payload: + +```python +from pytfe.models import TaskResultCallbackRequestOptions, TaskResultStatus + + +client.run_task_integrations.callback( + callback_url, + access_token, + TaskResultCallbackRequestOptions( + status=TaskResultStatus.passed, + message="Checks passed", + ), +) +``` + +## Task stages and results + +Use `client.task_stages` and `client.task_results` for stage-level inspection +and overrides: + +```python +stage = client.task_stages.read("ts-abc123") +result = client.task_results.read("taskrs-abc123") +``` diff --git a/docs/api/runs-plans-applies.md b/docs/api/runs-plans-applies.md new file mode 100644 index 00000000..704748a6 --- /dev/null +++ b/docs/api/runs-plans-applies.md @@ -0,0 +1,105 @@ +# Runs, plans, and applies + +Runs represent the lifecycle of a Terraform operation. A run can have a plan, +an apply, policy checks, task stages, comments, events, and a configuration +version. + +Upstream docs: + +- Runs: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/run +- Plans: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/plans +- Applies: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/applies +- Configuration versions: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/configuration-versions + +Examples: + +- [run.py](../../examples/run.py) +- [plan.py](../../examples/plan.py) +- [apply.py](../../examples/apply.py) +- [configuration_version.py](../../examples/configuration_version.py) + +## Common run methods + +| Method | Purpose | +|---|---| +| `client.runs.list(workspace_id, options=None)` | Iterate runs for a workspace. | +| `client.runs.list_for_organization(organization, options=None)` | Iterate runs across an organization. | +| `client.runs.read(run_id)` | Read a run. | +| `client.runs.read_with_options(run_id, options)` | Read with included relationships. | +| `client.runs.create(options)` | Queue a run. | +| `client.runs.apply(run_id, options=None)` | Confirm/apply a run. | +| `client.runs.cancel(run_id, options=None)` | Cancel a run. | +| `client.runs.force_cancel(run_id, options=None)` | Force-cancel a run. | +| `client.runs.discard(run_id, options=None)` | Discard a run. | + +## List workspace runs + +```python +from pytfe import TFEClient +from pytfe.models import RunListOptions + +client = TFEClient() + +options = RunListOptions(page_size=50, status="planned") + +for run in client.runs.list("ws-abc123", options): + print(run.id, run.status) +``` + +## Read a run with relationships + +```python +from pytfe.models import RunIncludeOpt, RunReadOptions + +run = client.runs.read_with_options( + "run-abc123", + RunReadOptions(include=[RunIncludeOpt.RUN_WORKSPACE, RunIncludeOpt.RUN_PLAN]), +) + +print(run.workspace.id if run.workspace else None) +print(run.plan.id if run.plan else None) +``` + +## Queue a run + +```python +from pytfe.models import RunCreateOptions, Workspace + +run = client.runs.create( + RunCreateOptions( + workspace=Workspace(id="ws-abc123"), + message="Queued by pyTFE", + ) +) + +print(run.id) +``` + +## Plans and JSON output + +```python +plan = client.plans.read_for_run("run-abc123") +json_output = client.plans.read_json_output_for_run("run-abc123") + +print(plan.id) +print(json_output.get("format_version")) +``` + +Plan JSON output and schema endpoints may redirect to signed blob URLs. pyTFE +handles those redirects internally. + +## Applies and errored state + +```python +apply = client.applies.read("apply-abc123") +logs = client.applies.logs(apply.id) + +try: + errored_state = client.applies.errored_state(apply.id) +except Exception: + errored_state = None +``` + +`errored_state` is only available for applies that failed during state upload. +The API returns `404` when there is no recoverable errored state. + diff --git a/docs/api/state-versions.md b/docs/api/state-versions.md new file mode 100644 index 00000000..f829cd40 --- /dev/null +++ b/docs/api/state-versions.md @@ -0,0 +1,97 @@ +# State versions + +State versions represent Terraform state snapshots stored by HCP Terraform or +Terraform Enterprise. Use this API carefully: state can contain sensitive +values. + +Upstream docs: + +- State versions: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/state-versions +- State version outputs: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/state-version-outputs + +Example: [state_versions.py](../../examples/state_versions.py) + +## Common methods + +| Method | Purpose | +|---|---| +| `client.state_versions.list(options=None)` | Iterate state versions with optional organization/workspace filters. | +| `client.state_versions.read(state_version_id)` | Read a state version. | +| `client.state_versions.read_current(workspace_id)` | Read the current state version for a workspace. | +| `client.state_versions.create(workspace, options, organization=None)` | Create a state-version record. | +| `client.state_versions.upload(...)` | Create a state version and upload raw state bytes. | +| `client.state_versions.download(state_version_id)` | Download raw state bytes. | +| `client.state_versions.download_current(workspace_id)` | Download current raw state bytes. | +| `client.state_versions.list_outputs(state_version_id, options=None)` | Iterate outputs for a state version. | +| `client.state_versions.rollback(workspace_id, state_version_id)` | Roll a workspace back to an earlier state version. | +| `client.state_version_outputs.read(output_id)` | Read a single output. | +| `client.state_version_outputs.read_current(workspace_id, options=None)` | Iterate current outputs for a workspace. | + +## List state versions + +```python +from pytfe import TFEClient +from pytfe.models import StateVersionListOptions + +client = TFEClient() + +options = StateVersionListOptions( + organization="my-organization", + workspace="example-workspace", + page_size=50, +) + +for state_version in client.state_versions.list(options): + print(state_version.id, state_version.serial, state_version.status) +``` + +## Read or download current state + +```python +current = client.state_versions.read_current("ws-abc123") +raw_state = client.state_versions.download_current("ws-abc123") + +print(current.id) +print(len(raw_state)) +``` + +Downloaded state bytes should be treated as sensitive. + +## Upload state + +```python +import hashlib + +from pytfe.models import StateVersionCreateOptions + +raw_state = b"{... terraform state json ...}" + +state_version = client.state_versions.upload( + "ws-abc123", + raw_state=raw_state, + options=StateVersionCreateOptions( + serial=42, + md5=hashlib.md5(raw_state).hexdigest(), + ), +) + +print(state_version.id, state_version.status) +``` + +`upload` follows the API's hosted upload URL workflow and returns a refreshed +state-version object. Depending on server timing, the returned state version may +still be processing. + +## Roll back a workspace + +```python +rolled_back = client.state_versions.rollback( + "ws-abc123", + "sv-previous123", +) + +print(rolled_back.id) +``` + +The workspace must be locked by the caller before rollback; otherwise the API +returns a conflict. diff --git a/docs/api/teams-and-access.md b/docs/api/teams-and-access.md new file mode 100644 index 00000000..b0909fc8 --- /dev/null +++ b/docs/api/teams-and-access.md @@ -0,0 +1,76 @@ +# Teams and access + +Team and access APIs control who can view, plan, apply, manage variables, and +administer workspaces or projects. + +Upstream docs: + +- Teams: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/teams +- Team tokens: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/team-tokens +- Team access: https://developer.hashicorp.com/terraform/enterprise/api-docs/team-access +- Project team access: https://developer.hashicorp.com/terraform/enterprise/api-docs/project-team-access +- Organization memberships: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/organization-memberships + +Examples: + +- [team.py](../../examples/team.py) +- [team_workspace_access.py](../../examples/team_workspace_access.py) +- [team_project_access.py](../../examples/team_project_access.py) +- [team_token.py](../../examples/team_token.py) + +## Teams + +| Method | Purpose | +|---|---| +| `client.teams.list(organization, options=None)` | Iterate teams in an organization. | +| `client.teams.read(team_id)` | Read a team. | +| `client.teams.create(organization, options)` | Create a team. | +| `client.teams.update(team_id, options)` | Update a team. | +| `client.teams.delete(team_id)` | Delete a team. | +| `client.teams.add_users(team_id, usernames)` | Add users by username. | +| `client.teams.remove_users(team_id, usernames)` | Remove users by username. | +| `client.teams.add_organization_memberships(team_id, ids)` | Add users by organization membership ID. | +| `client.teams.remove_organization_memberships(team_id, ids)` | Remove users by organization membership ID. | +| `client.teams.list_users(team_id)` | List users included in a team. | +| `client.teams.list_organization_memberships(team_id)` | List team memberships. | + +## Workspace access + +```python +from pytfe.models import ( + TeamWorkspaceAccessAddOptions, + TeamWorkspaceAccessType, +) + +grant = client.team_workspace_accesses.add( + TeamWorkspaceAccessAddOptions( + team_id="team-abc123", + workspace_id="ws-abc123", + access=TeamWorkspaceAccessType.WRITE, + ) +) + +print(grant.id) +``` + +Use `TeamWorkspaceAccessType.CUSTOM` with the custom permission fields when you +need to model fine-grained access. + +## Project access + +`client.team_project_accesses` manages access grants between teams and projects. +Use project access for broad permissions across all workspaces in a project. +Use workspace access for exceptions or smaller scopes. + +## Team tokens + +Team tokens are useful for automation owned by a team: + +```python +token = client.team_tokens.create("team-abc123") +print(token.token) +``` + +Store returned token values in a secret manager. Token values are sensitive and +may only be returned at creation time. + diff --git a/docs/api/variables-and-variable-sets.md b/docs/api/variables-and-variable-sets.md new file mode 100644 index 00000000..b68df60a --- /dev/null +++ b/docs/api/variables-and-variable-sets.md @@ -0,0 +1,76 @@ +# Variables and variable sets + +pyTFE has separate services for workspace variables, variable sets, and +variables inside variable sets. + +Upstream docs: + +- Workspace variables: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/workspace-variables +- Variable sets: https://developer.hashicorp.com/terraform/enterprise/api-docs/variable-sets + +Examples: + +- [variables.py](../../examples/variables.py) +- [variable_sets.py](../../examples/variable_sets.py) + +## Workspace variables + +| Method | Purpose | +|---|---| +| `client.variables.list(workspace_id, options=None)` | Iterate variables directly attached to a workspace. | +| `client.variables.list_all(workspace_id, options=None)` | Iterate direct and inherited variables. | +| `client.variables.read(workspace_id, variable_id)` | Read a variable. | +| `client.variables.create(workspace_id, options)` | Create a variable. | +| `client.variables.update(workspace_id, variable_id, options)` | Update a variable. | +| `client.variables.delete(workspace_id, variable_id)` | Delete a variable. | + +```python +from pytfe.models import CategoryType, VariableCreateOptions + +variable = client.variables.create( + "ws-abc123", + VariableCreateOptions( + key="TF_VAR_region", + value="us-east-1", + category=CategoryType.TERRAFORM, + sensitive=False, + ), +) + +print(variable.id) +``` + +Use `list_all` when you need variables inherited from variable sets: + +```python +for variable in client.variables.list_all("ws-abc123"): + print(variable.key) +``` + +## Variable sets + +| Method | Purpose | +|---|---| +| `client.variable_sets.list(organization, options=None)` | Iterate variable sets in an organization. | +| `client.variable_sets.list_for_workspace(workspace_id, options=None)` | Iterate variable sets attached to a workspace. | +| `client.variable_sets.list_for_project(project_id, options=None)` | Iterate variable sets attached to a project. | +| `client.variable_sets.read(varset_id, options=None)` | Read a variable set. | +| `client.variable_sets.create(organization, options)` | Create a variable set. | +| `client.variable_sets.update(varset_id, options)` | Update a variable set. | +| `client.variable_sets.delete(varset_id)` | Delete a variable set. | +| `client.variable_sets.apply_to_workspaces(...)` | Attach a variable set to workspaces. | +| `client.variable_sets.apply_to_projects(...)` | Attach a variable set to projects. | + +## Variables inside variable sets + +| Method | Purpose | +|---|---| +| `client.variable_set_variables.list(varset_id, options=None)` | Iterate variables in a variable set. | +| `client.variable_set_variables.read(varset_id, variable_id)` | Read a variable-set variable. | +| `client.variable_set_variables.create(varset_id, options)` | Create a variable-set variable. | +| `client.variable_set_variables.update(varset_id, variable_id, options)` | Update a variable-set variable. | +| `client.variable_set_variables.delete(varset_id, variable_id)` | Delete a variable-set variable. | + +Prefer variable sets for shared values across many workspaces or projects. +Prefer workspace variables for workspace-specific values. + diff --git a/docs/api/workspaces.md b/docs/api/workspaces.md new file mode 100644 index 00000000..eb1bae7f --- /dev/null +++ b/docs/api/workspaces.md @@ -0,0 +1,96 @@ +# Workspaces + +Workspaces are the center of most pyTFE workflows. Use `client.workspaces` for +workspace settings and relationships, then combine it with runs, variables, +state versions, teams, and policies as needed. + +Upstream docs: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/workspaces + +Example: [workspace.py](../../examples/workspace.py) + +## Common methods + +| Method | Purpose | +|---|---| +| `client.workspaces.list(organization, options=None)` | Iterate workspaces in an organization. | +| `client.workspaces.read(name, *, organization)` | Read by name. `organization` is keyword-only. | +| `client.workspaces.read_by_id(workspace_id)` | Read by workspace ID. | +| `client.workspaces.create(organization, options)` | Create a workspace. | +| `client.workspaces.update(name, options, *, organization)` | Update by name. `organization` is keyword-only. | +| `client.workspaces.update_by_id(workspace_id, options)` | Update by workspace ID. | +| `client.workspaces.delete(name, *, organization)` / `delete_by_id(workspace_id)` | Delete a workspace. | +| `client.workspaces.safe_delete(name, *, organization)` / `safe_delete_by_id(workspace_id)` | Delete with the API safe-delete path. | +| `client.workspaces.lock(...)`, `unlock(...)`, `force_unlock(...)` | Manage workspace locks. | +| `client.workspaces.assign_ssh_key(...)`, `unassign_ssh_key(...)` | Manage workspace SSH key assignment. | +| `client.workspaces.current_assessment_result(workspace_id)` | Read the latest health assessment, or `None` if assessments are disabled. | +| `client.workspaces.list_applicable_varsets(workspace_id)` | Iterate variable sets that apply to a workspace (direct, inherited, and global). | +| `client.workspaces.list_remote_state_consumers(...)` and related methods | Manage remote state consumers. | +| `client.workspaces.list_tags(...)`, `add_tags(...)`, `remove_tags(...)` | Manage workspace tags. | +| `client.workspaces.list_tag_bindings(...)` and related methods | Manage tag bindings. | + +## List and filter + +```python +from pytfe import TFEClient +from pytfe.models import WorkspaceListOptions + +client = TFEClient() + +options = WorkspaceListOptions(page_size=50, search="prod") + +for workspace in client.workspaces.list("my-organization", options): + print(workspace.id, workspace.name) +``` + +`list` returns an iterator. Use `list(client.workspaces.list(...))` if you need +a materialized Python list. + +## Create a workspace + +```python +from pytfe import TFEClient +from pytfe.models import WorkspaceCreateOptions + +client = TFEClient() + +workspace = client.workspaces.create( + "my-organization", + WorkspaceCreateOptions(name="example-workspace"), +) + +print(workspace.id) +``` + +## Read by name or ID + +```python +workspace = client.workspaces.read("example-workspace", organization="my-organization") +same_workspace = client.workspaces.read_by_id(workspace.id) +``` + +`organization` is a keyword-only argument on `read`, `update`, `delete`, and +`safe_delete`. The workspace name is positional; the organization name must be +passed by keyword. The same applies when updating or deleting by name: + +```python +from pytfe.models import WorkspaceUpdateOptions + +client.workspaces.update( + "example-workspace", + WorkspaceUpdateOptions(description="Updated by pyTFE"), + organization="my-organization", +) + +client.workspaces.delete("example-workspace", organization="my-organization") +``` + +Prefer ID-based methods in automation when you already have the workspace ID. +They avoid ambiguity when names change. + +## Related resources + +- Runs: [runs-plans-applies.md](runs-plans-applies.md) +- State: [state-versions.md](state-versions.md) +- Variables: [variables-and-variable-sets.md](variables-and-variable-sets.md) +- Teams and access: [teams-and-access.md](teams-and-access.md) + diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 00000000..e9100f0f --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,145 @@ +# Authentication + +pyTFE authenticates to HCP Terraform and Terraform Enterprise with an API token. +The SDK sends the token as a bearer token on API requests. + +HashiCorp's API overview documents bearer-token authentication, and HashiCorp's +API token guide explains user, team, group, and organization token behavior: + +- HCP Terraform API overview: https://developer.hashicorp.com/terraform/cloud-docs/api-docs +- API token guide: https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/api-tokens + +## Default environment-based configuration + +`TFEClient()` with no arguments calls `TFEConfig.from_env()`, which reads the +same defaults as `TFEConfig()`. + +```python +from pytfe import TFEClient + +client = TFEClient() +``` + +Supported SDK configuration environment variables: + +| Environment variable | `TFEConfig` field | Default | Notes | +|---|---|---|---| +| `TFE_TOKEN` | `token` | `""` | API token used for bearer authentication. Most real calls require this to be set. | +| `TFE_ADDRESS` | `address` | `https://app.terraform.io` | Base URL for HCP Terraform or Terraform Enterprise. Do not include `/api/v2`. | +| `TFE_TIMEOUT` | `timeout` | `30` | Request timeout in seconds. Parsed as a float. | +| `TFE_VERIFY_TLS` | `verify_tls` | `true` | Set to `0`, `false`, or `no` to disable TLS verification. Use this only for controlled local testing. | +| `TFE_MAX_RETRIES` | `max_retries` | `5` | Maximum retry attempts for transient transport/server failures. Parsed as an integer. | +| `SSL_CERT_FILE` | `ca_bundle` | unset | Path to a custom CA bundle, useful for Terraform Enterprise installations using an internal CA. | + +Example: + +```bash +export TFE_TOKEN="your-api-token" +export TFE_ADDRESS="https://app.terraform.io" +export TFE_TIMEOUT="60" +export TFE_MAX_RETRIES="5" +``` + +Then: + +```python +from pytfe import TFEClient + +client = TFEClient() +``` + +## Explicit configuration + +Explicit `TFEConfig(...)` values override environment fallback for the fields +you set: + +```python +from pytfe import TFEClient, TFEConfig + +config = TFEConfig( + address="https://tfe.example.com", + token="your-api-token", + timeout=60.0, + verify_tls=True, + max_retries=3, + ca_bundle="/etc/ssl/certs/internal-ca.pem", + user_agent_suffix="my-automation/1.0", +) + +client = TFEClient(config) +``` + +Use explicit configuration when a process needs multiple clients, when tests +need isolated config, or when credentials come from a secret manager instead of +process environment variables. + +## Token type guidance + +Choose the narrowest token type that can perform the workflow: + +| Token type | Typical use | +|---|---| +| User token | Interactive or user-owned automation. Most flexible because permissions follow the user. | +| Team token | Workspace automation owned by a team. Good for routine run, state, and workspace workflows where team permissions are already scoped. | +| Group token | HCP Europe equivalent for group-based access. | +| Organization token | Organization setup and administration, such as creating workspaces and teams. Avoid using it as a general-purpose automation token. | + +Some endpoints cannot be used with organization tokens. HashiCorp marks those +endpoints in the upstream API docs. For example, API-driven runs, configuration +version uploads, and state-version writes often need a user, team, or group +token with workspace permissions. + +Never commit tokens to source control. Prefer environment variables, CI secret +stores, or a secret manager. + +## HCP Terraform vs Terraform Enterprise addresses + +| Platform | Address value | +|---|---| +| HCP Terraform | `https://app.terraform.io` | +| HCP Terraform Europe | Use the organization URL, commonly `https://app.eu.terraform.io` | +| Terraform Enterprise | Your installation base URL, for example `https://tfe.example.com` | + +The SDK appends `/api/v2/...` paths internally. + +## TLS and custom CAs + +For Terraform Enterprise with an internal CA: + +```bash +export SSL_CERT_FILE="/path/to/internal-ca-bundle.pem" +``` + +Or explicitly: + +```python +from pytfe import TFEConfig + +config = TFEConfig(ca_bundle="/path/to/internal-ca-bundle.pem") +``` + +Disabling verification is supported for local testing: + +```bash +export TFE_VERIFY_TLS=false +``` + +Do not disable TLS verification for production automation. + +## `TFE_ORG` and `TFE_ORGANIZATION` + +`TFE_ORG` and `TFE_ORGANIZATION` are not SDK configuration fields. Some example +scripts read them as convenient defaults for an organization name: + +```bash +export TFE_ORG="my-organization" +python examples/workspace.py +``` + +Application code should pass organization names to resource methods directly: + +```python +for workspace in client.workspaces.list("my-organization"): + print(workspace.name) +``` + diff --git a/docs/errors.md b/docs/errors.md new file mode 100644 index 00000000..1b472e62 --- /dev/null +++ b/docs/errors.md @@ -0,0 +1,99 @@ +# Errors + +pyTFE raises Python exceptions for local validation failures, transport errors, +and API errors returned by HCP Terraform or Terraform Enterprise. + +## Base exception + +All SDK-owned API/transport errors inherit from `pytfe.errors.TFEError`: + +```python +from pytfe.errors import TFEError + +try: + workspace = client.workspaces.read_by_id("ws-abc123") +except TFEError as exc: + print(exc) + print(exc.status) + print(exc.errors) +``` + +`TFEError` exposes: + +- `status`: HTTP status code when available. +- `errors`: parsed JSON:API error objects when available. + +## Common typed errors + +| Error | Typical cause | +|---|---| +| `AuthError` | Unauthorized or forbidden request. | +| `NotFound` | Resource not found or not visible to the token. | +| `RateLimited` | Server asked the client to slow down. Includes `retry_after` when available. | +| `ValidationError` | API validation failure. | +| `ServerError` | Server-side or transport failure. | +| `UnsupportedInCloud` | Endpoint only supported in Terraform Enterprise. | +| `UnsupportedInEnterprise` | Endpoint only supported in HCP Terraform. | + +Resource-specific errors, such as `InvalidWorkspaceIDError` or +`InvalidRunIDError`, also live in `pytfe.errors`. + +## Local validation errors + +Some existing public methods raise `ValueError` for invalid local input. This is +kept for backward compatibility. Newer APIs generally prefer typed `TFEError` +subclasses, but callers should be prepared for both in older resource surfaces. + +```python +from pytfe.errors import TFEError + +try: + runs = list(client.runs.list("")) +except TFEError as exc: + handle_sdk_error(exc) +except ValueError as exc: + handle_local_validation_error(exc) +``` + +## Downstream tools and Ansible modules + +Downstream tools should catch narrow errors when they can produce a useful +message, then catch `TFEError` for general API failures: + +```python +from pytfe.errors import AuthError, NotFound, TFEError + +try: + workspace = client.workspaces.read_by_id(workspace_id) +except AuthError as exc: + module.fail_json(msg=f"authentication failed: {exc}") +except NotFound as exc: + module.fail_json(msg=f"workspace not found or not visible: {exc}") +except TFEError as exc: + module.fail_json( + msg=str(exc), + status=exc.status, + errors=exc.errors, + ) +except ValueError as exc: + module.fail_json(msg=f"invalid module input: {exc}") +``` + +Avoid string-matching error messages when a typed exception, HTTP status, or +JSON:API error pointer is available. + +## Inspecting API error details + +When the server returns JSON:API errors, `exc.errors` may contain structured +entries: + +```python +try: + make_request() +except TFEError as exc: + for error in exc.errors: + print(error) +``` + +Use these details for logs and diagnostics, but keep user-facing messages short +and avoid printing secrets. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 00000000..cab08515 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,107 @@ +# Getting started + +pyTFE is a Python client for the HCP Terraform and Terraform Enterprise API v2. +The client exposes one `TFEClient` object with resource services such as +`client.workspaces`, `client.runs`, and `client.state_versions`. + +## Install + +```bash +pip install pytfe +``` + +For local development from this repository: + +```bash +pip install -e .[dev] +``` + +## Configure credentials + +The quickest setup is environment variables: + +```bash +export TFE_TOKEN="your-api-token" +export TFE_ADDRESS="https://app.terraform.io" +``` + +`TFE_ADDRESS` defaults to `https://app.terraform.io`, so HCP Terraform users +usually only need `TFE_TOKEN`. Terraform Enterprise users should set +`TFE_ADDRESS` to the base URL of their self-hosted instance, for example +`https://tfe.example.com`. Do not include `/api/v2`; pyTFE adds API paths. + +For HCP Terraform Europe, use the address shown for your organization, commonly +`https://app.eu.terraform.io`. + +## Create a client + +With environment variables: + +```python +from pytfe import TFEClient + +client = TFEClient() +``` + +With explicit configuration: + +```python +from pytfe import TFEClient, TFEConfig + +config = TFEConfig( + address="https://app.terraform.io", + token="your-api-token", + timeout=30.0, +) +client = TFEClient(config) +``` + +Explicit `TFEConfig(...)` values are useful in applications that manage more +than one HCP Terraform or Terraform Enterprise instance. + +## First API call + +List the organizations visible to the token: + +```python +from pytfe import TFEClient + +client = TFEClient() + +for organization in client.organizations.list(): + print(organization.name) +``` + +List workspaces in an organization: + +```python +from pytfe import TFEClient + +client = TFEClient() + +for workspace in client.workspaces.list("my-organization"): + print(workspace.id, workspace.name) +``` + +All methods named `list` or `list_*` return iterators. Use `list(...)` when you +need a concrete Python list: + +```python +workspaces = list(client.workspaces.list("my-organization")) +print(f"found {len(workspaces)} workspaces") +``` + +See [pagination.md](pagination.md) for details. + +## Common next steps + +- [authentication.md](authentication.md) documents supported environment + variables, token types, TLS settings, and explicit `TFEConfig` fields. +- [pagination.md](pagination.md) explains list iterators and page-size options. +- [api/index.md](api/index.md) maps `TFEClient` attributes to pyTFE resources, + examples, and upstream HCP Terraform API docs. +- [scenarios/api-driven-run.md](scenarios/api-driven-run.md) walks through a + full API-driven run from configuration upload to final status. +- [troubleshooting.md](troubleshooting.md) covers auth, permissions, + pagination, TLS, retries, and debug logging. +- [../examples](../examples) contains runnable scripts for common workflows. diff --git a/docs/pagination.md b/docs/pagination.md new file mode 100644 index 00000000..370d1c9b --- /dev/null +++ b/docs/pagination.md @@ -0,0 +1,95 @@ +# Pagination + +HCP Terraform list endpoints are paginated. pyTFE hides the page loop behind +Python iterators so callers can stream results naturally. + +## The rule + +Every public resource method named `list` or `list_*` returns an iterator: + +```python +for workspace in client.workspaces.list("my-organization"): + print(workspace.name) +``` + +The SDK fetches more pages as the iterator advances. + +## Materialize when you need a Python list + +Use `list(...)` when you need indexing, `len(...)`, sorting, or multiple passes: + +```python +workspaces = list(client.workspaces.list("my-organization")) + +print(len(workspaces)) +print(workspaces[0].name) +``` + +## Iterators are single-use + +Once an iterator has been consumed, iterating it again returns no items: + +```python +workspace_iter = client.workspaces.list("my-organization") + +first_pass = list(workspace_iter) +second_pass = list(workspace_iter) # [] +``` + +Create a new iterator or materialize the results first. + +## Iterators are always truthy + +Do not use `if client.workspaces.list(...):` to check whether results exist. +Python iterator objects are truthy even if the API would return zero items. + +Use: + +```python +workspaces = list(client.workspaces.list("my-organization")) +if workspaces: + print("found workspaces") +``` + +## Page-size options + +Many resources have a `*ListOptions` model with `page_size`, filters, search +fields, or include options. The SDK still returns an iterator; `page_size` only +controls how many items each underlying API request asks for. + +```python +from pytfe.models import WorkspaceListOptions + +options = WorkspaceListOptions(page_size=50, search="prod") + +for workspace in client.workspaces.list("my-organization", options): + print(workspace.name) +``` + +Runs support both page size and filters: + +```python +from pytfe.models import RunListOptions + +options = RunListOptions(page_size=50, status="planned") + +for run in client.runs.list("ws-abc123", options): + print(run.id, run.status) +``` + +## Common gotchas + +- `list` / `list_*` methods are lazy. If an invalid-id check is inside a + generator method, the exception is raised when you iterate, not when you + create the iterator. +- Some relationship endpoints are not paginated by the server, but pyTFE still + exposes them as iterators for a consistent public API. +- A small number of older methods intentionally return concrete lists for + backward compatibility. Prefer the iterator rule for new code, and check the + method's return type if you are unsure. +- `page[number]` is managed internally by pyTFE's pagination helper. In normal + usage, set filters and `page_size`, then iterate. + +For contributor implementation rules, see the internal reference +[ITERATORS.md](ITERATORS.md). + diff --git a/docs/scenarios/agent-pool-setup.md b/docs/scenarios/agent-pool-setup.md new file mode 100644 index 00000000..a4fd34f8 --- /dev/null +++ b/docs/scenarios/agent-pool-setup.md @@ -0,0 +1,170 @@ +# Scenario: Agent pool setup + +HCP Terraform agents let Terraform runs reach private networks that the hosted +runners cannot. A working agent setup needs four pieces: + +1. An agent pool in the organization. +2. An agent authentication token for each running agent process. +3. Workspaces (or projects) configured to use the pool. +4. One or more agent processes started with the token, pointing at the API + address. + +This scenario covers the SDK side: creating the pool, generating a token, and +attaching workspaces. Starting the agent process itself is done outside Python +with the `tfc-agent` binary. + +Upstream docs: + +- Agents: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/agents +- Agent tokens: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/agent-tokens +- Agent pool concepts: https://developer.hashicorp.com/terraform/cloud-docs/agents + +## Prerequisites + +```bash +export TFE_TOKEN="your-api-token" +export TFE_ADDRESS="https://app.terraform.io" +``` + +The token needs `manage-agent-pools` permission on the organization, and +workspace write access on any workspaces you intend to attach. + +## Step 1: Create the agent pool + +```python +from pytfe import TFEClient +from pytfe.models import AgentPoolCreateOptions + + +client = TFEClient() +organization = "my-organization" + +pool = client.agent_pools.create( + organization, + AgentPoolCreateOptions( + name="private-network-pool", + organization_scoped=False, + allowed_workspace_ids=["ws-abc123"], + ), +) + +print(pool.id, pool.name) +``` + +Set `organization_scoped=True` to allow every workspace in the organization to +use the pool. Set it to `False` and pass `allowed_workspace_ids` (and/or +`allowed_project_ids`) to scope the pool explicitly. Scoped pools are safer for +shared organizations because they prevent unrelated workspaces from picking up +the agent. + +## Step 2: Create an agent token + +Each running agent process needs its own token: + +```python +from pytfe.models import AgentTokenCreateOptions + +token = client.agent_tokens.create( + pool.id, + AgentTokenCreateOptions(description="agent-host-1"), +) + +print(token.id) +print(token.token) +``` + +The token value is returned only at creation time. Store it in a secret manager +immediately and reference it from the agent host's environment. + +For multiple agents, create one token per host so revocation is granular: + +```python +for host in ["agent-host-1", "agent-host-2", "agent-host-3"]: + t = client.agent_tokens.create( + pool.id, + AgentTokenCreateOptions(description=host), + ) + save_to_secret_manager(host, t.token) +``` + +## Step 3: Attach workspaces to the pool + +A workspace uses an agent pool when its `execution_mode` is `agent` and its +`agent_pool_id` references the pool: + +```python +from pytfe.models import ExecutionMode, WorkspaceUpdateOptions + +client.workspaces.update_by_id( + "ws-abc123", + WorkspaceUpdateOptions( + execution_mode=ExecutionMode.AGENT, + agent_pool_id=pool.id, + ), +) +``` + +For a scoped pool, you can also widen or narrow the allowed list later: + +```python +from pytfe.models import AgentPoolAssignToWorkspacesOptions + +client.agent_pools.assign_to_workspaces( + pool.id, + AgentPoolAssignToWorkspacesOptions( + workspace_ids=["ws-abc123", "ws-def456"], + ), +) +``` + +`assign_to_workspaces` replaces the allowed-workspaces list in full; it does +not append. Always pass the complete intended list. + +## Step 4: Start the agent process + +Outside Python, on the host that has the network path to your private +infrastructure: + +```bash +export TFC_AGENT_TOKEN="" +export TFC_AGENT_NAME="agent-host-1" +export TFC_ADDRESS="https://app.terraform.io" + +tfc-agent +``` + +Confirm the agent registered: + +```python +for agent in client.agents.list(pool.id): + print(agent.id, agent.name, agent.status) +``` + +A healthy agent reports `status="idle"` or `status="busy"`. + +## Cleanup + +Revoke tokens when a host is decommissioned. Delete the pool only after no +workspaces or projects reference it. + +```python +client.agent_tokens.delete(token.id) + +# Detach the workspace before deleting the pool. +client.workspaces.update_by_id( + "ws-abc123", + WorkspaceUpdateOptions(execution_mode=ExecutionMode.REMOTE), +) +client.agent_pools.delete(pool.id) +``` + +## Operational notes + +- Treat agent tokens like SSH keys: one per host, rotated, stored in a secret + manager. +- Prefer scoped pools over organization-scoped pools when only some workspaces + need private-network access. +- Agent processes hold long-lived connections. Restart them after rotating + tokens or upgrading the agent binary. +- An agent pool with no running agents will leave runs queued indefinitely. + Monitor `client.agents.list(pool.id)` from your observability stack. diff --git a/docs/scenarios/api-driven-run.md b/docs/scenarios/api-driven-run.md new file mode 100644 index 00000000..6d12a4e2 --- /dev/null +++ b/docs/scenarios/api-driven-run.md @@ -0,0 +1,137 @@ +# Scenario: API-driven run + +This scenario shows the common API-driven workflow: + +1. Create or read a workspace. +2. Create a configuration version. +3. Upload Terraform configuration. +4. Queue a run using that configuration version. +5. Wait for the plan. +6. Read plan JSON output. +7. Apply the run. +8. Read the final run status. + +Upstream docs: + +- Configuration versions: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/configuration-versions +- Runs: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/run +- Plans: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/plans +- Applies: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/applies + +## Prerequisites + +Set authentication and choose an organization: + +```bash +export TFE_TOKEN="your-api-token" +export TFE_ADDRESS="https://app.terraform.io" +export TFE_ORG="my-organization" +``` + +The token needs permission to create or update the workspace, create +configuration versions, queue runs, and apply runs. + +## End-to-end example + +```python +import os +import time +from pathlib import Path + +from pytfe import TFEClient +from pytfe.models import ( + ConfigurationVersion, + ConfigurationVersionCreateOptions, + RunApplyOptions, + RunCreateOptions, + Workspace, + WorkspaceCreateOptions, +) +from pytfe.errors import TFEError + + +client = TFEClient() +organization = os.environ["TFE_ORG"] +workspace_name = "pytfe-api-driven-example" +terraform_dir = Path("./terraform") + + +def read_or_create_workspace() -> Workspace: + try: + return client.workspaces.read(workspace_name, organization=organization) + except TFEError: + return client.workspaces.create( + organization, + WorkspaceCreateOptions(name=workspace_name), + ) + + +workspace = read_or_create_workspace() + +config_version = client.configuration_versions.create( + workspace.id, + ConfigurationVersionCreateOptions(auto_queue_runs=False), +) + +if not config_version.upload_url: + raise RuntimeError("configuration version did not include an upload URL") + +client.configuration_versions.upload(config_version.upload_url, str(terraform_dir)) + +run = client.runs.create( + RunCreateOptions( + workspace=Workspace(id=workspace.id), + configuration_version=ConfigurationVersion(id=config_version.id), + message="Queued by pyTFE", + ) +) + +terminal_statuses = {"applied", "errored", "canceled", "discarded"} +plan_ready_statuses = { + "planned", + "planned_and_finished", + "planned_and_saved", + "policy_checked", + "policy_soft_failed", + "cost_estimated", +} + +while True: + run = client.runs.read(run.id) + status = run.status.value if run.status else "" + print("run status:", status) + + if status in plan_ready_statuses or status in terminal_statuses: + break + + time.sleep(5) + +plan_json = client.plans.read_json_output_for_run(run.id) +print("plan format:", plan_json.get("format_version")) + +if (run.status.value if run.status else "") not in terminal_statuses: + client.runs.apply(run.id, RunApplyOptions(comment="Applied by pyTFE")) + +while True: + run = client.runs.read(run.id) + status = run.status.value if run.status else "" + print("run status:", status) + + if status in terminal_statuses: + break + + time.sleep(5) + +print("final status:", run.status) +``` + +## Notes + +- `ConfigurationVersionCreateOptions(auto_queue_runs=False)` keeps the example + explicit: the code uploads configuration first, then queues a run. +- `configuration_versions.upload(upload_url, path)` packages the directory into + a tar gzip archive and uploads it to the hosted upload URL. +- Plan JSON endpoints may redirect to signed blob URLs. pyTFE follows those + redirects internally. +- Always add cleanup if this runs in CI or repeated integration tests. + diff --git a/docs/scenarios/errored-state-recovery.md b/docs/scenarios/errored-state-recovery.md new file mode 100644 index 00000000..2c93d065 --- /dev/null +++ b/docs/scenarios/errored-state-recovery.md @@ -0,0 +1,167 @@ +# Scenario: Recover from an errored apply + +When an apply fails after Terraform has already mutated real infrastructure but +before the new state file is uploaded, HCP Terraform stores the in-flight state +on the apply record. The workspace's current state still points at the old +version, so re-running Terraform without recovery will either replay destructive +changes or report drift it cannot reconcile. + +This scenario walks through the recovery path: + +1. Detect that an apply finished in `errored` and has recoverable errored state. +2. Download the errored state bytes. +3. Inspect or repair the state locally. +4. Upload the repaired state as the workspace's new current state version. + +Upstream docs: + +- Applies: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/applies +- State versions: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/state-versions +- Manipulating Terraform state: https://developer.hashicorp.com/terraform/cli/state + +## Prerequisites + +- A workspace with an apply in `errored` status. +- A token with workspace write access and permission to upload state versions. +- The workspace must be locked by the same caller before uploading state. + +```bash +export TFE_TOKEN="your-api-token" +export TFE_ADDRESS="https://app.terraform.io" +``` + +## Step 1: Download the errored state + +```python +from pytfe import TFEClient +from pytfe.errors import NotFound + + +client = TFEClient() +apply_id = "apply-abc123" + +try: + errored_state = client.applies.errored_state(apply_id) +except NotFound: + errored_state = None + +if errored_state is None: + raise SystemExit("apply has no recoverable errored state") + +print(f"downloaded {len(errored_state)} bytes") +``` + +`applies.errored_state` returns the raw bytes of the state file Terraform was +about to upload when the apply failed. `NotFound` means the apply either +succeeded, failed before any state was produced, or has already been recovered. + +Treat the returned bytes as secret. Do not log them or commit them to source +control. + +## Step 2: Inspect or repair the state locally + +Write the state to a temporary file with restricted permissions and use the +Terraform CLI to inspect or surgically edit it: + +```python +import os +import tempfile + +with tempfile.NamedTemporaryFile( + prefix="errored-", + suffix=".tfstate", + delete=False, +) as fh: + os.chmod(fh.name, 0o600) + fh.write(errored_state) + statefile_path = fh.name + +print(f"wrote state to {statefile_path}") +``` + +Typical local commands: + +```bash +terraform show -json "$statefile_path" | jq '.values.root_module.resources[].address' +terraform state list -state="$statefile_path" +terraform state rm -state="$statefile_path" 'aws_instance.removed_by_mistake' +``` + +Always make a backup copy before editing. State surgery is irreversible. + +## Step 3: Upload the repaired state + +The workspace must be locked by the caller before uploading state. Read the +current serial first and use a strictly greater serial for the new version. + +```python +import hashlib +from pathlib import Path + +from pytfe.models import StateVersionCreateOptions, WorkspaceLockOptions + + +workspace_id = "ws-abc123" + +repaired = Path(statefile_path).read_bytes() + +current = client.state_versions.read_current(workspace_id) +new_serial = (current.serial or 0) + 1 + +client.workspaces.lock( + workspace_id, + WorkspaceLockOptions(reason="Recover errored apply state via pyTFE"), +) + +try: + new_state = client.state_versions.upload( + workspace_id, + raw_state=repaired, + options=StateVersionCreateOptions( + serial=new_serial, + md5=hashlib.md5(repaired).hexdigest(), + ), + ) + print("uploaded", new_state.id, new_state.status) +finally: + client.workspaces.unlock(workspace_id) + os.unlink(statefile_path) +``` + +`state_versions.upload` follows the API's hosted upload-URL workflow: create the +state-version record, `PUT` the raw bytes to the signed Archivist URL, then +read the version back. Depending on server timing the returned version may +still be processing; poll `read` if you need to wait for `finalized`. + +## Step 4: Confirm the next run sees the repaired state + +After unlocking, queue a no-op plan to confirm Terraform sees the recovered +state: + +```python +from pytfe.models import RunCreateOptions, Workspace + +run = client.runs.create( + RunCreateOptions( + workspace=Workspace(id=workspace_id), + message="Verify errored-state recovery", + is_destroy=False, + ) +) +print("verification run:", run.id) +``` + +A plan that shows zero changes confirms recovery succeeded. A plan with +unexpected creates or destroys means the repaired state still diverges from +reality; do not apply until the divergence is understood. + +## Operational notes + +- Always lock the workspace before uploading state. The API returns `409` if + the workspace is unlocked or locked by a different caller. +- Pick a `serial` strictly greater than the current state's serial. Reusing or + decreasing the serial is rejected. +- Keep the downloaded bytes out of logs, CI artifacts, and long-lived disk. + Remove the temporary file in a `finally` block. +- Recovery is a manual operational action. Pair it with an incident note and a + follow-up to investigate why the apply failed mid-upload. diff --git a/docs/scenarios/manage-workspace-variables.md b/docs/scenarios/manage-workspace-variables.md new file mode 100644 index 00000000..ea39e802 --- /dev/null +++ b/docs/scenarios/manage-workspace-variables.md @@ -0,0 +1,124 @@ +# Scenario: Manage workspace variables + +HCP Terraform has two related variable concepts: + +- Workspace variables belong directly to one workspace. +- Variable sets are reusable collections that can apply to many workspaces or + projects. + +Use workspace variables for workspace-specific values. Use variable sets for +shared values such as cloud regions, common Terraform inputs, or provider +credentials reused across many workspaces. + +Upstream docs: + +- Workspace variables: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/workspace-variables +- Variable sets: https://developer.hashicorp.com/terraform/enterprise/api-docs/variable-sets + +## Workspace variables + +```python +from pytfe import TFEClient +from pytfe.models import CategoryType, VariableCreateOptions, VariableUpdateOptions + + +client = TFEClient() +workspace_id = "ws-abc123" + +region = client.variables.create( + workspace_id, + VariableCreateOptions( + key="TF_VAR_region", + value="us-east-1", + category=CategoryType.TERRAFORM, + sensitive=False, + ), +) + +updated = client.variables.update( + workspace_id, + region.id, + VariableUpdateOptions(value="us-west-2"), +) + +for variable in client.variables.list(workspace_id): + print(variable.id, variable.key, variable.category, variable.sensitive) + +client.variables.delete(workspace_id, updated.id) +``` + +Sensitive variable values may not be returned by the API after creation. Store +the source value in your secret manager; do not rely on reading it back. + +## Inherited variables + +`client.variables.list(...)` returns variables directly attached to a workspace. +Use `list_all(...)` when you also need variables inherited from variable sets: + +```python +for variable in client.variables.list_all("ws-abc123"): + print(variable.key) +``` + +## Variable sets + +```python +from pytfe.models import ( + CategoryType, + VariableSetApplyToWorkspacesOptions, + VariableSetCreateOptions, + VariableSetVariableCreateOptions, + Workspace, +) + + +varset = client.variable_sets.create( + "my-organization", + VariableSetCreateOptions( + name="shared-cloud-settings", + description="Shared cloud settings", + global_=False, + ), +) + +client.variable_set_variables.create( + varset.id, + VariableSetVariableCreateOptions( + key="TF_VAR_owner", + value="platform-team", + category=CategoryType.TERRAFORM, + sensitive=False, + ), +) + +client.variable_sets.apply_to_workspaces( + varset.id, + VariableSetApplyToWorkspacesOptions( + workspaces=[Workspace(id="ws-abc123")], + ), +) +``` + +## Update and cleanup + +```python +from pytfe.models import VariableSetVariableUpdateOptions + + +for variable in client.variable_set_variables.list(varset.id): + if variable.key == "TF_VAR_owner": + client.variable_set_variables.update( + varset.id, + variable.id, + VariableSetVariableUpdateOptions(value="infra-team"), + ) + +client.variable_sets.delete(varset.id) +``` + +## Operational tips + +- Treat sensitive variables as write-only. +- Prefer variable sets for shared values to avoid drift between workspaces. +- Use workspace variables for exceptions and workspace-local values. +- Be deliberate with global variable sets because they apply broadly. diff --git a/docs/scenarios/migrate-workspaces-and-state.md b/docs/scenarios/migrate-workspaces-and-state.md new file mode 100644 index 00000000..0ab578e8 --- /dev/null +++ b/docs/scenarios/migrate-workspaces-and-state.md @@ -0,0 +1,284 @@ +# Scenario: Migrate workspaces and state between instances + +Upstream docs: + +- Migration overview: https://developer.hashicorp.com/terraform/cloud-docs/migrate +- Workspaces: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/workspaces +- Workspace variables: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/workspace-variables +- State versions: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/state-versions + +## What pyTFE covers + +| Migration area | pyTFE coverage | Notes | +|---|---|---| +| Source and target clients | `TFEClient(TFEConfig(...))` | Use explicit configs so source and target tokens do not mix. | +| Workspace inventory and creation | `workspaces.list`, `read`, `create`, `update` | Copy only settings you understand. Keep VCS and agent settings deliberate. | +| Current state migration | `state_versions.read_current`, `download_current`, `upload` | Lock the target workspace before upload. Prefer new target workspaces. | +| Local state-file import | `workspaces.create`, `workspaces.lock`, `state_versions.upload`, `workspaces.unlock` | Useful when migrating many Terraform OSS state files. | +| Workspace variables | `variables.list`, `variables.create` | Sensitive values may not be readable. Supply them from a secret map. | +| Variable sets | `variable_sets`, `variable_set_variables` | Same sensitive-value caveat as workspace variables. | +| Teams and access | `teams`, `team_workspace_accesses`, `team_project_accesses` | Usernames, org membership IDs, and target team IDs need planning. | +| Policies and policy sets | `policies`, `policy_sets`, `policy_set_parameters`, `policy_set_versions` | Sensitive policy-set parameters must be rehydrated from a secure source. | +| SSH keys | `ssh_keys` | Public metadata can be listed, but private key material must be re-added. | +| Configuration versions | `configuration_versions.download`, `upload` | API availability and source retention vary. Keep local config archives when possible. | +| VCS connections | `oauth_clients`, `oauth_tokens`, workspace `vcs_repo` fields | Target VCS connections usually need a manual source-token to target-token map. GitHub App connections may need manual setup. | +| TFE admin settings | Partial or unsupported | SSO, cost estimation, some admin settings, and older TFE-only APIs are outside this scenario. | + +## Prepare source and target clients + +Use separate environment variables for the two sides: + +```bash +export TFE_SOURCE_ADDRESS="https://tfe-source.example.com" +export TFE_SOURCE_TOKEN="source-user-token" +export TFE_SOURCE_ORG="source-org" + +export TFE_TARGET_ADDRESS="https://app.terraform.io" +export TFE_TARGET_TOKEN="target-user-token" +export TFE_TARGET_ORG="target-org" +``` + +Then create explicit clients: + +```python +import os + +from pytfe import TFEClient, TFEConfig + + +source = TFEClient( + TFEConfig( + address=os.environ["TFE_SOURCE_ADDRESS"], + token=os.environ["TFE_SOURCE_TOKEN"], + ) +) +target = TFEClient( + TFEConfig( + address=os.environ["TFE_TARGET_ADDRESS"], + token=os.environ["TFE_TARGET_TOKEN"], + ) +) + +source_org = os.environ["TFE_SOURCE_ORG"] +target_org = os.environ["TFE_TARGET_ORG"] +``` + +Use user or team tokens with enough permission to read source data and create +target workspaces, variables, and state versions. + +## Create a matching target workspace + +Start with a conservative subset of workspace settings. Do not blindly copy +agent pools, VCS OAuth token IDs, SSH key IDs, or project IDs across instances; +those IDs are instance-local and usually need a target-side mapping. + +```python +from pytfe.errors import NotFound +from pytfe.models import Workspace, WorkspaceCreateOptions + + +def read_or_create_target_workspace(source_workspace: Workspace) -> Workspace: + assert source_workspace.name is not None + + try: + return target.workspaces.read(source_workspace.name, organization=target_org) + except NotFound: + pass + + options = WorkspaceCreateOptions( + name=source_workspace.name, + description=source_workspace.description, + terraform_version=source_workspace.terraform_version, + working_directory=source_workspace.working_directory, + auto_apply=source_workspace.auto_apply, + file_triggers_enabled=source_workspace.file_triggers_enabled, + global_remote_state=source_workspace.global_remote_state, + queue_all_runs=source_workspace.queue_all_runs, + speculative_enabled=source_workspace.speculative_enabled, + trigger_prefixes=source_workspace.trigger_prefixes or None, + trigger_patterns=source_workspace.trigger_patterns or None, + ) + return target.workspaces.create(target_org, options) +``` + +If the source workspace uses VCS, agents, SSH keys, or project placement, +create the target-side resources first and pass the mapped target IDs in a +separate migration pass. + +## Copy workspace variables + +Non-sensitive variables can be copied from the source API response. Sensitive +variables usually cannot be read back, so pass their values in from a secret +manager or an operator-reviewed JSON file. + +```python +from pytfe.models import VariableCreateOptions + + +def copy_workspace_variables( + source_workspace_id: str, + target_workspace_id: str, + *, + sensitive_values: dict[str, str], +) -> list[str]: + missing_sensitive: list[str] = [] + + for variable in source.variables.list(source_workspace_id): + if variable.sensitive: + value = sensitive_values.get(variable.key or "") + if value is None: + missing_sensitive.append(variable.key or "") + continue + else: + value = variable.value + + target.variables.create( + target_workspace_id, + VariableCreateOptions( + key=variable.key, + value=value, + description=variable.description, + category=variable.category, + hcl=variable.hcl, + sensitive=variable.sensitive, + ), + ) + + return missing_sensitive +``` + +Do not print sensitive values. If `missing_sensitive` is not empty, pause the +migration and fill the secret map before running a real plan. + +## Migrate current state from a source workspace + +This copies only the current state version. That is the safest default for a +trimmed migration. Historical state-version migration is possible with +`state_versions.list(...)`, but it is slower, noisier, and usually not needed +for a functional cutover. + +```python +import hashlib + +from pytfe.models import StateVersionCreateOptions, WorkspaceLockOptions + + +def migrate_current_state(source_workspace_id: str, target_workspace_id: str) -> str: + source_current = source.state_versions.read_current(source_workspace_id) + raw_state = source.state_versions.download_current(source_workspace_id) + + target.workspaces.lock( + target_workspace_id, + WorkspaceLockOptions(reason="Migrate current state with pyTFE"), + ) + try: + migrated = target.state_versions.upload( + target_workspace_id, + raw_state=raw_state, + options=StateVersionCreateOptions( + serial=source_current.serial or 1, + md5=hashlib.md5(raw_state).hexdigest(), + ), + ) + finally: + target.workspaces.unlock(target_workspace_id) + + return migrated.id +``` + +Use this against a target workspace that has never run Terraform. If the target +already has state, choose a serial strictly greater than the target current +serial and confirm that replacing the state is intentional. + +## Import many local state files + +The state-only workflow from local `terraform.tfstate` files uses the same +workspace-create, lock, upload, and unlock sequence. + +```python +from pathlib import Path + + +def upload_local_state_file( + workspace_name: str, + state_path: Path, + *, + serial: int = 1, +) -> str: + try: + workspace = target.workspaces.read(workspace_name, organization=target_org) + except NotFound: + workspace = target.workspaces.create( + target_org, + WorkspaceCreateOptions(name=workspace_name), + ) + + raw_state = state_path.read_bytes() + + target.workspaces.lock( + workspace.id, + WorkspaceLockOptions(reason=f"Import {state_path.name} with pyTFE"), + ) + try: + state_version = target.state_versions.upload( + workspace.id, + raw_state=raw_state, + options=StateVersionCreateOptions( + serial=serial, + md5=hashlib.md5(raw_state).hexdigest(), + ), + ) + finally: + target.workspaces.unlock(workspace.id) + + return state_version.id +``` + +For bulk migrations, keep a manifest that maps each state file to the intended +target workspace name. Run a dry-run pass that creates no resources and prints +the planned mappings before uploading anything. + +## End-to-end skeleton + +```python +sensitive_values_by_workspace = { + # "source-workspace-name": {"TF_VAR_password": "..."} +} + +for source_workspace in source.workspaces.list(source_org): + if not source_workspace.name: + continue + + target_workspace = read_or_create_target_workspace(source_workspace) + + missing = copy_workspace_variables( + source_workspace.id, + target_workspace.id, + sensitive_values=sensitive_values_by_workspace.get(source_workspace.name, {}), + ) + if missing: + print(f"skipped sensitive values for {source_workspace.name}: {missing}") + + migrated_state_id = migrate_current_state( + source_workspace.id, + target_workspace.id, + ) + print(source_workspace.name, "state migrated as", migrated_state_id) +``` + +After migration, queue a speculative or no-op plan in each target workspace +before enabling normal automation. + +## Operational checklist + +- Stop source-side Terraform operations before copying state. +- Prefer new target workspaces that have never performed a run. +- Keep state bytes, variable values, SSH keys, and configuration archives out + of logs and CI artifacts. +- Build explicit ID maps for VCS OAuth tokens, SSH keys, projects, teams, and + agent pools. Do not reuse source IDs in the target instance. +- Rehydrate sensitive variables, sensitive policy-set parameters, SSH private + keys, and configuration archives from a secure operator-provided source. +- Validate the target workspace with a plan before applying. +- Keep the source organization read-only until the target validation is + complete and rollback expectations are documented. diff --git a/docs/scenarios/notification-configurations.md b/docs/scenarios/notification-configurations.md new file mode 100644 index 00000000..506c4acd --- /dev/null +++ b/docs/scenarios/notification-configurations.md @@ -0,0 +1,192 @@ +# Scenario: Notification configurations + +Notification configurations send run-lifecycle events from a workspace (or +team) to an external destination. HCP Terraform supports four destination +types: + +- `email` — sends to a list of organization users. +- `slack` — posts to an incoming-webhook URL. +- `microsoft-teams` — posts to a Microsoft Teams incoming webhook. +- `generic` — POSTs a JSON payload to a URL you control, signed with an HMAC + token. + +This scenario shows how to create each type, verify delivery, and update or +delete configurations later. + +Upstream docs: + +- Notification configurations: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/notification-configurations +- Notification payload reference: https://developer.hashicorp.com/terraform/cloud-docs/workspaces/settings/notifications + +Example: [notification_configuration.py](../../examples/notification_configuration.py) + +## Prerequisites + +```bash +export TFE_TOKEN="your-api-token" +export TFE_ADDRESS="https://app.terraform.io" +``` + +The token needs write access on the workspace (or team) that owns the +configuration. + +## Create a Slack notification + +```python +from pytfe import TFEClient +from pytfe.models import ( + NotificationConfigurationCreateOptions, + NotificationDestinationType, + NotificationTriggerType, +) + + +client = TFEClient() +workspace_id = "ws-abc123" + +slack = client.notification_configurations.create( + workspace_id, + NotificationConfigurationCreateOptions( + name="slack-run-events", + destination_type=NotificationDestinationType.SLACK, + enabled=True, + url="https://hooks.slack.com/services/T000/B000/XXXX", + triggers=[ + NotificationTriggerType.NEEDS_ATTENTION, + NotificationTriggerType.ERRORED, + NotificationTriggerType.COMPLETED, + ], + ), +) + +print(slack.id) +``` + +Slack and Microsoft Teams configurations need a `url`. The SDK validates this +locally and raises `ValidationError` if the URL is missing for a destination +type that requires it. + +## Create a Microsoft Teams notification + +```python +teams = client.notification_configurations.create( + workspace_id, + NotificationConfigurationCreateOptions( + name="teams-run-events", + destination_type=NotificationDestinationType.MICROSOFT_TEAMS, + enabled=True, + url="https://outlook.office.com/webhook/...", + triggers=[ + NotificationTriggerType.ERRORED, + NotificationTriggerType.NEEDS_ATTENTION, + ], + ), +) +``` + +## Create a generic webhook notification + +`generic` posts a JSON payload to your own service. Use the `token` field to +share an HMAC signing secret; HCP Terraform sends `X-TFE-Notification-Signature` +on each delivery so your service can verify authenticity. + +```python +import secrets + +hmac_secret = secrets.token_urlsafe(32) + +webhook = client.notification_configurations.create( + workspace_id, + NotificationConfigurationCreateOptions( + name="generic-webhook", + destination_type=NotificationDestinationType.GENERIC, + enabled=True, + url="https://example.com/tfe-notifications", + token=hmac_secret, + triggers=[NotificationTriggerType.COMPLETED], + ), +) + +# Persist hmac_secret in your secret manager — it is not returned again. +``` + +Store the HMAC secret in a secret manager. The API does not return the token +value on subsequent reads. + +## Create an email notification + +Email notifications go to organization users, identified either by email +address or by user ID: + +```python +email = client.notification_configurations.create( + workspace_id, + NotificationConfigurationCreateOptions( + name="ops-email", + destination_type=NotificationDestinationType.EMAIL, + enabled=True, + triggers=[NotificationTriggerType.ERRORED], + email_addresses=["oncall@example.com"], + ), +) +``` + +`email` configurations do not use `url`. They use `email_addresses` and/or +`email_users` (user objects with an `id`). + +## Verify the configuration + +`verify()` asks HCP Terraform to deliver a test payload to the configured +destination and records the response on the configuration: + +```python +verified = client.notification_configurations.verify(webhook.id) + +for delivery in verified.delivery_responses: + print(delivery.code, delivery.successful, delivery.sent_at) +``` + +A `successful` value of `"true"` confirms the destination accepted the test +payload. A `code` outside the 2xx range or `successful="false"` indicates the +destination URL is unreachable, returns a non-2xx status, or rejects the +payload format. + +Verification works for `slack`, `microsoft-teams`, and `generic`. Email +verification is implicit when the user receives the test message. + +## Update an existing configuration + +```python +from pytfe.models import NotificationConfigurationUpdateOptions + +client.notification_configurations.update( + slack.id, + NotificationConfigurationUpdateOptions( + enabled=False, + ), +) +``` + +Disable a configuration with `enabled=False` instead of deleting it when you +want to keep its history of deliveries. + +## List and delete + +```python +for config in client.notification_configurations.list(workspace_id): + print(config.id, config.name, config.destination_type, config.enabled) + +client.notification_configurations.delete(webhook.id) +``` + +## Operational notes + +- Treat the generic `token` like an HMAC signing key. Rotate it by creating a + new configuration with a new token, switching consumers to it, then deleting + the old configuration. +- Verify every new generic webhook before relying on it in production. The API + will silently drop deliveries to a misconfigured URL. +- Scope triggers narrowly. `NEEDS_ATTENTION` plus `ERRORED` covers most + on-call needs without paging on every successful apply. +- Slack/Teams incoming-webhook URLs grant posting rights to their channel. + Store them in a secret manager and rotate them when team membership changes. diff --git a/docs/scenarios/policy-enforcement.md b/docs/scenarios/policy-enforcement.md new file mode 100644 index 00000000..db9cd029 --- /dev/null +++ b/docs/scenarios/policy-enforcement.md @@ -0,0 +1,107 @@ +# Scenario: Policy enforcement + +This scenario shows a basic policy workflow: create a policy, create a policy +set, attach it to a workspace or project, inspect policy checks, and override +when permitted. + +Upstream docs: + +- Policies: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policies +- Policy sets: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policy-sets +- Policy checks: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policy-checks +- Policy evaluations: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policy-evaluations + +## Create a policy + +```python +from pytfe import TFEClient +from pytfe.models import ( + EnforcementLevel, + Policy, + PolicyCreateOptions, + PolicyKind, + PolicySetAddPoliciesOptions, + PolicySetAddWorkspacesOptions, + PolicySetCreateOptions, + PolicySetRemovePoliciesOptions, + Workspace, +) + + +client = TFEClient() +organization = "my-organization" + +policy = client.policies.create( + organization, + PolicyCreateOptions( + name="require-tags", + kind=PolicyKind.OPA, + query="data.terraform.main.deny", + enforcement_level=EnforcementLevel.ENFORCEMENT_ADVISORY, + description="Example policy managed by pyTFE", + ), +) + +client.policies.upload(policy.id, b'package terraform.main\n\ndeny := []\n') +``` + +## Create a policy set and attach resources + +```python +policy_set = client.policy_sets.create( + organization, + PolicySetCreateOptions( + name="platform-guardrails", + description="Platform policy set", + kind=PolicyKind.OPA, + Global=False, + ), +) + +client.policy_sets.add_policies( + policy_set.id, + PolicySetAddPoliciesOptions(policies=[Policy(id=policy.id)]), +) + +client.policy_sets.add_workspaces( + policy_set.id, + PolicySetAddWorkspacesOptions(workspaces=[Workspace(id="ws-abc123")]), +) +``` + +Use project relationships when every workspace in a project should share the +same policy set. + +## Inspect checks on a run + +```python +for check in client.policy_checks.list("run-abc123"): + print(check.id, check.status) +``` + +Read logs for a check: + +```python +logs = client.policy_checks.logs("polchk-abc123") +print(logs) +``` + +## Override when allowed + +```python +client.policy_checks.override("polchk-abc123") +``` + +Overrides require server-side permission and policy configuration that allows +overrides. + +## Cleanup + +```python +client.policy_sets.remove_policies( + policy_set.id, + PolicySetRemovePoliciesOptions(policies=[Policy(id=policy.id)]), +) +client.policy_sets.delete(policy_set.id) +client.policies.delete(policy.id) +``` diff --git a/docs/scenarios/run-task-integration.md b/docs/scenarios/run-task-integration.md new file mode 100644 index 00000000..b3fee85b --- /dev/null +++ b/docs/scenarios/run-task-integration.md @@ -0,0 +1,101 @@ +# Scenario: Run task integration + +Run tasks let HCP Terraform call an external service during the run lifecycle. +pyTFE supports managing run tasks, attaching them to workspaces, sending +callback responses, and reading task stages/results. + +Upstream docs: + +- Run tasks: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/run-tasks/run-tasks +- Run task integration: https://developer.hashicorp.com/terraform/enterprise/api-docs/run-tasks/run-tasks-integration +- Run task stages and results: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/run-tasks/run-task-stages-and-results + +## Create a run task + +```python +from pytfe import TFEClient +from pytfe.models import ( + RunTask, + RunTaskCreateOptions, + Stage, + TaskEnforcementLevel, + TaskResultCallbackRequestOptions, + TaskResultStatus, + WorkspaceRunTaskCreateOptions, +) + + +client = TFEClient() + +task = client.run_tasks.create( + "my-organization", + RunTaskCreateOptions( + name="external-security-check", + description="Example external check", + url="https://example.com/tfc/run-task", + category="task", + hmac_key="shared-secret", + enabled=True, + ), +) +``` + +## Attach the task to a workspace + +```python +workspace_task = client.workspace_run_tasks.create( + "ws-abc123", + WorkspaceRunTaskCreateOptions( + enforcement_level=TaskEnforcementLevel.MANDATORY, + run_task=RunTask(id=task.id), + stages=[Stage.PRE_PLAN], + ), +) + +print(workspace_task.id) +``` + +Stage enum values should match the API contract. Check upstream docs and go-tfe +when adding or changing stage handling. + +## Send a callback response + +When HCP Terraform triggers the run task, it sends your service a request body +that includes a callback URL and callback access token. Use those values for the +callback; do not use the SDK client's normal `TFE_TOKEN`. + +```python +client.run_task_integrations.callback( + callback_url, + callback_access_token, + TaskResultCallbackRequestOptions( + status=TaskResultStatus.passed, + message="External check passed", + url="https://example.com/results/123", + ), +) +``` + +## Inspect task stages and results + +```python +for stage in client.task_stages.list("run-abc123"): + print(stage.id, stage.stage, stage.status) + +stage = client.task_stages.read("ts-abc123") +result = client.task_results.read("taskrs-abc123") +``` + +If a stage is awaiting override and your token has permission: + +```python +client.task_stages.override("ts-abc123", "Approved by platform team") +``` + +## Security notes + +- Verify webhook signatures in your run task service. +- Store the HMAC key in a secret manager. +- Use the callback access token only for the callback request. +- Do not log webhook payloads if they may contain sensitive plan data. + diff --git a/docs/scenarios/state-management.md b/docs/scenarios/state-management.md new file mode 100644 index 00000000..f4ee12bf --- /dev/null +++ b/docs/scenarios/state-management.md @@ -0,0 +1,101 @@ +# Scenario: State management + +Terraform state can contain provider credentials, resource attributes, outputs, +and other sensitive values. Treat any downloaded state bytes as secret material. +Do not log state, commit state to source control, or store state in CI artifacts. + +Upstream docs: + +- State versions: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/state-versions +- State version outputs: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/state-version-outputs + +## Read current state metadata + +```python +from pytfe import TFEClient + + +client = TFEClient() +workspace_id = "ws-abc123" + +current = client.state_versions.read_current(workspace_id) +print(current.id, current.serial, current.status) +``` + +## Download current state bytes + +```python +raw_state = client.state_versions.download_current(workspace_id) +print(f"downloaded {len(raw_state)} bytes") +``` + +The returned bytes are the raw state file. Keep them in memory when possible. +If you must write them to disk, use restricted permissions and remove the file +after use. + +## List outputs + +```python +for output in client.state_versions.list_outputs(current.id): + print(output.name, output.sensitive) +``` + +For current workspace outputs: + +```python +for output in client.state_version_outputs.read_current(workspace_id): + print(output.name, output.sensitive) +``` + +Do not print sensitive output values. + +## Upload state + +Uploading state is an advanced operation. Prefer normal Terraform runs when +possible. + +```python +import hashlib + +from pytfe.models import StateVersionCreateOptions, WorkspaceLockOptions + + +raw_state = b"{... raw terraform state json ...}" + +new_state = client.state_versions.upload( + workspace_id, + raw_state=raw_state, + options=StateVersionCreateOptions( + serial=43, + md5=hashlib.md5(raw_state).hexdigest(), + ), +) + +print(new_state.id, new_state.status) +``` + +Use a serial number newer than the current state. Depending on server timing, +the returned state version may still be processing. + +## Roll back a workspace + +Rollback duplicates an older state version and makes the copy current. The +workspace must be locked by the caller first. + +```python +client.workspaces.lock( + workspace_id, + WorkspaceLockOptions(reason="Rollback state with pyTFE"), +) + +try: + rolled_back = client.state_versions.rollback( + workspace_id, + "sv-previous123", + ) + print(rolled_back.id) +finally: + client.workspaces.unlock(workspace_id) +``` + +Use rollback only with an explicit operational reason and a recovery plan. diff --git a/docs/scenarios/team-access-onboarding.md b/docs/scenarios/team-access-onboarding.md new file mode 100644 index 00000000..bd61783d --- /dev/null +++ b/docs/scenarios/team-access-onboarding.md @@ -0,0 +1,120 @@ +# Scenario: Team access onboarding + +This scenario shows how to create a team, add members, grant workspace access, +and create a team token for automation. + +Upstream docs: + +- Teams: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/teams +- Team access: https://developer.hashicorp.com/terraform/enterprise/api-docs/team-access +- Team tokens: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/team-tokens + +## Create a team + +```python +from datetime import datetime, timezone + +from pytfe import TFEClient +from pytfe.models import ( + TeamCreateOptions, + TeamTokenCreateOptions, + TeamWorkspaceAccessAddOptions, + TeamWorkspaceAccessType, +) + + +client = TFEClient() + +team = client.teams.create( + "my-organization", + TeamCreateOptions( + name="platform-automation", + visibility="organization", + ), +) + +print(team.id) +``` + +## Add users or organization memberships + +If you know usernames: + +```python +client.teams.add_users(team.id, ["alice", "bob"]) +``` + +If you manage users by organization membership ID: + +```python +client.teams.add_organization_memberships( + team.id, + ["ou-abc123", "ou-def456"], +) +``` + +You can inspect membership later: + +```python +users = list(client.teams.list_users(team.id)) +memberships = list(client.teams.list_organization_memberships(team.id)) +``` + +## Grant workspace access + +```python +grant = client.team_workspace_accesses.add( + TeamWorkspaceAccessAddOptions( + team_id=team.id, + workspace_id="ws-abc123", + access=TeamWorkspaceAccessType.WRITE, + ) +) + +print(grant.id) +``` + +Use project access when the team needs the same access across a project: + +```python +from pytfe.models.team_project_access import TeamProjectAccessListOptions + +# See examples/team_project_access.py for a full project access example. +for access in client.team_project_accesses.list( + TeamProjectAccessListOptions(Project_id="prj-abc123") +): + print(access.id) +``` + +`team_project_accesses.list` filters by project, not by team. Pass the project +ID and iterate the returned grants to see which teams have access. + +## Create a team token + +```python +token = client.team_tokens.create_with_options( + team.id, + TeamTokenCreateOptions( + description="automation", + expired_at=datetime(2026, 12, 31, tzinfo=timezone.utc), + ), +) + +print(token.id) +print(token.token) +``` + +The token value is sensitive. Store it in a secret manager immediately. Do not +print it in production logs. + +## Cleanup + +```python +client.team_workspace_accesses.remove("twsa-abc123") +client.team_tokens.delete_by_id("at-abc123") +client.teams.remove_users(team.id, ["alice", "bob"]) +client.teams.delete(team.id) +``` + +Use the IDs returned by your create calls for cleanup. + diff --git a/docs/terraform-enterprise.md b/docs/terraform-enterprise.md new file mode 100644 index 00000000..6b3c1065 --- /dev/null +++ b/docs/terraform-enterprise.md @@ -0,0 +1,95 @@ +# Terraform Enterprise + +pyTFE supports both HCP Terraform and Terraform Enterprise. Most SDK calls use +the same API paths, but Terraform Enterprise installations often need extra +connection and compatibility setup. + +## Address + +Set `TFE_ADDRESS` to the base URL of your Terraform Enterprise installation: + +```bash +export TFE_ADDRESS="https://tfe.example.com" +export TFE_TOKEN="your-api-token" +``` + +Do not include `/api/v2`; pyTFE adds API paths internally. + +Equivalent explicit configuration: + +```python +from pytfe import TFEClient, TFEConfig + +client = TFEClient( + TFEConfig( + address="https://tfe.example.com", + token="your-api-token", + ) +) +``` + +## TLS and private CAs + +Use `SSL_CERT_FILE` for private or internal certificate authorities: + +```bash +export SSL_CERT_FILE="/etc/ssl/certs/tfe-ca-bundle.pem" +``` + +Or: + +```python +from pytfe import TFEConfig + +config = TFEConfig( + address="https://tfe.example.com", + token="your-api-token", + ca_bundle="/etc/ssl/certs/tfe-ca-bundle.pem", +) +``` + +Disable TLS verification only for controlled local testing: + +```bash +export TFE_VERIFY_TLS=false +``` + +## Private network access + +Terraform Enterprise instances are often only reachable from a private network. +If pyTFE works locally but fails in CI, check: + +- CI runner network access to the Terraform Enterprise hostname. +- DNS resolution from the runner. +- Corporate proxy rules. +- Firewall rules and allow lists. +- TLS interception and CA trust. + +## Older server feature gaps + +Some pyTFE methods wrap newer API endpoints. Older Terraform Enterprise +versions may not support every feature exposed by the SDK. + +Common signs: + +- `404` for an endpoint that exists in current HCP Terraform docs. +- `422` validation errors for newer request attributes. +- Missing response fields or relationships. +- Hosted upload/download URLs not returned by older endpoints. + +When adding new automation, test it against the oldest Terraform Enterprise +version you support. + +## Enterprise-only and Cloud-only endpoints + +Some endpoints are only available in Terraform Enterprise. Others are only +available in HCP Terraform. pyTFE exposes typed errors such as +`UnsupportedInEnterprise` and `UnsupportedInCloud` where the SDK can identify +that distinction. + +For behavior questions, compare: + +- Terraform Enterprise API docs: https://developer.hashicorp.com/terraform/enterprise/api-docs +- HCP Terraform API docs: https://developer.hashicorp.com/terraform/cloud-docs/api-docs +- go-tfe: https://github.com/hashicorp/go-tfe + diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 00000000..04794492 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,123 @@ +# Troubleshooting + +This page covers common pyTFE issues and the first checks to run. + +## Turn on transport logs + +```bash +PYTFE_LOG=debug python your_script.py +``` + +`PYTFE_LOG=debug` prints request/response traces through the `pytfe.transport` +logger. Tokens and common secret keys are redacted before logging. + +For retry-only visibility: + +```bash +PYTFE_LOG=info python your_script.py +``` + +See [LOGGING.md](LOGGING.md) for the full logging reference. + +## Authentication failures + +Symptoms: + +- `AuthError` +- HTTP `401` +- HTTP `403` + +Checks: + +- Confirm `TFE_TOKEN` is set in the process running the code. +- Confirm `TFE_ADDRESS` points to the right HCP Terraform or Terraform + Enterprise instance. +- Confirm the token type is accepted by the endpoint. Some endpoints require a + user, team, or group token rather than an organization token. +- Confirm the token has permission on the target organization, project, or + workspace. + +## `404` can mean missing resource or missing permission + +HCP Terraform may return `404` when the resource does not exist or when the +token cannot see it. Check: + +- Does the ID belong to the same organization or instance? +- Is the resource deleted or archived? +- Does the token have permission to read the parent workspace/project/org? +- Are you using a workspace name where the SDK method expects a workspace ID? + +## Pagination surprises + +All `list` and `list_*` methods return iterators: + +```python +items = client.workspaces.list("my-org") +print(items) # iterator object +print(list(items)) # actual results +``` + +Iterators are single-use. If you need to traverse results twice, materialize +them once: + +```python +workspaces = list(client.workspaces.list("my-org")) +``` + +See [pagination.md](pagination.md). + +## Terraform Enterprise with self-signed or private CAs + +If TLS verification fails for Terraform Enterprise, prefer a CA bundle: + +```bash +export SSL_CERT_FILE="/path/to/internal-ca-bundle.pem" +``` + +Or: + +```python +from pytfe import TFEConfig + +config = TFEConfig(ca_bundle="/path/to/internal-ca-bundle.pem") +``` + +Avoid `TFE_VERIFY_TLS=false` outside local testing. + +## Retries and transient failures + +pyTFE retries transient failures in the transport layer. Configure the maximum +retry count with: + +```bash +export TFE_MAX_RETRIES=5 +``` + +For noisy CI environments, enabling retry logs can help: + +```bash +PYTFE_LOG=info python your_script.py +``` + +## Signed upload and download URLs + +Some endpoints return hosted upload/download URLs for configuration versions, +state versions, plan JSON, or errored state. Prefer the high-level pyTFE helper +methods such as: + +- `client.configuration_versions.upload(...)` +- `client.state_versions.upload(...)` +- `client.state_versions.download(...)` +- `client.plans.read_json_output_for_run(...)` +- `client.applies.errored_state(...)` + +These methods handle the non-standard response shapes for you. + +## Getting help from the upstream API docs + +When an endpoint behaves unexpectedly, compare your code with: + +- HCP Terraform API docs: https://developer.hashicorp.com/terraform/cloud-docs/api-docs +- Terraform Enterprise API docs: https://developer.hashicorp.com/terraform/enterprise/api-docs +- go-tfe implementation: https://github.com/hashicorp/go-tfe + diff --git a/examples/variable_sets.py b/examples/variable_sets.py index 258319a2..2fb980cf 100644 --- a/examples/variable_sets.py +++ b/examples/variable_sets.py @@ -76,13 +76,11 @@ def variable_set_example(): # 2. Create a new variable set print("2. Creating a new variable set...") - create_options = VariableSetCreateOptions.model_validate( - { - "name": "python-sdk-example-varset", - "description": "Example variable set created with Python SDK", - "global": False, # Not global, will apply to specific workspaces/projects - "priority": True, # High priority - } + create_options = VariableSetCreateOptions( + name="python-sdk-example-varset", + description="Example variable set created with Python SDK", + global_=False, # Not global, will apply to specific workspaces/projects + priority=True, # High priority ) new_variable_set = client.variable_sets.create(org_name, create_options) @@ -359,13 +357,11 @@ def global_variable_set_example(): # Create a global variable set print("Creating a global variable set...") - global_create_options = VariableSetCreateOptions.model_validate( - { - "name": "python-sdk-global-varset", - "description": "Global variable set for common settings", - "global": True, # Make it global - "priority": False, - } + global_create_options = VariableSetCreateOptions( + name="python-sdk-global-varset", + description="Global variable set for common settings", + global_=True, # Make it global + priority=False, ) global_varset = client.variable_sets.create(org_name, global_create_options) @@ -451,13 +447,11 @@ def project_scoped_variable_set_example(): print("Creating a project-scoped variable set...") parent = Parent(project=Project(id=target_project.id)) - project_create_options = VariableSetCreateOptions.model_validate( - { - "name": "python-sdk-project-varset", - "description": f"Project-specific variables for {target_project.name}", - "global": False, # Not global - "parent": parent.model_dump(), # Scope to specific project - } + project_create_options = VariableSetCreateOptions( + name="python-sdk-project-varset", + description=f"Project-specific variables for {target_project.name}", + global_=False, # Not global + parent=parent, # Scope to specific project ) project_varset = client.variable_sets.create(org_name, project_create_options) diff --git a/src/pytfe/models/variable_set.py b/src/pytfe/models/variable_set.py index 02c413f4..32256836 100644 --- a/src/pytfe/models/variable_set.py +++ b/src/pytfe/models/variable_set.py @@ -6,7 +6,7 @@ from datetime import datetime from enum import Enum -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from .organization import Organization from .project import Project @@ -33,6 +33,8 @@ class Parent(BaseModel): class VariableSet(BaseModel): """Represents a Terraform Enterprise variable set.""" + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + id: str | None = None name: str | None = None description: str | None = None @@ -80,6 +82,8 @@ class VariableSetListOptions(BaseModel): class VariableSetCreateOptions(BaseModel): """Options for creating a variable set.""" + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + name: str description: str | None = None global_: bool = Field(alias="global") @@ -96,6 +100,8 @@ class VariableSetReadOptions(BaseModel): class VariableSetUpdateOptions(BaseModel): """Options for updating a variable set.""" + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + name: str | None = None description: str | None = None global_: bool | None = Field(alias="global", default=None) From 6d180c06382509331363bbf31ed8544458b6d48a Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Wed, 27 May 2026 00:16:10 +0530 Subject: [PATCH 92/95] enforce populate_by_name through ci --- docs/MODELS.md | 22 +++++ src/pytfe/models/agent.py | 2 + src/pytfe/models/run.py | 2 + tests/units/test_model_conventions.py | 123 ++++++++++++++++++++++++++ 4 files changed, 149 insertions(+) create mode 100644 tests/units/test_model_conventions.py diff --git a/docs/MODELS.md b/docs/MODELS.md index 2c036090..fae5fc4f 100644 --- a/docs/MODELS.md +++ b/docs/MODELS.md @@ -248,6 +248,28 @@ workspace: dict | None = None # loses type information workspace: Workspace | None = None # ✅ (filled via model_construct in resource) ``` +### Python keyword aliases require `populate_by_name=True` + +`populate_by_name=True` is documented above as "Always" — for aliases that are Python keywords (`global`, `class`, `from`, `import`, `return`, `yield`, `lambda`, `del`, `pass`, `raise`, `with`, `as`, `is`, `in`, `not`, `and`, `or`, `if`, `else`, `elif`, `for`, `while`, `try`, `except`, `finally`, `def`, `async`, `await`), it's not just convenience — it's a correctness requirement. Without it, callers cannot construct the model with a kwarg at all and are forced into ugly workarounds: + +```python +# ❌ Without populate_by_name=True, this is the only way to construct: +VariableSetCreateOptions(name="x", **{"global": False}) # awkward +VariableSetCreateOptions.model_validate({"name": "x", "global": False}) # inconsistent with every other *CreateOptions + +# Field name with trailing underscore IS NOT accepted because populate_by_name defaults to False: +VariableSetCreateOptions(name="x", global_=False) # ValidationError: 'global' field required + +# ✅ With populate_by_name=True, the trailing-underscore form works and matches the rest of the SDK: +class VariableSetCreateOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + global_: bool = Field(alias="global") + +VariableSetCreateOptions(name="x", global_=False) # ✅ +``` + +The CI check in `tests/units/test_model_conventions.py` enforces this for every model that declares an `alias=`. Models that intentionally accept only the wire-format alias must be added to the explicit allowlist in that test, with a comment explaining why. + ## Checklist when adding a new model - [ ] `from __future__ import annotations` at the top diff --git a/src/pytfe/models/agent.py b/src/pytfe/models/agent.py index 76c02f3c..2c1db661 100644 --- a/src/pytfe/models/agent.py +++ b/src/pytfe/models/agent.py @@ -156,6 +156,8 @@ def valid(self) -> AgentPoolUpdateOptions: class AgentPoolReadOptions(BaseModel): """Options for reading an agent pool.""" + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + # Optional: Include related resources include: list[AgentPoolIncludeOpt] | None = Field(default=None, alias="include") diff --git a/src/pytfe/models/run.py b/src/pytfe/models/run.py index 5a82f7fa..6da48e22 100644 --- a/src/pytfe/models/run.py +++ b/src/pytfe/models/run.py @@ -227,6 +227,8 @@ class RunList(BaseModel): class RunListOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + page_number: int | None = Field(default=1, alias="page[number]") page_size: int | None = Field(default=20, alias="page[size]") diff --git a/tests/units/test_model_conventions.py b/tests/units/test_model_conventions.py new file mode 100644 index 00000000..791436d2 --- /dev/null +++ b/tests/units/test_model_conventions.py @@ -0,0 +1,123 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Convention checks for pyTFE Pydantic models. + +These are not behavior tests — they enforce repo-wide conventions documented in +``docs/MODELS.md`` so a regression cannot ship. +""" + +from __future__ import annotations + +import importlib +import pkgutil +from typing import Iterator + +from pydantic import BaseModel + +import pytfe.models + + +# Models that intentionally accept only their wire-format alias and reject the +# Python field name as a constructor kwarg. Every entry needs a reason. The +# test verifies allowlisted models really do have alias fields and really do +# omit populate_by_name=True — stale entries are flagged so this list cannot +# silently rot. +# +# Default for new work is the empty allowlist. If you are tempted to add an +# entry, first ask whether populate_by_name=True would actually break anything. +# Usually the answer is "no" and the right fix is to set the ConfigDict, not +# to allowlist. +ALIAS_POPULATE_ALLOWLIST: dict[str, str] = { + # "SomeModel": "Reason this model intentionally rejects field-name kwargs", +} + + +def _iter_model_classes() -> Iterator[type[BaseModel]]: + """Yield every BaseModel subclass defined in pytfe.models submodules. + + Walks the package without importing submodules eagerly so failures in an + unrelated module surface as test failures, not import errors at collection. + """ + seen: set[type[BaseModel]] = set() + for module_info in pkgutil.iter_modules(pytfe.models.__path__): + module = importlib.import_module(f"pytfe.models.{module_info.name}") + for obj in vars(module).values(): + if ( + isinstance(obj, type) + and issubclass(obj, BaseModel) + and obj is not BaseModel + # Skip re-exports from other modules so each class is checked + # once in its defining module. + and obj.__module__ == module.__name__ + and obj not in seen + ): + seen.add(obj) + yield obj + + +def _has_alias_field(cls: type[BaseModel]) -> bool: + return any( + getattr(field, "alias", None) is not None + for field in cls.model_fields.values() + ) + + +def _populate_by_name(cls: type[BaseModel]) -> bool: + return bool(cls.model_config.get("populate_by_name", False)) + + +def test_aliased_models_set_populate_by_name() -> None: + """Models that declare an ``alias=`` field must allow construction by field + name. Without ``populate_by_name=True`` callers cannot pass the Python + field name as a kwarg (and for aliases that are Python keywords like + ``global``, they cannot pass the alias as a kwarg either — they would have + to use ``**{"global": ...}`` or ``model_validate({...})``). + + See ``docs/MODELS.md`` "Python keyword aliases require populate_by_name=True". + """ + offenders: list[str] = [] + for cls in _iter_model_classes(): + if not _has_alias_field(cls): + continue + if _populate_by_name(cls): + continue + if cls.__name__ in ALIAS_POPULATE_ALLOWLIST: + continue + offenders.append(f"{cls.__module__}.{cls.__name__}") + + assert not offenders, ( + "Models with alias= fields must set " + "`model_config = ConfigDict(populate_by_name=True, validate_by_name=True)`.\n" + "Either add the ConfigDict or, if the model intentionally rejects " + "field-name kwargs, add it to ALIAS_POPULATE_ALLOWLIST with a reason.\n" + "Offenders:\n - " + "\n - ".join(offenders) + ) + + +def test_allowlist_entries_are_not_stale() -> None: + """Each allowlist entry must correspond to a real model that still has + alias fields and still omits populate_by_name. If a model has been fixed + or no longer has aliases, its allowlist entry must be removed. + """ + by_name: dict[str, type[BaseModel]] = { + cls.__name__: cls for cls in _iter_model_classes() + } + + stale: list[str] = [] + for name in ALIAS_POPULATE_ALLOWLIST: + cls = by_name.get(name) + if cls is None: + stale.append(f"{name}: no such model in pytfe.models") + continue + if not _has_alias_field(cls): + stale.append(f"{name}: no longer has alias= fields — remove from allowlist") + continue + if _populate_by_name(cls): + stale.append( + f"{name}: already sets populate_by_name=True — remove from allowlist" + ) + + assert not stale, ( + "Stale entries in ALIAS_POPULATE_ALLOWLIST:\n - " + "\n - ".join(stale) + ) From 842ff8e3fc01bdb1a9c592f29e57c0b6750dbe4d Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Wed, 27 May 2026 00:16:40 +0530 Subject: [PATCH 93/95] enforce populate_by_name through ci --- tests/units/test_model_conventions.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/units/test_model_conventions.py b/tests/units/test_model_conventions.py index 791436d2..62c0aebb 100644 --- a/tests/units/test_model_conventions.py +++ b/tests/units/test_model_conventions.py @@ -11,13 +11,12 @@ import importlib import pkgutil -from typing import Iterator +from collections.abc import Iterator from pydantic import BaseModel import pytfe.models - # Models that intentionally accept only their wire-format alias and reject the # Python field name as a constructor kwarg. Every entry needs a reason. The # test verifies allowlisted models really do have alias fields and really do @@ -58,8 +57,7 @@ def _iter_model_classes() -> Iterator[type[BaseModel]]: def _has_alias_field(cls: type[BaseModel]) -> bool: return any( - getattr(field, "alias", None) is not None - for field in cls.model_fields.values() + getattr(field, "alias", None) is not None for field in cls.model_fields.values() ) From be457d01009030493ca5714cf090c6ab38df52fe Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Wed, 27 May 2026 14:53:57 +0530 Subject: [PATCH 94/95] Updated team model UpdateOption visibility and OrganizationAccess to default None, updated run-task create payload with right attributes and updated agent-pool relations at Workspace API response capture --- src/pytfe/models/team.py | 32 +++++++++++++++---------------- src/pytfe/resources/run_task.py | 4 ++-- src/pytfe/resources/workspaces.py | 2 +- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/pytfe/models/team.py b/src/pytfe/models/team.py index 61e8f9be..23f7c343 100644 --- a/src/pytfe/models/team.py +++ b/src/pytfe/models/team.py @@ -102,25 +102,25 @@ def valid(self) -> TeamListOptions: class OrganizationAccessOptions(BaseModel): model_config = ConfigDict(populate_by_name=True) - manage_policies: bool | None = Field(default=False, alias="manage-policies") + manage_policies: bool | None = Field(default=None, alias="manage-policies") manage_policy_overrides: bool | None = Field( - default=False, alias="manage-policy-overrides" + default=None, 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_workspaces: bool | None = Field(default=None, alias="manage-workspaces") + manage_vcs_settings: bool | None = Field(default=None, alias="manage-vcs-settings") + manage_providers: bool | None = Field(default=None, alias="manage-providers") + manage_modules: bool | None = Field(default=None, alias="manage-modules") + manage_run_tasks: bool | None = Field(default=None, alias="manage-run-tasks") + manage_projects: bool | None = Field(default=None, alias="manage-projects") + read_workspaces: bool | None = Field(default=None, alias="read-workspaces") + read_projects: bool | None = Field(default=None, alias="read-projects") + manage_membership: bool | None = Field(default=None, alias="manage-membership") + manage_teams: bool | None = Field(default=None, alias="manage-teams") manage_organization_access: bool | None = Field( - default=False, alias="manage-organization-access" + default=None, 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") + access_secret_teams: bool | None = Field(default=None, alias="access-secret-teams") + manage_agent_pools: bool | None = Field(default=None, alias="manage-agent-pools") class TeamCreateOptions(BaseModel): @@ -152,7 +152,7 @@ class TeamUpdateOptions(BaseModel): organization_access: OrganizationAccessOptions | None = Field( default=None, alias="organization-access" ) - visibility: str | None = Field(alias="visibility") + visibility: str | None = Field(default=None, alias="visibility") allow_member_token_management: bool | None = Field( default=None, alias="allow-member-token-management" ) diff --git a/src/pytfe/resources/run_task.py b/src/pytfe/resources/run_task.py index 69a34f68..e1dc622c 100644 --- a/src/pytfe/resources/run_task.py +++ b/src/pytfe/resources/run_task.py @@ -166,7 +166,7 @@ def create(self, organization_id: str, options: RunTaskCreateOptions) -> RunTask }, } if options.hmac_key is not None: - body["data"]["attributes"]["hmac_key"] = options.hmac_key + body["data"]["attributes"]["hmac-key"] = options.hmac_key if options.enabled is not None: body["data"]["attributes"]["enabled"] = options.enabled if options.global_configuration is not None: @@ -181,7 +181,7 @@ def create(self, organization_id: str, options: RunTaskCreateOptions) -> RunTask body["data"]["attributes"]["global-configuration"] = gc_dict if options.agent_pool is not None and options.agent_pool.id: body["data"]["relationships"] = { - "agent_pool": { + "agent-pool": { "data": {"type": "agent_pools", "id": options.agent_pool.id} } } diff --git a/src/pytfe/resources/workspaces.py b/src/pytfe/resources/workspaces.py index a2d419ec..3fe7caac 100644 --- a/src/pytfe/resources/workspaces.py +++ b/src/pytfe/resources/workspaces.py @@ -193,7 +193,7 @@ def _ws_from(d: dict[str, Any]) -> Workspace: {"id": relationships["ssh-key"]["data"].get("id")} ) if relationships.get("agent-pool", {}).get("data"): - attr["agent_pools"] = AgentPool.model_validate( + attr["agent_pool"] = AgentPool.model_validate( {"id": relationships["agent-pool"]["data"].get("id")} ) if relationships.get("current-run", {}).get("data"): From 02f87375a91e38022d0f5a1baafe0a060ce1fd77 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Wed, 27 May 2026 15:25:43 +0530 Subject: [PATCH 95/95] Add feature no-code-module and bump user-agent (#174) --- CHANGELOG.md | 6 +- README.md | 2 +- docs/api/index.md | 2 + docs/api/no-code-provisioning.md | 274 ++++++++++ docs/scenarios/no-code-provisioning.md | 253 +++++++++ examples/no_code_provisioning.py | 175 +++++++ src/pytfe/_jsonapi.py | 2 +- src/pytfe/client.py | 2 + src/pytfe/errors.py | 22 + src/pytfe/models/__init__.py | 25 + src/pytfe/models/no_code_module.py | 186 +++++++ src/pytfe/resources/no_code_module.py | 460 +++++++++++++++++ tests/units/test_no_code_module.py | 680 +++++++++++++++++++++++++ 13 files changed, 2086 insertions(+), 3 deletions(-) create mode 100644 docs/api/no-code-provisioning.md create mode 100644 docs/scenarios/no-code-provisioning.md create mode 100644 examples/no_code_provisioning.py create mode 100644 src/pytfe/models/no_code_module.py create mode 100644 src/pytfe/resources/no_code_module.py create mode 100644 tests/units/test_no_code_module.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f3d333e..1a7c26f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,9 @@ ### Team Workspace Access * Added Team Workspace Access resource list, read, add, update and remove methods along with models and examples for managing team access to workspaces by @iam404 [#168](https://github.com/hashicorp/python-tfe/pull/168) +### No-Code Provisioning +* Added no_code_modules resource with create, read, update, delete, read_variables, create_workspace, upgrade_workspace, read_workspace_upgrade, and confirm_workspace_upgrade methods. + ## Enhancements ### Terraform Actions @@ -97,7 +100,8 @@ ## Bug Fixes * Fixed task result relationships to map into typed SDK models instead of raw JSON by @TanyaSingh369-svg [#156](https://github.com/hashicorp/python-tfe/pull/156) * Fixed task stage relationship mapping in the task result resource by @TanyaSingh369-svg [#156](https://github.com/hashicorp/python-tfe/pull/156) -* Updated variable set models to support ``global_`` inputs. Since ``global`` is a Python reserved word, callers previously had to use ``model_validate`` as a workaround; existing ``global`` alias usage continues to work unchanged. +* Updated variable set models to support **global_** inputs. Since **global** is a Python reserved word, callers previously had to use **model_validate** as a workaround; existing **global** alias usage continues to work unchanged. +* Fixed the workspace JSON:API parser to populate the singular agent_pool field instead of writing to a non-existent agent_pools key. The relationship was previously parsed off the wire but silently dropped because the model field is singular; workspace.agent_pool now returns the related AgentPool stub as documented. # v0.1.5 diff --git a/README.md b/README.md index 0bd386ed..072071b2 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ and upstream HCP Terraform API docs. |---|---| | Configure the SDK | [Authentication](./docs/authentication.md), [Pagination](./docs/pagination.md), [Logging](./docs/LOGGING.md) | | API guides | [API index](./docs/api/index.md), [Workspaces](./docs/api/workspaces.md), [Runs/plans/applies](./docs/api/runs-plans-applies.md), [State versions](./docs/api/state-versions.md) | -| Scenario guides | [API-driven run](./docs/scenarios/api-driven-run.md), [State management](./docs/scenarios/state-management.md), [Migrate workspaces and state](./docs/scenarios/migrate-workspaces-and-state.md), [Team access onboarding](./docs/scenarios/team-access-onboarding.md) | +| Scenario guides | [API-driven run](./docs/scenarios/api-driven-run.md), [State management](./docs/scenarios/state-management.md), [Migrate workspaces and state](./docs/scenarios/migrate-workspaces-and-state.md), [Team access onboarding](./docs/scenarios/team-access-onboarding.md), [No-code provisioning](./docs/scenarios/no-code-provisioning.md) | | Operations guides | [Troubleshooting](./docs/troubleshooting.md), [Errors](./docs/errors.md), [Terraform Enterprise](./docs/terraform-enterprise.md) | | Contribute to the SDK | [CONTRIBUTING](./docs/CONTRIBUTING.md), [ITERATORS](./docs/ITERATORS.md), [MODELS](./docs/MODELS.md), [RESOURCE](./docs/RESOURCE.md) | diff --git a/docs/api/index.md b/docs/api/index.md index f13ec141..f61ea95e 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -81,6 +81,7 @@ column. | `client.agents` | `Agents` | `list`, `read`, `delete` | [agent.py](../../examples/agent.py) | [Agents](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/agents) | | `client.agent_tokens` | `AgentTokens` | `list`, `read`, `create`, `delete` | [agent.py](../../examples/agent.py) | [Agent tokens](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/agent-tokens) | | `client.registry_modules` | `RegistryModules` | `list`, `read`, `create`, `update`, `delete`, version and upload helpers | [registry_module.py](../../examples/registry_module.py) | [Registry modules](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/modules) | +| `client.no_code_modules` | `NoCodeModules` | `create`, `read`, `update`, `delete`, `read_variables`, `create_workspace`, `upgrade_workspace`, `read_workspace_upgrade`, `confirm_workspace_upgrade` | [no_code_provisioning.py](../../examples/no_code_provisioning.py) | [No-code provisioning](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/no-code-provisioning) | | `client.registry_providers` | `RegistryProviders` | `list`, `read`, `create`, `delete` | [registry_provider.py](../../examples/registry_provider.py) | [Registry providers](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/providers) | | `client.registry_provider_versions` | `RegistryProviderVersions` | `list`, `read`, `create`, `delete` | [registry_provider_version.py](../../examples/registry_provider_version.py) | [Registry providers](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/providers) | | `client.registry_provider_platforms` | `RegistryProviderPlatforms` | `list`, `read`, `create`, `delete` | [registry_provider_platform.py](../../examples/registry_provider_platform.py) | [Registry providers](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/providers) | @@ -103,3 +104,4 @@ column. - [teams-and-access.md](teams-and-access.md) - [policies.md](policies.md) - [run-tasks.md](run-tasks.md) +- [no-code-provisioning.md](no-code-provisioning.md) diff --git a/docs/api/no-code-provisioning.md b/docs/api/no-code-provisioning.md new file mode 100644 index 00000000..a851cd19 --- /dev/null +++ b/docs/api/no-code-provisioning.md @@ -0,0 +1,274 @@ +# No-code provisioning + +No-code provisioning lets users create workspaces from a curated registry +module without writing Terraform code. The workflow has three actors: + +- A platform admin enables a registry module for no-code use and sets allowed + variable values. +- An end user creates a workspace from that module, supplying only variable + values. +- Either party can later upgrade an existing no-code workspace to a newer + module version. + +`client.no_code_modules` covers all four pieces: module CRUD, variable +introspection, workspace creation, and workspace upgrade lifecycle. + +Upstream docs: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/no-code-provisioning + +Example: [no_code_provisioning.py](../../examples/no_code_provisioning.py) + +## Token requirements + +Every write endpoint (`create`, `update`, `delete`, `create_workspace`, +`upgrade_workspace`, `confirm_workspace_upgrade`) requires a **user or team +token**. Organization tokens are not accepted by the API. Read endpoints +(`read`, `read_variables`, `read_workspace_upgrade`) do not have this +restriction. + +## Common methods + +| Method | Purpose | +|---|---| +| `client.no_code_modules.create(organization, options)` | Enable no-code provisioning for a registry module. | +| `client.no_code_modules.read(no_code_module_id, options=None)` | Read a no-code module; pass `NoCodeModuleReadOptions(include=[VARIABLE_OPTIONS])` to materialise allowed-values. | +| `client.no_code_modules.update(no_code_module_id, options)` | Update enabled flag, version pin, or variable options. | +| `client.no_code_modules.delete(no_code_module_id)` | Disable no-code provisioning for the module. | +| `client.no_code_modules.read_variables(no_code_module_id, version)` | Iterate variables declared by a specific module version, for form-building. | +| `client.no_code_modules.create_workspace(no_code_module_id, options)` | Create a workspace from the no-code module. | +| `client.no_code_modules.upgrade_workspace(no_code_module_id, workspace_id, options=None)` | Start an upgrade; returns a `WorkspaceUpgrade` to poll. | +| `client.no_code_modules.read_workspace_upgrade(no_code_module_id, workspace_id, upgrade_id)` | Poll upgrade status. | +| `client.no_code_modules.confirm_workspace_upgrade(no_code_module_id, workspace_id, upgrade_id)` | Confirm and apply the upgrade plan. | + +## Enable a registry module for no-code use + +```python +from pytfe import TFEClient +from pytfe.models import ( + NoCodeModuleCreateOptions, + NoCodeVariableOption, +) + + +client = TFEClient() + +no_code_module = client.no_code_modules.create( + "my-organization", + NoCodeModuleCreateOptions( + registry_module_id="mod-abc123", + enabled=True, + version_pin="1.4.0", + variable_options=[ + NoCodeVariableOption( + variable_name="region", + variable_type="string", + options=["us-east-1", "us-west-2", "eu-west-1"], + ), + NoCodeVariableOption( + variable_name="instance_size", + variable_type="string", + options=["small", "medium", "large"], + ), + ], + ), +) + +print(no_code_module.id) +``` + +`version_pin` defaults to the latest version when omitted. Each +`NoCodeVariableOption` constrains end users to one of the listed values for +that variable. + +## Read a module with its variable options + +`variable_options` are returned by reference only unless you ask for them with +the `include` query: + +```python +from pytfe.models import NoCodeModuleIncludeOpt, NoCodeModuleReadOptions + +module = client.no_code_modules.read( + "nocode-abc123", + NoCodeModuleReadOptions(include=[NoCodeModuleIncludeOpt.VARIABLE_OPTIONS]), +) + +for option in module.variable_options: + print(option.variable_name, option.options) +``` + +Without the include, `module.variable_options` contains stubs with `id` only. + +## Update variable options + +To update an existing option, pass its `id` in the entry; entries without an +`id` are added as new options. To remove an option, omit it from the list — +update calls replace the entire `variable-options` set. + +The HCP API requires every PATCH on a no-code module to include the +`registry-module` relationship in the body, or the server returns `404`. The +SDK handles this transparently: if you don't supply `registry_module_id` +on `NoCodeModuleUpdateOptions`, the resource issues an extra GET to pick up +the current module's relationship. Pass `registry_module_id` explicitly to +skip the round trip: + +```python +NoCodeModuleUpdateOptions( + registry_module_id="mod-abc123", + enabled=False, +) +``` + +```python +from pytfe.models import NoCodeModuleUpdateOptions, NoCodeVariableOption + +updated = client.no_code_modules.update( + "nocode-abc123", + NoCodeModuleUpdateOptions( + version_pin="1.5.0", + variable_options=[ + NoCodeVariableOption( + id="vo-existing123", + variable_name="region", + variable_type="string", + options=["us-east-1", "us-east-2", "us-west-2"], + ), + NoCodeVariableOption( + variable_name="environment", + variable_type="string", + options=["dev", "staging", "prod"], + ), + ], + ), +) +``` + +## Introspect variables for a module version + +When building a UI that lets users pick variable values, use +`read_variables` to discover what the module accepts: + +```python +for var in client.no_code_modules.read_variables("nocode-abc123", "1.4.0"): + print(var.name, var.type, var.required, var.options) +``` + +The returned `RegistryModuleVariable` objects include `name`, `type`, +`description`, `default`, `required`, `sensitive`, and `options`. + +## Create a workspace from a no-code module + +```python +from pytfe.models import ( + NoCodeWorkspaceCreateOptions, + NoCodeWorkspaceVariable, + CategoryType, +) + +workspace = client.no_code_modules.create_workspace( + "nocode-abc123", + NoCodeWorkspaceCreateOptions( + name="customer-acme-us-east-1", + project_id="prj-abc123", + description="Production environment for ACME (us-east-1)", + terraform_version="1.7.0", + vars=[ + NoCodeWorkspaceVariable( + key="region", + value="us-east-1", + category=CategoryType.TERRAFORM, + ), + NoCodeWorkspaceVariable( + key="environment", + value="prod", + category=CategoryType.TERRAFORM, + ), + ], + ), +) + +print(workspace.id, workspace.execution_mode) +``` + +The returned `Workspace` is parsed the same way as `client.workspaces.read`, +so relationships (`project`, `agent_pool`, `vars`) are available when the +server includes them. + +For agent execution, set both `execution_mode` and `agent_pool_id`: + +```python +from pytfe.models import ExecutionMode + +workspace = client.no_code_modules.create_workspace( + "nocode-abc123", + NoCodeWorkspaceCreateOptions( + name="private-network-ws", + project_id="prj-abc123", + execution_mode=ExecutionMode.AGENT, + agent_pool_id="apool-abc123", + ), +) +``` + +The SDK raises `RequiredAgentPoolIDError` if `execution_mode=AGENT` is set +without an `agent_pool_id`. + +## Upgrade a no-code workspace + +Upgrades are a three-step lifecycle: initiate → poll → confirm. + +```python +import time + +from pytfe.models import ( + NoCodeWorkspaceUpgradeOptions, + NoCodeWorkspaceVariable, + CategoryType, +) + +upgrade = client.no_code_modules.upgrade_workspace( + "nocode-abc123", + "ws-abc123", + NoCodeWorkspaceUpgradeOptions( + vars=[ + NoCodeWorkspaceVariable( + key="region", + value="us-west-2", + category=CategoryType.TERRAFORM, + ), + ], + ), +) + +terminal_plan_states = {"planned_and_finished", "errored", "canceled"} +while True: + status = client.no_code_modules.read_workspace_upgrade( + "nocode-abc123", "ws-abc123", upgrade.id + ) + print(status.status, status.plan_url) + if status.status in terminal_plan_states: + break + time.sleep(5) + +if status.status == "planned_and_finished": + client.no_code_modules.confirm_workspace_upgrade( + "nocode-abc123", "ws-abc123", upgrade.id + ) +``` + +`confirm_workspace_upgrade` returns `None` and signals success via HTTP +status; the API responds with a plain-text body (`"Workspace update +completed"`) which is intentionally not surfaced. + +## Operational notes + +- **Variable options are wire-replaced, not merged.** Every `update` call + with a `variable_options` list replaces the whole set. To keep an existing + option, include it (with its `id`) in the list. +- **`version_pin` controls what the no-code workspace gets.** Update it to + roll out a new module version; downstream workspaces still need explicit + `upgrade_workspace` calls to adopt it. +- **Pin a version explicitly in production.** Defaulting to "latest" means a + registry module publish can change behaviour for every consumer. +- **Treat the workspace returned by `create_workspace` like any other.** + Once created, use `client.workspaces`, `client.runs`, `client.state_versions` + to manage it. diff --git a/docs/scenarios/no-code-provisioning.md b/docs/scenarios/no-code-provisioning.md new file mode 100644 index 00000000..f30c4a83 --- /dev/null +++ b/docs/scenarios/no-code-provisioning.md @@ -0,0 +1,253 @@ +# Scenario: No-code provisioning + +This scenario walks through the full no-code provisioning lifecycle from a +platform team's perspective: + +1. Enable a private registry module for no-code use. +2. Constrain end users to specific allowed values for module variables. +3. Create a workspace from the no-code module on behalf of an end user. +4. Roll the workspace forward to a new module version (upgrade). + +Upstream docs: + +- No-code provisioning: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/no-code-provisioning +- Private registry modules: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/modules + +## Prerequisites + +```bash +export TFE_TOKEN="your-api-token" # user or team token (not org) +export TFE_ADDRESS="https://app.terraform.io" +export TFE_ORG="my-organization" +``` + +You also need: + +- A private registry module already published in the organization. +- A project ID for the workspaces you'll create. +- The token must have permission to manage no-code modules and to create + workspaces in the target project. + +Organization tokens are **not** accepted by the no-code write endpoints. Use +a user or team token. See [authentication.md](../authentication.md). + +## Step 1: Enable no-code provisioning on a registry module + +```python +import os + +from pytfe import TFEClient +from pytfe.models import ( + NoCodeModuleCreateOptions, + NoCodeVariableOption, +) + + +client = TFEClient() +organization = os.environ["TFE_ORG"] +registry_module_id = "mod-abc123" + +no_code_module = client.no_code_modules.create( + organization, + NoCodeModuleCreateOptions( + registry_module_id=registry_module_id, + enabled=True, + version_pin="1.4.0", + ), +) + +print("no-code module id:", no_code_module.id) +``` + +`version_pin` defaults to the latest published version if omitted. Pinning +explicitly is safer in production: a registry publish would otherwise change +behaviour for every consumer with no review step. + +## Step 2: Discover the module's variables + +When you don't yet know what variables the module exposes, ask the API: + +```python +for var in client.no_code_modules.read_variables(no_code_module.id, "1.4.0"): + print(f"{var.name} ({var.type})", "required" if var.required else "optional") + if var.description: + print(" ", var.description) +``` + +`read_variables` returns one `RegistryModuleVariable` per declared variable +with `name`, `type`, `description`, `default`, `required`, `sensitive`, and +any `options` listed by the module author. + +## Step 3: Constrain allowed values + +For variables where end users should only pick from a curated set, attach +`NoCodeVariableOption` entries: + +```python +from pytfe.models import NoCodeModuleUpdateOptions, NoCodeVariableOption + +client.no_code_modules.update( + no_code_module.id, + NoCodeModuleUpdateOptions( + variable_options=[ + NoCodeVariableOption( + variable_name="region", + variable_type="string", + options=["us-east-1", "us-west-2", "eu-west-1"], + ), + NoCodeVariableOption( + variable_name="instance_size", + variable_type="string", + options=["small", "medium", "large"], + ), + ], + ), +) +``` + +`update` replaces the entire `variable_options` list every time. To keep +existing options, include them (with their `id` set) in the next update. + +## Step 4: Create a workspace from the module + +This is what an end user (or a platform automation acting on their behalf) +runs. The workspace gets its Terraform code from the pinned module version +and its variable values from the inline `vars`. + +```python +from pytfe.models import ( + NoCodeWorkspaceCreateOptions, + NoCodeWorkspaceVariable, + CategoryType, +) + +workspace = client.no_code_modules.create_workspace( + no_code_module.id, + NoCodeWorkspaceCreateOptions( + name="customer-acme-us-east-1", + project_id="prj-abc123", + description="Production environment for ACME (us-east-1)", + terraform_version="1.7.0", + vars=[ + NoCodeWorkspaceVariable( + key="region", + value="us-east-1", + category=CategoryType.TERRAFORM, + ), + NoCodeWorkspaceVariable( + key="instance_size", + value="medium", + category=CategoryType.TERRAFORM, + ), + ], + ), +) + +print("workspace id:", workspace.id) +``` + +The returned `Workspace` is the same shape `client.workspaces.read` returns, +so you can chain it with the standard workspace, run, and state APIs. + +### Agent execution mode + +Workspaces that need to reach a private network use agents: + +```python +from pytfe.models import ExecutionMode + +workspace = client.no_code_modules.create_workspace( + no_code_module.id, + NoCodeWorkspaceCreateOptions( + name="private-network-ws", + project_id="prj-abc123", + execution_mode=ExecutionMode.AGENT, + agent_pool_id="apool-abc123", + ), +) +``` + +The SDK raises `RequiredAgentPoolIDError` locally if `execution_mode=AGENT` +is set without an `agent_pool_id`. + +## Step 5: Upgrade a workspace to a new module version + +Bump the no-code module's `version_pin`, then upgrade each consuming +workspace. Upgrades are a three-step lifecycle: initiate → poll → confirm. + +```python +import time + +from pytfe.models import ( + NoCodeModuleUpdateOptions, + NoCodeWorkspaceUpgradeOptions, + NoCodeWorkspaceVariable, + CategoryType, +) + +# Bump the module version. +client.no_code_modules.update( + no_code_module.id, + NoCodeModuleUpdateOptions(version_pin="1.5.0"), +) + +# Initiate the upgrade for one workspace, optionally changing variables. +upgrade = client.no_code_modules.upgrade_workspace( + no_code_module.id, + workspace.id, + NoCodeWorkspaceUpgradeOptions( + vars=[ + NoCodeWorkspaceVariable( + key="instance_size", + value="large", + category=CategoryType.TERRAFORM, + ), + ], + ), +) + +terminal_plan_states = {"planned_and_finished", "errored", "canceled"} +while True: + status = client.no_code_modules.read_workspace_upgrade( + no_code_module.id, workspace.id, upgrade.id + ) + print("upgrade status:", status.status) + if status.status in terminal_plan_states: + break + time.sleep(5) + +if status.status == "planned_and_finished": + client.no_code_modules.confirm_workspace_upgrade( + no_code_module.id, workspace.id, upgrade.id + ) + print("upgrade applied") +``` + +`confirm_workspace_upgrade` returns `None`; success is signalled by HTTP +status. The plan URL on `status.plan_url` opens the upgrade plan in the +HCP Terraform UI for review before confirming. + +## Cleanup + +To disable no-code provisioning for the module (without deleting the +underlying registry module): + +```python +client.no_code_modules.delete(no_code_module.id) +``` + +Workspaces already created from the module remain; they just no longer +receive new upgrades through the no-code flow. Delete the workspaces +separately through `client.workspaces.delete(...)` if appropriate. + +## Operational notes + +- Use a **team token** for automation. User tokens work but couple the + automation to a single person. +- Pin `version_pin` explicitly. Defaulting to "latest" lets a publish change + every workspace created from the module thereafter. +- Treat `variable_options` updates as set replacements — every update writes + the full list. +- Audit who creates workspaces from no-code modules. Combine team workspace + access (see [Team access onboarding](team-access-onboarding.md)) with + no-code provisioning to keep ownership clean. diff --git a/examples/no_code_provisioning.py b/examples/no_code_provisioning.py new file mode 100644 index 00000000..958a6bb8 --- /dev/null +++ b/examples/no_code_provisioning.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Example: HCP Terraform no-code provisioning. + +Walks through the four phases of the no-code lifecycle: + +1. Enable a registry module for no-code use, with allowed variable values. +2. Read variables declared by the module version (used for form-building). +3. Create a workspace from the no-code module on behalf of an end user. +4. Upgrade the workspace to a newer module version. + +Environment variables: + + TFE_TOKEN user or team token (not an org token) + TFE_ADDRESS HCP Terraform / Terraform Enterprise URL + TFE_ORG organization name + TFE_REGISTRY_MODULE_ID registry module ID to enable + TFE_PROJECT_ID project to create workspaces in + TFE_MODULE_VERSION initial module version (default: latest) + TFE_NEXT_MODULE_VERSION upgrade target version (optional; skips upgrade if unset) + +The script creates one workspace and (optionally) upgrades it. It does NOT +delete the workspace or the no-code module by default — uncomment the +cleanup block at the end if you want it to. +""" + +from __future__ import annotations + +import os +import sys +import time + +from pytfe.client import TFEClient +from pytfe.errors import TFEError +from pytfe.models import ( + CategoryType, + NoCodeModuleCreateOptions, + NoCodeModuleIncludeOpt, + NoCodeModuleReadOptions, + NoCodeModuleUpdateOptions, + NoCodeVariableOption, + NoCodeWorkspaceCreateOptions, + NoCodeWorkspaceUpgradeOptions, + NoCodeWorkspaceVariable, +) + +TERMINAL_UPGRADE_STATES = {"planned_and_finished", "errored", "canceled"} + + +def main() -> int: + organization = os.environ["TFE_ORG"] + registry_module_id = os.environ["TFE_REGISTRY_MODULE_ID"] + project_id = os.environ["TFE_PROJECT_ID"] + module_version = os.environ.get("TFE_MODULE_VERSION") + next_version = os.environ.get("TFE_NEXT_MODULE_VERSION") + + client = TFEClient() + + print("=== pyTFE No-Code Provisioning Example ===\n") + + # 1. Enable no-code provisioning on a registry module with allowed values. + print(f"1. Enabling no-code on registry module {registry_module_id}...") + no_code_module = client.no_code_modules.create( + organization, + NoCodeModuleCreateOptions( + registry_module_id=registry_module_id, + enabled=True, + version_pin=module_version, + variable_options=[ + NoCodeVariableOption( + variable_name="region", + variable_type="string", + options=["us-east-1", "us-west-2", "eu-west-1"], + ), + ], + ), + ) + print(f" no-code module id: {no_code_module.id}") + print(f" version pin: {no_code_module.version_pin}") + + # Re-read with include to confirm variable options round-trip. + refreshed = client.no_code_modules.read( + no_code_module.id, + NoCodeModuleReadOptions(include=[NoCodeModuleIncludeOpt.VARIABLE_OPTIONS]), + ) + for option in refreshed.variable_options: + print( + " variable option:", + option.variable_name, + "->", + option.options, + ) + + # 2. Introspect variables for the pinned version. + if module_version: + print(f"\n2. Reading variables for version {module_version}...") + try: + for var in client.no_code_modules.read_variables( + no_code_module.id, module_version + ): + req = "required" if var.required else "optional" + print(f" - {var.name} ({var.type}) [{req}]") + except TFEError as exc: + print(f" could not read variables: {exc}") + else: + print("\n2. Skipping variable introspection — TFE_MODULE_VERSION not set.") + + # 3. Create a workspace from the no-code module. + print("\n3. Creating a workspace from the no-code module...") + workspace_name = f"pytfe-no-code-{int(time.time())}" + workspace = client.no_code_modules.create_workspace( + no_code_module.id, + NoCodeWorkspaceCreateOptions( + name=workspace_name, + project_id=project_id, + description="Created by pyTFE no-code example", + vars=[ + NoCodeWorkspaceVariable( + key="region", + value="us-east-1", + category=CategoryType.TERRAFORM, + ), + ], + ), + ) + print(f" workspace id: {workspace.id}") + print(f" workspace name: {workspace.name}") + print(f" execution mode: {workspace.execution_mode}") + + # 4. Optional: upgrade to a newer version. + if next_version: + print(f"\n4. Upgrading workspace to module version {next_version}...") + client.no_code_modules.update( + no_code_module.id, + NoCodeModuleUpdateOptions(version_pin=next_version), + ) + upgrade = client.no_code_modules.upgrade_workspace( + no_code_module.id, + workspace.id, + NoCodeWorkspaceUpgradeOptions(), + ) + print(f" upgrade id: {upgrade.id}") + + while True: + status = client.no_code_modules.read_workspace_upgrade( + no_code_module.id, workspace.id, upgrade.id + ) + print(f" upgrade status: {status.status}") + if status.status in TERMINAL_UPGRADE_STATES: + break + time.sleep(5) + + if status.status == "planned_and_finished": + client.no_code_modules.confirm_workspace_upgrade( + no_code_module.id, workspace.id, upgrade.id + ) + print(" upgrade applied.") + else: + print(f" upgrade did not complete cleanly (status={status.status}).") + else: + print("\n4. Skipping upgrade — TFE_NEXT_MODULE_VERSION not set.") + + print("\nDone.") + print( + "Workspace and no-code module were NOT deleted. " + "Delete them via client.workspaces.delete_by_id(...) " + "and client.no_code_modules.delete(...) when finished." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/pytfe/_jsonapi.py b/src/pytfe/_jsonapi.py index 7fafff9c..6f031452 100644 --- a/src/pytfe/_jsonapi.py +++ b/src/pytfe/_jsonapi.py @@ -7,7 +7,7 @@ def build_headers(user_agent_suffix: str | None = None) -> dict[str, str]: - ua = "pytfe/0.1" + ua = "pytfe/1.0" if user_agent_suffix: ua = f"{ua} {user_agent_suffix}" return { diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 2655c1fc..63548716 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -11,6 +11,7 @@ from .resources.comment import Comments from .resources.configuration_version import ConfigurationVersions from .resources.explorer import Explorer +from .resources.no_code_module import NoCodeModules from .resources.notification_configuration import NotificationConfigurations from .resources.oauth_client import OAuthClients from .resources.oauth_token import OAuthTokens @@ -108,6 +109,7 @@ def __init__(self, config: TFEConfig | None = None): self.workspace_resources = WorkspaceResourcesService(self._transport) self.workspace_run_tasks = WorkspaceRunTasks(self._transport) self.registry_modules = RegistryModules(self._transport) + self.no_code_modules = NoCodeModules(self._transport) self.registry_providers = RegistryProviders(self._transport) self.registry_provider_versions = RegistryProviderVersions(self._transport) self.registry_provider_platforms = RegistryProviderPlatforms(self._transport) diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index dbf4ba38..dc870dae 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -717,3 +717,25 @@ class RequiredProjectError(RequiredFieldMissing): def __init__(self, message: str = "project is required"): super().__init__(message) + + +# No-code module errors +class InvalidNoCodeModuleIDError(InvalidValues): + """Raised when an invalid no-code module ID is provided.""" + + def __init__(self, message: str = "invalid value for no-code module ID"): + super().__init__(message) + + +class InvalidWorkspaceUpgradeIDError(InvalidValues): + """Raised when an invalid workspace upgrade ID is provided.""" + + def __init__(self, message: str = "invalid value for workspace upgrade ID"): + super().__init__(message) + + +class RequiredRegistryModuleIDError(RequiredFieldMissing): + """Raised when a registry module ID is required but missing.""" + + def __init__(self, message: str = "registry module ID is required"): + super().__init__(message) diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index c3c6f56b..6ca910dd 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -76,6 +76,19 @@ ) # ── Notification Configurations ─────────────────────────────────────────────── +from .no_code_module import ( + NoCodeModule, + NoCodeModuleCreateOptions, + NoCodeModuleIncludeOpt, + NoCodeModuleReadOptions, + NoCodeModuleUpdateOptions, + NoCodeVariableOption, + NoCodeWorkspaceCreateOptions, + NoCodeWorkspaceUpgradeOptions, + NoCodeWorkspaceVariable, + RegistryModuleVariable, + WorkspaceUpgrade, +) from .notification_configuration import ( DeliveryResponse, NotificationConfiguration, @@ -523,6 +536,18 @@ # ── Public surface ──────────────────────────────────────────────────────────── __all__ = [ + # No-code provisioning + "NoCodeModule", + "NoCodeModuleCreateOptions", + "NoCodeModuleIncludeOpt", + "NoCodeModuleReadOptions", + "NoCodeModuleUpdateOptions", + "NoCodeVariableOption", + "NoCodeWorkspaceCreateOptions", + "NoCodeWorkspaceUpgradeOptions", + "NoCodeWorkspaceVariable", + "RegistryModuleVariable", + "WorkspaceUpgrade", # Notification configurations "DeliveryResponse", "NotificationConfiguration", diff --git a/src/pytfe/models/no_code_module.py b/src/pytfe/models/no_code_module.py new file mode 100644 index 00000000..c2f5ea6c --- /dev/null +++ b/src/pytfe/models/no_code_module.py @@ -0,0 +1,186 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field + +from .organization import Organization +from .registry_module import RegistryModule +from .variable import CategoryType +from .workspace import ExecutionMode, Workspace + + +class NoCodeModuleIncludeOpt(str, Enum): + """Include options for no-code module read.""" + + VARIABLE_OPTIONS = "variable_options" + + +class NoCodeVariableOption(BaseModel): + """An allowed-values constraint on a single variable in a no-code module. + + Returned as part of a no-code module's ``variable-options`` relationship. + The same shape is used both when reading a module (with ``include`` set) + and when constructing create/update options. + """ + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str | None = None + variable_name: str | None = Field(default=None, alias="variable-name") + variable_type: str | None = Field(default=None, alias="variable-type") + options: list[str] = Field(default_factory=list) + + +class NoCodeModule(BaseModel): + """Represents a no-code module — a registry module that has been enabled + for the no-code provisioning workflow. + """ + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str | None = None + enabled: bool | None = None + version_pin: str | None = Field(default=None, alias="version-pin") + + # Relationships + organization: Organization | None = None + registry_module: RegistryModule | None = Field( + default=None, alias="registry-module" + ) + variable_options: list[NoCodeVariableOption] = Field( + default_factory=list, alias="variable-options" + ) + + +class NoCodeModuleCreateOptions(BaseModel): + """Options for enabling no-code provisioning on a registry module.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + registry_module_id: str = Field( + ..., description="ID of the registry module to enable" + ) + enabled: bool | None = None + version_pin: str | None = Field(default=None, alias="version-pin") + variable_options: list[NoCodeVariableOption] = Field( + default_factory=list, alias="variable-options" + ) + + +class NoCodeModuleUpdateOptions(BaseModel): + """Options for updating no-code provisioning settings. + + ``variable_options`` entries with an ``id`` set update existing options; + entries without an ``id`` add new options. + """ + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + registry_module_id: str | None = None + enabled: bool | None = None + version_pin: str | None = Field(default=None, alias="version-pin") + variable_options: list[NoCodeVariableOption] | None = Field( + default=None, alias="variable-options" + ) + + +class NoCodeModuleReadOptions(BaseModel): + """Options for reading a no-code module with optional includes.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + include: list[NoCodeModuleIncludeOpt] | None = None + + +class NoCodeWorkspaceVariable(BaseModel): + """A workspace variable supplied inline during no-code workspace creation + or upgrade. Mirrors the fields accepted under the ``vars`` relationship. + """ + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + key: str + value: str | None = None + description: str | None = None + category: CategoryType | None = None + hcl: bool | None = None + sensitive: bool | None = None + + +class NoCodeWorkspaceCreateOptions(BaseModel): + """Options for creating a workspace from a no-code module.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + name: str = Field(..., description="Workspace name") + project_id: str = Field( + ..., description="ID of the project to create the workspace in" + ) + description: str | None = None + agent_pool_id: str | None = Field(default=None, alias="agent-pool-id") + auto_apply: bool | None = None + execution_mode: ExecutionMode | None = Field(default=None, alias="execution-mode") + source_name: str | None = Field(default=None, alias="source-name") + source_url: str | None = Field(default=None, alias="source-url") + terraform_version: str | None = Field(default=None, alias="terraform-version") + vars: list[NoCodeWorkspaceVariable] = Field(default_factory=list) + + +class NoCodeWorkspaceUpgradeOptions(BaseModel): + """Options for initiating a no-code workspace upgrade.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + vars: list[NoCodeWorkspaceVariable] = Field(default_factory=list) + + +class WorkspaceUpgrade(BaseModel): + """The result of initiating or polling a no-code workspace upgrade.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str | None = None + status: str | None = None + plan_url: str | None = Field(default=None, alias="plan-url") + message: str | None = None + + # Relationships + workspace: Workspace | None = None + + +class RegistryModuleVariable(BaseModel): + """A variable declared by a specific version of a registry module. + + Returned by ``client.no_code_modules.read_variables`` for use in driving + UIs that build no-code workspace creation forms. + """ + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str | None = None + name: str | None = None + type: str | None = None + description: str | None = None + default: str | None = None + required: bool | None = None + sensitive: bool | None = None + options: list[str] = Field(default_factory=list) + + +__all__ = [ + "NoCodeModule", + "NoCodeModuleCreateOptions", + "NoCodeModuleIncludeOpt", + "NoCodeModuleReadOptions", + "NoCodeModuleUpdateOptions", + "NoCodeVariableOption", + "NoCodeWorkspaceCreateOptions", + "NoCodeWorkspaceUpgradeOptions", + "NoCodeWorkspaceVariable", + "RegistryModuleVariable", + "WorkspaceUpgrade", +] diff --git a/src/pytfe/resources/no_code_module.py b/src/pytfe/resources/no_code_module.py new file mode 100644 index 00000000..e285b9e2 --- /dev/null +++ b/src/pytfe/resources/no_code_module.py @@ -0,0 +1,460 @@ +# 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 ( + InvalidNoCodeModuleIDError, + InvalidOrgError, + InvalidVersionError, + InvalidWorkspaceIDError, + InvalidWorkspaceUpgradeIDError, + RequiredAgentPoolIDError, + RequiredNameError, + RequiredProjectError, + RequiredRegistryModuleIDError, +) +from ..models.no_code_module import ( + NoCodeModule, + NoCodeModuleCreateOptions, + NoCodeModuleReadOptions, + NoCodeModuleUpdateOptions, + NoCodeVariableOption, + NoCodeWorkspaceCreateOptions, + NoCodeWorkspaceUpgradeOptions, + NoCodeWorkspaceVariable, + RegistryModuleVariable, + WorkspaceUpgrade, +) +from ..models.organization import Organization +from ..models.registry_module import RegistryModule +from ..models.workspace import ExecutionMode, Workspace +from ..utils import valid_string, valid_string_id +from ._base import _Service + +_NO_CODE_MODULE_TYPE = "no-code-modules" +_REGISTRY_MODULE_TYPE = "registry-module" +_VARIABLE_OPTIONS_TYPE = "variable-options" +_WORKSPACE_TYPE = "workspaces" +_VARS_TYPE = "vars" + + +def _variable_option_payload(opt: NoCodeVariableOption) -> dict[str, Any]: + """Serialize a NoCodeVariableOption to its JSON:API relationship-data shape. + + The API accepts both new entries (no ``id``) and updates to existing + entries (``id`` set). Only the wire-aliased keys are emitted. + """ + attrs: dict[str, Any] = {} + if opt.variable_name is not None: + attrs["variable-name"] = opt.variable_name + if opt.variable_type is not None: + attrs["variable-type"] = opt.variable_type + if opt.options: + attrs["options"] = list(opt.options) + entry: dict[str, Any] = {"type": _VARIABLE_OPTIONS_TYPE, "attributes": attrs} + if opt.id: + entry["id"] = opt.id + return entry + + +def _inline_var_payload(var: NoCodeWorkspaceVariable) -> dict[str, Any]: + attrs: dict[str, Any] = {"key": var.key} + if var.value is not None: + attrs["value"] = var.value + if var.description is not None: + attrs["description"] = var.description + if var.category is not None: + attrs["category"] = var.category.value + if var.hcl is not None: + attrs["hcl"] = var.hcl + if var.sensitive is not None: + attrs["sensitive"] = var.sensitive + return {"type": _VARS_TYPE, "attributes": attrs} + + +def _variable_option_from(data: dict[str, Any]) -> NoCodeVariableOption: + attrs = data.get("attributes") or {} + return NoCodeVariableOption.model_validate( + { + "id": data.get("id"), + "variable-name": attrs.get("variable-name"), + "variable-type": attrs.get("variable-type"), + "options": attrs.get("options") or [], + } + ) + + +def _no_code_module_from( + data: dict[str, Any], included: list[dict[str, Any]] | None = None +) -> NoCodeModule: + attrs = data.get("attributes") or {} + relationships = data.get("relationships") or {} + + module = NoCodeModule.model_validate( + { + "id": data.get("id"), + "enabled": attrs.get("enabled"), + "version-pin": attrs.get("version-pin"), + } + ) + + org_data = (relationships.get("organization") or {}).get("data") + if org_data and org_data.get("id"): + module.organization = Organization.model_construct(id=org_data["id"]) + + rm_data = (relationships.get("registry-module") or {}).get("data") + if rm_data and rm_data.get("id"): + module.registry_module = RegistryModule.model_construct(id=rm_data["id"]) + + vo_rel = (relationships.get("variable-options") or {}).get("data") or [] + if vo_rel and included: + index = {(item.get("type"), item.get("id")): item for item in included} + resolved: list[NoCodeVariableOption] = [] + for ref in vo_rel: + key = (ref.get("type"), ref.get("id")) + full = index.get(key) + if full is not None: + resolved.append(_variable_option_from(full)) + else: + resolved.append( + NoCodeVariableOption.model_validate({"id": ref.get("id")}) + ) + module.variable_options = resolved + elif vo_rel: + module.variable_options = [ + NoCodeVariableOption.model_validate({"id": ref.get("id")}) + for ref in vo_rel + if ref.get("id") + ] + + return module + + +def _workspace_upgrade_from(data: dict[str, Any]) -> WorkspaceUpgrade: + attrs = data.get("attributes") or {} + relationships = data.get("relationships") or {} + + upgrade = WorkspaceUpgrade.model_validate( + { + "id": data.get("id"), + "status": attrs.get("status"), + "plan-url": attrs.get("plan-url"), + "message": attrs.get("message"), + } + ) + + ws_data = (relationships.get("workspace") or {}).get("data") + if ws_data and ws_data.get("id"): + upgrade.workspace = Workspace.model_construct(id=ws_data["id"]) + + return upgrade + + +class NoCodeModules(_Service): + """No-code provisioning: enable a registry module for self-service + workspace creation, manage allowed variable values, and drive workspace + create/upgrade flows. + + Upstream docs: + https://developer.hashicorp.com/terraform/cloud-docs/api-docs/no-code-provisioning + + All write endpoints require a user or team token. Organization tokens are + not supported by the API. + """ + + # ---- No-code module CRUD ---- + + def create( + self, organization: str, options: NoCodeModuleCreateOptions + ) -> NoCodeModule: + """Enable no-code provisioning on a registry module.""" + if not valid_string_id(organization): + raise InvalidOrgError() + if not valid_string_id(options.registry_module_id): + raise RequiredRegistryModuleIDError() + + body = self._build_module_payload(options) + r = self.t.request( + "POST", + f"/api/v2/organizations/{organization}/no-code-modules", + json_body=body, + ) + return _no_code_module_from(r.json()["data"]) + + def read( + self, + no_code_module_id: str, + options: NoCodeModuleReadOptions | None = None, + ) -> NoCodeModule: + """Read a no-code module by ID, optionally including variable options.""" + if not valid_string_id(no_code_module_id): + raise InvalidNoCodeModuleIDError() + + params: dict[str, Any] = {} + if options and options.include: + params["include"] = ",".join(i.value for i in options.include) + + r = self.t.request( + "GET", + f"/api/v2/no-code-modules/{no_code_module_id}", + params=params or None, + ) + body = r.json() + return _no_code_module_from(body["data"], body.get("included")) + + def update( + self, no_code_module_id: str, options: NoCodeModuleUpdateOptions + ) -> NoCodeModule: + """Update no-code provisioning settings. + + The HCP API requires every PATCH on a no-code module to include the + ``registry-module`` relationship in the request body — without it, + the endpoint returns 404 even though the module exists. If the + caller didn't supply ``registry_module_id`` in ``options``, we + read the current module to pick up its existing relationship so + callers don't have to remember this quirk. + """ + if not valid_string_id(no_code_module_id): + raise InvalidNoCodeModuleIDError() + + if not options.registry_module_id: + current = self.read(no_code_module_id) + if current.registry_module and current.registry_module.id: + options = options.model_copy( + update={"registry_module_id": current.registry_module.id} + ) + + body = self._build_module_payload(options) + r = self.t.request( + "PATCH", + f"/api/v2/no-code-modules/{no_code_module_id}", + json_body=body, + ) + return _no_code_module_from(r.json()["data"]) + + def delete(self, no_code_module_id: str) -> None: + """Disable no-code provisioning for a registry module.""" + if not valid_string_id(no_code_module_id): + raise InvalidNoCodeModuleIDError() + + self.t.request("DELETE", f"/api/v2/no-code-modules/{no_code_module_id}") + + def read_variables( + self, no_code_module_id: str, version: str + ) -> Iterator[RegistryModuleVariable]: + """Iterate the variables declared by a specific version of a no-code + module. Useful for driving a form that lets users supply ``vars`` when + creating a workspace. + """ + if not valid_string_id(no_code_module_id): + raise InvalidNoCodeModuleIDError() + if not valid_string(version): + raise InvalidVersionError() + + path = ( + f"/api/v2/no-code-modules/{no_code_module_id}" + f"/versions/{version}/module-variables" + ) + for item in self._list(path): + attrs = item.get("attributes") or {} + yield RegistryModuleVariable.model_validate( + { + "id": item.get("id"), + "name": attrs.get("name"), + "type": attrs.get("type"), + "description": attrs.get("description"), + "default": attrs.get("default"), + "required": attrs.get("required"), + "sensitive": attrs.get("sensitive"), + "options": attrs.get("options") or [], + } + ) + + # ---- Workspace lifecycle ---- + + def create_workspace( + self, no_code_module_id: str, options: NoCodeWorkspaceCreateOptions + ) -> Workspace: + """Create a workspace from a no-code module. + + The returned Workspace is populated by the workspaces parser, so + relationships (project, agent_pool, vars) are available when the + server includes them. + """ + if not valid_string_id(no_code_module_id): + raise InvalidNoCodeModuleIDError() + if not valid_string(options.name): + raise RequiredNameError() + if not valid_string_id(options.project_id): + raise RequiredProjectError() + if options.execution_mode == ExecutionMode.AGENT and not valid_string_id( + options.agent_pool_id + ): + raise RequiredAgentPoolIDError() + + body = self._build_workspace_payload(options) + r = self.t.request( + "POST", + f"/api/v2/no-code-modules/{no_code_module_id}/workspaces", + json_body=body, + ) + # Reuse the workspace parser so all relationship handling stays in + # one place. Imported lazily to avoid a circular import. + from .workspaces import _ws_from + + return _ws_from(r.json()["data"]) + + def upgrade_workspace( + self, + no_code_module_id: str, + workspace_id: str, + options: NoCodeWorkspaceUpgradeOptions | None = None, + ) -> WorkspaceUpgrade: + """Initiate a no-code workspace upgrade. Returns the upgrade record; + poll with ``read_workspace_upgrade`` until ``status`` is + ``planned_and_finished`` (or terminal), then call + ``confirm_workspace_upgrade``. + """ + if not valid_string_id(no_code_module_id): + raise InvalidNoCodeModuleIDError() + if not valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + + attrs: dict[str, Any] = {} + body: dict[str, Any] = {"data": {"type": _WORKSPACE_TYPE, "attributes": attrs}} + if options and options.vars: + body["data"]["relationships"] = { + "vars": {"data": [_inline_var_payload(v) for v in options.vars]} + } + + r = self.t.request( + "POST", + ( + f"/api/v2/no-code-modules/{no_code_module_id}" + f"/workspaces/{workspace_id}/upgrade" + ), + json_body=body, + ) + return _workspace_upgrade_from(r.json()["data"]) + + def read_workspace_upgrade( + self, + no_code_module_id: str, + workspace_id: str, + upgrade_id: str, + ) -> WorkspaceUpgrade: + """Read the current status of a no-code workspace upgrade.""" + if not valid_string_id(no_code_module_id): + raise InvalidNoCodeModuleIDError() + if not valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + if not valid_string_id(upgrade_id): + raise InvalidWorkspaceUpgradeIDError() + + r = self.t.request( + "GET", + ( + f"/api/v2/no-code-modules/{no_code_module_id}" + f"/workspaces/{workspace_id}/upgrade/{upgrade_id}" + ), + ) + return _workspace_upgrade_from(r.json()["data"]) + + def confirm_workspace_upgrade( + self, + no_code_module_id: str, + workspace_id: str, + upgrade_id: str, + ) -> None: + """Confirm and apply a no-code workspace upgrade plan. + + The API returns a plain-text body (``"Workspace update completed"``) + rather than a JSON:API envelope; we intentionally return ``None`` and + rely on the HTTP status for success/failure semantics, matching the + SDK's pattern for action endpoints. + """ + if not valid_string_id(no_code_module_id): + raise InvalidNoCodeModuleIDError() + if not valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + if not valid_string_id(upgrade_id): + raise InvalidWorkspaceUpgradeIDError() + + self.t.request( + "POST", + ( + f"/api/v2/no-code-modules/{no_code_module_id}" + f"/workspaces/{workspace_id}/upgrade/{upgrade_id}" + ), + ) + + # ---- Payload builders ---- + + def _build_module_payload( + self, + options: NoCodeModuleCreateOptions | NoCodeModuleUpdateOptions, + ) -> dict[str, Any]: + attrs: dict[str, Any] = {} + if options.enabled is not None: + attrs["enabled"] = options.enabled + if options.version_pin is not None: + attrs["version-pin"] = options.version_pin + + body: dict[str, Any] = { + "data": {"type": _NO_CODE_MODULE_TYPE, "attributes": attrs} + } + + relationships: dict[str, Any] = {} + if options.registry_module_id: + relationships["registry-module"] = { + "data": { + "type": _REGISTRY_MODULE_TYPE, + "id": options.registry_module_id, + } + } + + var_opts = options.variable_options + if var_opts: + relationships["variable-options"] = { + "data": [_variable_option_payload(v) for v in var_opts] + } + + if relationships: + body["data"]["relationships"] = relationships + + return body + + def _build_workspace_payload( + self, options: NoCodeWorkspaceCreateOptions + ) -> dict[str, Any]: + attrs: dict[str, Any] = {"name": options.name} + if options.description is not None: + attrs["description"] = options.description + if options.agent_pool_id is not None: + attrs["agent-pool-id"] = options.agent_pool_id + if options.auto_apply is not None: + attrs["auto_apply"] = options.auto_apply + if options.execution_mode is not None: + attrs["execution-mode"] = options.execution_mode.value + if options.source_name is not None: + attrs["source-name"] = options.source_name + if options.source_url is not None: + attrs["source-url"] = options.source_url + if options.terraform_version is not None: + attrs["terraform-version"] = options.terraform_version + + body: dict[str, Any] = {"data": {"type": _WORKSPACE_TYPE, "attributes": attrs}} + + relationships: dict[str, Any] = { + "project": {"data": {"type": "projects", "id": options.project_id}}, + } + if options.vars: + relationships["vars"] = { + "data": [_inline_var_payload(v) for v in options.vars] + } + body["data"]["relationships"] = relationships + return body diff --git a/tests/units/test_no_code_module.py b/tests/units/test_no_code_module.py new file mode 100644 index 00000000..b6255e09 --- /dev/null +++ b/tests/units/test_no_code_module.py @@ -0,0 +1,680 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the no-code provisioning resource.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import Mock + +import pytest + +from pytfe.errors import ( + InvalidNoCodeModuleIDError, + InvalidOrgError, + InvalidVersionError, + InvalidWorkspaceIDError, + InvalidWorkspaceUpgradeIDError, + RequiredAgentPoolIDError, + RequiredNameError, + RequiredProjectError, + RequiredRegistryModuleIDError, +) +from pytfe.models.no_code_module import ( + NoCodeModuleCreateOptions, + NoCodeModuleIncludeOpt, + NoCodeModuleReadOptions, + NoCodeModuleUpdateOptions, + NoCodeVariableOption, + NoCodeWorkspaceCreateOptions, + NoCodeWorkspaceUpgradeOptions, + NoCodeWorkspaceVariable, +) +from pytfe.models.variable import CategoryType +from pytfe.models.workspace import ExecutionMode +from pytfe.resources.no_code_module import NoCodeModules + + +def _resp(json_body: Any) -> Mock: + r = Mock() + r.json.return_value = json_body + return r + + +def _no_code_module_body( + *, + nc_id: str = "nocode-abc123", + enabled: bool = True, + version_pin: str | None = "1.0.0", + org_id: str = "my-org", + registry_module_id: str = "mod-abc123", + variable_option_refs: list[dict[str, str]] | None = None, + included: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + relationships: dict[str, Any] = { + "organization": {"data": {"type": "organizations", "id": org_id}}, + "registry-module": { + "data": {"type": "registry-modules", "id": registry_module_id} + }, + } + if variable_option_refs is not None: + relationships["variable-options"] = {"data": variable_option_refs} + + body: dict[str, Any] = { + "data": { + "id": nc_id, + "type": "no-code-modules", + "attributes": {"enabled": enabled, "version-pin": version_pin}, + "relationships": relationships, + } + } + if included is not None: + body["included"] = included + return body + + +def _workspace_body(*, ws_id: str = "ws-abc123") -> dict[str, Any]: + return { + "data": { + "id": ws_id, + "type": "workspaces", + "attributes": { + "name": "no-code-ws", + "execution-mode": "remote", + }, + "relationships": { + "project": {"data": {"type": "projects", "id": "prj-abc123"}}, + }, + } + } + + +def _upgrade_body( + *, + upgrade_id: str = "wsu-abc123", + status: str = "planned", + ws_id: str = "ws-abc123", +) -> dict[str, Any]: + return { + "data": { + "id": upgrade_id, + "type": "workspace-upgrade", + "attributes": { + "status": status, + "plan-url": "https://app.terraform.io/plan/abc", + }, + "relationships": { + "workspace": {"data": {"type": "workspaces", "id": ws_id}}, + }, + } + } + + +class TestNoCodeModuleCreate: + def setup_method(self) -> None: + self.transport = Mock() + self.service = NoCodeModules(self.transport) + + def test_create_minimum_payload(self) -> None: + self.transport.request.return_value = _resp(_no_code_module_body()) + + result = self.service.create( + "my-org", + NoCodeModuleCreateOptions(registry_module_id="mod-abc123"), + ) + + method, path = self.transport.request.call_args.args + body = self.transport.request.call_args.kwargs["json_body"] + + assert method == "POST" + assert path == "/api/v2/organizations/my-org/no-code-modules" + assert body["data"]["type"] == "no-code-modules" + assert body["data"]["attributes"] == {} + assert body["data"]["relationships"]["registry-module"]["data"] == { + "type": "registry-module", + "id": "mod-abc123", + } + assert "variable-options" not in body["data"]["relationships"] + assert result.id == "nocode-abc123" + assert result.registry_module is not None + assert result.registry_module.id == "mod-abc123" + + def test_create_with_enabled_and_version_pin(self) -> None: + self.transport.request.return_value = _resp(_no_code_module_body()) + + self.service.create( + "my-org", + NoCodeModuleCreateOptions( + registry_module_id="mod-abc123", + enabled=True, + version_pin="2.4.0", + ), + ) + + body = self.transport.request.call_args.kwargs["json_body"] + assert body["data"]["attributes"] == { + "enabled": True, + "version-pin": "2.4.0", + } + + def test_create_with_variable_options(self) -> None: + self.transport.request.return_value = _resp(_no_code_module_body()) + + self.service.create( + "my-org", + NoCodeModuleCreateOptions( + registry_module_id="mod-abc123", + variable_options=[ + NoCodeVariableOption( + variable_name="region", + variable_type="string", + options=["us-east-1", "us-west-2"], + ) + ], + ), + ) + + body = self.transport.request.call_args.kwargs["json_body"] + var_opts = body["data"]["relationships"]["variable-options"]["data"] + assert len(var_opts) == 1 + assert var_opts[0] == { + "type": "variable-options", + "attributes": { + "variable-name": "region", + "variable-type": "string", + "options": ["us-east-1", "us-west-2"], + }, + } + # No id on a new option + assert "id" not in var_opts[0] + + def test_create_invalid_org_raises(self) -> None: + with pytest.raises(InvalidOrgError): + self.service.create( + "", + NoCodeModuleCreateOptions(registry_module_id="mod-abc123"), + ) + + def test_create_missing_registry_module_id_raises(self) -> None: + with pytest.raises(RequiredRegistryModuleIDError): + self.service.create( + "my-org", + NoCodeModuleCreateOptions(registry_module_id=""), + ) + + +class TestNoCodeModuleRead: + def setup_method(self) -> None: + self.transport = Mock() + self.service = NoCodeModules(self.transport) + + def test_read_without_include(self) -> None: + self.transport.request.return_value = _resp(_no_code_module_body()) + + result = self.service.read("nocode-abc123") + + method, path = self.transport.request.call_args.args + kwargs = self.transport.request.call_args.kwargs + assert method == "GET" + assert path == "/api/v2/no-code-modules/nocode-abc123" + assert kwargs.get("params") is None + assert result.id == "nocode-abc123" + assert result.enabled is True + + def test_read_with_include_emits_query_param(self) -> None: + self.transport.request.return_value = _resp(_no_code_module_body()) + + self.service.read( + "nocode-abc123", + NoCodeModuleReadOptions(include=[NoCodeModuleIncludeOpt.VARIABLE_OPTIONS]), + ) + + params = self.transport.request.call_args.kwargs["params"] + assert params == {"include": "variable_options"} + + def test_read_resolves_included_variable_options(self) -> None: + body = _no_code_module_body( + variable_option_refs=[ + {"type": "variable-options", "id": "vo-1"}, + {"type": "variable-options", "id": "vo-2"}, + ], + included=[ + { + "type": "variable-options", + "id": "vo-1", + "attributes": { + "variable-name": "region", + "variable-type": "string", + "options": ["us-east-1"], + }, + }, + # Only one of the two is in `included` — the other should + # fall back to an id-only stub. + ], + ) + self.transport.request.return_value = _resp(body) + + result = self.service.read("nocode-abc123") + + assert len(result.variable_options) == 2 + first = result.variable_options[0] + assert first.id == "vo-1" + assert first.variable_name == "region" + assert first.options == ["us-east-1"] + second = result.variable_options[1] + assert second.id == "vo-2" + assert second.variable_name is None + + def test_read_invalid_id_raises(self) -> None: + with pytest.raises(InvalidNoCodeModuleIDError): + self.service.read("") + + +class TestNoCodeModuleUpdate: + def setup_method(self) -> None: + self.transport = Mock() + self.service = NoCodeModules(self.transport) + + def test_update_variable_options_with_and_without_ids(self) -> None: + # Caller supplies registry_module_id explicitly, so no auto-read. + self.transport.request.return_value = _resp(_no_code_module_body()) + + self.service.update( + "nocode-abc123", + NoCodeModuleUpdateOptions( + registry_module_id="mod-abc123", + enabled=False, + variable_options=[ + NoCodeVariableOption( + id="vo-existing", + variable_name="region", + variable_type="string", + options=["us-east-1", "us-west-2"], + ), + NoCodeVariableOption( + variable_name="size", + variable_type="string", + options=["small", "medium", "large"], + ), + ], + ), + ) + + # One PATCH (no preceding GET because the caller provided + # registry_module_id explicitly). + assert self.transport.request.call_count == 1 + method, path = self.transport.request.call_args.args + body = self.transport.request.call_args.kwargs["json_body"] + assert method == "PATCH" + assert path == "/api/v2/no-code-modules/nocode-abc123" + assert body["data"]["attributes"] == {"enabled": False} + + # API requires the registry-module relationship on every PATCH. + assert body["data"]["relationships"]["registry-module"]["data"] == { + "type": "registry-module", + "id": "mod-abc123", + } + + var_opts = body["data"]["relationships"]["variable-options"]["data"] + assert len(var_opts) == 2 + assert var_opts[0]["id"] == "vo-existing" + assert "id" not in var_opts[1] + + def test_update_without_registry_module_id_auto_reads(self) -> None: + # When the caller omits registry_module_id, the resource fetches the + # current module to satisfy the API's PATCH requirement. + read_response = _resp(_no_code_module_body(registry_module_id="mod-xyz789")) + patch_response = _resp(_no_code_module_body(registry_module_id="mod-xyz789")) + self.transport.request.side_effect = [read_response, patch_response] + + self.service.update( + "nocode-abc123", + NoCodeModuleUpdateOptions(enabled=True), + ) + + # Two requests: first GET (auto-read), then PATCH. + assert self.transport.request.call_count == 2 + first_method, first_path = self.transport.request.call_args_list[0].args + second_method, second_path = self.transport.request.call_args_list[1].args + assert first_method == "GET" + assert first_path == "/api/v2/no-code-modules/nocode-abc123" + assert second_method == "PATCH" + assert second_path == "/api/v2/no-code-modules/nocode-abc123" + + # PATCH body picked up the existing registry-module relationship. + body = self.transport.request.call_args_list[1].kwargs["json_body"] + assert body["data"]["relationships"]["registry-module"]["data"] == { + "type": "registry-module", + "id": "mod-xyz789", + } + + def test_update_invalid_id_raises(self) -> None: + with pytest.raises(InvalidNoCodeModuleIDError): + self.service.update( + "", + NoCodeModuleUpdateOptions(enabled=True), + ) + + +class TestNoCodeModuleDelete: + def setup_method(self) -> None: + self.transport = Mock() + self.service = NoCodeModules(self.transport) + + def test_delete_calls_delete_path(self) -> None: + self.transport.request.return_value = _resp({}) + + self.service.delete("nocode-abc123") + + method, path = self.transport.request.call_args.args + assert method == "DELETE" + assert path == "/api/v2/no-code-modules/nocode-abc123" + + def test_delete_invalid_id_raises(self) -> None: + with pytest.raises(InvalidNoCodeModuleIDError): + self.service.delete("") + + +class TestNoCodeModuleReadVariables: + def setup_method(self) -> None: + self.transport = Mock() + self.service = NoCodeModules(self.transport) + + def test_read_variables_yields_typed_records(self) -> None: + page = { + "data": [ + { + "id": "modvar-1", + "type": "module-variables", + "attributes": { + "name": "region", + "type": "string", + "description": "AWS region", + "default": "us-east-1", + "required": False, + "sensitive": False, + "options": ["us-east-1", "us-west-2"], + }, + } + ], + "meta": {"pagination": {"current-page": 1, "total-pages": 1}}, + } + self.transport.request.return_value = _resp(page) + + result = list(self.service.read_variables("nocode-abc123", "1.0.0")) + + path = self.transport.request.call_args.args[1] + assert ( + path + == "/api/v2/no-code-modules/nocode-abc123/versions/1.0.0/module-variables" + ) + assert len(result) == 1 + v = result[0] + assert v.id == "modvar-1" + assert v.name == "region" + assert v.options == ["us-east-1", "us-west-2"] + + def test_read_variables_invalid_id(self) -> None: + with pytest.raises(InvalidNoCodeModuleIDError): + list(self.service.read_variables("", "1.0.0")) + + def test_read_variables_invalid_version(self) -> None: + with pytest.raises(InvalidVersionError): + list(self.service.read_variables("nocode-abc123", "")) + + +class TestNoCodeCreateWorkspace: + def setup_method(self) -> None: + self.transport = Mock() + self.service = NoCodeModules(self.transport) + + def test_minimum_payload(self) -> None: + self.transport.request.return_value = _resp(_workspace_body()) + + ws = self.service.create_workspace( + "nocode-abc123", + NoCodeWorkspaceCreateOptions(name="no-code-ws", project_id="prj-abc123"), + ) + + method, path = self.transport.request.call_args.args + body = self.transport.request.call_args.kwargs["json_body"] + assert method == "POST" + assert path == "/api/v2/no-code-modules/nocode-abc123/workspaces" + assert body["data"]["type"] == "workspaces" + assert body["data"]["attributes"] == {"name": "no-code-ws"} + assert body["data"]["relationships"]["project"]["data"] == { + "type": "projects", + "id": "prj-abc123", + } + assert "vars" not in body["data"]["relationships"] + assert ws.id == "ws-abc123" + + def test_payload_with_inline_vars(self) -> None: + self.transport.request.return_value = _resp(_workspace_body()) + + self.service.create_workspace( + "nocode-abc123", + NoCodeWorkspaceCreateOptions( + name="no-code-ws", + project_id="prj-abc123", + description="from no-code module", + terraform_version="1.7.0", + vars=[ + NoCodeWorkspaceVariable( + key="region", + value="us-east-1", + category=CategoryType.TERRAFORM, + ), + NoCodeWorkspaceVariable( + key="API_KEY", + value="redacted", + category=CategoryType.ENV, + sensitive=True, + ), + ], + ), + ) + + body = self.transport.request.call_args.kwargs["json_body"] + attrs = body["data"]["attributes"] + assert attrs["name"] == "no-code-ws" + assert attrs["description"] == "from no-code module" + assert attrs["terraform-version"] == "1.7.0" + + var_data = body["data"]["relationships"]["vars"]["data"] + assert len(var_data) == 2 + assert var_data[0] == { + "type": "vars", + "attributes": { + "key": "region", + "value": "us-east-1", + "category": "terraform", + }, + } + assert var_data[1]["attributes"]["sensitive"] is True + assert var_data[1]["attributes"]["category"] == "env" + + def test_agent_execution_mode_requires_agent_pool_id(self) -> None: + with pytest.raises(RequiredAgentPoolIDError): + self.service.create_workspace( + "nocode-abc123", + NoCodeWorkspaceCreateOptions( + name="no-code-ws", + project_id="prj-abc123", + execution_mode=ExecutionMode.AGENT, + ), + ) + + def test_agent_execution_mode_with_agent_pool_id_succeeds(self) -> None: + self.transport.request.return_value = _resp(_workspace_body()) + + self.service.create_workspace( + "nocode-abc123", + NoCodeWorkspaceCreateOptions( + name="no-code-ws", + project_id="prj-abc123", + execution_mode=ExecutionMode.AGENT, + agent_pool_id="apool-abc123", + ), + ) + + attrs = self.transport.request.call_args.kwargs["json_body"]["data"][ + "attributes" + ] + assert attrs["execution-mode"] == "agent" + assert attrs["agent-pool-id"] == "apool-abc123" + + def test_missing_name_raises(self) -> None: + with pytest.raises(RequiredNameError): + self.service.create_workspace( + "nocode-abc123", + NoCodeWorkspaceCreateOptions(name="", project_id="prj-abc123"), + ) + + def test_missing_project_id_raises(self) -> None: + with pytest.raises(RequiredProjectError): + self.service.create_workspace( + "nocode-abc123", + NoCodeWorkspaceCreateOptions(name="no-code-ws", project_id=""), + ) + + def test_invalid_module_id_raises(self) -> None: + with pytest.raises(InvalidNoCodeModuleIDError): + self.service.create_workspace( + "", + NoCodeWorkspaceCreateOptions( + name="no-code-ws", project_id="prj-abc123" + ), + ) + + +class TestNoCodeUpgradeWorkspace: + def setup_method(self) -> None: + self.transport = Mock() + self.service = NoCodeModules(self.transport) + + def test_upgrade_without_vars(self) -> None: + self.transport.request.return_value = _resp(_upgrade_body()) + + result = self.service.upgrade_workspace("nocode-abc123", "ws-abc123") + + method, path = self.transport.request.call_args.args + body = self.transport.request.call_args.kwargs["json_body"] + assert method == "POST" + assert ( + path == "/api/v2/no-code-modules/nocode-abc123/workspaces/ws-abc123/upgrade" + ) + assert body == {"data": {"type": "workspaces", "attributes": {}}} + assert result.id == "wsu-abc123" + assert result.status == "planned" + assert result.plan_url == "https://app.terraform.io/plan/abc" + assert result.workspace is not None + assert result.workspace.id == "ws-abc123" + + def test_upgrade_with_vars(self) -> None: + self.transport.request.return_value = _resp(_upgrade_body()) + + self.service.upgrade_workspace( + "nocode-abc123", + "ws-abc123", + NoCodeWorkspaceUpgradeOptions( + vars=[ + NoCodeWorkspaceVariable( + key="region", + value="us-west-2", + category=CategoryType.TERRAFORM, + ), + ] + ), + ) + + body = self.transport.request.call_args.kwargs["json_body"] + var_data = body["data"]["relationships"]["vars"]["data"] + assert var_data[0]["attributes"]["value"] == "us-west-2" + + def test_invalid_module_id(self) -> None: + with pytest.raises(InvalidNoCodeModuleIDError): + self.service.upgrade_workspace("", "ws-abc123") + + def test_invalid_workspace_id(self) -> None: + with pytest.raises(InvalidWorkspaceIDError): + self.service.upgrade_workspace("nocode-abc123", "") + + +class TestNoCodeReadAndConfirmUpgrade: + def setup_method(self) -> None: + self.transport = Mock() + self.service = NoCodeModules(self.transport) + + def test_read_upgrade(self) -> None: + self.transport.request.return_value = _resp( + _upgrade_body(status="planned_and_finished") + ) + + result = self.service.read_workspace_upgrade( + "nocode-abc123", "ws-abc123", "wsu-abc123" + ) + + method, path = self.transport.request.call_args.args + assert method == "GET" + assert path == ( + "/api/v2/no-code-modules/nocode-abc123/workspaces/" + "ws-abc123/upgrade/wsu-abc123" + ) + assert result.status == "planned_and_finished" + + def test_confirm_upgrade_returns_none_and_ignores_plain_text(self) -> None: + # The API returns a plain-text body. The resource should not try to + # parse it; status code alone signals success. + r = Mock() + r.json.side_effect = ValueError("not JSON") + r.text = "Workspace update completed" + self.transport.request.return_value = r + + result = self.service.confirm_workspace_upgrade( + "nocode-abc123", "ws-abc123", "wsu-abc123" + ) + + method, path = self.transport.request.call_args.args + assert method == "POST" + assert path == ( + "/api/v2/no-code-modules/nocode-abc123/workspaces/" + "ws-abc123/upgrade/wsu-abc123" + ) + assert result is None + + def test_read_upgrade_invalid_upgrade_id(self) -> None: + with pytest.raises(InvalidWorkspaceUpgradeIDError): + self.service.read_workspace_upgrade("nocode-abc123", "ws-abc123", "") + + def test_confirm_upgrade_invalid_workspace_id(self) -> None: + with pytest.raises(InvalidWorkspaceIDError): + self.service.confirm_workspace_upgrade("nocode-abc123", "", "wsu-abc123") + + +class TestWorkspaceAgentPoolParserFix: + """Regression test for the workspace parser bug fixed alongside this + feature. Prior to the fix, the parser wrote ``attr["agent_pools"]`` + (plural) while the Workspace model declares ``agent_pool`` (singular), + so the relationship was always silently dropped. + """ + + def test_agent_pool_relationship_populated_on_parsed_workspace(self) -> None: + from pytfe.resources.workspaces import _ws_from + + data = { + "id": "ws-agent", + "type": "workspaces", + "attributes": {"name": "agent-ws", "execution-mode": "agent"}, + "relationships": { + "agent-pool": { + "data": {"type": "agent-pools", "id": "apool-abc123"}, + }, + }, + } + + ws = _ws_from(data) + + assert ws.agent_pool is not None + assert ws.agent_pool.id == "apool-abc123"