feat(daily-challenge): simple implement
This commit is contained in:
@@ -25,7 +25,7 @@ from .playlist_best_score import PlaylistBestScore
|
||||
from .playlists import Playlist, PlaylistResp
|
||||
from .pp_best_score import PPBestScore
|
||||
from .relationship import Relationship, RelationshipResp, RelationshipType
|
||||
from .room import Room, RoomResp
|
||||
from .room import APIUploadedRoom, Room, RoomResp
|
||||
from .room_participated_user import RoomParticipatedUser
|
||||
from .score import (
|
||||
MultiplayerScores,
|
||||
@@ -48,6 +48,7 @@ from .user_account_history import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"APIUploadedRoom",
|
||||
"Beatmap",
|
||||
"Beatmapset",
|
||||
"BeatmapsetResp",
|
||||
|
||||
@@ -138,6 +138,6 @@ class PlaylistResp(PlaylistBase):
|
||||
) -> "PlaylistResp":
|
||||
data = playlist.model_dump()
|
||||
if "beatmap" in include:
|
||||
data["beatmap"] = await BeatmapResp.from_db(playlist.beatmap, from_set=True)
|
||||
data["beatmap"] = await BeatmapResp.from_db(playlist.beatmap)
|
||||
resp = cls.model_validate(data)
|
||||
return resp
|
||||
|
||||
@@ -160,3 +160,18 @@ class RoomResp(RoomBase):
|
||||
participant_count=len(room.users),
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
class APIUploadedRoom(RoomBase):
|
||||
def to_room(self) -> Room:
|
||||
"""
|
||||
将 APIUploadedRoom 转换为 Room 对象,playlist 字段需单独处理。
|
||||
"""
|
||||
room_dict = self.model_dump()
|
||||
room_dict.pop("playlist", None)
|
||||
# host_id 已在字段中
|
||||
return Room(**room_dict)
|
||||
|
||||
id: int | None
|
||||
host_id: int | None = None
|
||||
playlist: list[Playlist] = Field(default_factory=list)
|
||||
|
||||
26
app/dependencies/scheduler.py
Normal file
26
app/dependencies/scheduler.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
|
||||
scheduler: AsyncIOScheduler | None = None
|
||||
|
||||
|
||||
def init_scheduler():
|
||||
global scheduler
|
||||
scheduler = AsyncIOScheduler(timezone=UTC)
|
||||
scheduler.start()
|
||||
|
||||
|
||||
def get_scheduler() -> AsyncIOScheduler:
|
||||
global scheduler
|
||||
if scheduler is None:
|
||||
init_scheduler()
|
||||
return scheduler # pyright: ignore[reportReturnType]
|
||||
|
||||
|
||||
def stop_scheduler():
|
||||
global scheduler
|
||||
if scheduler:
|
||||
scheduler.shutdown()
|
||||
@@ -123,3 +123,7 @@ class OnlineStatus(IntEnum):
|
||||
OFFLINE = 0 # 隐身
|
||||
DO_NOT_DISTURB = 1
|
||||
ONLINE = 2
|
||||
|
||||
|
||||
class DailyChallengeInfo(BaseModel):
|
||||
room_id: int
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
from app.database.beatmap import Beatmap, BeatmapResp
|
||||
@@ -8,13 +8,14 @@ from app.database.beatmapset import BeatmapsetResp
|
||||
from app.database.lazer_user import User, UserResp
|
||||
from app.database.multiplayer_event import MultiplayerEvent, MultiplayerEventResp
|
||||
from app.database.playlist_attempts import ItemAttemptsCount, ItemAttemptsResp
|
||||
from app.database.playlists import Playlist, PlaylistBase, PlaylistResp
|
||||
from app.database.room import Room, RoomBase, RoomResp
|
||||
from app.database.playlists import Playlist, PlaylistResp
|
||||
from app.database.room import APIUploadedRoom, Room, RoomResp
|
||||
from app.database.room_participated_user import RoomParticipatedUser
|
||||
from app.database.score import Score
|
||||
from app.dependencies.database import get_db, get_redis
|
||||
from app.dependencies.user import 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 .api_router import router
|
||||
@@ -92,21 +93,6 @@ class APICreatedRoom(RoomResp):
|
||||
error: str = ""
|
||||
|
||||
|
||||
class APIUploadedRoom(RoomBase):
|
||||
def to_room(self) -> Room:
|
||||
"""
|
||||
将 APIUploadedRoom 转换为 Room 对象,playlist 字段需单独处理。
|
||||
"""
|
||||
room_dict = self.model_dump()
|
||||
room_dict.pop("playlist", None)
|
||||
# host_id 已在字段中
|
||||
return Room(**room_dict)
|
||||
|
||||
id: int | None
|
||||
host_id: int | None = None
|
||||
playlist: list[PlaylistBase] = Field(default_factory=list)
|
||||
|
||||
|
||||
async def _participate_room(
|
||||
room_id: int, user_id: int, db_room: Room, session: AsyncSession
|
||||
):
|
||||
@@ -137,27 +123,11 @@ async def create_room(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
# db_room = Room.from_resp(room)
|
||||
await db.refresh(current_user)
|
||||
user_id = current_user.id
|
||||
db_room = room.to_room()
|
||||
db_room.host_id = current_user.id if current_user.id else 1
|
||||
db_room.starts_at = datetime.now(UTC)
|
||||
db_room.ends_at = db_room.starts_at + timedelta(
|
||||
minutes=db_room.duration if db_room.duration is not None else 0
|
||||
)
|
||||
db.add(db_room)
|
||||
await db.commit()
|
||||
await db.refresh(db_room)
|
||||
db_room = await create_playlist_room_from_api(db, room, user_id)
|
||||
await _participate_room(db_room.id, user_id, db_room, db)
|
||||
|
||||
for item in room.playlist:
|
||||
item.id = await Playlist.get_next_id_for_room(db_room.id, db)
|
||||
item.room_id = db_room.id
|
||||
item.owner_id = user_id if user_id else 1
|
||||
db.add(item)
|
||||
await db.commit()
|
||||
await db.refresh(db_room)
|
||||
# await db.commit()
|
||||
# await db.refresh(db_room)
|
||||
created_room = APICreatedRoom.model_validate(await RoomResp.from_db(db_room, db))
|
||||
created_room.error = ""
|
||||
return created_room
|
||||
|
||||
@@ -32,6 +32,7 @@ from app.dependencies.fetcher import get_fetcher
|
||||
from app.dependencies.user import get_current_user
|
||||
from app.fetcher import Fetcher
|
||||
from app.models.beatmap import BeatmapRankStatus
|
||||
from app.models.room import RoomCategory
|
||||
from app.models.score import (
|
||||
INT_TO_MODE,
|
||||
GameMode,
|
||||
@@ -402,6 +403,10 @@ async def index_playlist_scores(
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
room = await session.get(Room, room_id)
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
|
||||
limit = clamp(limit, 1, 50)
|
||||
|
||||
scores = (
|
||||
@@ -426,6 +431,12 @@ async def index_playlist_scores(
|
||||
score.position = await get_position(room_id, playlist_id, score.id, session)
|
||||
if score.user_id == current_user.id:
|
||||
user_score = score
|
||||
|
||||
if room.category == RoomCategory.DAILY_CHALLENGE:
|
||||
score_resp = [s for s in score_resp if s.passed]
|
||||
if user_score and not user_score.passed:
|
||||
user_score = None
|
||||
|
||||
resp = IndexedScoreResp(
|
||||
scores=score_resp,
|
||||
user_score=user_score,
|
||||
|
||||
10
app/service/__init__.py
Normal file
10
app/service/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .daily_challenge import create_daily_challenge_room
|
||||
from .room import create_playlist_room, create_playlist_room_from_api
|
||||
|
||||
__all__ = [
|
||||
"create_daily_challenge_room",
|
||||
"create_playlist_room",
|
||||
"create_playlist_room_from_api",
|
||||
]
|
||||
103
app/service/daily_challenge.py
Normal file
103
app/service/daily_challenge.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import json
|
||||
|
||||
from app.database.playlists import Playlist
|
||||
from app.database.room import Room
|
||||
from app.dependencies.database import engine, get_redis
|
||||
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
|
||||
from app.models.room import RoomCategory
|
||||
from app.signalr.hub import MetadataHubs
|
||||
|
||||
from .room import create_playlist_room
|
||||
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
|
||||
async def create_daily_challenge_room(
|
||||
beatmap: int, ruleset_id: int, required_mods: list[APIMod] = []
|
||||
) -> Room:
|
||||
async with AsyncSession(engine) as session:
|
||||
today = datetime.now(UTC).date()
|
||||
return await create_playlist_room(
|
||||
session=session,
|
||||
name=str(today),
|
||||
host_id=3,
|
||||
playlist=[
|
||||
Playlist(
|
||||
id=0,
|
||||
room_id=0,
|
||||
owner_id=3,
|
||||
ruleset_id=ruleset_id,
|
||||
beatmap_id=beatmap,
|
||||
required_mods=required_mods,
|
||||
)
|
||||
],
|
||||
category=RoomCategory.DAILY_CHALLENGE,
|
||||
duration=24 * 60 - 2, # remain 2 minute to apply new daily challenge
|
||||
)
|
||||
|
||||
|
||||
@get_scheduler().scheduled_job("cron", hour=0, minute=0, second=0, id="daily_challenge")
|
||||
async def daily_challenge_job():
|
||||
today = datetime.now(UTC).date()
|
||||
redis = get_redis()
|
||||
key = f"daily_challenge:{today.year}-{today.month}-{today.day}"
|
||||
if not await redis.exists(key):
|
||||
return
|
||||
try:
|
||||
beatmap = await redis.hget(key, "beatmap") # pyright: ignore[reportGeneralTypeIssues]
|
||||
ruleset_id = await redis.hget(key, "ruleset_id") # pyright: ignore[reportGeneralTypeIssues]
|
||||
required_mods = await redis.hget(key, "required_mods") # pyright: ignore[reportGeneralTypeIssues]
|
||||
|
||||
if beatmap is None or ruleset_id is None:
|
||||
logger.warning(
|
||||
f"[DailyChallenge] Missing required data for daily challenge {today}."
|
||||
" Will try again in 5 minutes."
|
||||
)
|
||||
get_scheduler().add_job(
|
||||
daily_challenge_job,
|
||||
"date",
|
||||
run_date=datetime.now(UTC) + timedelta(minutes=5),
|
||||
)
|
||||
return
|
||||
|
||||
beatmap_int = int(beatmap)
|
||||
ruleset_id_int = int(ruleset_id)
|
||||
|
||||
mods_list = []
|
||||
if required_mods:
|
||||
mods_list = json.loads(required_mods)
|
||||
|
||||
room = await create_daily_challenge_room(
|
||||
beatmap=beatmap_int,
|
||||
ruleset_id=ruleset_id_int,
|
||||
required_mods=mods_list,
|
||||
)
|
||||
await MetadataHubs.broadcast_call(
|
||||
"DailyChallengeUpdated", DailyChallengeInfo(room_id=room.id)
|
||||
)
|
||||
logger.success(
|
||||
"[DailyChallenge] Added today's daily challenge: "
|
||||
f"{beatmap=}, {ruleset_id=}, {required_mods=}"
|
||||
)
|
||||
return
|
||||
except (ValueError, json.JSONDecodeError) as e:
|
||||
logger.warning(
|
||||
f"[DailyChallenge] Error processing daily challenge data: {e}"
|
||||
" Will try again in 5 minutes."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"[DailyChallenge] Unexpected error in daily challenge job: {e}"
|
||||
" Will try again in 5 minutes."
|
||||
)
|
||||
get_scheduler().add_job(
|
||||
daily_challenge_job,
|
||||
"date",
|
||||
run_date=datetime.now(UTC) + timedelta(minutes=5),
|
||||
)
|
||||
78
app/service/room.py
Normal file
78
app/service/room.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from app.database.beatmap import Beatmap
|
||||
from app.database.playlists import Playlist
|
||||
from app.database.room import APIUploadedRoom, Room
|
||||
from app.dependencies.fetcher import get_fetcher
|
||||
from app.models.room import MatchType, QueueMode, RoomCategory, RoomStatus
|
||||
|
||||
from sqlalchemy import exists
|
||||
from sqlmodel import col, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
|
||||
async def create_playlist_room_from_api(
|
||||
session: AsyncSession, room: APIUploadedRoom, host_id: int
|
||||
) -> Room:
|
||||
db_room = room.to_room()
|
||||
db_room.host_id = host_id
|
||||
db_room.starts_at = datetime.now(UTC)
|
||||
db_room.ends_at = db_room.starts_at + timedelta(
|
||||
minutes=db_room.duration if db_room.duration is not None else 0
|
||||
)
|
||||
session.add(db_room)
|
||||
await session.commit()
|
||||
await session.refresh(db_room)
|
||||
await add_playlists_to_room(session, db_room.id, room.playlist, host_id)
|
||||
await session.refresh(db_room)
|
||||
return db_room
|
||||
|
||||
|
||||
async def create_playlist_room(
|
||||
session: AsyncSession,
|
||||
name: str,
|
||||
host_id: int,
|
||||
category: RoomCategory = RoomCategory.NORMAL,
|
||||
duration: int = 30,
|
||||
max_attempts: int | None = None,
|
||||
playlist: list[Playlist] = [],
|
||||
) -> Room:
|
||||
db_room = Room(
|
||||
name=name,
|
||||
category=category,
|
||||
duration=duration,
|
||||
starts_at=datetime.now(UTC),
|
||||
ends_at=datetime.now(UTC) + timedelta(minutes=duration),
|
||||
participant_count=0,
|
||||
max_attempts=max_attempts,
|
||||
type=MatchType.PLAYLISTS,
|
||||
queue_mode=QueueMode.HOST_ONLY,
|
||||
auto_skip=False,
|
||||
auto_start_duration=0,
|
||||
status=RoomStatus.IDLE,
|
||||
host_id=host_id,
|
||||
)
|
||||
session.add(db_room)
|
||||
await session.commit()
|
||||
await session.refresh(db_room)
|
||||
await add_playlists_to_room(session, db_room.id, playlist, host_id)
|
||||
await session.refresh(db_room)
|
||||
return db_room
|
||||
|
||||
|
||||
async def add_playlists_to_room(
|
||||
session: AsyncSession, room_id: int, playlist: list[Playlist], owner_id: int
|
||||
):
|
||||
for item in playlist:
|
||||
if not (
|
||||
await session.exec(select(exists().where(col(Beatmap.id) == item.beatmap)))
|
||||
).first():
|
||||
fetcher = await get_fetcher()
|
||||
await Beatmap.get_or_fetch(session, fetcher, item.beatmap_id)
|
||||
item.id = await Playlist.get_next_id_for_room(room_id, session)
|
||||
item.room_id = room_id
|
||||
item.owner_id = owner_id
|
||||
session.add(item)
|
||||
await session.commit()
|
||||
@@ -6,12 +6,19 @@ from datetime import UTC, datetime
|
||||
from typing import override
|
||||
|
||||
from app.database import Relationship, RelationshipType, User
|
||||
from app.database.room import Room
|
||||
from app.dependencies.database import engine, get_redis
|
||||
from app.models.metadata_hub import MetadataClientState, OnlineStatus, UserActivity
|
||||
from app.models.metadata_hub import (
|
||||
DailyChallengeInfo,
|
||||
MetadataClientState,
|
||||
OnlineStatus,
|
||||
UserActivity,
|
||||
)
|
||||
from app.models.room import RoomCategory
|
||||
|
||||
from .hub import Client, Hub
|
||||
|
||||
from sqlmodel import select
|
||||
from sqlmodel import col, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
ONLINE_PRESENCE_WATCHERS_GROUP = "metadata:online-presence-watchers"
|
||||
@@ -107,6 +114,23 @@ class MetadataHub(Hub[MetadataClientState]):
|
||||
)
|
||||
)
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
daily_challenge_room = (
|
||||
await session.exec(
|
||||
select(Room).where(
|
||||
col(Room.ends_at) > datetime.now(UTC),
|
||||
Room.category == RoomCategory.DAILY_CHALLENGE,
|
||||
)
|
||||
)
|
||||
).first()
|
||||
if daily_challenge_room:
|
||||
await self.call_noblock(
|
||||
client,
|
||||
"DailyChallengeUpdated",
|
||||
DailyChallengeInfo(
|
||||
room_id=daily_challenge_room.id,
|
||||
),
|
||||
)
|
||||
redis = get_redis()
|
||||
await redis.set(f"metadata:online:{user_id}", "")
|
||||
|
||||
|
||||
3
main.py
3
main.py
@@ -6,6 +6,7 @@ from datetime import datetime
|
||||
from app.config import settings
|
||||
from app.dependencies.database import create_tables, engine, redis_client
|
||||
from app.dependencies.fetcher import get_fetcher
|
||||
from app.dependencies.scheduler import init_scheduler, stop_scheduler
|
||||
from app.router import (
|
||||
api_router,
|
||||
auth_router,
|
||||
@@ -21,8 +22,10 @@ async def lifespan(app: FastAPI):
|
||||
# on startup
|
||||
await create_tables()
|
||||
await get_fetcher() # 初始化 fetcher
|
||||
init_scheduler()
|
||||
# on shutdown
|
||||
yield
|
||||
stop_scheduler()
|
||||
await engine.dispose()
|
||||
await redis_client.aclose()
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"aiomysql>=0.2.0",
|
||||
"alembic>=1.12.1",
|
||||
"apscheduler>=3.11.0",
|
||||
"bcrypt>=4.1.2",
|
||||
"cryptography>=41.0.7",
|
||||
"fastapi>=0.104.1",
|
||||
|
||||
37
uv.lock
generated
37
uv.lock
generated
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 2
|
||||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[manifest]
|
||||
@@ -57,6 +57,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "apscheduler"
|
||||
version = "3.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "tzlocal" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/00/6d6814ddc19be2df62c8c898c4df6b5b1914f3bd024b780028caa392d186/apscheduler-3.11.0.tar.gz", hash = "sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133", size = 107347, upload-time = "2024-11-24T19:39:26.463Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/ae/9a053dd9229c0fde6b1f1f33f609ccff1ee79ddda364c756a924c6d8563b/APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da", size = 64004, upload-time = "2024-11-24T19:39:24.442Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bcrypt"
|
||||
version = "4.3.0"
|
||||
@@ -493,6 +505,7 @@ source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiomysql" },
|
||||
{ name = "alembic" },
|
||||
{ name = "apscheduler" },
|
||||
{ name = "bcrypt" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "fastapi" },
|
||||
@@ -522,6 +535,7 @@ dev = [
|
||||
requires-dist = [
|
||||
{ name = "aiomysql", specifier = ">=0.2.0" },
|
||||
{ name = "alembic", specifier = ">=1.12.1" },
|
||||
{ name = "apscheduler", specifier = ">=3.11.0" },
|
||||
{ name = "bcrypt", specifier = ">=4.1.2" },
|
||||
{ name = "cryptography", specifier = ">=41.0.7" },
|
||||
{ name = "fastapi", specifier = ">=0.104.1" },
|
||||
@@ -904,6 +918,27 @@ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2025.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzlocal"
|
||||
version = "5.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.35.0"
|
||||
|
||||
Reference in New Issue
Block a user