diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 32a1e09..c29fdab 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -107,6 +107,6 @@ 80, 8080 ], - "postCreateCommand": "uv sync --dev && uv run alembic upgrade head && uv run pre-commit install && cd packages/msgpack_lazer_api && cargo check && cd ../../spectator-server && dotnet restore", + "postCreateCommand": "uv sync --dev && uv run alembic upgrade head && uv run pre-commit install && cd spectator-server && dotnet restore", "remoteUser": "vscode" } diff --git a/app/config.py b/app/config.py index 1761d7d..adb7794 100644 --- a/app/config.py +++ b/app/config.py @@ -266,18 +266,6 @@ STORAGE_SETTINGS='{ else: return "/" - # SignalR 设置 - signalr_negotiate_timeout: Annotated[ - int, - Field(default=30, description="SignalR 协商超时时间(秒)"), - "SignalR 服务器设置", - ] - signalr_ping_interval: Annotated[ - int, - Field(default=15, description="SignalR ping 间隔(秒)"), - "SignalR 服务器设置", - ] - # Fetcher 设置 fetcher_client_id: Annotated[ str, diff --git a/app/database/playlists.py b/app/database/playlists.py index b589e96..4eeb2ef 100644 --- a/app/database/playlists.py +++ b/app/database/playlists.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING from app.models.model import UTCBaseModel from app.models.mods import APIMod +from app.models.playlist import PlaylistItem from .beatmap import Beatmap, BeatmapResp @@ -21,8 +22,6 @@ from sqlmodel import ( from sqlmodel.ext.asyncio.session import AsyncSession if TYPE_CHECKING: - from app.models.multiplayer_hub import PlaylistItem - from .room import Room @@ -73,7 +72,7 @@ class Playlist(PlaylistBase, table=True): return result.one() @classmethod - async def from_hub(cls, playlist: "PlaylistItem", room_id: int, session: AsyncSession) -> "Playlist": + async def from_model(cls, playlist: PlaylistItem, room_id: int, session: AsyncSession) -> "Playlist": next_id = await cls.get_next_id_for_room(room_id, session=session) return cls( id=next_id, @@ -90,7 +89,7 @@ class Playlist(PlaylistBase, table=True): ) @classmethod - async def update(cls, playlist: "PlaylistItem", room_id: int, session: AsyncSession): + async def update(cls, playlist: PlaylistItem, room_id: int, session: AsyncSession): db_playlist = await session.exec(select(cls).where(cls.id == playlist.id, cls.room_id == room_id)) db_playlist = db_playlist.first() if db_playlist is None: @@ -107,8 +106,8 @@ class Playlist(PlaylistBase, table=True): await session.commit() @classmethod - async def add_to_db(cls, playlist: "PlaylistItem", room_id: int, session: AsyncSession): - db_playlist = await cls.from_hub(playlist, room_id, session) + async def add_to_db(cls, playlist: PlaylistItem, room_id: int, session: AsyncSession): + db_playlist = await cls.from_model(playlist, room_id, session) session.add(db_playlist) await session.commit() await session.refresh(db_playlist) diff --git a/app/database/room.py b/app/database/room.py index 2729e37..add0dbc 100644 --- a/app/database/room.py +++ b/app/database/room.py @@ -1,5 +1,4 @@ from datetime import datetime -from typing import TYPE_CHECKING from app.database.item_attempts_count import PlaylistAggregateScore from app.database.room_participated_user import RoomParticipatedUser @@ -32,9 +31,6 @@ from sqlmodel import ( ) from sqlmodel.ext.asyncio.session import AsyncSession -if TYPE_CHECKING: - from app.models.multiplayer_hub import ServerMultiplayerRoom - class RoomBase(SQLModel, UTCBaseModel): name: str = Field(index=True) @@ -163,25 +159,6 @@ class RoomResp(RoomBase): resp.current_user_score = await PlaylistAggregateScore.from_db(room.id, user.id, session) return resp - @classmethod - async def from_hub(cls, server_room: "ServerMultiplayerRoom") -> "RoomResp": - room = server_room.room - resp = cls( - id=room.room_id, - name=room.settings.name, - type=room.settings.match_type, - queue_mode=room.settings.queue_mode, - auto_skip=room.settings.auto_skip, - auto_start_duration=int(room.settings.auto_start_duration.total_seconds()), - status=server_room.status, - category=server_room.category, - # duration = room.settings.duration, - starts_at=server_room.start_at, - participant_count=len(room.users), - channel_id=server_room.room.channel_id or 0, - ) - return resp - class APIUploadedRoom(RoomBase): def to_room(self) -> Room: diff --git a/app/exception.py b/app/exception.py deleted file mode 100644 index 8510bf1..0000000 --- a/app/exception.py +++ /dev/null @@ -1,10 +0,0 @@ -from __future__ import annotations - - -class SignalRException(Exception): - pass - - -class InvokeException(SignalRException): - def __init__(self, message: str) -> None: - self.message = message diff --git a/app/interfaces/session_verification.py b/app/interfaces/session_verification.py deleted file mode 100644 index 2953086..0000000 --- a/app/interfaces/session_verification.py +++ /dev/null @@ -1,73 +0,0 @@ -""" -会话验证接口 - -基于osu-web的SessionVerificationInterface实现 -用于标准化会话验证行为 -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod - - -class SessionVerificationInterface(ABC): - """会话验证接口 - - 定义了会话验证所需的基本操作,参考osu-web的实现 - """ - - @classmethod - @abstractmethod - async def find_for_verification(cls, session_id: str) -> SessionVerificationInterface | None: - """根据会话ID查找会话用于验证 - - Args: - session_id: 会话ID - - Returns: - 会话实例或None - """ - pass - - @abstractmethod - def get_key(self) -> str: - """获取会话密钥/ID""" - pass - - @abstractmethod - def get_key_for_event(self) -> str: - """获取用于事件广播的会话密钥""" - pass - - @abstractmethod - def get_verification_method(self) -> str | None: - """获取当前验证方法 - - Returns: - 验证方法 ('totp', 'mail') 或 None - """ - pass - - @abstractmethod - def is_verified(self) -> bool: - """检查会话是否已验证""" - pass - - @abstractmethod - async def mark_verified(self) -> None: - """标记会话为已验证""" - pass - - @abstractmethod - async def set_verification_method(self, method: str) -> None: - """设置验证方法 - - Args: - method: 验证方法 ('totp', 'mail') - """ - pass - - @abstractmethod - def user_id(self) -> int | None: - """获取关联的用户ID""" - pass diff --git a/app/models/metadata_hub.py b/app/models/metadata_hub.py index 7188235..e69de29 100644 --- a/app/models/metadata_hub.py +++ b/app/models/metadata_hub.py @@ -1,157 +0,0 @@ -from __future__ import annotations - -from enum import IntEnum -from typing import ClassVar, Literal - -from app.models.signalr import SignalRUnionMessage, UserState - -from pydantic import BaseModel, Field - -TOTAL_SCORE_DISTRIBUTION_BINS = 13 - - -class _UserActivity(SignalRUnionMessage): ... - - -class ChoosingBeatmap(_UserActivity): - union_type: ClassVar[Literal[11]] = 11 - - -class _InGame(_UserActivity): - beatmap_id: int - beatmap_display_title: str - ruleset_id: int - ruleset_playing_verb: str - - -class InSoloGame(_InGame): - union_type: ClassVar[Literal[12]] = 12 - - -class InMultiplayerGame(_InGame): - union_type: ClassVar[Literal[23]] = 23 - - -class SpectatingMultiplayerGame(_InGame): - union_type: ClassVar[Literal[24]] = 24 - - -class InPlaylistGame(_InGame): - union_type: ClassVar[Literal[31]] = 31 - - -class PlayingDailyChallenge(_InGame): - union_type: ClassVar[Literal[52]] = 52 - - -class EditingBeatmap(_UserActivity): - union_type: ClassVar[Literal[41]] = 41 - beatmap_id: int - beatmap_display_title: str - - -class TestingBeatmap(EditingBeatmap): - union_type: ClassVar[Literal[43]] = 43 - - -class ModdingBeatmap(EditingBeatmap): - union_type: ClassVar[Literal[42]] = 42 - - -class WatchingReplay(_UserActivity): - union_type: ClassVar[Literal[13]] = 13 - score_id: int - player_name: str - beatmap_id: int - beatmap_display_title: str - - -class SpectatingUser(WatchingReplay): - union_type: ClassVar[Literal[14]] = 14 - - -class SearchingForLobby(_UserActivity): - union_type: ClassVar[Literal[21]] = 21 - - -class InLobby(_UserActivity): - union_type: ClassVar[Literal[22]] = 22 - room_id: int - room_name: str - - -class InDailyChallengeLobby(_UserActivity): - union_type: ClassVar[Literal[51]] = 51 - - -UserActivity = ( - ChoosingBeatmap - | InSoloGame - | WatchingReplay - | SpectatingUser - | SearchingForLobby - | InLobby - | InMultiplayerGame - | SpectatingMultiplayerGame - | InPlaylistGame - | EditingBeatmap - | ModdingBeatmap - | TestingBeatmap - | InDailyChallengeLobby - | PlayingDailyChallenge -) - - -class UserPresence(BaseModel): - activity: UserActivity | None = None - - status: OnlineStatus | None = None - - @property - def pushable(self) -> bool: - return self.status is not None and self.status != OnlineStatus.OFFLINE - - @property - def for_push(self) -> "UserPresence | None": - return UserPresence( - activity=self.activity, - status=self.status, - ) - - -class MetadataClientState(UserPresence, UserState): ... - - -class OnlineStatus(IntEnum): - OFFLINE = 0 # 隐身 - DO_NOT_DISTURB = 1 - ONLINE = 2 - - -class DailyChallengeInfo(BaseModel): - room_id: int - - -class MultiplayerPlaylistItemStats(BaseModel): - playlist_item_id: int = 0 - total_score_distribution: list[int] = Field( - default_factory=list, - min_length=TOTAL_SCORE_DISTRIBUTION_BINS, - max_length=TOTAL_SCORE_DISTRIBUTION_BINS, - ) - cumulative_score: int = 0 - last_processed_score_id: int = 0 - - -class MultiplayerRoomStats(BaseModel): - room_id: int - playlist_item_stats: dict[int, MultiplayerPlaylistItemStats] = Field(default_factory=dict) - - -class MultiplayerRoomScoreSetEvent(BaseModel): - room_id: int - playlist_item_id: int - score_id: int - user_id: int - total_score: int - new_rank: int | None = None diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py index 7df4949..e69de29 100644 --- a/app/models/multiplayer_hub.py +++ b/app/models/multiplayer_hub.py @@ -1,840 +0,0 @@ -from __future__ import annotations - -from abc import ABC, abstractmethod -import asyncio -from collections.abc import Awaitable, Callable -from dataclasses import dataclass -from datetime import datetime, timedelta -from enum import IntEnum -from typing import ( - TYPE_CHECKING, - Annotated, - Any, - ClassVar, - Literal, - TypedDict, - cast, - override, -) - -from app.database.beatmap import Beatmap -from app.dependencies.database import with_db -from app.dependencies.fetcher import get_fetcher -from app.exception import InvokeException -from app.utils import utcnow - -from .mods import API_MODS, APIMod -from .room import ( - DownloadState, - MatchType, - MultiplayerRoomState, - MultiplayerUserState, - QueueMode, - RoomCategory, - RoomStatus, -) -from .signalr import ( - SignalRMeta, - SignalRUnionMessage, - UserState, -) - -from pydantic import BaseModel, Field -from sqlalchemy import update -from sqlmodel import col - -if TYPE_CHECKING: - from app.database.room import Room - from app.signalr.hub import MultiplayerHub - -HOST_LIMIT = 50 -PER_USER_LIMIT = 3 - - -class MultiplayerClientState(UserState): - room_id: int = 0 - - -class MultiplayerRoomSettings(BaseModel): - name: str = "Unnamed Room" - playlist_item_id: Annotated[int, Field(default=0), SignalRMeta(use_abbr=False)] - password: str = "" - match_type: MatchType = MatchType.HEAD_TO_HEAD - queue_mode: QueueMode = QueueMode.HOST_ONLY - auto_start_duration: timedelta = timedelta(seconds=0) - auto_skip: bool = False - - @property - def auto_start_enabled(self) -> bool: - return self.auto_start_duration != timedelta(seconds=0) - - -class BeatmapAvailability(BaseModel): - state: DownloadState = DownloadState.UNKNOWN - download_progress: float | None = None - - -class _MatchUserState(SignalRUnionMessage): ... - - -class TeamVersusUserState(_MatchUserState): - team_id: int - - union_type: ClassVar[Literal[0]] = 0 - - -MatchUserState = TeamVersusUserState - - -class _MatchRoomState(SignalRUnionMessage): ... - - -class MultiplayerTeam(BaseModel): - id: int - name: str - - -class TeamVersusRoomState(_MatchRoomState): - teams: list[MultiplayerTeam] = Field( - default_factory=lambda: [ - MultiplayerTeam(id=0, name="Team Red"), - MultiplayerTeam(id=1, name="Team Blue"), - ] - ) - - union_type: ClassVar[Literal[0]] = 0 - - -MatchRoomState = TeamVersusRoomState - - -class PlaylistItem(BaseModel): - id: Annotated[int, Field(default=0), SignalRMeta(use_abbr=False)] - owner_id: int - beatmap_id: int - beatmap_checksum: str - ruleset_id: int - required_mods: list[APIMod] = Field(default_factory=list) - allowed_mods: list[APIMod] = Field(default_factory=list) - expired: bool - playlist_order: int - played_at: datetime | None = None - star_rating: float - freestyle: bool - - def _validate_mod_for_ruleset(self, mod: APIMod, ruleset_key: int, context: str = "mod") -> None: - typed_ruleset_key = cast(Literal[0, 1, 2, 3], ruleset_key) - - # Check if mod is valid for ruleset - if typed_ruleset_key not in API_MODS or mod["acronym"] not in API_MODS[typed_ruleset_key]: - raise InvokeException(f"{context} {mod['acronym']} is invalid for this ruleset") - - mod_settings = API_MODS[typed_ruleset_key][mod["acronym"]] - - # Check if mod is unplayable in multiplayer - if mod_settings.get("UserPlayable", True) is False: - raise InvokeException(f"{context} {mod['acronym']} is not playable by users") - - if mod_settings.get("ValidForMultiplayer", True) is False: - raise InvokeException(f"{context} {mod['acronym']} is not valid for multiplayer") - - def _check_mod_compatibility(self, mods: list[APIMod], ruleset_key: int) -> None: - from typing import Literal, cast - - typed_ruleset_key = cast(Literal[0, 1, 2, 3], ruleset_key) - - for i, mod1 in enumerate(mods): - mod1_settings = API_MODS[typed_ruleset_key].get(mod1["acronym"]) - if mod1_settings: - incompatible = set(mod1_settings.get("IncompatibleMods", [])) - for mod2 in mods[i + 1 :]: - if mod2["acronym"] in incompatible: - raise InvokeException(f"Mods {mod1['acronym']} and {mod2['acronym']} are incompatible") - - def _check_required_allowed_compatibility(self, ruleset_key: int) -> None: - from typing import Literal, cast - - typed_ruleset_key = cast(Literal[0, 1, 2, 3], ruleset_key) - allowed_acronyms = {mod["acronym"] for mod in self.allowed_mods} - - for req_mod in self.required_mods: - req_acronym = req_mod["acronym"] - req_settings = API_MODS[typed_ruleset_key].get(req_acronym) - if req_settings: - incompatible = set(req_settings.get("IncompatibleMods", [])) - conflicting_allowed = allowed_acronyms & incompatible - if conflicting_allowed: - conflict_list = ", ".join(conflicting_allowed) - raise InvokeException(f"Required mod {req_acronym} conflicts with allowed mods: {conflict_list}") - - def validate_playlist_item_mods(self) -> None: - ruleset_key = cast(Literal[0, 1, 2, 3], self.ruleset_id) - - # Validate required mods - for mod in self.required_mods: - self._validate_mod_for_ruleset(mod, ruleset_key, "Required mod") - - # Validate allowed mods - for mod in self.allowed_mods: - self._validate_mod_for_ruleset(mod, ruleset_key, "Allowed mod") - - # Check internal compatibility of required mods - self._check_mod_compatibility(self.required_mods, ruleset_key) - - # Check compatibility between required and allowed mods - self._check_required_allowed_compatibility(ruleset_key) - - def validate_user_mods( - self, - user: "MultiplayerRoomUser", - proposed_mods: list[APIMod], - ) -> tuple[bool, list[APIMod]]: - """ - Validates user mods against playlist item rules and returns valid mods. - Returns (is_valid, valid_mods). - """ - from typing import Literal, cast - - ruleset_id = user.ruleset_id if user.ruleset_id is not None else self.ruleset_id - ruleset_key = cast(Literal[0, 1, 2, 3], ruleset_id) - - valid_mods = [] - all_proposed_valid = True - - # Check if mods are valid for the ruleset - for mod in proposed_mods: - if ruleset_key not in API_MODS or mod["acronym"] not in API_MODS[ruleset_key]: - all_proposed_valid = False - continue - valid_mods.append(mod) - - # Check mod compatibility within user mods - incompatible_mods = set() - final_valid_mods = [] - for mod in valid_mods: - if mod["acronym"] in incompatible_mods: - all_proposed_valid = False - continue - setting_mods = API_MODS[ruleset_key].get(mod["acronym"]) - if setting_mods: - incompatible_mods.update(setting_mods["IncompatibleMods"]) - final_valid_mods.append(mod) - - # If not freestyle, check against allowed mods - if not self.freestyle: - allowed_acronyms = {mod["acronym"] for mod in self.allowed_mods} - filtered_valid_mods = [] - for mod in final_valid_mods: - if mod["acronym"] not in allowed_acronyms: - all_proposed_valid = False - else: - filtered_valid_mods.append(mod) - final_valid_mods = filtered_valid_mods - - # Check compatibility with required mods - required_mod_acronyms = {mod["acronym"] for mod in self.required_mods} - all_mod_acronyms = {mod["acronym"] for mod in final_valid_mods} | required_mod_acronyms - - # Check for incompatibility between required and user mods - filtered_valid_mods = [] - for mod in final_valid_mods: - mod_acronym = mod["acronym"] - is_compatible = True - - for other_acronym in all_mod_acronyms: - if other_acronym == mod_acronym: - continue - setting_mods = API_MODS[ruleset_key].get(mod_acronym) - if setting_mods and other_acronym in setting_mods["IncompatibleMods"]: - is_compatible = False - all_proposed_valid = False - break - - if is_compatible: - filtered_valid_mods.append(mod) - - return all_proposed_valid, filtered_valid_mods - - def clone(self) -> "PlaylistItem": - copy = self.model_copy() - copy.required_mods = list(self.required_mods) - copy.allowed_mods = list(self.allowed_mods) - copy.expired = False - copy.played_at = None - return copy - - -class _MultiplayerCountdown(SignalRUnionMessage): - id: int = 0 - time_remaining: timedelta - is_exclusive: Annotated[bool, Field(default=True), SignalRMeta(member_ignore=True)] = True - - -class MatchStartCountdown(_MultiplayerCountdown): - union_type: ClassVar[Literal[0]] = 0 - - -class ForceGameplayStartCountdown(_MultiplayerCountdown): - union_type: ClassVar[Literal[1]] = 1 - - -class ServerShuttingDownCountdown(_MultiplayerCountdown): - union_type: ClassVar[Literal[2]] = 2 - - -MultiplayerCountdown = MatchStartCountdown | ForceGameplayStartCountdown | ServerShuttingDownCountdown - - -class MultiplayerRoomUser(BaseModel): - user_id: int - state: MultiplayerUserState = MultiplayerUserState.IDLE - availability: BeatmapAvailability = BeatmapAvailability(state=DownloadState.UNKNOWN, download_progress=None) - mods: list[APIMod] = Field(default_factory=list) - match_state: MatchUserState | None = None - ruleset_id: int | None = None # freestyle - beatmap_id: int | None = None # freestyle - - -class MultiplayerRoom(BaseModel): - room_id: int - state: MultiplayerRoomState - settings: MultiplayerRoomSettings - users: list[MultiplayerRoomUser] = Field(default_factory=list) - host: MultiplayerRoomUser | None = None - match_state: MatchRoomState | None = None - playlist: list[PlaylistItem] = Field(default_factory=list) - active_countdowns: list[MultiplayerCountdown] = Field(default_factory=list) - channel_id: int - - @classmethod - def from_db(cls, room: "Room") -> "MultiplayerRoom": - """ - 将 Room (数据库模型) 转换为 MultiplayerRoom (业务模型) - """ - - # 用户列表 - users = [MultiplayerRoomUser(user_id=room.host_id)] - host_user = MultiplayerRoomUser(user_id=room.host_id) - # playlist 转换 - playlist = [] - if room.playlist: - for item in room.playlist: - playlist.append( - PlaylistItem( - id=item.id, - owner_id=item.owner_id, - beatmap_id=item.beatmap_id, - beatmap_checksum=item.beatmap.checksum if item.beatmap else "", - ruleset_id=item.ruleset_id, - required_mods=item.required_mods, - allowed_mods=item.allowed_mods, - expired=item.expired, - playlist_order=item.playlist_order, - played_at=item.played_at, - star_rating=item.beatmap.difficulty_rating if item.beatmap is not None else 0.0, - freestyle=item.freestyle, - ) - ) - - return cls( - room_id=room.id, - state=getattr(room, "state", MultiplayerRoomState.OPEN), - settings=MultiplayerRoomSettings( - name=room.name, - playlist_item_id=playlist[0].id if playlist else 0, - password=getattr(room, "password", ""), - match_type=room.type, - queue_mode=room.queue_mode, - auto_start_duration=timedelta(seconds=room.auto_start_duration), - auto_skip=room.auto_skip, - ), - users=users, - host=host_user, - match_state=None, - playlist=playlist, - active_countdowns=[], - channel_id=room.channel_id or 0, - ) - - -class MultiplayerQueue: - def __init__(self, room: "ServerMultiplayerRoom"): - self.server_room = room - self.current_index = 0 - - @property - def hub(self) -> "MultiplayerHub": - return self.server_room.hub - - @property - def upcoming_items(self): - return sorted( - (item for item in self.room.playlist if not item.expired), - key=lambda i: i.playlist_order, - ) - - @property - def room(self): - return self.server_room.room - - async def update_order(self): - from app.database import Playlist - - match self.room.settings.queue_mode: - case QueueMode.ALL_PLAYERS_ROUND_ROBIN: - ordered_active_items = [] - - is_first_set = True - first_set_order_by_user_id = {} - - active_items = [item for item in self.room.playlist if not item.expired] - active_items.sort(key=lambda x: x.id) - - user_item_groups = {} - for item in active_items: - if item.owner_id not in user_item_groups: - user_item_groups[item.owner_id] = [] - user_item_groups[item.owner_id].append(item) - - max_items = max((len(items) for items in user_item_groups.values()), default=0) - - for i in range(max_items): - current_set = [] - for user_id, items in user_item_groups.items(): - if i < len(items): - current_set.append(items[i]) - - if is_first_set: - current_set.sort(key=lambda item: (item.playlist_order, item.id)) - ordered_active_items.extend(current_set) - first_set_order_by_user_id = { - item.owner_id: idx for idx, item in enumerate(ordered_active_items) - } - else: - current_set.sort(key=lambda item: first_set_order_by_user_id.get(item.owner_id, 0)) - ordered_active_items.extend(current_set) - - is_first_set = False - case _: - ordered_active_items = sorted( - (item for item in self.room.playlist if not item.expired), - key=lambda x: x.id, - ) - async with with_db() as session: - for idx, item in enumerate(ordered_active_items): - if item.playlist_order == idx: - continue - item.playlist_order = idx - await Playlist.update(item, self.room.room_id, session) - await self.hub.playlist_changed(self.server_room, item, beatmap_changed=False) - - async def update_current_item(self): - upcoming_items = self.upcoming_items - if upcoming_items: - # 优先选择未过期的项目 - next_item = upcoming_items[0] - else: - # 如果所有项目都过期了,选择最近添加的项目(played_at 为 None 或最新的) - # 优先选择 expired=False 的项目,然后是 played_at 最晚的 - next_item = max( - self.room.playlist, - key=lambda i: (not i.expired, i.played_at or datetime.min), - ) - self.current_index = self.room.playlist.index(next_item) - last_id = self.room.settings.playlist_item_id - self.room.settings.playlist_item_id = next_item.id - if last_id != next_item.id: - await self.hub.setting_changed(self.server_room, True) - - async def add_item(self, item: PlaylistItem, user: MultiplayerRoomUser): - from app.database import Playlist - - is_host = self.room.host and self.room.host.user_id == user.user_id - if self.room.settings.queue_mode == QueueMode.HOST_ONLY and not is_host: - raise InvokeException("You are not the host") - - limit = HOST_LIMIT if is_host else PER_USER_LIMIT - if len([True for u in self.room.playlist if u.owner_id == user.user_id and not u.expired]) >= limit: - raise InvokeException(f"You can only have {limit} items in the queue") - - if item.freestyle and len(item.allowed_mods) > 0: - raise InvokeException("Freestyle items cannot have allowed mods") - - async with with_db() as session: - fetcher = await get_fetcher() - async with session: - beatmap = await Beatmap.get_or_fetch(session, fetcher, bid=item.beatmap_id) - if beatmap is None: - raise InvokeException("Beatmap not found") - if item.beatmap_checksum != beatmap.checksum: - raise InvokeException("Checksum mismatch") - - item.validate_playlist_item_mods() - item.owner_id = user.user_id - item.star_rating = beatmap.difficulty_rating - await Playlist.add_to_db(item, self.room.room_id, session) - self.room.playlist.append(item) - await self.hub.playlist_added(self.server_room, item) - await self.update_order() - await self.update_current_item() - - async def edit_item(self, item: PlaylistItem, user: MultiplayerRoomUser): - from app.database import Playlist - - if item.freestyle and len(item.allowed_mods) > 0: - raise InvokeException("Freestyle items cannot have allowed mods") - - async with with_db() as session: - fetcher = await get_fetcher() - async with session: - beatmap = await Beatmap.get_or_fetch(session, fetcher, bid=item.beatmap_id) - if item.beatmap_checksum != beatmap.checksum: - raise InvokeException("Checksum mismatch") - - existing_item = next((i for i in self.room.playlist if i.id == item.id), None) - if existing_item is None: - raise InvokeException("Attempted to change an item that doesn't exist") - - if existing_item.owner_id != user.user_id and self.room.host != user: - raise InvokeException("Attempted to change an item which is not owned by the user") - - if existing_item.expired: - raise InvokeException("Attempted to change an item which has already been played") - - item.validate_playlist_item_mods() - item.owner_id = user.user_id - item.star_rating = float(beatmap.difficulty_rating) - item.playlist_order = existing_item.playlist_order - - await Playlist.update(item, self.room.room_id, session) - - # Update item in playlist - for idx, playlist_item in enumerate(self.room.playlist): - if playlist_item.id == item.id: - self.room.playlist[idx] = item - break - - await self.hub.playlist_changed( - self.server_room, - item, - beatmap_changed=item.beatmap_checksum != existing_item.beatmap_checksum, - ) - - async def remove_item(self, playlist_item_id: int, user: MultiplayerRoomUser): - from app.database import Playlist - - item = next( - (i for i in self.room.playlist if i.id == playlist_item_id), - None, - ) - - if item is None: - raise InvokeException("Item does not exist in the room") - - # Check if it's the only item and current item - if item == self.current_item: - upcoming_items = [i for i in self.room.playlist if not i.expired] - if len(upcoming_items) == 1: - raise InvokeException("The only item in the room cannot be removed") - - if item.owner_id != user.user_id and self.room.host != user: - raise InvokeException("Attempted to remove an item which is not owned by the user") - - if item.expired: - raise InvokeException("Attempted to remove an item which has already been played") - - async with with_db() as session: - await Playlist.delete_item(item.id, self.room.room_id, session) - - found_item = next((i for i in self.room.playlist if i.id == item.id), None) - if found_item: - self.room.playlist.remove(found_item) - self.current_index = self.room.playlist.index(self.upcoming_items[0]) - - await self.update_order() - await self.update_current_item() - await self.hub.playlist_removed(self.server_room, item.id) - - async def finish_current_item(self): - from app.database import Playlist - - async with with_db() as session: - played_at = utcnow() - await session.execute( - update(Playlist) - .where( - col(Playlist.id) == self.current_item.id, - col(Playlist.room_id) == self.room.room_id, - ) - .values(expired=True, played_at=played_at) - ) - self.room.playlist[self.current_index].expired = True - self.room.playlist[self.current_index].played_at = played_at - await self.hub.playlist_changed(self.server_room, self.current_item, True) - await self.update_order() - if self.room.settings.queue_mode == QueueMode.HOST_ONLY and all( - playitem.expired for playitem in self.room.playlist - ): - assert self.room.host - await self.add_item(self.current_item.clone(), self.room.host) - await self.update_current_item() - - async def update_queue_mode(self): - if self.room.settings.queue_mode == QueueMode.HOST_ONLY and all( - playitem.expired for playitem in self.room.playlist - ): - assert self.room.host - await self.add_item(self.current_item.clone(), self.room.host) - await self.update_order() - await self.update_current_item() - - @property - def current_item(self): - return self.room.playlist[self.current_index] - - -@dataclass -class CountdownInfo: - countdown: MultiplayerCountdown - duration: timedelta - task: asyncio.Task | None = None - - def __init__(self, countdown: MultiplayerCountdown): - self.countdown = countdown - self.duration = ( - countdown.time_remaining if countdown.time_remaining > timedelta(seconds=0) else timedelta(seconds=0) - ) - - -class _MatchRequest(SignalRUnionMessage): ... - - -class ChangeTeamRequest(_MatchRequest): - union_type: ClassVar[Literal[0]] = 0 - team_id: int - - -class StartMatchCountdownRequest(_MatchRequest): - union_type: ClassVar[Literal[1]] = 1 - duration: timedelta - - -class StopCountdownRequest(_MatchRequest): - union_type: ClassVar[Literal[2]] = 2 - id: int - - -MatchRequest = ChangeTeamRequest | StartMatchCountdownRequest | StopCountdownRequest - - -class MatchTypeHandler(ABC): - def __init__(self, room: "ServerMultiplayerRoom"): - self.room = room - self.hub = room.hub - - @abstractmethod - async def handle_join(self, user: MultiplayerRoomUser): ... - - @abstractmethod - async def handle_request(self, user: MultiplayerRoomUser, request: MatchRequest): ... - - @abstractmethod - async def handle_leave(self, user: MultiplayerRoomUser): ... - - @abstractmethod - def get_details(self) -> MatchStartedEventDetail: ... - - -class HeadToHeadHandler(MatchTypeHandler): - @override - async def handle_join(self, user: MultiplayerRoomUser): - if user.match_state is not None: - user.match_state = None - await self.hub.change_user_match_state(self.room, user) - - @override - async def handle_request(self, user: MultiplayerRoomUser, request: MatchRequest): ... - - @override - async def handle_leave(self, user: MultiplayerRoomUser): ... - - @override - def get_details(self) -> MatchStartedEventDetail: - detail = MatchStartedEventDetail(room_type="head_to_head", team=None) - return detail - - -class TeamVersusHandler(MatchTypeHandler): - @override - def __init__(self, room: "ServerMultiplayerRoom"): - super().__init__(room) - self.state = TeamVersusRoomState() - room.room.match_state = self.state - task = asyncio.create_task(self.hub.change_room_match_state(self.room)) - self.hub.tasks.add(task) - task.add_done_callback(self.hub.tasks.discard) - - def _get_best_available_team(self) -> int: - for team in self.state.teams: - if all( - ( - user.match_state is None - or not isinstance(user.match_state, TeamVersusUserState) - or user.match_state.team_id != team.id - ) - for user in self.room.room.users - ): - return team.id - - from collections import defaultdict - - team_counts = defaultdict(int) - for user in self.room.room.users: - if user.match_state is not None and isinstance(user.match_state, TeamVersusUserState): - team_counts[user.match_state.team_id] += 1 - - if team_counts: - min_count = min(team_counts.values()) - for team_id, count in team_counts.items(): - if count == min_count: - return team_id - return self.state.teams[0].id if self.state.teams else 0 - - @override - async def handle_join(self, user: MultiplayerRoomUser): - best_team_id = self._get_best_available_team() - user.match_state = TeamVersusUserState(team_id=best_team_id) - await self.hub.change_user_match_state(self.room, user) - - @override - async def handle_request(self, user: MultiplayerRoomUser, request: MatchRequest): - if not isinstance(request, ChangeTeamRequest): - return - - if request.team_id not in [team.id for team in self.state.teams]: - raise InvokeException("Invalid team ID") - - user.match_state = TeamVersusUserState(team_id=request.team_id) - await self.hub.change_user_match_state(self.room, user) - - @override - async def handle_leave(self, user: MultiplayerRoomUser): ... - - @override - def get_details(self) -> MatchStartedEventDetail: - teams: dict[int, Literal["blue", "red"]] = {} - for user in self.room.room.users: - if user.match_state is not None and isinstance(user.match_state, TeamVersusUserState): - teams[user.user_id] = "blue" if user.match_state.team_id == 1 else "red" - detail = MatchStartedEventDetail(room_type="team_versus", team=teams) - return detail - - -MATCH_TYPE_HANDLERS = { - MatchType.HEAD_TO_HEAD: HeadToHeadHandler, - MatchType.TEAM_VERSUS: TeamVersusHandler, -} - - -@dataclass -class ServerMultiplayerRoom: - room: MultiplayerRoom - category: RoomCategory - status: RoomStatus - start_at: datetime - hub: "MultiplayerHub" - match_type_handler: MatchTypeHandler - queue: MultiplayerQueue - _next_countdown_id: int - _countdown_id_lock: asyncio.Lock - _tracked_countdown: dict[int, CountdownInfo] - - def __init__( - self, - room: MultiplayerRoom, - category: RoomCategory, - start_at: datetime, - hub: "MultiplayerHub", - ): - self.room = room - self.category = category - self.status = RoomStatus.IDLE - self.start_at = start_at - self.hub = hub - self.queue = MultiplayerQueue(self) - self._next_countdown_id = 0 - self._countdown_id_lock = asyncio.Lock() - self._tracked_countdown = {} - - async def set_handler(self): - self.match_type_handler = MATCH_TYPE_HANDLERS[self.room.settings.match_type](self) - for i in self.room.users: - await self.match_type_handler.handle_join(i) - - async def get_next_countdown_id(self) -> int: - async with self._countdown_id_lock: - self._next_countdown_id += 1 - return self._next_countdown_id - - async def start_countdown( - self, - countdown: MultiplayerCountdown, - on_complete: Callable[["ServerMultiplayerRoom"], Awaitable[Any]] | None = None, - ): - async def _countdown_task(self: "ServerMultiplayerRoom"): - await asyncio.sleep(info.duration.total_seconds()) - if on_complete is not None: - await on_complete(self) - await self.stop_countdown(countdown) - - if countdown.is_exclusive: - await self.stop_all_countdowns(countdown.__class__) - countdown.id = await self.get_next_countdown_id() - info = CountdownInfo(countdown) - self.room.active_countdowns.append(info.countdown) - self._tracked_countdown[countdown.id] = info - await self.hub.send_match_event(self, CountdownStartedEvent(countdown=info.countdown)) - info.task = asyncio.create_task(_countdown_task(self)) - - async def stop_countdown(self, countdown: MultiplayerCountdown): - info = self._tracked_countdown.get(countdown.id) - if info is None: - return - del self._tracked_countdown[countdown.id] - self.room.active_countdowns.remove(countdown) - await self.hub.send_match_event(self, CountdownStoppedEvent(id=countdown.id)) - if info.task is not None and not info.task.done(): - info.task.cancel() - - async def stop_all_countdowns(self, typ: type[MultiplayerCountdown]): - for countdown in list(self._tracked_countdown.values()): - if isinstance(countdown.countdown, typ): - await self.stop_countdown(countdown.countdown) - - -class _MatchServerEvent(SignalRUnionMessage): ... - - -class CountdownStartedEvent(_MatchServerEvent): - countdown: MultiplayerCountdown - - union_type: ClassVar[Literal[0]] = 0 - - -class CountdownStoppedEvent(_MatchServerEvent): - id: int - - union_type: ClassVar[Literal[1]] = 1 - - -MatchServerEvent = CountdownStartedEvent | CountdownStoppedEvent - - -class GameplayAbortReason(IntEnum): - LOAD_TOOK_TOO_LONG = 0 - HOST_ABORTED = 1 - - -class MatchStartedEventDetail(TypedDict): - room_type: Literal["playlists", "head_to_head", "team_versus"] - team: dict[int, Literal["blue", "red"]] | None diff --git a/app/models/playlist.py b/app/models/playlist.py new file mode 100644 index 0000000..d938f4b --- /dev/null +++ b/app/models/playlist.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from datetime import datetime + +from app.models.mods import APIMod + +from pydantic import BaseModel, Field + + +class PlaylistItem(BaseModel): + id: int = Field(default=0, ge=-1) + owner_id: int + beatmap_id: int + beatmap_checksum: str = "" + ruleset_id: int = 0 + required_mods: list[APIMod] = Field(default_factory=list) + allowed_mods: list[APIMod] = Field(default_factory=list) + expired: bool = False + playlist_order: int = 0 + played_at: datetime | None = None + star_rating: float = 0.0 + freestyle: bool = False diff --git a/app/models/signalr.py b/app/models/signalr.py index 8a60b26..8b13789 100644 --- a/app/models/signalr.py +++ b/app/models/signalr.py @@ -1,37 +1 @@ -from __future__ import annotations -from dataclasses import dataclass -from typing import ClassVar - -from pydantic import ( - BaseModel, - Field, -) - - -@dataclass -class SignalRMeta: - member_ignore: bool = False # implement of IgnoreMember (msgpack) attribute - json_ignore: bool = False # implement of JsonIgnore (json) attribute - use_abbr: bool = True - - -class SignalRUnionMessage(BaseModel): - union_type: ClassVar[int] - - -class Transport(BaseModel): - transport: str - transfer_formats: list[str] = Field(default_factory=lambda: ["Binary", "Text"], alias="transferFormats") - - -class NegotiateResponse(BaseModel): - connectionId: str - connectionToken: str - negotiateVersion: int = 1 - availableTransports: list[Transport] - - -class UserState(BaseModel): - connection_id: str - connection_token: str diff --git a/app/models/spectator_hub.py b/app/models/spectator_hub.py index 8a5eb71..e69de29 100644 --- a/app/models/spectator_hub.py +++ b/app/models/spectator_hub.py @@ -1,131 +0,0 @@ -from __future__ import annotations - -import datetime -from enum import IntEnum -from typing import Annotated, Any - -from app.models.beatmap import BeatmapRankStatus -from app.models.mods import APIMod - -from .score import ( - ScoreStatistics, -) -from .signalr import SignalRMeta, UserState - -from pydantic import BaseModel, Field, field_validator - - -class SpectatedUserState(IntEnum): - Idle = 0 - Playing = 1 - Paused = 2 - Passed = 3 - Failed = 4 - Quit = 5 - - -class SpectatorState(BaseModel): - beatmap_id: int | None = None - ruleset_id: int | None = None # 0,1,2,3 - mods: list[APIMod] = Field(default_factory=list) - state: SpectatedUserState - maximum_statistics: ScoreStatistics = Field(default_factory=dict) - - def __eq__(self, other: object) -> bool: - if not isinstance(other, SpectatorState): - return False - return ( - self.beatmap_id == other.beatmap_id - and self.ruleset_id == other.ruleset_id - and self.mods == other.mods - and self.state == other.state - ) - - -class ScoreProcessorStatistics(BaseModel): - base_score: float - maximum_base_score: float - accuracy_judgement_count: int - combo_portion: float - bonus_portion: float - - -class FrameHeader(BaseModel): - total_score: int - accuracy: float - combo: int - max_combo: int - statistics: ScoreStatistics = Field(default_factory=dict) - score_processor_statistics: ScoreProcessorStatistics - received_time: datetime.datetime - mods: list[APIMod] = Field(default_factory=list) - - @field_validator("received_time", mode="before") - @classmethod - def validate_timestamp(cls, v: Any) -> datetime.datetime: - if isinstance(v, list): - return v[0] - if isinstance(v, datetime.datetime): - return v - if isinstance(v, int | float): - return datetime.datetime.fromtimestamp(v, tz=datetime.UTC) - if isinstance(v, str): - return datetime.datetime.fromisoformat(v) - raise ValueError(f"Cannot convert {type(v)} to datetime") - - -# class ReplayButtonState(IntEnum): -# NONE = 0 -# LEFT1 = 1 -# RIGHT1 = 2 -# LEFT2 = 4 -# RIGHT2 = 8 -# SMOKE = 16 - - -class LegacyReplayFrame(BaseModel): - time: float # from ReplayFrame,the parent of LegacyReplayFrame - mouse_x: float | None = None - mouse_y: float | None = None - button_state: int - - header: Annotated[FrameHeader | None, Field(default=None), SignalRMeta(member_ignore=True)] - - -class FrameDataBundle(BaseModel): - header: FrameHeader - frames: list[LegacyReplayFrame] - - -# Use for server -class APIUser(BaseModel): - id: int - name: str - - -class ScoreInfo(BaseModel): - mods: list[APIMod] - user: APIUser - ruleset: int - maximum_statistics: ScoreStatistics - id: int | None = None - total_score: int | None = None - accuracy: float | None = None - max_combo: int | None = None - combo: int | None = None - statistics: ScoreStatistics = Field(default_factory=dict) - - -class StoreScore(BaseModel): - score_info: ScoreInfo - replay_frames: list[LegacyReplayFrame] = Field(default_factory=list) - - -class StoreClientState(UserState): - state: SpectatorState | None = None - beatmap_status: BeatmapRankStatus | None = None - checksum: str | None = None - ruleset_id: int | None = None - score_token: int | None = None - watched_user: set[int] = Field(default_factory=set) - score: StoreScore | None = None diff --git a/app/router/__init__.py b/app/router/__init__.py index 4316997..fc5d2a7 100644 --- a/app/router/__init__.py +++ b/app/router/__init__.py @@ -1,6 +1,5 @@ from __future__ import annotations -# from app.signalr import signalr_router as signalr_router from .auth import router as auth_router from .fetcher import fetcher_router as fetcher_router from .file import file_router as file_router @@ -25,5 +24,4 @@ __all__ = [ "private_router", "redirect_api_router", "redirect_router", - # "signalr_router", ] diff --git a/app/router/lio.py b/app/router/lio.py index 28d8f35..969b214 100644 --- a/app/router/lio.py +++ b/app/router/lio.py @@ -15,7 +15,7 @@ from app.dependencies.database import Database, Redis from app.dependencies.fetcher import Fetcher from app.dependencies.storage import StorageService from app.log import log -from app.models.multiplayer_hub import PlaylistItem as HubPlaylistItem +from app.models.playlist import PlaylistItem from app.models.room import MatchType, QueueMode, RoomCategory, RoomStatus from app.utils import utcnow @@ -216,7 +216,7 @@ async def _add_playlist_items(db: Database, room_id: int, room_data: dict[str, A # Insert playlist items for item_data in items_raw: - hub_item = HubPlaylistItem( + playlist_item = PlaylistItem( id=-1, # Placeholder, will be assigned by add_to_db owner_id=item_data["owner_id"], ruleset_id=item_data["ruleset_id"], @@ -230,7 +230,7 @@ async def _add_playlist_items(db: Database, room_id: int, room_data: dict[str, A beatmap_checksum=item_data["beatmap_checksum"], star_rating=item_data["star_rating"], ) - await DBPlaylist.add_to_db(hub_item, room_id=room_id, session=db) + await DBPlaylist.add_to_db(playlist_item, room_id=room_id, session=db) async def _add_host_as_participant(db: Database, room_id: int, host_user_id: int) -> None: diff --git a/app/router/notification/banchobot.py b/app/router/notification/banchobot.py index c140650..7b01347 100644 --- a/app/router/notification/banchobot.py +++ b/app/router/notification/banchobot.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable -from datetime import timedelta from math import ceil import random import shlex @@ -10,27 +9,15 @@ import shlex from app.calculator import calculate_weighted_pp from app.const import BANCHOBOT_ID from app.database import ChatMessageResp -from app.database.beatmap import Beatmap from app.database.chat import ChannelType, ChatChannel, ChatMessage, MessageType from app.database.score import Score, get_best_id from app.database.statistics import UserStatistics, get_rank from app.database.user import User -from app.dependencies.fetcher import get_fetcher -from app.exception import InvokeException -from app.models.mods import APIMod, get_available_mods, mod_to_save -from app.models.multiplayer_hub import ( - ChangeTeamRequest, - ServerMultiplayerRoom, - StartMatchCountdownRequest, -) -from app.models.room import MatchType, QueueMode, RoomStatus +from app.models.mods import mod_to_save from app.models.score import GameMode -from app.signalr.hub import MultiplayerHubs -from app.signalr.hub.hub import Client from .server import server -from httpx import HTTPError from sqlalchemy.orm import joinedload from sqlmodel import col, func, select from sqlmodel.ext.asyncio.session import AsyncSession @@ -216,352 +203,6 @@ PP: {statistics.pp:.2f} """ -async def _mp_name( - signalr_client: Client, - room: ServerMultiplayerRoom, - args: list[str], - session: AsyncSession, -) -> str: - if len(args) < 1: - return "Usage: !mp name " - - name = args[0] - try: - settings = room.room.settings.model_copy() - settings.name = name - await MultiplayerHubs.ChangeSettings(signalr_client, settings) - return f"Room name has changed to {name}" - except InvokeException as e: - return e.message - - -async def _mp_set( - signalr_client: Client, - room: ServerMultiplayerRoom, - args: list[str], - session: AsyncSession, -) -> str: - if len(args) < 1: - return "Usage: !mp set []" - - teammode = {"0": MatchType.HEAD_TO_HEAD, "2": MatchType.TEAM_VERSUS}.get(args[0]) - if not teammode: - return "Invalid teammode. Use 0 for Head-to-Head or 2 for Team Versus." - queuemode = ( - { - "0": QueueMode.HOST_ONLY, - "1": QueueMode.ALL_PLAYERS, - "2": QueueMode.ALL_PLAYERS_ROUND_ROBIN, - }.get(args[1]) - if len(args) >= 2 - else None - ) - try: - settings = room.room.settings.model_copy() - settings.match_type = teammode - if queuemode: - settings.queue_mode = queuemode - await MultiplayerHubs.ChangeSettings(signalr_client, settings) - return f"Room setting 'teammode' has been changed to {teammode.name.lower()}" - except InvokeException as e: - return e.message - - -async def _mp_host( - signalr_client: Client, - room: ServerMultiplayerRoom, - args: list[str], - session: AsyncSession, -) -> str: - if len(args) < 1: - return "Usage: !mp host " - - username = args[0] - user_id = (await session.exec(select(User.id).where(User.username == username))).first() - if not user_id: - return f"User '{username}' not found." - - try: - await MultiplayerHubs.TransferHost(signalr_client, user_id) - return f"User '{username}' has been hosted in the room." - except InvokeException as e: - return e.message - - -async def _mp_start( - signalr_client: Client, - room: ServerMultiplayerRoom, - args: list[str], - session: AsyncSession, -) -> str: - timer = None - if len(args) >= 1 and args[0].isdigit(): - timer = int(args[0]) - - try: - if timer is not None: - await MultiplayerHubs.SendMatchRequest( - signalr_client, - StartMatchCountdownRequest(duration=timedelta(seconds=timer)), - ) - return "" - else: - await MultiplayerHubs.StartMatch(signalr_client) - return "Good luck! Enjoy game!" - except InvokeException as e: - return e.message - - -async def _mp_abort( - signalr_client: Client, - room: ServerMultiplayerRoom, - args: list[str], - session: AsyncSession, -) -> str: - try: - await MultiplayerHubs.AbortMatch(signalr_client) - return "Match aborted." - except InvokeException as e: - return e.message - - -async def _mp_team( - signalr_client: Client, - room: ServerMultiplayerRoom, - args: list[str], - session: AsyncSession, -): - if room.room.settings.match_type != MatchType.TEAM_VERSUS: - return "This command is only available in Team Versus mode." - - if len(args) < 2: - return "Usage: !mp team " - - username = args[0] - team = {"red": 0, "blue": 1}.get(args[1]) - if team is None: - return "Invalid team colour. Use 'red' or 'blue'." - - user_id = (await session.exec(select(User.id).where(User.username == username))).first() - if not user_id: - return f"User '{username}' not found." - user_client = MultiplayerHubs.get_client_by_id(str(user_id)) - if not user_client: - return f"User '{username}' is not in the room." - assert room.room.host - if user_client.user_id != signalr_client.user_id and room.room.host.user_id != signalr_client.user_id: - return "You are not allowed to change other users' teams." - - try: - await MultiplayerHubs.SendMatchRequest(user_client, ChangeTeamRequest(team_id=team)) - return "" - except InvokeException as e: - return e.message - - -async def _mp_password( - signalr_client: Client, - room: ServerMultiplayerRoom, - args: list[str], - session: AsyncSession, -) -> str: - password = "" - if len(args) >= 1: - password = args[0] - - try: - settings = room.room.settings.model_copy() - settings.password = password - await MultiplayerHubs.ChangeSettings(signalr_client, settings) - return "Room password has been set." - except InvokeException as e: - return e.message - - -async def _mp_kick( - signalr_client: Client, - room: ServerMultiplayerRoom, - args: list[str], - session: AsyncSession, -) -> str: - if len(args) < 1: - return "Usage: !mp kick " - - username = args[0] - user_id = (await session.exec(select(User.id).where(User.username == username))).first() - if not user_id: - return f"User '{username}' not found." - - try: - await MultiplayerHubs.KickUser(signalr_client, user_id) - return f"User '{username}' has been kicked from the room." - except InvokeException as e: - return e.message - - -async def _mp_map( - signalr_client: Client, - room: ServerMultiplayerRoom, - args: list[str], - session: AsyncSession, -) -> str: - if len(args) < 1: - return "Usage: !mp map []" - - if room.status != RoomStatus.IDLE: - return "Cannot change map while the game is running." - - map_id = args[0] - if not map_id.isdigit(): - return "Invalid map ID." - map_id = int(map_id) - playmode = GameMode.parse(args[1].upper()) if len(args) >= 2 else None - if playmode not in ( - GameMode.OSU, - GameMode.TAIKO, - GameMode.FRUITS, - GameMode.MANIA, - None, - ): - return "Invalid playmode." - - try: - beatmap = await Beatmap.get_or_fetch(session, await get_fetcher(), bid=map_id) - if beatmap.mode != GameMode.OSU and playmode and playmode != beatmap.mode: - return f"Cannot convert to {playmode.value}. Original mode is {beatmap.mode.value}." - except HTTPError: - return "Beatmap not found" - - try: - current_item = room.queue.current_item - item = current_item.model_copy(deep=True) - item.owner_id = signalr_client.user_id - item.beatmap_checksum = beatmap.checksum - item.required_mods = [] - item.allowed_mods = [] - item.freestyle = False - item.beatmap_id = map_id - if playmode is not None: - item.ruleset_id = int(playmode) - if item.expired: - item.id = 0 - item.expired = False - item.played_at = None - await MultiplayerHubs.AddPlaylistItem(signalr_client, item) - else: - await MultiplayerHubs.EditPlaylistItem(signalr_client, item) - return "" - except InvokeException as e: - return e.message - - -async def _mp_mods( - signalr_client: Client, - room: ServerMultiplayerRoom, - args: list[str], - session: AsyncSession, -) -> str: - if len(args) < 1: - return "Usage: !mp mods [ ...]" - - if room.status != RoomStatus.IDLE: - return "Cannot change mods while the game is running." - - required_mods = [] - allowed_mods = [] - freestyle = False - freemod = False - for arg in args: - arg = arg.upper() - if arg == "NONE": - required_mods.clear() - allowed_mods.clear() - break - elif arg == "FREESTYLE": - freestyle = True - elif arg == "FREEMOD": - freemod = True - elif arg.startswith("+"): - mod = arg.removeprefix("+") - if len(mod) != 2: - return f"Invalid mod: {mod}." - allowed_mods.append(APIMod(acronym=mod)) - else: - if len(arg) != 2: - return f"Invalid mod: {arg}." - required_mods.append(APIMod(acronym=arg)) - - try: - current_item = room.queue.current_item - item = current_item.model_copy(deep=True) - item.owner_id = signalr_client.user_id - item.freestyle = freestyle - if freestyle: - item.allowed_mods = [] - elif freemod: - item.allowed_mods = get_available_mods(current_item.ruleset_id, required_mods) - else: - item.allowed_mods = allowed_mods - item.required_mods = required_mods - if item.expired: - item.id = 0 - item.expired = False - item.played_at = None - await MultiplayerHubs.AddPlaylistItem(signalr_client, item) - else: - await MultiplayerHubs.EditPlaylistItem(signalr_client, item) - return "" - except InvokeException as e: - return e.message - - -_MP_COMMANDS = { - "name": _mp_name, - "set": _mp_set, - "host": _mp_host, - "start": _mp_start, - "abort": _mp_abort, - "map": _mp_map, - "mods": _mp_mods, - "kick": _mp_kick, - "password": _mp_password, - "team": _mp_team, -} -_MP_HELP = """!mp name -!mp set [] -!mp host -!mp start [] -!mp abort -!mp map [] -!mp mods [ ...] -!mp kick -!mp password [] -!mp team """ - - -@bot.command("mp") -async def _mp(user: User, args: list[str], session: AsyncSession, channel: ChatChannel): - if not channel.name.startswith("room_"): - return - - room_id = int(channel.name[5:]) - room = MultiplayerHubs.rooms.get(room_id) - if not room: - return - signalr_client = MultiplayerHubs.get_client_by_id(str(user.id)) - if not signalr_client: - return - - if len(args) < 1: - return f"Usage: !mp <{'|'.join(_MP_COMMANDS.keys())}> [args]" - - command = args[0].lower() - if command not in _MP_COMMANDS: - return f"No such command: {command}" - - return await _MP_COMMANDS[command](signalr_client, room, args[1:], session) - - async def _score( user_id: int, session: AsyncSession, diff --git a/app/router/v2/room.py b/app/router/v2/room.py index 21ea109..decf936 100644 --- a/app/router/v2/room.py +++ b/app/router/v2/room.py @@ -16,7 +16,6 @@ from app.dependencies.database import Database, Redis from app.dependencies.user import ClientUser, get_current_user from app.models.room import RoomCategory, RoomStatus from app.service.room import create_playlist_room_from_api -from app.signalr.hub import MultiplayerHubs from app.utils import utcnow from .router import router @@ -391,14 +390,12 @@ async def get_room_events( first_event_id = min(first_event_id, event.id) last_event_id = max(last_event_id, event.id) - if room := MultiplayerHubs.rooms.get(room_id): - current_playlist_item_id = room.queue.current_item.id - room_resp = await RoomResp.from_hub(room) - else: - room = (await db.exec(select(Room).where(Room.id == room_id))).first() - if room is None: - raise HTTPException(404, "Room not found") - room_resp = await RoomResp.from_db(room, db) + room = (await db.exec(select(Room).where(Room.id == room_id))).first() + if room is None: + raise HTTPException(404, "Room not found") + room_resp = await RoomResp.from_db(room, db) + if room.category == RoomCategory.REALTIME and room_resp.current_playlist_item: + current_playlist_item_id = room_resp.current_playlist_item.id users = await db.exec(select(User).where(col(User.id).in_(user_ids))) user_resps = [await UserResp.from_db(user, db) for user in users] diff --git a/app/service/message_queue_processor.py b/app/service/message_queue_processor.py index 41ef9bf..199d8ee 100644 --- a/app/service/message_queue_processor.py +++ b/app/service/message_queue_processor.py @@ -217,8 +217,7 @@ class MessageQueueProcessor: ): """通知客户端消息ID已更新""" try: - # 这里我们需要通过 SignalR 发送消息更新通知 - # 但为了避免循环依赖,我们将通过 Redis 发布消息更新事件 + # 通过 Redis 发布消息更新事件,由聊天通知服务分发到客户端 update_event = { "event": "chat.message.update", "data": { @@ -229,7 +228,6 @@ class MessageQueueProcessor: }, } - # 发布到 Redis 频道,让 SignalR 服务处理 await self._redis_exec( self.redis_message.publish, f"chat_updates:{channel_id}", diff --git a/app/service/subscribers/score_processed.py b/app/service/subscribers/score_processed.py deleted file mode 100644 index 6775b97..0000000 --- a/app/service/subscribers/score_processed.py +++ /dev/null @@ -1,85 +0,0 @@ -from __future__ import annotations - -import json -from typing import TYPE_CHECKING - -from app.database import PlaylistBestScore, Score -from app.database.playlist_best_score import get_position -from app.dependencies.database import with_db -from app.models.metadata_hub import MultiplayerRoomScoreSetEvent - -from .base import RedisSubscriber - -from sqlmodel import select - -if TYPE_CHECKING: - from app.signalr.hub import MetadataHub - - -CHANNEL = "osu-channel:score:processed" - - -class ScoreSubscriber(RedisSubscriber): - def __init__(self): - super().__init__() - self.room_subscriber: dict[int, list[int]] = {} - self.metadata_hub: "MetadataHub | None " = None - self.subscribed = False - self.handlers[CHANNEL] = [self._handler] - - async def subscribe_room_score(self, room_id: int, user_id: int): - if room_id not in self.room_subscriber: - await self.subscribe(CHANNEL) - self.start() - self.room_subscriber.setdefault(room_id, []).append(user_id) - - async def unsubscribe_room_score(self, room_id: int, user_id: int): - if room_id in self.room_subscriber: - try: - self.room_subscriber[room_id].remove(user_id) - except ValueError: - pass - if not self.room_subscriber[room_id]: - del self.room_subscriber[room_id] - - async def _notify_room_score_processed(self, score_id: int): - if not self.metadata_hub: - return - async with with_db() as session: - score = await session.get(Score, score_id) - if not score or not score.passed or score.room_id is None or score.playlist_item_id is None: - return - if not self.room_subscriber.get(score.room_id, []): - return - - new_rank = None - user_best = ( - await session.exec( - select(PlaylistBestScore).where( - PlaylistBestScore.user_id == score.user_id, - PlaylistBestScore.room_id == score.room_id, - ) - ) - ).first() - if user_best and user_best.score_id == score_id: - new_rank = await get_position( - user_best.room_id, - user_best.playlist_id, - user_best.score_id, - session, - ) - - event = MultiplayerRoomScoreSetEvent( - room_id=score.room_id, - playlist_item_id=score.playlist_item_id, - score_id=score_id, - user_id=score.user_id, - total_score=score.total_score, - new_rank=new_rank, - ) - await self.metadata_hub.notify_room_score_processed(event) - - async def _handler(self, channel: str, data: str): - score_id = json.loads(data)["ScoreId"] - if self.metadata_hub: - await self._notify_room_score_processed(score_id) diff --git a/app/signalr/__init__.py b/app/signalr/__init__.py deleted file mode 100644 index 5938238..0000000 --- a/app/signalr/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from __future__ import annotations - -from .router import router as signalr_router - -__all__ = ["signalr_router"] diff --git a/app/signalr/hub/__init__.py b/app/signalr/hub/__init__.py deleted file mode 100644 index 231ecf4..0000000 --- a/app/signalr/hub/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -from __future__ import annotations - -from .hub import Hub -from .metadata import MetadataHub -from .multiplayer import MultiplayerHub -from .spectator import SpectatorHub - -SpectatorHubs = SpectatorHub() -MultiplayerHubs = MultiplayerHub() -MetadataHubs = MetadataHub() -Hubs: dict[str, Hub] = { - "spectator": SpectatorHubs, - "multiplayer": MultiplayerHubs, - "metadata": MetadataHubs, -} diff --git a/app/signalr/hub/hub.py b/app/signalr/hub/hub.py deleted file mode 100644 index daeba34..0000000 --- a/app/signalr/hub/hub.py +++ /dev/null @@ -1,322 +0,0 @@ -from __future__ import annotations - -from abc import abstractmethod -import asyncio -import time -from typing import Any - -from app.config import settings -from app.exception import InvokeException -from app.log import logger -from app.models.signalr import UserState -from app.signalr.packet import ( - ClosePacket, - CompletionPacket, - InvocationPacket, - Packet, - PingPacket, - Protocol, -) -from app.signalr.store import ResultStore -from app.signalr.utils import get_signature - -from fastapi import WebSocket -from starlette.websockets import WebSocketDisconnect - - -class CloseConnection(Exception): - def __init__( - self, - message: str = "Connection closed", - allow_reconnect: bool = False, - from_client: bool = False, - ) -> None: - super().__init__(message) - self.message = message - self.allow_reconnect = allow_reconnect - self.from_client = from_client - - -class Client: - def __init__( - self, - connection_id: str, - connection_token: str, - connection: WebSocket, - protocol: Protocol, - ) -> None: - self.connection_id = connection_id - self.connection_token = connection_token - self.connection = connection - self.protocol = protocol - self._listen_task: asyncio.Task | None = None - self._ping_task: asyncio.Task | None = None - self._store = ResultStore() - - def __hash__(self) -> int: - return hash(self.connection_token) - - @property - def user_id(self) -> int: - return int(self.connection_id) - - async def send_packet(self, packet: Packet): - await self.connection.send_bytes(self.protocol.encode(packet)) - - async def receive_packets(self) -> list[Packet]: - message = await self.connection.receive() - d = message.get("bytes") or message.get("text", "").encode() - if not d: - return [] - return self.protocol.decode(d) - - async def _ping(self): - while True: - try: - await self.send_packet(PingPacket()) - await asyncio.sleep(settings.signalr_ping_interval) - except WebSocketDisconnect: - break - except RuntimeError as e: - if "disconnect message" in str(e) or "close message" in str(e): - break - else: - logger.error(f"Error in ping task for {self.connection_id}: {e}") - break - except Exception: - logger.exception(f"Error in client {self.connection_id}") - - -class Hub[TState: UserState]: - def __init__(self) -> None: - self.clients: dict[str, Client] = {} - self.waited_clients: dict[str, int] = {} - self.tasks: set[asyncio.Task] = set() - self.groups: dict[str, set[Client]] = {} - self.state: dict[int, TState] = {} - - def add_waited_client(self, connection_token: str, timestamp: int) -> None: - self.waited_clients[connection_token] = timestamp - - def get_client_by_id(self, id: str, default: Any = None) -> Client: - for client in self.clients.values(): - if client.connection_id == id: - return client - return default - - def get_before_clients(self, id: str, current_token: str) -> list[Client]: - clients = [] - for client in self.clients.values(): - if client.connection_id != id: - continue - if client.connection_token == current_token: - continue - clients.append(client) - return clients - - @abstractmethod - def create_state(self, client: Client) -> TState: - raise NotImplementedError - - def get_or_create_state(self, client: Client) -> TState: - if (state := self.state.get(client.user_id)) is not None: - return state - state = self.create_state(client) - self.state[client.user_id] = state - return state - - def add_to_group(self, client: Client, group_id: str) -> None: - self.groups.setdefault(group_id, set()).add(client) - - def remove_from_group(self, client: Client, group_id: str) -> None: - if group_id in self.groups: - self.groups[group_id].discard(client) - - async def kick_client(self, client: Client) -> None: - await self.call_noblock(client, "DisconnectRequested") - await client.send_packet(ClosePacket(allow_reconnect=False)) - await client.connection.close(code=1000, reason="Disconnected by server") - - async def add_client( - self, - connection_id: str, - connection_token: str, - protocol: Protocol, - connection: WebSocket, - ) -> Client: - if connection_token in self.clients: - raise ValueError(f"Client with connection token {connection_token} already exists.") - if connection_token in self.waited_clients: - if self.waited_clients[connection_token] < time.time() - settings.signalr_negotiate_timeout: - raise TimeoutError(f"Connection {connection_id} has waited too long.") - del self.waited_clients[connection_token] - client = Client(connection_id, connection_token, connection, protocol) - self.clients[connection_token] = client - task = asyncio.create_task(client._ping()) - self.tasks.add(task) - client._ping_task = task - return client - - async def remove_client(self, client: Client) -> None: - if client.connection_token not in self.clients: - return - del self.clients[client.connection_token] - if client._listen_task: - client._listen_task.cancel() - if client._ping_task: - client._ping_task.cancel() - for group in self.groups.values(): - group.discard(client) - await self.clean_state(client, False) - - @abstractmethod - async def _clean_state(self, state: TState) -> None: - return - - async def clean_state(self, client: Client, disconnected: bool) -> None: - if (state := self.state.get(client.user_id)) is None: - return - if disconnected and client.connection_token != state.connection_token: - return - try: - await self._clean_state(state) - del self.state[client.user_id] - except Exception: - ... - - async def on_connect(self, client: Client) -> None: - if method := getattr(self, "on_client_connect", None): - await method(client) - - async def send_packet(self, client: Client, packet: Packet) -> None: - logger.trace(f"[SignalR] send to {client.connection_id} packet {packet}") - try: - await client.send_packet(packet) - except WebSocketDisconnect as e: - logger.info(f"Client {client.connection_id} disconnected: {e.code}, {e.reason}") - await self.remove_client(client) - except RuntimeError as e: - if "disconnect message" in str(e): - logger.info(f"Client {client.connection_id} closed the connection.") - else: - logger.exception(f"RuntimeError in client {client.connection_id}: {e}") - await self.remove_client(client) - except Exception: - logger.exception(f"Error in client {client.connection_id}") - await self.remove_client(client) - - async def broadcast_call(self, method: str, *args: Any) -> None: - tasks = [] - for client in self.clients.values(): - tasks.append(self.call_noblock(client, method, *args)) - await asyncio.gather(*tasks) - - async def broadcast_group_call(self, group_id: str, method: str, *args: Any) -> None: - tasks = [] - for client in self.groups.get(group_id, []): - tasks.append(self.call_noblock(client, method, *args)) - await asyncio.gather(*tasks) - - async def _listen_client(self, client: Client) -> None: - try: - while True: - packets = await client.receive_packets() - for packet in packets: - if isinstance(packet, PingPacket): - continue - elif isinstance(packet, ClosePacket): - raise CloseConnection( - packet.error or "Connection closed by client", - packet.allow_reconnect, - True, - ) - task = asyncio.create_task(self._handle_packet(client, packet)) - self.tasks.add(task) - task.add_done_callback(self.tasks.discard) - except WebSocketDisconnect as e: - logger.info(f"Client {client.connection_id} disconnected: {e.code}, {e.reason}") - except RuntimeError as e: - if "disconnect message" in str(e): - logger.info(f"Client {client.connection_id} closed the connection.") - else: - logger.exception(f"RuntimeError in client {client.connection_id}: {e}") - except CloseConnection as e: - if not e.from_client: - await client.send_packet(ClosePacket(error=e.message, allow_reconnect=e.allow_reconnect)) - logger.info(f"Client {client.connection_id} closed the connection: {e.message}") - except Exception: - logger.exception(f"Error in client {client.connection_id}") - - await self.remove_client(client) - - async def _handle_packet(self, client: Client, packet: Packet) -> None: - if isinstance(packet, PingPacket): - return - elif isinstance(packet, InvocationPacket): - args = packet.arguments or [] - error = None - result = None - try: - result = await self.invoke_method(client, packet.target, args) - except InvokeException as e: - error = e.message - logger.debug(f"Client {client.connection_token} call {packet.target} failed: {error}") - except Exception: - logger.exception(f"Error invoking method {packet.target} for client {client.connection_id}") - error = "Unknown error occured in server" - if packet.invocation_id is not None: - await self.send_packet( - client, - CompletionPacket( - invocation_id=packet.invocation_id, - error=error, - result=result, - ), - ) - elif isinstance(packet, CompletionPacket): - client._store.add_result(packet.invocation_id, packet.result, packet.error) - - async def invoke_method(self, client: Client, method: str, args: list[Any]) -> Any: - method_ = getattr(self, method, None) - call_params = [] - if not method_: - raise InvokeException(f"Method '{method}' not found in hub.") - signature = get_signature(method_) - for name, param in signature.parameters.items(): - if name == "self" or param.annotation is Client: - continue - call_params.append(client.protocol.validate_object(args.pop(0), param.annotation)) - return await method_(client, *call_params) - - async def call(self, client: Client, method: str, *args: Any) -> Any: - invocation_id = client._store.get_invocation_id() - await self.send_packet( - client, - InvocationPacket( - header={}, - invocation_id=invocation_id, - target=method, - arguments=list(args), - stream_ids=None, - ), - ) - r = await client._store.fetch(invocation_id, None) - if r[1]: - raise InvokeException(r[1]) - return r[0] - - async def call_noblock(self, client: Client, method: str, *args: Any) -> None: - await self.send_packet( - client, - InvocationPacket( - header={}, - invocation_id=None, - target=method, - arguments=list(args), - stream_ids=None, - ), - ) - return None - - def __contains__(self, item: str) -> bool: - return item in self.clients or item in self.waited_clients diff --git a/app/signalr/hub/metadata.py b/app/signalr/hub/metadata.py deleted file mode 100644 index 1052be5..0000000 --- a/app/signalr/hub/metadata.py +++ /dev/null @@ -1,296 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections import defaultdict -from collections.abc import Coroutine -import math -from typing import override - -from app.calculator import clamp -from app.database import Relationship, RelationshipType, User -from app.database.playlist_best_score import PlaylistBestScore -from app.database.playlists import Playlist -from app.database.room import Room -from app.database.score import Score -from app.dependencies.database import with_db -from app.log import logger -from app.models.metadata_hub import ( - TOTAL_SCORE_DISTRIBUTION_BINS, - DailyChallengeInfo, - MetadataClientState, - MultiplayerPlaylistItemStats, - MultiplayerRoomScoreSetEvent, - MultiplayerRoomStats, - OnlineStatus, - UserActivity, -) -from app.models.room import RoomCategory -from app.service.subscribers.score_processed import ScoreSubscriber -from app.utils import utcnow - -from .hub import Client, Hub - -from sqlmodel import col, select - -ONLINE_PRESENCE_WATCHERS_GROUP = "metadata:online-presence-watchers" - - -class MetadataHub(Hub[MetadataClientState]): - def __init__(self) -> None: - super().__init__() - self.subscriber = ScoreSubscriber() - self.subscriber.metadata_hub = self - self._daily_challenge_stats: MultiplayerRoomStats | None = None - self._today = utcnow().date() - self._lock = asyncio.Lock() - - def get_daily_challenge_stats(self, daily_challenge_room: int) -> MultiplayerRoomStats: - if self._daily_challenge_stats is None or self._today != utcnow().date(): - self._daily_challenge_stats = MultiplayerRoomStats( - room_id=daily_challenge_room, - playlist_item_stats={}, - ) - return self._daily_challenge_stats - - @staticmethod - def online_presence_watchers_group() -> str: - return ONLINE_PRESENCE_WATCHERS_GROUP - - @staticmethod - def room_watcher_group(room_id: int) -> str: - return f"metadata:multiplayer-room-watchers:{room_id}" - - def broadcast_tasks(self, user_id: int, store: MetadataClientState | None) -> set[Coroutine]: - if store is not None and not store.pushable: - return set() - data = store.for_push if store else None - return { - self.broadcast_group_call( - self.online_presence_watchers_group(), - "UserPresenceUpdated", - user_id, - data, - ), - self.broadcast_group_call( - self.friend_presence_watchers_group(user_id), - "FriendPresenceUpdated", - user_id, - data, - ), - } - - @staticmethod - def friend_presence_watchers_group(user_id: int): - return f"metadata:friend-presence-watchers:{user_id}" - - @override - async def _clean_state(self, state: MetadataClientState) -> None: - user_id = int(state.connection_id) - - if state.pushable: - await asyncio.gather(*self.broadcast_tasks(user_id, None)) - - async with with_db() as session: - async with session.begin(): - user = (await session.exec(select(User).where(User.id == int(state.connection_id)))).one() - user.last_visit = utcnow() - await session.commit() - - @override - def create_state(self, client: Client) -> MetadataClientState: - return MetadataClientState( - connection_id=client.connection_id, - connection_token=client.connection_token, - ) - - async def on_client_connect(self, client: Client) -> None: - user_id = int(client.connection_id) - store = self.get_or_create_state(client) - - # CRITICAL FIX: Set online status IMMEDIATELY upon connection - # This matches the C# official implementation behavior - store.status = OnlineStatus.ONLINE - logger.info(f"[MetadataHub] Set user {user_id} status to ONLINE upon connection") - - async with with_db() as session: - async with session.begin(): - friends = ( - await session.exec( - select(Relationship.target_id).where( - Relationship.user_id == user_id, - Relationship.type == RelationshipType.FOLLOW, - ) - ) - ).all() - tasks = [] - for friend_id in friends: - self.groups.setdefault(self.friend_presence_watchers_group(friend_id), set()).add(client) - if (friend_state := self.state.get(friend_id)) and friend_state.pushable: - tasks.append( - self.broadcast_group_call( - self.friend_presence_watchers_group(friend_id), - "FriendPresenceUpdated", - friend_id, - friend_state.for_push if friend_state.pushable else None, - ) - ) - await asyncio.gather(*tasks) - - daily_challenge_room = ( - await session.exec( - select(Room).where( - col(Room.ends_at) > utcnow(), - Room.category == RoomCategory.DAILY_CHALLENGE, - ) - ) - ).first() - if daily_challenge_room: - await self.call_noblock( - client, - "DailyChallengeUpdated", - DailyChallengeInfo( - room_id=daily_challenge_room.id, - ), - ) - - # CRITICAL FIX: Immediately broadcast the user's online status to all watchers - # This ensures the user appears as "currently online" right after connection - # Similar to the C# implementation's immediate broadcast logic - online_presence_tasks = self.broadcast_tasks(user_id, store) - if online_presence_tasks: - await asyncio.gather(*online_presence_tasks) - logger.info(f"[MetadataHub] Broadcasted online status for user {user_id} to watchers") - - # Also send the user's own presence update to confirm online status - await self.call_noblock( - client, - "UserPresenceUpdated", - user_id, - store.for_push, - ) - logger.info(f"[MetadataHub] User {user_id} is now ONLINE and visible to other clients") - - async def UpdateStatus(self, client: Client, status: int) -> None: - status_ = OnlineStatus(status) - user_id = int(client.connection_id) - store = self.get_or_create_state(client) - if store.status is not None and store.status == status_: - return - store.status = OnlineStatus(status_) - tasks = self.broadcast_tasks(user_id, store) - tasks.add( - self.call_noblock( - client, - "UserPresenceUpdated", - user_id, - store.for_push, - ) - ) - await asyncio.gather(*tasks) - - async def UpdateActivity(self, client: Client, activity: UserActivity | None) -> None: - user_id = int(client.connection_id) - store = self.get_or_create_state(client) - store.activity = activity - tasks = self.broadcast_tasks(user_id, store) - tasks.add( - self.call_noblock( - client, - "UserPresenceUpdated", - user_id, - store.for_push, - ) - ) - await asyncio.gather(*tasks) - - async def BeginWatchingUserPresence(self, client: Client) -> None: - # Critical fix: Send all currently online users to the new watcher - # Must use for_push to get the correct UserPresence format - await asyncio.gather( - *[ - self.call_noblock( - client, - "UserPresenceUpdated", - user_id, - store.for_push, # Fixed: use for_push instead of store - ) - for user_id, store in self.state.items() - if store.pushable - ] - ) - self.add_to_group(client, self.online_presence_watchers_group()) - logger.info( - f"[MetadataHub] Client {client.connection_id} now watching user presence, " - f"sent {len([s for s in self.state.values() if s.pushable])} online users" - ) - - async def EndWatchingUserPresence(self, client: Client) -> None: - self.remove_from_group(client, self.online_presence_watchers_group()) - - async def notify_room_score_processed(self, event: MultiplayerRoomScoreSetEvent): - await self.broadcast_group_call(self.room_watcher_group(event.room_id), "MultiplayerRoomScoreSet", event) - - async def BeginWatchingMultiplayerRoom(self, client: Client, room_id: int): - self.add_to_group(client, self.room_watcher_group(room_id)) - await self.subscriber.subscribe_room_score(room_id, client.user_id) - stats = self.get_daily_challenge_stats(room_id) - await self.update_daily_challenge_stats(stats) - return list(stats.playlist_item_stats.values()) - - async def update_daily_challenge_stats(self, stats: MultiplayerRoomStats) -> None: - async with with_db() as session: - playlist_ids = ( - await session.exec( - select(Playlist.id).where( - Playlist.room_id == stats.room_id, - ) - ) - ).all() - for playlist_id in playlist_ids: - item = stats.playlist_item_stats.get(playlist_id, None) - if item is None: - item = MultiplayerPlaylistItemStats( - playlist_item_id=playlist_id, - total_score_distribution=[0] * TOTAL_SCORE_DISTRIBUTION_BINS, - cumulative_score=0, - last_processed_score_id=0, - ) - stats.playlist_item_stats[playlist_id] = item - last_processed_score_id = item.last_processed_score_id - scores = ( - await session.exec( - select(PlaylistBestScore).where( - PlaylistBestScore.room_id == stats.room_id, - PlaylistBestScore.playlist_id == playlist_id, - PlaylistBestScore.score_id > last_processed_score_id, - col(PlaylistBestScore.score).has(col(Score.passed).is_(True)), - ) - ) - ).all() - if len(scores) == 0: - continue - - async with self._lock: - if item.last_processed_score_id == last_processed_score_id: - totals = defaultdict(int) - for score in scores: - bin_index = int( - clamp( - math.floor(score.total_score / 100000), - 0, - TOTAL_SCORE_DISTRIBUTION_BINS - 1, - ) - ) - totals[bin_index] += 1 - - item.cumulative_score += sum(score.total_score for score in scores) - - for j in range(TOTAL_SCORE_DISTRIBUTION_BINS): - item.total_score_distribution[j] += totals.get(j, 0) - - if scores: - item.last_processed_score_id = max(score.score_id for score in scores) - - async def EndWatchingMultiplayerRoom(self, client: Client, room_id: int): - self.remove_from_group(client, self.room_watcher_group(room_id)) - await self.subscriber.unsubscribe_room_score(room_id, client.user_id) diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py deleted file mode 100644 index 345de1d..0000000 --- a/app/signalr/hub/multiplayer.py +++ /dev/null @@ -1,1393 +0,0 @@ -from __future__ import annotations - -import asyncio -from datetime import timedelta -from typing import override - -from app.database import Room -from app.database.beatmap import Beatmap -from app.database.chat import ChannelType, ChatChannel -from app.database.multiplayer_event import MultiplayerEvent -from app.database.playlists import Playlist -from app.database.relationship import Relationship, RelationshipType -from app.database.room_participated_user import RoomParticipatedUser -from app.database.user import User -from app.dependencies.database import get_redis, with_db -from app.dependencies.fetcher import get_fetcher -from app.exception import InvokeException -from app.log import logger -from app.models.mods import APIMod -from app.models.multiplayer_hub import ( - BeatmapAvailability, - ForceGameplayStartCountdown, - GameplayAbortReason, - MatchRequest, - MatchServerEvent, - MatchStartCountdown, - MatchStartedEventDetail, - MultiplayerClientState, - MultiplayerRoom, - MultiplayerRoomSettings, - MultiplayerRoomUser, - PlaylistItem, - ServerMultiplayerRoom, - ServerShuttingDownCountdown, - StartMatchCountdownRequest, - StopCountdownRequest, -) -from app.models.room import ( - DownloadState, - MatchType, - MultiplayerRoomState, - MultiplayerUserState, - RoomCategory, - RoomStatus, -) -from app.models.score import GameMode -from app.utils import utcnow - -from .hub import Client, Hub - -from httpx import HTTPError -from sqlalchemy import update -from sqlmodel import col, exists, select - -GAMEPLAY_LOAD_TIMEOUT = 30 - - -class MultiplayerEventLogger: - def __init__(self): - pass - - async def log_event(self, event: MultiplayerEvent): - try: - async with with_db() as session: - session.add(event) - await session.commit() - except Exception as e: - logger.warning(f"Failed to log multiplayer room event to database: {e}") - - async def room_created(self, room_id: int, user_id: int): - event = MultiplayerEvent( - room_id=room_id, - user_id=user_id, - event_type="room_created", - ) - await self.log_event(event) - - async def room_disbanded(self, room_id: int, user_id: int): - event = MultiplayerEvent( - room_id=room_id, - user_id=user_id, - event_type="room_disbanded", - ) - await self.log_event(event) - - async def player_joined(self, room_id: int, user_id: int): - event = MultiplayerEvent( - room_id=room_id, - user_id=user_id, - event_type="player_joined", - ) - await self.log_event(event) - - async def player_left(self, room_id: int, user_id: int): - event = MultiplayerEvent( - room_id=room_id, - user_id=user_id, - event_type="player_left", - ) - await self.log_event(event) - - async def player_kicked(self, room_id: int, user_id: int): - event = MultiplayerEvent( - room_id=room_id, - user_id=user_id, - event_type="player_kicked", - ) - await self.log_event(event) - - async def host_changed(self, room_id: int, user_id: int): - event = MultiplayerEvent( - room_id=room_id, - user_id=user_id, - event_type="host_changed", - ) - await self.log_event(event) - - async def game_started(self, room_id: int, playlist_item_id: int, details: MatchStartedEventDetail): - event = MultiplayerEvent( - room_id=room_id, - playlist_item_id=playlist_item_id, - event_type="game_started", - event_detail=details, # pyright: ignore[reportArgumentType] - ) - await self.log_event(event) - - async def game_aborted(self, room_id: int, playlist_item_id: int): - event = MultiplayerEvent( - room_id=room_id, - playlist_item_id=playlist_item_id, - event_type="game_aborted", - ) - await self.log_event(event) - - async def game_completed(self, room_id: int, playlist_item_id: int): - event = MultiplayerEvent( - room_id=room_id, - playlist_item_id=playlist_item_id, - event_type="game_completed", - ) - await self.log_event(event) - - -class MultiplayerHub(Hub[MultiplayerClientState]): - @override - def __init__(self): - super().__init__() - self.rooms: dict[int, ServerMultiplayerRoom] = {} - self.event_logger = MultiplayerEventLogger() - - @staticmethod - def group_id(room: int) -> str: - return f"room:{room}" - - @override - def create_state(self, client: Client) -> MultiplayerClientState: - return MultiplayerClientState( - connection_id=client.connection_id, - connection_token=client.connection_token, - ) - - @override - async def _clean_state(self, state: MultiplayerClientState): - user_id = int(state.connection_id) - - if state.room_id != 0 and state.room_id in self.rooms: - server_room = self.rooms[state.room_id] - room = server_room.room - user = next((u for u in room.users if u.user_id == user_id), None) - if user is not None: - await self.make_user_leave(self.get_client_by_id(str(user_id)), server_room, user) - - async def on_client_connect(self, client: Client) -> None: - """Track online users when connecting to multiplayer hub""" - logger.info(f"[MultiplayerHub] Client {client.user_id} connected") - - def _ensure_in_room(self, client: Client) -> ServerMultiplayerRoom: - store = self.get_or_create_state(client) - if store.room_id == 0: - raise InvokeException("You are not in a room") - if store.room_id not in self.rooms: - raise InvokeException("Room does not exist") - server_room = self.rooms[store.room_id] - return server_room - - def _ensure_host(self, client: Client, server_room: ServerMultiplayerRoom): - room = server_room.room - if room.host is None or room.host.user_id != client.user_id: - raise InvokeException("You are not the host of this room") - - async def CreateRoom(self, client: Client, room: MultiplayerRoom): - logger.info(f"[MultiplayerHub] {client.user_id} creating room") - store = self.get_or_create_state(client) - if store.room_id != 0: - raise InvokeException("You are already in a room") - async with with_db() as session: - async with session: - db_room = Room( - name=room.settings.name, - category=RoomCategory.REALTIME, - type=room.settings.match_type, - queue_mode=room.settings.queue_mode, - auto_skip=room.settings.auto_skip, - auto_start_duration=int(room.settings.auto_start_duration.total_seconds()), - host_id=client.user_id, - status=RoomStatus.IDLE, - ) - session.add(db_room) - await session.commit() - await session.refresh(db_room) - - channel = ChatChannel( - name=f"room_{db_room.id}", - description="Multiplayer room", - type=ChannelType.MULTIPLAYER, - ) - session.add(channel) - await session.commit() - await session.refresh(channel) - await session.refresh(db_room) - room.channel_id = channel.channel_id - db_room.channel_id = channel.channel_id - - item = room.playlist[0] - item.owner_id = client.user_id - room.room_id = db_room.id - starts_at = db_room.starts_at or utcnow() - beatmap_exists = await session.exec(select(exists().where(col(Beatmap.id) == item.beatmap_id))) - if not beatmap_exists.one(): - fetcher = await get_fetcher() - try: - await Beatmap.get_or_fetch(session, fetcher, bid=item.beatmap_id) - except HTTPError: - raise InvokeException("Failed to fetch beatmap, please retry later") - await Playlist.add_to_db(item, room.room_id, session) - - server_room = ServerMultiplayerRoom( - room=room, - category=RoomCategory.NORMAL, - start_at=starts_at, - hub=self, - ) - self.rooms[room.room_id] = server_room - await server_room.set_handler() - await self.event_logger.room_created(room.room_id, client.user_id) - return await self.JoinRoomWithPassword(client, room.room_id, room.settings.password) - - async def JoinRoom(self, client: Client, room_id: int): - return self.JoinRoomWithPassword(client, room_id, "") - - async def JoinRoomWithPassword(self, client: Client, room_id: int, password: str): - logger.info(f"[MultiplayerHub] {client.user_id} joining room {room_id}") - store = self.get_or_create_state(client) - if store.room_id != 0: - raise InvokeException("You are already in a room") - user = MultiplayerRoomUser(user_id=client.user_id) - if room_id not in self.rooms: - raise InvokeException("Room does not exist") - server_room = self.rooms[room_id] - room = server_room.room - for u in room.users: - if u.user_id == client.user_id: - raise InvokeException("You are already in this room") - if room.settings.password != password: - raise InvokeException("Incorrect password") - if room.host is None: - # from CreateRoom - room.host = user - store.room_id = room_id - await self.broadcast_group_call(self.group_id(room_id), "UserJoined", user) - room.users.append(user) - self.add_to_group(client, self.group_id(room_id)) - await server_room.match_type_handler.handle_join(user) - - # Critical fix: Send current room and gameplay state to new user - # This ensures spectators joining ongoing games get proper state sync - await self._send_room_state_to_new_user(client, server_room) - - await self.event_logger.player_joined(room_id, user.user_id) - - async with with_db() as session: - async with session.begin(): - if ( - participated_user := ( - await session.exec( - select(RoomParticipatedUser).where( - RoomParticipatedUser.room_id == room_id, - RoomParticipatedUser.user_id == client.user_id, - ) - ) - ).first() - ) is None: - participated_user = RoomParticipatedUser( - room_id=room_id, - user_id=client.user_id, - ) - session.add(participated_user) - else: - participated_user.left_at = None - participated_user.joined_at = utcnow() - - db_room = await session.get(Room, room_id) - if db_room is None: - raise InvokeException("Room does not exist in database") - db_room.participant_count += 1 - - redis = get_redis() - await redis.publish("chat:room:joined", f"{room.channel_id}:{user.user_id}") - - return room - - async def change_beatmap_availability( - self, - room_id: int, - user: MultiplayerRoomUser, - beatmap_availability: BeatmapAvailability, - ): - availability = user.availability - if ( - availability.state == beatmap_availability.state - and availability.download_progress == beatmap_availability.download_progress - ): - return - user.availability = beatmap_availability - await self.broadcast_group_call( - self.group_id(room_id), - "UserBeatmapAvailabilityChanged", - user.user_id, - beatmap_availability, - ) - - async def ChangeBeatmapAvailability(self, client: Client, beatmap_availability: BeatmapAvailability): - server_room = self._ensure_in_room(client) - room = server_room.room - user = next((u for u in room.users if u.user_id == client.user_id), None) - if user is None: - raise InvokeException("You are not in this room") - await self.change_beatmap_availability( - room.room_id, - user, - beatmap_availability, - ) - - async def AddPlaylistItem(self, client: Client, item: PlaylistItem): - server_room = self._ensure_in_room(client) - room = server_room.room - - user = next((u for u in room.users if u.user_id == client.user_id), None) - if user is None: - raise InvokeException("You are not in this room") - logger.info(f"[MultiplayerHub] {client.user_id} adding beatmap {item.beatmap_id} to room {room.room_id}") - await server_room.queue.add_item( - item, - user, - ) - - async def EditPlaylistItem(self, client: Client, item: PlaylistItem): - server_room = self._ensure_in_room(client) - room = server_room.room - - user = next((u for u in room.users if u.user_id == client.user_id), None) - if user is None: - raise InvokeException("You are not in this room") - - logger.info(f"[MultiplayerHub] {client.user_id} editing item {item.id} in room {room.room_id}") - await server_room.queue.edit_item( - item, - user, - ) - - async def RemovePlaylistItem(self, client: Client, item_id: int): - server_room = self._ensure_in_room(client) - room = server_room.room - - user = next((u for u in room.users if u.user_id == client.user_id), None) - if user is None: - raise InvokeException("You are not in this room") - - logger.info(f"[MultiplayerHub] {client.user_id} removing item {item_id} from room {room.room_id}") - await server_room.queue.remove_item( - item_id, - user, - ) - - async def change_db_settings(self, room: ServerMultiplayerRoom): - async with with_db() as session: - await session.execute( - update(Room) - .where(col(Room.id) == room.room.room_id) - .values( - name=room.room.settings.name, - type=room.room.settings.match_type, - queue_mode=room.room.settings.queue_mode, - auto_skip=room.room.settings.auto_skip, - auto_start_duration=int(room.room.settings.auto_start_duration.total_seconds()), - host_id=room.room.host.user_id if room.room.host else None, - ) - ) - await session.commit() - - async def setting_changed(self, room: ServerMultiplayerRoom, beatmap_changed: bool): - await self.change_db_settings(room) - await self.validate_styles(room) - await self.unready_all_users(room, beatmap_changed) - await self.broadcast_group_call( - self.group_id(room.room.room_id), - "SettingsChanged", - room.room.settings, - ) - - async def playlist_added(self, room: ServerMultiplayerRoom, item: PlaylistItem): - await self.broadcast_group_call( - self.group_id(room.room.room_id), - "PlaylistItemAdded", - item, - ) - - async def playlist_removed(self, room: ServerMultiplayerRoom, item_id: int): - await self.broadcast_group_call( - self.group_id(room.room.room_id), - "PlaylistItemRemoved", - item_id, - ) - - async def playlist_changed(self, room: ServerMultiplayerRoom, item: PlaylistItem, beatmap_changed: bool): - if item.id == room.room.settings.playlist_item_id: - await self.validate_styles(room) - await self.unready_all_users(room, beatmap_changed) - await self.broadcast_group_call( - self.group_id(room.room.room_id), - "PlaylistItemChanged", - item, - ) - - async def ChangeUserStyle(self, client: Client, beatmap_id: int | None, ruleset_id: int | None): - server_room = self._ensure_in_room(client) - room = server_room.room - user = next((u for u in room.users if u.user_id == client.user_id), None) - if user is None: - raise InvokeException("You are not in this room") - - await self.change_user_style( - beatmap_id, - ruleset_id, - server_room, - user, - ) - - async def validate_styles(self, room: ServerMultiplayerRoom): - fetcher = await get_fetcher() - if not room.queue.current_item.freestyle: - for user in room.room.users: - await self.change_user_style( - None, - None, - room, - user, - ) - async with with_db() as session: - try: - beatmap = await Beatmap.get_or_fetch(session, fetcher, bid=room.queue.current_item.beatmap_id) - except HTTPError: - raise InvokeException("Current item beatmap not found") - beatmap_ids = ( - await session.exec( - select(Beatmap.id, Beatmap.mode).where( - Beatmap.beatmapset_id == beatmap.beatmapset_id, - ) - ) - ).all() - for user in room.room.users: - beatmap_id = user.beatmap_id - ruleset_id = user.ruleset_id - user_beatmap = next( - (b for b in beatmap_ids if b[0] == beatmap_id), - None, - ) - if beatmap_id is not None and user_beatmap is None: - beatmap_id = None - beatmap_ruleset = user_beatmap[1] if user_beatmap else beatmap.mode - if ruleset_id is not None and beatmap_ruleset != GameMode.OSU and ruleset_id != beatmap_ruleset: - ruleset_id = None - await self.change_user_style( - beatmap_id, - ruleset_id, - room, - user, - ) - - for user in room.room.users: - is_valid, valid_mods = room.queue.current_item.validate_user_mods(user, user.mods) - if not is_valid: - await self.change_user_mods(valid_mods, room, user) - - async def change_user_style( - self, - beatmap_id: int | None, - ruleset_id: int | None, - room: ServerMultiplayerRoom, - user: MultiplayerRoomUser, - ): - if user.beatmap_id == beatmap_id and user.ruleset_id == ruleset_id: - return - - if beatmap_id is not None or ruleset_id is not None: - if not room.queue.current_item.freestyle: - raise InvokeException("Current item does not allow free user styles.") - - async with with_db() as session: - item_beatmap = await session.get(Beatmap, room.queue.current_item.beatmap_id) - if item_beatmap is None: - raise InvokeException("Item beatmap not found") - - user_beatmap = item_beatmap if beatmap_id is None else await session.get(Beatmap, beatmap_id) - - if user_beatmap is None: - raise InvokeException("Invalid beatmap selected.") - - if user_beatmap.beatmapset_id != item_beatmap.beatmapset_id: - raise InvokeException("Selected beatmap is not from the same beatmap set.") - - if ( - ruleset_id is not None - and user_beatmap.mode != GameMode.OSU - and ruleset_id != int(user_beatmap.mode) - ): - raise InvokeException("Selected ruleset is not supported for the given beatmap.") - - user.beatmap_id = beatmap_id - user.ruleset_id = ruleset_id - - await self.broadcast_group_call( - self.group_id(room.room.room_id), - "UserStyleChanged", - user.user_id, - beatmap_id, - ruleset_id, - ) - - async def ChangeUserMods(self, client: Client, new_mods: list[APIMod]): - server_room = self._ensure_in_room(client) - room = server_room.room - user = next((u for u in room.users if u.user_id == client.user_id), None) - if user is None: - raise InvokeException("You are not in this room") - - await self.change_user_mods(new_mods, server_room, user) - - async def change_user_mods( - self, - new_mods: list[APIMod], - room: ServerMultiplayerRoom, - user: MultiplayerRoomUser, - ): - is_valid, valid_mods = room.queue.current_item.validate_user_mods(user, new_mods) - if not is_valid: - incompatible_mods = [mod["acronym"] for mod in new_mods if mod not in valid_mods] - raise InvokeException(f"Incompatible mods were selected: {','.join(incompatible_mods)}") - - if user.mods == valid_mods: - return - - user.mods = valid_mods - - await self.broadcast_group_call( - self.group_id(room.room.room_id), - "UserModsChanged", - user.user_id, - valid_mods, - ) - - async def validate_user_stare( - self, - room: ServerMultiplayerRoom, - old: MultiplayerUserState, - new: MultiplayerUserState, - ): - match new: - case MultiplayerUserState.IDLE: - if old.is_playing: - raise InvokeException("Cannot return to idle without aborting gameplay.") - case MultiplayerUserState.READY: - if old != MultiplayerUserState.IDLE: - raise InvokeException(f"Cannot change state from {old} to {new}") - if room.queue.current_item.expired: - raise InvokeException("Cannot ready up while all items have been played.") - case MultiplayerUserState.WAITING_FOR_LOAD: - raise InvokeException(f"Cannot change state from {old} to {new}") - case MultiplayerUserState.LOADED: - if old != MultiplayerUserState.WAITING_FOR_LOAD: - raise InvokeException(f"Cannot change state from {old} to {new}") - case MultiplayerUserState.READY_FOR_GAMEPLAY: - if old != MultiplayerUserState.LOADED: - raise InvokeException(f"Cannot change state from {old} to {new}") - case MultiplayerUserState.PLAYING: - raise InvokeException("State is managed by the server.") - case MultiplayerUserState.FINISHED_PLAY: - if old != MultiplayerUserState.PLAYING: - raise InvokeException(f"Cannot change state from {old} to {new}") - case MultiplayerUserState.RESULTS: - # Allow server-managed transitions to RESULTS state - # This includes spectators who need to see results - if old not in ( - MultiplayerUserState.FINISHED_PLAY, - MultiplayerUserState.SPECTATING, # Allow spectators to see results - ): - raise InvokeException(f"Cannot change state from {old} to {new}") - case MultiplayerUserState.SPECTATING: - # Enhanced spectator validation - allow transitions from more states - # This matches official osu-server-spectator behavior - if old not in ( - MultiplayerUserState.IDLE, - MultiplayerUserState.READY, - MultiplayerUserState.RESULTS, # Allow spectating after results - ): - # Allow spectating during gameplay states only if the room is in appropriate state - if not ( - old.is_playing - and room.room.state - in ( - MultiplayerRoomState.WAITING_FOR_LOAD, - MultiplayerRoomState.PLAYING, - ) - ): - raise InvokeException(f"Cannot change state from {old} to {new}") - case _: - raise InvokeException(f"Invalid state transition from {old} to {new}") - - async def ChangeState(self, client: Client, state: MultiplayerUserState): - server_room = self._ensure_in_room(client) - room = server_room.room - user = next((u for u in room.users if u.user_id == client.user_id), None) - if user is None: - raise InvokeException("You are not in this room") - - if user.state == state: - return - - # Special handling for state changes during gameplay - match state: - case MultiplayerUserState.IDLE: - if user.state.is_playing: - return - case MultiplayerUserState.LOADED | MultiplayerUserState.READY_FOR_GAMEPLAY: - if not user.state.is_playing: - return - - logger.info(f"[MultiplayerHub] User {user.user_id} changing state from {user.state} to {state}") - - await self.validate_user_stare( - server_room, - user.state, - state, - ) - - await self.change_user_state(server_room, user, state) - - # Enhanced spectator handling based on official implementation - if state == MultiplayerUserState.SPECTATING: - await self.handle_spectator_state_change(client, server_room, user) - - await self.update_room_state(server_room) - - async def change_user_state( - self, - room: ServerMultiplayerRoom, - user: MultiplayerRoomUser, - state: MultiplayerUserState, - ): - logger.info(f"[MultiplayerHub] {user.user_id}'s state changed from {user.state} to {state}") - user.state = state - await self.broadcast_group_call( - self.group_id(room.room.room_id), - "UserStateChanged", - user.user_id, - user.state, - ) - - async def handle_spectator_state_change( - self, client: Client, room: ServerMultiplayerRoom, user: MultiplayerRoomUser - ): - """ - Handle special logic for users entering spectator mode during ongoing gameplay. - Based on official osu-server-spectator implementation. - """ - room_state = room.room.state - - # If switching to spectating during gameplay, immediately request load - if room_state == MultiplayerRoomState.WAITING_FOR_LOAD: - logger.info(f"[MultiplayerHub] Spectator {user.user_id} joining during load phase") - await self.call_noblock(client, "LoadRequested") - - elif room_state == MultiplayerRoomState.PLAYING: - logger.info(f"[MultiplayerHub] Spectator {user.user_id} joining during active gameplay") - await self.call_noblock(client, "LoadRequested") - - # Also sync the spectator with current game state - await self._send_current_gameplay_state_to_spectator(client, room) - - async def _send_current_gameplay_state_to_spectator(self, client: Client, room: ServerMultiplayerRoom): - """ - Send current gameplay state information to a newly joined spectator. - This helps spectators sync with ongoing gameplay. - """ - try: - # Send current room state - await self.call_noblock(client, "RoomStateChanged", room.room.state) - - # Send current user states for all players - for room_user in room.room.users: - if room_user.state.is_playing or room_user.state == MultiplayerUserState.RESULTS: - await self.call_noblock( - client, - "UserStateChanged", - room_user.user_id, - room_user.state, - ) - - # If the room is in OPEN state but we have users in RESULTS state, - # this means the game just finished and we should send ResultsReady - if room.room.state == MultiplayerRoomState.OPEN and any( - u.state == MultiplayerUserState.RESULTS for u in room.room.users - ): - logger.debug(f"[MultiplayerHub] Sending ResultsReady to new spectator {client.user_id}") - await self.call_noblock(client, "ResultsReady") - - logger.debug(f"[MultiplayerHub] Sent current gameplay state to spectator {client.user_id}") - except Exception as e: - logger.error(f"[MultiplayerHub] Failed to send gameplay state to spectator {client.user_id}: {e}") - - async def _send_room_state_to_new_user(self, client: Client, room: ServerMultiplayerRoom): - """ - Send complete room state to a newly joined user. - Critical for spectators joining ongoing games. - """ - try: - # Send current room state - if room.room.state != MultiplayerRoomState.OPEN: - await self.call_noblock(client, "RoomStateChanged", room.room.state) - - # If room is in gameplay state, send LoadRequested immediately - if room.room.state in ( - MultiplayerRoomState.WAITING_FOR_LOAD, - MultiplayerRoomState.PLAYING, - ): - logger.info( - f"[MultiplayerHub] Sending LoadRequested to user {client.user_id} " - f"joining ongoing game (room state: {room.room.state})" - ) - await self.call_noblock(client, "LoadRequested") - - # Send all user states to help with synchronization - for room_user in room.room.users: - if room_user.user_id != client.user_id: # Don't send own state - await self.call_noblock( - client, - "UserStateChanged", - room_user.user_id, - room_user.state, - ) - - # Critical fix: If room is OPEN but has users in RESULTS state, - # send ResultsReady to new joiners (including spectators) - if room.room.state == MultiplayerRoomState.OPEN and any( - u.state == MultiplayerUserState.RESULTS for u in room.room.users - ): - logger.info(f"[MultiplayerHub] Sending ResultsReady to newly joined user {client.user_id}") - await self.call_noblock(client, "ResultsReady") - - # Critical addition: Send current playing users to SpectatorHub for cross-hub sync - # This ensures spectators can watch multiplayer players properly - await self._sync_with_spectator_hub(client, room) - - logger.debug(f"[MultiplayerHub] Sent complete room state to new user {client.user_id}") - except Exception as e: - logger.error(f"[MultiplayerHub] Failed to send room state to user {client.user_id}: {e}") - - async def _sync_with_spectator_hub(self, client: Client, room: ServerMultiplayerRoom): - """ - Sync with SpectatorHub to ensure cross-hub spectating works properly. - This is crucial for users watching multiplayer players from other pages. - """ - try: - # Import here to avoid circular imports - from app.signalr.hub import SpectatorHubs - - # For each user in the room, check their state and sync appropriately - for room_user in room.room.users: - if room_user.state.is_playing: - spectator_state = SpectatorHubs.state.get(room_user.user_id) - if spectator_state and spectator_state.state: - # Send the spectator state to help with cross-hub watching - await self.call_noblock( - client, - "UserBeganPlaying", - room_user.user_id, - spectator_state.state, - ) - logger.debug( - f"[MultiplayerHub] Synced spectator state for user {room_user.user_id} " - f"to new client {client.user_id}" - ) - - # Critical addition: Notify SpectatorHub about users in RESULTS state - elif room_user.state == MultiplayerUserState.RESULTS: - # Create a synthetic finished state for cross-hub spectating - try: - from app.models.spectator_hub import ( - SpectatedUserState, - SpectatorState, - ) - - finished_state = SpectatorState( - beatmap_id=room.queue.current_item.beatmap_id, - ruleset_id=room_user.ruleset_id or 0, - mods=room_user.mods, - state=SpectatedUserState.Passed, # Assume passed for results - maximum_statistics={}, - ) - - await self.call_noblock( - client, - "UserFinishedPlaying", - room_user.user_id, - finished_state, - ) - logger.debug( - f"[MultiplayerHub] Sent synthetic finished state for user {room_user.user_id} " - f"to client {client.user_id}" - ) - except Exception as e: - logger.debug(f"[MultiplayerHub] Failed to create synthetic finished state: {e}") - - except Exception as e: - logger.debug(f"[MultiplayerHub] Failed to sync with SpectatorHub: {e}") - # This is not critical, so we don't raise the exception - - async def update_room_state(self, room: ServerMultiplayerRoom): - match room.room.state: - case MultiplayerRoomState.OPEN: - if room.room.settings.auto_start_enabled: - if ( - not room.queue.current_item.expired - and any(u.state == MultiplayerUserState.READY for u in room.room.users) - and not any( - isinstance(countdown, MatchStartCountdown) for countdown in room.room.active_countdowns - ) - ): - await room.start_countdown( - MatchStartCountdown(time_remaining=room.room.settings.auto_start_duration), - self.start_match, - ) - case MultiplayerRoomState.WAITING_FOR_LOAD: - played_count = len([True for user in room.room.users if user.state.is_playing]) - ready_count = len( - [True for user in room.room.users if user.state == MultiplayerUserState.READY_FOR_GAMEPLAY] - ) - if played_count == ready_count: - await self.start_gameplay(room) - case MultiplayerRoomState.PLAYING: - if all(u.state != MultiplayerUserState.PLAYING for u in room.room.users): - any_user_finished_playing = False - - # Handle finished players first - for u in filter( - lambda u: u.state == MultiplayerUserState.FINISHED_PLAY, - room.room.users, - ): - any_user_finished_playing = True - await self.change_user_state(room, u, MultiplayerUserState.RESULTS) - - # Critical fix: Handle spectators who should also see results - # Move spectators to RESULTS state so they can see the results screen - for u in filter( - lambda u: u.state == MultiplayerUserState.SPECTATING, - room.room.users, - ): - logger.debug(f"[MultiplayerHub] Moving spectator {u.user_id} to RESULTS state") - await self.change_user_state(room, u, MultiplayerUserState.RESULTS) - - await self.change_room_state(room, MultiplayerRoomState.OPEN) - - # Send ResultsReady to all room members - await self.broadcast_group_call( - self.group_id(room.room.room_id), - "ResultsReady", - ) - - # Critical addition: Notify SpectatorHub about finished games - # This ensures cross-hub spectating works properly - await self._notify_spectator_hub_game_ended(room) - - if any_user_finished_playing: - await self.event_logger.game_completed( - room.room.room_id, - room.queue.current_item.id, - ) - else: - await self.event_logger.game_aborted( - room.room.room_id, - room.queue.current_item.id, - ) - await room.queue.finish_current_item() - - async def change_room_state(self, room: ServerMultiplayerRoom, state: MultiplayerRoomState): - logger.debug(f"[MultiplayerHub] Room {room.room.room_id} state changed from {room.room.state} to {state}") - room.room.state = state - await self.broadcast_group_call( - self.group_id(room.room.room_id), - "RoomStateChanged", - state, - ) - - async def StartMatch(self, client: Client): - server_room = self._ensure_in_room(client) - room = server_room.room - user = next((u for u in room.users if u.user_id == client.user_id), None) - if user is None: - raise InvokeException("You are not in this room") - self._ensure_host(client, server_room) - - # Check host state - host must be ready or spectating - if room.host and room.host.state not in ( - MultiplayerUserState.SPECTATING, - MultiplayerUserState.READY, - ): - raise InvokeException("Can't start match when the host is not ready.") - - # Check if any users are ready - if all(u.state != MultiplayerUserState.READY for u in room.users): - raise InvokeException("Can't start match when no users are ready.") - - await self.start_match(server_room) - - async def start_match(self, room: ServerMultiplayerRoom): - if room.room.state != MultiplayerRoomState.OPEN: - raise InvokeException("Can't start match when already in a running state.") - if room.queue.current_item.expired: - raise InvokeException("Current playlist item is expired") - - if all(u.state != MultiplayerUserState.READY for u in room.room.users): - await room.queue.finish_current_item() - - logger.info(f"[MultiplayerHub] Room {room.room.room_id} match started") - - ready_users = [ - u - for u in room.room.users - if u.availability.state == DownloadState.LOCALLY_AVAILABLE - and (u.state == MultiplayerUserState.READY or u.state == MultiplayerUserState.IDLE) - ] - for u in ready_users: - await self.change_user_state(room, u, MultiplayerUserState.WAITING_FOR_LOAD) - await self.change_room_state( - room, - MultiplayerRoomState.WAITING_FOR_LOAD, - ) - await self.broadcast_group_call( - self.group_id(room.room.room_id), - "LoadRequested", - ) - await room.start_countdown( - ForceGameplayStartCountdown(time_remaining=timedelta(seconds=GAMEPLAY_LOAD_TIMEOUT)), - self.start_gameplay, - ) - await self.event_logger.game_started( - room.room.room_id, - room.queue.current_item.id, - details=room.match_type_handler.get_details(), - ) - - async def start_gameplay(self, room: ServerMultiplayerRoom): - if room.room.state != MultiplayerRoomState.WAITING_FOR_LOAD: - raise InvokeException("Room is not ready for gameplay") - if room.queue.current_item.expired: - raise InvokeException("Current playlist item is expired") - await room.stop_all_countdowns(ForceGameplayStartCountdown) - playing = False - played_user = 0 - for user in room.room.users: - client = self.get_client_by_id(str(user.user_id)) - if client is None: - continue - - if user.state in ( - MultiplayerUserState.READY_FOR_GAMEPLAY, - MultiplayerUserState.LOADED, - ): - playing = True - played_user += 1 - await self.change_user_state(room, user, MultiplayerUserState.PLAYING) - await self.call_noblock(client, "GameplayStarted") - elif user.state == MultiplayerUserState.WAITING_FOR_LOAD: - await self.change_user_state(room, user, MultiplayerUserState.IDLE) - await self.broadcast_group_call( - self.group_id(room.room.room_id), - "GameplayAborted", - GameplayAbortReason.LOAD_TOOK_TOO_LONG, - ) - await self.change_room_state( - room, - (MultiplayerRoomState.PLAYING if playing else MultiplayerRoomState.OPEN), - ) - if playing: - redis = get_redis() - await redis.set( - f"multiplayer:{room.room.room_id}:gameplay:players", - played_user, - ex=3600, - ) - - # Ensure spectator hub is aware of all active players for the new game. - # This helps spectators receive score data for every participant, - # especially in subsequent rounds where state may get out of sync. - for room_user in room.room.users: - if (client := self.get_client_by_id(str(room_user.user_id))) is not None: - try: - await self._sync_with_spectator_hub(client, room) - except Exception as e: - logger.debug( - f"[MultiplayerHub] Failed to resync spectator hub for user {room_user.user_id}: {e}" - ) - else: - await room.queue.finish_current_item() - - async def send_match_event(self, room: ServerMultiplayerRoom, event: MatchServerEvent): - await self.broadcast_group_call( - self.group_id(room.room.room_id), - "MatchEvent", - event, - ) - - async def make_user_leave( - self, - client: Client | None, - room: ServerMultiplayerRoom, - user: MultiplayerRoomUser, - kicked: bool = False, - ): - if client: - self.remove_from_group(client, self.group_id(room.room.room_id)) - room.room.users.remove(user) - - target_store = self.state.get(user.user_id) - if target_store: - target_store.room_id = 0 - - redis = get_redis() - await redis.publish("chat:room:left", f"{room.room.channel_id}:{user.user_id}") - - async with with_db() as session: - async with session.begin(): - participated_user = ( - await session.exec( - select(RoomParticipatedUser).where( - RoomParticipatedUser.room_id == room.room.room_id, - RoomParticipatedUser.user_id == user.user_id, - ) - ) - ).first() - if participated_user is not None: - participated_user.left_at = utcnow() - - db_room = await session.get(Room, room.room.room_id) - if db_room is None: - raise InvokeException("Room does not exist in database") - if db_room.participant_count > 0: - db_room.participant_count -= 1 - - if len(room.room.users) == 0: - await self.end_room(room) - return - await self.update_room_state(room) - if len(room.room.users) != 0 and room.room.host and room.room.host.user_id == user.user_id: - next_host = room.room.users[0] - await self.set_host(room, next_host) - - if kicked: - if client: - await self.call_noblock(client, "UserKicked", user) - await self.broadcast_group_call(self.group_id(room.room.room_id), "UserKicked", user) - else: - await self.broadcast_group_call(self.group_id(room.room.room_id), "UserLeft", user) - - async def end_room(self, room: ServerMultiplayerRoom): - assert room.room.host - async with with_db() as session: - await session.execute( - update(Room) - .where(col(Room.id) == room.room.room_id) - .values( - name=room.room.settings.name, - ends_at=utcnow(), - type=room.room.settings.match_type, - queue_mode=room.room.settings.queue_mode, - auto_skip=room.room.settings.auto_skip, - auto_start_duration=int(room.room.settings.auto_start_duration.total_seconds()), - host_id=room.room.host.user_id, - ) - ) - await self.event_logger.room_disbanded( - room.room.room_id, - room.room.host.user_id, - ) - del self.rooms[room.room.room_id] - logger.info(f"[MultiplayerHub] Room {room.room.room_id} ended") - - async def LeaveRoom(self, client: Client): - store = self.get_or_create_state(client) - if store.room_id == 0: - return - server_room = self._ensure_in_room(client) - room = server_room.room - user = next((u for u in room.users if u.user_id == client.user_id), None) - if user is None: - raise InvokeException("You are not in this room") - - await self.event_logger.player_left( - room.room_id, - user.user_id, - ) - await self.make_user_leave(client, server_room, user) - logger.info(f"[MultiplayerHub] {client.user_id} left room {room.room_id}") - - async def KickUser(self, client: Client, user_id: int): - server_room = self._ensure_in_room(client) - room = server_room.room - self._ensure_host(client, server_room) - - if user_id == client.user_id: - raise InvokeException("Can't kick self") - - user = next((u for u in room.users if u.user_id == user_id), None) - if user is None: - raise InvokeException("User not found in this room") - - await self.event_logger.player_kicked( - room.room_id, - user.user_id, - ) - target_client = self.get_client_by_id(str(user.user_id)) - await self.make_user_leave(target_client, server_room, user, kicked=True) - logger.info(f"[MultiplayerHub] {user.user_id} was kicked from room {room.room_id}by {client.user_id}") - - async def set_host(self, room: ServerMultiplayerRoom, user: MultiplayerRoomUser): - room.room.host = user - await self.change_db_settings(room) - await self.broadcast_group_call( - self.group_id(room.room.room_id), - "HostChanged", - user.user_id, - ) - - async def TransferHost(self, client: Client, user_id: int): - server_room = self._ensure_in_room(client) - room = server_room.room - self._ensure_host(client, server_room) - - new_host = next((u for u in room.users if u.user_id == user_id), None) - if new_host is None: - raise InvokeException("User not found in this room") - await self.event_logger.host_changed( - room.room_id, - new_host.user_id, - ) - await self.set_host(server_room, new_host) - logger.info(f"[MultiplayerHub] {client.user_id} transferred host to {new_host.user_id} in room {room.room_id}") - - async def AbortGameplay(self, client: Client): - server_room = self._ensure_in_room(client) - room = server_room.room - user = next((u for u in room.users if u.user_id == client.user_id), None) - if user is None: - raise InvokeException("You are not in this room") - - if not user.state.is_playing: - raise InvokeException("Cannot abort gameplay while not in a gameplay state") - - await self.change_user_state( - server_room, - user, - MultiplayerUserState.IDLE, - ) - await self.update_room_state(server_room) - - async def AbortMatch(self, client: Client): - server_room = self._ensure_in_room(client) - room = server_room.room - self._ensure_host(client, server_room) - - if room.state != MultiplayerRoomState.PLAYING and room.state != MultiplayerRoomState.WAITING_FOR_LOAD: - raise InvokeException("Cannot abort a match that hasn't started.") - - await asyncio.gather( - *[ - self.change_user_state(server_room, u, MultiplayerUserState.IDLE) - for u in room.users - if u.state.is_playing - ] - ) - await self.broadcast_group_call( - self.group_id(room.room_id), - "GameplayAborted", - GameplayAbortReason.HOST_ABORTED, - ) - await self.update_room_state(server_room) - logger.info(f"[MultiplayerHub] {client.user_id} aborted match in room {room.room_id}") - - async def change_user_match_state(self, room: ServerMultiplayerRoom, user: MultiplayerRoomUser): - await self.broadcast_group_call( - self.group_id(room.room.room_id), - "MatchUserStateChanged", - user.user_id, - user.match_state, - ) - - async def change_room_match_state(self, room: ServerMultiplayerRoom): - await self.broadcast_group_call( - self.group_id(room.room.room_id), - "MatchRoomStateChanged", - room.room.match_state, - ) - - async def ChangeSettings(self, client: Client, settings: MultiplayerRoomSettings): - server_room = self._ensure_in_room(client) - self._ensure_host(client, server_room) - room = server_room.room - - if room.state != MultiplayerRoomState.OPEN: - raise InvokeException("Cannot change settings while playing") - - if settings.match_type == MatchType.PLAYLISTS: - raise InvokeException("Invalid match type selected") - - settings.playlist_item_id = room.settings.playlist_item_id - previous_settings = room.settings - room.settings = settings - - if previous_settings.match_type != settings.match_type: - await server_room.set_handler() - if previous_settings.queue_mode != settings.queue_mode: - await server_room.queue.update_queue_mode() - - await self.setting_changed(server_room, beatmap_changed=False) - await self.update_room_state(server_room) - - async def SendMatchRequest(self, client: Client, request: MatchRequest): - server_room = self._ensure_in_room(client) - room = server_room.room - user = next((u for u in room.users if u.user_id == client.user_id), None) - if user is None: - raise InvokeException("You are not in this room") - - if isinstance(request, StartMatchCountdownRequest): - if room.host and room.host.user_id != user.user_id: - raise InvokeException("You are not the host of this room") - if room.state != MultiplayerRoomState.OPEN: - raise InvokeException("Cannot start match countdown when not open") - await server_room.start_countdown( - MatchStartCountdown(time_remaining=request.duration), - self.start_match, - ) - elif isinstance(request, StopCountdownRequest): - countdown = next( - (c for c in room.active_countdowns if c.id == request.id), - None, - ) - if countdown is None: - return - if (isinstance(countdown, MatchStartCountdown) and room.settings.auto_start_enabled) or isinstance( - countdown, (ForceGameplayStartCountdown | ServerShuttingDownCountdown) - ): - raise InvokeException("Cannot stop the requested countdown") - - await server_room.stop_countdown(countdown) - else: - await server_room.match_type_handler.handle_request(user, request) - - async def InvitePlayer(self, client: Client, user_id: int): - server_room = self._ensure_in_room(client) - room = server_room.room - user = next((u for u in room.users if u.user_id == client.user_id), None) - if user is None: - raise InvokeException("You are not in this room") - - async with with_db() as session: - db_user = await session.get(User, user_id) - target_relationship = ( - await session.exec( - select(Relationship).where( - Relationship.user_id == user_id, - Relationship.target_id == client.user_id, - ) - ) - ).first() - inviter_relationship = ( - await session.exec( - select(Relationship).where( - Relationship.user_id == client.user_id, - Relationship.target_id == user_id, - ) - ) - ).first() - if db_user is None: - raise InvokeException("User not found") - if db_user.id == client.user_id: - raise InvokeException("You cannot invite yourself") - if db_user.id in [u.user_id for u in room.users]: - raise InvokeException("User already invited") - if db_user.is_restricted: - raise InvokeException("User is restricted") - if inviter_relationship and inviter_relationship.type == RelationshipType.BLOCK: - raise InvokeException("Cannot perform action due to user being blocked") - if target_relationship and target_relationship.type == RelationshipType.BLOCK: - raise InvokeException("Cannot perform action due to user being blocked") - if ( - db_user.pm_friends_only - and target_relationship is not None - and target_relationship.type != RelationshipType.FOLLOW - ): - raise InvokeException("Cannot perform action because user has disabled non-friend communications") - - target_client = self.get_client_by_id(str(user_id)) - if target_client is None: - raise InvokeException("User is not online") - await self.call_noblock( - target_client, - "Invited", - client.user_id, - room.room_id, - room.settings.password, - ) - - async def unready_all_users(self, room: ServerMultiplayerRoom, reset_beatmap_availability: bool): - await asyncio.gather( - *[ - self.change_user_state( - room, - user, - MultiplayerUserState.IDLE, - ) - for user in room.room.users - if user.state == MultiplayerUserState.READY - ] - ) - if reset_beatmap_availability: - await asyncio.gather( - *[ - self.change_beatmap_availability( - room.room.room_id, - user, - BeatmapAvailability(state=DownloadState.UNKNOWN), - ) - for user in room.room.users - ] - ) - await room.stop_all_countdowns(MatchStartCountdown) - - async def _notify_spectator_hub_game_ended(self, room: ServerMultiplayerRoom): - """ - Notify SpectatorHub about ended multiplayer game. - This ensures cross-hub spectating works properly when games end. - """ - try: - # Import here to avoid circular imports - from app.models.spectator_hub import SpectatedUserState, SpectatorState - from app.signalr.hub import SpectatorHubs - - # For each user who finished the game, notify SpectatorHub - for room_user in room.room.users: - if room_user.state == MultiplayerUserState.RESULTS: - # Create a synthetic finished state - finished_state = SpectatorState( - beatmap_id=room.queue.current_item.beatmap_id, - ruleset_id=room_user.ruleset_id or 0, - mods=room_user.mods, - state=SpectatedUserState.Passed, # Assume passed for results - maximum_statistics={}, - ) - - # Notify all SpectatorHub watchers that this user finished - await SpectatorHubs.broadcast_group_call( - SpectatorHubs.group_id(room_user.user_id), - "UserFinishedPlaying", - room_user.user_id, - finished_state, - ) - - logger.debug(f"[MultiplayerHub] Notified SpectatorHub that user {room_user.user_id} finished game") - - except Exception as e: - logger.debug(f"[MultiplayerHub] Failed to notify SpectatorHub about game end: {e}") - # This is not critical, so we don't raise the exception diff --git a/app/signalr/hub/spectator.py b/app/signalr/hub/spectator.py deleted file mode 100644 index 05fbc42..0000000 --- a/app/signalr/hub/spectator.py +++ /dev/null @@ -1,585 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import lzma -import struct -import time -from typing import override - -from app.calculator import clamp -from app.config import settings -from app.database import Beatmap, User -from app.database.failtime import FailTime, FailTimeResp -from app.database.score import Score -from app.database.score_token import ScoreToken -from app.database.statistics import UserStatistics -from app.dependencies.database import get_redis, with_db -from app.dependencies.fetcher import get_fetcher -from app.dependencies.storage import get_storage_service -from app.exception import InvokeException -from app.log import logger -from app.models.mods import APIMod, mods_to_int -from app.models.score import GameMode, LegacyReplaySoloScoreInfo, ScoreStatistics -from app.models.spectator_hub import ( - APIUser, - FrameDataBundle, - LegacyReplayFrame, - ScoreInfo, - SpectatedUserState, - SpectatorState, - StoreClientState, - StoreScore, -) -from app.utils import unix_timestamp_to_windows - -from .hub import Client, Hub - -from httpx import HTTPError -from sqlalchemy.orm import joinedload -from sqlmodel import select - -READ_SCORE_TIMEOUT = 30 -REPLAY_LATEST_VER = 30000016 - - -def encode_uleb128(num: int) -> bytes | bytearray: - if num == 0: - return b"\x00" - - ret = bytearray() - - while num != 0: - ret.append(num & 0x7F) - num >>= 7 - if num != 0: - ret[-1] |= 0x80 - - return ret - - -def encode_string(s: str) -> bytes: - """Write `s` into bytes (ULEB128 & string).""" - if s: - encoded = s.encode() - ret = b"\x0b" + encode_uleb128(len(encoded)) + encoded - else: - ret = b"\x00" - - return ret - - -async def save_replay( - ruleset_id: int, - md5: str, - username: str, - score: Score, - statistics: ScoreStatistics, - maximum_statistics: ScoreStatistics, - frames: list[LegacyReplayFrame], -) -> None: - data = bytearray() - data.extend(struct.pack(" str: - return f"watch:{user_id}" - - @override - def create_state(self, client: Client) -> StoreClientState: - return StoreClientState( - connection_id=client.connection_id, - connection_token=client.connection_token, - ) - - @override - async def _clean_state(self, state: StoreClientState) -> None: - """ - Enhanced cleanup based on official osu-server-spectator implementation. - Properly notifies watched users when spectator disconnects. - """ - user_id = int(state.connection_id) - if state.state: - await self._end_session(user_id, state.state, state) - - # Critical fix: Notify all watched users that this spectator has disconnected - # This matches the official CleanUpState implementation - for watched_user_id in state.watched_user: - if (target_client := self.get_client_by_id(str(watched_user_id))) is not None: - await self.call_noblock(target_client, "UserEndedWatching", user_id) - logger.debug(f"[SpectatorHub] Notified {watched_user_id} that {user_id} stopped watching") - - async def on_client_connect(self, client: Client) -> None: - """ - Enhanced connection handling based on official implementation. - Send all active player states to newly connected clients. - """ - logger.info(f"[SpectatorHub] Client {client.user_id} connected") - - # Send all current player states to the new client - # This matches the official OnConnectedAsync behavior - active_states = [] - for user_id, store in self.state.items(): - if store.state is not None: - active_states.append((user_id, store.state)) - - if active_states: - logger.debug(f"[SpectatorHub] Sending {len(active_states)} active player states to {client.user_id}") - # Send states sequentially to avoid overwhelming the client - for user_id, state in active_states: - try: - await self.call_noblock(client, "UserBeganPlaying", user_id, state) - except Exception as e: - logger.debug(f"[SpectatorHub] Failed to send state for user {user_id}: {e}") - - # Also sync with MultiplayerHub for cross-hub spectating - await self._sync_with_multiplayer_hub(client) - - async def _sync_with_multiplayer_hub(self, client: Client) -> None: - """ - Sync with MultiplayerHub to get active multiplayer game states. - This ensures spectators can see multiplayer games from other pages. - """ - try: - # Import here to avoid circular imports - from app.signalr.hub import MultiplayerHubs - - # Check all active multiplayer rooms for playing users - for room_id, server_room in MultiplayerHubs.rooms.items(): - for room_user in server_room.room.users: - # Send state for users who are playing or in results - if room_user.state.is_playing and room_user.user_id not in self.state: - # Create a synthetic SpectatorState for multiplayer players - # This helps with cross-hub spectating - try: - synthetic_state = SpectatorState( - beatmap_id=server_room.queue.current_item.beatmap_id, - ruleset_id=room_user.ruleset_id or 0, # Default to osu! - mods=room_user.mods, - state=SpectatedUserState.Playing, - maximum_statistics={}, - ) - - await self.call_noblock( - client, - "UserBeganPlaying", - room_user.user_id, - synthetic_state, - ) - logger.debug( - f"[SpectatorHub] Sent synthetic multiplayer state for user {room_user.user_id}" - ) - except Exception as e: - logger.debug(f"[SpectatorHub] Failed to create synthetic state: {e}") - - # Critical addition: Notify about finished players in multiplayer games - elif ( - hasattr(room_user.state, "name") - and room_user.state.name == "RESULTS" - and room_user.user_id not in self.state - ): - try: - # Create a synthetic finished state - finished_state = SpectatorState( - beatmap_id=server_room.queue.current_item.beatmap_id, - ruleset_id=room_user.ruleset_id or 0, - mods=room_user.mods, - state=SpectatedUserState.Passed, # Assume passed for results - maximum_statistics={}, - ) - - await self.call_noblock( - client, - "UserFinishedPlaying", - room_user.user_id, - finished_state, - ) - logger.debug(f"[SpectatorHub] Sent synthetic finished state for user {room_user.user_id}") - except Exception as e: - logger.debug(f"[SpectatorHub] Failed to create synthetic finished state: {e}") - - except Exception as e: - logger.debug(f"[SpectatorHub] Failed to sync with MultiplayerHub: {e}") - # This is not critical, so we don't raise the exception - - async def BeginPlaySession(self, client: Client, score_token: int, state: SpectatorState) -> None: - user_id = int(client.connection_id) - store = self.get_or_create_state(client) - if store.state is not None: - logger.warning(f"[SpectatorHub] User {user_id} began new session without ending previous one; cleaning up") - try: - await self._end_session(user_id, store.state, store) - finally: - store.state = None - store.beatmap_status = None - store.checksum = None - store.ruleset_id = None - store.score_token = None - store.score = None - if state.beatmap_id is None or state.ruleset_id is None: - return - - fetcher = await get_fetcher() - async with with_db() as session: - async with session.begin(): - try: - beatmap = await Beatmap.get_or_fetch(session, fetcher, bid=state.beatmap_id) - except HTTPError: - raise InvokeException(f"Beatmap {state.beatmap_id} not found.") - user = (await session.exec(select(User).where(User.id == user_id))).first() - if not user: - return - name = user.username - store.state = state - store.beatmap_status = beatmap.beatmap_status - store.checksum = beatmap.checksum - store.ruleset_id = state.ruleset_id - store.score_token = score_token - store.score = StoreScore( - score_info=ScoreInfo( - mods=state.mods, - user=APIUser(id=user_id, name=name), - ruleset=state.ruleset_id, - maximum_statistics=state.maximum_statistics, - ) - ) - logger.info(f"[SpectatorHub] {client.user_id} began playing {state.beatmap_id}") - - await self.broadcast_group_call( - self.group_id(user_id), - "UserBeganPlaying", - user_id, - state, - ) - - async def SendFrameData(self, client: Client, frame_data: FrameDataBundle) -> None: - user_id = int(client.connection_id) - store = self.get_or_create_state(client) - if store.state is None or store.score is None: - return - - header = frame_data.header - score_info = store.score.score_info - score_info.accuracy = header.accuracy - score_info.combo = header.combo - score_info.max_combo = header.max_combo - score_info.statistics = header.statistics - store.score.replay_frames.extend(frame_data.frames) - - await self.broadcast_group_call(self.group_id(user_id), "UserSentFrames", user_id, frame_data) - - async def EndPlaySession(self, client: Client, state: SpectatorState) -> None: - user_id = int(client.connection_id) - store = self.get_or_create_state(client) - score = store.score - - # Early return if no active session - if ( - score is None - or store.score_token is None - or store.beatmap_status is None - or store.state is None - or store.score is None - ): - return - - try: - # Process score if conditions are met - if (settings.enable_all_beatmap_leaderboard and store.beatmap_status.has_leaderboard()) and any( - k.is_hit() and v > 0 for k, v in score.score_info.statistics.items() - ): - await self._process_score(store, client) - - # End the play session and notify watchers - await self._end_session(user_id, state, store) - - finally: - # CRITICAL FIX: Always clear state in finally block to ensure cleanup - # This matches the official C# implementation pattern - store.state = None - store.beatmap_status = None - store.checksum = None - store.ruleset_id = None - store.score_token = None - store.score = None - logger.info(f"[SpectatorHub] Cleared all session state for user {user_id}") - - async def _process_score(self, store: StoreClientState, client: Client) -> None: - user_id = int(client.connection_id) - assert store.state is not None - assert store.score_token is not None - assert store.checksum is not None - assert store.ruleset_id is not None - assert store.score is not None - async with with_db() as session: - async with session: - start_time = time.time() - score_record = None - while time.time() - start_time < READ_SCORE_TIMEOUT: - sub_query = select(ScoreToken.score_id).where( - ScoreToken.id == store.score_token, - ) - result = await session.exec( - select(Score) - .options(joinedload(Score.beatmap)) - .where( - Score.id == sub_query.scalar_subquery(), - Score.user_id == user_id, - ) - ) - score_record = result.first() - if score_record: - break - if not score_record: - return - if not score_record.passed: - return - await self.call_noblock( - client, - "UserScoreProcessed", - user_id, - score_record.id, - ) - # save replay - score_record.has_replay = True - await session.commit() - await session.refresh(score_record) - await save_replay( - ruleset_id=store.ruleset_id, - md5=store.checksum, - username=store.score.score_info.user.name, - score=score_record, - statistics=store.score.score_info.statistics, - maximum_statistics=store.score.score_info.maximum_statistics, - frames=store.score.replay_frames, - ) - - async def _end_session(self, user_id: int, state: SpectatorState, store: StoreClientState) -> None: - async def _add_failtime(): - async with with_db() as session: - failtime = await session.get(FailTime, state.beatmap_id) - total_length = ( - await session.exec(select(Beatmap.total_length).where(Beatmap.id == state.beatmap_id)) - ).one() - index = clamp(round((exit_time / total_length) * 100), 0, 99) - if failtime is not None: - resp = FailTimeResp.from_db(failtime) - else: - resp = FailTimeResp() - if state.state == SpectatedUserState.Failed: - resp.fail[index] += 1 - elif state.state == SpectatedUserState.Quit: - resp.exit[index] += 1 - - assert state.beatmap_id - new_failtime = FailTime.from_resp(state.beatmap_id, resp) - if failtime is not None: - await session.merge(new_failtime) - else: - session.add(new_failtime) - await session.commit() - - async def _edit_playtime(token: int, ruleset_id: int, mods: list[APIMod]): - redis = get_redis() - key = f"score:existed_time:{token}" - messages = await redis.xrange(key, min="-", max="+", count=1) - if not messages: - return - before_time = int(messages[0][1]["time"]) - await redis.delete(key) - async with with_db() as session: - gamemode = GameMode.from_int(ruleset_id).to_special_mode(mods) - statistics = ( - await session.exec( - select(UserStatistics).where( - UserStatistics.user_id == user_id, - UserStatistics.mode == gamemode, - ) - ) - ).first() - if statistics is None: - return - statistics.play_time -= before_time - statistics.play_time += round(min(before_time, exit_time)) - - if state.state == SpectatedUserState.Playing: - state.state = SpectatedUserState.Quit - logger.debug(f"[SpectatorHub] Changed state from Playing to Quit for user {user_id}") - - # Calculate exit time safely - exit_time = 0 - if store.score and store.score.replay_frames: - exit_time = max(frame.time for frame in store.score.replay_frames) // 1000 - - # Background task for playtime editing - only if we have valid data - if store.score_token and store.ruleset_id and store.score: - task = asyncio.create_task( - _edit_playtime( - store.score_token, - store.ruleset_id, - store.score.score_info.mods, - ) - ) - self.tasks.add(task) - task.add_done_callback(self.tasks.discard) - - # Background task for failtime tracking - only for failed/quit states with valid data - if ( - state.beatmap_id is not None - and exit_time > 0 - and state.state in (SpectatedUserState.Failed, SpectatedUserState.Quit) - ): - task = asyncio.create_task(_add_failtime()) - self.tasks.add(task) - task.add_done_callback(self.tasks.discard) - - logger.info(f"[SpectatorHub] {user_id} finished playing {state.beatmap_id} with {state.state}") - await self.broadcast_group_call( - self.group_id(user_id), - "UserFinishedPlaying", - user_id, - state, - ) - - async def StartWatchingUser(self, client: Client, target_id: int) -> None: - """ - Enhanced StartWatchingUser based on official osu-server-spectator implementation. - Properly handles state synchronization and watcher notifications. - """ - user_id = int(client.connection_id) - - logger.info(f"[SpectatorHub] {user_id} started watching {target_id}") - - try: - # Get target user's current state if it exists - target_store = self.state.get(target_id) - if not target_store or not target_store.state: - logger.info(f"[SpectatorHub] Rejecting watch request for {target_id}: user not playing") - raise InvokeException("Target user is not currently playing") - - if target_store.state.state != SpectatedUserState.Playing: - logger.info( - f"[SpectatorHub] Rejecting watch request for {target_id}: state is {target_store.state.state}" - ) - raise InvokeException("Target user is not currently playing") - - logger.debug(f"[SpectatorHub] {target_id} is currently playing, sending state") - # Send current state to the watcher immediately - await self.call_noblock( - client, - "UserBeganPlaying", - target_id, - target_store.state, - ) - except InvokeException: - # Re-raise to inform caller without adding to group - raise - except Exception as e: - # User isn't tracked or error occurred - this is not critical - logger.debug(f"[SpectatorHub] Could not get state for {target_id}: {e}") - raise InvokeException("Target user is not currently playing") from e - - # Add watcher to our tracked users only after validation - store = self.get_or_create_state(client) - store.watched_user.add(target_id) - - # Add to SignalR group for this target user - self.add_to_group(client, self.group_id(target_id)) - - # Get watcher's username and notify the target user - try: - async with with_db() as session: - username = (await session.exec(select(User.username).where(User.id == user_id))).first() - if not username: - logger.warning(f"[SpectatorHub] Could not find username for user {user_id}") - return - - # Notify target user that someone started watching - if (target_client := self.get_client_by_id(str(target_id))) is not None: - # Create watcher info array (matches official format) - watcher_info = [[user_id, username]] - await self.call_noblock(target_client, "UserStartedWatching", watcher_info) - logger.debug(f"[SpectatorHub] Notified {target_id} that {username} started watching") - except Exception as e: - logger.error(f"[SpectatorHub] Error notifying target user {target_id}: {e}") - - async def EndWatchingUser(self, client: Client, target_id: int) -> None: - """ - Enhanced EndWatchingUser based on official osu-server-spectator implementation. - Properly cleans up watcher state and notifies target user. - """ - user_id = int(client.connection_id) - - logger.info(f"[SpectatorHub] {user_id} ended watching {target_id}") - - # Remove from SignalR group - self.remove_from_group(client, self.group_id(target_id)) - - # Remove from our tracked watched users - store = self.get_or_create_state(client) - store.watched_user.discard(target_id) - - # Notify target user that watcher stopped watching - if (target_client := self.get_client_by_id(str(target_id))) is not None: - await self.call_noblock(target_client, "UserEndedWatching", user_id) - logger.debug(f"[SpectatorHub] Notified {target_id} that {user_id} stopped watching") - else: - logger.debug(f"[SpectatorHub] Target user {target_id} not found for end watching notification") diff --git a/app/signalr/packet.py b/app/signalr/packet.py deleted file mode 100644 index 4c0acdd..0000000 --- a/app/signalr/packet.py +++ /dev/null @@ -1,492 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -import datetime -from enum import Enum, IntEnum -import inspect -import json -from types import NoneType, UnionType -from typing import ( - Any, - Protocol as TypingProtocol, - Union, - get_args, - get_origin, -) - -from app.models.signalr import SignalRMeta, SignalRUnionMessage -from app.utils import camel_to_snake, snake_to_camel, snake_to_pascal - -import msgpack_lazer_api as m -from pydantic import BaseModel - -SEP = b"\x1e" - - -class PacketType(IntEnum): - INVOCATION = 1 - STREAM_ITEM = 2 - COMPLETION = 3 - STREAM_INVOCATION = 4 - CANCEL_INVOCATION = 5 - PING = 6 - CLOSE = 7 - - -@dataclass(kw_only=True) -class Packet: - type: PacketType - header: dict[str, Any] | None = None - - -@dataclass(kw_only=True) -class InvocationPacket(Packet): - type: PacketType = PacketType.INVOCATION - invocation_id: str | None - target: str - arguments: list[Any] | None = None - stream_ids: list[str] | None = None - - -@dataclass(kw_only=True) -class CompletionPacket(Packet): - type: PacketType = PacketType.COMPLETION - invocation_id: str - result: Any - error: str | None = None - - -@dataclass(kw_only=True) -class PingPacket(Packet): - type: PacketType = PacketType.PING - - -@dataclass(kw_only=True) -class ClosePacket(Packet): - type: PacketType = PacketType.CLOSE - error: str | None = None - allow_reconnect: bool = False - - -PACKETS = { - PacketType.INVOCATION: InvocationPacket, - PacketType.COMPLETION: CompletionPacket, - PacketType.PING: PingPacket, - PacketType.CLOSE: ClosePacket, -} - - -class Protocol(TypingProtocol): - @staticmethod - def decode(input: bytes) -> list[Packet]: ... - - @staticmethod - def encode(packet: Packet) -> bytes: ... - - @classmethod - def validate_object(cls, v: Any, typ: type) -> Any: ... - - -class MsgpackProtocol: - @classmethod - def serialize_msgpack(cls, v: Any) -> Any: - typ = v.__class__ - if issubclass(typ, BaseModel): - return cls.serialize_to_list(v) - elif issubclass(typ, list): - return [cls.serialize_msgpack(item) for item in v] - elif issubclass(typ, datetime.datetime): - return [v, 0] - elif issubclass(typ, datetime.timedelta): - return int(v.total_seconds() * 10_000_000) - elif isinstance(v, dict): - return {cls.serialize_msgpack(k): cls.serialize_msgpack(value) for k, value in v.items()} - elif issubclass(typ, Enum): - list_ = list(typ) - return list_.index(v) if v in list_ else v.value - return v - - @classmethod - def serialize_to_list(cls, value: BaseModel) -> list[Any]: - values = [] - for field, info in value.__class__.model_fields.items(): - metadata = next((m for m in info.metadata if isinstance(m, SignalRMeta)), None) - if metadata and metadata.member_ignore: - continue - values.append(cls.serialize_msgpack(v=getattr(value, field))) - if issubclass(value.__class__, SignalRUnionMessage): - return [value.__class__.union_type, values] - else: - return values - - @staticmethod - def process_object(v: Any, typ: type[BaseModel]) -> Any: - if isinstance(v, list): - d = {} - i = 0 - for field, info in typ.model_fields.items(): - metadata = next((m for m in info.metadata if isinstance(m, SignalRMeta)), None) - if metadata and metadata.member_ignore: - continue - anno = info.annotation - if anno is None: - d[camel_to_snake(field)] = v[i] - else: - d[field] = MsgpackProtocol.validate_object(v[i], anno) - i += 1 - return d - return v - - @staticmethod - def _encode_varint(value: int) -> bytes: - result = [] - while value >= 0x80: - result.append((value & 0x7F) | 0x80) - value >>= 7 - result.append(value & 0x7F) - return bytes(result) - - @staticmethod - def _decode_varint(data: bytes, offset: int = 0) -> tuple[int, int]: - result = 0 - shift = 0 - pos = offset - - while pos < len(data): - byte = data[pos] - result |= (byte & 0x7F) << shift - pos += 1 - if (byte & 0x80) == 0: - break - shift += 7 - - return result, pos - - @staticmethod - def decode(input: bytes) -> list[Packet]: - length, offset = MsgpackProtocol._decode_varint(input) - message_data = input[offset : offset + length] - unpacked = m.decode(message_data) - packet_type = PacketType(unpacked[0]) - if packet_type not in PACKETS: - raise ValueError(f"Unknown packet type: {packet_type}") - match packet_type: - case PacketType.INVOCATION: - return [ - InvocationPacket( - header=unpacked[1], - invocation_id=unpacked[2], - target=unpacked[3], - arguments=unpacked[4] if len(unpacked) > 4 else None, - stream_ids=unpacked[5] if len(unpacked) > 5 else None, - ) - ] - case PacketType.COMPLETION: - result_kind = unpacked[3] - return [ - CompletionPacket( - header=unpacked[1], - invocation_id=unpacked[2], - error=unpacked[4] if result_kind == 1 else None, - result=unpacked[5] if result_kind == 3 else None, - ) - ] - case PacketType.PING: - return [PingPacket()] - case PacketType.CLOSE: - return [ - ClosePacket( - error=unpacked[1], - allow_reconnect=unpacked[2] if len(unpacked) > 2 else False, - ) - ] - raise ValueError(f"Unsupported packet type: {packet_type}") - - @classmethod - def validate_object(cls, v: Any, typ: type) -> Any: - if issubclass(typ, BaseModel): - return typ.model_validate(obj=cls.process_object(v, typ)) - elif inspect.isclass(typ) and issubclass(typ, datetime.datetime): - return v[0] - elif inspect.isclass(typ) and issubclass(typ, datetime.timedelta): - return datetime.timedelta(seconds=int(v / 10_000_000)) - elif get_origin(typ) is list: - return [cls.validate_object(item, get_args(typ)[0]) for item in v] - elif inspect.isclass(typ) and issubclass(typ, Enum): - list_ = list(typ) - return list_[v] if isinstance(v, int) and 0 <= v < len(list_) else typ(v) - elif get_origin(typ) is dict: - return { - cls.validate_object(k, get_args(typ)[0]): cls.validate_object(v, get_args(typ)[1]) for k, v in v.items() - } - elif (origin := get_origin(typ)) is Union or origin is UnionType: - args = get_args(typ) - if len(args) == 2 and NoneType in args: - non_none_args = [arg for arg in args if arg is not NoneType] - if len(non_none_args) == 1: - if v is None: - return None - return cls.validate_object(v, non_none_args[0]) - - # suppose use `MessagePack-CSharp Union | None` - # except `X (Other Type) | None` - if NoneType in args and v is None: - return None - if not all(issubclass(arg, SignalRUnionMessage) or arg is NoneType for arg in args): - raise ValueError(f"Cannot validate {v} to {typ}, only SignalRUnionMessage subclasses are supported") - union_type = v[0] - for arg in args: - assert issubclass(arg, SignalRUnionMessage) - if arg.union_type == union_type: - return cls.validate_object(v[1], arg) - return v - - @staticmethod - def encode(packet: Packet) -> bytes: - payload = [packet.type.value, packet.header or {}] - if isinstance(packet, InvocationPacket): - payload.extend( - [ - packet.invocation_id, - packet.target, - ] - ) - if packet.arguments is not None: - payload.append([MsgpackProtocol.serialize_msgpack(arg) for arg in packet.arguments]) - if packet.stream_ids is not None: - payload.append(packet.stream_ids) - elif isinstance(packet, CompletionPacket): - result_kind = 2 - if packet.error: - result_kind = 1 - elif packet.result is not None: - result_kind = 3 - payload.extend( - [ - packet.invocation_id, - result_kind, - packet.error or MsgpackProtocol.serialize_msgpack(packet.result) or None, - ] - ) - elif isinstance(packet, ClosePacket): - payload.extend( - [ - packet.error or "", - packet.allow_reconnect, - ] - ) - elif isinstance(packet, PingPacket): - payload.pop(-1) - data = m.encode(payload) - return MsgpackProtocol._encode_varint(len(data)) + data - - -class JSONProtocol: - @classmethod - def serialize_to_json(cls, v: Any, dict_key: bool = False, in_union: bool = False): - typ = v.__class__ - if issubclass(typ, BaseModel): - return cls.serialize_model(v, in_union) - elif isinstance(v, dict): - return {cls.serialize_to_json(k, True): cls.serialize_to_json(value) for k, value in v.items()} - elif isinstance(v, list): - return [cls.serialize_to_json(item) for item in v] - elif isinstance(v, datetime.datetime): - return v.isoformat() - elif isinstance(v, datetime.timedelta): - # d.hh:mm:ss - total_seconds = int(v.total_seconds()) - hours, remainder = divmod(total_seconds, 3600) - minutes, seconds = divmod(remainder, 60) - return f"{hours:02}:{minutes:02}:{seconds:02}" - elif isinstance(v, Enum) and dict_key: - return v.value - elif isinstance(v, Enum): - list_ = list(typ) - return list_.index(v) - return v - - @classmethod - def serialize_model(cls, v: BaseModel, in_union: bool = False) -> dict[str, Any]: - d = {} - is_union = issubclass(v.__class__, SignalRUnionMessage) - for field, info in v.__class__.model_fields.items(): - metadata = next((m for m in info.metadata if isinstance(m, SignalRMeta)), None) - if metadata and metadata.json_ignore: - continue - name = ( - snake_to_camel( - field, - metadata.use_abbr if metadata else True, - ) - if not is_union - else snake_to_pascal( - field, - metadata.use_abbr if metadata else True, - ) - ) - d[name] = cls.serialize_to_json(getattr(v, field), in_union=is_union) - if is_union and not in_union: - return { - "$dtype": v.__class__.__name__, - "$value": d, - } - return d - - @staticmethod - def process_object(v: Any, typ: type[BaseModel], from_union: bool = False) -> dict[str, Any]: - d = {} - for field, info in typ.model_fields.items(): - metadata = next((m for m in info.metadata if isinstance(m, SignalRMeta)), None) - if metadata and metadata.json_ignore: - continue - name = ( - snake_to_camel(field, metadata.use_abbr if metadata else True) - if not from_union - else snake_to_pascal(field, metadata.use_abbr if metadata else True) - ) - value = v.get(name) - anno = typ.model_fields[field].annotation - if anno is None: - d[field] = value - continue - d[field] = JSONProtocol.validate_object(value, anno) - return d - - @staticmethod - def decode(input: bytes) -> list[Packet]: - packets_raw = input.removesuffix(SEP).split(SEP) - packets = [] - if len(packets_raw) > 1: - for packet_raw in packets_raw: - packets.extend(JSONProtocol.decode(packet_raw)) - return packets - else: - data = json.loads(packets_raw[0]) - packet_type = PacketType(data["type"]) - if packet_type not in PACKETS: - raise ValueError(f"Unknown packet type: {packet_type}") - match packet_type: - case PacketType.INVOCATION: - return [ - InvocationPacket( - header=data.get("header"), - invocation_id=data.get("invocationId"), - target=data["target"], - arguments=data.get("arguments"), - stream_ids=data.get("streamIds"), - ) - ] - case PacketType.COMPLETION: - return [ - CompletionPacket( - header=data.get("header"), - invocation_id=data["invocationId"], - error=data.get("error"), - result=data.get("result"), - ) - ] - case PacketType.PING: - return [PingPacket()] - case PacketType.CLOSE: - return [ - ClosePacket( - error=data.get("error"), - allow_reconnect=data.get("allowReconnect", False), - ) - ] - raise ValueError(f"Unsupported packet type: {packet_type}") - - @classmethod - def validate_object(cls, v: Any, typ: type, from_union: bool = False) -> Any: - if issubclass(typ, BaseModel): - return typ.model_validate(JSONProtocol.process_object(v, typ, from_union)) - elif inspect.isclass(typ) and issubclass(typ, datetime.datetime): - return datetime.datetime.fromisoformat(v) - elif inspect.isclass(typ) and issubclass(typ, datetime.timedelta): - # d.hh:mm:ss - parts = v.split(":") - if len(parts) == 3: - return datetime.timedelta(hours=int(parts[0]), minutes=int(parts[1]), seconds=int(parts[2])) - elif len(parts) == 2: - return datetime.timedelta(minutes=int(parts[0]), seconds=int(parts[1])) - elif len(parts) == 1: - return datetime.timedelta(seconds=int(parts[0])) - elif get_origin(typ) is list: - return [cls.validate_object(item, get_args(typ)[0]) for item in v] - elif inspect.isclass(typ) and issubclass(typ, Enum): - list_ = list(typ) - return list_[v] if isinstance(v, int) and 0 <= v < len(list_) else typ(v) - elif get_origin(typ) is dict: - return { - cls.validate_object(k, get_args(typ)[0]): cls.validate_object(v, get_args(typ)[1]) for k, v in v.items() - } - elif (origin := get_origin(typ)) is Union or origin is UnionType: - args = get_args(typ) - if len(args) == 2 and NoneType in args: - non_none_args = [arg for arg in args if arg is not NoneType] - if len(non_none_args) == 1: - if v is None: - return None - return cls.validate_object(v, non_none_args[0]) - - # suppose use `MessagePack-CSharp Union | None` - # except `X (Other Type) | None` - if NoneType in args and v is None: - return None - if not all(issubclass(arg, SignalRUnionMessage) or arg is NoneType for arg in args): - raise ValueError(f"Cannot validate {v} to {typ}, only SignalRUnionMessage subclasses are supported") - # https://github.com/ppy/osu/blob/98acd9/osu.Game/Online/SignalRDerivedTypeWorkaroundJsonConverter.cs - union_type = v["$dtype"] - for arg in args: - assert issubclass(arg, SignalRUnionMessage) - if arg.__name__ == union_type: - return cls.validate_object(v["$value"], arg, True) - return v - - @staticmethod - def encode(packet: Packet) -> bytes: - payload: dict[str, Any] = { - "type": packet.type.value, - } - if packet.header: - payload["header"] = packet.header - if isinstance(packet, InvocationPacket): - payload.update( - { - "target": packet.target, - } - ) - if packet.invocation_id is not None: - payload["invocationId"] = packet.invocation_id - if packet.arguments is not None: - payload["arguments"] = [JSONProtocol.serialize_to_json(arg) for arg in packet.arguments] - if packet.stream_ids is not None: - payload["streamIds"] = packet.stream_ids - elif isinstance(packet, CompletionPacket): - payload.update( - { - "invocationId": packet.invocation_id, - } - ) - if packet.error is not None: - payload["error"] = packet.error - if packet.result is not None: - payload["result"] = JSONProtocol.serialize_to_json(packet.result) - elif isinstance(packet, PingPacket): - pass - elif isinstance(packet, ClosePacket): - payload.update( - { - "allowReconnect": packet.allow_reconnect, - } - ) - if packet.error is not None: - payload["error"] = packet.error - return json.dumps(payload).encode("utf-8") + SEP - - -PROTOCOLS: dict[str, Protocol] = { - "json": JSONProtocol, - "messagepack": MsgpackProtocol, -} diff --git a/app/signalr/router.py b/app/signalr/router.py deleted file mode 100644 index 753bea3..0000000 --- a/app/signalr/router.py +++ /dev/null @@ -1,119 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import time -from typing import Literal -import uuid - -from app.database import User as DBUser -from app.dependencies.database import DBFactory, get_db_factory -from app.dependencies.user import get_current_user, get_current_user_and_token -from app.log import logger -from app.models.signalr import NegotiateResponse, Transport - -from .hub import Hubs -from .packet import PROTOCOLS, SEP - -from fastapi import APIRouter, Depends, Header, HTTPException, Query, WebSocket -from fastapi.security import SecurityScopes - -router = APIRouter(prefix="/signalr", include_in_schema=False) -logger.warning( - "The Python version of SignalR server is deprecated. " - "Maybe it will be removed or be fixed to continuously use in the future" -) - - -@router.post("/{hub}/negotiate", response_model=NegotiateResponse) -async def negotiate( - hub: Literal["spectator", "multiplayer", "metadata"], - negotiate_version: int = Query(1, alias="negotiateVersion"), - user: DBUser = Depends(get_current_user), -): - connectionId = str(user.id) - connectionToken = f"{connectionId}:{uuid.uuid4()}" - Hubs[hub].add_waited_client( - connection_token=connectionToken, - timestamp=int(time.time()), - ) - return NegotiateResponse( - connectionId=connectionId, - connectionToken=connectionToken, - negotiateVersion=negotiate_version, - availableTransports=[Transport(transport="WebSockets")], - ) - - -@router.websocket("/{hub}") -async def connect( - hub: Literal["spectator", "multiplayer", "metadata"], - websocket: WebSocket, - id: str, - authorization: str = Header(...), - factory: DBFactory = Depends(get_db_factory), -): - token = authorization[7:] - user_id = id.split(":")[0] - hub_ = Hubs[hub] - if id not in hub_: - await websocket.close(code=1008) - return - try: - async for session in factory(): - if ( - user_and_token := await get_current_user_and_token( - session, SecurityScopes(scopes=["*"]), token_pw=token - ) - ) is None or str(user_and_token[0].id) != user_id: - await websocket.close(code=1008) - return - except HTTPException: - await websocket.close(code=1008) - return - await websocket.accept() - - # handshake - handshake = await websocket.receive() - message = handshake.get("bytes") or handshake.get("text") - if not message: - await websocket.close(code=1008) - return - handshake_payload = json.loads(message[:-1]) - error = "" - protocol = handshake_payload.get("protocol", "json") - - client = None - try: - client = await hub_.add_client( - connection_id=user_id, - connection_token=id, - connection=websocket, - protocol=PROTOCOLS[protocol], - ) - except KeyError: - error = f"Protocol '{protocol}' is not supported." - except TimeoutError: - error = f"Connection {id} has waited too long." - except ValueError as e: - error = str(e) - payload = {"error": error} if error else {} - # finish handshake - await websocket.send_bytes(json.dumps(payload).encode() + SEP) - if error or not client: - await websocket.close(code=1008) - return - - connected_clients = hub_.get_before_clients(user_id, id) - for connected_client in connected_clients: - await hub_.kick_client(connected_client) - - await hub_.clean_state(client, False) - task = asyncio.create_task(hub_.on_connect(client)) - hub_.tasks.add(task) - task.add_done_callback(hub_.tasks.discard) - await hub_._listen_client(client) - try: - await websocket.close() - except Exception: - ... diff --git a/app/signalr/store.py b/app/signalr/store.py deleted file mode 100644 index 3d5591a..0000000 --- a/app/signalr/store.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import annotations - -import asyncio -import sys -from typing import Any - - -class ResultStore: - def __init__(self) -> None: - self._seq: int = 1 - self._futures: dict[str, asyncio.Future] = {} - - @property - def current_invocation_id(self) -> int: - return self._seq - - def get_invocation_id(self) -> str: - s = self._seq - self._seq = (self._seq + 1) % sys.maxsize - return str(s) - - def add_result(self, invocation_id: str, result: Any, error: str | None = None) -> None: - if isinstance(invocation_id, str) and invocation_id.isdecimal(): - if future := self._futures.get(invocation_id): - future.set_result((result, error)) - - async def fetch( - self, - invocation_id: str, - timeout: float | None, # noqa: ASYNC109 - ) -> tuple[Any, str | None]: - future = asyncio.get_event_loop().create_future() - self._futures[invocation_id] = future - try: - return await asyncio.wait_for(future, timeout) - finally: - del self._futures[invocation_id] diff --git a/app/signalr/utils.py b/app/signalr/utils.py deleted file mode 100644 index d7d23cf..0000000 --- a/app/signalr/utils.py +++ /dev/null @@ -1,42 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable -import inspect -import sys -from typing import Any, ForwardRef, cast - -# https://github.com/pydantic/pydantic/blob/main/pydantic/v1/typing.py#L61-L75 -if sys.version_info < (3, 12, 4): - - def evaluate_forwardref(type_: ForwardRef, globalns: Any, localns: Any) -> Any: - return cast(Any, type_)._evaluate(globalns, localns, recursive_guard=set()) -else: - - def evaluate_forwardref(type_: ForwardRef, globalns: Any, localns: Any) -> Any: - return cast(Any, type_)._evaluate(globalns, localns, type_params=(), recursive_guard=set()) - - -def get_annotation(param: inspect.Parameter, globalns: dict[str, Any]) -> Any: - annotation = param.annotation - if isinstance(annotation, str): - annotation = ForwardRef(annotation) - try: - annotation = evaluate_forwardref(annotation, globalns, globalns) - except Exception: - return inspect.Parameter.empty - return annotation - - -def get_signature(call: Callable[..., Any]) -> inspect.Signature: - signature = inspect.signature(call) - globalns = getattr(call, "__globals__", {}) - typed_params = [ - inspect.Parameter( - name=param.name, - kind=param.kind, - default=param.default, - annotation=get_annotation(param, globalns), - ) - for param in signature.parameters.values() - ] - return inspect.Signature(typed_params) diff --git a/app/tasks/daily_challenge.py b/app/tasks/daily_challenge.py index 3057fcb..8dd5e1c 100644 --- a/app/tasks/daily_challenge.py +++ b/app/tasks/daily_challenge.py @@ -14,7 +14,6 @@ from app.database.user import User from app.dependencies.database import get_redis, with_db from app.dependencies.scheduler import get_scheduler from app.log import logger -from app.models.metadata_hub import DailyChallengeInfo from app.models.mods import APIMod, get_available_mods from app.models.room import RoomCategory from app.service.room import create_playlist_room @@ -54,8 +53,6 @@ async def create_daily_challenge_room( @get_scheduler().scheduled_job("cron", hour=0, minute=0, second=0, id="daily_challenge") async def daily_challenge_job(): - from app.signalr.hub import MetadataHubs - now = utcnow() redis = get_redis() key = f"daily_challenge:{now.date()}" @@ -108,7 +105,6 @@ async def daily_challenge_job(): allowed_mods=allowed_mods_list, duration=int((next_day - now - timedelta(minutes=2)).total_seconds() / 60), ) - await MetadataHubs.broadcast_call("DailyChallengeUpdated", DailyChallengeInfo(room_id=room.id)) logger.success(f"Added today's daily challenge: {beatmap=}, {ruleset_id=}, {required_mods=}") return except (ValueError, json.JSONDecodeError) as e: diff --git a/main.py b/main.py index ee8dcb4..282fd18 100644 --- a/main.py +++ b/main.py @@ -166,9 +166,6 @@ app.include_router(auth_router) app.include_router(private_router) app.include_router(lio_router) -# from app.signalr import signalr_router -# app.include_router(signalr_router) - # 会话验证中间件 if settings.enable_session_verification: app.add_middleware(VerifySessionMiddleware) diff --git a/osu_lazer_api.code-workspace b/osu_lazer_api.code-workspace index 7796233..a224bcb 100644 --- a/osu_lazer_api.code-workspace +++ b/osu_lazer_api.code-workspace @@ -4,10 +4,7 @@ "path": "." }, { - "path": "packages/msgpack_lazer_api" - }, - { - "path": "spectator-server" - } + "path": "spectator-server" + } ] } diff --git a/packages/msgpack_lazer_api/Cargo.lock b/packages/msgpack_lazer_api/Cargo.lock deleted file mode 100644 index 7672a52..0000000 --- a/packages/msgpack_lazer_api/Cargo.lock +++ /dev/null @@ -1,424 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "bumpalo" -version = "3.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "cc" -version = "1.2.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7" -dependencies = [ - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" - -[[package]] -name = "chrono" -version = "0.4.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" -dependencies = [ - "android-tzdata", - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "iana-time-zone" -version = "0.1.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "indoc" -version = "2.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" - -[[package]] -name = "js-sys" -version = "0.3.77" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "libc" -version = "0.2.174" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" - -[[package]] -name = "log" -version = "0.4.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "msgpack-lazer-api" -version = "0.1.0" -dependencies = [ - "chrono", - "pyo3", - "rmp", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "portable-atomic" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" - -[[package]] -name = "proc-macro2" -version = "1.0.95" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "pyo3" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8970a78afe0628a3e3430376fc5fd76b6b45c4d43360ffd6cdd40bdde72b682a" -dependencies = [ - "chrono", - "indoc", - "libc", - "memoffset", - "once_cell", - "portable-atomic", - "pyo3-build-config", - "pyo3-ffi", - "pyo3-macros", - "unindent", -] - -[[package]] -name = "pyo3-build-config" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "458eb0c55e7ece017adeba38f2248ff3ac615e53660d7c71a238d7d2a01c7598" -dependencies = [ - "once_cell", - "target-lexicon", -] - -[[package]] -name = "pyo3-ffi" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7114fe5457c61b276ab77c5055f206295b812608083644a5c5b2640c3102565c" -dependencies = [ - "libc", - "pyo3-build-config", -] - -[[package]] -name = "pyo3-macros" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8725c0a622b374d6cb051d11a0983786448f7785336139c3c94f5aa6bef7e50" -dependencies = [ - "proc-macro2", - "pyo3-macros-backend", - "quote", - "syn", -] - -[[package]] -name = "pyo3-macros-backend" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4109984c22491085343c05b0dbc54ddc405c3cf7b4374fc533f5c3313a572ccc" -dependencies = [ - "heck", - "proc-macro2", - "pyo3-build-config", - "quote", - "syn", -] - -[[package]] -name = "quote" -version = "1.0.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "rmp" -version = "0.8.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "228ed7c16fa39782c3b3468e974aec2795e9089153cd08ee2e9aefb3613334c4" -dependencies = [ - "byteorder", - "num-traits", - "paste", -] - -[[package]] -name = "rustversion" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "syn" -version = "2.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "target-lexicon" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a" - -[[package]] -name = "unicode-ident" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" - -[[package]] -name = "unindent" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" - -[[package]] -name = "wasm-bindgen" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-backend", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link", -] diff --git a/packages/msgpack_lazer_api/Cargo.toml b/packages/msgpack_lazer_api/Cargo.toml deleted file mode 100644 index b1722aa..0000000 --- a/packages/msgpack_lazer_api/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "msgpack-lazer-api" -version = "0.1.0" -edition = "2021" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html -[lib] -name = "msgpack_lazer_api" -crate-type = ["cdylib"] - -[dependencies] -chrono = "0.4.41" -pyo3 = { version = "0.25.0", features = ["extension-module", "chrono"] } -rmp = "0.8.14" diff --git a/packages/msgpack_lazer_api/msgpack_lazer_api.pyi b/packages/msgpack_lazer_api/msgpack_lazer_api.pyi deleted file mode 100644 index 433c53b..0000000 --- a/packages/msgpack_lazer_api/msgpack_lazer_api.pyi +++ /dev/null @@ -1,4 +0,0 @@ -from typing import Any - -def encode(obj: Any) -> bytes: ... -def decode(data: bytes) -> Any: ... diff --git a/packages/msgpack_lazer_api/pyproject.toml b/packages/msgpack_lazer_api/pyproject.toml deleted file mode 100644 index e3b5305..0000000 --- a/packages/msgpack_lazer_api/pyproject.toml +++ /dev/null @@ -1,16 +0,0 @@ -[build-system] -requires = ["maturin>=1.9,<2.0"] -build-backend = "maturin" - -[project] -name = "msgpack-lazer-api" -requires-python = ">=3.12" -classifiers = [ - "Programming Language :: Rust", - "Programming Language :: Python :: Implementation :: CPython", - "Programming Language :: Python :: Implementation :: PyPy", -] -dynamic = ["version"] - -[tool.maturin] -features = ["pyo3/extension-module"] diff --git a/packages/msgpack_lazer_api/src/decode.rs b/packages/msgpack_lazer_api/src/decode.rs deleted file mode 100644 index 1e36c42..0000000 --- a/packages/msgpack_lazer_api/src/decode.rs +++ /dev/null @@ -1,312 +0,0 @@ -use chrono::{TimeZone, Utc}; -use pyo3::types::PyDict; -use pyo3::{prelude::*, IntoPyObjectExt}; -use std::io::Read; - -pub fn read_object( - py: Python<'_>, - cursor: &mut std::io::Cursor<&[u8]>, - api_mod: bool, -) -> PyResult { - match rmp::decode::read_marker(cursor) { - Ok(marker) => match marker { - rmp::Marker::Null => Ok(py.None()), - rmp::Marker::True => Ok(true.into_py_any(py)?), - rmp::Marker::False => Ok(false.into_py_any(py)?), - rmp::Marker::FixPos(val) => Ok(val.into_pyobject(py)?.into_any().unbind()), - rmp::Marker::FixNeg(val) => Ok(val.into_pyobject(py)?.into_any().unbind()), - rmp::Marker::U8 => { - let mut buf = [0u8; 1]; - cursor.read_exact(&mut buf).map_err(to_py_err)?; - Ok(buf[0].into_pyobject(py)?.into_any().unbind()) - } - rmp::Marker::U16 => { - let mut buf = [0u8; 2]; - cursor.read_exact(&mut buf).map_err(to_py_err)?; - let val = u16::from_be_bytes(buf); - Ok(val.into_pyobject(py)?.into_any().unbind()) - } - rmp::Marker::U32 => { - let mut buf = [0u8; 4]; - cursor.read_exact(&mut buf).map_err(to_py_err)?; - let val = u32::from_be_bytes(buf); - Ok(val.into_pyobject(py)?.into_any().unbind()) - } - rmp::Marker::U64 => { - let mut buf = [0u8; 8]; - cursor.read_exact(&mut buf).map_err(to_py_err)?; - let val = u64::from_be_bytes(buf); - Ok(val.into_pyobject(py)?.into_any().unbind()) - } - rmp::Marker::I8 => { - let mut buf = [0u8; 1]; - cursor.read_exact(&mut buf).map_err(to_py_err)?; - let val = i8::from_be_bytes(buf); - Ok(val.into_pyobject(py)?.into_any().unbind()) - } - rmp::Marker::I16 => { - let mut buf = [0u8; 2]; - cursor.read_exact(&mut buf).map_err(to_py_err)?; - let val = i16::from_be_bytes(buf); - Ok(val.into_pyobject(py)?.into_any().unbind()) - } - rmp::Marker::I32 => { - let mut buf = [0u8; 4]; - cursor.read_exact(&mut buf).map_err(to_py_err)?; - let val = i32::from_be_bytes(buf); - Ok(val.into_pyobject(py)?.into_any().unbind()) - } - rmp::Marker::I64 => { - let mut buf = [0u8; 8]; - cursor.read_exact(&mut buf).map_err(to_py_err)?; - let val = i64::from_be_bytes(buf); - Ok(val.into_pyobject(py)?.into_any().unbind()) - } - rmp::Marker::Bin8 => { - let mut buf = [0u8; 1]; - cursor.read_exact(&mut buf).map_err(to_py_err)?; - let len = buf[0] as u32; - let mut data = vec![0u8; len as usize]; - cursor.read_exact(&mut data).map_err(to_py_err)?; - Ok(data.into_pyobject(py)?.into_any().unbind()) - } - rmp::Marker::Bin16 => { - let mut buf = [0u8; 2]; - cursor.read_exact(&mut buf).map_err(to_py_err)?; - let len = u16::from_be_bytes(buf) as u32; - let mut data = vec![0u8; len as usize]; - cursor.read_exact(&mut data).map_err(to_py_err)?; - Ok(data.into_pyobject(py)?.into_any().unbind()) - } - rmp::Marker::Bin32 => { - let mut buf = [0u8; 4]; - cursor.read_exact(&mut buf).map_err(to_py_err)?; - let len = u32::from_be_bytes(buf); - let mut data = vec![0u8; len as usize]; - cursor.read_exact(&mut data).map_err(to_py_err)?; - Ok(data.into_pyobject(py)?.into_any().unbind()) - } - rmp::Marker::FixStr(len) => read_string(py, cursor, len as u32), - rmp::Marker::Str8 => { - let mut buf = [0u8; 1]; - cursor.read_exact(&mut buf).map_err(to_py_err)?; - let len = buf[0] as u32; - read_string(py, cursor, len) - } - rmp::Marker::Str16 => { - let mut buf = [0u8; 2]; - cursor.read_exact(&mut buf).map_err(to_py_err)?; - let len = u16::from_be_bytes(buf) as u32; - read_string(py, cursor, len) - } - rmp::Marker::Str32 => { - let mut buf = [0u8; 4]; - cursor.read_exact(&mut buf).map_err(to_py_err)?; - let len = u32::from_be_bytes(buf); - read_string(py, cursor, len) - } - rmp::Marker::FixArray(len) => read_array(py, cursor, len as u32, api_mod), - rmp::Marker::Array16 => { - let mut buf = [0u8; 2]; - cursor.read_exact(&mut buf).map_err(to_py_err)?; - let len = u16::from_be_bytes(buf) as u32; - read_array(py, cursor, len, api_mod) - } - rmp::Marker::Array32 => { - let mut buf = [0u8; 4]; - cursor.read_exact(&mut buf).map_err(to_py_err)?; - let len = u32::from_be_bytes(buf); - read_array(py, cursor, len, api_mod) - } - rmp::Marker::FixMap(len) => read_map(py, cursor, len as u32), - rmp::Marker::Map16 => { - let mut buf = [0u8; 2]; - cursor.read_exact(&mut buf).map_err(to_py_err)?; - let len = u16::from_be_bytes(buf) as u32; - read_map(py, cursor, len) - } - rmp::Marker::Map32 => { - let mut buf = [0u8; 4]; - cursor.read_exact(&mut buf).map_err(to_py_err)?; - let len = u32::from_be_bytes(buf); - read_map(py, cursor, len) - } - rmp::Marker::F32 => { - let mut buf = [0u8; 4]; - cursor.read_exact(&mut buf).map_err(to_py_err)?; - let val = f32::from_be_bytes(buf); - Ok(val.into_pyobject(py)?.into_any().unbind()) - } - rmp::Marker::F64 => { - let mut buf = [0u8; 8]; - cursor.read_exact(&mut buf).map_err(to_py_err)?; - let val = f64::from_be_bytes(buf); - Ok(val.into_pyobject(py)?.into_any().unbind()) - } - rmp::Marker::FixExt1 => read_ext(py, cursor, 1), - rmp::Marker::FixExt2 => read_ext(py, cursor, 2), - rmp::Marker::FixExt4 => read_ext(py, cursor, 4), - rmp::Marker::FixExt8 => read_ext(py, cursor, 8), - rmp::Marker::FixExt16 => read_ext(py, cursor, 16), - rmp::Marker::Ext8 => { - let mut buf = [0u8; 1]; - cursor.read_exact(&mut buf).map_err(to_py_err)?; - let len = buf[0] as u32; - read_ext(py, cursor, len) - } - rmp::Marker::Ext16 => { - let mut buf = [0u8; 2]; - cursor.read_exact(&mut buf).map_err(to_py_err)?; - let len = u16::from_be_bytes(buf) as u32; - read_ext(py, cursor, len) - } - rmp::Marker::Ext32 => { - let mut buf = [0u8; 4]; - cursor.read_exact(&mut buf).map_err(to_py_err)?; - let len = u32::from_be_bytes(buf); - read_ext(py, cursor, len) - } - _ => Err(PyErr::new::( - "Unsupported MessagePack marker", - )), - }, - Err(e) => Err(PyErr::new::(format!( - "Failed to read marker: {:?}", - e - ))), - } -} - -fn read_string( - py: Python<'_>, - cursor: &mut std::io::Cursor<&[u8]>, - len: u32, -) -> PyResult { - let mut buf = vec![0u8; len as usize]; - cursor.read_exact(&mut buf).map_err(to_py_err)?; - let s = String::from_utf8(buf) - .map_err(|_| PyErr::new::("Invalid UTF-8"))?; - Ok(s.into_pyobject(py)?.into_any().unbind()) -} - -fn read_array( - py: Python, - cursor: &mut std::io::Cursor<&[u8]>, - len: u32, - api_mod: bool, -) -> PyResult { - let mut items = Vec::new(); - let array_len = if api_mod { len * 2 } else { len }; - let dict = PyDict::new(py); - let mut i = 0; - if len == 2 && !api_mod { - // 姑且这样判断:列表长度为2,第一个元素为长度为2的字符串,api_mod 模式未启用(不存在嵌套 APIMod) - let obj1 = read_object(py, cursor, false)?; - if obj1.extract::(py).map_or(false, |k| k.len() == 2) { - let obj2 = read_object(py, cursor, true)?; - - let api_mod_dict = PyDict::new(py); - api_mod_dict.set_item("acronym", obj1)?; - api_mod_dict.set_item("settings", obj2)?; - - return Ok(api_mod_dict.into_pyobject(py)?.into_any().unbind()); - } else { - items.push(obj1); - i += 1; - } - } - while i < array_len { - if api_mod && i % 2 == 0 { - let key = read_object(py, cursor, false)?; - let value = read_object(py, cursor, false)?; - dict.set_item(key, value)?; - i += 2; - } else { - let item = read_object(py, cursor, api_mod)?; - items.push(item); - i += 1; - } - } - - if api_mod { - return Ok(dict.into_pyobject(py)?.into_any().unbind()); - } else { - Ok(items.into_pyobject(py)?.into_any().unbind()) - } -} - -fn read_map(py: Python, cursor: &mut std::io::Cursor<&[u8]>, len: u32) -> PyResult { - let mut pairs = Vec::new(); - for _ in 0..len { - let key = read_object(py, cursor, false)?; - let value = read_object(py, cursor, false)?; - pairs.push((key, value)); - } - - let dict = PyDict::new(py); - for (key, value) in pairs { - dict.set_item(key, value)?; - } - return Ok(dict.into_pyobject(py)?.into_any().unbind()); -} - -fn to_py_err(err: std::io::Error) -> PyErr { - PyErr::new::(format!("IO error: {}", err)) -} - -fn read_ext(py: Python, cursor: &mut std::io::Cursor<&[u8]>, len: u32) -> PyResult { - // Read the extension type - let mut type_buf = [0u8; 1]; - cursor.read_exact(&mut type_buf).map_err(to_py_err)?; - let ext_type = type_buf[0] as i8; - - // Read the extension data - let mut data = vec![0u8; len as usize]; - cursor.read_exact(&mut data).map_err(to_py_err)?; - - // Handle timestamp extension (type = -1) - if ext_type == -1 { - read_timestamp(py, &data) - } else { - // For other extension types, return as bytes or handle as needed - Err(PyErr::new::(format!( - "Unsupported extension type: {}", - ext_type - ))) - } -} - -fn read_timestamp(py: Python, data: &[u8]) -> PyResult { - let (secs, nsec) = match data.len() { - 4 => { - // timestamp32: 4-byte big endian seconds - let secs = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as u64; - (secs, 0u32) - } - 8 => { - // timestamp64: 8-byte packed => upper 34 bits nsec, lower 30 bits secs - let packed = u64::from_be_bytes([ - data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7], - ]); - let nsec = (packed >> 34) as u32; - let secs = packed & 0x3FFFFFFFF; // lower 34 bits - (secs, nsec) - } - 12 => { - // timestamp96: 12 bytes = 4-byte nsec + 8-byte seconds signed - let nsec = u32::from_be_bytes([data[0], data[1], data[2], data[3]]); - let secs = i64::from_be_bytes([ - data[4], data[5], data[6], data[7], data[8], data[9], data[10], data[11], - ]) as u64; - (secs, nsec) - } - _ => { - return Err(PyErr::new::(format!( - "Invalid timestamp data length: {}", - data.len() - ))); - } - }; - let time = Utc.timestamp_opt(secs as i64, nsec).single(); - Ok(time.into_pyobject(py)?.into_any().unbind()) -} diff --git a/packages/msgpack_lazer_api/src/encode.rs b/packages/msgpack_lazer_api/src/encode.rs deleted file mode 100644 index 3ff4864..0000000 --- a/packages/msgpack_lazer_api/src/encode.rs +++ /dev/null @@ -1,156 +0,0 @@ -use chrono::{DateTime, Utc}; -use pyo3::prelude::{PyAnyMethods, PyDictMethods, PyListMethods, PyResult, PyStringMethods}; -use pyo3::types::{PyBool, PyBytes, PyDateTime, PyDict, PyFloat, PyInt, PyList, PyNone, PyString}; -use pyo3::{Bound, PyAny}; -use std::io::Write; - -fn write_list(buf: &mut Vec, obj: &Bound<'_, PyList>) { - rmp::encode::write_array_len(buf, obj.len() as u32).unwrap(); - for item in obj.iter() { - write_object(buf, &item); - } -} - -fn write_string(buf: &mut Vec, obj: &Bound<'_, PyString>) { - let s = obj.to_string_lossy(); - rmp::encode::write_str(buf, &s).unwrap(); -} - -fn write_integer(buf: &mut Vec, obj: &Bound<'_, PyInt>) { - if let Ok(val) = obj.extract::() { - rmp::encode::write_i32(buf, val).unwrap(); - } else if let Ok(val) = obj.extract::() { - rmp::encode::write_i64(buf, val).unwrap(); - } else { - panic!("Unsupported integer type"); - } -} - -fn write_float(buf: &mut Vec, obj: &Bound<'_, PyAny>) { - if let Ok(val) = obj.extract::() { - rmp::encode::write_f32(buf, val).unwrap(); - } else if let Ok(val) = obj.extract::() { - rmp::encode::write_f64(buf, val).unwrap(); - } else { - panic!("Unsupported float type"); - } -} - -fn write_bool(buf: &mut Vec, obj: &Bound<'_, PyBool>) { - if let Ok(b) = obj.extract::() { - rmp::encode::write_bool(buf, b).unwrap(); - } else { - panic!("Unsupported boolean type"); - } -} - -fn write_bin(buf: &mut Vec, obj: &Bound<'_, PyBytes>) { - if let Ok(bytes) = obj.extract::>() { - rmp::encode::write_bin(buf, &bytes).unwrap(); - } else { - panic!("Unsupported binary type"); - } -} - -fn write_hashmap(buf: &mut Vec, obj: &Bound<'_, PyDict>) { - rmp::encode::write_map_len(buf, obj.len() as u32).unwrap(); - for (key, value) in obj.iter() { - write_object(buf, &key); - write_object(buf, &value); - } -} - -fn write_nil(buf: &mut Vec) { - rmp::encode::write_nil(buf).unwrap(); -} - -fn is_api_mod(dict: &Bound<'_, PyDict>) -> bool { - if let Ok(Some(acronym)) = dict.get_item("acronym") { - if let Ok(acronym_str) = acronym.extract::() { - return acronym_str.len() == 2; - } - } - false -} - -// https://github.com/ppy/osu/blob/3dced3/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs -fn write_api_mod(buf: &mut Vec, api_mod: &Bound<'_, PyDict>) -> PyResult<()> { - let acronym = api_mod - .get_item("acronym")? - .ok_or_else(|| pyo3::exceptions::PyKeyError::new_err("APIMod missing 'acronym' field"))?; - let acronym_str = acronym.extract::()?; - - let settings = api_mod - .get_item("settings")? - .unwrap_or_else(|| PyDict::new(acronym.py()).into_any()); - let settings_dict = settings.downcast::()?; - - rmp::encode::write_array_len(buf, 2).unwrap(); - rmp::encode::write_str(buf, &acronym_str).unwrap(); - rmp::encode::write_array_len(buf, settings_dict.len() as u32).unwrap(); - - for (k, v) in settings_dict.iter() { - let key_str = k.extract::()?; - rmp::encode::write_str(buf, &key_str).unwrap(); - write_object(buf, &v); - } - - Ok(()) -} - -fn write_datetime(buf: &mut Vec, obj: &Bound<'_, PyDateTime>) { - if let Ok(dt) = obj.extract::>() { - let secs = dt.timestamp(); - let nsec = dt.timestamp_subsec_nanos(); - write_timestamp(buf, secs, nsec); - } else { - panic!("Unsupported datetime type. Check your input, timezone is needed."); - } -} - -fn write_timestamp(wr: &mut Vec, secs: i64, nsec: u32) { - let buf: Vec = if nsec == 0 && secs >= 0 && secs <= u32::MAX as i64 { - // timestamp32: 4-byte big endian seconds - secs.to_be_bytes()[4..].to_vec() - } else if secs >= -(1 << 34) && secs < (1 << 34) { - // timestamp64: 8-byte packed => upper 34 bits nsec, lower 34 bits secs - let packed = ((nsec as u64) << 34) | (secs as u64 & ((1 << 34) - 1)); - packed.to_be_bytes().to_vec() - } else { - // timestamp96: 12 bytes = 4-byte nsec + 8-byte seconds signed - let mut v = Vec::with_capacity(12); - v.extend_from_slice(&nsec.to_be_bytes()); - v.extend_from_slice(&secs.to_be_bytes()); - v - }; - rmp::encode::write_ext_meta(wr, buf.len() as u32, -1).unwrap(); - wr.write_all(&buf).unwrap(); -} - -pub fn write_object(buf: &mut Vec, obj: &Bound<'_, PyAny>) { - if let Ok(list) = obj.downcast::() { - write_list(buf, list); - } else if let Ok(string) = obj.downcast::() { - write_string(buf, string); - } else if let Ok(boolean) = obj.downcast::() { - write_bool(buf, boolean); - } else if let Ok(float) = obj.downcast::() { - write_float(buf, float); - } else if let Ok(integer) = obj.downcast::() { - write_integer(buf, integer); - } else if let Ok(bytes) = obj.downcast::() { - write_bin(buf, bytes); - } else if let Ok(dict) = obj.downcast::() { - if is_api_mod(dict) { - write_api_mod(buf, dict).unwrap_or_else(|_| write_hashmap(buf, dict)); - } else { - write_hashmap(buf, dict); - } - } else if let Ok(_none) = obj.downcast::() { - write_nil(buf); - } else if let Ok(datetime) = obj.downcast::() { - write_datetime(buf, datetime); - } else { - panic!("Unsupported type"); - } -} diff --git a/packages/msgpack_lazer_api/src/lib.rs b/packages/msgpack_lazer_api/src/lib.rs deleted file mode 100644 index 220e645..0000000 --- a/packages/msgpack_lazer_api/src/lib.rs +++ /dev/null @@ -1,26 +0,0 @@ -mod decode; -mod encode; - -use pyo3::prelude::*; - -#[pyfunction] -#[pyo3(name = "encode")] -fn encode_py(obj: &Bound<'_, PyAny>) -> PyResult> { - let mut buf = Vec::new(); - encode::write_object(&mut buf, obj); - Ok(buf) -} - -#[pyfunction] -#[pyo3(name = "decode")] -fn decode_py(py: Python, data: &[u8]) -> PyResult { - let mut cursor = std::io::Cursor::new(data); - decode::read_object(py, &mut cursor, false) -} - -#[pymodule] -fn msgpack_lazer_api(m: &Bound<'_, PyModule>) -> PyResult<()> { - m.add_function(wrap_pyfunction!(encode_py, m)?)?; - m.add_function(wrap_pyfunction!(decode_py, m)?)?; - Ok(()) -} diff --git a/pyproject.toml b/pyproject.toml index 3527b16..9862e00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "g0v0-server" version = "0.1.0" -description = "3rd-party osu!lazer server which supports the latest osu!lazer." +description = "3rd-party osu!lazer private server which supports the latest osu!lazer." readme = "README.md" requires-python = ">=3.12" dependencies = [ @@ -18,7 +18,6 @@ dependencies = [ "httpx>=0.28.1", "loguru>=0.7.3", "maxminddb>=2.8.2", - "msgpack-lazer-api", "newrelic>=10.1.0", "osupyparser>=1.0.7", "passlib[bcrypt]>=1.7.4", @@ -103,20 +102,14 @@ exclude = ["migrations/", ".venv/", "venv/"] [tool.uv.workspace] members = [ - "packages/msgpack_lazer_api", "packages/osupyparser", ] [tool.uv.sources] -msgpack-lazer-api = { workspace = true } osupyparser = { git = "https://github.com/MingxuanGame/osupyparser.git" } -[tool.uv] -cache-keys = [{file = "pyproject.toml"}, {file = "packages/msgpack_lazer_api/Cargo.toml"}, {file = "**/*.rs"}] - [dependency-groups] dev = [ - "maturin>=1.9.2", "pre-commit>=4.2.0", "pyright>=1.1.404", "ruff>=0.12.4", diff --git a/uv.lock b/uv.lock index f48d358..66d910d 100644 --- a/uv.lock +++ b/uv.lock @@ -2,12 +2,6 @@ version = 1 revision = 3 requires-python = ">=3.12" -[manifest] -members = [ - "g0v0-server", - "msgpack-lazer-api", -] - [[package]] name = "aioboto3" version = "15.1.0" @@ -172,16 +166,16 @@ wheels = [ [[package]] name = "anyio" -version = "4.10.0" +version = "4.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "sniffio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, ] [[package]] @@ -207,52 +201,68 @@ wheels = [ [[package]] name = "bcrypt" -version = "4.3.0" +version = "5.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bb/5d/6d7433e0f3cd46ce0b43cd65e1db465ea024dbb8216fb2404e919c2ad77b/bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18", size = 25697, upload-time = "2025-02-28T01:24:09.174Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/2c/3d44e853d1fe969d229bd58d39ae6902b3d924af0e2b5a60d17d4b809ded/bcrypt-4.3.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281", size = 483719, upload-time = "2025-02-28T01:22:34.539Z" }, - { url = "https://files.pythonhosted.org/packages/a1/e2/58ff6e2a22eca2e2cff5370ae56dba29d70b1ea6fc08ee9115c3ae367795/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb", size = 272001, upload-time = "2025-02-28T01:22:38.078Z" }, - { url = "https://files.pythonhosted.org/packages/37/1f/c55ed8dbe994b1d088309e366749633c9eb90d139af3c0a50c102ba68a1a/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59e1aa0e2cd871b08ca146ed08445038f42ff75968c7ae50d2fdd7860ade2180", size = 277451, upload-time = "2025-02-28T01:22:40.787Z" }, - { url = "https://files.pythonhosted.org/packages/d7/1c/794feb2ecf22fe73dcfb697ea7057f632061faceb7dcf0f155f3443b4d79/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:0042b2e342e9ae3d2ed22727c1262f76cc4f345683b5c1715f0250cf4277294f", size = 272792, upload-time = "2025-02-28T01:22:43.144Z" }, - { url = "https://files.pythonhosted.org/packages/13/b7/0b289506a3f3598c2ae2bdfa0ea66969812ed200264e3f61df77753eee6d/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74a8d21a09f5e025a9a23e7c0fd2c7fe8e7503e4d356c0a2c1486ba010619f09", size = 289752, upload-time = "2025-02-28T01:22:45.56Z" }, - { url = "https://files.pythonhosted.org/packages/dc/24/d0fb023788afe9e83cc118895a9f6c57e1044e7e1672f045e46733421fe6/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:0142b2cb84a009f8452c8c5a33ace5e3dfec4159e7735f5afe9a4d50a8ea722d", size = 277762, upload-time = "2025-02-28T01:22:47.023Z" }, - { url = "https://files.pythonhosted.org/packages/e4/38/cde58089492e55ac4ef6c49fea7027600c84fd23f7520c62118c03b4625e/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:12fa6ce40cde3f0b899729dbd7d5e8811cb892d31b6f7d0334a1f37748b789fd", size = 272384, upload-time = "2025-02-28T01:22:49.221Z" }, - { url = "https://files.pythonhosted.org/packages/de/6a/d5026520843490cfc8135d03012a413e4532a400e471e6188b01b2de853f/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:5bd3cca1f2aa5dbcf39e2aa13dd094ea181f48959e1071265de49cc2b82525af", size = 277329, upload-time = "2025-02-28T01:22:51.603Z" }, - { url = "https://files.pythonhosted.org/packages/b3/a3/4fc5255e60486466c389e28c12579d2829b28a527360e9430b4041df4cf9/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:335a420cfd63fc5bc27308e929bee231c15c85cc4c496610ffb17923abf7f231", size = 305241, upload-time = "2025-02-28T01:22:53.283Z" }, - { url = "https://files.pythonhosted.org/packages/c7/15/2b37bc07d6ce27cc94e5b10fd5058900eb8fb11642300e932c8c82e25c4a/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:0e30e5e67aed0187a1764911af023043b4542e70a7461ad20e837e94d23e1d6c", size = 309617, upload-time = "2025-02-28T01:22:55.461Z" }, - { url = "https://files.pythonhosted.org/packages/5f/1f/99f65edb09e6c935232ba0430c8c13bb98cb3194b6d636e61d93fe60ac59/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b8d62290ebefd49ee0b3ce7500f5dbdcf13b81402c05f6dafab9a1e1b27212f", size = 335751, upload-time = "2025-02-28T01:22:57.81Z" }, - { url = "https://files.pythonhosted.org/packages/00/1b/b324030c706711c99769988fcb694b3cb23f247ad39a7823a78e361bdbb8/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef6630e0ec01376f59a006dc72918b1bf436c3b571b80fa1968d775fa02fe7d", size = 355965, upload-time = "2025-02-28T01:22:59.181Z" }, - { url = "https://files.pythonhosted.org/packages/aa/dd/20372a0579dd915dfc3b1cd4943b3bca431866fcb1dfdfd7518c3caddea6/bcrypt-4.3.0-cp313-cp313t-win32.whl", hash = "sha256:7a4be4cbf241afee43f1c3969b9103a41b40bcb3a3f467ab19f891d9bc4642e4", size = 155316, upload-time = "2025-02-28T01:23:00.763Z" }, - { url = "https://files.pythonhosted.org/packages/6d/52/45d969fcff6b5577c2bf17098dc36269b4c02197d551371c023130c0f890/bcrypt-4.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c1949bf259a388863ced887c7861da1df681cb2388645766c89fdfd9004c669", size = 147752, upload-time = "2025-02-28T01:23:02.908Z" }, - { url = "https://files.pythonhosted.org/packages/11/22/5ada0b9af72b60cbc4c9a399fdde4af0feaa609d27eb0adc61607997a3fa/bcrypt-4.3.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d", size = 498019, upload-time = "2025-02-28T01:23:05.838Z" }, - { url = "https://files.pythonhosted.org/packages/b8/8c/252a1edc598dc1ce57905be173328eda073083826955ee3c97c7ff5ba584/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b", size = 279174, upload-time = "2025-02-28T01:23:07.274Z" }, - { url = "https://files.pythonhosted.org/packages/29/5b/4547d5c49b85f0337c13929f2ccbe08b7283069eea3550a457914fc078aa/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e", size = 283870, upload-time = "2025-02-28T01:23:09.151Z" }, - { url = "https://files.pythonhosted.org/packages/be/21/7dbaf3fa1745cb63f776bb046e481fbababd7d344c5324eab47f5ca92dd2/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59", size = 279601, upload-time = "2025-02-28T01:23:11.461Z" }, - { url = "https://files.pythonhosted.org/packages/6d/64/e042fc8262e971347d9230d9abbe70d68b0a549acd8611c83cebd3eaec67/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753", size = 297660, upload-time = "2025-02-28T01:23:12.989Z" }, - { url = "https://files.pythonhosted.org/packages/50/b8/6294eb84a3fef3b67c69b4470fcdd5326676806bf2519cda79331ab3c3a9/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761", size = 284083, upload-time = "2025-02-28T01:23:14.5Z" }, - { url = "https://files.pythonhosted.org/packages/62/e6/baff635a4f2c42e8788fe1b1633911c38551ecca9a749d1052d296329da6/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb", size = 279237, upload-time = "2025-02-28T01:23:16.686Z" }, - { url = "https://files.pythonhosted.org/packages/39/48/46f623f1b0c7dc2e5de0b8af5e6f5ac4cc26408ac33f3d424e5ad8da4a90/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d", size = 283737, upload-time = "2025-02-28T01:23:18.897Z" }, - { url = "https://files.pythonhosted.org/packages/49/8b/70671c3ce9c0fca4a6cc3cc6ccbaa7e948875a2e62cbd146e04a4011899c/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f", size = 312741, upload-time = "2025-02-28T01:23:21.041Z" }, - { url = "https://files.pythonhosted.org/packages/27/fb/910d3a1caa2d249b6040a5caf9f9866c52114d51523ac2fb47578a27faee/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732", size = 316472, upload-time = "2025-02-28T01:23:23.183Z" }, - { url = "https://files.pythonhosted.org/packages/dc/cf/7cf3a05b66ce466cfb575dbbda39718d45a609daa78500f57fa9f36fa3c0/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef", size = 343606, upload-time = "2025-02-28T01:23:25.361Z" }, - { url = "https://files.pythonhosted.org/packages/e3/b8/e970ecc6d7e355c0d892b7f733480f4aa8509f99b33e71550242cf0b7e63/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304", size = 362867, upload-time = "2025-02-28T01:23:26.875Z" }, - { url = "https://files.pythonhosted.org/packages/a9/97/8d3118efd8354c555a3422d544163f40d9f236be5b96c714086463f11699/bcrypt-4.3.0-cp38-abi3-win32.whl", hash = "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51", size = 160589, upload-time = "2025-02-28T01:23:28.381Z" }, - { url = "https://files.pythonhosted.org/packages/29/07/416f0b99f7f3997c69815365babbc2e8754181a4b1899d921b3c7d5b6f12/bcrypt-4.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62", size = 152794, upload-time = "2025-02-28T01:23:30.187Z" }, - { url = "https://files.pythonhosted.org/packages/6e/c1/3fa0e9e4e0bfd3fd77eb8b52ec198fd6e1fd7e9402052e43f23483f956dd/bcrypt-4.3.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3", size = 498969, upload-time = "2025-02-28T01:23:31.945Z" }, - { url = "https://files.pythonhosted.org/packages/ce/d4/755ce19b6743394787fbd7dff6bf271b27ee9b5912a97242e3caf125885b/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24", size = 279158, upload-time = "2025-02-28T01:23:34.161Z" }, - { url = "https://files.pythonhosted.org/packages/9b/5d/805ef1a749c965c46b28285dfb5cd272a7ed9fa971f970435a5133250182/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef", size = 284285, upload-time = "2025-02-28T01:23:35.765Z" }, - { url = "https://files.pythonhosted.org/packages/ab/2b/698580547a4a4988e415721b71eb45e80c879f0fb04a62da131f45987b96/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b", size = 279583, upload-time = "2025-02-28T01:23:38.021Z" }, - { url = "https://files.pythonhosted.org/packages/f2/87/62e1e426418204db520f955ffd06f1efd389feca893dad7095bf35612eec/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676", size = 297896, upload-time = "2025-02-28T01:23:39.575Z" }, - { url = "https://files.pythonhosted.org/packages/cb/c6/8fedca4c2ada1b6e889c52d2943b2f968d3427e5d65f595620ec4c06fa2f/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1", size = 284492, upload-time = "2025-02-28T01:23:40.901Z" }, - { url = "https://files.pythonhosted.org/packages/4d/4d/c43332dcaaddb7710a8ff5269fcccba97ed3c85987ddaa808db084267b9a/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe", size = 279213, upload-time = "2025-02-28T01:23:42.653Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7f/1e36379e169a7df3a14a1c160a49b7b918600a6008de43ff20d479e6f4b5/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0", size = 284162, upload-time = "2025-02-28T01:23:43.964Z" }, - { url = "https://files.pythonhosted.org/packages/1c/0a/644b2731194b0d7646f3210dc4d80c7fee3ecb3a1f791a6e0ae6bb8684e3/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f", size = 312856, upload-time = "2025-02-28T01:23:46.011Z" }, - { url = "https://files.pythonhosted.org/packages/dc/62/2a871837c0bb6ab0c9a88bf54de0fc021a6a08832d4ea313ed92a669d437/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23", size = 316726, upload-time = "2025-02-28T01:23:47.575Z" }, - { url = "https://files.pythonhosted.org/packages/0c/a1/9898ea3faac0b156d457fd73a3cb9c2855c6fd063e44b8522925cdd8ce46/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe", size = 343664, upload-time = "2025-02-28T01:23:49.059Z" }, - { url = "https://files.pythonhosted.org/packages/40/f2/71b4ed65ce38982ecdda0ff20c3ad1b15e71949c78b2c053df53629ce940/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505", size = 363128, upload-time = "2025-02-28T01:23:50.399Z" }, - { url = "https://files.pythonhosted.org/packages/11/99/12f6a58eca6dea4be992d6c681b7ec9410a1d9f5cf368c61437e31daa879/bcrypt-4.3.0-cp39-abi3-win32.whl", hash = "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a", size = 160598, upload-time = "2025-02-28T01:23:51.775Z" }, - { url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799, upload-time = "2025-02-28T01:23:53.139Z" }, + { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" }, + { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" }, + { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" }, + { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" }, + { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" }, + { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, + { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, + { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, + { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, + { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, ] [[package]] @@ -297,14 +307,14 @@ wheels = [ [[package]] name = "botocore-stubs" -version = "1.40.30" +version = "1.40.33" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "types-awscrt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b0/d8/a8cf273b0342ee4e8c8ed30cf2b32b00616e1090c4df12beba8bb65334a1/botocore_stubs-1.40.30.tar.gz", hash = "sha256:73baabaef96fa74af4034c22e37fd71a752075867dd31e06e5a3809ffbc151ec", size = 42768, upload-time = "2025-09-12T20:24:45.257Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/94/16f8e1f41feaa38f1350aa5a4c60c5724b6c8524ca0e6c28523bf5070e74/botocore_stubs-1.40.33.tar.gz", hash = "sha256:89c51ae0b28d9d79fde8c497cf908ddf872ce027d2737d4d4ba473fde9cdaa82", size = 42742, upload-time = "2025-09-17T20:25:56.388Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/42/7d20894547feccb3f0ce16150d98795a2aae3b469f8dd582d99078478b39/botocore_stubs-1.40.30-py3-none-any.whl", hash = "sha256:7d511dcd45f327446ebb130b71d9d124b026f572e12da956df58fdc56ab426ac", size = 66843, upload-time = "2025-09-12T20:24:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/af/7b/6d8fe12a955b16094460e89ea7c4e063f131f4b3bd461b96bcd625d0c79e/botocore_stubs-1.40.33-py3-none-any.whl", hash = "sha256:ad21fee32cbdc7ad4730f29baf88424c7086bf88a745f8e43660ca3e9a7e5f89", size = 66843, upload-time = "2025-09-17T20:25:54.052Z" }, ] [[package]] @@ -384,14 +394,14 @@ wheels = [ [[package]] name = "click" -version = "8.2.1" +version = "8.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, + { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, ] [[package]] @@ -405,37 +415,58 @@ wheels = [ [[package]] name = "cryptography" -version = "45.0.7" +version = "46.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a7/35/c495bffc2056f2dadb32434f1feedd79abde2a7f8363e1974afa9c33c7e2/cryptography-45.0.7.tar.gz", hash = "sha256:4b1654dfc64ea479c242508eb8c724044f1e964a47d1d1cacc5132292d851971", size = 744980, upload-time = "2025-09-01T11:15:03.146Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/9b/e301418629f7bfdf72db9e80ad6ed9d1b83c487c471803eaa6464c511a01/cryptography-46.0.2.tar.gz", hash = "sha256:21b6fc8c71a3f9a604f028a329e5560009cc4a3a828bfea5fcba8eb7647d88fe", size = 749293, upload-time = "2025-10-01T00:29:11.856Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/91/925c0ac74362172ae4516000fe877912e33b5983df735ff290c653de4913/cryptography-45.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3be4f21c6245930688bd9e162829480de027f8bf962ede33d4f8ba7d67a00cee", size = 7041105, upload-time = "2025-09-01T11:13:59.684Z" }, - { url = "https://files.pythonhosted.org/packages/fc/63/43641c5acce3a6105cf8bd5baeceeb1846bb63067d26dae3e5db59f1513a/cryptography-45.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:67285f8a611b0ebc0857ced2081e30302909f571a46bfa7a3cc0ad303fe015c6", size = 4205799, upload-time = "2025-09-01T11:14:02.517Z" }, - { url = "https://files.pythonhosted.org/packages/bc/29/c238dd9107f10bfde09a4d1c52fd38828b1aa353ced11f358b5dd2507d24/cryptography-45.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:577470e39e60a6cd7780793202e63536026d9b8641de011ed9d8174da9ca5339", size = 4430504, upload-time = "2025-09-01T11:14:04.522Z" }, - { url = "https://files.pythonhosted.org/packages/62/62/24203e7cbcc9bd7c94739428cd30680b18ae6b18377ae66075c8e4771b1b/cryptography-45.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4bd3e5c4b9682bc112d634f2c6ccc6736ed3635fc3319ac2bb11d768cc5a00d8", size = 4209542, upload-time = "2025-09-01T11:14:06.309Z" }, - { url = "https://files.pythonhosted.org/packages/cd/e3/e7de4771a08620eef2389b86cd87a2c50326827dea5528feb70595439ce4/cryptography-45.0.7-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:465ccac9d70115cd4de7186e60cfe989de73f7bb23e8a7aa45af18f7412e75bf", size = 3889244, upload-time = "2025-09-01T11:14:08.152Z" }, - { url = "https://files.pythonhosted.org/packages/96/b8/bca71059e79a0bb2f8e4ec61d9c205fbe97876318566cde3b5092529faa9/cryptography-45.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:16ede8a4f7929b4b7ff3642eba2bf79aa1d71f24ab6ee443935c0d269b6bc513", size = 4461975, upload-time = "2025-09-01T11:14:09.755Z" }, - { url = "https://files.pythonhosted.org/packages/58/67/3f5b26937fe1218c40e95ef4ff8d23c8dc05aa950d54200cc7ea5fb58d28/cryptography-45.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8978132287a9d3ad6b54fcd1e08548033cc09dc6aacacb6c004c73c3eb5d3ac3", size = 4209082, upload-time = "2025-09-01T11:14:11.229Z" }, - { url = "https://files.pythonhosted.org/packages/0e/e4/b3e68a4ac363406a56cf7b741eeb80d05284d8c60ee1a55cdc7587e2a553/cryptography-45.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b6a0e535baec27b528cb07a119f321ac024592388c5681a5ced167ae98e9fff3", size = 4460397, upload-time = "2025-09-01T11:14:12.924Z" }, - { url = "https://files.pythonhosted.org/packages/22/49/2c93f3cd4e3efc8cb22b02678c1fad691cff9dd71bb889e030d100acbfe0/cryptography-45.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a24ee598d10befaec178efdff6054bc4d7e883f615bfbcd08126a0f4931c83a6", size = 4337244, upload-time = "2025-09-01T11:14:14.431Z" }, - { url = "https://files.pythonhosted.org/packages/04/19/030f400de0bccccc09aa262706d90f2ec23d56bc4eb4f4e8268d0ddf3fb8/cryptography-45.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:fa26fa54c0a9384c27fcdc905a2fb7d60ac6e47d14bc2692145f2b3b1e2cfdbd", size = 4568862, upload-time = "2025-09-01T11:14:16.185Z" }, - { url = "https://files.pythonhosted.org/packages/29/56/3034a3a353efa65116fa20eb3c990a8c9f0d3db4085429040a7eef9ada5f/cryptography-45.0.7-cp311-abi3-win32.whl", hash = "sha256:bef32a5e327bd8e5af915d3416ffefdbe65ed975b646b3805be81b23580b57b8", size = 2936578, upload-time = "2025-09-01T11:14:17.638Z" }, - { url = "https://files.pythonhosted.org/packages/b3/61/0ab90f421c6194705a99d0fa9f6ee2045d916e4455fdbb095a9c2c9a520f/cryptography-45.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:3808e6b2e5f0b46d981c24d79648e5c25c35e59902ea4391a0dcb3e667bf7443", size = 3405400, upload-time = "2025-09-01T11:14:18.958Z" }, - { url = "https://files.pythonhosted.org/packages/63/e8/c436233ddf19c5f15b25ace33979a9dd2e7aa1a59209a0ee8554179f1cc0/cryptography-45.0.7-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bfb4c801f65dd61cedfc61a83732327fafbac55a47282e6f26f073ca7a41c3b2", size = 7021824, upload-time = "2025-09-01T11:14:20.954Z" }, - { url = "https://files.pythonhosted.org/packages/bc/4c/8f57f2500d0ccd2675c5d0cc462095adf3faa8c52294ba085c036befb901/cryptography-45.0.7-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:81823935e2f8d476707e85a78a405953a03ef7b7b4f55f93f7c2d9680e5e0691", size = 4202233, upload-time = "2025-09-01T11:14:22.454Z" }, - { url = "https://files.pythonhosted.org/packages/eb/ac/59b7790b4ccaed739fc44775ce4645c9b8ce54cbec53edf16c74fd80cb2b/cryptography-45.0.7-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3994c809c17fc570c2af12c9b840d7cea85a9fd3e5c0e0491f4fa3c029216d59", size = 4423075, upload-time = "2025-09-01T11:14:24.287Z" }, - { url = "https://files.pythonhosted.org/packages/b8/56/d4f07ea21434bf891faa088a6ac15d6d98093a66e75e30ad08e88aa2b9ba/cryptography-45.0.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dad43797959a74103cb59c5dac71409f9c27d34c8a05921341fb64ea8ccb1dd4", size = 4204517, upload-time = "2025-09-01T11:14:25.679Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ac/924a723299848b4c741c1059752c7cfe09473b6fd77d2920398fc26bfb53/cryptography-45.0.7-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ce7a453385e4c4693985b4a4a3533e041558851eae061a58a5405363b098fcd3", size = 3882893, upload-time = "2025-09-01T11:14:27.1Z" }, - { url = "https://files.pythonhosted.org/packages/83/dc/4dab2ff0a871cc2d81d3ae6d780991c0192b259c35e4d83fe1de18b20c70/cryptography-45.0.7-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b04f85ac3a90c227b6e5890acb0edbaf3140938dbecf07bff618bf3638578cf1", size = 4450132, upload-time = "2025-09-01T11:14:28.58Z" }, - { url = "https://files.pythonhosted.org/packages/12/dd/b2882b65db8fc944585d7fb00d67cf84a9cef4e77d9ba8f69082e911d0de/cryptography-45.0.7-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:48c41a44ef8b8c2e80ca4527ee81daa4c527df3ecbc9423c41a420a9559d0e27", size = 4204086, upload-time = "2025-09-01T11:14:30.572Z" }, - { url = "https://files.pythonhosted.org/packages/5d/fa/1d5745d878048699b8eb87c984d4ccc5da4f5008dfd3ad7a94040caca23a/cryptography-45.0.7-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f3df7b3d0f91b88b2106031fd995802a2e9ae13e02c36c1fc075b43f420f3a17", size = 4449383, upload-time = "2025-09-01T11:14:32.046Z" }, - { url = "https://files.pythonhosted.org/packages/36/8b/fc61f87931bc030598e1876c45b936867bb72777eac693e905ab89832670/cryptography-45.0.7-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd342f085542f6eb894ca00ef70236ea46070c8a13824c6bde0dfdcd36065b9b", size = 4332186, upload-time = "2025-09-01T11:14:33.95Z" }, - { url = "https://files.pythonhosted.org/packages/0b/11/09700ddad7443ccb11d674efdbe9a832b4455dc1f16566d9bd3834922ce5/cryptography-45.0.7-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1993a1bb7e4eccfb922b6cd414f072e08ff5816702a0bdb8941c247a6b1b287c", size = 4561639, upload-time = "2025-09-01T11:14:35.343Z" }, - { url = "https://files.pythonhosted.org/packages/71/ed/8f4c1337e9d3b94d8e50ae0b08ad0304a5709d483bfcadfcc77a23dbcb52/cryptography-45.0.7-cp37-abi3-win32.whl", hash = "sha256:18fcf70f243fe07252dcb1b268a687f2358025ce32f9f88028ca5c364b123ef5", size = 2926552, upload-time = "2025-09-01T11:14:36.929Z" }, - { url = "https://files.pythonhosted.org/packages/bc/ff/026513ecad58dacd45d1d24ebe52b852165a26e287177de1d545325c0c25/cryptography-45.0.7-cp37-abi3-win_amd64.whl", hash = "sha256:7285a89df4900ed3bfaad5679b1e668cb4b38a8de1ccbfc84b05f34512da0a90", size = 3392742, upload-time = "2025-09-01T11:14:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/e0/98/7a8df8c19a335c8028414738490fc3955c0cecbfdd37fcc1b9c3d04bd561/cryptography-46.0.2-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:f3e32ab7dd1b1ef67b9232c4cf5e2ee4cd517d4316ea910acaaa9c5712a1c663", size = 7261255, upload-time = "2025-10-01T00:27:22.947Z" }, + { url = "https://files.pythonhosted.org/packages/c6/38/b2adb2aa1baa6706adc3eb746691edd6f90a656a9a65c3509e274d15a2b8/cryptography-46.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1fd1a69086926b623ef8126b4c33d5399ce9e2f3fac07c9c734c2a4ec38b6d02", size = 4297596, upload-time = "2025-10-01T00:27:25.258Z" }, + { url = "https://files.pythonhosted.org/packages/e4/27/0f190ada240003119488ae66c897b5e97149292988f556aef4a6a2a57595/cryptography-46.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb7fb9cd44c2582aa5990cf61a4183e6f54eea3172e54963787ba47287edd135", size = 4450899, upload-time = "2025-10-01T00:27:27.458Z" }, + { url = "https://files.pythonhosted.org/packages/85/d5/e4744105ab02fdf6bb58ba9a816e23b7a633255987310b4187d6745533db/cryptography-46.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9066cfd7f146f291869a9898b01df1c9b0e314bfa182cef432043f13fc462c92", size = 4300382, upload-time = "2025-10-01T00:27:29.091Z" }, + { url = "https://files.pythonhosted.org/packages/33/fb/bf9571065c18c04818cb07de90c43fc042c7977c68e5de6876049559c72f/cryptography-46.0.2-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:97e83bf4f2f2c084d8dd792d13841d0a9b241643151686010866bbd076b19659", size = 4017347, upload-time = "2025-10-01T00:27:30.767Z" }, + { url = "https://files.pythonhosted.org/packages/35/72/fc51856b9b16155ca071080e1a3ad0c3a8e86616daf7eb018d9565b99baa/cryptography-46.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:4a766d2a5d8127364fd936572c6e6757682fc5dfcbdba1632d4554943199f2fa", size = 4983500, upload-time = "2025-10-01T00:27:32.741Z" }, + { url = "https://files.pythonhosted.org/packages/c1/53/0f51e926799025e31746d454ab2e36f8c3f0d41592bc65cb9840368d3275/cryptography-46.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:fab8f805e9675e61ed8538f192aad70500fa6afb33a8803932999b1049363a08", size = 4482591, upload-time = "2025-10-01T00:27:34.869Z" }, + { url = "https://files.pythonhosted.org/packages/86/96/4302af40b23ab8aa360862251fb8fc450b2a06ff24bc5e261c2007f27014/cryptography-46.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1e3b6428a3d56043bff0bb85b41c535734204e599c1c0977e1d0f261b02f3ad5", size = 4300019, upload-time = "2025-10-01T00:27:37.029Z" }, + { url = "https://files.pythonhosted.org/packages/9b/59/0be12c7fcc4c5e34fe2b665a75bc20958473047a30d095a7657c218fa9e8/cryptography-46.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:1a88634851d9b8de8bb53726f4300ab191d3b2f42595e2581a54b26aba71b7cc", size = 4950006, upload-time = "2025-10-01T00:27:40.272Z" }, + { url = "https://files.pythonhosted.org/packages/55/1d/42fda47b0111834b49e31590ae14fd020594d5e4dadd639bce89ad790fba/cryptography-46.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:be939b99d4e091eec9a2bcf41aaf8f351f312cd19ff74b5c83480f08a8a43e0b", size = 4482088, upload-time = "2025-10-01T00:27:42.668Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/60f583f69aa1602c2bdc7022dae86a0d2b837276182f8c1ec825feb9b874/cryptography-46.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f13b040649bc18e7eb37936009b24fd31ca095a5c647be8bb6aaf1761142bd1", size = 4425599, upload-time = "2025-10-01T00:27:44.616Z" }, + { url = "https://files.pythonhosted.org/packages/d1/57/d8d4134cd27e6e94cf44adb3f3489f935bde85f3a5508e1b5b43095b917d/cryptography-46.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bdc25e4e01b261a8fda4e98618f1c9515febcecebc9566ddf4a70c63967043b", size = 4697458, upload-time = "2025-10-01T00:27:46.209Z" }, + { url = "https://files.pythonhosted.org/packages/d1/2b/531e37408573e1da33adfb4c58875013ee8ac7d548d1548967d94a0ae5c4/cryptography-46.0.2-cp311-abi3-win32.whl", hash = "sha256:8b9bf67b11ef9e28f4d78ff88b04ed0929fcd0e4f70bb0f704cfc32a5c6311ee", size = 3056077, upload-time = "2025-10-01T00:27:48.424Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cd/2f83cafd47ed2dc5a3a9c783ff5d764e9e70d3a160e0df9a9dcd639414ce/cryptography-46.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:758cfc7f4c38c5c5274b55a57ef1910107436f4ae842478c4989abbd24bd5acb", size = 3512585, upload-time = "2025-10-01T00:27:50.521Z" }, + { url = "https://files.pythonhosted.org/packages/00/36/676f94e10bfaa5c5b86c469ff46d3e0663c5dc89542f7afbadac241a3ee4/cryptography-46.0.2-cp311-abi3-win_arm64.whl", hash = "sha256:218abd64a2e72f8472c2102febb596793347a3e65fafbb4ad50519969da44470", size = 2927474, upload-time = "2025-10-01T00:27:52.91Z" }, + { url = "https://files.pythonhosted.org/packages/6f/cc/47fc6223a341f26d103cb6da2216805e08a37d3b52bee7f3b2aee8066f95/cryptography-46.0.2-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:bda55e8dbe8533937956c996beaa20266a8eca3570402e52ae52ed60de1faca8", size = 7198626, upload-time = "2025-10-01T00:27:54.8Z" }, + { url = "https://files.pythonhosted.org/packages/93/22/d66a8591207c28bbe4ac7afa25c4656dc19dc0db29a219f9809205639ede/cryptography-46.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e7155c0b004e936d381b15425273aee1cebc94f879c0ce82b0d7fecbf755d53a", size = 4287584, upload-time = "2025-10-01T00:27:57.018Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3e/fac3ab6302b928e0398c269eddab5978e6c1c50b2b77bb5365ffa8633b37/cryptography-46.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a61c154cc5488272a6c4b86e8d5beff4639cdb173d75325ce464d723cda0052b", size = 4433796, upload-time = "2025-10-01T00:27:58.631Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d8/24392e5d3c58e2d83f98fe5a2322ae343360ec5b5b93fe18bc52e47298f5/cryptography-46.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:9ec3f2e2173f36a9679d3b06d3d01121ab9b57c979de1e6a244b98d51fea1b20", size = 4292126, upload-time = "2025-10-01T00:28:00.643Z" }, + { url = "https://files.pythonhosted.org/packages/ed/38/3d9f9359b84c16c49a5a336ee8be8d322072a09fac17e737f3bb11f1ce64/cryptography-46.0.2-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2fafb6aa24e702bbf74de4cb23bfa2c3beb7ab7683a299062b69724c92e0fa73", size = 3993056, upload-time = "2025-10-01T00:28:02.8Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a3/4c44fce0d49a4703cc94bfbe705adebf7ab36efe978053742957bc7ec324/cryptography-46.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:0c7ffe8c9b1fcbb07a26d7c9fa5e857c2fe80d72d7b9e0353dcf1d2180ae60ee", size = 4967604, upload-time = "2025-10-01T00:28:04.783Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c2/49d73218747c8cac16bb8318a5513fde3129e06a018af3bc4dc722aa4a98/cryptography-46.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:5840f05518caa86b09d23f8b9405a7b6d5400085aa14a72a98fdf5cf1568c0d2", size = 4465367, upload-time = "2025-10-01T00:28:06.864Z" }, + { url = "https://files.pythonhosted.org/packages/1b/64/9afa7d2ee742f55ca6285a54386ed2778556a4ed8871571cb1c1bfd8db9e/cryptography-46.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:27c53b4f6a682a1b645fbf1cd5058c72cf2f5aeba7d74314c36838c7cbc06e0f", size = 4291678, upload-time = "2025-10-01T00:28:08.982Z" }, + { url = "https://files.pythonhosted.org/packages/50/48/1696d5ea9623a7b72ace87608f6899ca3c331709ac7ebf80740abb8ac673/cryptography-46.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:512c0250065e0a6b286b2db4bbcc2e67d810acd53eb81733e71314340366279e", size = 4931366, upload-time = "2025-10-01T00:28:10.74Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/9dfc778401a334db3b24435ee0733dd005aefb74afe036e2d154547cb917/cryptography-46.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:07c0eb6657c0e9cca5891f4e35081dbf985c8131825e21d99b4f440a8f496f36", size = 4464738, upload-time = "2025-10-01T00:28:12.491Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b1/abcde62072b8f3fd414e191a6238ce55a0050e9738090dc6cded24c12036/cryptography-46.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:48b983089378f50cba258f7f7aa28198c3f6e13e607eaf10472c26320332ca9a", size = 4419305, upload-time = "2025-10-01T00:28:14.145Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1f/3d2228492f9391395ca34c677e8f2571fb5370fe13dc48c1014f8c509864/cryptography-46.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e6f6775eaaa08c0eec73e301f7592f4367ccde5e4e4df8e58320f2ebf161ea2c", size = 4681201, upload-time = "2025-10-01T00:28:15.951Z" }, + { url = "https://files.pythonhosted.org/packages/de/77/b687745804a93a55054f391528fcfc76c3d6bfd082ce9fb62c12f0d29fc1/cryptography-46.0.2-cp314-cp314t-win32.whl", hash = "sha256:e8633996579961f9b5a3008683344c2558d38420029d3c0bc7ff77c17949a4e1", size = 3022492, upload-time = "2025-10-01T00:28:17.643Z" }, + { url = "https://files.pythonhosted.org/packages/60/a5/8d498ef2996e583de0bef1dcc5e70186376f00883ae27bf2133f490adf21/cryptography-46.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:48c01988ecbb32979bb98731f5c2b2f79042a6c58cc9a319c8c2f9987c7f68f9", size = 3496215, upload-time = "2025-10-01T00:28:19.272Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/ee67aaef459a2706bc302b15889a1a8126ebe66877bab1487ae6ad00f33d/cryptography-46.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:8e2ad4d1a5899b7caa3a450e33ee2734be7cc0689010964703a7c4bcc8dd4fd0", size = 2919255, upload-time = "2025-10-01T00:28:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/d5/bb/fa95abcf147a1b0bb94d95f53fbb09da77b24c776c5d87d36f3d94521d2c/cryptography-46.0.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a08e7401a94c002e79dc3bc5231b6558cd4b2280ee525c4673f650a37e2c7685", size = 7248090, upload-time = "2025-10-01T00:28:22.846Z" }, + { url = "https://files.pythonhosted.org/packages/b7/66/f42071ce0e3ffbfa80a88feadb209c779fda92a23fbc1e14f74ebf72ef6b/cryptography-46.0.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d30bc11d35743bf4ddf76674a0a369ec8a21f87aaa09b0661b04c5f6c46e8d7b", size = 4293123, upload-time = "2025-10-01T00:28:25.072Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/1fdbd2e5c1ba822828d250e5a966622ef00185e476d1cd2726b6dd135e53/cryptography-46.0.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bca3f0ce67e5a2a2cf524e86f44697c4323a86e0fd7ba857de1c30d52c11ede1", size = 4439524, upload-time = "2025-10-01T00:28:26.808Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c1/5e4989a7d102d4306053770d60f978c7b6b1ea2ff8c06e0265e305b23516/cryptography-46.0.2-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ff798ad7a957a5021dcbab78dfff681f0cf15744d0e6af62bd6746984d9c9e9c", size = 4297264, upload-time = "2025-10-01T00:28:29.327Z" }, + { url = "https://files.pythonhosted.org/packages/28/78/b56f847d220cb1d6d6aef5a390e116ad603ce13a0945a3386a33abc80385/cryptography-46.0.2-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cb5e8daac840e8879407acbe689a174f5ebaf344a062f8918e526824eb5d97af", size = 4011872, upload-time = "2025-10-01T00:28:31.479Z" }, + { url = "https://files.pythonhosted.org/packages/e1/80/2971f214b066b888944f7b57761bf709ee3f2cf805619a18b18cab9b263c/cryptography-46.0.2-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:3f37aa12b2d91e157827d90ce78f6180f0c02319468a0aea86ab5a9566da644b", size = 4978458, upload-time = "2025-10-01T00:28:33.267Z" }, + { url = "https://files.pythonhosted.org/packages/a5/84/0cb0a2beaa4f1cbe63ebec4e97cd7e0e9f835d0ba5ee143ed2523a1e0016/cryptography-46.0.2-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:5e38f203160a48b93010b07493c15f2babb4e0f2319bbd001885adb3f3696d21", size = 4472195, upload-time = "2025-10-01T00:28:36.039Z" }, + { url = "https://files.pythonhosted.org/packages/30/8b/2b542ddbf78835c7cd67b6fa79e95560023481213a060b92352a61a10efe/cryptography-46.0.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d19f5f48883752b5ab34cff9e2f7e4a7f216296f33714e77d1beb03d108632b6", size = 4296791, upload-time = "2025-10-01T00:28:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/78/12/9065b40201b4f4876e93b9b94d91feb18de9150d60bd842a16a21565007f/cryptography-46.0.2-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:04911b149eae142ccd8c9a68892a70c21613864afb47aba92d8c7ed9cc001023", size = 4939629, upload-time = "2025-10-01T00:28:39.654Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9e/6507dc048c1b1530d372c483dfd34e7709fc542765015425f0442b08547f/cryptography-46.0.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8b16c1ede6a937c291d41176934268e4ccac2c6521c69d3f5961c5a1e11e039e", size = 4471988, upload-time = "2025-10-01T00:28:41.822Z" }, + { url = "https://files.pythonhosted.org/packages/b1/86/d025584a5f7d5c5ec8d3633dbcdce83a0cd579f1141ceada7817a4c26934/cryptography-46.0.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:747b6f4a4a23d5a215aadd1d0b12233b4119c4313df83ab4137631d43672cc90", size = 4422989, upload-time = "2025-10-01T00:28:43.608Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/536370418b38a15a61bbe413006b79dfc3d2b4b0eafceb5581983f973c15/cryptography-46.0.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6b275e398ab3a7905e168c036aad54b5969d63d3d9099a0a66cc147a3cc983be", size = 4685578, upload-time = "2025-10-01T00:28:45.361Z" }, + { url = "https://files.pythonhosted.org/packages/15/52/ea7e2b1910f547baed566c866fbb86de2402e501a89ecb4871ea7f169a81/cryptography-46.0.2-cp38-abi3-win32.whl", hash = "sha256:0b507c8e033307e37af61cb9f7159b416173bdf5b41d11c4df2e499a1d8e007c", size = 3036711, upload-time = "2025-10-01T00:28:47.096Z" }, + { url = "https://files.pythonhosted.org/packages/71/9e/171f40f9c70a873e73c2efcdbe91e1d4b1777a03398fa1c4af3c56a2477a/cryptography-46.0.2-cp38-abi3-win_amd64.whl", hash = "sha256:f9b2dc7668418fb6f221e4bf701f716e05e8eadb4f1988a2487b11aedf8abe62", size = 3500007, upload-time = "2025-10-01T00:28:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/3e/7c/15ad426257615f9be8caf7f97990cf3dcbb5b8dd7ed7e0db581a1c4759dd/cryptography-46.0.2-cp38-abi3-win_arm64.whl", hash = "sha256:91447f2b17e83c9e0c89f133119d83f94ce6e0fb55dd47da0a959316e6e9cfa1", size = 2918153, upload-time = "2025-10-01T00:28:51.003Z" }, ] [[package]] @@ -483,16 +514,16 @@ wheels = [ [[package]] name = "fastapi" -version = "0.116.1" +version = "0.118.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/d7/6c8b3bfe33eeffa208183ec037fee0cce9f7f024089ab1c5d12ef04bd27c/fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143", size = 296485, upload-time = "2025-07-11T16:22:32.057Z" } +sdist = { url = "https://files.pythonhosted.org/packages/28/3c/2b9345a6504e4055eaa490e0b41c10e338ad61d9aeaae41d97807873cdf2/fastapi-0.118.0.tar.gz", hash = "sha256:5e81654d98c4d2f53790a7d32d25a7353b30c81441be7d0958a26b5d761fa1c8", size = 310536, upload-time = "2025-09-29T03:37:23.126Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565", size = 95631, upload-time = "2025-07-11T16:22:30.485Z" }, + { url = "https://files.pythonhosted.org/packages/54/20/54e2bdaad22ca91a59455251998d43094d5c3d3567c52c7c04774b3f43f2/fastapi-0.118.0-py3-none-any.whl", hash = "sha256:705137a61e2ef71019d2445b123aa8845bd97273c395b744d5a7dfe559056855", size = 97694, upload-time = "2025-09-29T03:37:21.338Z" }, ] [[package]] @@ -595,7 +626,6 @@ dependencies = [ { name = "httpx" }, { name = "loguru" }, { name = "maxminddb" }, - { name = "msgpack-lazer-api" }, { name = "newrelic" }, { name = "osupyparser" }, { name = "passlib", extra = ["bcrypt"] }, @@ -617,7 +647,6 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "maturin" }, { name = "pre-commit" }, { name = "pyright" }, { name = "ruff" }, @@ -639,7 +668,6 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "maxminddb", specifier = ">=2.8.2" }, - { name = "msgpack-lazer-api", editable = "packages/msgpack_lazer_api" }, { name = "newrelic", specifier = ">=10.1.0" }, { name = "osupyparser", git = "https://github.com/MingxuanGame/osupyparser.git" }, { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, @@ -661,7 +689,6 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "maturin", specifier = ">=1.9.2" }, { name = "pre-commit", specifier = ">=4.2.0" }, { name = "pyright", specifier = ">=1.1.404" }, { name = "ruff", specifier = ">=0.12.4" }, @@ -762,11 +789,11 @@ wheels = [ [[package]] name = "identify" -version = "2.6.14" +version = "2.6.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/c4/62963f25a678f6a050fb0505a65e9e726996171e6dbe1547f79619eefb15/identify-2.6.14.tar.gz", hash = "sha256:663494103b4f717cb26921c52f8751363dc89db64364cd836a9bf1535f53cd6a", size = 99283, upload-time = "2025-09-06T19:30:52.938Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/ae/2ad30f4652712c82f1c23423d79136fbce338932ad166d70c1efb86a5998/identify-2.6.14-py2.py3-none-any.whl", hash = "sha256:11a073da82212c6646b1f39bb20d4483bfb9543bd5566fec60053c4bb309bf2e", size = 99172, upload-time = "2025-09-06T19:30:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, ] [[package]] @@ -814,61 +841,65 @@ wheels = [ [[package]] name = "markupsafe" -version = "3.0.2" +version = "3.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, - { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, - { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, - { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, - { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, - { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, - { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, - { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, - { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, - { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, - { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, - { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, - { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, - { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, - { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, - { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, - { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, - { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, - { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, - { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, - { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, - { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, - { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, - { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, - { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, - { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, -] - -[[package]] -name = "maturin" -version = "1.9.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/7c/b11b870fc4fd84de2099906314ce45488ae17be32ff5493519a6cddc518a/maturin-1.9.4.tar.gz", hash = "sha256:235163a0c99bc6f380fb8786c04fd14dcf6cd622ff295ea3de525015e6ac40cf", size = 213647, upload-time = "2025-08-27T11:37:57.079Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/90/0d99389eea1939116fca841cad0763600c8d3183a02a9478d066736c60e8/maturin-1.9.4-py3-none-linux_armv6l.whl", hash = "sha256:6ff37578e3f5fdbe685110d45f60af1f5a7dfce70a1e26dfe3810af66853ecae", size = 8276133, upload-time = "2025-08-27T11:37:23.325Z" }, - { url = "https://files.pythonhosted.org/packages/f4/ed/c8ec68b383e50f084bf1fa9605e62a90cd32a3f75d9894ed3a6e5d4cc5b3/maturin-1.9.4-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:f3837bb53611b2dafa1c090436c330f2d743ba305ef00d8801a371f4495e7e1b", size = 15994496, upload-time = "2025-08-27T11:37:27.092Z" }, - { url = "https://files.pythonhosted.org/packages/84/4e/401ff5f3cfc6b123364d4b94379bf910d7baee32c9c95b72784ff2329357/maturin-1.9.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4227d627d8e3bfe45877a8d65e9d8351a9d01434549f0da75d2c06a1b570de58", size = 8362228, upload-time = "2025-08-27T11:37:31.181Z" }, - { url = "https://files.pythonhosted.org/packages/51/8e/c56176dd360da9650c62b8a5ecfb85432cf011e97e46c186901e6996002e/maturin-1.9.4-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:1bb2aa0fa29032e9c5aac03ac400396ddea12cadef242f8967e9c8ef715313a1", size = 8271397, upload-time = "2025-08-27T11:37:33.672Z" }, - { url = "https://files.pythonhosted.org/packages/d2/46/001fcc5c6ad509874896418d6169a61acd619df5b724f99766308c44a99f/maturin-1.9.4-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:a0868d52934c8a5d1411b42367633fdb5cd5515bec47a534192282167448ec30", size = 8775625, upload-time = "2025-08-27T11:37:35.86Z" }, - { url = "https://files.pythonhosted.org/packages/b4/2e/26fa7574f01c19b7a74680fd70e5bae2e8c40fed9683d1752e765062cc2b/maturin-1.9.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:68b7b833b25741c0f553b78e8b9e095b31ae7c6611533b3c7b71f84c2cb8fc44", size = 8051117, upload-time = "2025-08-27T11:37:38.278Z" }, - { url = "https://files.pythonhosted.org/packages/73/ee/ca7308832d4f5b521c1aa176d9265f6f93e0bd1ad82a90fd9cd799f6b28c/maturin-1.9.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:08dc86312afee55af778af919818632e35d8d0464ccd79cb86700d9ea560ccd7", size = 8132122, upload-time = "2025-08-27T11:37:40.499Z" }, - { url = "https://files.pythonhosted.org/packages/45/e8/c623955da75e801a06942edf1fdc4e772a9e8fbc1ceebbdc85d59584dc10/maturin-1.9.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:ef20ffdd943078c4c3699c29fb2ed722bb6b4419efdade6642d1dbf248f94a70", size = 10586762, upload-time = "2025-08-27T11:37:42.718Z" }, - { url = "https://files.pythonhosted.org/packages/3c/4b/19ad558fdf54e151b1b4916ed45f1952ada96684ee6db64f9cd91cabec09/maturin-1.9.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:368e958468431dfeec80f75eea9639b4356d8c42428b0128444424b083fecfb0", size = 8926988, upload-time = "2025-08-27T11:37:45.492Z" }, - { url = "https://files.pythonhosted.org/packages/7e/27/153ad15eccae26921e8a01812da9f3b7f9013368f8f92c36853f2043b2a3/maturin-1.9.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:273f879214f63f79bfe851cd7d541f8150bdbfae5dfdc3c0c4d125d02d1f41b4", size = 8536758, upload-time = "2025-08-27T11:37:48.213Z" }, - { url = "https://files.pythonhosted.org/packages/43/e3/f304c3bdc3fba9adebe5348d4d2dd015f1152c0a9027aaf52cae0bb182c8/maturin-1.9.4-py3-none-win32.whl", hash = "sha256:ed2e54d132ace7e61829bd49709331007dd9a2cc78937f598aa76a4f69b6804d", size = 7265200, upload-time = "2025-08-27T11:37:50.881Z" }, - { url = "https://files.pythonhosted.org/packages/14/14/f86d0124bf1816b99005c058a1dbdca7cb5850d9cf4b09dcae07a1bc6201/maturin-1.9.4-py3-none-win_amd64.whl", hash = "sha256:8e450bb2c9afdf38a0059ee2e1ec2b17323f152b59c16f33eb9c74edaf1f9f79", size = 8237391, upload-time = "2025-08-27T11:37:53.23Z" }, - { url = "https://files.pythonhosted.org/packages/3f/25/8320fc2591e45b750c3ae71fa596b47aefa802d07d6abaaa719034a85160/maturin-1.9.4-py3-none-win_arm64.whl", hash = "sha256:7a6f980a9b67a5c13c844c268eabd855b54a6a765df4b4bb07d15a990572a4c9", size = 6988277, upload-time = "2025-08-27T11:37:55.429Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] [[package]] @@ -925,10 +956,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/33/09601f476fd9d494e967f15c1e05aa1e35bdf5ee54555596e05e5c9ec8c9/maxminddb-2.8.2-cp314-cp314t-win_arm64.whl", hash = "sha256:929a00528db82ffa5aa928a9cd1a972e8f93c36243609c25574dfd920c21533b", size = 33990, upload-time = "2025-07-25T20:31:23.367Z" }, ] -[[package]] -name = "msgpack-lazer-api" -source = { editable = "packages/msgpack_lazer_api" } - [[package]] name = "multidict" version = "6.6.4" @@ -994,18 +1021,18 @@ wheels = [ [[package]] name = "newrelic" -version = "10.17.0" +version = "11.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/82/71eb49483a2675eec2a5cfce2ea08c898c9329072a83f6671d2a6f68d101/newrelic-10.17.0.tar.gz", hash = "sha256:f092109ac024f9524fafdd06126924c0fbd2af54684571167c0ee1d1cc1bcb7d", size = 1286818, upload-time = "2025-09-04T22:15:25.988Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/99/dffd5c418b3551b4598871c2e74c39933d2622e1996f30d751f9bd2f4de9/newrelic-11.0.0.tar.gz", hash = "sha256:3419599597dfcb5c7dd78dd46d12097d20c72b19cc1c89218783804976d0931e", size = 1287164, upload-time = "2025-09-25T22:50:28.4Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/e3/c99b99ebedccb960b835a656d27e0d062904fbe49f8d98bf77eae617b649/newrelic-10.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ab9cbce3f8da5bfcd085b8a59591cfb653d75834b362e3eccb86bdf21eea917", size = 863000, upload-time = "2025-09-04T22:14:55.091Z" }, - { url = "https://files.pythonhosted.org/packages/73/c8/7c5175f1e070063033a38aa50b920d3523c018a4244890068d211bd98c14/newrelic-10.17.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:889969407457a9344927b1a9e9d5afde77345857db4d2ea75b119d02c02d9174", size = 862657, upload-time = "2025-09-04T22:14:56.546Z" }, - { url = "https://files.pythonhosted.org/packages/1f/37/34d71dcf78c7f539381ba71e0b7624ce170822d13364768241f11832ad0d/newrelic-10.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d74c06863356b19c1fcf19900f85057ab33ef779aa3e0de3cb7e3d3f37ca8e20", size = 860626, upload-time = "2025-09-04T22:14:58.707Z" }, - { url = "https://files.pythonhosted.org/packages/7f/40/137ed6c2558ba38237193501e3da466dfefe019a7fdf43e7c57eda0bb293/newrelic-10.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:083777458f93d16ae9e605fd66a6298d856e32deea11cf3270ed237858dcbfe6", size = 860491, upload-time = "2025-09-04T22:15:00.497Z" }, - { url = "https://files.pythonhosted.org/packages/44/60/731dbb138b7ec21a292769f5bed36a207ea957fbd6bfabca8954bfb029b3/newrelic-10.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:165c7045da474342d4ceab67776e8aeb66e77674fab7484b4e2e4ea68d02ed4d", size = 862986, upload-time = "2025-09-04T22:15:01.831Z" }, - { url = "https://files.pythonhosted.org/packages/0a/d2/fb51ee95e13e00a81d526cdd12359dc543e66ccb975ac0e164ef1008a852/newrelic-10.17.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc3c3c82c95b57fe30fc1f684bf920bd8b0ecae70a16cc11f55d0867ffb7520d", size = 862661, upload-time = "2025-09-04T22:15:03.303Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e2/adea40e0e6d6ef16083fbb48aae45a353910b61edbd253969ded2042b1e0/newrelic-10.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5789f739e6ca1c4a49649e406b538d67c063ec33ab4658d01049303dfad9398b", size = 860798, upload-time = "2025-09-04T22:15:05.018Z" }, - { url = "https://files.pythonhosted.org/packages/eb/bb/2ee2ef672ab1dafcc40c17a13fa33cd5055275426f8853fbfadde563ccf6/newrelic-10.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4144dd96fa55772456326b3a6032a53d54aa9245ffc5041b002ce9eb5dbd0992", size = 860672, upload-time = "2025-09-04T22:15:06.346Z" }, + { url = "https://files.pythonhosted.org/packages/56/f5/ecdd3300dda9168a207eeea53db63219d2b6400e16f2a53b89c3c1c973e1/newrelic-11.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b42fe1c4c9e35a9fdf4c264faa1e92f6918d0014c31dad69fede2798bc4482bb", size = 885585, upload-time = "2025-09-25T22:50:02.947Z" }, + { url = "https://files.pythonhosted.org/packages/78/98/35ac917abd6340e81395a12075f131e1b102bf1ca40a598f601b409bc80b/newrelic-11.0.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:927b150eefd9843a5abfc407a9eeac02f4119a842ea0597cd30e33c4b1d5bc61", size = 885753, upload-time = "2025-09-25T22:50:04.653Z" }, + { url = "https://files.pythonhosted.org/packages/c8/86/976281a0882ec357d5a67b3ea6e5c7a80552b42196e5b3c6a13f9ff1ba77/newrelic-11.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a1ca9deba4f44e13eab9eb9866757135a88544b33d971e0929c6884821286bb5", size = 883124, upload-time = "2025-09-25T22:50:06.085Z" }, + { url = "https://files.pythonhosted.org/packages/97/c7/4f5acac16611050200c555ad6ba49a3c95cc59b6fcab2dec45a2f45120c3/newrelic-11.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ee444805ea0df9bcd3bb62873e16f8c5fe3848e0169fc90f8f74b611a51ae2d0", size = 883631, upload-time = "2025-09-25T22:50:07.667Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/70d4f214c652e7f9df357e0723b7ac2bb7361d89e2e0f403b6c458d8c7e8/newrelic-11.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a060dd3918e19e629c9e0e5c16b8c97c4ea1989c7fc12e1845abec2b688e80f2", size = 885596, upload-time = "2025-09-25T22:50:09.041Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/aa6fdb9089c64686ca06d1faa7411083ff961aeccd37e6e8a9283c68b1a5/newrelic-11.0.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a5bbdc9c1062d8178dd47aaf26bda8c1ebc2700ed82927973ac005cdd1668a8", size = 885777, upload-time = "2025-09-25T22:50:10.574Z" }, + { url = "https://files.pythonhosted.org/packages/e5/0a/391f17dd79507a3082a7ab1c3580d947937f51578cdfe2e0aedbf5e9286b/newrelic-11.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c27e089768c8883d4df35f5f1f406ec475ff5053f1463082e01115b25881e4a6", size = 883238, upload-time = "2025-09-25T22:50:12.364Z" }, + { url = "https://files.pythonhosted.org/packages/96/02/b878cc2f472dd9868eb46a3b6de40a5b04991db697ddf25179e967eb2967/newrelic-11.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eb16e55ac25781812bfde1e7e4c181857f9578957919106e049e1a784b333942", size = 883744, upload-time = "2025-09-25T22:50:13.738Z" }, ] [[package]] @@ -1266,16 +1293,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.10.1" +version = "2.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/c5/dbbc27b814c71676593d1c3f718e6cd7d4f00652cefa24b75f7aa3efb25e/pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180", size = 188394, upload-time = "2025-09-24T14:19:11.764Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, + { url = "https://files.pythonhosted.org/packages/83/d6/887a1ff844e64aa823fb4905978d882a633cfe295c32eacad582b78a7d8b/pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c", size = 48608, upload-time = "2025-09-24T14:19:10.015Z" }, ] [[package]] @@ -1298,15 +1325,15 @@ wheels = [ [[package]] name = "pyright" -version = "1.1.405" +version = "1.1.406" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/6c/ba4bbee22e76af700ea593a1d8701e3225080956753bee9750dcc25e2649/pyright-1.1.405.tar.gz", hash = "sha256:5c2a30e1037af27eb463a1cc0b9f6d65fec48478ccf092c1ac28385a15c55763", size = 4068319, upload-time = "2025-09-04T03:37:06.776Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/6b4fbdd1fef59a0292cbb99f790b44983e390321eccbc5921b4d161da5d1/pyright-1.1.406.tar.gz", hash = "sha256:c4872bc58c9643dac09e8a2e74d472c62036910b3bd37a32813989ef7576ea2c", size = 4113151, upload-time = "2025-10-02T01:04:45.488Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/1a/524f832e1ff1962a22a1accc775ca7b143ba2e9f5924bb6749dce566784a/pyright-1.1.405-py3-none-any.whl", hash = "sha256:a2cb13700b5508ce8e5d4546034cb7ea4aedb60215c6c33f56cec7f53996035a", size = 5905038, upload-time = "2025-09-04T03:37:04.913Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a2/e309afbb459f50507103793aaef85ca4348b66814c86bc73908bdeb66d12/pyright-1.1.406-py3-none-any.whl", hash = "sha256:1d81fb43c2407bf566e97e57abb01c811973fdb21b2df8df59f870f688bdca71", size = 5980982, upload-time = "2025-10-02T01:04:43.137Z" }, ] [[package]] @@ -1360,28 +1387,48 @@ wheels = [ [[package]] name = "pyyaml" -version = "6.0.2" +version = "6.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, - { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, - { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, - { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, - { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, - { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, - { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, - { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, - { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, - { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, - { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, - { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, - { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, - { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, - { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] @@ -1433,28 +1480,28 @@ wheels = [ [[package]] name = "ruff" -version = "0.13.0" +version = "0.13.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6e/1a/1f4b722862840295bcaba8c9e5261572347509548faaa99b2d57ee7bfe6a/ruff-0.13.0.tar.gz", hash = "sha256:5b4b1ee7eb35afae128ab94459b13b2baaed282b1fb0f472a73c82c996c8ae60", size = 5372863, upload-time = "2025-09-10T16:25:37.917Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/8e/f9f9ca747fea8e3ac954e3690d4698c9737c23b51731d02df999c150b1c9/ruff-0.13.3.tar.gz", hash = "sha256:5b0ba0db740eefdfbcce4299f49e9eaefc643d4d007749d77d047c2bab19908e", size = 5438533, upload-time = "2025-10-02T19:29:31.582Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/fe/6f87b419dbe166fd30a991390221f14c5b68946f389ea07913e1719741e0/ruff-0.13.0-py3-none-linux_armv6l.whl", hash = "sha256:137f3d65d58ee828ae136a12d1dc33d992773d8f7644bc6b82714570f31b2004", size = 12187826, upload-time = "2025-09-10T16:24:39.5Z" }, - { url = "https://files.pythonhosted.org/packages/e4/25/c92296b1fc36d2499e12b74a3fdb230f77af7bdf048fad7b0a62e94ed56a/ruff-0.13.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:21ae48151b66e71fd111b7d79f9ad358814ed58c339631450c66a4be33cc28b9", size = 12933428, upload-time = "2025-09-10T16:24:43.866Z" }, - { url = "https://files.pythonhosted.org/packages/44/cf/40bc7221a949470307d9c35b4ef5810c294e6cfa3caafb57d882731a9f42/ruff-0.13.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:64de45f4ca5441209e41742d527944635a05a6e7c05798904f39c85bafa819e3", size = 12095543, upload-time = "2025-09-10T16:24:46.638Z" }, - { url = "https://files.pythonhosted.org/packages/f1/03/8b5ff2a211efb68c63a1d03d157e924997ada87d01bebffbd13a0f3fcdeb/ruff-0.13.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b2c653ae9b9d46e0ef62fc6fbf5b979bda20a0b1d2b22f8f7eb0cde9f4963b8", size = 12312489, upload-time = "2025-09-10T16:24:49.556Z" }, - { url = "https://files.pythonhosted.org/packages/37/fc/2336ef6d5e9c8d8ea8305c5f91e767d795cd4fc171a6d97ef38a5302dadc/ruff-0.13.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4cec632534332062bc9eb5884a267b689085a1afea9801bf94e3ba7498a2d207", size = 11991631, upload-time = "2025-09-10T16:24:53.439Z" }, - { url = "https://files.pythonhosted.org/packages/39/7f/f6d574d100fca83d32637d7f5541bea2f5e473c40020bbc7fc4a4d5b7294/ruff-0.13.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dcd628101d9f7d122e120ac7c17e0a0f468b19bc925501dbe03c1cb7f5415b24", size = 13720602, upload-time = "2025-09-10T16:24:56.392Z" }, - { url = "https://files.pythonhosted.org/packages/fd/c8/a8a5b81d8729b5d1f663348d11e2a9d65a7a9bd3c399763b1a51c72be1ce/ruff-0.13.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:afe37db8e1466acb173bb2a39ca92df00570e0fd7c94c72d87b51b21bb63efea", size = 14697751, upload-time = "2025-09-10T16:24:59.89Z" }, - { url = "https://files.pythonhosted.org/packages/57/f5/183ec292272ce7ec5e882aea74937f7288e88ecb500198b832c24debc6d3/ruff-0.13.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0f96a8d90bb258d7d3358b372905fe7333aaacf6c39e2408b9f8ba181f4b6ef2", size = 14095317, upload-time = "2025-09-10T16:25:03.025Z" }, - { url = "https://files.pythonhosted.org/packages/9f/8d/7f9771c971724701af7926c14dab31754e7b303d127b0d3f01116faef456/ruff-0.13.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94b5e3d883e4f924c5298e3f2ee0f3085819c14f68d1e5b6715597681433f153", size = 13144418, upload-time = "2025-09-10T16:25:06.272Z" }, - { url = "https://files.pythonhosted.org/packages/a8/a6/7985ad1778e60922d4bef546688cd8a25822c58873e9ff30189cfe5dc4ab/ruff-0.13.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03447f3d18479df3d24917a92d768a89f873a7181a064858ea90a804a7538991", size = 13370843, upload-time = "2025-09-10T16:25:09.965Z" }, - { url = "https://files.pythonhosted.org/packages/64/1c/bafdd5a7a05a50cc51d9f5711da704942d8dd62df3d8c70c311e98ce9f8a/ruff-0.13.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fbc6b1934eb1c0033da427c805e27d164bb713f8e273a024a7e86176d7f462cf", size = 13321891, upload-time = "2025-09-10T16:25:12.969Z" }, - { url = "https://files.pythonhosted.org/packages/bc/3e/7817f989cb9725ef7e8d2cee74186bf90555279e119de50c750c4b7a72fe/ruff-0.13.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a8ab6a3e03665d39d4a25ee199d207a488724f022db0e1fe4002968abdb8001b", size = 12119119, upload-time = "2025-09-10T16:25:16.621Z" }, - { url = "https://files.pythonhosted.org/packages/58/07/9df080742e8d1080e60c426dce6e96a8faf9a371e2ce22eef662e3839c95/ruff-0.13.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2a5c62f8ccc6dd2fe259917482de7275cecc86141ee10432727c4816235bc41", size = 11961594, upload-time = "2025-09-10T16:25:19.49Z" }, - { url = "https://files.pythonhosted.org/packages/6a/f4/ae1185349197d26a2316840cb4d6c3fba61d4ac36ed728bf0228b222d71f/ruff-0.13.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b7b85ca27aeeb1ab421bc787009831cffe6048faae08ad80867edab9f2760945", size = 12933377, upload-time = "2025-09-10T16:25:22.371Z" }, - { url = "https://files.pythonhosted.org/packages/b6/39/e776c10a3b349fc8209a905bfb327831d7516f6058339a613a8d2aaecacd/ruff-0.13.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:79ea0c44a3032af768cabfd9616e44c24303af49d633b43e3a5096e009ebe823", size = 13418555, upload-time = "2025-09-10T16:25:25.681Z" }, - { url = "https://files.pythonhosted.org/packages/46/09/dca8df3d48e8b3f4202bf20b1658898e74b6442ac835bfe2c1816d926697/ruff-0.13.0-py3-none-win32.whl", hash = "sha256:4e473e8f0e6a04e4113f2e1de12a5039579892329ecc49958424e5568ef4f768", size = 12141613, upload-time = "2025-09-10T16:25:28.664Z" }, - { url = "https://files.pythonhosted.org/packages/61/21/0647eb71ed99b888ad50e44d8ec65d7148babc0e242d531a499a0bbcda5f/ruff-0.13.0-py3-none-win_amd64.whl", hash = "sha256:48e5c25c7a3713eea9ce755995767f4dcd1b0b9599b638b12946e892123d1efb", size = 13258250, upload-time = "2025-09-10T16:25:31.773Z" }, - { url = "https://files.pythonhosted.org/packages/e1/a3/03216a6a86c706df54422612981fb0f9041dbb452c3401501d4a22b942c9/ruff-0.13.0-py3-none-win_arm64.whl", hash = "sha256:ab80525317b1e1d38614addec8ac954f1b3e662de9d59114ecbf771d00cf613e", size = 12312357, upload-time = "2025-09-10T16:25:35.595Z" }, + { url = "https://files.pythonhosted.org/packages/d2/33/8f7163553481466a92656d35dea9331095122bb84cf98210bef597dd2ecd/ruff-0.13.3-py3-none-linux_armv6l.whl", hash = "sha256:311860a4c5e19189c89d035638f500c1e191d283d0cc2f1600c8c80d6dcd430c", size = 12484040, upload-time = "2025-10-02T19:28:49.199Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b5/4a21a4922e5dd6845e91896b0d9ef493574cbe061ef7d00a73c61db531af/ruff-0.13.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2bdad6512fb666b40fcadb65e33add2b040fc18a24997d2e47fee7d66f7fcae2", size = 13122975, upload-time = "2025-10-02T19:28:52.446Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/15649af836d88c9f154e5be87e64ae7d2b1baa5a3ef317cb0c8fafcd882d/ruff-0.13.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fc6fa4637284708d6ed4e5e970d52fc3b76a557d7b4e85a53013d9d201d93286", size = 12346621, upload-time = "2025-10-02T19:28:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/bcbccb8141305f9a6d3f72549dd82d1134299177cc7eaf832599700f95a7/ruff-0.13.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c9e6469864f94a98f412f20ea143d547e4c652f45e44f369d7b74ee78185838", size = 12574408, upload-time = "2025-10-02T19:28:56.679Z" }, + { url = "https://files.pythonhosted.org/packages/ce/19/0f3681c941cdcfa2d110ce4515624c07a964dc315d3100d889fcad3bfc9e/ruff-0.13.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5bf62b705f319476c78891e0e97e965b21db468b3c999086de8ffb0d40fd2822", size = 12285330, upload-time = "2025-10-02T19:28:58.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/f8/387976bf00d126b907bbd7725219257feea58650e6b055b29b224d8cb731/ruff-0.13.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78cc1abed87ce40cb07ee0667ce99dbc766c9f519eabfd948ed87295d8737c60", size = 13980815, upload-time = "2025-10-02T19:29:01.577Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a6/7c8ec09d62d5a406e2b17d159e4817b63c945a8b9188a771193b7e1cc0b5/ruff-0.13.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4fb75e7c402d504f7a9a259e0442b96403fa4a7310ffe3588d11d7e170d2b1e3", size = 14987733, upload-time = "2025-10-02T19:29:04.036Z" }, + { url = "https://files.pythonhosted.org/packages/97/e5/f403a60a12258e0fd0c2195341cfa170726f254c788673495d86ab5a9a9d/ruff-0.13.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:17b951f9d9afb39330b2bdd2dd144ce1c1335881c277837ac1b50bfd99985ed3", size = 14439848, upload-time = "2025-10-02T19:29:06.684Z" }, + { url = "https://files.pythonhosted.org/packages/39/49/3de381343e89364c2334c9f3268b0349dc734fc18b2d99a302d0935c8345/ruff-0.13.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6052f8088728898e0a449f0dde8fafc7ed47e4d878168b211977e3e7e854f662", size = 13421890, upload-time = "2025-10-02T19:29:08.767Z" }, + { url = "https://files.pythonhosted.org/packages/ab/b5/c0feca27d45ae74185a6bacc399f5d8920ab82df2d732a17213fb86a2c4c/ruff-0.13.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc742c50f4ba72ce2a3be362bd359aef7d0d302bf7637a6f942eaa763bd292af", size = 13444870, upload-time = "2025-10-02T19:29:11.234Z" }, + { url = "https://files.pythonhosted.org/packages/50/a1/b655298a1f3fda4fdc7340c3f671a4b260b009068fbeb3e4e151e9e3e1bf/ruff-0.13.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8e5640349493b378431637019366bbd73c927e515c9c1babfea3e932f5e68e1d", size = 13691599, upload-time = "2025-10-02T19:29:13.353Z" }, + { url = "https://files.pythonhosted.org/packages/32/b0/a8705065b2dafae007bcae21354e6e2e832e03eb077bb6c8e523c2becb92/ruff-0.13.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6b139f638a80eae7073c691a5dd8d581e0ba319540be97c343d60fb12949c8d0", size = 12421893, upload-time = "2025-10-02T19:29:15.668Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/cbe7082588d025cddbb2f23e6dfef08b1a2ef6d6f8328584ad3015b5cebd/ruff-0.13.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6b547def0a40054825de7cfa341039ebdfa51f3d4bfa6a0772940ed351d2746c", size = 12267220, upload-time = "2025-10-02T19:29:17.583Z" }, + { url = "https://files.pythonhosted.org/packages/a5/99/4086f9c43f85e0755996d09bdcb334b6fee9b1eabdf34e7d8b877fadf964/ruff-0.13.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9cc48a3564423915c93573f1981d57d101e617839bef38504f85f3677b3a0a3e", size = 13177818, upload-time = "2025-10-02T19:29:19.943Z" }, + { url = "https://files.pythonhosted.org/packages/9b/de/7b5db7e39947d9dc1c5f9f17b838ad6e680527d45288eeb568e860467010/ruff-0.13.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1a993b17ec03719c502881cb2d5f91771e8742f2ca6de740034433a97c561989", size = 13618715, upload-time = "2025-10-02T19:29:22.527Z" }, + { url = "https://files.pythonhosted.org/packages/28/d3/bb25ee567ce2f61ac52430cf99f446b0e6d49bdfa4188699ad005fdd16aa/ruff-0.13.3-py3-none-win32.whl", hash = "sha256:f14e0d1fe6460f07814d03c6e32e815bff411505178a1f539a38f6097d3e8ee3", size = 12334488, upload-time = "2025-10-02T19:29:24.782Z" }, + { url = "https://files.pythonhosted.org/packages/cf/49/12f5955818a1139eed288753479ba9d996f6ea0b101784bb1fe6977ec128/ruff-0.13.3-py3-none-win_amd64.whl", hash = "sha256:621e2e5812b691d4f244638d693e640f188bacbb9bc793ddd46837cea0503dd2", size = 13455262, upload-time = "2025-10-02T19:29:26.882Z" }, + { url = "https://files.pythonhosted.org/packages/fe/72/7b83242b26627a00e3af70d0394d68f8f02750d642567af12983031777fc/ruff-0.13.3-py3-none-win_arm64.whl", hash = "sha256:9e9e9d699841eaf4c2c798fa783df2fabc680b72059a02ca0ed81c460bc58330", size = 12538484, upload-time = "2025-10-02T19:29:28.951Z" }, ] [[package]] @@ -1471,15 +1518,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.37.1" +version = "2.39.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/be/ffc232c32d0be18f8e4eff7a22dffc1f1fef2894703d64cc281a80e75da6/sentry_sdk-2.37.1.tar.gz", hash = "sha256:531751da91aa62a909b42a7be155b41f6bb0de9df6ae98441d23b95de2f98475", size = 346235, upload-time = "2025-09-09T13:48:27.137Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/72/43294fa4bdd75c51610b5104a3ff834459ba653abb415150aa7826a249dd/sentry_sdk-2.39.0.tar.gz", hash = "sha256:8c185854d111f47f329ab6bc35993f28f7a6b7114db64aa426b326998cfa14e9", size = 348556, upload-time = "2025-09-25T09:15:39.064Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/c3/cba447ab531331d165d9003c04473be944a308ad916ca2345b5ef1969ed9/sentry_sdk-2.37.1-py2.py3-none-any.whl", hash = "sha256:baaaea6608ed3a639766a69ded06b254b106d32ad9d180bdbe58f3db9364592b", size = 368307, upload-time = "2025-09-09T13:48:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/4356cc64246ba7b2b920f7c97a85c3c52748e213e250b512ee8152eb559d/sentry_sdk-2.39.0-py2.py3-none-any.whl", hash = "sha256:ba655ca5e57b41569b18e2a5552cb3375209760a5d332cdd87c6c3f28f729602", size = 370851, upload-time = "2025-09-25T09:15:36.35Z" }, ] [package.optional-dependencies] @@ -1545,28 +1592,28 @@ wheels = [ [[package]] name = "sqlmodel" -version = "0.0.24" +version = "0.0.25" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "sqlalchemy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/86/4b/c2ad0496f5bdc6073d9b4cef52be9c04f2b37a5773441cc6600b1857648b/sqlmodel-0.0.24.tar.gz", hash = "sha256:cc5c7613c1a5533c9c7867e1aab2fd489a76c9e8a061984da11b4e613c182423", size = 116780, upload-time = "2025-03-07T05:43:32.887Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/80/d9c098a88724ee4554907939cf39590cf67e10c6683723216e228d3315f7/sqlmodel-0.0.25.tar.gz", hash = "sha256:56548c2e645975b1ed94d6c53f0d13c85593f57926a575e2bf566650b2243fa4", size = 117075, upload-time = "2025-09-17T21:44:41.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/91/484cd2d05569892b7fef7f5ceab3bc89fb0f8a8c0cde1030d383dbc5449c/sqlmodel-0.0.24-py3-none-any.whl", hash = "sha256:6778852f09370908985b667d6a3ab92910d0d5ec88adcaf23dbc242715ff7193", size = 28622, upload-time = "2025-03-07T05:43:30.37Z" }, + { url = "https://files.pythonhosted.org/packages/57/cf/5d175ce8de07fe694ec4e3d4d65c2dd06cc30f6c79599b31f9d2f6dd2830/sqlmodel-0.0.25-py3-none-any.whl", hash = "sha256:c98234cda701fb77e9dcbd81688c23bb251c13bb98ce1dd8d4adc467374d45b7", size = 28893, upload-time = "2025-09-17T21:44:39.764Z" }, ] [[package]] name = "starlette" -version = "0.47.3" +version = "0.48.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/15/b9/cc3017f9a9c9b6e27c5106cc10cc7904653c3eec0729793aec10479dd669/starlette-0.47.3.tar.gz", hash = "sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9", size = 2584144, upload-time = "2025-08-24T13:36:42.122Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/a5/d6f429d43394057b67a6b5bbe6eae2f77a6bf7459d961fdb224bf206eee6/starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46", size = 2652949, upload-time = "2025-09-13T08:41:05.699Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/fd/901cfa59aaa5b30a99e16876f11abe38b59a1a2c51ffb3d7142bb6089069/starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51", size = 72991, upload-time = "2025-08-24T13:36:40.887Z" }, + { url = "https://files.pythonhosted.org/packages/be/72/2db2f49247d0a18b4f1bb9a5a39a0162869acf235f3a96418363947b3d46/starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659", size = 73736, upload-time = "2025-09-13T08:41:03.869Z" }, ] [[package]] @@ -1713,14 +1760,14 @@ wheels = [ [[package]] name = "typing-inspection" -version = "0.4.1" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] @@ -1755,15 +1802,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.35.0" +version = "0.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" } +sdist = { url = "https://files.pythonhosted.org/packages/71/57/1616c8274c3442d802621abf5deb230771c7a0fec9414cb6763900eb3868/uvicorn-0.37.0.tar.gz", hash = "sha256:4115c8add6d3fd536c8ee77f0e14a7fd2ebba939fed9b02583a97f80648f9e13", size = 80367, upload-time = "2025-09-23T13:33:47.486Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, + { url = "https://files.pythonhosted.org/packages/85/cd/584a2ceb5532af99dd09e50919e3615ba99aa127e9850eafe5f31ddfdb9a/uvicorn-0.37.0-py3-none-any.whl", hash = "sha256:913b2b88672343739927ce381ff9e2ad62541f9f8289664fa1d1d3803fa2ce6c", size = 67976, upload-time = "2025-09-23T13:33:45.842Z" }, ] [package.optional-dependencies]