18 Commits

Author SHA1 Message Date
MingxuanGame
1ce5f3cc16 chore(custom-ruleset): Update custom ruleset definitions (#105) 2026-01-17 23:46:34 +08:00
MingxuanGame
96c8b70df6 fix(chat): fix pydantic warnings for channel name 2026-01-11 16:38:03 +08:00
MingxuanGame
8923d714a7 feat(client-verification): add client verification (#104)
New configurations:

- `CHECK_CLIENT_VERSION` enables the check (default=True)
- `CLIENT_VERSION_URLS` contains a chain of valid client hashes. [osu!](https://osu.ppy.sh/home/download) and [osu! GU](https://github.com/GooGuTeam/osu/releases) are valid by default. View [g0v0-client-versions](https://github.com/GooGuTeam/g0v0-client-versions) to learn how to support your own client.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-11 16:30:25 +08:00
MingxuanGame
e5802aefbb fix(rosu-pp-py): extra arguments for rosu calculator 2026-01-10 20:06:53 +08:00
MingxuanGame
c323373510 fix(newrelic): check whether newrelic.ini is a file 2026-01-10 19:53:03 +08:00
pre-commit-ci[bot]
fe0c13bdd3 chore(deps): auto update by pre-commit hooks (#102)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.14.7 → v0.14.10](https://github.com/astral-sh/ruff-pre-commit/compare/v0.14.7...v0.14.10)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-01-10 19:10:47 +08:00
MingxuanGame
1c3b309804 fix(user): missing the first entry 2026-01-03 16:37:55 +08:00
MingxuanGame
282eda3250 fix(user): order by id on favourite beatmapsets 2026-01-03 16:04:47 +08:00
MingxuanGame
38a2c8720b chore(dev): update devcontainer configuration
To tackle the problem that nginx cannot connect to server due to ip change
2026-01-03 15:38:16 +08:00
MingxuanGame
87ffc6f581 perf(user): use keyset to boost user scores API & user beatmap API 2026-01-03 15:36:18 +08:00
MingxuanGame
735a22d500 chore(custom-ruleset): update custom ruleset definitions (#100)
* chore(custom-ruleset): Update custom ruleset definitions
2025-12-28 18:00:26 +08:00
dependabot[bot]
a6c596318e chore(deps): bump the minor-and-patch group with 4 updates (#99)
* chore(deps): bump the minor-and-patch group with 4 updates

Bumps the minor-and-patch group with 4 updates: [apscheduler](https://github.com/agronholm/apscheduler), [fastapi](https://github.com/fastapi/fastapi), [sqlmodel](https://github.com/fastapi/sqlmodel) and [datamodel-code-generator](https://github.com/koxudaxi/datamodel-code-generator).


Updates `apscheduler` from 3.11.1 to 3.11.2
- [Release notes](https://github.com/agronholm/apscheduler/releases)
- [Commits](https://github.com/agronholm/apscheduler/compare/3.11.1...3.11.2)

Updates `fastapi` from 0.126.0 to 0.128.0
- [Release notes](https://github.com/fastapi/fastapi/releases)
- [Commits](https://github.com/fastapi/fastapi/compare/0.126.0...0.128.0)

Updates `sqlmodel` from 0.0.27 to 0.0.30
- [Release notes](https://github.com/fastapi/sqlmodel/releases)
- [Changelog](https://github.com/fastapi/sqlmodel/blob/main/docs/release-notes.md)
- [Commits](https://github.com/fastapi/sqlmodel/compare/0.0.27...0.0.30)

Updates `datamodel-code-generator` from 0.46.0 to 0.49.0
- [Release notes](https://github.com/koxudaxi/datamodel-code-generator/releases)
- [Changelog](https://github.com/koxudaxi/datamodel-code-generator/blob/main/CHANGELOG.md)
- [Commits](https://github.com/koxudaxi/datamodel-code-generator/compare/0.46.0...0.49.0)

---
updated-dependencies:
- dependency-name: apscheduler
  dependency-version: 3.11.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: fastapi
  dependency-version: 0.128.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: sqlmodel
  dependency-version: 0.0.30
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: datamodel-code-generator
  dependency-version: 0.49.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(linter): auto fix by pre-commit hooks

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-12-28 17:26:53 +08:00
MingxuanGame
fed1471129 fix(multiplayer): channel is not set as mp channel again 2025-12-27 19:55:42 +08:00
MingxuanGame
a58b4cb172 fix(multiplayer): channel is not set as mp channel 2025-12-27 19:30:09 +08:00
MingxuanGame
e5a4a0d9e4 fix(playlist): add playlist and room parameters to ScoreModel transform 2025-12-27 19:04:37 +08:00
MingxuanGame
10095f7da2 fix(score): remove exclude flag from total_score_without_mods field 2025-12-27 18:50:14 +08:00
dependabot[bot]
18574587e3 chore(deps): bump the minor-and-patch group with 7 updates (#98)
Bumps the minor-and-patch group with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [fastapi](https://github.com/fastapi/fastapi) | `0.124.0` | `0.126.0` |
| [newrelic](https://github.com/newrelic/newrelic-python-agent) | `11.1.0` | `11.2.0` |
| [python-multipart](https://github.com/Kludex/python-multipart) | `0.0.20` | `0.0.21` |
| [sqlalchemy](https://github.com/sqlalchemy/sqlalchemy) | `2.0.44` | `2.0.45` |
| [datamodel-code-generator](https://github.com/koxudaxi/datamodel-code-generator) | `0.41.0` | `0.46.0` |
| [pre-commit](https://github.com/pre-commit/pre-commit) | `4.5.0` | `4.5.1` |
| [ruff](https://github.com/astral-sh/ruff) | `0.14.8` | `0.14.10` |


Updates `fastapi` from 0.124.0 to 0.126.0
- [Release notes](https://github.com/fastapi/fastapi/releases)
- [Commits](https://github.com/fastapi/fastapi/compare/0.124.0...0.126.0)

Updates `newrelic` from 11.1.0 to 11.2.0
- [Release notes](https://github.com/newrelic/newrelic-python-agent/releases)
- [Commits](https://github.com/newrelic/newrelic-python-agent/compare/v11.1.0...v11.2.0)

Updates `python-multipart` from 0.0.20 to 0.0.21
- [Release notes](https://github.com/Kludex/python-multipart/releases)
- [Changelog](https://github.com/Kludex/python-multipart/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Kludex/python-multipart/compare/0.0.20...0.0.21)

Updates `sqlalchemy` from 2.0.44 to 2.0.45
- [Release notes](https://github.com/sqlalchemy/sqlalchemy/releases)
- [Changelog](https://github.com/sqlalchemy/sqlalchemy/blob/main/CHANGES.rst)
- [Commits](https://github.com/sqlalchemy/sqlalchemy/commits)

Updates `datamodel-code-generator` from 0.41.0 to 0.46.0
- [Release notes](https://github.com/koxudaxi/datamodel-code-generator/releases)
- [Changelog](https://github.com/koxudaxi/datamodel-code-generator/blob/main/CHANGELOG.md)
- [Commits](https://github.com/koxudaxi/datamodel-code-generator/compare/0.41.0...0.46.0)

Updates `pre-commit` from 4.5.0 to 4.5.1
- [Release notes](https://github.com/pre-commit/pre-commit/releases)
- [Changelog](https://github.com/pre-commit/pre-commit/blob/main/CHANGELOG.md)
- [Commits](https://github.com/pre-commit/pre-commit/compare/v4.5.0...v4.5.1)

Updates `ruff` from 0.14.8 to 0.14.10
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.14.8...0.14.10)

---
updated-dependencies:
- dependency-name: fastapi
  dependency-version: 0.126.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: newrelic
  dependency-version: 11.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: python-multipart
  dependency-version: 0.0.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: sqlalchemy
  dependency-version: 2.0.45
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: datamodel-code-generator
  dependency-version: 0.46.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: pre-commit
  dependency-version: 4.5.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: ruff
  dependency-version: 0.14.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-21 16:40:16 +08:00
MingxuanGame
f628061971 feat(score_token): add room_id to score token table 2025-12-20 19:42:14 +08:00
28 changed files with 689 additions and 221 deletions

View File

@@ -73,7 +73,6 @@ services:
image: nginx:alpine image: nginx:alpine
restart: unless-stopped restart: unless-stopped
ports: ports:
- "80:80"
- "8080:80" - "8080:80"
volumes: volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro

View File

@@ -1,14 +1,18 @@
resolver 127.0.0.11 valid=10s ipv6=off;
map $http_upgrade $connection_upgrade { map $http_upgrade $connection_upgrade {
default upgrade; default upgrade;
'' close; '' close;
} }
upstream app { upstream app {
server devcontainer:8000; zone app_backend 64k;
server devcontainer:8000 resolve;
} }
upstream spectator { upstream spectator {
server devcontainer:8086; zone app_backend 64k;
server devcontainer:8086 resolve;
} }
server { server {

View File

@@ -7,7 +7,7 @@ ci:
autoupdate_commit_msg: "chore(deps): auto update by pre-commit hooks" autoupdate_commit_msg: "chore(deps): auto update by pre-commit hooks"
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.7 rev: v0.14.10
hooks: hooks:
- id: ruff-check - id: ruff-check
args: [--fix] args: [--fix]

View File

@@ -31,6 +31,9 @@ class AvailableModes(NamedTuple):
class PerformanceCalculator(abc.ABC): class PerformanceCalculator(abc.ABC):
def __init__(self, **kwargs) -> None:
pass
@abc.abstractmethod @abc.abstractmethod
async def get_available_modes(self) -> AvailableModes: async def get_available_modes(self) -> AvailableModes:
raise NotImplementedError raise NotImplementedError

View File

@@ -33,7 +33,7 @@ class AvailableRulesetResp(TypedDict):
class PerformanceServerPerformanceCalculator(BasePerformanceCalculator): class PerformanceServerPerformanceCalculator(BasePerformanceCalculator):
def __init__(self, server_url: str = "http://localhost:5225") -> None: def __init__(self, server_url: str = "http://localhost:5225", **kwargs) -> None: # noqa: ARG002
self.server_url = server_url self.server_url = server_url
self._available_modes: AvailableModes | None = None self._available_modes: AvailableModes | None = None

View File

@@ -705,6 +705,21 @@ CALCULATOR_CONFIG='{}'
Field(default=True, description="检查自定义 ruleset 版本"), Field(default=True, description="检查自定义 ruleset 版本"),
"反作弊设置", "反作弊设置",
] ]
check_client_version: Annotated[
bool,
Field(default=True, description="检查客户端版本"),
"反作弊设置",
]
client_version_urls: Annotated[
list[str],
Field(
default=["https://raw.githubusercontent.com/GooGuTeam/g0v0-client-versions/main/version_list.json"],
description=(
"客户端版本列表 URL, 查看 https://github.com/GooGuTeam/g0v0-client-versions 来添加你自己的客户端"
),
),
"反作弊设置",
]
# 存储设置 # 存储设置
storage_service: Annotated[ storage_service: Annotated[

View File

@@ -12,6 +12,7 @@ from sqlmodel import (
Column, Column,
Field, Field,
ForeignKey, ForeignKey,
Index,
Relationship, Relationship,
select, select,
) )
@@ -32,11 +33,7 @@ class BeatmapPlaycountsDict(TypedDict):
class BeatmapPlaycountsModel(AsyncAttrs, DatabaseModel[BeatmapPlaycountsDict]): class BeatmapPlaycountsModel(AsyncAttrs, DatabaseModel[BeatmapPlaycountsDict]):
__tablename__: str = "beatmap_playcounts" id: int = Field(default=None, sa_column=Column(BigInteger, primary_key=True, autoincrement=True), exclude=True)
id: int | None = Field(
default=None, sa_column=Column(BigInteger, primary_key=True, autoincrement=True), exclude=True
)
user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True)) user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True))
beatmap_id: int = Field(foreign_key="beatmaps.id", index=True) beatmap_id: int = Field(foreign_key="beatmaps.id", index=True)
playcount: int = Field(default=0, exclude=True) playcount: int = Field(default=0, exclude=True)
@@ -68,6 +65,9 @@ class BeatmapPlaycountsModel(AsyncAttrs, DatabaseModel[BeatmapPlaycountsDict]):
class BeatmapPlaycounts(BeatmapPlaycountsModel, table=True): class BeatmapPlaycounts(BeatmapPlaycountsModel, table=True):
__tablename__: str = "beatmap_playcounts"
__table_args__ = (Index("idx_beatmap_playcounts_playcount_id", "playcount", "id"),)
user: "User" = Relationship() user: "User" = Relationship()
beatmap: "Beatmap" = Relationship() beatmap: "Beatmap" = Relationship()

View File

@@ -93,7 +93,7 @@ class ChatChannelModel(DatabaseModel[ChatChannelDict]):
target_user_id = next(u for u in users if u != user.id) target_user_id = next(u for u in users if u != user.id)
target_name = await session.exec(select(User.username).where(User.id == target_user_id)) target_name = await session.exec(select(User.username).where(User.id == target_user_id))
return target_name.one() return target_name.one()
return channel.name return channel.channel_name
@included @included
@staticmethod @staticmethod
@@ -208,7 +208,7 @@ class ChatChannelModel(DatabaseModel[ChatChannelDict]):
class ChatChannel(ChatChannelModel, table=True): class ChatChannel(ChatChannelModel, table=True):
__tablename__: str = "chat_channels" __tablename__: str = "chat_channels"
name: str = Field(sa_column=Column(VARCHAR(50), index=True)) channel_name: str = Field(sa_column=Column(name="name", type_=VARCHAR(50), index=True))
@classmethod @classmethod
async def get(cls, channel: str | int, session: AsyncSession) -> "ChatChannel | None": async def get(cls, channel: str | int, session: AsyncSession) -> "ChatChannel | None":
@@ -218,7 +218,7 @@ class ChatChannel(ChatChannelModel, table=True):
channel_ = result.first() channel_ = result.first()
if channel_ is not None: if channel_ is not None:
return channel_ return channel_
result = await session.exec(select(ChatChannel).where(ChatChannel.name == channel)) result = await session.exec(select(ChatChannel).where(ChatChannel.channel_name == channel))
return result.first() return result.first()
@classmethod @classmethod

View File

@@ -17,7 +17,8 @@ from sqlmodel import (
class FavouriteBeatmapset(AsyncAttrs, SQLModel, table=True): class FavouriteBeatmapset(AsyncAttrs, SQLModel, table=True):
__tablename__: str = "favourite_beatmapset" __tablename__: str = "favourite_beatmapset"
id: int | None = Field(
id: int = Field(
default=None, default=None,
sa_column=Column(BigInteger, autoincrement=True, primary_key=True), sa_column=Column(BigInteger, autoincrement=True, primary_key=True),
exclude=True, exclude=True,

View File

@@ -56,9 +56,9 @@ from .user import User, UserDict, UserModel
from pydantic import BaseModel, field_serializer, field_validator from pydantic import BaseModel, field_serializer, field_validator
from redis.asyncio import Redis from redis.asyncio import Redis
from sqlalchemy import Boolean, Column, DateTime, TextClause from sqlalchemy import Boolean, Column, DateTime, Index, TextClause, exists
from sqlalchemy.ext.asyncio import AsyncAttrs from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import Mapped, joinedload from sqlalchemy.orm import Mapped, aliased, joinedload
from sqlalchemy.sql.elements import ColumnElement from sqlalchemy.sql.elements import ColumnElement
from sqlmodel import ( from sqlmodel import (
JSON, JSON,
@@ -158,7 +158,7 @@ class ScoreModel(AsyncAttrs, DatabaseModel[ScoreDict]):
total_score: int = Field(default=0, sa_column=Column(BigInteger)) total_score: int = Field(default=0, sa_column=Column(BigInteger))
maximum_statistics: ScoreStatistics = Field(sa_column=Column(JSON), default_factory=dict) maximum_statistics: ScoreStatistics = Field(sa_column=Column(JSON), default_factory=dict)
mods: list[APIMod] = Field(sa_column=Column(JSON)) mods: list[APIMod] = Field(sa_column=Column(JSON))
total_score_without_mods: int = Field(default=0, sa_column=Column(BigInteger), exclude=True) total_score_without_mods: int = Field(default=0, sa_column=Column(BigInteger))
# solo # solo
classic_total_score: int | None = Field(default=0, sa_column=Column(BigInteger)) classic_total_score: int | None = Field(default=0, sa_column=Column(BigInteger))
@@ -414,6 +414,11 @@ class ScoreModel(AsyncAttrs, DatabaseModel[ScoreDict]):
class Score(ScoreModel, table=True): class Score(ScoreModel, table=True):
__tablename__: str = "scores" __tablename__: str = "scores"
__table_args__ = (
Index("idx_score_user_mode_pinned", "user_id", "gamemode", "pinned_order", "id"),
Index("idx_score_user_mode_pp", "user_id", "gamemode", "pp", "id"),
Index("idx_score_user_mode_date", "user_id", "gamemode", "ended_at", "id"),
)
# ScoreStatistics # ScoreStatistics
n300: int = Field(exclude=True) n300: int = Field(exclude=True)
@@ -828,64 +833,54 @@ async def get_user_best_score_with_mod_in_beatmap(
async def get_user_first_scores( async def get_user_first_scores(
session: AsyncSession, user_id: int, mode: GameMode, limit: int = 5, offset: int = 0 session: AsyncSession,
user_id: int,
mode: GameMode,
limit: int = 5,
offset: int = 0,
cursor_id: int | None = None,
) -> list[TotalScoreBestScore]: ) -> list[TotalScoreBestScore]:
rownum = ( # Alias for the subquery table
func.row_number() s2 = aliased(TotalScoreBestScore)
.over(
partition_by=(col(TotalScoreBestScore.beatmap_id), col(TotalScoreBestScore.gamemode)), query = select(TotalScoreBestScore).where(
order_by=col(TotalScoreBestScore.total_score).desc(), TotalScoreBestScore.user_id == user_id,
) TotalScoreBestScore.gamemode == mode,
.label("rn")
) )
# Step 1: Fetch top score_ids in Python # Subquery for NOT EXISTS
subq = ( # Check if there is a score with same beatmap, same mode, but higher total_score
select( subq = select(1).where(
col(TotalScoreBestScore.score_id).label("score_id"), s2.beatmap_id == TotalScoreBestScore.beatmap_id,
col(TotalScoreBestScore.user_id).label("user_id"), s2.gamemode == TotalScoreBestScore.gamemode,
rownum, s2.total_score > TotalScoreBestScore.total_score,
)
.where(col(TotalScoreBestScore.gamemode) == mode)
.subquery()
) )
top_ids_stmt = select(subq.c.score_id).where(subq.c.rn == 1, subq.c.user_id == user_id).limit(limit).offset(offset) query = query.where(~exists(subq))
top_ids = await session.exec(top_ids_stmt) if cursor_id:
top_ids = list(top_ids) query = query.where(TotalScoreBestScore.score_id < cursor_id)
stmt = ( query = query.order_by(col(TotalScoreBestScore.score_id).desc()).limit(limit).offset(offset)
select(TotalScoreBestScore)
.where(col(TotalScoreBestScore.score_id).in_(top_ids))
.order_by(col(TotalScoreBestScore.total_score).desc())
)
result = await session.exec(stmt) result = await session.exec(query)
return list(result.all()) return list(result.all())
async def get_user_first_score_count(session: AsyncSession, user_id: int, mode: GameMode) -> int: async def get_user_first_score_count(session: AsyncSession, user_id: int, mode: GameMode) -> int:
rownum = ( s2 = aliased(TotalScoreBestScore)
func.row_number() query = select(func.count()).where(
.over( TotalScoreBestScore.user_id == user_id,
partition_by=(col(TotalScoreBestScore.beatmap_id), col(TotalScoreBestScore.gamemode)), TotalScoreBestScore.gamemode == mode,
order_by=col(TotalScoreBestScore.total_score).desc(),
)
.label("rn")
) )
subq = ( subq = select(1).where(
select( s2.beatmap_id == TotalScoreBestScore.beatmap_id,
col(TotalScoreBestScore.score_id).label("score_id"), s2.gamemode == TotalScoreBestScore.gamemode,
col(TotalScoreBestScore.user_id).label("user_id"), s2.total_score > TotalScoreBestScore.total_score,
rownum,
)
.where(col(TotalScoreBestScore.gamemode) == mode)
.subquery()
) )
count_stmt = select(func.count()).where(subq.c.rn == 1, subq.c.user_id == user_id) query = query.where(~exists(subq))
result = await session.exec(count_stmt) result = await session.exec(query)
return result.one() return result.one()
@@ -975,8 +970,6 @@ async def process_score(
score_token: ScoreToken, score_token: ScoreToken,
info: SoloScoreSubmissionInfo, info: SoloScoreSubmissionInfo,
session: AsyncSession, session: AsyncSession,
item_id: int | None = None,
room_id: int | None = None,
) -> Score: ) -> Score:
gamemode = GameMode.from_int(info.ruleset_id).to_special_mode(info.mods) gamemode = GameMode.from_int(info.ruleset_id).to_special_mode(info.mods)
logger.info( logger.info(
@@ -1014,8 +1007,8 @@ async def process_score(
nsmall_tick_hit=info.statistics.get(HitResult.SMALL_TICK_HIT, 0), nsmall_tick_hit=info.statistics.get(HitResult.SMALL_TICK_HIT, 0),
nlarge_tick_hit=info.statistics.get(HitResult.LARGE_TICK_HIT, 0), nlarge_tick_hit=info.statistics.get(HitResult.LARGE_TICK_HIT, 0),
nslider_tail_hit=info.statistics.get(HitResult.SLIDER_TAIL_HIT, 0), nslider_tail_hit=info.statistics.get(HitResult.SLIDER_TAIL_HIT, 0),
playlist_item_id=item_id, playlist_item_id=score_token.playlist_item_id,
room_id=room_id, room_id=score_token.room_id,
maximum_statistics=info.maximum_statistics, maximum_statistics=info.maximum_statistics,
processed=True, processed=True,
ranked=ranked, ranked=ranked,

View File

@@ -13,17 +13,6 @@ from sqlmodel import BigInteger, Field, ForeignKey, Relationship, SQLModel
class ScoreTokenBase(SQLModel, UTCBaseModel): class ScoreTokenBase(SQLModel, UTCBaseModel):
score_id: int | None = Field(sa_column=Column(BigInteger), default=None)
ruleset_id: GameMode
playlist_item_id: int | None = Field(default=None) # playlist
created_at: datetime = Field(default_factory=utcnow, sa_column=Column(DateTime))
updated_at: datetime = Field(default_factory=utcnow, sa_column=Column(DateTime))
class ScoreToken(ScoreTokenBase, table=True):
__tablename__: str = "score_tokens"
__table_args__ = (Index("idx_user_playlist", "user_id", "playlist_item_id"),)
id: int | None = Field( id: int | None = Field(
default=None, default=None,
sa_column=Column( sa_column=Column(
@@ -33,18 +22,28 @@ class ScoreToken(ScoreTokenBase, table=True):
autoincrement=True, autoincrement=True,
), ),
) )
score_id: int | None = Field(sa_column=Column(BigInteger), default=None)
ruleset_id: GameMode
user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("lazer_users.id"))) user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("lazer_users.id")))
beatmap_id: int = Field(foreign_key="beatmaps.id") beatmap_id: int = Field(foreign_key="beatmaps.id")
user: Mapped[User] = Relationship() room_id: int | None = Field(default=None)
playlist_item_id: int | None = Field(default=None) # playlist
created_at: datetime = Field(default_factory=utcnow, sa_column=Column(DateTime))
updated_at: datetime = Field(default_factory=utcnow, sa_column=Column(DateTime))
class ScoreToken(ScoreTokenBase, table=True):
__tablename__: str = "score_tokens"
__table_args__ = (
Index("idx_user_playlist", "user_id", "playlist_item_id"),
Index("idx_playlist_room", "playlist_item_id", "room_id"),
)
user: Mapped[User] = Relationship()
beatmap: Mapped[Beatmap] = Relationship() beatmap: Mapped[Beatmap] = Relationship()
class ScoreTokenResp(ScoreTokenBase): class ScoreTokenResp(ScoreTokenBase):
id: int
user_id: int
beatmap_id: int
@classmethod @classmethod
def from_db(cls, obj: ScoreToken) -> "ScoreTokenResp": def from_db(cls, obj: ScoreToken) -> "ScoreTokenResp":
return cls.model_validate(obj) return cls.model_validate(obj)

View File

@@ -6,6 +6,7 @@ from app.models.score import GameMode, Rank
from .statistics import UserStatistics from .statistics import UserStatistics
from .user import User from .user import User
from sqlalchemy import Index
from sqlmodel import ( from sqlmodel import (
JSON, JSON,
BigInteger, BigInteger,
@@ -27,6 +28,10 @@ if TYPE_CHECKING:
class TotalScoreBestScore(SQLModel, table=True): class TotalScoreBestScore(SQLModel, table=True):
__tablename__: str = "total_score_best_scores" __tablename__: str = "total_score_best_scores"
__table_args__ = (
Index("ix_total_score_best_scores_user_mode_score", "user_id", "gamemode", "score_id"),
Index("ix_total_score_best_scores_beatmap_mode_score", "beatmap_id", "gamemode", "total_score"),
)
user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True)) user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True))
score_id: int = Field(sa_column=Column(BigInteger, ForeignKey("scores.id"), primary_key=True)) score_id: int = Field(sa_column=Column(BigInteger, ForeignKey("scores.id"), primary_key=True))
beatmap_id: int = Field(foreign_key="beatmaps.id", index=True) beatmap_id: int = Field(foreign_key="beatmaps.id", index=True)

View File

@@ -0,0 +1,10 @@
from typing import Annotated
from app.service.client_verification_service import (
ClientVerificationService as OriginalClientVerificationService,
get_client_verification_service,
)
from fastapi import Depends
ClientVerificationService = Annotated[OriginalClientVerificationService, Depends(get_client_verification_service)]

View File

@@ -1,4 +1,4 @@
# Version: 2025.1108.0 # Version: 2026.117.1
# Auto-generated by scripts/generate_ruleset_attributes.py. # Auto-generated by scripts/generate_ruleset_attributes.py.
# Schema generated by https://github.com/GooGuTeam/custom-rulesets # Schema generated by https://github.com/GooGuTeam/custom-rulesets
# Do not edit this file directly. # Do not edit this file directly.
@@ -29,9 +29,9 @@ class OsuPerformanceAttributes(PerformanceAttributes):
accuracy: float accuracy: float
flashlight: float flashlight: float
effective_miss_count: float effective_miss_count: float
speed_deviation: float | None = None speed_deviation: float | None
combo_based_estimated_miss_count: float combo_based_estimated_miss_count: float
score_based_estimated_miss_count: float | None = None score_based_estimated_miss_count: float | None
aim_estimated_slider_breaks: float aim_estimated_slider_breaks: float
speed_estimated_slider_breaks: float speed_estimated_slider_breaks: float
@@ -66,7 +66,7 @@ class OsuDifficultyAttributes(DifficultyAttributes):
class TaikoPerformanceAttributes(PerformanceAttributes): class TaikoPerformanceAttributes(PerformanceAttributes):
difficulty: float difficulty: float
accuracy: float accuracy: float
estimated_unstable_rate: float | None = None estimated_unstable_rate: float | None
class TaikoDifficultyAttributes(DifficultyAttributes): class TaikoDifficultyAttributes(DifficultyAttributes):

27
app/models/version.py Normal file
View File

@@ -0,0 +1,27 @@
from typing import NamedTuple, TypedDict
class VersionInfo(TypedDict):
version: str
release_date: str
hashes: dict[str, str]
class VersionList(TypedDict):
name: str
versions: list[VersionInfo]
class VersionCheckResult(NamedTuple):
is_valid: bool
client_name: str = ""
version: str = ""
os: str = ""
def __bool__(self) -> bool:
return self.is_valid
def __str__(self) -> str:
if self.is_valid:
return f"{self.client_name} {self.version} ({self.os})"
return "Invalid Client Version"

View File

@@ -59,7 +59,7 @@ async def _ensure_room_chat_channel(
await db.commit() await db.commit()
await db.refresh(ch) await db.refresh(ch)
await db.refresh(room) await db.refresh(room)
if room.channel_id is None: if room.channel_id == 0:
room.channel_id = ch.channel_id room.channel_id = ch.channel_id
else: else:
room.channel_id = ch.channel_id room.channel_id = ch.channel_id

View File

@@ -99,7 +99,7 @@ async def join_channel(
if channel.isdigit(): if channel.isdigit():
db_channel = (await session.exec(select(ChatChannel).where(ChatChannel.channel_id == int(channel)))).first() db_channel = (await session.exec(select(ChatChannel).where(ChatChannel.channel_id == int(channel)))).first()
else: else:
db_channel = (await session.exec(select(ChatChannel).where(ChatChannel.name == channel))).first() db_channel = (await session.exec(select(ChatChannel).where(ChatChannel.channel_name == channel))).first()
if db_channel is None: if db_channel is None:
raise HTTPException(status_code=404, detail="Channel not found") raise HTTPException(status_code=404, detail="Channel not found")
@@ -126,7 +126,7 @@ async def leave_channel(
if channel.isdigit(): if channel.isdigit():
db_channel = (await session.exec(select(ChatChannel).where(ChatChannel.channel_id == int(channel)))).first() db_channel = (await session.exec(select(ChatChannel).where(ChatChannel.channel_id == int(channel)))).first()
else: else:
db_channel = (await session.exec(select(ChatChannel).where(ChatChannel.name == channel))).first() db_channel = (await session.exec(select(ChatChannel).where(ChatChannel.channel_name == channel))).first()
if db_channel is None: if db_channel is None:
raise HTTPException(status_code=404, detail="Channel not found") raise HTTPException(status_code=404, detail="Channel not found")
@@ -182,14 +182,14 @@ async def get_channel(
if channel.isdigit(): if channel.isdigit():
db_channel = (await session.exec(select(ChatChannel).where(ChatChannel.channel_id == int(channel)))).first() db_channel = (await session.exec(select(ChatChannel).where(ChatChannel.channel_id == int(channel)))).first()
else: else:
db_channel = (await session.exec(select(ChatChannel).where(ChatChannel.name == channel))).first() db_channel = (await session.exec(select(ChatChannel).where(ChatChannel.channel_name == channel))).first()
if db_channel is None: if db_channel is None:
raise HTTPException(status_code=404, detail="Channel not found") raise HTTPException(status_code=404, detail="Channel not found")
# 立即提取需要的属性 # 立即提取需要的属性
channel_type = db_channel.type channel_type = db_channel.type
channel_name = db_channel.name channel_name = db_channel.channel_name
users = [] users = []
if channel_type == ChannelType.PM: if channel_type == ChannelType.PM:
@@ -270,7 +270,7 @@ async def create_channel(
channel_name = f"pm_{current_user.id}_{req.target_id}" channel_name = f"pm_{current_user.id}_{req.target_id}"
else: else:
channel_name = req.channel.name if req.channel else "Unnamed Channel" channel_name = req.channel.name if req.channel else "Unnamed Channel"
result = await session.exec(select(ChatChannel).where(ChatChannel.name == channel_name)) result = await session.exec(select(ChatChannel).where(ChatChannel.channel_name == channel_name))
channel = result.first() channel = result.first()
if channel is None: if channel is None:

View File

@@ -87,7 +87,7 @@ async def send_message(
if channel.isdigit(): if channel.isdigit():
db_channel = (await session.exec(select(ChatChannel).where(ChatChannel.channel_id == int(channel)))).first() db_channel = (await session.exec(select(ChatChannel).where(ChatChannel.channel_id == int(channel)))).first()
else: else:
db_channel = (await session.exec(select(ChatChannel).where(ChatChannel.name == channel))).first() db_channel = (await session.exec(select(ChatChannel).where(ChatChannel.channel_name == channel))).first()
if db_channel is None: if db_channel is None:
raise HTTPException(status_code=404, detail="Channel not found") raise HTTPException(status_code=404, detail="Channel not found")
@@ -95,7 +95,7 @@ async def send_message(
# 立即提取所有需要的属性,避免后续延迟加载 # 立即提取所有需要的属性,避免后续延迟加载
channel_id = db_channel.channel_id channel_id = db_channel.channel_id
channel_type = db_channel.type channel_type = db_channel.type
channel_name = db_channel.name channel_name = db_channel.channel_name
user_id = current_user.id user_id = current_user.id
# 对于多人游戏房间在发送消息前进行Redis键检查 # 对于多人游戏房间在发送消息前进行Redis键检查
@@ -169,7 +169,7 @@ async def get_message(
if channel.isdigit(): if channel.isdigit():
db_channel = (await session.exec(select(ChatChannel).where(ChatChannel.channel_id == int(channel)))).first() db_channel = (await session.exec(select(ChatChannel).where(ChatChannel.channel_id == int(channel)))).first()
else: else:
db_channel = (await session.exec(select(ChatChannel).where(ChatChannel.name == channel))).first() db_channel = (await session.exec(select(ChatChannel).where(ChatChannel.channel_name == channel))).first()
if db_channel is None: if db_channel is None:
raise HTTPException(status_code=404, detail="Channel not found") raise HTTPException(status_code=404, detail="Channel not found")
@@ -231,7 +231,7 @@ async def mark_as_read(
if channel.isdigit(): if channel.isdigit():
db_channel = (await session.exec(select(ChatChannel).where(ChatChannel.channel_id == int(channel)))).first() db_channel = (await session.exec(select(ChatChannel).where(ChatChannel.channel_id == int(channel)))).first()
else: else:
db_channel = (await session.exec(select(ChatChannel).where(ChatChannel.name == channel))).first() db_channel = (await session.exec(select(ChatChannel).where(ChatChannel.channel_name == channel))).first()
if db_channel is None: if db_channel is None:
raise HTTPException(status_code=404, detail="Channel not found") raise HTTPException(status_code=404, detail="Channel not found")

View File

@@ -35,6 +35,7 @@ from app.database.score import (
) )
from app.dependencies.api_version import APIVersion from app.dependencies.api_version import APIVersion
from app.dependencies.cache import UserCacheService from app.dependencies.cache import UserCacheService
from app.dependencies.client_verification import ClientVerificationService
from app.dependencies.database import Database, Redis, get_redis, with_db from app.dependencies.database import Database, Redis, get_redis, with_db
from app.dependencies.fetcher import Fetcher, get_fetcher from app.dependencies.fetcher import Fetcher, get_fetcher
from app.dependencies.storage import StorageService from app.dependencies.storage import StorageService
@@ -123,14 +124,11 @@ async def _process_user(score_id: int, user_id: int, redis: Redis, fetcher: Fetc
async def submit_score( async def submit_score(
background_task: BackgroundTasks, background_task: BackgroundTasks,
info: SoloScoreSubmissionInfo, info: SoloScoreSubmissionInfo,
beatmap: int,
token: int, token: int,
current_user: User, current_user: User,
db: AsyncSession, db: AsyncSession,
redis: Redis, redis: Redis,
fetcher: Fetcher, fetcher: Fetcher,
item_id: int | None = None,
room_id: int | None = None,
): ):
# 立即获取用户ID避免后续的懒加载问题 # 立即获取用户ID避免后续的懒加载问题
user_id = current_user.id user_id = current_user.id
@@ -154,6 +152,7 @@ async def submit_score(
if not score: if not score:
raise HTTPException(status_code=404, detail="Score not found") raise HTTPException(status_code=404, detail="Score not found")
else: else:
beatmap = score_token.beatmap_id
try: try:
cache_service = get_beatmap_cache_service(redis, fetcher) cache_service = get_beatmap_cache_service(redis, fetcher)
await cache_service.smart_preload_for_score(beatmap) await cache_service.smart_preload_for_score(beatmap)
@@ -172,8 +171,6 @@ async def submit_score(
score_token, score_token,
info, info,
db, db,
item_id,
room_id,
) )
await db.refresh(score_token) await db.refresh(score_token)
score_id = score.id score_id = score.id
@@ -419,6 +416,8 @@ async def get_user_all_beatmap_scores(
async def create_solo_score( async def create_solo_score(
background_task: BackgroundTasks, background_task: BackgroundTasks,
db: Database, db: Database,
fetcher: Fetcher,
verification_service: ClientVerificationService,
beatmap_id: Annotated[int, Path(description="谱面 ID")], beatmap_id: Annotated[int, Path(description="谱面 ID")],
beatmap_hash: Annotated[str, Form(description="谱面文件哈希")], beatmap_hash: Annotated[str, Form(description="谱面文件哈希")],
ruleset_id: Annotated[int, Form(..., description="ruleset 数字 ID (0-3)")], ruleset_id: Annotated[int, Form(..., description="ruleset 数字 ID (0-3)")],
@@ -434,6 +433,21 @@ async def create_solo_score(
except ValueError: except ValueError:
raise HTTPException(status_code=400, detail="Invalid ruleset ID") raise HTTPException(status_code=400, detail="Invalid ruleset ID")
if not (
client_version := await verification_service.validate_client_version(
version_hash,
)
):
logger.info(
f"Client version check failed for user {current_user.id} on beatmap {beatmap_id} "
f"(version hash: {version_hash})"
)
raise HTTPException(status_code=422, detail="invalid client hash")
beatmap = await Beatmap.get_or_fetch(db, fetcher, md5=beatmap_hash)
if not beatmap or beatmap.id != beatmap_id:
raise HTTPException(status_code=422, detail="invalid or missing beatmap_hash")
if not (result := gamemode.check_ruleset_version(ruleset_hash)): if not (result := gamemode.check_ruleset_version(ruleset_hash)):
logger.info( logger.info(
f"Ruleset version check failed for user {current_user.id} on beatmap {beatmap_id} " f"Ruleset version check failed for user {current_user.id} on beatmap {beatmap_id} "
@@ -454,6 +468,15 @@ async def create_solo_score(
db.add(score_token) db.add(score_token)
await db.commit() await db.commit()
await db.refresh(score_token) await db.refresh(score_token)
logger.debug(
"User {user_id} created solo score {score_token} for beatmap {beatmap_id} "
"(mode: {mode}), using client {client_version}",
user_id=user_id,
score_token=score_token.id,
beatmap_id=beatmap_id,
mode=ruleset_id,
client_version=str(client_version),
)
return ScoreTokenResp.from_db(score_token) return ScoreTokenResp.from_db(score_token)
@@ -474,7 +497,7 @@ async def submit_solo_score(
redis: Redis, redis: Redis,
fetcher: Fetcher, fetcher: Fetcher,
): ):
return await submit_score(background_task, info, beatmap_id, token, current_user, db, redis, fetcher) return await submit_score(background_task, info, token, current_user, db, redis, fetcher)
@router.post( @router.post(
@@ -489,8 +512,9 @@ async def create_playlist_score(
background_task: BackgroundTasks, background_task: BackgroundTasks,
room_id: int, room_id: int,
playlist_id: int, playlist_id: int,
verification_service: ClientVerificationService,
beatmap_id: Annotated[int, Form(description="谱面 ID")], beatmap_id: Annotated[int, Form(description="谱面 ID")],
beatmap_hash: Annotated[str, Form(description="游戏版本哈希")], beatmap_hash: Annotated[str, Form(description="谱面文件哈希")],
ruleset_id: Annotated[int, Form(..., description="ruleset 数字 ID (0-3)")], ruleset_id: Annotated[int, Form(..., description="ruleset 数字 ID (0-3)")],
current_user: ClientUser, current_user: ClientUser,
version_hash: Annotated[str, Form(description="谱面版本哈希")] = "", version_hash: Annotated[str, Form(description="谱面版本哈希")] = "",
@@ -501,6 +525,17 @@ async def create_playlist_score(
except ValueError: except ValueError:
raise HTTPException(status_code=400, detail="Invalid ruleset ID") raise HTTPException(status_code=400, detail="Invalid ruleset ID")
if not (
client_version := await verification_service.validate_client_version(
version_hash,
)
):
logger.info(
f"Client version check failed for user {current_user.id} on room {room_id}, playlist {playlist_id} "
f"(version hash: {version_hash})"
)
raise HTTPException(status_code=422, detail="invalid client hash")
if not (result := gamemode.check_ruleset_version(ruleset_hash)): if not (result := gamemode.check_ruleset_version(ruleset_hash)):
logger.info( logger.info(
f"Ruleset version check failed for user {current_user.id} on room {room_id}, playlist {playlist_id}," f"Ruleset version check failed for user {current_user.id} on room {room_id}, playlist {playlist_id},"
@@ -555,10 +590,22 @@ async def create_playlist_score(
beatmap_id=beatmap_id, beatmap_id=beatmap_id,
ruleset_id=GameMode.from_int(ruleset_id), ruleset_id=GameMode.from_int(ruleset_id),
playlist_item_id=playlist_id, playlist_item_id=playlist_id,
room_id=room_id,
) )
session.add(score_token) session.add(score_token)
await session.commit() await session.commit()
await session.refresh(score_token) await session.refresh(score_token)
logger.debug(
"User {user_id} created playlist score {score_token} for beatmap {beatmap_id} "
"(mode: {mode}, room {room_id}, item {playlist_id}), using client {client_version}",
user_id=user_id,
score_token=score_token.id,
beatmap_id=beatmap_id,
mode=ruleset_id,
room_id=room_id,
playlist_id=playlist_id,
client_version=str(client_version),
)
return ScoreTokenResp.from_db(score_token) return ScoreTokenResp.from_db(score_token)
@@ -595,14 +642,11 @@ async def submit_playlist_score(
score_resp = await submit_score( score_resp = await submit_score(
background_task, background_task,
info, info,
item.beatmap_id,
token, token,
current_user, current_user,
session, session,
redis, redis,
fetcher, fetcher,
item.id,
room_id,
) )
await process_playlist_best_score( await process_playlist_best_score(
room_id, room_id,
@@ -753,7 +797,13 @@ async def show_playlist_score(
] ]
if completed: if completed:
includes.append("scores_around") includes.append("scores_around")
resp = await ScoreModel.transform(score_record.score, includes=includes) resp = await ScoreModel.transform(
score_record.score,
includes=includes,
playlist_id=playlist_id,
room_id=room_id,
is_playlist=is_playlist,
)
return resp return resp

View File

@@ -1,4 +1,5 @@
from datetime import timedelta from datetime import datetime, timedelta
import sys
from typing import Annotated, Literal from typing import Annotated, Literal
from app.config import settings from app.config import settings
@@ -8,6 +9,7 @@ from app.database import (
BeatmapModel, BeatmapModel,
BeatmapPlaycounts, BeatmapPlaycounts,
BeatmapsetModel, BeatmapsetModel,
FavouriteBeatmapset,
User, User,
) )
from app.database.beatmap_playcounts import BeatmapPlaycountsModel from app.database.beatmap_playcounts import BeatmapPlaycountsModel
@@ -30,7 +32,7 @@ from app.utils import api_doc, utcnow
from .router import router from .router import router
from fastapi import BackgroundTasks, HTTPException, Path, Query, Request, Security from fastapi import BackgroundTasks, HTTPException, Path, Query, Request, Security
from sqlmodel import exists, false, select from sqlmodel import exists, select, tuple_
from sqlmodel.sql.expression import col from sqlmodel.sql.expression import col
@@ -140,20 +142,39 @@ async def get_users(
async def get_user_events( async def get_user_events(
session: Database, session: Database,
user_id: Annotated[int, Path(description="用户 ID")], user_id: Annotated[int, Path(description="用户 ID")],
limit: Annotated[int | None, Query(description="限制返回的活动数量")] = None, limit: Annotated[int, Query(description="限制返回的活动数量")] = 50,
offset: Annotated[int | None, Query(description="活动日志的偏移量")] = None, offset: Annotated[int | None, Query(description="活动日志的偏移量")] = None,
current_user: User | None = Security(get_optional_user, scopes=["public"]), current_user: User | None = Security(get_optional_user, scopes=["public"]),
): ):
db_user = await session.get(User, user_id) db_user = await session.get(User, user_id)
if db_user is None or not await visible_to_current_user(db_user, current_user, session): if db_user is None or not await visible_to_current_user(db_user, current_user, session):
raise HTTPException(404, "User Not found") raise HTTPException(404, "User Not found")
if offset is None:
offset = 0
if limit > 100:
limit = 100
if offset == 0:
cursor = sys.maxsize
else:
cursor = (
await session.exec(
select(Event.id)
.where(Event.user_id == db_user.id, Event.created_at >= utcnow() - timedelta(days=30))
.order_by(col(Event.id).desc())
.limit(1)
.offset(offset - 1)
)
).first()
if cursor is None:
return []
events = ( events = (
await session.exec( await session.exec(
select(Event) select(Event)
.where(Event.user_id == db_user.id, Event.created_at >= utcnow() - timedelta(days=30)) .where(Event.user_id == db_user.id, Event.created_at >= utcnow() - timedelta(days=30), Event.id < cursor)
.order_by(col(Event.created_at).desc()) .order_by(col(Event.id).desc())
.limit(limit) .limit(limit)
.offset(offset)
) )
).all() ).all()
return events return events
@@ -418,7 +439,7 @@ async def get_user_beatmapsets(
return cached_result return cached_result
user = await session.get(User, user_id) user = await session.get(User, user_id)
if not user or user.id == BANCHOBOT_ID: if not user or user.id == BANCHOBOT_ID or not await visible_to_current_user(user, current_user, session):
raise HTTPException(404, detail="User not found") raise HTTPException(404, detail="User not found")
if type in { if type in {
@@ -433,10 +454,28 @@ async def get_user_beatmapsets(
resp = [] resp = []
elif type == BeatmapsetType.FAVOURITE: elif type == BeatmapsetType.FAVOURITE:
user = await session.get(User, user_id) if offset == 0:
if user is None or not await visible_to_current_user(user, current_user, session): cursor = sys.maxsize
raise HTTPException(404, detail="User not found") else:
favourites = await user.awaitable_attrs.favourite_beatmapsets cursor = (
await session.exec(
select(FavouriteBeatmapset.id)
.where(FavouriteBeatmapset.user_id == user_id)
.order_by(col(FavouriteBeatmapset.id).desc())
.limit(1)
.offset(offset - 1)
)
).first()
if cursor is None:
return []
favourites = (
await session.exec(
select(FavouriteBeatmapset)
.where(FavouriteBeatmapset.user_id == user_id, FavouriteBeatmapset.id < cursor)
.order_by(col(FavouriteBeatmapset.id).desc())
.limit(limit)
)
).all()
resp = [ resp = [
await BeatmapsetModel.transform( await BeatmapsetModel.transform(
favourite.beatmapset, session=session, user=user, includes=beatmapset_includes favourite.beatmapset, session=session, user=user, includes=beatmapset_includes
@@ -445,16 +484,29 @@ async def get_user_beatmapsets(
] ]
elif type == BeatmapsetType.MOST_PLAYED: elif type == BeatmapsetType.MOST_PLAYED:
user = await session.get(User, user_id) if offset == 0:
if user is None or not await visible_to_current_user(user, current_user, session): cursor = sys.maxsize, sys.maxsize
raise HTTPException(404, detail="User not found") else:
cursor = (
await session.exec(
select(BeatmapPlaycounts.playcount, BeatmapPlaycounts.id)
.where(BeatmapPlaycounts.user_id == user_id)
.order_by(col(BeatmapPlaycounts.playcount).desc(), col(BeatmapPlaycounts.id).desc())
.limit(1)
.offset(offset - 1)
)
).first()
if cursor is None:
return []
cursor_pc, cursor_id = cursor
most_played = await session.exec( most_played = await session.exec(
select(BeatmapPlaycounts) select(BeatmapPlaycounts)
.where(BeatmapPlaycounts.user_id == user_id) .where(
.order_by(col(BeatmapPlaycounts.playcount).desc()) BeatmapPlaycounts.user_id == user_id,
tuple_(BeatmapPlaycounts.playcount, BeatmapPlaycounts.id) < tuple_(cursor_pc, cursor_id),
)
.order_by(col(BeatmapPlaycounts.playcount).desc(), col(BeatmapPlaycounts.id).desc())
.limit(limit) .limit(limit)
.offset(offset)
) )
resp = [ resp = [
await BeatmapPlaycountsModel.transform(most_played_beatmap, user=user, includes=beatmapset_includes) await BeatmapPlaycountsModel.transform(most_played_beatmap, user=user, includes=beatmapset_includes)
@@ -487,7 +539,6 @@ async def get_user_beatmapsets(
) )
@asset_proxy_response @asset_proxy_response
async def get_user_scores( async def get_user_scores(
request: Request,
session: Database, session: Database,
api_version: APIVersion, api_version: APIVersion,
background_task: BackgroundTasks, background_task: BackgroundTasks,
@@ -520,32 +571,93 @@ async def get_user_scores(
raise HTTPException(404, detail="User not found") raise HTTPException(404, detail="User not found")
gamemode = mode or db_user.playmode gamemode = mode or db_user.playmode
order_by = None
where_clause = (col(Score.user_id) == db_user.id) & (col(Score.gamemode) == gamemode) where_clause = (col(Score.user_id) == db_user.id) & (col(Score.gamemode) == gamemode)
includes = Score.USER_PROFILE_INCLUDES.copy() includes = Score.USER_PROFILE_INCLUDES.copy()
if not include_fails: if not include_fails:
where_clause &= col(Score.passed).is_(True) where_clause &= col(Score.passed).is_(True)
scores = []
if type == "pinned": if type == "pinned":
where_clause &= Score.pinned_order > 0 where_clause &= Score.pinned_order > 0
order_by = col(Score.pinned_order).asc() if offset == 0:
cursor = 0, sys.maxsize
else:
cursor = (
await session.exec(
select(Score.pinned_order, Score.id)
.where(where_clause)
.order_by(col(Score.pinned_order).asc(), col(Score.id).desc())
.limit(1)
.offset(offset - 1)
)
).first()
if cursor:
cursor_pinned, cursor_id = cursor
where_clause &= (col(Score.pinned_order) > cursor_pinned) | (
(col(Score.pinned_order) == cursor_pinned) & (col(Score.id) < cursor_id)
)
scores = (
await session.exec(
select(Score)
.where(where_clause)
.order_by(col(Score.pinned_order).asc(), col(Score.id).desc())
.limit(limit)
)
).all()
elif type == "best": elif type == "best":
where_clause &= exists().where(col(BestScore.score_id) == Score.id) where_clause &= exists().where(col(BestScore.score_id) == Score.id)
order_by = col(Score.pp).desc()
includes.append("weight") includes.append("weight")
if offset == 0:
cursor = sys.maxsize, sys.maxsize
else:
cursor = (
await session.exec(
select(Score.pp, Score.id)
.where(where_clause)
.order_by(col(Score.pp).desc(), col(Score.id).desc())
.limit(1)
.offset(offset - 1)
)
).first()
if cursor:
cursor_pp, cursor_id = cursor
where_clause &= tuple_(col(Score.pp), col(Score.id)) < tuple_(cursor_pp, cursor_id)
scores = (
await session.exec(
select(Score).where(where_clause).order_by(col(Score.pp).desc(), col(Score.id).desc()).limit(limit)
)
).all()
elif type == "recent": elif type == "recent":
where_clause &= Score.ended_at > utcnow() - timedelta(hours=24) where_clause &= Score.ended_at > utcnow() - timedelta(hours=24)
order_by = col(Score.ended_at).desc() if offset == 0:
elif type == "firsts": cursor = datetime.max, sys.maxsize
where_clause &= false() else:
cursor = (
await session.exec(
select(Score.ended_at, Score.id)
.where(where_clause)
.order_by(col(Score.ended_at).desc(), col(Score.id).desc())
.limit(1)
.offset(offset - 1)
)
).first()
if cursor:
cursor_date, cursor_id = cursor
where_clause &= tuple_(col(Score.ended_at), col(Score.id)) < tuple_(cursor_date, cursor_id)
scores = (
await session.exec(
select(Score)
.where(where_clause)
.order_by(col(Score.ended_at).desc(), col(Score.id).desc())
.limit(limit)
)
).all()
if type != "firsts": elif type == "firsts":
scores = ( best_scores = await get_user_first_scores(session, db_user.id, gamemode, limit, offset)
await session.exec(select(Score).where(where_clause).order_by(order_by).limit(limit).offset(offset))
).all()
if not scores:
return []
else:
best_scores = await get_user_first_scores(session, db_user.id, gamemode, limit)
scores = [best_score.score for best_score in best_scores] scores = [best_score.score for best_score in best_scores]
score_responses = [ score_responses = [

View File

@@ -0,0 +1,131 @@
"""Service for verifying client versions against known valid versions."""
import asyncio
import json
from app.config import settings
from app.log import logger
from app.models.version import VersionCheckResult, VersionList
from app.path import CONFIG_DIR
import aiofiles
import httpx
from httpx import AsyncClient
HASHES_DIR = CONFIG_DIR / "client_versions.json"
class ClientVerificationService:
"""A service to verify client versions against known valid versions.
Attributes:
version_lists (list[VersionList]): A list of version lists fetched from remote sources.
Methods:
init(): Initialize the service by loading version data from disk and refreshing from remote.
refresh(): Fetch the latest version lists from configured URLs and store them locally.
load_from_disk(): Load version lists from the local JSON file.
validate_client_version(client_version: str) -> VersionCheckResult: Validate a given client version against the known versions.
""" # noqa: E501
def __init__(self) -> None:
self.original_version_lists: dict[str, list[VersionList]] = {}
self.versions: dict[str, tuple[str, str, str]] = {}
self._lock = asyncio.Lock()
async def init(self) -> None:
"""Initialize the service by loading version data from disk and refreshing from remote."""
await self.load_from_disk(first_load=True)
await self.refresh()
await self.load_from_disk()
async def refresh(self) -> None:
"""Fetch the latest version lists from configured URLs and store them locally."""
lists: dict[str, list[VersionList]] = self.original_version_lists.copy()
async with AsyncClient() as client:
for url in settings.client_version_urls:
try:
resp = await client.get(url, timeout=10)
resp.raise_for_status()
data = resp.json()
if len(data) == 0:
logger.warning(f"Client version list from {url} is empty")
continue
lists[url] = data
logger.info(f"Fetched client version list from {url}, total {len(data)} clients")
except httpx.TimeoutException:
logger.warning(f"Timeout when fetching client version list from {url}")
except Exception as e:
logger.warning(f"Failed to fetch client version list from {url}: {e}")
async with aiofiles.open(HASHES_DIR, "wb") as f:
await f.write(json.dumps(lists).encode("utf-8"))
async def load_from_disk(self, first_load: bool = False) -> None:
"""Load version lists from the local JSON file."""
async with self._lock:
self.versions.clear()
try:
if not HASHES_DIR.is_file() and not first_load:
logger.warning("Client version list file does not exist on disk")
return
async with aiofiles.open(HASHES_DIR, "rb") as f:
content = await f.read()
self.original_version_lists = json.loads(content.decode("utf-8"))
for version_list_group in self.original_version_lists.values():
for version_list in version_list_group:
for version_info in version_list["versions"]:
for client_hash, os_name in version_info["hashes"].items():
self.versions[client_hash] = (
version_list["name"],
version_info["version"],
os_name,
)
if not first_load:
if len(self.versions) == 0:
logger.warning("Client version list is empty after loading from disk")
else:
logger.info(
"Loaded client version list from disk, "
f"total {len(self.versions)} clients, {len(self.versions)} versions"
)
except Exception as e:
logger.exception(f"Failed to load client version list from disk: {e}")
async def validate_client_version(self, client_version: str) -> VersionCheckResult:
"""Validate a given client version against the known versions.
Args:
client_version (str): The client version string to validate.
Returns:
VersionCheckResult: The result of the validation.
"""
if not settings.check_client_version:
return VersionCheckResult(is_valid=True)
async with self._lock:
if client_version in self.versions:
name, version, os_name = self.versions[client_version]
return VersionCheckResult(is_valid=True, client_name=name, version=version, os=os_name)
return VersionCheckResult(is_valid=False)
_client_verification_service: ClientVerificationService | None = None
def get_client_verification_service() -> ClientVerificationService:
"""Get the singleton instance of ClientVerificationService.
Returns:
ClientVerificationService: The singleton instance.
"""
global _client_verification_service
if _client_verification_service is None:
_client_verification_service = ClientVerificationService()
return _client_verification_service
async def init_client_verification_service() -> None:
"""Initialize the ClientVerificationService singleton."""
service = get_client_verification_service()
logger.info("Initializing ClientVerificationService...")
await service.init()

View File

@@ -6,6 +6,7 @@ from . import (
database_cleanup, database_cleanup,
recalculate_banned_beatmap, recalculate_banned_beatmap,
recalculate_failed_score, recalculate_failed_score,
update_client_version,
) )
from .cache import start_cache_tasks, stop_cache_tasks from .cache import start_cache_tasks, stop_cache_tasks
from .calculate_all_user_rank import calculate_user_rank from .calculate_all_user_rank import calculate_user_rank

View File

@@ -0,0 +1,13 @@
from app.config import settings
from app.dependencies.scheduler import get_scheduler
from app.log import logger
from app.service.client_verification_service import get_client_verification_service
if settings.check_client_version:
@get_scheduler().scheduled_job("interval", id="update_client_version", hours=2)
async def update_client_version():
logger.info("Updating client version lists...")
client_verification_service = get_client_verification_service()
await client_verification_service.refresh()
await client_verification_service.load_from_disk()

View File

@@ -33,6 +33,7 @@ from app.router.redirect import redirect_router
from app.router.v1 import api_v1_public_router from app.router.v1 import api_v1_public_router
from app.service.beatmap_download_service import download_service from app.service.beatmap_download_service import download_service
from app.service.beatmapset_update_service import init_beatmapset_update_service from app.service.beatmapset_update_service import init_beatmapset_update_service
from app.service.client_verification_service import init_client_verification_service
from app.service.email_queue import start_email_processor, stop_email_processor from app.service.email_queue import start_email_processor, stop_email_processor
from app.service.redis_message_system import redis_message_system from app.service.redis_message_system import redis_message_system
from app.service.subscribers.user_cache import user_online_subscriber from app.service.subscribers.user_cache import user_online_subscriber
@@ -68,6 +69,9 @@ async def lifespan(app: FastAPI): # noqa: ARG001
load_achievements() load_achievements()
await init_calculator() await init_calculator()
if settings.check_client_version:
await init_client_verification_service()
# init rate limiter # init rate limiter
await FastAPILimiter.init(redis_rate_limit_client) await FastAPILimiter.init(redis_rate_limit_client)
@@ -158,7 +162,7 @@ v1 API 采用 API Key 鉴权,将 API Key 放入 Query `k` 中。
# 检查 New Relic 配置文件是否存在,如果存在则初始化 New Relic # 检查 New Relic 配置文件是否存在,如果存在则初始化 New Relic
newrelic_config_path = Path("newrelic.ini") newrelic_config_path = Path("newrelic.ini")
if newrelic_config_path.exists(): if newrelic_config_path.is_file():
try: try:
import newrelic.agent import newrelic.agent

View File

@@ -0,0 +1,34 @@
"""score_token: add room_id
Revision ID: 96c4f4b3f0ab
Revises: d430db6fc051
Create Date: 2025-12-20 11:39:02.640676
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "96c4f4b3f0ab"
down_revision: str | Sequence[str] | None = "d430db6fc051"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("score_tokens", sa.Column("room_id", sa.Integer(), nullable=True))
op.create_index("idx_playlist_room", "score_tokens", ["playlist_item_id", "room_id"], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index("idx_playlist_room", table_name="score_tokens")
op.drop_column("score_tokens", "room_id")
# ### end Alembic commands ###

View File

@@ -0,0 +1,51 @@
"""add union index for score
Revision ID: c5472f592d13
Revises: 96c4f4b3f0ab
Create Date: 2026-01-03 07:10:11.050661
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c5472f592d13"
down_revision: str | Sequence[str] | None = "96c4f4b3f0ab"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_index("idx_score_user_mode_date", "scores", ["user_id", "gamemode", "ended_at", "id"], unique=False)
op.create_index("idx_score_user_mode_pinned", "scores", ["user_id", "gamemode", "pinned_order", "id"], unique=False)
op.create_index("idx_score_user_mode_pp", "scores", ["user_id", "gamemode", "pp", "id"], unique=False)
op.create_index("idx_beatmap_playcounts_playcount_id", "beatmap_playcounts", ["playcount", "id"], unique=False)
op.create_index(
"ix_total_score_best_scores_user_mode_score",
"total_score_best_scores",
["user_id", "gamemode", "score_id"],
unique=False,
)
op.create_index(
"ix_total_score_best_scores_beatmap_mode_score",
"total_score_best_scores",
["beatmap_id", "gamemode", "total_score"],
unique=False,
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index("idx_score_user_mode_pp", table_name="scores")
op.drop_index("idx_score_user_mode_pinned", table_name="scores")
op.drop_index("idx_score_user_mode_date", table_name="scores")
op.drop_index("idx_beatmap_playcounts_playcount_id", table_name="beatmap_playcounts")
op.drop_index("ix_total_score_best_scores_beatmap_mode_score", table_name="total_score_best_scores")
op.drop_index("ix_total_score_best_scores_user_mode_score", table_name="total_score_best_scores")
# ### end Alembic commands ###

View File

@@ -1,40 +1,50 @@
{ {
"Sentakki": { "Sentakki": {
"latest-version": "2025.1108.0", "latest-version": "2026.117.1",
"versions": { "versions": {
"2025.1012.1": "66e02af2097f446246b146641295573a", "2025.1012.1": "66e02af2097f446246b146641295573a",
"2025.1026.1": "f3d5757ed7f55b95e4582a033f9dd04d", "2025.1026.1": "f3d5757ed7f55b95e4582a033f9dd04d",
"2025.1108.0": "c4b367c7ea8468e991f566b296673d78" "2025.1108.0": "c4b367c7ea8468e991f566b296673d78",
"2025.1228.0": "ccb7103b857bd6618a4af1ee0d067c31",
"2026.117.1": "4626f932db2294b028aac90b58e1e074"
} }
}, },
"tau": { "tau": {
"latest-version": "2025.1108.0", "latest-version": "2026.117.1",
"versions": { "versions": {
"2025.1012.1": "3a2dd168c2e520a3620a5dfd7b3c0b73", "2025.1012.1": "3a2dd168c2e520a3620a5dfd7b3c0b73",
"2025.1026.1": "470b456e4767719a49a1e0a349006179", "2025.1026.1": "470b456e4767719a49a1e0a349006179",
"2025.1101.1": "0895877497e0c77743e7487760434473", "2025.1101.1": "0895877497e0c77743e7487760434473",
"2025.1108.0": "b2ec1228096e2e36206559c3b87a4f16" "2025.1108.0": "b2ec1228096e2e36206559c3b87a4f16",
"2025.1228.0": "9c3d0958480ec3f7640ec400a518c7ed",
"2026.117.1": "b462d3901ef7dde05f675ccff2ce5c05"
} }
}, },
"rush": { "rush": {
"latest-version": "2025.1108.0", "latest-version": "2026.117.1",
"versions": { "versions": {
"2025.1026.1": "df0c211c8c40f42feb119a3a11549a6f", "2025.1026.1": "df0c211c8c40f42feb119a3a11549a6f",
"2025.1108.0": "96804cbeafe4729f778d8bbbd228b516" "2025.1108.0": "96804cbeafe4729f778d8bbbd228b516",
"2025.1228.0": "586154c9759affd1e2787caf833f8c69",
"2026.117.1": "5537a382139b71b6850024574ef78d2c"
} }
}, },
"hishigata": { "hishigata": {
"latest-version": "2025.1108.0", "latest-version": "2026.117.1",
"versions": { "versions": {
"2025.1026.1": "af26c2946cd0b2258ac52f5cce91958c", "2025.1026.1": "af26c2946cd0b2258ac52f5cce91958c",
"2025.1108.0": "880c1b05efcd97755a23cc1d646a5b96" "2025.1108.0": "880c1b05efcd97755a23cc1d646a5b96",
"2025.1228.0": "59265f6e3e0f134d90545e70b6db80aa",
"2026.117.1": "92fbc8b4c4ea8717f230ce5a1decabdc"
} }
}, },
"soyokaze": { "soyokaze": {
"latest-version": "2025.1108.0", "latest-version": "2026.117.1",
"versions": { "versions": {
"2025.1026.1": "fea5c97b8b436305ba98ef8b39b133b6", "2025.1026.1": "fea5c97b8b436305ba98ef8b39b133b6",
"2025.1108.0": "a531dd4bc50f6137f187839eb6db9482" "2025.1108.0": "a531dd4bc50f6137f187839eb6db9482",
"2025.1228.0": "8ad95cd074a231cab49d00d24df6955c",
"2026.117.1": "2dd43d9c428f1feb72abf19a279d9b78"
} }
} }
} }

148
uv.lock generated
View File

@@ -195,14 +195,14 @@ wheels = [
[[package]] [[package]]
name = "apscheduler" name = "apscheduler"
version = "3.11.1" version = "3.11.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "tzlocal" }, { name = "tzlocal" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/d0/81/192db4f8471de5bc1f0d098783decffb1e6e69c4f8b4bc6711094691950b/apscheduler-3.11.1.tar.gz", hash = "sha256:0db77af6400c84d1747fe98a04b8b58f0080c77d11d338c4f507a9752880f221", size = 108044, upload-time = "2025-10-31T18:55:42.819Z" } sdist = { url = "https://files.pythonhosted.org/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683, upload-time = "2025-12-22T00:39:34.884Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/58/9f/d3c76f76c73fcc959d28e9def45b8b1cc3d7722660c5003b19c1022fd7f4/apscheduler-3.11.1-py3-none-any.whl", hash = "sha256:6162cb5683cb09923654fa9bdd3130c4be4bfda6ad8990971c9597ecd52965d2", size = 64278, upload-time = "2025-10-31T18:55:41.186Z" }, { url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" },
] ]
[[package]] [[package]]
@@ -562,7 +562,7 @@ wheels = [
[[package]] [[package]]
name = "datamodel-code-generator" name = "datamodel-code-generator"
version = "0.41.0" version = "0.49.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "argcomplete" }, { name = "argcomplete" },
@@ -575,9 +575,9 @@ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pyyaml" }, { name = "pyyaml" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/21/a0/f3a08b0e2068eae40a170c8485d0a68bfe54210df4b8a67ee4fe27bae681/datamodel_code_generator-0.41.0.tar.gz", hash = "sha256:f5ded2baab48488c09ce91c564e486dee9145029c53016f1befb732313d66411", size = 473910, upload-time = "2025-12-05T16:50:19.049Z" } sdist = { url = "https://files.pythonhosted.org/packages/7b/92/738536021ea6b0ec7cc080bb0fa149b52e908b662ea03aac9462c7b02d7c/datamodel_code_generator-0.49.0.tar.gz", hash = "sha256:eb4a95ee21c10f7675982f8b7d92344971b9a5e4c34c89b54652f8576b3a604b", size = 695361, upload-time = "2025-12-25T02:19:50.164Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/11/9cec012b7764443a7b03bf3725ea56481a3f6d45c363a15406d57d048cf9/datamodel_code_generator-0.41.0-py3-none-any.whl", hash = "sha256:02caf2372d298844d6080fe805770161cac9aa720f143f349cee6ca76b50e5bf", size = 158701, upload-time = "2025-12-05T16:50:17.796Z" }, { url = "https://files.pythonhosted.org/packages/2b/e4/c59fd088850a8d35085d7ba90fe70098096da152600a96b9ba260740b76c/datamodel_code_generator-0.49.0-py3-none-any.whl", hash = "sha256:0c24d21b7a3103a89a324be48bbcac3fed1fd2f22804de8589e589f2c93d9dc0", size = 214650, upload-time = "2025-12-25T02:19:48.255Z" },
] ]
[[package]] [[package]]
@@ -625,7 +625,7 @@ wheels = [
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.124.0" version = "0.128.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "annotated-doc" }, { name = "annotated-doc" },
@@ -633,9 +633,9 @@ dependencies = [
{ name = "starlette" }, { name = "starlette" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/48/9c/11969bd3e3bc4aa3a711f83dd3720239d3565a934929c74fc32f6c9f3638/fastapi-0.124.0.tar.gz", hash = "sha256:260cd178ad75e6d259991f2fd9b0fee924b224850079df576a3ba604ce58f4e6", size = 357623, upload-time = "2025-12-06T13:11:35.692Z" } sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/29/9e1e82e16e9a1763d3b55bfbe9b2fa39d7175a1fd97685c482fa402e111d/fastapi-0.124.0-py3-none-any.whl", hash = "sha256:91596bdc6dde303c318f06e8d2bc75eafb341fc793a0c9c92c0bc1db1ac52480", size = 112505, upload-time = "2025-12-06T13:11:34.392Z" }, { url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" },
] ]
[[package]] [[package]]
@@ -1251,22 +1251,22 @@ wheels = [
[[package]] [[package]]
name = "newrelic" name = "newrelic"
version = "11.1.0" version = "11.2.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/81/9682239403ec4110a3d3cfe79fb78ad014909c25fd30f0c55a48c90ebe6b/newrelic-11.1.0.tar.gz", hash = "sha256:11b0c4f916f81c7ed0c2f4cd282215634d09882761ad0c8e9866f07c4b58e015", size = 1307829, upload-time = "2025-11-03T23:53:10.988Z" } sdist = { url = "https://files.pythonhosted.org/packages/99/f5/efe719e766b7cccb8395bb768b3faa7c9313e390b30eb4950034c49d4cd6/newrelic-11.2.0.tar.gz", hash = "sha256:6dd9f303904220700ba8b25af2f622cd23a4b5071cc53b4309e90bf3dcdb7221", size = 1321580, upload-time = "2025-12-08T23:17:48.599Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/39/75/9569d66adf72b7e5e66ec4e79725896c31e60537df096562395b41ee9e44/newrelic-11.1.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6ec6c5f1767f306b484b13742464c98a5e1aacb54b399f6aca639a50656c587", size = 889879, upload-time = "2025-11-03T23:52:34.827Z" }, { url = "https://files.pythonhosted.org/packages/df/ee/6a6107c0d81977c3e86f7406b629d445613c739052e8dab571f649c49ba6/newrelic-11.2.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ad8110d9622b21db6103640a5754d2c8b7d03eba5fe7ee9abbc5c5637160e08", size = 896908, upload-time = "2025-12-08T23:17:12.955Z" },
{ url = "https://files.pythonhosted.org/packages/62/7f/ec355728e9915acaf068d386f4997e1e6c602ea4f264e87b9304db3eaa3b/newrelic-11.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:384388783b78bb09afc594fa9f27338b6d322356a4fc1347e605eebad60a0928", size = 890524, upload-time = "2025-11-03T23:52:36.518Z" }, { url = "https://files.pythonhosted.org/packages/2b/87/a330367eb11c3503d4b4b4cb9f48b3a5145941accf896734c3bfb2b42ffc/newrelic-11.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:092660d17d21b53ea12659f9ef56f8e44075eb980ee4a1b7340651295f89f016", size = 897554, upload-time = "2025-12-08T23:17:14.905Z" },
{ url = "https://files.pythonhosted.org/packages/6f/2e/02b042b0b8e7ae2e40c4bc63728d8f1a1e59658d04c6111537334d3c4c76/newrelic-11.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:569019b274f26f34e146fc52392fd4a8cc9750108242e0d5b6f2fbb09dd0b1c6", size = 888694, upload-time = "2025-11-03T23:52:38.595Z" }, { url = "https://files.pythonhosted.org/packages/08/9c/af75018b9ce156b3cef046ba9b8df50ac6f09a03ab411eb083dc20c8346b/newrelic-11.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cded013ccad26decf430cc66401adbdcdff036389325dc3b3451723e097bd5e5", size = 895723, upload-time = "2025-12-08T23:17:16.522Z" },
{ url = "https://files.pythonhosted.org/packages/0f/59/e49144a9881b82954add6b875b541b53a30d2bb4ddc7b954496fc46bed1a/newrelic-11.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77c47efaba50c22461c85ee4d899028d56d97085ced660e84456bc45451805fa", size = 889273, upload-time = "2025-11-03T23:52:41.666Z" }, { url = "https://files.pythonhosted.org/packages/28/5d/20eef6a2222dc243fd2cdeae82aa10df195ebe7cafd988f700370a349865/newrelic-11.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eb1551edbad5faa58845b57e48c54468c5d3ce7aa193869c56cfe57b68c05267", size = 896302, upload-time = "2025-12-08T23:17:18.645Z" },
{ url = "https://files.pythonhosted.org/packages/62/dc/9918cdd0e05013c955194fbf9338402bd9299e01c42f6407aa2d9fb7a3db/newrelic-11.1.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9479467720159f1e8032e8ec46ec8428424bd2ad1e595a112bd321b541ab2ac3", size = 889988, upload-time = "2025-11-03T23:52:43.232Z" }, { url = "https://files.pythonhosted.org/packages/b6/8f/4a982d8c2811cd79f61683f56f6dffbd5a3bab2069c836362627c17539b7/newrelic-11.2.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0a273a69456fc63bd2ceac8a16c44cce297bc78b37e73aa44ac58eeea0c6c1e6", size = 897017, upload-time = "2025-12-08T23:17:20.547Z" },
{ url = "https://files.pythonhosted.org/packages/dc/1b/433dfc8c327e8d27c1b6dc9d10a663fa70934cb3ef4bc9b3890fc61653f9/newrelic-11.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:726a7648db224fbac08fc781532864c01e05f5b8581212f8192acb7376ecb337", size = 890646, upload-time = "2025-11-03T23:52:46.075Z" }, { url = "https://files.pythonhosted.org/packages/5a/1f/454ca513f4cc7e01e1d0a11150bcc91db0d98b0048941d9f1fb2a016a290/newrelic-11.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:486e012dd4bec702df218dba2c77c14a01c4cfa03bd67a8993a002f088c51c82", size = 897675, upload-time = "2025-12-08T23:17:22.514Z" },
{ url = "https://files.pythonhosted.org/packages/8b/5f/a0b4fd131da88c9288b213d5b27204ea3c23448f93eaeeb0d2977f2c0c84/newrelic-11.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e58d9859b7f978340987fc788b25498297f2537836a07544579ab146f891bd4b", size = 888810, upload-time = "2025-11-03T23:52:47.968Z" }, { url = "https://files.pythonhosted.org/packages/86/f9/d98391da6ca75011356118b2da70053ea82edd62fe85f4422c2b2e13b2c9/newrelic-11.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:77fd587c18438546ab62b13a6f602ec95d92bf15caa95f27b0f368453f99c8e1", size = 895838, upload-time = "2025-12-08T23:17:24.232Z" },
{ url = "https://files.pythonhosted.org/packages/57/d1/d6c416771b05f5e0520074b3b91771b800818a31ae639cbd007cea8f03eb/newrelic-11.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:831a72e1693ad08e9baf98762ab5f01c264beb11935e3739d2152ae6fd4120c5", size = 889396, upload-time = "2025-11-03T23:52:49.799Z" }, { url = "https://files.pythonhosted.org/packages/c0/01/5b9a3d3c9a7ce8a682e8bf0f95f31ed72264368d0bde9669620761ab773a/newrelic-11.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:90010f1e1355b5225803470f5fab01910f865c66bbbb4e896493c0b508592838", size = 896425, upload-time = "2025-12-08T23:17:26.093Z" },
{ url = "https://files.pythonhosted.org/packages/c5/66/fb523993831186220f6679b09a507b85a841043df13d51122eaefed7059e/newrelic-11.1.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:be47fba30bd9486e1679a0a505c29a78c1f58c2e20ccdf1d38217e6292be9ff9", size = 888992, upload-time = "2025-11-03T23:52:51.353Z" }, { url = "https://files.pythonhosted.org/packages/48/5f/ccb373ee01647a7962d27153002d16ce4ebe37f5f4cdedbf1e3dd584ec82/newrelic-11.2.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f34d750cd1f87a7d31172eaadb61418e729e3fe739b3a99b3720c0cc8cfacb85", size = 896021, upload-time = "2025-12-08T23:17:28.122Z" },
{ url = "https://files.pythonhosted.org/packages/d8/ff/055f5e2fc03635c26c4120dc151b7109ecc4459e454507e64f62394f7aa4/newrelic-11.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91d1d4cbc79b76c4258ce0c38a14c649f270728c9d02e1a128612a0fdb9ac26e", size = 890430, upload-time = "2025-11-03T23:52:52.994Z" }, { url = "https://files.pythonhosted.org/packages/b9/80/0723fa8fcd5cb4ddc2053f9838216db9c89d2b86097326cd15e8e93792a0/newrelic-11.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:745fdcc449c8d3f6041b4a56e9c03693710e6620b35790a0cb0f9641e248a2b2", size = 897460, upload-time = "2025-12-08T23:17:29.685Z" },
{ url = "https://files.pythonhosted.org/packages/6b/cc/fa2df87a2b64458e08c5763b5b5dfca633d0faa8bd282f43098824d72dba/newrelic-11.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:569192559dd912023e7b0e77b295a42895ebd402596730976441a717ef8d414e", size = 888599, upload-time = "2025-11-03T23:52:54.575Z" }, { url = "https://files.pythonhosted.org/packages/dd/5d/1a548981ecf5b06bdee8bb484f6d7665df4ae320deeacbe8ee0d932f607c/newrelic-11.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2bc8f821c94e1f4beb899a2421d062a9739da519e58cd5429fc70a88a8c74bf6", size = 895628, upload-time = "2025-12-08T23:17:31.274Z" },
{ url = "https://files.pythonhosted.org/packages/00/15/7b8a257bc7816a6f68762db4633e334b80a11fec4dc93bff1247734c11fd/newrelic-11.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9aeafc4c95ed513c5a3ae6b1ccf8fe5f6453f04125682b3b5bb2f59065278e36", size = 888501, upload-time = "2025-11-03T23:52:56.273Z" }, { url = "https://files.pythonhosted.org/packages/e8/7c/a90b2f527e19236ff07e0dd7102badc688840b968ff621e225032ec1bf25/newrelic-11.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2a024bef9c0bdf72556f6f35e9fd9aaf1e9b8d7640cf2fa2c105b7ad3deccb9c", size = 895531, upload-time = "2025-12-08T23:17:32.85Z" },
] ]
[[package]] [[package]]
@@ -1395,7 +1395,7 @@ wheels = [
[[package]] [[package]]
name = "pre-commit" name = "pre-commit"
version = "4.5.0" version = "4.5.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "cfgv" }, { name = "cfgv" },
@@ -1404,9 +1404,9 @@ dependencies = [
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "virtualenv" }, { name = "virtualenv" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/f4/9b/6a4ffb4ed980519da959e1cf3122fc6cb41211daa58dbae1c73c0e519a37/pre_commit-4.5.0.tar.gz", hash = "sha256:dc5a065e932b19fc1d4c653c6939068fe54325af8e741e74e88db4d28a4dd66b", size = 198428, upload-time = "2025-11-22T21:02:42.304Z" } sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/c4/b2d28e9d2edf4f1713eb3c29307f1a63f3d67cf09bdda29715a36a68921a/pre_commit-4.5.0-py2.py3-none-any.whl", hash = "sha256:25e2ce09595174d9c97860a95609f9f852c0614ba602de3561e267547f2335e1", size = 226429, upload-time = "2025-11-22T21:02:40.836Z" }, { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" },
] ]
[[package]] [[package]]
@@ -1633,11 +1633,11 @@ cryptography = [
[[package]] [[package]]
name = "python-multipart" name = "python-multipart"
version = "0.0.20" version = "0.0.21"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } sdist = { url = "https://files.pythonhosted.org/packages/78/96/804520d0850c7db98e5ccb70282e29208723f0964e88ffd9d0da2f52ea09/python_multipart-0.0.21.tar.gz", hash = "sha256:7137ebd4d3bbf70ea1622998f902b97a29434a9e8dc40eb203bbcf7c2a2cba92", size = 37196, upload-time = "2025-12-17T09:24:22.446Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, { url = "https://files.pythonhosted.org/packages/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090", size = 24541, upload-time = "2025-12-17T09:24:21.153Z" },
] ]
[[package]] [[package]]
@@ -1837,28 +1837,28 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.14.8" version = "0.14.10"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ed/d9/f7a0c4b3a2bf2556cd5d99b05372c29980249ef71e8e32669ba77428c82c/ruff-0.14.8.tar.gz", hash = "sha256:774ed0dd87d6ce925e3b8496feb3a00ac564bea52b9feb551ecd17e0a23d1eed", size = 5765385, upload-time = "2025-12-04T15:06:17.669Z" } sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/48/b8/9537b52010134b1d2b72870cc3f92d5fb759394094741b09ceccae183fbe/ruff-0.14.8-py3-none-linux_armv6l.whl", hash = "sha256:ec071e9c82eca417f6111fd39f7043acb53cd3fde9b1f95bbed745962e345afb", size = 13441540, upload-time = "2025-12-04T15:06:14.896Z" }, { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" },
{ url = "https://files.pythonhosted.org/packages/24/00/99031684efb025829713682012b6dd37279b1f695ed1b01725f85fd94b38/ruff-0.14.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8cdb162a7159f4ca36ce980a18c43d8f036966e7f73f866ac8f493b75e0c27e9", size = 13669384, upload-time = "2025-12-04T15:06:51.809Z" }, { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" },
{ url = "https://files.pythonhosted.org/packages/72/64/3eb5949169fc19c50c04f28ece2c189d3b6edd57e5b533649dae6ca484fe/ruff-0.14.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2e2fcbefe91f9fad0916850edf0854530c15bd1926b6b779de47e9ab619ea38f", size = 12806917, upload-time = "2025-12-04T15:06:08.925Z" }, { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" },
{ url = "https://files.pythonhosted.org/packages/c4/08/5250babb0b1b11910f470370ec0cbc67470231f7cdc033cee57d4976f941/ruff-0.14.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9d70721066a296f45786ec31916dc287b44040f553da21564de0ab4d45a869b", size = 13256112, upload-time = "2025-12-04T15:06:23.498Z" }, { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" },
{ url = "https://files.pythonhosted.org/packages/78/4c/6c588e97a8e8c2d4b522c31a579e1df2b4d003eddfbe23d1f262b1a431ff/ruff-0.14.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c87e09b3cd9d126fc67a9ecd3b5b1d3ded2b9c7fce3f16e315346b9d05cfb52", size = 13227559, upload-time = "2025-12-04T15:06:33.432Z" }, { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" },
{ url = "https://files.pythonhosted.org/packages/23/ce/5f78cea13eda8eceac71b5f6fa6e9223df9b87bb2c1891c166d1f0dce9f1/ruff-0.14.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d62cb310c4fbcb9ee4ac023fe17f984ae1e12b8a4a02e3d21489f9a2a5f730c", size = 13896379, upload-time = "2025-12-04T15:06:02.687Z" }, { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" },
{ url = "https://files.pythonhosted.org/packages/cf/79/13de4517c4dadce9218a20035b21212a4c180e009507731f0d3b3f5df85a/ruff-0.14.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1af35c2d62633d4da0521178e8a2641c636d2a7153da0bac1b30cfd4ccd91344", size = 15372786, upload-time = "2025-12-04T15:06:29.828Z" }, { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" },
{ url = "https://files.pythonhosted.org/packages/00/06/33df72b3bb42be8a1c3815fd4fae83fa2945fc725a25d87ba3e42d1cc108/ruff-0.14.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:25add4575ffecc53d60eed3f24b1e934493631b48ebbc6ebaf9d8517924aca4b", size = 14990029, upload-time = "2025-12-04T15:06:36.812Z" }, { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" },
{ url = "https://files.pythonhosted.org/packages/64/61/0f34927bd90925880394de0e081ce1afab66d7b3525336f5771dcf0cb46c/ruff-0.14.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c943d847b7f02f7db4201a0600ea7d244d8a404fbb639b439e987edcf2baf9a", size = 14407037, upload-time = "2025-12-04T15:06:39.979Z" }, { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" },
{ url = "https://files.pythonhosted.org/packages/96/bc/058fe0aefc0fbf0d19614cb6d1a3e2c048f7dc77ca64957f33b12cfdc5ef/ruff-0.14.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb6e8bf7b4f627548daa1b69283dac5a296bfe9ce856703b03130732e20ddfe2", size = 14102390, upload-time = "2025-12-04T15:06:46.372Z" }, { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" },
{ url = "https://files.pythonhosted.org/packages/af/a4/e4f77b02b804546f4c17e8b37a524c27012dd6ff05855d2243b49a7d3cb9/ruff-0.14.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7aaf2974f378e6b01d1e257c6948207aec6a9b5ba53fab23d0182efb887a0e4a", size = 14230793, upload-time = "2025-12-04T15:06:20.497Z" }, { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" },
{ url = "https://files.pythonhosted.org/packages/3f/52/bb8c02373f79552e8d087cedaffad76b8892033d2876c2498a2582f09dcf/ruff-0.14.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e5758ca513c43ad8a4ef13f0f081f80f08008f410790f3611a21a92421ab045b", size = 13160039, upload-time = "2025-12-04T15:06:49.06Z" }, { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" },
{ url = "https://files.pythonhosted.org/packages/1f/ad/b69d6962e477842e25c0b11622548df746290cc6d76f9e0f4ed7456c2c31/ruff-0.14.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f74f7ba163b6e85a8d81a590363bf71618847e5078d90827749bfda1d88c9cdf", size = 13205158, upload-time = "2025-12-04T15:06:54.574Z" }, { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" },
{ url = "https://files.pythonhosted.org/packages/06/63/54f23da1315c0b3dfc1bc03fbc34e10378918a20c0b0f086418734e57e74/ruff-0.14.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:eed28f6fafcc9591994c42254f5a5c5ca40e69a30721d2ab18bb0bb3baac3ab6", size = 13469550, upload-time = "2025-12-04T15:05:59.209Z" }, { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" },
{ url = "https://files.pythonhosted.org/packages/70/7d/a4d7b1961e4903bc37fffb7ddcfaa7beb250f67d97cfd1ee1d5cddb1ec90/ruff-0.14.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:21d48fa744c9d1cb8d71eb0a740c4dd02751a5de9db9a730a8ef75ca34cf138e", size = 14211332, upload-time = "2025-12-04T15:06:06.027Z" }, { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" },
{ url = "https://files.pythonhosted.org/packages/5d/93/2a5063341fa17054e5c86582136e9895db773e3c2ffb770dde50a09f35f0/ruff-0.14.8-py3-none-win32.whl", hash = "sha256:15f04cb45c051159baebb0f0037f404f1dc2f15a927418f29730f411a79bc4e7", size = 13151890, upload-time = "2025-12-04T15:06:11.668Z" }, { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" },
{ url = "https://files.pythonhosted.org/packages/02/1c/65c61a0859c0add13a3e1cbb6024b42de587456a43006ca2d4fd3d1618fe/ruff-0.14.8-py3-none-win_amd64.whl", hash = "sha256:9eeb0b24242b5bbff3011409a739929f497f3fb5fe3b5698aba5e77e8c833097", size = 14537826, upload-time = "2025-12-04T15:06:26.409Z" }, { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" },
{ url = "https://files.pythonhosted.org/packages/6d/63/8b41cea3afd7f58eb64ac9251668ee0073789a3bc9ac6f816c8c6fef986d/ruff-0.14.8-py3-none-win_arm64.whl", hash = "sha256:965a582c93c63fe715fd3e3f8aa37c4b776777203d8e1d8aa3cc0c14424a4b99", size = 13634522, upload-time = "2025-12-04T15:06:43.212Z" }, { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" },
] ]
[[package]] [[package]]
@@ -1920,44 +1920,50 @@ wheels = [
[[package]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "2.0.44" version = "2.0.45"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/f0/f2/840d7b9496825333f532d2e3976b8eadbf52034178aac53630d09fe6e1ef/sqlalchemy-2.0.44.tar.gz", hash = "sha256:0ae7454e1ab1d780aee69fd2aae7d6b8670a581d8847f2d1e0f7ddfbf47e5a22", size = 9819830, upload-time = "2025-10-10T14:39:12.935Z" } sdist = { url = "https://files.pythonhosted.org/packages/be/f9/5e4491e5ccf42f5d9cfc663741d261b3e6e1683ae7812114e7636409fcc6/sqlalchemy-2.0.45.tar.gz", hash = "sha256:1632a4bda8d2d25703fdad6363058d882541bdaaee0e5e3ddfa0cd3229efce88", size = 9869912, upload-time = "2025-12-09T21:05:16.737Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/c4/59c7c9b068e6813c898b771204aad36683c96318ed12d4233e1b18762164/sqlalchemy-2.0.44-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72fea91746b5890f9e5e0997f16cbf3d53550580d76355ba2d998311b17b2250", size = 2139675, upload-time = "2025-10-10T16:03:31.064Z" }, { url = "https://files.pythonhosted.org/packages/2d/c7/1900b56ce19bff1c26f39a4ce427faec7716c81ac792bfac8b6a9f3dca93/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3ee2aac15169fb0d45822983631466d60b762085bc4535cd39e66bea362df5f", size = 3333760, upload-time = "2025-12-09T22:11:02.66Z" },
{ url = "https://files.pythonhosted.org/packages/d6/ae/eeb0920537a6f9c5a3708e4a5fc55af25900216bdb4847ec29cfddf3bf3a/sqlalchemy-2.0.44-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:585c0c852a891450edbb1eaca8648408a3cc125f18cf433941fa6babcc359e29", size = 2127726, upload-time = "2025-10-10T16:03:35.934Z" }, { url = "https://files.pythonhosted.org/packages/0a/93/3be94d96bb442d0d9a60e55a6bb6e0958dd3457751c6f8502e56ef95fed0/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba547ac0b361ab4f1608afbc8432db669bd0819b3e12e29fb5fa9529a8bba81d", size = 3348268, upload-time = "2025-12-09T22:13:49.054Z" },
{ url = "https://files.pythonhosted.org/packages/d8/d5/2ebbabe0379418eda8041c06b0b551f213576bfe4c2f09d77c06c07c8cc5/sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b94843a102efa9ac68a7a30cd46df3ff1ed9c658100d30a725d10d9c60a2f44", size = 3327603, upload-time = "2025-10-10T15:35:28.322Z" }, { url = "https://files.pythonhosted.org/packages/48/4b/f88ded696e61513595e4a9778f9d3f2bf7332cce4eb0c7cedaabddd6687b/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215f0528b914e5c75ef2559f69dca86878a3beeb0c1be7279d77f18e8d180ed4", size = 3278144, upload-time = "2025-12-09T22:11:04.14Z" },
{ url = "https://files.pythonhosted.org/packages/45/e5/5aa65852dadc24b7d8ae75b7efb8d19303ed6ac93482e60c44a585930ea5/sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:119dc41e7a7defcefc57189cfa0e61b1bf9c228211aba432b53fb71ef367fda1", size = 3337842, upload-time = "2025-10-10T15:43:45.431Z" }, { url = "https://files.pythonhosted.org/packages/ed/6a/310ecb5657221f3e1bd5288ed83aa554923fb5da48d760a9f7622afeb065/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:107029bf4f43d076d4011f1afb74f7c3e2ea029ec82eb23d8527d5e909e97aa6", size = 3313907, upload-time = "2025-12-09T22:13:50.598Z" },
{ url = "https://files.pythonhosted.org/packages/41/92/648f1afd3f20b71e880ca797a960f638d39d243e233a7082c93093c22378/sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0765e318ee9179b3718c4fd7ba35c434f4dd20332fbc6857a5e8df17719c24d7", size = 3264558, upload-time = "2025-10-10T15:35:29.93Z" }, { url = "https://files.pythonhosted.org/packages/5c/39/69c0b4051079addd57c84a5bfb34920d87456dd4c90cf7ee0df6efafc8ff/sqlalchemy-2.0.45-cp312-cp312-win32.whl", hash = "sha256:0c9f6ada57b58420a2c0277ff853abe40b9e9449f8d7d231763c6bc30f5c4953", size = 2112182, upload-time = "2025-12-09T21:39:30.824Z" },
{ url = "https://files.pythonhosted.org/packages/40/cf/e27d7ee61a10f74b17740918e23cbc5bc62011b48282170dc4c66da8ec0f/sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2e7b5b079055e02d06a4308d0481658e4f06bc7ef211567edc8f7d5dce52018d", size = 3301570, upload-time = "2025-10-10T15:43:48.407Z" }, { url = "https://files.pythonhosted.org/packages/f7/4e/510db49dd89fc3a6e994bee51848c94c48c4a00dc905e8d0133c251f41a7/sqlalchemy-2.0.45-cp312-cp312-win_amd64.whl", hash = "sha256:8defe5737c6d2179c7997242d6473587c3beb52e557f5ef0187277009f73e5e1", size = 2139200, upload-time = "2025-12-09T21:39:32.321Z" },
{ url = "https://files.pythonhosted.org/packages/3b/3d/3116a9a7b63e780fb402799b6da227435be878b6846b192f076d2f838654/sqlalchemy-2.0.44-cp312-cp312-win32.whl", hash = "sha256:846541e58b9a81cce7dee8329f352c318de25aa2f2bbe1e31587eb1f057448b4", size = 2103447, upload-time = "2025-10-10T15:03:21.678Z" }, { url = "https://files.pythonhosted.org/packages/6a/c8/7cc5221b47a54edc72a0140a1efa56e0a2730eefa4058d7ed0b4c4357ff8/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe187fc31a54d7fd90352f34e8c008cf3ad5d064d08fedd3de2e8df83eb4a1cf", size = 3277082, upload-time = "2025-12-09T22:11:06.167Z" },
{ url = "https://files.pythonhosted.org/packages/25/83/24690e9dfc241e6ab062df82cc0df7f4231c79ba98b273fa496fb3dd78ed/sqlalchemy-2.0.44-cp312-cp312-win_amd64.whl", hash = "sha256:7cbcb47fd66ab294703e1644f78971f6f2f1126424d2b300678f419aa73c7b6e", size = 2130912, upload-time = "2025-10-10T15:03:24.656Z" }, { url = "https://files.pythonhosted.org/packages/0e/50/80a8d080ac7d3d321e5e5d420c9a522b0aa770ec7013ea91f9a8b7d36e4a/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:672c45cae53ba88e0dad74b9027dddd09ef6f441e927786b05bec75d949fbb2e", size = 3293131, upload-time = "2025-12-09T22:13:52.626Z" },
{ url = "https://files.pythonhosted.org/packages/45/d3/c67077a2249fdb455246e6853166360054c331db4613cda3e31ab1cadbef/sqlalchemy-2.0.44-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ff486e183d151e51b1d694c7aa1695747599bb00b9f5f604092b54b74c64a8e1", size = 2135479, upload-time = "2025-10-10T16:03:37.671Z" }, { url = "https://files.pythonhosted.org/packages/da/4c/13dab31266fc9904f7609a5dc308a2432a066141d65b857760c3bef97e69/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:470daea2c1ce73910f08caf10575676a37159a6d16c4da33d0033546bddebc9b", size = 3225389, upload-time = "2025-12-09T22:11:08.093Z" },
{ url = "https://files.pythonhosted.org/packages/2b/91/eabd0688330d6fd114f5f12c4f89b0d02929f525e6bf7ff80aa17ca802af/sqlalchemy-2.0.44-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b1af8392eb27b372ddb783b317dea0f650241cea5bd29199b22235299ca2e45", size = 2123212, upload-time = "2025-10-10T16:03:41.755Z" }, { url = "https://files.pythonhosted.org/packages/74/04/891b5c2e9f83589de202e7abaf24cd4e4fa59e1837d64d528829ad6cc107/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9c6378449e0940476577047150fd09e242529b761dc887c9808a9a937fe990c8", size = 3266054, upload-time = "2025-12-09T22:13:54.262Z" },
{ url = "https://files.pythonhosted.org/packages/b0/bb/43e246cfe0e81c018076a16036d9b548c4cc649de241fa27d8d9ca6f85ab/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b61188657e3a2b9ac4e8f04d6cf8e51046e28175f79464c67f2fd35bceb0976", size = 3255353, upload-time = "2025-10-10T15:35:31.221Z" }, { url = "https://files.pythonhosted.org/packages/f1/24/fc59e7f71b0948cdd4cff7a286210e86b0443ef1d18a23b0d83b87e4b1f7/sqlalchemy-2.0.45-cp313-cp313-win32.whl", hash = "sha256:4b6bec67ca45bc166c8729910bd2a87f1c0407ee955df110d78948f5b5827e8a", size = 2110299, upload-time = "2025-12-09T21:39:33.486Z" },
{ url = "https://files.pythonhosted.org/packages/b9/96/c6105ed9a880abe346b64d3b6ddef269ddfcab04f7f3d90a0bf3c5a88e82/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b87e7b91a5d5973dda5f00cd61ef72ad75a1db73a386b62877d4875a8840959c", size = 3260222, upload-time = "2025-10-10T15:43:50.124Z" }, { url = "https://files.pythonhosted.org/packages/c0/c5/d17113020b2d43073412aeca09b60d2009442420372123b8d49cc253f8b8/sqlalchemy-2.0.45-cp313-cp313-win_amd64.whl", hash = "sha256:afbf47dc4de31fa38fd491f3705cac5307d21d4bb828a4f020ee59af412744ee", size = 2136264, upload-time = "2025-12-09T21:39:36.801Z" },
{ url = "https://files.pythonhosted.org/packages/44/16/1857e35a47155b5ad927272fee81ae49d398959cb749edca6eaa399b582f/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15f3326f7f0b2bfe406ee562e17f43f36e16167af99c4c0df61db668de20002d", size = 3189614, upload-time = "2025-10-10T15:35:32.578Z" }, { url = "https://files.pythonhosted.org/packages/3d/8d/bb40a5d10e7a5f2195f235c0b2f2c79b0bf6e8f00c0c223130a4fbd2db09/sqlalchemy-2.0.45-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83d7009f40ce619d483d26ac1b757dfe3167b39921379a8bd1b596cf02dab4a6", size = 3521998, upload-time = "2025-12-09T22:13:28.622Z" },
{ url = "https://files.pythonhosted.org/packages/88/ee/4afb39a8ee4fc786e2d716c20ab87b5b1fb33d4ac4129a1aaa574ae8a585/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e77faf6ff919aa8cd63f1c4e561cac1d9a454a191bb864d5dd5e545935e5a40", size = 3226248, upload-time = "2025-10-10T15:43:51.862Z" }, { url = "https://files.pythonhosted.org/packages/75/a5/346128b0464886f036c039ea287b7332a410aa2d3fb0bb5d404cb8861635/sqlalchemy-2.0.45-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d8a2ca754e5415cde2b656c27900b19d50ba076aa05ce66e2207623d3fe41f5a", size = 3473434, upload-time = "2025-12-09T22:13:30.188Z" },
{ url = "https://files.pythonhosted.org/packages/32/d5/0e66097fc64fa266f29a7963296b40a80d6a997b7ac13806183700676f86/sqlalchemy-2.0.44-cp313-cp313-win32.whl", hash = "sha256:ee51625c2d51f8baadf2829fae817ad0b66b140573939dd69284d2ba3553ae73", size = 2101275, upload-time = "2025-10-10T15:03:26.096Z" }, { url = "https://files.pythonhosted.org/packages/cc/64/4e1913772646b060b025d3fc52ce91a58967fe58957df32b455de5a12b4f/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f46ec744e7f51275582e6a24326e10c49fbdd3fc99103e01376841213028774", size = 3272404, upload-time = "2025-12-09T22:11:09.662Z" },
{ url = "https://files.pythonhosted.org/packages/03/51/665617fe4f8c6450f42a6d8d69243f9420f5677395572c2fe9d21b493b7b/sqlalchemy-2.0.44-cp313-cp313-win_amd64.whl", hash = "sha256:c1c80faaee1a6c3428cecf40d16a2365bcf56c424c92c2b6f0f9ad204b899e9e", size = 2127901, upload-time = "2025-10-10T15:03:27.548Z" }, { url = "https://files.pythonhosted.org/packages/b3/27/caf606ee924282fe4747ee4fd454b335a72a6e018f97eab5ff7f28199e16/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:883c600c345123c033c2f6caca18def08f1f7f4c3ebeb591a63b6fceffc95cce", size = 3277057, upload-time = "2025-12-09T22:13:56.213Z" },
{ url = "https://files.pythonhosted.org/packages/9c/5e/6a29fa884d9fb7ddadf6b69490a9d45fded3b38541713010dad16b77d015/sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05", size = 1928718, upload-time = "2025-10-10T15:29:45.32Z" }, { url = "https://files.pythonhosted.org/packages/85/d0/3d64218c9724e91f3d1574d12eb7ff8f19f937643815d8daf792046d88ab/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c0b74aa79e2deade948fe8593654c8ef4228c44ba862bb7c9585c8e0db90f33", size = 3222279, upload-time = "2025-12-09T22:11:11.1Z" },
{ url = "https://files.pythonhosted.org/packages/24/10/dd7688a81c5bc7690c2a3764d55a238c524cd1a5a19487928844cb247695/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a420169cef179d4c9064365f42d779f1e5895ad26ca0c8b4c0233920973db74", size = 3244508, upload-time = "2025-12-09T22:13:57.932Z" },
{ url = "https://files.pythonhosted.org/packages/aa/41/db75756ca49f777e029968d9c9fee338c7907c563267740c6d310a8e3f60/sqlalchemy-2.0.45-cp314-cp314-win32.whl", hash = "sha256:e50dcb81a5dfe4b7b4a4aa8f338116d127cb209559124f3694c70d6cd072b68f", size = 2113204, upload-time = "2025-12-09T21:39:38.365Z" },
{ url = "https://files.pythonhosted.org/packages/89/a2/0e1590e9adb292b1d576dbcf67ff7df8cf55e56e78d2c927686d01080f4b/sqlalchemy-2.0.45-cp314-cp314-win_amd64.whl", hash = "sha256:4748601c8ea959e37e03d13dcda4a44837afcd1b21338e637f7c935b8da06177", size = 2138785, upload-time = "2025-12-09T21:39:39.503Z" },
{ url = "https://files.pythonhosted.org/packages/42/39/f05f0ed54d451156bbed0e23eb0516bcad7cbb9f18b3bf219c786371b3f0/sqlalchemy-2.0.45-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd337d3526ec5298f67d6a30bbbe4ed7e5e68862f0bf6dd21d289f8d37b7d60b", size = 3522029, upload-time = "2025-12-09T22:13:32.09Z" },
{ url = "https://files.pythonhosted.org/packages/54/0f/d15398b98b65c2bce288d5ee3f7d0a81f77ab89d9456994d5c7cc8b2a9db/sqlalchemy-2.0.45-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9a62b446b7d86a3909abbcd1cd3cc550a832f99c2bc37c5b22e1925438b9367b", size = 3475142, upload-time = "2025-12-09T22:13:33.739Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0", size = 1936672, upload-time = "2025-12-09T21:54:52.608Z" },
] ]
[[package]] [[package]]
name = "sqlmodel" name = "sqlmodel"
version = "0.0.27" version = "0.0.30"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/90/5a/693d90866233e837d182da76082a6d4c2303f54d3aaaa5c78e1238c5d863/sqlmodel-0.0.27.tar.gz", hash = "sha256:ad1227f2014a03905aef32e21428640848ac09ff793047744a73dfdd077ff620", size = 118053, upload-time = "2025-10-08T16:39:11.938Z" } sdist = { url = "https://files.pythonhosted.org/packages/4a/bc/162873c9b2466bb6bda1531b6023b5d8ecda4daffaaae54e6fec53e2c0a7/sqlmodel-0.0.30.tar.gz", hash = "sha256:076076976aac683bae3e10c791d9e2ec5fead57ab25f7e476dea36cdfe9505e1", size = 97581, upload-time = "2025-12-26T11:45:55.002Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/92/c35e036151fe53822893979f8a13e6f235ae8191f4164a79ae60a95d66aa/sqlmodel-0.0.27-py3-none-any.whl", hash = "sha256:667fe10aa8ff5438134668228dc7d7a08306f4c5c4c7e6ad3ad68defa0e7aa49", size = 29131, upload-time = "2025-10-08T16:39:10.917Z" }, { url = "https://files.pythonhosted.org/packages/35/f3/247bad31f13066a8186f2d4d1a43c75a441ed9f889d629d69d9820116dfc/sqlmodel-0.0.30-py3-none-any.whl", hash = "sha256:1b7f992170ff3145d98a72d41271335c8bb51d579c7254a1626f82bde4ec64ed", size = 29362, upload-time = "2025-12-26T11:45:53.298Z" },
] ]
[[package]] [[package]]