diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6f3bb72..0da42b7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,6 +40,10 @@ repos: rev: 1.6.0 hooks: - id: poetry-check + - repo: https://github.com/aio-libs/sort-all + rev: v1.2.0 + hooks: + - id: sort-all default_language_version: python: python3.9 diff --git a/aiosu/__init__.py b/aiosu/__init__.py index 5e84319..9941a43 100644 --- a/aiosu/__init__.py +++ b/aiosu/__init__.py @@ -20,8 +20,8 @@ "events", "exceptions", "helpers", - "utils", "models", + "utils", "v1", "v2", ) diff --git a/aiosu/events.py b/aiosu/events.py index 7b357a8..75d45e8 100644 --- a/aiosu/events.py +++ b/aiosu/events.py @@ -12,10 +12,10 @@ from .models import OAuthToken __all__ = ( - "Eventable", "BaseEvent", "ClientAddEvent", "ClientUpdateEvent", + "Eventable", ) diff --git a/aiosu/helpers.py b/aiosu/helpers.py index e732a8d..8128624 100644 --- a/aiosu/helpers.py +++ b/aiosu/helpers.py @@ -16,8 +16,8 @@ T = TypeVar("T") __all__ = ( - "from_list", "add_param", + "from_list", ) diff --git a/aiosu/models/artist.py b/aiosu/models/artist.py index 38a206e..e51b110 100644 --- a/aiosu/models/artist.py +++ b/aiosu/models/artist.py @@ -13,9 +13,9 @@ from .common import CursorModel __all__ = ( - "ArtistTrack", "ArtistResponse", "ArtistSortType", + "ArtistTrack", ) ArtistSortType = Literal[ diff --git a/aiosu/models/beatmap.py b/aiosu/models/beatmap.py index 42e96dc..200a99e 100644 --- a/aiosu/models/beatmap.py +++ b/aiosu/models/beatmap.py @@ -1,605 +1,605 @@ -""" -This module contains models for Beatmap objects. -""" -from __future__ import annotations - -from collections.abc import Mapping -from datetime import datetime -from enum import Enum -from enum import unique -from functools import cached_property -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 .base import cast_int -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", - "UserBeatmapType", - "BeatmapPackType", - "BeatmapPackUserCompletion", - "BeatmapPack", - "BeatmapPacksResponse", -) - -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", -] - -UserBeatmapType = Literal["favourite", "graveyard", "loved", "ranked", "pending"] - -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) -> BeatmapRankStatus: - 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.") - - -@unique -class BeatmapPackType(Enum): - STANDARD = ("S", "Standard") - FEATURED = ("F", "Featured Artist") - TOURNAMENT = ("P", "Tournament") - LOVED = ("L", "Project Loved") - CHART = ("R", "Spotlights") - THEME = ("T", "Theme") - ARTIST = ("A", "Artist/Album") - - def __init__(self, tag: str, description: str) -> None: - self.tag = tag - self.description = description - - @classmethod - def from_tag(cls, tag: str) -> BeatmapPackType: - beatmap_pack_type = next((x for x in cls if x.tag == tag), None) - if beatmap_pack_type is None: - raise ValueError(f"BeatmapPackType {tag} does not exist.") - return beatmap_pack_type - - def __str__(self) -> str: - return self.name.lower() - - @classmethod - def _missing_(cls, query: object) -> BeatmapPackType: - if isinstance(query, cls): - return query - if isinstance(query, str): - query = query.upper() - try: - return BeatmapPackType[query] - except KeyError: - return cls.from_tag(query) - - raise ValueError(f"BeatmapPackType {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: Mapping[str, object]) -> 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: Mapping[str, object]) -> BeatmapCovers: - return cls.from_beatmapset_id(cast_int(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 - - @property - def discussion_url(self) -> str: - return f"https://osu.ppy.sh/beatmapsets/{self.beatmapset_id}/discussion/{self.id}/general" - - @computed_field # type: ignore - @cached_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 - - @model_validator(mode="before") - @classmethod - def _set_url(cls, values: dict[str, object]) -> dict[str, object]: - 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 - - @classmethod - def _from_api_v1(cls, data: Mapping[str, object]) -> Beatmap: - return cls.model_validate( - { - "beatmapset_id": data["beatmapset_id"], - "difficulty_rating": data["difficultyrating"], - "id": data["beatmap_id"], - "mode": cast_int(data["mode"]), - "status": cast_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: Mapping[str, object]) -> 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": cast_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 BeatmapPackUserCompletion(BaseModel): - beatmapset_ids: list[int] - completed: bool - - -class BeatmapPack(BaseModel): - author: str - date: datetime - name: str - no_diff_reduction: bool - tag: str - url: str - ruleset_id: Optional[int] = None - beatmapsets: Optional[list[Beatmapset]] = None - user_completion_data: Optional[BeatmapPackUserCompletion] = None - - @property - def mode(self) -> Optional[Gamemode]: - if self.ruleset_id is None: - return None - return Gamemode(self.ruleset_id) - - @property - def pack_type(self) -> BeatmapPackType: - return BeatmapPackType.from_tag(self.tag[0]) - - @property - def id(self) -> int: - return int(self.tag[1:]) - - -class BeatmapPacksResponse(CursorModel): - beatmap_packs: list[BeatmapPack] - - -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, object]) -> dict[str, object]: - if isinstance(values["reviews_config"], Mapping): - 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 collections.abc import Mapping +from datetime import datetime +from enum import Enum +from enum import unique +from functools import cached_property +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 .base import cast_int +from .common import CurrentUserAttributes +from .common import CursorModel +from .gamemode import Gamemode +from .user import User + +__all__ = ( + "Beatmap", + "BeatmapAvailability", + "BeatmapCovers", + "BeatmapDescription", + "BeatmapDifficultyAttributes", + "BeatmapFailtimes", + "BeatmapGenre", + "BeatmapHype", + "BeatmapLanguage", + "BeatmapNominations", + "BeatmapPack", + "BeatmapPackType", + "BeatmapPackUserCompletion", + "BeatmapPacksResponse", + "BeatmapRankStatus", + "BeatmapUserPlaycount", + "Beatmapset", + "BeatmapsetDiscussion", + "BeatmapsetDiscussionPost", + "BeatmapsetDiscussionPostResponse", + "BeatmapsetDiscussionResponse", + "BeatmapsetDiscussionVoteResponse", + "BeatmapsetDisscussionType", + "BeatmapsetEvent", + "BeatmapsetEventComment", + "BeatmapsetEventType", + "BeatmapsetRequestStatus", + "BeatmapsetSearchResponse", + "BeatmapsetVoteEvent", + "UserBeatmapType", +) + +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", +] + +UserBeatmapType = Literal["favourite", "graveyard", "loved", "ranked", "pending"] + +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) -> BeatmapRankStatus: + 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.") + + +@unique +class BeatmapPackType(Enum): + STANDARD = ("S", "Standard") + FEATURED = ("F", "Featured Artist") + TOURNAMENT = ("P", "Tournament") + LOVED = ("L", "Project Loved") + CHART = ("R", "Spotlights") + THEME = ("T", "Theme") + ARTIST = ("A", "Artist/Album") + + def __init__(self, tag: str, description: str) -> None: + self.tag = tag + self.description = description + + @classmethod + def from_tag(cls, tag: str) -> BeatmapPackType: + beatmap_pack_type = next((x for x in cls if x.tag == tag), None) + if beatmap_pack_type is None: + raise ValueError(f"BeatmapPackType {tag} does not exist.") + return beatmap_pack_type + + def __str__(self) -> str: + return self.name.lower() + + @classmethod + def _missing_(cls, query: object) -> BeatmapPackType: + if isinstance(query, cls): + return query + if isinstance(query, str): + query = query.upper() + try: + return BeatmapPackType[query] + except KeyError: + return cls.from_tag(query) + + raise ValueError(f"BeatmapPackType {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: Mapping[str, object]) -> 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: Mapping[str, object]) -> BeatmapCovers: + return cls.from_beatmapset_id(cast_int(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 + + @property + def discussion_url(self) -> str: + return f"https://osu.ppy.sh/beatmapsets/{self.beatmapset_id}/discussion/{self.id}/general" + + @computed_field # type: ignore + @cached_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 + + @model_validator(mode="before") + @classmethod + def _set_url(cls, values: dict[str, object]) -> dict[str, object]: + 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 + + @classmethod + def _from_api_v1(cls, data: Mapping[str, object]) -> Beatmap: + return cls.model_validate( + { + "beatmapset_id": data["beatmapset_id"], + "difficulty_rating": data["difficultyrating"], + "id": data["beatmap_id"], + "mode": cast_int(data["mode"]), + "status": cast_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: Mapping[str, object]) -> 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": cast_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 BeatmapPackUserCompletion(BaseModel): + beatmapset_ids: list[int] + completed: bool + + +class BeatmapPack(BaseModel): + author: str + date: datetime + name: str + no_diff_reduction: bool + tag: str + url: str + ruleset_id: Optional[int] = None + beatmapsets: Optional[list[Beatmapset]] = None + user_completion_data: Optional[BeatmapPackUserCompletion] = None + + @property + def mode(self) -> Optional[Gamemode]: + if self.ruleset_id is None: + return None + return Gamemode(self.ruleset_id) + + @property + def pack_type(self) -> BeatmapPackType: + return BeatmapPackType.from_tag(self.tag[0]) + + @property + def id(self) -> int: + return int(self.tag[1:]) + + +class BeatmapPacksResponse(CursorModel): + beatmap_packs: list[BeatmapPack] + + +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, object]) -> dict[str, object]: + if isinstance(values["reviews_config"], Mapping): + 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/changelog.py b/aiosu/models/changelog.py index fe26318..730242f 100644 --- a/aiosu/models/changelog.py +++ b/aiosu/models/changelog.py @@ -16,13 +16,13 @@ __all__ = ( "Build", "ChangelogEntry", + "ChangelogEntryType", + "ChangelogListing", + "ChangelogMessageFormat", + "ChangelogSearch", "GithubUser", "UpdateStream", "Version", - "ChangelogListing", - "ChangelogSearch", - "ChangelogMessageFormat", - "ChangelogEntryType", ) ChangelogMessageFormat = Literal["markdown", "html"] diff --git a/aiosu/models/chat.py b/aiosu/models/chat.py index 9514000..21b9b67 100644 --- a/aiosu/models/chat.py +++ b/aiosu/models/chat.py @@ -14,13 +14,13 @@ __all__ = ( "ChatChannel", - "ChatMessage", - "ChatMessageCreateResponse", + "ChatChannelResponse", "ChatChannelType", "ChatIncludeType", + "ChatMessage", + "ChatMessageCreateResponse", "ChatUpdateResponse", "ChatUserSilence", - "ChatChannelResponse", ) ChatChannelType = Literal[ diff --git a/aiosu/models/comment.py b/aiosu/models/comment.py index b4acbf6..ced5e77 100644 --- a/aiosu/models/comment.py +++ b/aiosu/models/comment.py @@ -13,10 +13,10 @@ from .user import User __all__ = ( - "Commentable", "Comment", "CommentBundle", "CommentSortType", + "Commentable", "CommentableType", ) diff --git a/aiosu/models/common.py b/aiosu/models/common.py index a52a77e..07ba6cc 100644 --- a/aiosu/models/common.py +++ b/aiosu/models/common.py @@ -1,140 +1,140 @@ -""" -This module contains models for miscellaneous objects. -""" -from __future__ import annotations - -from collections.abc import Coroutine -from datetime import datetime -from functools import cached_property -from functools import partial -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", - "SortType", - "ScoreType", - "BeatmapScoreboardType", - "HTMLBody", -) - - -SortType = Literal["id_asc", "id_desc"] -ScoreType = Literal[ - "solo_score", - "score_best_osu", - "score_best_taiko", - "score_best_fruits", - "score_best_mania", - "score_osu", - "score_taiko", - "score_fruits", - "score_mania", -] -BeatmapScoreboardType = Literal["global", "country", "friend"] - - -class TimestampedCount(BaseModel): - start_date: datetime - count: int - - @field_validator("start_date", mode="before") - @classmethod - def _date_validate(cls, v: object) -> datetime: - if isinstance(v, str): - return datetime.strptime(v, "%Y-%m-%d") - if isinstance(v, datetime): - return v - - raise ValueError(f"{v} is not a valid value.") - - -class Achievement(BaseModel): - id: int - name: str - slug: str - description: str - grouping: str - icon_url: str - ordering: int - mode: Optional[Gamemode] = None - instructions: Optional[str] = None - - -class Country(BaseModel): - code: str - name: str - - @computed_field # type: ignore - @cached_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: ScoreType - - -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[object, object, 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 collections.abc import Coroutine +from datetime import datetime +from functools import cached_property +from functools import partial +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", + "BeatmapScoreboardType", + "Country", + "CurrentUserAttributes", + "CursorModel", + "HTMLBody", + "ScoreType", + "SortType", + "TimestampedCount", +) + + +SortType = Literal["id_asc", "id_desc"] +ScoreType = Literal[ + "solo_score", + "score_best_osu", + "score_best_taiko", + "score_best_fruits", + "score_best_mania", + "score_osu", + "score_taiko", + "score_fruits", + "score_mania", +] +BeatmapScoreboardType = Literal["global", "country", "friend"] + + +class TimestampedCount(BaseModel): + start_date: datetime + count: int + + @field_validator("start_date", mode="before") + @classmethod + def _date_validate(cls, v: object) -> datetime: + if isinstance(v, str): + return datetime.strptime(v, "%Y-%m-%d") + if isinstance(v, datetime): + return v + + raise ValueError(f"{v} is not a valid value.") + + +class Achievement(BaseModel): + id: int + name: str + slug: str + description: str + grouping: str + icon_url: str + ordering: int + mode: Optional[Gamemode] = None + instructions: Optional[str] = None + + +class Country(BaseModel): + code: str + name: str + + @computed_field # type: ignore + @cached_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: ScoreType + + +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[object, object, CursorModel]]] = Field( + default=None, + exclude=True, + ) + """Partial function to get the next page of results.""" diff --git a/aiosu/models/event.py b/aiosu/models/event.py index 23e47ed..ba8ceca 100644 --- a/aiosu/models/event.py +++ b/aiosu/models/event.py @@ -23,9 +23,9 @@ "Event", "EventBeatmap", "EventBeatmapset", - "EventUser", - "EventType", "EventResponse", + "EventType", + "EventUser", ) EventType = Literal[ diff --git a/aiosu/models/files/replay.py b/aiosu/models/files/replay.py index b915740..436a58d 100644 --- a/aiosu/models/files/replay.py +++ b/aiosu/models/files/replay.py @@ -1,125 +1,125 @@ -""" -This module contains models for replays. -""" -from __future__ import annotations - -from datetime import datetime -from enum import IntFlag -from enum import unique -from typing import Optional - -from pydantic import model_validator - -from ..base import BaseModel -from ..gamemode import Gamemode -from ..lazer import LazerReplayData -from ..mods import Mod -from ..mods import Mods -from ..score import ScoreStatistics - -__all__ = ( - "ReplayFile", - "ReplayKey", - "ReplayLifebarEvent", - "ReplayEvent", -) - - -def _parse_skip_offset(events: list[ReplayEvent], mods: Mods) -> int: - """Parse the skip offset from a list of replay events.""" - if len(events) < 2: - return 0 - if Mod.Autoplay in mods: - return events[1].time - 100000 - return events[1].time - - -def _parse_rng_seed(events: list[ReplayEvent]) -> int: - """Parse the RNG seed from a list of replay events.""" - if len(events) < 2: - return 0 - return int(events[-1].keys) - - -@unique -class ReplayKey(IntFlag): - """Replay key data.""" - - K1 = 1 << 0 - K2 = 1 << 1 - K3 = 1 << 2 - K4 = 1 << 3 - K5 = 1 << 4 - K6 = 1 << 5 - K7 = 1 << 6 - K8 = 1 << 7 - K9 = 1 << 8 - K10 = 1 << 9 - K11 = 1 << 10 - K12 = 1 << 11 - K13 = 1 << 12 - K14 = 1 << 13 - K15 = 1 << 14 - K16 = 1 << 15 - K17 = 1 << 16 - K18 = 1 << 17 - - -class ReplayLifebarEvent(BaseModel): - """Replay lifebar event data.""" - - time: int - hp: float - - -class ReplayEvent(BaseModel): - """Replay event data.""" - - time: int - x: float - y: float - keys: ReplayKey - - -class ReplayFile(BaseModel): - """Replay file data.""" - - mode: Gamemode - version: int - map_md5: str - player_name: str - played_at: datetime - replay_md5: str - online_id: int - score: int - max_combo: int - perfect_combo: bool - mods: Mods - statistics: ScoreStatistics - replay_data: list[ReplayEvent] - lifebar_data: list[ReplayLifebarEvent] - mod_extras: Optional[float] = None - skip_offset: Optional[int] = None - rng_seed: Optional[int] = None - lazer_replay_data: Optional[LazerReplayData] = None - - def __repr__(self) -> str: - return f"" - - def __str__(self) -> str: - return f"{self.player_name} {self.played_at} {self.map_md5} +{self.mods}" - - @model_validator(mode="after") - def _add_skip_offset(self) -> ReplayFile: - if not self.skip_offset: - self.skip_offset = _parse_skip_offset( - self.replay_data, - self.mods, - ) - return self - - @model_validator(mode="after") - def _add_rng_seed(self) -> ReplayFile: - if not self.rng_seed and self.version >= 2013_03_19: - self.rng_seed = _parse_rng_seed(self.replay_data) - return self +""" +This module contains models for replays. +""" +from __future__ import annotations + +from datetime import datetime +from enum import IntFlag +from enum import unique +from typing import Optional + +from pydantic import model_validator + +from ..base import BaseModel +from ..gamemode import Gamemode +from ..lazer import LazerReplayData +from ..mods import Mod +from ..mods import Mods +from ..score import ScoreStatistics + +__all__ = ( + "ReplayEvent", + "ReplayFile", + "ReplayKey", + "ReplayLifebarEvent", +) + + +def _parse_skip_offset(events: list[ReplayEvent], mods: Mods) -> int: + """Parse the skip offset from a list of replay events.""" + if len(events) < 2: + return 0 + if Mod.Autoplay in mods: + return events[1].time - 100000 + return events[1].time + + +def _parse_rng_seed(events: list[ReplayEvent]) -> int: + """Parse the RNG seed from a list of replay events.""" + if len(events) < 2: + return 0 + return int(events[-1].keys) + + +@unique +class ReplayKey(IntFlag): + """Replay key data.""" + + K1 = 1 << 0 + K2 = 1 << 1 + K3 = 1 << 2 + K4 = 1 << 3 + K5 = 1 << 4 + K6 = 1 << 5 + K7 = 1 << 6 + K8 = 1 << 7 + K9 = 1 << 8 + K10 = 1 << 9 + K11 = 1 << 10 + K12 = 1 << 11 + K13 = 1 << 12 + K14 = 1 << 13 + K15 = 1 << 14 + K16 = 1 << 15 + K17 = 1 << 16 + K18 = 1 << 17 + + +class ReplayLifebarEvent(BaseModel): + """Replay lifebar event data.""" + + time: int + hp: float + + +class ReplayEvent(BaseModel): + """Replay event data.""" + + time: int + x: float + y: float + keys: ReplayKey + + +class ReplayFile(BaseModel): + """Replay file data.""" + + mode: Gamemode + version: int + map_md5: str + player_name: str + played_at: datetime + replay_md5: str + online_id: int + score: int + max_combo: int + perfect_combo: bool + mods: Mods + statistics: ScoreStatistics + replay_data: list[ReplayEvent] + lifebar_data: list[ReplayLifebarEvent] + mod_extras: Optional[float] = None + skip_offset: Optional[int] = None + rng_seed: Optional[int] = None + lazer_replay_data: Optional[LazerReplayData] = None + + def __repr__(self) -> str: + return f"" + + def __str__(self) -> str: + return f"{self.player_name} {self.played_at} {self.map_md5} +{self.mods}" + + @model_validator(mode="after") + def _add_skip_offset(self) -> ReplayFile: + if not self.skip_offset: + self.skip_offset = _parse_skip_offset( + self.replay_data, + self.mods, + ) + return self + + @model_validator(mode="after") + def _add_rng_seed(self) -> ReplayFile: + if not self.rng_seed and self.version >= 2013_03_19: + self.rng_seed = _parse_rng_seed(self.replay_data) + return self diff --git a/aiosu/models/forum.py b/aiosu/models/forum.py index 619f890..06e0840 100644 --- a/aiosu/models/forum.py +++ b/aiosu/models/forum.py @@ -12,13 +12,13 @@ from .common import HTMLBody __all__ = [ + "ForumCreateTopicResponse", + "ForumPoll", + "ForumPollOption", "ForumPost", "ForumTopic", "ForumTopicResponse", "ForumTopicType", - "ForumPoll", - "ForumPollOption", - "ForumCreateTopicResponse", ] ForumTopicType = Literal[ diff --git a/aiosu/models/kudosu.py b/aiosu/models/kudosu.py index 6b5ec04..865a91a 100644 --- a/aiosu/models/kudosu.py +++ b/aiosu/models/kudosu.py @@ -12,8 +12,8 @@ __all__ = ( "KudosuAction", "KudosuGiver", - "KudosuPost", "KudosuHistory", + "KudosuPost", ) KudosuAction = Literal[ diff --git a/aiosu/models/lazer.py b/aiosu/models/lazer.py index 5553991..55d9717 100644 --- a/aiosu/models/lazer.py +++ b/aiosu/models/lazer.py @@ -1,221 +1,221 @@ -""" -This module contains models for lazer specific data. -""" -from __future__ import annotations - -from datetime import datetime -from functools import cached_property -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 .common import ScoreType -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, object] = 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 - - @property - def count_300(self) -> int: - return self.great - - @property - def count_100(self) -> int: - return self.ok - - @property - def count_50(self) -> int: - return self.meh - - @property - def count_miss(self) -> int: - return self.miss - - @property - def count_geki(self) -> int: - return self.perfect - - @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: ScoreType - current_user_attributes: CurrentUserAttributes - beatmap: Beatmap - beatmapset: Beatmapset - user: User - build_id: Optional[int] = None - legacy_score_id: Optional[int] = None - legacy_total_score: 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 - - @property - def created_at(self) -> datetime: - return self.ended_at - - @property - def score(self) -> int: - return self.total_score - - @property - def has_replay(self) -> bool: - return self.replay - - @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}" - ) - - @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" - ) - - @computed_field # type: ignore - @cached_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 - @cached_property - def mode(self) -> Gamemode: - return Gamemode(self.ruleset_id) - - @computed_field # type: ignore - @cached_property - def mods_str(self) -> str: - return "".join(str(mod) for mod in self.mods) - - @model_validator(mode="before") - @classmethod - def _fail_rank(cls, values: dict[str, object]) -> dict[str, object]: - 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 functools import cached_property +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 .common import ScoreType +from .gamemode import Gamemode +from .score import ScoreWeight +from .user import User + +__all__ = ( + "LazerMod", + "LazerReplayData", + "LazerScore", + "LazerScoreStatistics", +) + + +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, object] = 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 + + @property + def count_300(self) -> int: + return self.great + + @property + def count_100(self) -> int: + return self.ok + + @property + def count_50(self) -> int: + return self.meh + + @property + def count_miss(self) -> int: + return self.miss + + @property + def count_geki(self) -> int: + return self.perfect + + @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: ScoreType + current_user_attributes: CurrentUserAttributes + beatmap: Beatmap + beatmapset: Beatmapset + user: User + build_id: Optional[int] = None + legacy_score_id: Optional[int] = None + legacy_total_score: 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 + + @property + def created_at(self) -> datetime: + return self.ended_at + + @property + def score(self) -> int: + return self.total_score + + @property + def has_replay(self) -> bool: + return self.replay + + @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}" + ) + + @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" + ) + + @computed_field # type: ignore + @cached_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 + @cached_property + def mode(self) -> Gamemode: + return Gamemode(self.ruleset_id) + + @computed_field # type: ignore + @cached_property + def mods_str(self) -> str: + return "".join(str(mod) for mod in self.mods) + + @model_validator(mode="before") + @classmethod + def _fail_rank(cls, values: dict[str, object]) -> dict[str, object]: + if not values["passed"]: + values["rank"] = "F" + return values diff --git a/aiosu/models/legacy/match.py b/aiosu/models/legacy/match.py index 408b40f..e716607 100644 --- a/aiosu/models/legacy/match.py +++ b/aiosu/models/legacy/match.py @@ -18,12 +18,12 @@ __all__ = ( - "MatchTeam", + "Match", + "MatchGame", + "MatchScore", "MatchScoringType", + "MatchTeam", "MatchTeamType", - "MatchScore", - "MatchGame", - "Match", ) diff --git a/aiosu/models/mods.py b/aiosu/models/mods.py index a46a594..bb0ecbc 100644 --- a/aiosu/models/mods.py +++ b/aiosu/models/mods.py @@ -18,10 +18,10 @@ from typing import Union __all__ = ( + "FreemodAllowed", + "KeyMod", "Mod", "Mods", - "KeyMod", - "FreemodAllowed", "ScoreIncreaseMods", "SpeedChangingMods", ) diff --git a/aiosu/models/multiplayer.py b/aiosu/models/multiplayer.py index c61869f..fcb84c6 100644 --- a/aiosu/models/multiplayer.py +++ b/aiosu/models/multiplayer.py @@ -20,23 +20,23 @@ from .user import User __all__ = ( - "MultiplayerScoreSortType", - "MultiplayerScoresResponse", - "MultiplayerScore", - "MultiplayerScoresAround", - "MultiplayerMatch", - "MultiplayerEventType", "MultiplayerEvent", + "MultiplayerEventType", + "MultiplayerLeaderboardItem", + "MultiplayerLeaderboardResponse", + "MultiplayerMatch", "MultiplayerMatchResponse", "MultiplayerMatchesResponse", - "MultiplayerRoomMode", + "MultiplayerQueueMode", "MultiplayerRoom", - "MultiplayerRoomsResponse", "MultiplayerRoomCategory", + "MultiplayerRoomMode", "MultiplayerRoomTypeGroup", - "MultiplayerLeaderboardResponse", - "MultiplayerLeaderboardItem", - "MultiplayerQueueMode", + "MultiplayerRoomsResponse", + "MultiplayerScore", + "MultiplayerScoreSortType", + "MultiplayerScoresAround", + "MultiplayerScoresResponse", ) MultiplayerScoreSortType = Literal["score_asc", "score_desc"] diff --git a/aiosu/models/news.py b/aiosu/models/news.py index 179af5a..b01fd97 100644 --- a/aiosu/models/news.py +++ b/aiosu/models/news.py @@ -12,9 +12,9 @@ __all__ = ( - "NewsPost", "Navigation", "NewsListing", + "NewsPost", "NewsSearch", "NewsSortType", ) diff --git a/aiosu/models/performance.py b/aiosu/models/performance.py index 0bcc709..a40dd81 100644 --- a/aiosu/models/performance.py +++ b/aiosu/models/performance.py @@ -8,11 +8,11 @@ from .base import BaseModel __all__ = ( - "PerformanceAttributes", + "CatchPerformanceAttributes", + "ManiaPerformanceAttributes", "OsuPerformanceAttributes", + "PerformanceAttributes", "TaikoPerformanceAttributes", - "ManiaPerformanceAttributes", - "CatchPerformanceAttributes", ) diff --git a/aiosu/models/rankings.py b/aiosu/models/rankings.py index 52cb862..b9e4d10 100644 --- a/aiosu/models/rankings.py +++ b/aiosu/models/rankings.py @@ -14,8 +14,8 @@ __all__ = ( "RankingFilter", - "RankingVariant", "RankingType", + "RankingVariant", "Rankings", ) diff --git a/aiosu/models/scopes.py b/aiosu/models/scopes.py index 3d570e0..55b6e05 100644 --- a/aiosu/models/scopes.py +++ b/aiosu/models/scopes.py @@ -7,9 +7,9 @@ from enum import unique __all__ = ( + "OWN_CLIENT_SCOPES", "Scopes", "VALID_CLIENT_SCOPES", - "OWN_CLIENT_SCOPES", ) diff --git a/aiosu/models/score.py b/aiosu/models/score.py index f01e6ab..da43768 100644 --- a/aiosu/models/score.py +++ b/aiosu/models/score.py @@ -1,263 +1,263 @@ -""" -This module contains models for Score objects. -""" -from __future__ import annotations - -from datetime import datetime -from functools import cached_property -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 .base import cast_int -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 collections.abc import Mapping - 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, object]) -> dict[str, object]: - # 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: Mapping[str, object]) -> 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""" - - @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}" - ) - - @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" - ) - - @computed_field # type: ignore - @cached_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) - - @model_validator(mode="before") - @classmethod - def _fail_rank(cls, values: dict[str, object]) -> dict[str, object]: - 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: Mapping[str, object], - 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": cast_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 functools import cached_property +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 .base import cast_int +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 collections.abc import Mapping + 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, object]) -> dict[str, object]: + # 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: Mapping[str, object]) -> 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""" + + @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}" + ) + + @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" + ) + + @computed_field # type: ignore + @cached_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) + + @model_validator(mode="before") + @classmethod + def _fail_rank(cls, values: dict[str, object]) -> dict[str, object]: + 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: Mapping[str, object], + 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": cast_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/search.py b/aiosu/models/search.py index 07de00f..b7a3886 100644 --- a/aiosu/models/search.py +++ b/aiosu/models/search.py @@ -12,9 +12,9 @@ from .wiki import WikiPage __all__ = ( - "SearchResult", - "SearchResponse", "SearchMode", + "SearchResponse", + "SearchResult", ) SearchMode = Literal["all", "user", "wiki_page"] diff --git a/aiosu/models/user.py b/aiosu/models/user.py index c62474e..5a9450e 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 collections.abc import Mapping -from datetime import datetime -from enum import Enum -from enum import unique -from functools import cached_property -from typing import Literal -from typing import Optional - -from pydantic import computed_field -from pydantic import Field - -from .base import BaseModel -from .base import cast_float -from .base import cast_int -from .common import Country -from .common import HTMLBody -from .common import TimestampedCount -from .gamemode import Gamemode - - -__all__ = ( - "User", - "UserAccountHistory", - "UserBadge", - "UserGradeCounts", - "UserGroup", - "UserKudosu", - "UserLevel", - "UserProfileCover", - "UserProfileTournamentBanner", - "UserQueryType", - "UserRankHistoryElement", - "UserStatsVariant", - "UserStats", - "UserAccountHistoryType", - "UserRankHighest", - "ManiaStatsVariantsType", -) - - -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) -> UserQueryType: - 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: int - - @classmethod - def _from_api_v1(cls, data: Mapping[str, object]) -> UserLevel: - level = cast_float(data["level"]) - current = int(level) - progress = (level - current) * 100 - return cls.model_validate({"current": current, "progress": int(progress)}) - - -class UserKudosu(BaseModel): - total: int - available: int - - -class UserRankHistoryElement(BaseModel): - mode: str - data: list[int] - - @computed_field # type: ignore - @cached_property - def average_gain(self) -> float: - r"""Average rank gain. - - :return: Average rank gain for a user - :rtype: float - """ - if not self.data: - return 0.0 - return (self.data[0] - 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 - image_2x_url: str = Field(alias="image@2x_url") - url: str - - -class UserAccountHistory(BaseModel): - id: int - timestamp: datetime - length: int - permanent: bool - type: UserAccountHistoryType - description: Optional[str] = None - - -class UserGradeCounts(BaseModel): - ssh: int - """Number of Silver SS ranks achieved.""" - ss: int - """Number of SS ranks achieved.""" - sh: int - """Number of Silver S ranks achieved.""" - s: int - """Number of S ranks achieved.""" - a: int - """Number of A ranks achieved.""" - - @classmethod - def _from_api_v1(cls, data: Mapping[str, object]) -> 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 - @cached_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.0 - return self.pp / self.play_time * 3600 - - @classmethod - def _from_api_v1(cls, data: Mapping[str, object]) -> 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: Mapping[str, object]) -> 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 collections.abc import Mapping +from datetime import datetime +from enum import Enum +from enum import unique +from functools import cached_property +from typing import Literal +from typing import Optional + +from pydantic import computed_field +from pydantic import Field + +from .base import BaseModel +from .base import cast_float +from .base import cast_int +from .common import Country +from .common import HTMLBody +from .common import TimestampedCount +from .gamemode import Gamemode + + +__all__ = ( + "ManiaStatsVariantsType", + "User", + "UserAccountHistory", + "UserAccountHistoryType", + "UserBadge", + "UserGradeCounts", + "UserGroup", + "UserKudosu", + "UserLevel", + "UserProfileCover", + "UserProfileTournamentBanner", + "UserQueryType", + "UserRankHighest", + "UserRankHistoryElement", + "UserStats", + "UserStatsVariant", +) + + +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) -> UserQueryType: + 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: int + + @classmethod + def _from_api_v1(cls, data: Mapping[str, object]) -> UserLevel: + level = cast_float(data["level"]) + current = int(level) + progress = (level - current) * 100 + return cls.model_validate({"current": current, "progress": int(progress)}) + + +class UserKudosu(BaseModel): + total: int + available: int + + +class UserRankHistoryElement(BaseModel): + mode: str + data: list[int] + + @computed_field # type: ignore + @cached_property + def average_gain(self) -> float: + r"""Average rank gain. + + :return: Average rank gain for a user + :rtype: float + """ + if not self.data: + return 0.0 + return (self.data[0] - 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 + image_2x_url: str = Field(alias="image@2x_url") + url: str + + +class UserAccountHistory(BaseModel): + id: int + timestamp: datetime + length: int + permanent: bool + type: UserAccountHistoryType + description: Optional[str] = None + + +class UserGradeCounts(BaseModel): + ssh: int + """Number of Silver SS ranks achieved.""" + ss: int + """Number of SS ranks achieved.""" + sh: int + """Number of Silver S ranks achieved.""" + s: int + """Number of S ranks achieved.""" + a: int + """Number of A ranks achieved.""" + + @classmethod + def _from_api_v1(cls, data: Mapping[str, object]) -> 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 + @cached_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.0 + return self.pp / self.play_time * 3600 + + @classmethod + def _from_api_v1(cls, data: Mapping[str, object]) -> 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: Mapping[str, object]) -> 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() diff --git a/aiosu/utils/accuracy.py b/aiosu/utils/accuracy.py index b1b0734..f9f5f84 100644 --- a/aiosu/utils/accuracy.py +++ b/aiosu/utils/accuracy.py @@ -14,10 +14,10 @@ from ..models.score import Score __all__ = [ + "CatchAccuracyCalculator", + "ManiaAccuracyCalculator", "OsuAccuracyCalculator", "TaikoAccuracyCalculator", - "ManiaAccuracyCalculator", - "CatchAccuracyCalculator", ] diff --git a/aiosu/utils/binary.py b/aiosu/utils/binary.py index fd3c513..fa7ea73 100644 --- a/aiosu/utils/binary.py +++ b/aiosu/utils/binary.py @@ -17,28 +17,28 @@ __all__ = ( "pack", "pack_byte", - "pack_short", - "pack_int", - "pack_long", - "pack_uleb128", - "pack_string", - "pack_timestamp", "pack_float16", "pack_float32", "pack_float64", + "pack_int", + "pack_long", "pack_replay_data", + "pack_short", + "pack_string", + "pack_timestamp", + "pack_uleb128", "unpack", "unpack_byte", - "unpack_short", - "unpack_int", - "unpack_long", - "unpack_uleb128", - "unpack_string", - "unpack_timestamp", "unpack_float16", "unpack_float32", "unpack_float64", + "unpack_int", + "unpack_long", "unpack_replay_data", + "unpack_short", + "unpack_string", + "unpack_timestamp", + "unpack_uleb128", ) diff --git a/aiosu/utils/performance.py b/aiosu/utils/performance.py index fe446c2..58f361b 100644 --- a/aiosu/utils/performance.py +++ b/aiosu/utils/performance.py @@ -26,10 +26,10 @@ __all__ = [ + "CatchPerformanceCalculator", + "ManiaPerformanceCalculator", "OsuPerformanceCalculator", "TaikoPerformanceCalculator", - "ManiaPerformanceCalculator", - "CatchPerformanceCalculator", ] OSU_BASE_MULTIPLIER = 1.14 diff --git a/aiosu/utils/replay.py b/aiosu/utils/replay.py index 25e7dcc..3ad04bf 100644 --- a/aiosu/utils/replay.py +++ b/aiosu/utils/replay.py @@ -33,8 +33,8 @@ __all__ = ( "parse_file", "parse_path", - "write_replay", "write_path", + "write_replay", )