From b811f60ee514db8316a2a368aa759bc5c031dd60 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Tue, 17 Mar 2026 16:03:28 +0530 Subject: [PATCH 1/7] 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 e913f6d..8c69a60 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 0000000..82225a6 --- /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 2/7] 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 0000000..6ed8af4 --- /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 30b506b..a22402a 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 0000000..dd3217f --- /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 3/7] 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 82225a6..29aa8ca 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 4/7] 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 dd3217f..746fc59 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 5/7] 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 8c69a60..bd702df 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 6/7] 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 6ed8af4..1f42d17 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 7/7] 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 1f42d17..b91b194 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 0000000..75b6a4a --- /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("")