diff --git a/aiosu/models/beatmap.py b/aiosu/models/beatmap.py index b1ca54f..99a4fba 100644 --- a/aiosu/models/beatmap.py +++ b/aiosu/models/beatmap.py @@ -1,523 +1,522 @@ -""" -This module contains models for Beatmap objects. -""" -from __future__ import annotations - -from datetime import datetime -from enum import Enum -from enum import unique -from typing import Any -from typing import Literal -from typing import Optional - -from pydantic import computed_field -from pydantic import Field -from pydantic import model_validator - -from .base import BaseModel -from .common import CurrentUserAttributes -from .common import CursorModel -from .gamemode import Gamemode -from .user import User - -__all__ = ( - "Beatmap", - "BeatmapDescription", - "BeatmapGenre", - "BeatmapLanguage", - "BeatmapAvailability", - "BeatmapCovers", - "BeatmapDifficultyAttributes", - "BeatmapFailtimes", - "BeatmapHype", - "BeatmapNominations", - "BeatmapRankStatus", - "Beatmapset", - "BeatmapsetDiscussion", - "BeatmapsetDiscussionPost", - "BeatmapsetDisscussionType", - "BeatmapsetEvent", - "BeatmapsetEventComment", - "BeatmapsetEventType", - "BeatmapsetRequestStatus", - "BeatmapsetVoteEvent", - "BeatmapUserPlaycount", - "BeatmapsetDiscussionResponse", - "BeatmapsetDiscussionPostResponse", - "BeatmapsetDiscussionVoteResponse", - "BeatmapsetSearchResponse", -) - -BeatmapsetDisscussionType = Literal[ - "hype", - "praise", - "problem", - "review", - "suggestion", - "mapper_note", -] - - -BeatmapsetEventType = Literal[ - "approve", - "beatmap_owner_change", - "discussion_delete", - "discussion_post_delete", - "discussion_post_restore", - "discussion_restore", - "discussion_lock", - "discussion_unlock", - "disqualify", - "genre_edit", - "issue_reopen", - "issue_resolve", - "kudosu_allow", - "kudosu_deny", - "kudosu_gain", - "kudosu_lost", - "kudosu_recalculate", - "language_edit", - "love", - "nominate", - "nomination_reset", - "nomination_reset_received", - "nsfw_toggle", - "offset_edit", - "qualify", - "rank", - "remove_from_loved", -] - -BeatmapsetRequestStatus = Literal[ - "all", - "ranked", - "qualified", - "disqualified", - "never_ranked", -] - -BEATMAP_RANK_STATUS_NAMES = { - -2: "graveyard", - -1: "wip", - 0: "pending", - 1: "ranked", - 2: "approved", - 3: "qualified", - 4: "loved", -} - - -@unique -class BeatmapRankStatus(Enum): - GRAVEYARD = -2 - WIP = -1 - PENDING = 0 - RANKED = 1 - APPROVED = 2 - QUALIFIED = 3 - LOVED = 4 - - @property - def id(self) -> int: - return self.value - - @property - def name_api(self) -> str: - return BEATMAP_RANK_STATUS_NAMES[self.id] - - def __str__(self) -> str: - return self.name_api - - @classmethod - def _missing_(cls, query: object) -> Any: - if isinstance(query, int): - for status in list(BeatmapRankStatus): - if status.id == query: - return status - elif isinstance(query, str): - for status in list(BeatmapRankStatus): - if status.name_api == query.lower(): - return status - raise ValueError(f"BeatmapRankStatus {query} does not exist.") - - -class BeatmapDescription(BaseModel): - bbcode: Optional[str] = None - description: Optional[str] = None - - -class BeatmapGenre(BaseModel): - name: str - id: Optional[int] = None - - -class BeatmapLanguage(BaseModel): - name: str - id: Optional[int] = None - - -class BeatmapAvailability(BaseModel): - more_information: Optional[str] = None - download_disabled: Optional[bool] = None - - @classmethod - def _from_api_v1(cls, data: Any) -> BeatmapAvailability: - return cls.model_validate({"download_disabled": data["download_unavailable"]}) - - -class BeatmapNominations(BaseModel): - current: Optional[int] = None - required: Optional[int] = None - - -class BeatmapNomination(BaseModel): - beatmapset_id: int - reset: bool - user_id: int - rulesets: Optional[list[Gamemode]] = None - - -class BeatmapCovers(BaseModel): - cover: str - card: str - list: str - slimcover: str - cover_2_x: Optional[str] = Field(default=None, alias="cover@2x") - card_2_x: Optional[str] = Field(default=None, alias="card@2x") - list_2_x: Optional[str] = Field(default=None, alias="list@2x") - slimcover_2_x: Optional[str] = Field(default=None, alias="slimcover@2x") - - @classmethod - def from_beatmapset_id(cls, beatmapset_id: int) -> BeatmapCovers: - base_url = "https://assets.ppy.sh/beatmaps/" - return cls.model_validate( - { - "cover": f"{base_url}{beatmapset_id}/covers/cover.jpg", - "card": f"{base_url}{beatmapset_id}/covers/card.jpg", - "list": f"{base_url}{beatmapset_id}/covers/list.jpg", - "slimcover": f"{base_url}{beatmapset_id}/covers/slimcover.jpg", - "cover_2_x": f"{base_url}{beatmapset_id}/covers/cover@2x.jpg", - "card_2_x": f"{base_url}{beatmapset_id}/covers/card@2x.jpg", - "list_2_x": f"{base_url}{beatmapset_id}/covers/list@2x.jpg", - "slimcover_2_x": f"{base_url}{beatmapset_id}/covers/slimcover@2x.jpg", - }, - ) - - @classmethod - def _from_api_v1(cls, data: Any) -> BeatmapCovers: - return cls.from_beatmapset_id(data["beatmapset_id"]) - - -class BeatmapHype(BaseModel): - current: int - required: int - - -class BeatmapFailtimes(BaseModel): - exit: Optional[list[int]] = None - fail: Optional[list[int]] = None - - -class BeatmapDifficultyAttributes(BaseModel): - max_combo: int - star_rating: float - # osu standard - aim_difficulty: Optional[float] = None - approach_rate: Optional[float] = None # osu catch + standard - flashlight_difficulty: Optional[float] = None - overall_difficulty: Optional[float] = None - slider_factor: Optional[float] = None - speed_difficulty: Optional[float] = None - speed_note_count: Optional[float] = None - # osu taiko - stamina_difficulty: Optional[float] = None - rhythm_difficulty: Optional[float] = None - colour_difficulty: Optional[float] = None - # osu mania - great_hit_window: Optional[float] = None - score_multiplier: Optional[float] = None - - -class Beatmap(BaseModel): - id: int - url: str - mode: Gamemode - beatmapset_id: int - difficulty_rating: float - status: BeatmapRankStatus - total_length: int - user_id: int - version: str - accuracy: Optional[float] = None - ar: Optional[float] = None - cs: Optional[float] = None - bpm: Optional[float] = None - convert: Optional[bool] = None - count_circles: Optional[int] = None - count_sliders: Optional[int] = None - count_spinners: Optional[int] = None - deleted_at: Optional[datetime] = None - drain: Optional[float] = None - hit_length: Optional[int] = None - is_scoreable: Optional[bool] = None - last_updated: Optional[datetime] = None - passcount: Optional[int] = None - play_count: Optional[int] = Field(default=None, alias="playcount") - checksum: Optional[str] = None - max_combo: Optional[int] = None - beatmapset: Optional[Beatmapset] = None - failtimes: Optional[BeatmapFailtimes] = None - - @model_validator(mode="before") - @classmethod - def _set_url(cls, values: dict[str, Any]) -> dict[str, Any]: - if values.get("url") is None: - id = values["id"] - beatmapset_id = values["beatmapset_id"] - mode = Gamemode(values["mode"]) - values[ - "url" - ] = f"https://osu.ppy.sh/beatmapsets/{beatmapset_id}#{mode}/{id}" - return values - - @computed_field # type: ignore - @property - def discussion_url(self) -> str: - return f"https://osu.ppy.sh/beatmapsets/{self.beatmapset_id}/discussion/{self.id}/general" - - @computed_field # type: ignore - @property - def count_objects(self) -> int: - """Total count of the objects. - - :raises ValueError: Raised if object counts are none - :return: Sum of counts of all objects - :rtype: int - """ - if ( - self.count_circles is None - or self.count_spinners is None - or self.count_sliders is None - ): - raise ValueError("Beatmap contains no object count information.") - return self.count_spinners + self.count_circles + self.count_sliders - - @classmethod - def _from_api_v1(cls, data: Any) -> Beatmap: - return cls.model_validate( - { - "beatmapset_id": data["beatmapset_id"], - "difficulty_rating": data["difficultyrating"], - "id": data["beatmap_id"], - "mode": int(data["mode"]), - "status": int(data["approved"]), - "total_length": data["total_length"], - "hit_length": data["total_length"], - "user_id": data["creator_id"], - "version": data["version"], - "accuracy": data["diff_overall"], - "cs": data["diff_size"], - "ar": data["diff_approach"], - "drain": data["diff_drain"], - "last_updated": data["last_update"], - "bpm": data["bpm"], - "checksum": data["file_md5"], - "playcount": data["playcount"], - "passcount": data["passcount"], - "count_circles": data["count_normal"], - "count_sliders": data["count_slider"], - "count_spinners": data["count_spinner"], - "max_combo": data["max_combo"], - }, - ) - - -class Beatmapset(BaseModel): - id: int - artist: str - artist_unicode: str - covers: BeatmapCovers - creator: str - favourite_count: int - play_count: int = Field(alias="playcount") - preview_url: str - source: str - status: BeatmapRankStatus - title: str - title_unicode: str - user_id: int - video: bool - nsfw: Optional[bool] = None - hype: Optional[BeatmapHype] = None - availability: Optional[BeatmapAvailability] = None - bpm: Optional[float] = None - can_be_hyped: Optional[bool] = None - discussion_enabled: Optional[bool] = None - discussion_locked: Optional[bool] = None - is_scoreable: Optional[bool] = None - last_updated: Optional[datetime] = None - legacy_thread_url: Optional[str] = None - nominations: Optional[BeatmapNominations] = None - current_nominations: Optional[list[BeatmapNomination]] = None - ranked_date: Optional[datetime] = None - storyboard: Optional[bool] = None - submitted_date: Optional[datetime] = None - tags: Optional[str] = None - pack_tags: Optional[list[str]] = None - track_id: Optional[int] = None - related_users: Optional[list[User]] = None - current_user_attributes: Optional[CurrentUserAttributes] = None - description: Optional[BeatmapDescription] = None - genre: Optional[BeatmapGenre] = None - language: Optional[BeatmapLanguage] = None - ratings: Optional[list[int]] = None - has_favourited: Optional[bool] = None - beatmaps: Optional[list[Beatmap]] = None - converts: Optional[list[Beatmap]] = None - - @computed_field # type: ignore - @property - def url(self) -> str: - return f"https://osu.ppy.sh/beatmapsets/{self.id}" - - @computed_field # type: ignore - @property - def discussion_url(self) -> str: - return f"https://osu.ppy.sh/beatmapsets/{self.id}/discussion" - - @classmethod - def _from_api_v1(cls, data: Any) -> Beatmapset: - return cls.model_validate( - { - "id": data["beatmapset_id"], - "artist": data["artist"], - "artist_unicode": data["artist"], - "covers": BeatmapCovers._from_api_v1(data), - "favourite_count": data["favourite_count"], - "creator": data["creator"], - "play_count": data["playcount"], - "preview_url": f"//b.ppy.sh/preview/{data['beatmapset_id']}.mp3", - "source": data["source"], - "status": int(data["approved"]), - "title": data["title"], - "title_unicode": data["title"], - "user_id": data["creator_id"], - "video": data["video"], - "submitted_date": data["submit_date"], - "ranked_date": data["approved_date"], - "last_updated": data["last_update"], - "tags": data["tags"], - "storyboard": data["storyboard"], - "availabiliy": BeatmapAvailability._from_api_v1(data), - "beatmaps": [Beatmap._from_api_v1(data)], - }, - ) - - -class BeatmapsetSearchResponse(CursorModel): - beatmapsets: list[Beatmapset] - - -class BeatmapUserPlaycount(BaseModel): - count: int - beatmap_id: int - beatmap: Optional[Beatmap] = None - beatmapset: Optional[Beatmapset] = None - - -class BeatmapsetDiscussionPost(BaseModel): - id: int - user_id: int - system: bool - message: str - created_at: datetime - beatmap_discussion_id: Optional[int] = None - last_editor_id: Optional[int] = None - deleted_by_id: Optional[int] = None - updated_at: Optional[datetime] = None - deleted_at: Optional[datetime] = None - - -class BeatmapsetDiscussion(BaseModel): - id: int - beatmapset_id: int - user_id: int - message_type: BeatmapsetDisscussionType - resolved: bool - can_be_resolved: bool - can_grant_kudosu: bool - created_at: datetime - beatmap_id: Optional[int] = None - deleted_by_id: Optional[int] = None - parent_id: Optional[int] = None - timestamp: Optional[int] = None - updated_at: Optional[datetime] = None - deleted_at: Optional[datetime] = None - last_post_at: Optional[datetime] = None - kudosu_denied: Optional[bool] = None - starting_post: Optional[BeatmapsetDiscussionPost] = None - - -class BeatmapsetVoteEvent(BaseModel): - score: int - user_id: int - id: Optional[int] = None - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None - beatmapset_discussion_id: Optional[int] = None - - -class BeatmapsetEventComment(BaseModel): - beatmap_discussion_id: Optional[int] = None - beatmap_discussion_post_id: Optional[int] = None - new_vote: Optional[BeatmapsetVoteEvent] = None - votes: Optional[list[BeatmapsetVoteEvent]] = None - mode: Optional[Gamemode] = None - reason: Optional[str] = None - source_user_id: Optional[int] = None - source_user_username: Optional[str] = None - nominator_ids: Optional[list[int]] = None - new: Optional[str] = None - old: Optional[str] = None - new_user_id: Optional[int] = None - new_user_username: Optional[str] = None - - -class BeatmapsetEvent(BaseModel): - id: int - type: BeatmapsetEventType - r"""Information on types: https://github.com/ppy/osu-web/blob/master/resources/assets/lib/interfaces/beatmapset-event-json.ts""" - created_at: datetime - user_id: int - beatmapset: Optional[Beatmapset] = None - discussion: Optional[BeatmapsetDiscussion] = None - comment: Optional[dict] = None - - -class BeatmapsetDiscussionResponse(CursorModel): - beatmaps: list[Beatmap] - discussions: list[BeatmapsetDiscussion] - included_discussions: list[BeatmapsetDiscussion] - users: list[User] - max_blocks: int - - @model_validator(mode="before") - @classmethod - def _set_max_blocks(cls, values: dict[str, Any]) -> dict[str, Any]: - values["max_blocks"] = values["reviews_config"]["max_blocks"] - return values - - -class BeatmapsetDiscussionPostResponse(CursorModel): - beatmapsets: list[Beatmapset] - posts: list[BeatmapsetDiscussionPost] - users: list[User] - - -class BeatmapsetDiscussionVoteResponse(CursorModel): - votes: list[BeatmapsetVoteEvent] - discussions: list[BeatmapsetDiscussion] - users: list[User] - - -Beatmap.model_rebuild() +""" +This module contains models for Beatmap objects. +""" +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from enum import unique +from typing import Any +from typing import Literal +from typing import Optional + +from pydantic import computed_field +from pydantic import Field +from pydantic import model_validator + +from .base import BaseModel +from .common import CurrentUserAttributes +from .common import CursorModel +from .gamemode import Gamemode +from .user import User + +__all__ = ( + "Beatmap", + "BeatmapDescription", + "BeatmapGenre", + "BeatmapLanguage", + "BeatmapAvailability", + "BeatmapCovers", + "BeatmapDifficultyAttributes", + "BeatmapFailtimes", + "BeatmapHype", + "BeatmapNominations", + "BeatmapRankStatus", + "Beatmapset", + "BeatmapsetDiscussion", + "BeatmapsetDiscussionPost", + "BeatmapsetDisscussionType", + "BeatmapsetEvent", + "BeatmapsetEventComment", + "BeatmapsetEventType", + "BeatmapsetRequestStatus", + "BeatmapsetVoteEvent", + "BeatmapUserPlaycount", + "BeatmapsetDiscussionResponse", + "BeatmapsetDiscussionPostResponse", + "BeatmapsetDiscussionVoteResponse", + "BeatmapsetSearchResponse", +) + +BeatmapsetDisscussionType = Literal[ + "hype", + "praise", + "problem", + "review", + "suggestion", + "mapper_note", +] + + +BeatmapsetEventType = Literal[ + "approve", + "beatmap_owner_change", + "discussion_delete", + "discussion_post_delete", + "discussion_post_restore", + "discussion_restore", + "discussion_lock", + "discussion_unlock", + "disqualify", + "genre_edit", + "issue_reopen", + "issue_resolve", + "kudosu_allow", + "kudosu_deny", + "kudosu_gain", + "kudosu_lost", + "kudosu_recalculate", + "language_edit", + "love", + "nominate", + "nomination_reset", + "nomination_reset_received", + "nsfw_toggle", + "offset_edit", + "qualify", + "rank", + "remove_from_loved", +] + +BeatmapsetRequestStatus = Literal[ + "all", + "ranked", + "qualified", + "disqualified", + "never_ranked", +] + +BEATMAP_RANK_STATUS_NAMES = { + -2: "graveyard", + -1: "wip", + 0: "pending", + 1: "ranked", + 2: "approved", + 3: "qualified", + 4: "loved", +} + + +@unique +class BeatmapRankStatus(Enum): + GRAVEYARD = -2 + WIP = -1 + PENDING = 0 + RANKED = 1 + APPROVED = 2 + QUALIFIED = 3 + LOVED = 4 + + @property + def id(self) -> int: + return self.value + + @property + def name_api(self) -> str: + return BEATMAP_RANK_STATUS_NAMES[self.id] + + def __str__(self) -> str: + return self.name_api + + @classmethod + def _missing_(cls, query: object) -> Any: + if isinstance(query, int): + for status in list(BeatmapRankStatus): + if status.id == query: + return status + elif isinstance(query, str): + for status in list(BeatmapRankStatus): + if status.name_api == query.lower(): + return status + raise ValueError(f"BeatmapRankStatus {query} does not exist.") + + +class BeatmapDescription(BaseModel): + bbcode: Optional[str] = None + description: Optional[str] = None + + +class BeatmapGenre(BaseModel): + name: str + id: Optional[int] = None + + +class BeatmapLanguage(BaseModel): + name: str + id: Optional[int] = None + + +class BeatmapAvailability(BaseModel): + more_information: Optional[str] = None + download_disabled: Optional[bool] = None + + @classmethod + def _from_api_v1(cls, data: Any) -> BeatmapAvailability: + return cls.model_validate({"download_disabled": data["download_unavailable"]}) + + +class BeatmapNominations(BaseModel): + current: Optional[int] = None + required: Optional[int] = None + + +class BeatmapNomination(BaseModel): + beatmapset_id: int + reset: bool + user_id: int + rulesets: Optional[list[Gamemode]] = None + + +class BeatmapCovers(BaseModel): + cover: str + card: str + list: str + slimcover: str + cover_2_x: Optional[str] = Field(default=None, alias="cover@2x") + card_2_x: Optional[str] = Field(default=None, alias="card@2x") + list_2_x: Optional[str] = Field(default=None, alias="list@2x") + slimcover_2_x: Optional[str] = Field(default=None, alias="slimcover@2x") + + @classmethod + def from_beatmapset_id(cls, beatmapset_id: int) -> BeatmapCovers: + base_url = "https://assets.ppy.sh/beatmaps/" + return cls.model_validate( + { + "cover": f"{base_url}{beatmapset_id}/covers/cover.jpg", + "card": f"{base_url}{beatmapset_id}/covers/card.jpg", + "list": f"{base_url}{beatmapset_id}/covers/list.jpg", + "slimcover": f"{base_url}{beatmapset_id}/covers/slimcover.jpg", + "cover_2_x": f"{base_url}{beatmapset_id}/covers/cover@2x.jpg", + "card_2_x": f"{base_url}{beatmapset_id}/covers/card@2x.jpg", + "list_2_x": f"{base_url}{beatmapset_id}/covers/list@2x.jpg", + "slimcover_2_x": f"{base_url}{beatmapset_id}/covers/slimcover@2x.jpg", + }, + ) + + @classmethod + def _from_api_v1(cls, data: Any) -> BeatmapCovers: + return cls.from_beatmapset_id(data["beatmapset_id"]) + + +class BeatmapHype(BaseModel): + current: int + required: int + + +class BeatmapFailtimes(BaseModel): + exit: Optional[list[int]] = None + fail: Optional[list[int]] = None + + +class BeatmapDifficultyAttributes(BaseModel): + max_combo: int + star_rating: float + # osu standard + aim_difficulty: Optional[float] = None + approach_rate: Optional[float] = None # osu catch + standard + flashlight_difficulty: Optional[float] = None + overall_difficulty: Optional[float] = None + slider_factor: Optional[float] = None + speed_difficulty: Optional[float] = None + speed_note_count: Optional[float] = None + # osu taiko + stamina_difficulty: Optional[float] = None + rhythm_difficulty: Optional[float] = None + colour_difficulty: Optional[float] = None + # osu mania + great_hit_window: Optional[float] = None + score_multiplier: Optional[float] = None + + +class Beatmap(BaseModel): + id: int + url: str + mode: Gamemode + beatmapset_id: int + difficulty_rating: float + status: BeatmapRankStatus + total_length: int + user_id: int + version: str + accuracy: Optional[float] = None + ar: Optional[float] = None + cs: Optional[float] = None + bpm: Optional[float] = None + convert: Optional[bool] = None + count_circles: Optional[int] = None + count_sliders: Optional[int] = None + count_spinners: Optional[int] = None + deleted_at: Optional[datetime] = None + drain: Optional[float] = None + hit_length: Optional[int] = None + is_scoreable: Optional[bool] = None + last_updated: Optional[datetime] = None + passcount: Optional[int] = None + play_count: Optional[int] = Field(default=None, alias="playcount") + checksum: Optional[str] = None + max_combo: Optional[int] = None + beatmapset: Optional[Beatmapset] = None + failtimes: Optional[BeatmapFailtimes] = None + + @model_validator(mode="before") + @classmethod + def _set_url(cls, values: dict[str, Any]) -> dict[str, Any]: + if values.get("url") is None: + id = values["id"] + beatmapset_id = values["beatmapset_id"] + mode = Gamemode(values["mode"]) + values[ + "url" + ] = f"https://osu.ppy.sh/beatmapsets/{beatmapset_id}#{mode}/{id}" + return values + + @computed_field # type: ignore + @property + def discussion_url(self) -> str: + return f"https://osu.ppy.sh/beatmapsets/{self.beatmapset_id}/discussion/{self.id}/general" + + @computed_field # type: ignore + @property + def count_objects(self) -> Optional[int]: + """Total count of the objects. + + :return: Sum of counts of all objects. None if no object count information. + :rtype: Optional[int] + """ + if ( + self.count_circles is None + or self.count_spinners is None + or self.count_sliders is None + ): + return None + return self.count_spinners + self.count_circles + self.count_sliders + + @classmethod + def _from_api_v1(cls, data: Any) -> Beatmap: + return cls.model_validate( + { + "beatmapset_id": data["beatmapset_id"], + "difficulty_rating": data["difficultyrating"], + "id": data["beatmap_id"], + "mode": int(data["mode"]), + "status": int(data["approved"]), + "total_length": data["total_length"], + "hit_length": data["total_length"], + "user_id": data["creator_id"], + "version": data["version"], + "accuracy": data["diff_overall"], + "cs": data["diff_size"], + "ar": data["diff_approach"], + "drain": data["diff_drain"], + "last_updated": data["last_update"], + "bpm": data["bpm"], + "checksum": data["file_md5"], + "playcount": data["playcount"], + "passcount": data["passcount"], + "count_circles": data["count_normal"], + "count_sliders": data["count_slider"], + "count_spinners": data["count_spinner"], + "max_combo": data["max_combo"], + }, + ) + + +class Beatmapset(BaseModel): + id: int + artist: str + artist_unicode: str + covers: BeatmapCovers + creator: str + favourite_count: int + play_count: int = Field(alias="playcount") + preview_url: str + source: str + status: BeatmapRankStatus + title: str + title_unicode: str + user_id: int + video: bool + nsfw: Optional[bool] = None + hype: Optional[BeatmapHype] = None + availability: Optional[BeatmapAvailability] = None + bpm: Optional[float] = None + can_be_hyped: Optional[bool] = None + discussion_enabled: Optional[bool] = None + discussion_locked: Optional[bool] = None + is_scoreable: Optional[bool] = None + last_updated: Optional[datetime] = None + legacy_thread_url: Optional[str] = None + nominations: Optional[BeatmapNominations] = None + current_nominations: Optional[list[BeatmapNomination]] = None + ranked_date: Optional[datetime] = None + storyboard: Optional[bool] = None + submitted_date: Optional[datetime] = None + tags: Optional[str] = None + pack_tags: Optional[list[str]] = None + track_id: Optional[int] = None + related_users: Optional[list[User]] = None + current_user_attributes: Optional[CurrentUserAttributes] = None + description: Optional[BeatmapDescription] = None + genre: Optional[BeatmapGenre] = None + language: Optional[BeatmapLanguage] = None + ratings: Optional[list[int]] = None + has_favourited: Optional[bool] = None + beatmaps: Optional[list[Beatmap]] = None + converts: Optional[list[Beatmap]] = None + + @computed_field # type: ignore + @property + def url(self) -> str: + return f"https://osu.ppy.sh/beatmapsets/{self.id}" + + @computed_field # type: ignore + @property + def discussion_url(self) -> str: + return f"https://osu.ppy.sh/beatmapsets/{self.id}/discussion" + + @classmethod + def _from_api_v1(cls, data: Any) -> Beatmapset: + return cls.model_validate( + { + "id": data["beatmapset_id"], + "artist": data["artist"], + "artist_unicode": data["artist"], + "covers": BeatmapCovers._from_api_v1(data), + "favourite_count": data["favourite_count"], + "creator": data["creator"], + "play_count": data["playcount"], + "preview_url": f"//b.ppy.sh/preview/{data['beatmapset_id']}.mp3", + "source": data["source"], + "status": int(data["approved"]), + "title": data["title"], + "title_unicode": data["title"], + "user_id": data["creator_id"], + "video": data["video"], + "submitted_date": data["submit_date"], + "ranked_date": data["approved_date"], + "last_updated": data["last_update"], + "tags": data["tags"], + "storyboard": data["storyboard"], + "availabiliy": BeatmapAvailability._from_api_v1(data), + "beatmaps": [Beatmap._from_api_v1(data)], + }, + ) + + +class BeatmapsetSearchResponse(CursorModel): + beatmapsets: list[Beatmapset] + + +class BeatmapUserPlaycount(BaseModel): + count: int + beatmap_id: int + beatmap: Optional[Beatmap] = None + beatmapset: Optional[Beatmapset] = None + + +class BeatmapsetDiscussionPost(BaseModel): + id: int + user_id: int + system: bool + message: str + created_at: datetime + beatmap_discussion_id: Optional[int] = None + last_editor_id: Optional[int] = None + deleted_by_id: Optional[int] = None + updated_at: Optional[datetime] = None + deleted_at: Optional[datetime] = None + + +class BeatmapsetDiscussion(BaseModel): + id: int + beatmapset_id: int + user_id: int + message_type: BeatmapsetDisscussionType + resolved: bool + can_be_resolved: bool + can_grant_kudosu: bool + created_at: datetime + beatmap_id: Optional[int] = None + deleted_by_id: Optional[int] = None + parent_id: Optional[int] = None + timestamp: Optional[int] = None + updated_at: Optional[datetime] = None + deleted_at: Optional[datetime] = None + last_post_at: Optional[datetime] = None + kudosu_denied: Optional[bool] = None + starting_post: Optional[BeatmapsetDiscussionPost] = None + + +class BeatmapsetVoteEvent(BaseModel): + score: int + user_id: int + id: Optional[int] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + beatmapset_discussion_id: Optional[int] = None + + +class BeatmapsetEventComment(BaseModel): + beatmap_discussion_id: Optional[int] = None + beatmap_discussion_post_id: Optional[int] = None + new_vote: Optional[BeatmapsetVoteEvent] = None + votes: Optional[list[BeatmapsetVoteEvent]] = None + mode: Optional[Gamemode] = None + reason: Optional[str] = None + source_user_id: Optional[int] = None + source_user_username: Optional[str] = None + nominator_ids: Optional[list[int]] = None + new: Optional[str] = None + old: Optional[str] = None + new_user_id: Optional[int] = None + new_user_username: Optional[str] = None + + +class BeatmapsetEvent(BaseModel): + id: int + type: BeatmapsetEventType + r"""Information on types: https://github.com/ppy/osu-web/blob/master/resources/assets/lib/interfaces/beatmapset-event-json.ts""" + created_at: datetime + user_id: int + beatmapset: Optional[Beatmapset] = None + discussion: Optional[BeatmapsetDiscussion] = None + comment: Optional[dict] = None + + +class BeatmapsetDiscussionResponse(CursorModel): + beatmaps: list[Beatmap] + discussions: list[BeatmapsetDiscussion] + included_discussions: list[BeatmapsetDiscussion] + users: list[User] + max_blocks: int + + @model_validator(mode="before") + @classmethod + def _set_max_blocks(cls, values: dict[str, Any]) -> dict[str, Any]: + values["max_blocks"] = values["reviews_config"]["max_blocks"] + return values + + +class BeatmapsetDiscussionPostResponse(CursorModel): + beatmapsets: list[Beatmapset] + posts: list[BeatmapsetDiscussionPost] + users: list[User] + + +class BeatmapsetDiscussionVoteResponse(CursorModel): + votes: list[BeatmapsetVoteEvent] + discussions: list[BeatmapsetDiscussion] + users: list[User] + + +Beatmap.model_rebuild() diff --git a/aiosu/models/common.py b/aiosu/models/common.py index 48412cc..c911823 100644 --- a/aiosu/models/common.py +++ b/aiosu/models/common.py @@ -1,121 +1,121 @@ -""" -This module contains models for miscellaneous objects. -""" -from __future__ import annotations - -from datetime import datetime -from functools import partial -from typing import Any -from typing import Coroutine -from typing import Literal -from typing import Optional - -from emojiflags.lookup import lookup as flag_lookup # type: ignore -from pydantic import computed_field -from pydantic import Field -from pydantic import field_validator - -from .base import BaseModel -from .gamemode import Gamemode - -__all__ = ( - "Achievement", - "Country", - "CurrentUserAttributes", - "TimestampedCount", - "CursorModel", - "SortTypes", - "HTMLBody", -) - - -SortTypes = Literal["id_asc", "id_desc"] - - -class TimestampedCount(BaseModel): - start_date: datetime - count: int - - @field_validator("start_date", mode="before") - @classmethod - def _date_validate(cls, v: str) -> datetime: - return datetime.strptime(v, "%Y-%m-%d") - - -class Achievement(BaseModel): - id: int - name: str - slug: str - desciption: str - grouping: str - icon_url: str - mode: Gamemode - ordering: int - instructions: Optional[str] = None - - -class Country(BaseModel): - code: str - name: str - - @computed_field # type: ignore - @property - def flag_emoji(self) -> str: - r"""Emoji for the flag. - - :return: Unicode emoji representation of the country's flag - :rtype: str - """ - return flag_lookup(self.code) - - -class HTMLBody(BaseModel): - html: str - raw: Optional[str] = None - bbcode: Optional[str] = None - - -class PinAttributes(BaseModel): - is_pinned: bool - score_id: int - score_type: str - - -class CurrentUserAttributes(BaseModel): - can_beatmap_update_owner: Optional[bool] = None - can_delete: Optional[bool] = None - can_edit_metadata: Optional[bool] = None - can_edit_tags: Optional[bool] = None - can_hype: Optional[bool] = None - can_hype_reason: Optional[str] = None - can_love: Optional[bool] = None - can_remove_from_loved: Optional[bool] = None - is_watching: Optional[bool] = None - new_hype_time: Optional[datetime] = None - nomination_modes: Optional[list[Gamemode]] = None - remaining_hype: Optional[int] = None - can_destroy: Optional[bool] = None - can_reopen: Optional[bool] = None - can_moderate_kudosu: Optional[bool] = None - can_resolve: Optional[bool] = None - vote_score: Optional[int] = None - can_message: Optional[bool] = None - can_message_error: Optional[str] = None - last_read_id: Optional[int] = None - can_new_comment: Optional[bool] = None - can_new_comment_reason: Optional[str] = None - pin: Optional[PinAttributes] = None - - -class CursorModel(BaseModel): - r"""NOTE: This model is not serializable by orjson directly. - - Use the provided .model_dump_json() or .model_dump() methods instead. - """ - - cursor_string: Optional[str] = None - next: Optional[partial[Coroutine[Any, Any, CursorModel]]] = Field( - default=None, - exclude=True, - ) - """Partial function to get the next page of results.""" +""" +This module contains models for miscellaneous objects. +""" +from __future__ import annotations + +from datetime import datetime +from functools import partial +from typing import Any +from typing import Coroutine +from typing import Literal +from typing import Optional + +from emojiflags.lookup import lookup as flag_lookup # type: ignore +from pydantic import computed_field +from pydantic import Field +from pydantic import field_validator + +from .base import BaseModel +from .gamemode import Gamemode + +__all__ = ( + "Achievement", + "Country", + "CurrentUserAttributes", + "TimestampedCount", + "CursorModel", + "SortTypes", + "HTMLBody", +) + + +SortTypes = Literal["id_asc", "id_desc"] + + +class TimestampedCount(BaseModel): + start_date: datetime + count: int + + @field_validator("start_date", mode="before") + @classmethod + def _date_validate(cls, v: str) -> datetime: + return datetime.strptime(v, "%Y-%m-%d") + + +class Achievement(BaseModel): + id: int + name: str + slug: str + desciption: str + grouping: str + icon_url: str + mode: Gamemode + ordering: int + instructions: Optional[str] = None + + +class Country(BaseModel): + code: str + name: str + + @computed_field # type: ignore + @property + def flag_emoji(self) -> str: + r"""Emoji for the flag. + + :return: Unicode emoji representation of the country's flag + :rtype: str + """ + return flag_lookup(self.code) + + +class HTMLBody(BaseModel): + html: str + raw: Optional[str] = None + bbcode: Optional[str] = None + + +class PinAttributes(BaseModel): + is_pinned: bool + score_id: int + score_type: str + + +class CurrentUserAttributes(BaseModel): + can_beatmap_update_owner: Optional[bool] = None + can_delete: Optional[bool] = None + can_edit_metadata: Optional[bool] = None + can_edit_tags: Optional[bool] = None + can_hype: Optional[bool] = None + can_hype_reason: Optional[str] = None + can_love: Optional[bool] = None + can_remove_from_loved: Optional[bool] = None + is_watching: Optional[bool] = None + new_hype_time: Optional[datetime] = None + nomination_modes: Optional[list[Gamemode]] = None + remaining_hype: Optional[int] = None + can_destroy: Optional[bool] = None + can_reopen: Optional[bool] = None + can_moderate_kudosu: Optional[bool] = None + can_resolve: Optional[bool] = None + vote_score: Optional[int] = None + can_message: Optional[bool] = None + can_message_error: Optional[str] = None + last_read_id: Optional[int] = None + can_new_comment: Optional[bool] = None + can_new_comment_reason: Optional[str] = None + pin: Optional[PinAttributes] = None + + +class CursorModel(BaseModel): + r"""NOTE: This model is not serializable by orjson directly. + + Use the provided .model_dump_json() or .model_dump() methods instead. + """ + + cursor_string: Optional[str] = None + next: Optional[partial[Coroutine[Any, Any, CursorModel]]] = Field( + default=None, + exclude=True, + ) + """Partial function to get the next page of results.""" diff --git a/aiosu/models/lazer.py b/aiosu/models/lazer.py index 419fb72..95532ca 100644 --- a/aiosu/models/lazer.py +++ b/aiosu/models/lazer.py @@ -1,223 +1,224 @@ -""" -This module contains models for lazer specific data. -""" -from __future__ import annotations - -from datetime import datetime -from typing import Any -from typing import Optional - -from pydantic import computed_field -from pydantic import Field -from pydantic import model_validator - -from .base import BaseModel -from .beatmap import Beatmap -from .beatmap import Beatmapset -from .common import CurrentUserAttributes -from .gamemode import Gamemode -from .score import ScoreWeight -from .user import User - -__all__ = ( - "LazerMod", - "LazerScoreStatistics", - "LazerReplayData", - "LazerScore", -) - - -def calculate_score_completion( - statistics: LazerScoreStatistics, - beatmap: Beatmap, -) -> float: - """Calculates completion for a score. - - :param statistics: The statistics of the score - :type statistics: aiosu.models.lazer.LazerScoreStatistics - :param beatmap: The beatmap of the score - :type beatmap: aiosu.models.beatmap.Beatmap - :raises ValueError: If the gamemode is unknown - :return: Completion for the given score - :rtype: float - """ - return ( - ( - statistics.perfect - + statistics.good - + statistics.great - + statistics.ok - + statistics.meh - + statistics.miss - ) - / beatmap.count_objects - ) * 100 - - -class LazerMod(BaseModel): - """Temporary model for lazer mods.""" - - acronym: str - settings: dict[str, Any] = Field(default_factory=dict) - - def __str__(self) -> str: - return self.acronym - - -class LazerScoreStatistics(BaseModel): - ok: int = 0 - meh: int = 0 - miss: int = 0 - great: int = 0 - ignore_hit: int = 0 - ignore_miss: int = 0 - large_bonus: int = 0 - large_tick_hit: int = 0 - large_tick_miss: int = 0 - small_bonus: int = 0 - small_tick_hit: int = 0 - small_tick_miss: int = 0 - good: int = 0 - perfect: int = 0 - legacy_combo_increase: int = 0 - - @computed_field # type: ignore - @property - def count_300(self) -> int: - return self.great - - @computed_field # type: ignore - @property - def count_100(self) -> int: - return self.ok - - @computed_field # type: ignore - @property - def count_50(self) -> int: - return self.meh - - @computed_field # type: ignore - @property - def count_miss(self) -> int: - return self.miss - - @computed_field # type: ignore - @property - def count_geki(self) -> int: - return self.perfect - - @computed_field # type: ignore - @property - def count_katu(self) -> int: - return self.good - - -class LazerReplayData(BaseModel): - mods: list[LazerMod] - statistics: LazerScoreStatistics - maximum_statistics: LazerScoreStatistics - - -class LazerScore(BaseModel): - id: int - accuracy: float - beatmap_id: int - max_combo: int - maximum_statistics: LazerScoreStatistics - mods: list[LazerMod] - passed: bool - rank: str - ruleset_id: int - ended_at: datetime - statistics: LazerScoreStatistics - total_score: int - user_id: int - replay: bool - type: str - current_user_attributes: CurrentUserAttributes - beatmap: Beatmap - beatmapset: Beatmapset - user: User - build_id: Optional[int] = None - started_at: Optional[datetime] = None - best_id: Optional[int] = None - legacy_perfect: Optional[bool] = None - pp: Optional[float] = None - weight: Optional[ScoreWeight] = None - - @computed_field # type: ignore - @property - def mods_str(self) -> str: - return "".join(str(mod) for mod in self.mods) - - @computed_field # type: ignore - @property - def created_at(self) -> datetime: - return self.ended_at - - @computed_field # type: ignore - @property - def completion(self) -> float: - """Beatmap completion. - - :raises ValueError: If beatmap is None - :raises ValueError: If mode is unknown - :return: Beatmap completion of a score (%). 100% for passes - :rtype: float - """ - if self.beatmap is None: - raise ValueError("Beatmap object is not set.") - - if self.passed: - return 100.0 - - return calculate_score_completion(self.statistics, self.beatmap) - - @computed_field # type: ignore - @property - def mode(self) -> Gamemode: - return Gamemode(self.ruleset_id) - - @computed_field # type: ignore - @property - def score(self) -> int: - return self.total_score - - @computed_field # type: ignore - @property - def score_url(self) -> Optional[str]: - r"""Link to the score. - - :return: Link to the score on the osu! website - :rtype: Optional[str] - """ - if (not self.id and not self.best_id) or not self.passed: - return None - return ( - f"https://osu.ppy.sh/scores/{self.mode.name_api}/{self.best_id}" - if self.best_id - else f"https://osu.ppy.sh/scores/{self.id}" - ) - - @computed_field # type: ignore - @property - def replay_url(self) -> Optional[str]: - r"""Link to the replay. - - :return: Link to download the replay on the osu! website - :rtype: Optional[str] - """ - if not self.replay: - return None - return ( - f"https://osu.ppy.sh/scores/{self.mode.name_api}/{self.best_id}/download" - if self.best_id - else f"https://osu.ppy.sh/scores/{self.id}/download" - ) - - @model_validator(mode="before") - @classmethod - def _fail_rank(cls, values: dict[str, Any]) -> dict[str, Any]: - if not values["passed"]: - values["rank"] = "F" - return values +""" +This module contains models for lazer specific data. +""" +from __future__ import annotations + +from datetime import datetime +from typing import Any +from typing import Optional + +from pydantic import computed_field +from pydantic import Field +from pydantic import model_validator + +from .base import BaseModel +from .beatmap import Beatmap +from .beatmap import Beatmapset +from .common import CurrentUserAttributes +from .gamemode import Gamemode +from .score import ScoreWeight +from .user import User + +__all__ = ( + "LazerMod", + "LazerScoreStatistics", + "LazerReplayData", + "LazerScore", +) + + +def calculate_score_completion( + statistics: LazerScoreStatistics, + beatmap: Beatmap, +) -> Optional[float]: + """Calculates completion for a score. + + :param statistics: The statistics of the score + :type statistics: aiosu.models.lazer.LazerScoreStatistics + :param beatmap: The beatmap of the score + :type beatmap: aiosu.models.beatmap.Beatmap + :raises ValueError: If the gamemode is unknown + :return: Completion for the given score + :rtype: Optional[float] + """ + if not beatmap.count_objects: + return None + + return ( + ( + statistics.perfect + + statistics.good + + statistics.great + + statistics.ok + + statistics.meh + + statistics.miss + ) + / beatmap.count_objects + ) * 100 + + +class LazerMod(BaseModel): + """Temporary model for lazer mods.""" + + acronym: str + settings: dict[str, Any] = Field(default_factory=dict) + + def __str__(self) -> str: + return self.acronym + + +class LazerScoreStatistics(BaseModel): + ok: int = 0 + meh: int = 0 + miss: int = 0 + great: int = 0 + ignore_hit: int = 0 + ignore_miss: int = 0 + large_bonus: int = 0 + large_tick_hit: int = 0 + large_tick_miss: int = 0 + small_bonus: int = 0 + small_tick_hit: int = 0 + small_tick_miss: int = 0 + good: int = 0 + perfect: int = 0 + legacy_combo_increase: int = 0 + + @computed_field # type: ignore + @property + def count_300(self) -> int: + return self.great + + @computed_field # type: ignore + @property + def count_100(self) -> int: + return self.ok + + @computed_field # type: ignore + @property + def count_50(self) -> int: + return self.meh + + @computed_field # type: ignore + @property + def count_miss(self) -> int: + return self.miss + + @computed_field # type: ignore + @property + def count_geki(self) -> int: + return self.perfect + + @computed_field # type: ignore + @property + def count_katu(self) -> int: + return self.good + + +class LazerReplayData(BaseModel): + mods: list[LazerMod] + statistics: LazerScoreStatistics + maximum_statistics: LazerScoreStatistics + + +class LazerScore(BaseModel): + id: int + accuracy: float + beatmap_id: int + max_combo: int + maximum_statistics: LazerScoreStatistics + mods: list[LazerMod] + passed: bool + rank: str + ruleset_id: int + ended_at: datetime + statistics: LazerScoreStatistics + total_score: int + user_id: int + replay: bool + type: str + current_user_attributes: CurrentUserAttributes + beatmap: Beatmap + beatmapset: Beatmapset + user: User + build_id: Optional[int] = None + started_at: Optional[datetime] = None + best_id: Optional[int] = None + legacy_perfect: Optional[bool] = None + pp: Optional[float] = None + weight: Optional[ScoreWeight] = None + + @computed_field # type: ignore + @property + def mods_str(self) -> str: + return "".join(str(mod) for mod in self.mods) + + @computed_field # type: ignore + @property + def created_at(self) -> datetime: + return self.ended_at + + @computed_field # type: ignore + @property + def completion(self) -> Optional[float]: + """Beatmap completion. + + :return: Beatmap completion of a score (%). 100% for passes. None if no beatmap. + :rtype: Optional[float] + """ + if not self.beatmap: + return None + + if self.passed: + return 100.0 + + return calculate_score_completion(self.statistics, self.beatmap) + + @computed_field # type: ignore + @property + def mode(self) -> Gamemode: + return Gamemode(self.ruleset_id) + + @computed_field # type: ignore + @property + def score(self) -> int: + return self.total_score + + @computed_field # type: ignore + @property + def score_url(self) -> Optional[str]: + r"""Link to the score. + + :return: Link to the score on the osu! website + :rtype: Optional[str] + """ + if (not self.id and not self.best_id) or not self.passed: + return None + return ( + f"https://osu.ppy.sh/scores/{self.mode.name_api}/{self.best_id}" + if self.best_id + else f"https://osu.ppy.sh/scores/{self.id}" + ) + + @computed_field # type: ignore + @property + def replay_url(self) -> Optional[str]: + r"""Link to the replay. + + :return: Link to download the replay on the osu! website + :rtype: Optional[str] + """ + if not self.replay: + return None + return ( + f"https://osu.ppy.sh/scores/{self.mode.name_api}/{self.best_id}/download" + if self.best_id + else f"https://osu.ppy.sh/scores/{self.id}/download" + ) + + @model_validator(mode="before") + @classmethod + def _fail_rank(cls, values: dict[str, Any]) -> dict[str, Any]: + if not values["passed"]: + values["rank"] = "F" + return values diff --git a/aiosu/models/oauthtoken.py b/aiosu/models/oauthtoken.py index 421138f..154440c 100644 --- a/aiosu/models/oauthtoken.py +++ b/aiosu/models/oauthtoken.py @@ -1,63 +1,63 @@ -""" -This module contains models for API v2 token objects. -""" -from __future__ import annotations - -from datetime import datetime -from datetime import timedelta -from functools import cached_property -from typing import TYPE_CHECKING - -import jwt -from pydantic import computed_field -from pydantic import model_validator - -from .base import FrozenModel -from .scopes import Scopes - -if TYPE_CHECKING: - from typing import Any - -__all__ = ("OAuthToken",) - - -class OAuthToken(FrozenModel): - token_type: str = "Bearer" - """Defaults to 'Bearer'""" - access_token: str = "" - refresh_token: str = "" - expires_on: datetime = datetime.utcfromtimestamp(31536000) - """Can be a datetime.datetime object or a string. Alternatively, expires_in may be passed representing the number of seconds the token will be valid for.""" - - @computed_field # type: ignore - @cached_property - def owner_id(self) -> int: - if not self.access_token: - return 0 - decoded = jwt.decode(self.access_token, options={"verify_signature": False}) - if decoded["sub"]: - return int(decoded["sub"]) - return 0 - - @computed_field # type: ignore - @cached_property - def scopes(self) -> Scopes: - if not self.access_token: - return Scopes.PUBLIC - decoded = jwt.decode(self.access_token, options={"verify_signature": False}) - return Scopes.from_api_list(decoded["scopes"]) - - @computed_field # type: ignore - @cached_property - def can_refresh(self) -> bool: - """Returns True if the token can be refreshed.""" - return bool(self.refresh_token) - - @model_validator(mode="before") - @classmethod - def _set_expires_on(cls, values: dict[str, Any]) -> dict[str, Any]: - if isinstance(values.get("expires_in"), int): - values["expires_on"] = datetime.utcnow() + timedelta( - seconds=values["expires_in"], - ) - return values +""" +This module contains models for API v2 token objects. +""" +from __future__ import annotations + +from datetime import datetime +from datetime import timedelta +from functools import cached_property +from typing import TYPE_CHECKING + +import jwt +from pydantic import computed_field +from pydantic import model_validator + +from .base import FrozenModel +from .scopes import Scopes + +if TYPE_CHECKING: + from typing import Any + +__all__ = ("OAuthToken",) + + +class OAuthToken(FrozenModel): + token_type: str = "Bearer" + """Defaults to 'Bearer'""" + access_token: str = "" + refresh_token: str = "" + expires_on: datetime = datetime.utcfromtimestamp(31536000) + """Can be a datetime.datetime object or a string. Alternatively, expires_in may be passed representing the number of seconds the token will be valid for.""" + + @computed_field # type: ignore + @cached_property + def owner_id(self) -> int: + if not self.access_token: + return 0 + decoded = jwt.decode(self.access_token, options={"verify_signature": False}) + if decoded["sub"]: + return int(decoded["sub"]) + return 0 + + @computed_field # type: ignore + @cached_property + def scopes(self) -> Scopes: + if not self.access_token: + return Scopes.PUBLIC + decoded = jwt.decode(self.access_token, options={"verify_signature": False}) + return Scopes.from_api_list(decoded["scopes"]) + + @computed_field # type: ignore + @cached_property + def can_refresh(self) -> bool: + """Returns True if the token can be refreshed.""" + return bool(self.refresh_token) + + @model_validator(mode="before") + @classmethod + def _set_expires_on(cls, values: dict[str, Any]) -> dict[str, Any]: + if isinstance(values.get("expires_in"), int): + values["expires_on"] = datetime.utcnow() + timedelta( + seconds=values["expires_in"], + ) + return values diff --git a/aiosu/models/score.py b/aiosu/models/score.py index 88341dc..054a498 100644 --- a/aiosu/models/score.py +++ b/aiosu/models/score.py @@ -1,260 +1,263 @@ -""" -This module contains models for Score objects. -""" -from __future__ import annotations - -from datetime import datetime -from typing import Optional -from typing import TYPE_CHECKING - -from pydantic import computed_field -from pydantic import model_validator - -from ..utils.accuracy import CatchAccuracyCalculator -from ..utils.accuracy import ManiaAccuracyCalculator -from ..utils.accuracy import OsuAccuracyCalculator -from ..utils.accuracy import TaikoAccuracyCalculator -from .base import BaseModel -from .beatmap import Beatmap -from .beatmap import Beatmapset -from .common import CurrentUserAttributes -from .gamemode import Gamemode -from .mods import Mods -from .user import User - -if TYPE_CHECKING: - from typing import Any - from .. import v1 - -__all__ = ( - "Score", - "ScoreStatistics", - "ScoreWeight", - "calculate_score_completion", -) - -accuracy_calculators = { - "osu": OsuAccuracyCalculator(), - "mania": ManiaAccuracyCalculator(), - "taiko": TaikoAccuracyCalculator(), - "fruits": CatchAccuracyCalculator(), -} - - -def calculate_score_completion( - mode: Gamemode, - statistics: ScoreStatistics, - beatmap: Beatmap, -) -> float: - """Calculates completion for a score. - - :param mode: The gamemode of the score - :type mode: aiosu.models.gamemode.Gamemode - :param statistics: The statistics of the score - :type statistics: aiosu.models.score.ScoreStatistics - :param beatmap: The beatmap of the score - :type beatmap: aiosu.models.beatmap.Beatmap - :raises ValueError: If the gamemode is unknown - :return: Completion for the given score - :rtype: float - """ - if mode == Gamemode.STANDARD: - return ( - ( - statistics.count_300 - + statistics.count_100 - + statistics.count_50 - + statistics.count_miss - ) - / beatmap.count_objects - ) * 100 - elif mode == Gamemode.TAIKO: - return ( - (statistics.count_300 + statistics.count_100 + statistics.count_miss) - / beatmap.count_objects - ) * 100 - elif mode == Gamemode.CTB: - return ( - (statistics.count_300 + statistics.count_100 + +statistics.count_miss) - / beatmap.count_objects - ) * 100 - elif mode == Gamemode.MANIA: - return ( - ( - statistics.count_300 - + statistics.count_100 - + statistics.count_50 - + statistics.count_miss - + statistics.count_geki - + statistics.count_katu - ) - / beatmap.count_objects - ) * 100 - raise ValueError("Unknown mode specified.") - - -class ScoreWeight(BaseModel): - percentage: float - pp: float - - -class ScoreStatistics(BaseModel): - count_50: int - count_100: int - count_300: int - count_miss: int - count_geki: int - count_katu: int - - @model_validator(mode="before") - @classmethod - def _convert_none_to_zero(cls, values: dict[str, Any]) -> dict[str, Any]: - # Lazer API returns null for some statistics - for key in values: - if values[key] is None: - values[key] = 0 - return values - - @classmethod - def _from_api_v1(cls, data: Any) -> ScoreStatistics: - return cls.model_validate( - { - "count_50": data["count50"], - "count_100": data["count100"], - "count_300": data["count300"], - "count_geki": data["countgeki"], - "count_katu": data["countkatu"], - "count_miss": data["countmiss"], - }, - ) - - -class Score(BaseModel): - user_id: int - accuracy: float - mods: Mods - score: int - max_combo: int - passed: bool - perfect: bool - statistics: ScoreStatistics - rank: str - created_at: datetime - mode: Gamemode - replay: bool - id: Optional[int] = None - """Always present except for API v1 recent scores.""" - pp: Optional[float] = 0 - best_id: Optional[int] = None - beatmap: Optional[Beatmap] = None - beatmapset: Optional[Beatmapset] = None - weight: Optional[ScoreWeight] = None - user: Optional[User] = None - rank_global: Optional[int] = None - rank_country: Optional[int] = None - type: Optional[str] = None - current_user_attributes: Optional[CurrentUserAttributes] = None - beatmap_id: Optional[int] = None - """Only present on API v1""" - - @computed_field # type: ignore - @property - def completion(self) -> float: - """Beatmap completion. - - :raises ValueError: If beatmap is None - :raises ValueError: If mode is unknown - :return: Beatmap completion of a score (%). 100% for passes - :rtype: float - """ - if not self.beatmap: - raise ValueError("Beatmap object is not set.") - - if self.passed: - return 100.0 - - return calculate_score_completion(self.mode, self.statistics, self.beatmap) - - @computed_field # type: ignore - @property - def score_url(self) -> Optional[str]: - r"""Link to the score. - - :return: Link to the score on the osu! website - :rtype: Optional[str] - """ - if (not self.id and not self.best_id) or not self.passed: - return None - return ( - f"https://osu.ppy.sh/scores/{self.mode.name_api}/{self.best_id}" - if self.best_id - else f"https://osu.ppy.sh/scores/{self.id}" - ) - - @computed_field # type: ignore - @property - def replay_url(self) -> Optional[str]: - r"""Link to the replay. - - :return: Link to download the replay on the osu! website - :rtype: Optional[str] - """ - if not self.replay: - return None - return ( - f"https://osu.ppy.sh/scores/{self.mode.name_api}/{self.best_id}/download" - if self.best_id - else f"https://osu.ppy.sh/scores/{self.id}/download" - ) - - @model_validator(mode="before") - @classmethod - def _fail_rank(cls, values: dict[str, Any]) -> dict[str, Any]: - if not values["passed"]: - values["rank"] = "F" - return values - - async def request_beatmap(self, client: v1.Client) -> None: - r"""For v1 Scores: requests the beatmap from the API and sets it. - - :param client: An API v1 Client - :type client: aiosu.v1.client.Client - """ - if self.beatmap_id is None: - raise ValueError("Score has unknown beatmap ID") - if self.beatmap is None and self.beatmapset is None: - sets = await client.get_beatmap( - mode=self.mode, - beatmap_id=self.beatmap_id, - ) - self.beatmapset = sets[0] - self.beatmap = sets[0].beatmaps[0] # type: ignore - - @classmethod - def _from_api_v1( - cls, - data: Any, - mode: Gamemode, - ) -> Score: - statistics = ScoreStatistics._from_api_v1(data) - score = cls.model_validate( - { - "id": data["score_id"], - "user_id": data["user_id"], - "accuracy": 0.0, - "mods": int(data["enabled_mods"]), - "score": data["score"], - "pp": data.get("pp", 0.0), - "max_combo": data["maxcombo"], - "passed": data["rank"] != "F", - "perfect": data["perfect"], - "statistics": statistics, - "rank": data["rank"], - "created_at": data["date"], - "mode": mode, - "beatmap_id": data.get("beatmap_id"), - "replay": data.get("replay_available", False), - }, - ) - score.accuracy = accuracy_calculators[str(mode)].calculate(score) - return score +""" +This module contains models for Score objects. +""" +from __future__ import annotations + +from datetime import datetime +from typing import Optional +from typing import TYPE_CHECKING + +from pydantic import computed_field +from pydantic import model_validator + +from ..utils.accuracy import CatchAccuracyCalculator +from ..utils.accuracy import ManiaAccuracyCalculator +from ..utils.accuracy import OsuAccuracyCalculator +from ..utils.accuracy import TaikoAccuracyCalculator +from .base import BaseModel +from .beatmap import Beatmap +from .beatmap import Beatmapset +from .common import CurrentUserAttributes +from .gamemode import Gamemode +from .mods import Mods +from .user import User + +if TYPE_CHECKING: + from typing import Any + from .. import v1 + +__all__ = ( + "Score", + "ScoreStatistics", + "ScoreWeight", + "calculate_score_completion", +) + +accuracy_calculators = { + "osu": OsuAccuracyCalculator(), + "mania": ManiaAccuracyCalculator(), + "taiko": TaikoAccuracyCalculator(), + "fruits": CatchAccuracyCalculator(), +} + + +def calculate_score_completion( + mode: Gamemode, + statistics: ScoreStatistics, + beatmap: Beatmap, +) -> Optional[float]: + """Calculates completion for a score. + + :param mode: The gamemode of the score + :type mode: aiosu.models.gamemode.Gamemode + :param statistics: The statistics of the score + :type statistics: aiosu.models.score.ScoreStatistics + :param beatmap: The beatmap of the score + :type beatmap: aiosu.models.beatmap.Beatmap + :raises ValueError: If the gamemode is unknown + :return: Completion for the given score + :rtype: Optional[float] + """ + if not beatmap.count_objects: + return None + + if mode == Gamemode.STANDARD: + return ( + ( + statistics.count_300 + + statistics.count_100 + + statistics.count_50 + + statistics.count_miss + ) + / beatmap.count_objects + ) * 100 + elif mode == Gamemode.TAIKO: + return ( + (statistics.count_300 + statistics.count_100 + statistics.count_miss) + / beatmap.count_objects + ) * 100 + elif mode == Gamemode.CTB: + return ( + (statistics.count_300 + statistics.count_100 + +statistics.count_miss) + / beatmap.count_objects + ) * 100 + elif mode == Gamemode.MANIA: + return ( + ( + statistics.count_300 + + statistics.count_100 + + statistics.count_50 + + statistics.count_miss + + statistics.count_geki + + statistics.count_katu + ) + / beatmap.count_objects + ) * 100 + + raise ValueError("Unknown mode specified.") + + +class ScoreWeight(BaseModel): + percentage: float + pp: float + + +class ScoreStatistics(BaseModel): + count_50: int + count_100: int + count_300: int + count_miss: int + count_geki: int + count_katu: int + + @model_validator(mode="before") + @classmethod + def _convert_none_to_zero(cls, values: dict[str, Any]) -> dict[str, Any]: + # Lazer API returns null for some statistics + for key in values: + if values[key] is None: + values[key] = 0 + return values + + @classmethod + def _from_api_v1(cls, data: Any) -> ScoreStatistics: + return cls.model_validate( + { + "count_50": data["count50"], + "count_100": data["count100"], + "count_300": data["count300"], + "count_geki": data["countgeki"], + "count_katu": data["countkatu"], + "count_miss": data["countmiss"], + }, + ) + + +class Score(BaseModel): + user_id: int + accuracy: float + mods: Mods + score: int + max_combo: int + passed: bool + perfect: bool + statistics: ScoreStatistics + rank: str + created_at: datetime + mode: Gamemode + replay: bool + id: Optional[int] = None + """Always present except for API v1 recent scores.""" + pp: Optional[float] = 0 + best_id: Optional[int] = None + beatmap: Optional[Beatmap] = None + beatmapset: Optional[Beatmapset] = None + weight: Optional[ScoreWeight] = None + user: Optional[User] = None + rank_global: Optional[int] = None + rank_country: Optional[int] = None + type: Optional[str] = None + current_user_attributes: Optional[CurrentUserAttributes] = None + beatmap_id: Optional[int] = None + """Only present on API v1""" + + @computed_field # type: ignore + @property + def completion(self) -> Optional[float]: + """Beatmap completion. + + :raises ValueError: If mode is unknown + :return: Beatmap completion of a score (%). 100% for passes. None if no beatmap. + :rtype: Optional[float] + """ + if not self.beatmap: + return None + + if self.passed: + return 100.0 + + return calculate_score_completion(self.mode, self.statistics, self.beatmap) + + @computed_field # type: ignore + @property + def score_url(self) -> Optional[str]: + r"""Link to the score. + + :return: Link to the score on the osu! website + :rtype: Optional[str] + """ + if (not self.id and not self.best_id) or not self.passed: + return None + return ( + f"https://osu.ppy.sh/scores/{self.mode.name_api}/{self.best_id}" + if self.best_id + else f"https://osu.ppy.sh/scores/{self.id}" + ) + + @computed_field # type: ignore + @property + def replay_url(self) -> Optional[str]: + r"""Link to the replay. + + :return: Link to download the replay on the osu! website + :rtype: Optional[str] + """ + if not self.replay: + return None + return ( + f"https://osu.ppy.sh/scores/{self.mode.name_api}/{self.best_id}/download" + if self.best_id + else f"https://osu.ppy.sh/scores/{self.id}/download" + ) + + @model_validator(mode="before") + @classmethod + def _fail_rank(cls, values: dict[str, Any]) -> dict[str, Any]: + if not values["passed"]: + values["rank"] = "F" + return values + + async def request_beatmap(self, client: v1.Client) -> None: + r"""For v1 Scores: requests the beatmap from the API and sets it. + + :param client: An API v1 Client + :type client: aiosu.v1.client.Client + """ + if self.beatmap_id is None: + raise ValueError("Score has unknown beatmap ID") + if self.beatmap is None and self.beatmapset is None: + sets = await client.get_beatmap( + mode=self.mode, + beatmap_id=self.beatmap_id, + ) + self.beatmapset = sets[0] + self.beatmap = sets[0].beatmaps[0] # type: ignore + + @classmethod + def _from_api_v1( + cls, + data: Any, + mode: Gamemode, + ) -> Score: + statistics = ScoreStatistics._from_api_v1(data) + score = cls.model_validate( + { + "id": data["score_id"], + "user_id": data["user_id"], + "accuracy": 0.0, + "mods": int(data["enabled_mods"]), + "score": data["score"], + "pp": data.get("pp", 0.0), + "max_combo": data["maxcombo"], + "passed": data["rank"] != "F", + "perfect": data["perfect"], + "statistics": statistics, + "rank": data["rank"], + "created_at": data["date"], + "mode": mode, + "beatmap_id": data.get("beatmap_id"), + "replay": data.get("replay_available", False), + }, + ) + score.accuracy = accuracy_calculators[str(mode)].calculate(score) + return score diff --git a/aiosu/models/user.py b/aiosu/models/user.py index a0cdece..8f773bc 100644 --- a/aiosu/models/user.py +++ b/aiosu/models/user.py @@ -1,349 +1,349 @@ -""" -This module contains models for User objects. -""" -from __future__ import annotations - -from datetime import datetime -from enum import Enum -from enum import unique -from typing import Any -from typing import Literal -from typing import Optional -from typing import TYPE_CHECKING - -from pydantic import computed_field -from pydantic import Field - -from .base import BaseModel -from .common import Country -from .common import HTMLBody -from .common import TimestampedCount -from .gamemode import Gamemode - -if TYPE_CHECKING: - from typing import Callable - -__all__ = ( - "User", - "UserAccountHistory", - "UserBadge", - "UserGradeCounts", - "UserGroup", - "UserKudosu", - "UserLevel", - "UserProfileCover", - "UserProfileTournamentBanner", - "UserQueryType", - "UserRankHistoryElement", - "UserStatsVariant", - "UserStats", - "UserAccountHistoryType", - "UserRankHighest", - "ManiaStatsVariantsType", -) - - -cast_int: Callable[..., int] = lambda x: int(x or 0) -cast_float: Callable[..., float] = lambda x: float(x or 0) - -UserAccountHistoryType = Literal[ - "note", - "restriction", - "silence", - "tournament_ban", -] - -ManiaStatsVariantsType = Literal[ - "4k", - "7k", -] - -OLD_QUERY_TYPES = { - "ID": "id", - "USERNAME": "string", -} - - -@unique -class UserQueryType(Enum): - ID = "id" - USERNAME = "username" - - @computed_field # type: ignore - @property - def old_api_name(self) -> str: - return OLD_QUERY_TYPES[self.name] - - @computed_field # type: ignore - @property - def new_api_name(self) -> str: - return self.value - - @classmethod - def _missing_(cls, query: object) -> Any: - if isinstance(query, str): - query = query.lower() - for q in list(UserQueryType): - if query in (q.old_api_name, q.new_api_name): - return q - raise ValueError(f"UserQueryType {query} does not exist.") - - -class UserLevel(BaseModel): - current: int - progress: float - - @classmethod - def _from_api_v1(cls, data: Any) -> UserLevel: - level = cast_float(data["level"]) - current = int(level) - progress = (level - current) * 100 - return cls.model_validate({"current": current, "progress": progress}) - - -class UserKudosu(BaseModel): - total: int - available: int - - -class UserRankHistoryElement(BaseModel): - mode: str - data: list[int] - - @computed_field # type: ignore - @property - def average_gain(self) -> float: - r"""Average rank gain. - - :return: Average rank gain for a user - :rtype: float - """ - return (self.data[1] - self.data[-1]) / len(self.data) - - -class UserRankHighest(BaseModel): - rank: int - updated_at: datetime - - -class UserProfileCover(BaseModel): - url: str - custom_url: Optional[str] = None - id: Optional[str] = None - - -class UserProfileTournamentBanner(BaseModel): - tournament_id: int - id: Optional[int] = None - image: Optional[str] = None - image_2_x: Optional[str] = Field(default=None, alias="image@2x") - - -class UserBadge(BaseModel): - awarded_at: datetime - description: str - image_url: str - url: str - - -class UserAccountHistory(BaseModel): - id: int - timestamp: datetime - length: int - permanent: bool - type: UserAccountHistoryType - description: Optional[str] = None - - -class UserGradeCounts(BaseModel): - ssh: Optional[int] = None - """Number of Silver SS ranks achieved.""" - ss: Optional[int] = None - """Number of SS ranks achieved.""" - sh: Optional[int] = None - """Number of Silver S ranks achieved.""" - s: Optional[int] = None - """Number of S ranks achieved.""" - a: Optional[int] = None - """Number of A ranks achieved.""" - - @classmethod - def _from_api_v1(cls, data: Any) -> UserGradeCounts: - return cls.model_validate( - { - "ss": cast_int(data["count_rank_ss"]), - "ssh": cast_int(data["count_rank_ssh"]), - "s": cast_int(data["count_rank_s"]), - "sh": cast_int(data["count_rank_sh"]), - "a": cast_int(data["count_rank_a"]), - }, - ) - - -class UserGroup(BaseModel): - id: int - identifier: str - name: str - short_name: str - has_listing: bool - has_playmodes: bool - is_probationary: bool - colour: Optional[str] = None - playmodes: Optional[list[Gamemode]] = None - description: Optional[str] = None - - -class UserStatsVariant(BaseModel): - mode: Gamemode - variant: str - pp: float - country_rank: Optional[int] = None - global_rank: Optional[int] = None - - -class UserStats(BaseModel): - """Fields are marked as optional since they might be missing from rankings other than performance.""" - - ranked_score: Optional[int] = None - play_count: Optional[int] = None - grade_counts: Optional[UserGradeCounts] = None - total_hits: Optional[int] = None - is_ranked: Optional[bool] = None - total_score: Optional[int] = None - level: Optional[UserLevel] = None - hit_accuracy: Optional[float] = None - play_time: Optional[int] = None - pp: Optional[float] = None - pp_exp: Optional[float] = None - replays_watched_by_others: Optional[int] = None - maximum_combo: Optional[int] = None - global_rank: Optional[int] = None - global_rank_exp: Optional[int] = None - country_rank: Optional[int] = None - user: Optional[User] = None - count_300: Optional[int] = None - count_100: Optional[int] = None - count_50: Optional[int] = None - count_miss: Optional[int] = None - variants: Optional[list[UserStatsVariant]] = None - - @computed_field # type: ignore - @property - def pp_per_playtime(self) -> float: - r"""PP per playtime. - - :return: PP per playtime - :rtype: float - """ - if not self.play_time or not self.pp: - return 0 - return self.pp / self.play_time * 3600 - - @classmethod - def _from_api_v1(cls, data: Any) -> UserStats: - """Some fields can be None, we want to force them to cast to a value.""" - return cls.model_validate( - { - "level": UserLevel._from_api_v1(data), - "pp": cast_float(data["pp_raw"]), - "global_rank": cast_int(data["pp_rank"]), - "country_rank": cast_int(data["pp_country_rank"]), - "ranked_score": cast_int(data["ranked_score"]), - "hit_accuracy": cast_float(data["accuracy"]), - "play_count": cast_int(data["playcount"]), - "play_time": cast_int(data["total_seconds_played"]), - "total_score": cast_int(data["total_score"]), - "total_hits": cast_int(data["count300"]) - + cast_int(data["count100"]) - + cast_int(data["count50"]), - "is_ranked": cast_float(data["pp_raw"]) != 0, - "grade_counts": UserGradeCounts._from_api_v1(data), - "count_300": cast_int(data["count300"]), - "count_100": cast_int(data["count100"]), - "count_50": cast_int(data["count50"]), - }, - ) - - -class UserAchievmement(BaseModel): - achieved_at: datetime - achievement_id: int - - -class User(BaseModel): - avatar_url: str - country_code: str - id: int - username: str - default_group: Optional[str] = None - is_active: Optional[bool] = None - is_bot: Optional[bool] = None - is_online: Optional[bool] = None - is_supporter: Optional[bool] = None - pm_friends_only: Optional[bool] = None - profile_colour: Optional[str] = None - is_deleted: Optional[bool] = None - last_visit: Optional[datetime] = None - discord: Optional[str] = None - has_supported: Optional[bool] = None - interests: Optional[str] = None - join_date: Optional[datetime] = None - kudosu: Optional[UserKudosu] = None - location: Optional[str] = None - max_blocks: Optional[int] = None - max_friends: Optional[int] = None - occupation: Optional[str] = None - playmode: Optional[Gamemode] = None - playstyle: Optional[list[str]] = None - post_count: Optional[int] = None - profile_order: Optional[list[str]] = None - title: Optional[str] = None - twitter: Optional[str] = None - website: Optional[str] = None - country: Optional[Country] = None - cover: Optional[UserProfileCover] = None - is_restricted: Optional[bool] = None - account_history: Optional[list[UserAccountHistory]] = None - active_tournament_banner: Optional[UserProfileTournamentBanner] = None - badges: Optional[list[UserBadge]] = None - beatmap_playcounts_count: Optional[int] = None - favourite_beatmapset_count: Optional[int] = None - follower_count: Optional[int] = None - graveyard_beatmapset_count: Optional[int] = None - groups: Optional[list[UserGroup]] = None - loved_beatmapset_count: Optional[int] = None - monthly_playcounts: Optional[list[TimestampedCount]] = None - page: Optional[HTMLBody] = None - pending_beatmapset_count: Optional[int] = None - previous_usernames: Optional[list[str]] = None - ranked_beatmapset_count: Optional[int] = None - replays_watched_counts: Optional[list[TimestampedCount]] = None - scores_best_count: Optional[int] = None - scores_first_count: Optional[int] = None - scores_recent_count: Optional[int] = None - statistics: Optional[UserStats] = None - support_level: Optional[int] = None - user_achievements: Optional[list[UserAchievmement]] = None - rank_history: Optional[UserRankHistoryElement] = None - rank_highest: Optional[UserRankHighest] = None - - @computed_field # type: ignore - @property - def url(self) -> str: - return f"https://osu.ppy.sh/users/{self.id}" - - @classmethod - def _from_api_v1(cls, data: Any) -> User: - return cls.model_validate( - { - "avatar_url": f"https://s.ppy.sh/a/{data['user_id']}", - "country_code": data["country"], - "id": data["user_id"], - "username": data["username"], - "join_date": data["join_date"], - "statistics": UserStats._from_api_v1(data), - }, - ) - - -UserStats.model_rebuild() +""" +This module contains models for User objects. +""" +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from enum import unique +from typing import Any +from typing import Literal +from typing import Optional +from typing import TYPE_CHECKING + +from pydantic import computed_field +from pydantic import Field + +from .base import BaseModel +from .common import Country +from .common import HTMLBody +from .common import TimestampedCount +from .gamemode import Gamemode + +if TYPE_CHECKING: + from typing import Callable + +__all__ = ( + "User", + "UserAccountHistory", + "UserBadge", + "UserGradeCounts", + "UserGroup", + "UserKudosu", + "UserLevel", + "UserProfileCover", + "UserProfileTournamentBanner", + "UserQueryType", + "UserRankHistoryElement", + "UserStatsVariant", + "UserStats", + "UserAccountHistoryType", + "UserRankHighest", + "ManiaStatsVariantsType", +) + + +cast_int: Callable[..., int] = lambda x: int(x or 0) +cast_float: Callable[..., float] = lambda x: float(x or 0) + +UserAccountHistoryType = Literal[ + "note", + "restriction", + "silence", + "tournament_ban", +] + +ManiaStatsVariantsType = Literal[ + "4k", + "7k", +] + +OLD_QUERY_TYPES = { + "ID": "id", + "USERNAME": "string", +} + + +@unique +class UserQueryType(Enum): + ID = "id" + USERNAME = "username" + + @computed_field # type: ignore + @property + def old_api_name(self) -> str: + return OLD_QUERY_TYPES[self.name] + + @computed_field # type: ignore + @property + def new_api_name(self) -> str: + return self.value + + @classmethod + def _missing_(cls, query: object) -> Any: + if isinstance(query, str): + query = query.lower() + for q in list(UserQueryType): + if query in (q.old_api_name, q.new_api_name): + return q + raise ValueError(f"UserQueryType {query} does not exist.") + + +class UserLevel(BaseModel): + current: int + progress: float + + @classmethod + def _from_api_v1(cls, data: Any) -> UserLevel: + level = cast_float(data["level"]) + current = int(level) + progress = (level - current) * 100 + return cls.model_validate({"current": current, "progress": progress}) + + +class UserKudosu(BaseModel): + total: int + available: int + + +class UserRankHistoryElement(BaseModel): + mode: str + data: list[int] + + @computed_field # type: ignore + @property + def average_gain(self) -> float: + r"""Average rank gain. + + :return: Average rank gain for a user + :rtype: float + """ + return (self.data[1] - self.data[-1]) / len(self.data) + + +class UserRankHighest(BaseModel): + rank: int + updated_at: datetime + + +class UserProfileCover(BaseModel): + url: str + custom_url: Optional[str] = None + id: Optional[str] = None + + +class UserProfileTournamentBanner(BaseModel): + tournament_id: int + id: Optional[int] = None + image: Optional[str] = None + image_2_x: Optional[str] = Field(default=None, alias="image@2x") + + +class UserBadge(BaseModel): + awarded_at: datetime + description: str + image_url: str + url: str + + +class UserAccountHistory(BaseModel): + id: int + timestamp: datetime + length: int + permanent: bool + type: UserAccountHistoryType + description: Optional[str] = None + + +class UserGradeCounts(BaseModel): + ssh: Optional[int] = None + """Number of Silver SS ranks achieved.""" + ss: Optional[int] = None + """Number of SS ranks achieved.""" + sh: Optional[int] = None + """Number of Silver S ranks achieved.""" + s: Optional[int] = None + """Number of S ranks achieved.""" + a: Optional[int] = None + """Number of A ranks achieved.""" + + @classmethod + def _from_api_v1(cls, data: Any) -> UserGradeCounts: + return cls.model_validate( + { + "ss": cast_int(data["count_rank_ss"]), + "ssh": cast_int(data["count_rank_ssh"]), + "s": cast_int(data["count_rank_s"]), + "sh": cast_int(data["count_rank_sh"]), + "a": cast_int(data["count_rank_a"]), + }, + ) + + +class UserGroup(BaseModel): + id: int + identifier: str + name: str + short_name: str + has_listing: bool + has_playmodes: bool + is_probationary: bool + colour: Optional[str] = None + playmodes: Optional[list[Gamemode]] = None + description: Optional[str] = None + + +class UserStatsVariant(BaseModel): + mode: Gamemode + variant: str + pp: float + country_rank: Optional[int] = None + global_rank: Optional[int] = None + + +class UserStats(BaseModel): + """Fields are marked as optional since they might be missing from rankings other than performance.""" + + ranked_score: Optional[int] = None + play_count: Optional[int] = None + grade_counts: Optional[UserGradeCounts] = None + total_hits: Optional[int] = None + is_ranked: Optional[bool] = None + total_score: Optional[int] = None + level: Optional[UserLevel] = None + hit_accuracy: Optional[float] = None + play_time: Optional[int] = None + pp: Optional[float] = None + pp_exp: Optional[float] = None + replays_watched_by_others: Optional[int] = None + maximum_combo: Optional[int] = None + global_rank: Optional[int] = None + global_rank_exp: Optional[int] = None + country_rank: Optional[int] = None + user: Optional[User] = None + count_300: Optional[int] = None + count_100: Optional[int] = None + count_50: Optional[int] = None + count_miss: Optional[int] = None + variants: Optional[list[UserStatsVariant]] = None + + @computed_field # type: ignore + @property + def pp_per_playtime(self) -> float: + r"""PP per playtime. + + :return: PP per playtime + :rtype: float + """ + if not self.play_time or not self.pp: + return 0 + return self.pp / self.play_time * 3600 + + @classmethod + def _from_api_v1(cls, data: Any) -> UserStats: + """Some fields can be None, we want to force them to cast to a value.""" + return cls.model_validate( + { + "level": UserLevel._from_api_v1(data), + "pp": cast_float(data["pp_raw"]), + "global_rank": cast_int(data["pp_rank"]), + "country_rank": cast_int(data["pp_country_rank"]), + "ranked_score": cast_int(data["ranked_score"]), + "hit_accuracy": cast_float(data["accuracy"]), + "play_count": cast_int(data["playcount"]), + "play_time": cast_int(data["total_seconds_played"]), + "total_score": cast_int(data["total_score"]), + "total_hits": cast_int(data["count300"]) + + cast_int(data["count100"]) + + cast_int(data["count50"]), + "is_ranked": cast_float(data["pp_raw"]) != 0, + "grade_counts": UserGradeCounts._from_api_v1(data), + "count_300": cast_int(data["count300"]), + "count_100": cast_int(data["count100"]), + "count_50": cast_int(data["count50"]), + }, + ) + + +class UserAchievmement(BaseModel): + achieved_at: datetime + achievement_id: int + + +class User(BaseModel): + avatar_url: str + country_code: str + id: int + username: str + default_group: Optional[str] = None + is_active: Optional[bool] = None + is_bot: Optional[bool] = None + is_online: Optional[bool] = None + is_supporter: Optional[bool] = None + pm_friends_only: Optional[bool] = None + profile_colour: Optional[str] = None + is_deleted: Optional[bool] = None + last_visit: Optional[datetime] = None + discord: Optional[str] = None + has_supported: Optional[bool] = None + interests: Optional[str] = None + join_date: Optional[datetime] = None + kudosu: Optional[UserKudosu] = None + location: Optional[str] = None + max_blocks: Optional[int] = None + max_friends: Optional[int] = None + occupation: Optional[str] = None + playmode: Optional[Gamemode] = None + playstyle: Optional[list[str]] = None + post_count: Optional[int] = None + profile_order: Optional[list[str]] = None + title: Optional[str] = None + twitter: Optional[str] = None + website: Optional[str] = None + country: Optional[Country] = None + cover: Optional[UserProfileCover] = None + is_restricted: Optional[bool] = None + account_history: Optional[list[UserAccountHistory]] = None + active_tournament_banner: Optional[UserProfileTournamentBanner] = None + badges: Optional[list[UserBadge]] = None + beatmap_playcounts_count: Optional[int] = None + favourite_beatmapset_count: Optional[int] = None + follower_count: Optional[int] = None + graveyard_beatmapset_count: Optional[int] = None + groups: Optional[list[UserGroup]] = None + loved_beatmapset_count: Optional[int] = None + monthly_playcounts: Optional[list[TimestampedCount]] = None + page: Optional[HTMLBody] = None + pending_beatmapset_count: Optional[int] = None + previous_usernames: Optional[list[str]] = None + ranked_beatmapset_count: Optional[int] = None + replays_watched_counts: Optional[list[TimestampedCount]] = None + scores_best_count: Optional[int] = None + scores_first_count: Optional[int] = None + scores_recent_count: Optional[int] = None + statistics: Optional[UserStats] = None + support_level: Optional[int] = None + user_achievements: Optional[list[UserAchievmement]] = None + rank_history: Optional[UserRankHistoryElement] = None + rank_highest: Optional[UserRankHighest] = None + + @computed_field # type: ignore + @property + def url(self) -> str: + return f"https://osu.ppy.sh/users/{self.id}" + + @classmethod + def _from_api_v1(cls, data: Any) -> User: + return cls.model_validate( + { + "avatar_url": f"https://s.ppy.sh/a/{data['user_id']}", + "country_code": data["country"], + "id": data["user_id"], + "username": data["username"], + "join_date": data["join_date"], + "statistics": UserStats._from_api_v1(data), + }, + ) + + +UserStats.model_rebuild()