From 32a9abca1b0f3c85f0f34f9e9c255f991dd10499 Mon Sep 17 00:00:00 2001 From: Sivaselvan32 Date: Mon, 16 Mar 2026 17:51:55 +0530 Subject: [PATCH 1/7] 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 c19b007..7582030 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 2/7] 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 0000000..6ecf66a --- /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 d1c8337..bfa938d 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 168d37b..e1cebbb 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 8524e6b..c828fd7 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 a588e9c..3105bda 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 7582030..300921e 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 0000000..8188c4e --- /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 3/7] 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 6ecf66a..6045cca 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 300921e..f136e43 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 8188c4e..ba2c7fc 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 4/7] 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 6045cca..f719af3 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 e1cebbb..7a52768 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 f136e43..769ba97 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 ba2c7fc..a1eb0ce 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 5/7] 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 f719af3..b5d267b 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 a1eb0ce..5e6a2c2 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 6/7] 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 b5d267b..5615a8b 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 5e6a2c2..37df987 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 7/7] 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 0000000..cd38ab0 --- /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