Source code for arez.player

from __future__ import annotations

import logging
from datetime import datetime
from functools import cached_property
from typing import Optional, Union, List, Sequence, SupportsInt, TYPE_CHECKING

from .items import Loadout
from .match import PartialMatch
from .status import PlayerStatus
from .exceptions import Private, NotFound
from .enums import Language, Platform, Region, Queue
from .stats import Stats, RankedStats, ChampionStats
from .mixins import CacheClient, CacheObject, Expandable
from .utils import _convert_timestamp, Duration, Lookup, LookupGroup

if TYPE_CHECKING:
    from . import responses
    from .cache import DataCache
    from .champion import Champion


__all__ = ["PartialPlayer", "Player"]
logger = logging.getLogger(__package__)


[docs]class PartialPlayer(Expandable["Player"], CacheClient): """ This object stores basic information about a player, such as their Player ID, Player Name and their Platform. Depending on the way it was created, only the Player ID is guaranteed to exist - both ``name`` and ``platform`` can be an empty string and `Platform.Unknown` respectively. To ensure all attributes are filled up correctly before processing, you can upgrade this object to the full `Player` one first, by awaiting on it and using the result: .. code-block:: py player = await partial_player .. note:: In addition to the exceptions specified below, each API request can result in the following exceptions being raised: `Unavailable` The API is currently unavailable. `LimitReached` Your daily limit of requests has been reached. `HTTPException` Fetching the information requested failed due to connection problems. """ def __init__( self, api: DataCache, *, id: SupportsInt, name: str = '', platform: Union[str, int] = 0, private: bool = False, ): super().__init__(api) self._id: int = int(id) self._name: str = str(name) self._hash: Optional[int] = None if isinstance(platform, str) and platform.isdecimal(): platform = int(platform) self._platform = Platform(platform, _return_default=True) self._private = bool(private) logger.debug( f"Player(id={self._id}, name={self._name}, platform={self._platform.name}, " f"private={self._private}) -> created" ) async def _expand(self) -> Player: """ Upgrades this object to a full `Player` one, refreshing and ensuring information stored. Uses up a single request. Returns ------- Player A full player object with all fields filled up, for the same player. Raises ------ NotFound The player's profile doesn't exist / couldn't be found. Private The player's profile was private. """ if self.private: raise Private logger.info(f"Player(id={self._id}).expand()") player_list = await self._api.request("getplayer", self._id) if not player_list: raise NotFound("Player") player_data = player_list[0] if player_data["ret_msg"]: raise Private return Player(self._api, player_data) def __eq__(self, other) -> bool: if not isinstance(other, self.__class__): return NotImplemented return self._id != 0 and other._id != 0 and self._id == other._id def __hash__(self) -> int: if self._hash is None: if self._id != 0: # if it's not zero, just hash it self._hash = hash(("Player", self._id)) else: # with an ID of zero, fall back to identity object hash self._hash = object.__hash__(self) return self._hash def __repr__(self) -> str: return f"{self.__class__.__name__}: {self._name}({self._id} / {self.platform.name})" @property def id(self) -> int: """ Unique ID of the player. A value of ``0`` indicates a private player account, and shouldn't be used to distinguish between different players. :type: int """ return self._id @property def name(self) -> str: """ Name of the player. :type: str """ return self._name @property def platform(self) -> Platform: """ The player's platform. :type: Platform """ return self._platform @cached_property def private(self) -> bool: """ Checks to see if this profile is private or not. Trying to fetch any information for a private profile will raise the `Private` exception. Returns ------- bool `True` if this player profile is considered private, `False` otherwise. """ return self._private or self._id == 0
[docs] async def get_status(self) -> PlayerStatus: """ Fetches the player's current status. Uses up a single request. Returns ------- PlayerStatus The player's status. Raises ------ Private The player's profile was private. NotFound The player's status couldn't be found. """ if self.private: raise Private logger.info(f"Player(id={self._id}).get_status()") response = await self._api.request("getplayerstatus", self._id) if not response or response[0]["status"] == 5: raise NotFound("Player status") return PlayerStatus(self, response[0])
[docs] async def get_friends(self) -> List[PartialPlayer]: """ Fetches the player's friend list. Uses up a single request. Returns ------- List[PartialPlayer] A list of players this player is friends with.\n Some players might be missing if their profile is set as private. Raises ------ Private The player's profile was private. """ if self.private: raise Private logger.info(f"Player(id={self._id}).get_friends()") response = await self._api.request("getfriends", self._id) return [ PartialPlayer(self._api, id=p["player_id"], name=p["name"], platform=p["portal_id"]) for p in response if p["friend_flags"] == "1" # yes, apparently it's a string ]
[docs] async def get_loadouts( self, language: Optional[Language] = None ) -> LookupGroup[Union[Champion, CacheObject], Loadout]: """ Fetches the player's loadouts. Uses up a single request. .. note:: The `LookupGroup` class provides an easy way of searching for loadouts for a particular champion, based on the champion's name or ID. You can also obtain a list of all loadouts instead. Please see the example code below: .. code-block:: py player: PartialPlayer loadouts: LookupGroup[Champion, Loadout] = await player.get_loadouts() # obtain a list of all loadouts (for all champions) list_loadouts = list(loadouts) # get a list of loadouts for a particular champion champion_loadouts = loadouts.get("Androxus") # fuzzy name matching champion_loadouts = loadouts.get_fuzzy("andro") Parameters ---------- language : Optional[Language] The `Language` you want to fetch the information in.\n Default language is used if not provided. Returns ------- LookupGroup[Union[Champion, CacheObject], Loadout] An object that lets you iterate over and lookup player's loadouts for each champion. Raises ------ Private The player's profile was private. """ if self.private: raise Private if language is not None and not isinstance(language, Language): raise TypeError( f"language argument has to be None or of arez.Language type, got {type(language)}" ) if language is None: language = self._api._default_language cache_entry = await self._api._ensure_entry(language) logger.info(f"Player(id={self._id}).get_loadouts(language={language.name})") response = await self._api.request("getplayerloadouts", self._id, language.value) if not response or response and not response[0]["playerId"]: return LookupGroup([]) return LookupGroup( (Loadout(self, cache_entry, loadout_data) for loadout_data in response), key=lambda l: l.champion, )
[docs] async def get_champion_stats( self, language: Optional[Language] = None, *, queue: Optional[Queue] = None ) -> Lookup[Union[Champion, CacheObject], ChampionStats]: """ Fetches the player's champion statistics. Uses up a single request. .. note:: The `Lookup` class provides an easy way of searching for particular statistics, based on their associated champion's name or ID. You can also obtain a list of all champion statistics instead. Please see the example code below: .. code-block:: py player: PartialPlayer stats: Lookup[Champion, ChampionStats] = await player.get_champion_stats() # obtain a list of stats for all champion list_stats = list(stats) # get the stats object for a particular champion champion_stats = stats.get("Androxus") # fuzzy name matching champion_stats = stats.get_fuzzy("andro") Parameters ---------- language : Optional[Language] The `Language` you want to fetch the information in.\n Default language is used if not provided. queue : Optional[Queue] The queue you want to filter the returned stats to.\n Defaults to all queues. Returns ------- Lookup[Union[Champion, CacheObject], ChampionStats] An object that lets you iterate over and lookup each champion's statistics, one for each played champion.\n Some statistics may be missing for champions the player haven't played yet. Raises ------ Private The player's profile was private. """ if self.private: raise Private if language is not None and not isinstance(language, Language): raise TypeError( f"language argument has to be None or of arez.Language type, got {type(language)}" ) if language is None: language = self._api._default_language cache_entry = await self._api._ensure_entry(language) logger.info(f"Player(id={self._id}).get_champion_stats(language={language.name})") response: Sequence[Union[responses.ChampionRankObject, responses.ChampionQueueRankObject]] if queue is None: response = await self._api.request("getgodranks", self._id) else: response = await self._api.request("getqueuestats", self._id, queue.value) return Lookup( (ChampionStats(self, cache_entry, stats_data, queue) for stats_data in response), key=lambda s: s.champion, )
[docs] async def get_match_history(self, language: Optional[Language] = None) -> List[PartialMatch]: """ Fetches player's match history. Uses up a single request. .. note:: The returned list can be empty (or contain less elements) if the player haven't played any matches yet, or their last played match is over 30 days old. Parameters ---------- language : Optional[Language] The `Language` you want to fetch the information in.\n Default language is used if not provided. Returns ------- List[PartialMatch] A list of up to 50 partial matches, containing statistics for the current player only. Raises ------ Private The player's profile was private. """ if language is not None and not isinstance(language, Language): raise TypeError( f"language argument has to be None or of arez.Language type, got {type(language)}" ) if self.private: raise Private if language is None: language = self._api._default_language cache_entry = await self._api._ensure_entry(language) logger.info(f"Player(id={self._id}).get_match_history(language={language.name})") response = await self._api.request("getmatchhistory", self._id) if not response or response and response[0]["ret_msg"]: return [] return [PartialMatch(self, language, cache_entry, match_data) for match_data in response]
[docs]class Player(PartialPlayer): """ A full Player object, containing all information about a player. You can get this from the `PaladinsAPI.get_player` and `PaladinsAPI.get_players` methods, as well as from upgrading a `PartialPlayer` object, by awaiting on it. .. note:: This class inherits from `PartialPlayer`, so all of it's methods should be present here as well. Attributes ---------- active_player : Optional[PartialPlayer] The current active player between merged profiles.\n `None` if the current profile is the active profile. merged_players : List[PartialPlayer] A list of all merged profiles.\n Only ID and platform are present. created_at : Optional[datetime.datetime] A timestamp of the profile's creation date.\n This can be `None` for accounts that are really old. last_login : Optional[datetime.datetime] A timestamp of the profile's last successful in-game login.\n This can be `None` for accounts that are really old. platform_name : str The platform name of this profile. This is usually identical to `name`, except in cases where the platform allows nicknames (Steam profiles). title : str The player's currently equipped title.\n This will be an empty string without any title equipped. avatar_id : int The player's curremtly equipped avatar ID. avatar_url : str The player's currently equipped avatar URL. loading_frame : str The player's currently equipped loading frame name.\n This will be an empty string without any loading frame equipped. level : int The in-game level of this profile. playtime : Duration The amount of time spent playing on this profile. champion_count : int The amount of champions this player has unlocked. region : Region The player's currently set `Region`.\n This can be `Region.Unknown` for accounts that are really old. total_achievements : int The amount of achievements the player has. total_experience : int The total amount of experience the player has. casual : Stats Player's casual statistics. ranked_keyboard : RankedStats Player's ranked keyboard statistics. ranked_controller : RankedStats Player's ranked controller statistics. """ def __init__(self, api: DataCache, player_data: responses.PlayerObject): # delay super() to pre-process player names player_name: Optional[str] = player_data["hz_player_name"] gamer_tag: Optional[str] = player_data["hz_gamer_tag"] name: str = player_data["Name"] self.platform_name: str = name if player_name is not None: name = player_name elif gamer_tag is not None: # pragma: no branch name = gamer_tag super().__init__( api, id=player_data["Id"], name=name, platform=player_data["Platform"], # No private kwarg here, since this object can only exist for non-private accounts ) self.active_player: Optional[PartialPlayer] = None if player_data["ActivePlayerId"] != self._id: # pragma: no cover self.active_player = PartialPlayer(api, id=player_data["ActivePlayerId"]) self.merged_players: List[PartialPlayer] = [] if player_data["MergedPlayers"] is not None: for p in player_data["MergedPlayers"]: self.merged_players.append( PartialPlayer(api, id=p["playerId"], platform=p["portalId"]) ) self.created_at: Optional[datetime] = None self.last_login: Optional[datetime] = None if created_stamp := player_data["Created_Datetime"]: self.created_at = _convert_timestamp(created_stamp) if login_stamp := player_data["Last_Login_Datetime"]: self.last_login = _convert_timestamp(login_stamp) self.level: int = player_data["Level"] self.title: str = player_data["Title"] or '' self.avatar_id: int = player_data["AvatarId"] self.avatar_url: str = ( player_data["AvatarURL"] or "https://hirez-api-docs.herokuapp.com/paladins/avatar/0" # patch null here ) self.loading_frame: str = player_data["LoadingFrame"] or '' self.playtime = Duration(minutes=player_data["MinutesPlayed"]) self.champion_count: int = player_data["MasteryLevel"] self.region = Region(player_data["Region"], _return_default=True) self.total_achievements: int = player_data["Total_Achievements"] self.total_experience: int = player_data["Total_XP"] self.casual = Stats(player_data) self.ranked_keyboard = RankedStats("Keyboard", player_data["RankedKBM"]) self.ranked_controller = RankedStats("Controller", player_data["RankedController"]) @cached_property def ranked_best(self) -> RankedStats: """ Player's best ranked statistics, between the keyboard and controller ones. If the rank is the same, winrate is used to determine the one returned. :type: RankedStats """ if self.ranked_controller.rank == self.ranked_keyboard.rank: return max(self.ranked_keyboard, self.ranked_controller, key=lambda r: r.winrate) return max(self.ranked_keyboard, self.ranked_controller, key=lambda r: r.rank) @cached_property def calculated_level(self) -> int: """ The calculated level of this profile. This uses `total_experience` to calculate the player's level, instead of relying on the value returned from the API. It also allows you to calculate the theorethical level beyond the 999th level cap. :type: int """ # Players start at level 1, with 40k EXP needed to reach level 2. Afterwards, # the requirement grows by 20k per level, until reaching 1M EXP at level 50. After that, # each consecutive level consistently needs 1M EXP to advance. lvl_threshold = 25_480_000 # amount of EXP needed for lvl 50 if self.total_experience < lvl_threshold: s = 0 for lvl in range(2, 51): if (s := s + lvl * 20_000) > self.total_experience: return lvl - 1 return 50 # pragma: no cover # failsafe, this will never run else: return ((self.total_experience - lvl_threshold) // 1_000_000) + 50