# Copyright (c) 2026 bdperkin
# SPDX-License-Identifier: MIT
"""Team-scoped player roster operations."""
from __future__ import annotations
from contextlib import suppress
from typing import TYPE_CHECKING, Any
from gamesheet_sdk.admin.roster import players
from gamesheet_sdk.admin.roster.helpers import (
_build_player_roster_entry,
_build_player_update_payload,
_patch_player_record,
_populate_player_metadata,
_prepare_player_update,
_upload_photo,
get_team_for_roster_update,
update_team_roster,
)
from gamesheet_sdk.admin.roster.models import Player, parse_player
from gamesheet_sdk.common.exceptions import GameSheetError
from gamesheet_sdk.common.shared import JSONAPI_HEADERS, handle_response
if TYPE_CHECKING:
from gamesheet_sdk.common.session import Session
[docs]
def list_team_players(session: Session, season_id: str, team_id: str) -> list[Player]:
"""Return every player for the specified team.
The supplied :class:`Session` must already carry a bearer token (e.g. via
:meth:`Session.set_bearer_token`); the call is otherwise unauthenticated and will 401.
Args:
session (Session): An authenticated :class:`Session`.
season_id (str): The season identifier.
team_id (str): The team identifier whose players to list.
Returns:
list[Player]: A list of :class:`Player`, in the order the server returned them. The list may be empty
if the team has no players.
"""
endpoint = f"/api/seasons/{season_id}/teams/{team_id}"
response = session.get(
endpoint,
headers=JSONAPI_HEADERS,
params={"include": "players,coaches"},
)
handle_response(response, endpoint, "GET team")
body: dict[str, Any] = response.json()
included_players = {
item["id"]: item
for item in body.get(
"included",
[],
)
if item.get("type") == "players"
}
roster_metadata = {
str(p["id"]): p
for p in body.get("data", {}).get("attributes", {}).get("roster", {}).get("players", [])
}
team_players = []
for player_id, player_data in included_players.items():
player = parse_player(player_data)
if player_id in roster_metadata:
metadata = roster_metadata[player_id]
player.number = metadata.get("number")
player.position = metadata.get("position")
player.duty = metadata.get("duty")
# designation is stored as "duty" in the roster
# Map back: "captain" -> "Captain", "alternate_captain" -> "Alternate Captain"
if player.duty:
player.designation = player.duty.replace("_", " ").title()
player.status = metadata.get("status")
player.starting = metadata.get("starting")
player.added_at_game_time = metadata.get("added_at_game_time")
player.affiliated = metadata.get("affiliated")
team_players.append(player)
return team_players
[docs]
def get_team_player(
session: Session,
season_id: str,
team_id: str,
player_id: str,
) -> Player:
"""Get a single player from a team's roster.
This function retrieves team roster metadata (number, position, status, etc.) that is only available in
the team context, unlike :func:`get_player` which fetches from the season-level players endpoint without
roster metadata. The supplied :class:`Session` must already carry a bearer token (e.g. via
:meth:`Session.set_bearer_token`); the call is otherwise unauthenticated and will 401.
Args:
session (Session): An authenticated :class:`Session`.
season_id (str): The season identifier.
team_id (str): The team identifier.
player_id (str): The player identifier to retrieve.
Returns:
Player: The :class:`Player` with team roster metadata populated.
Raises:
GameSheetError: If the player is not found on the team's roster.
"""
team_players = list_team_players(session, season_id, team_id)
for player in team_players:
if player.id == player_id:
return player
msg = f"Player {player_id} not found on team {team_id}"
raise GameSheetError(msg)
[docs]
def create_team_player( # noqa: C901
session: Session,
season_id: str,
team_id: str,
first_name: str,
last_name: str,
*,
external_id: str | None = None,
jersey: str | None = None,
position: str | None = None,
status: str | None = None,
designation: str | None = None,
biography: str | None = None,
height: str | None = None,
weight: str | None = None,
shot_hand: str | None = None,
birthdate: str | None = None,
hometown: str | None = None,
country: str | None = None,
province: str | None = None,
drafted_by: str | None = None,
committed_to: str | None = None,
photo_path: str | None = None,
) -> Player:
r"""Create a new player and add to the specified team's roster.
This function performs two operations: (1) creates the player at the season level, (2) updates the team's
roster to include the new player with position and other metadata. The supplied :class:`Session` must
already carry a bearer token (e.g. via :meth:`Session.set_bearer_token`); the call is otherwise
unauthenticated and will 401.
Args:
session (Session): An authenticated :class:`Session`.
season_id (str): The season identifier.
team_id (str): The team identifier to add the player to.
first_name (str): Player's first name.
last_name (str): Player's last name.
external_id (str | None): Optional external identifier for the player.
jersey (str | None): Optional jersey number.
position (str | None): Optional position (Forward, Defence, Goalie, etc.).
status (str | None): Optional status (Regular, Affiliated, etc.).
designation (str | None): Optional designation (Captain, Alternate Captain, etc.).
biography (str | None): Optional biography text.
height (str | None): Optional height (e.g., "6'2\"").
weight (str | None): Optional weight (e.g., "185").
shot_hand (str | None): Optional shooting hand (left, right).
birthdate (str | None): Optional birthdate (ISO format: YYYY-MM-DD).
hometown (str | None): Optional hometown.
country (str | None): Optional country code (e.g., "US", "CA").
province (str | None): Optional province/state.
drafted_by (str | None): Optional drafted by team name.
committed_to (str | None): Optional committed to institution.
photo_path (str | None): Optional path to a local photo image file.
Returns:
Player: The newly created Player model instance with roster metadata populated.
"""
photo_url: str | None = None
if photo_path:
photo_url = _upload_photo(session, photo_path)
# Step 1: Create the player at the season level (without "type" field for team context)
endpoint = f"/api/seasons/{season_id}/players"
payload: dict[str, Any] = {
"data": {"attributes": {"first_name": first_name, "last_name": last_name}},
}
attrs = payload["data"]["attributes"]
if external_id:
attrs["external_id"] = external_id
if biography:
attrs["biography"] = biography
if height:
attrs["height"] = height
if weight:
attrs["weight"] = weight
if shot_hand:
attrs["shot_hand"] = shot_hand
if birthdate:
attrs["birthdate"] = birthdate
if hometown:
attrs["hometown"] = hometown
if country:
attrs["country"] = country
if province:
attrs["province"] = province
if drafted_by:
attrs["drafted_by"] = drafted_by
if committed_to:
attrs["committed_to"] = committed_to
if photo_url:
attrs["photo_url"] = photo_url
response = session.post(endpoint, headers=JSONAPI_HEADERS, json=payload)
handle_response(response, endpoint, "POST player")
player = parse_player(response.json()["data"])
# Step 2: Fetch current team data and update roster
team_data = get_team_for_roster_update(session, season_id, team_id)
roster = team_data.get("data", {}).get("attributes", {}).get("roster", {})
players_roster = roster.get("players", [])
players_roster.append(
_build_player_roster_entry(
player.id,
jersey=jersey,
position=position,
status=status,
designation=designation,
),
)
roster["players"] = players_roster
# Step 3: Update team roster
update_team_roster(
session,
season_id,
team_id,
roster,
team_data.get("data", {}).get("attributes", {}),
team_data.get("data", {}).get("relationships", {}),
)
_populate_player_metadata(
player,
jersey=jersey,
position=position,
status=status,
designation=designation,
)
return player
[docs]
def update_team_player(
session: Session,
season_id: str,
team_id: str,
player_id: str,
*,
first_name: str | None = None,
last_name: str | None = None,
external_id: str | None = None,
biography: str | None = None,
height: str | None = None,
weight: str | None = None,
shot_hand: str | None = None,
birthdate: str | None = None,
hometown: str | None = None,
country: str | None = None,
province: str | None = None,
drafted_by: str | None = None,
committed_to: str | None = None,
photo_path: str | None = None,
remove_photo: bool = False,
) -> Player:
r"""Update a player for a specific team.
This function updates the player at the season level. The supplied :class:`Session` must already carry a
bearer token (e.g. via :meth:`Session.set_bearer_token`); the call is otherwise unauthenticated and will
401.
Args:
session (Session): An authenticated :class:`Session`.
season_id (str): The season identifier.
team_id (str): The team identifier.
player_id (str): The player identifier to update.
first_name (str | None): Optional updated first name.
last_name (str | None): Optional updated last name.
external_id (str | None): Optional updated external identifier.
biography (str | None): Optional updated biography text.
height (str | None): Optional updated height (e.g., "6'2\"").
weight (str | None): Optional updated weight (e.g., "185").
shot_hand (str | None): Optional updated shooting hand (left, right).
birthdate (str | None): Optional updated birthdate (ISO format: YYYY-MM-DD).
hometown (str | None): Optional updated hometown.
country (str | None): Optional updated country code (e.g., "US", "CA").
province (str | None): Optional updated province/state.
drafted_by (str | None): Optional updated drafted by team name.
committed_to (str | None): Optional updated committed to institution.
photo_path (str | None): Optional path to a new photo image file.
remove_photo (bool): If True, remove the player's photo.
Returns:
Player: The updated :class:`Player`.
"""
# pylint: disable=duplicate-code
photo_url = _prepare_player_update(
session,
first_name,
last_name,
external_id,
biography,
height,
weight,
shot_hand,
birthdate,
hometown,
country,
province,
drafted_by,
committed_to,
photo_path=photo_path,
remove_photo=remove_photo,
)
# pylint: enable=duplicate-code
# Fetch current player to get all fields
current_player = get_team_player(session, season_id, team_id, player_id)
# Build payload with updated values (without "type" field for team context)
# pylint: disable=duplicate-code
payload = _build_player_update_payload(
player_id,
current_player,
first_name=first_name,
last_name=last_name,
external_id=external_id,
biography=biography,
height=height,
weight=weight,
shot_hand=shot_hand,
birthdate=birthdate,
hometown=hometown,
country=country,
province=province,
drafted_by=drafted_by,
committed_to=committed_to,
photo_url=photo_url,
remove_photo=remove_photo,
include_type=False,
)
# pylint: enable=duplicate-code
data = _patch_player_record(session, season_id, player_id, payload, "PATCH team player")
player = parse_player(data)
# Populate with current roster metadata
_populate_player_metadata(
player,
jersey=getattr(current_player, "jersey", None),
position=getattr(current_player, "position", None),
status=getattr(current_player, "status", None),
designation=getattr(current_player, "designation", None),
)
return player
[docs]
def delete_team_player(
session: Session,
season_id: str,
team_id: str,
player_id: str,
) -> None:
"""Delete a player from a team's roster and the season.
This function performs two operations: (1) removes the player from the team's roster,
(2) deletes the player at the season level. The supplied :class:`Session` must already
carry a bearer token (e.g. via :meth:`Session.set_bearer_token`); the call is otherwise unauthenticated
and will 401.
Args:
session (Session): An authenticated :class:`Session`.
season_id (str): The season identifier.
team_id (str): The team identifier.
player_id (str): The player identifier to delete.
"""
# Step 1: Remove player from team roster (may not be on this team's roster)
with suppress(GameSheetError):
players.unassign_player(session, season_id, player_id, team_id)
# Step 2: Delete the player at the season level
players.delete_player(session, season_id, player_id)
[docs]
def assign_team_player(
session: Session,
season_id: str,
team_id: str,
player_id: str,
*,
jersey: str | None = None,
position: str | None = None,
status: str | None = None,
designation: str | None = None,
) -> Player:
"""Assign an existing player to a team's roster (team-scoped alias).
This is an alias for :func:`assign_player` provided for consistency with the team-scoped command
structure. The supplied :class:`Session` must already carry a bearer token (e.g. via
:meth:`Session.set_bearer_token`); the call is otherwise unauthenticated and will 401.
Args:
session (Session): An authenticated :class:`Session`.
season_id (str): The season identifier.
team_id (str): The team identifier to assign the player to.
player_id (str): The player identifier to assign.
jersey (str | None): Optional jersey number.
position (str | None): Optional position (Forward, Defence, Goalie, etc.).
status (str | None): Optional status (Regular, Affiliated, etc.).
designation (str | None): Optional designation (Captain, Alternate Captain, etc.).
Returns:
Player: The :class:`Player` with roster metadata populated.
"""
return players.assign_player(
session,
season_id,
player_id,
team_id,
jersey=jersey,
position=position,
status=status,
designation=designation,
)
[docs]
def unassign_team_player(
session: Session,
season_id: str,
team_id: str,
player_id: str,
) -> None:
"""Unassign a player from a team's roster (team-scoped alias).
This is an alias for :func:`unassign_player` provided for consistency with the team-scoped command
structure. The supplied :class:`Session` must already carry a bearer token (e.g. via
:meth:`Session.set_bearer_token`); the call is otherwise unauthenticated and will 401.
Args:
session (Session): An authenticated :class:`Session`.
season_id (str): The season identifier.
team_id (str): The team identifier to unassign the player from.
player_id (str): The player identifier to unassign.
"""
players.unassign_player(session, season_id, player_id, team_id)
__all__ = [
"assign_team_player",
"create_team_player",
"delete_team_player",
"get_team_player",
"list_team_players",
"unassign_team_player",
"update_team_player",
]