feat(calculator): support generate PerformanceAttributes & DifficultyAttributes from JSON Schema (#59)

Prepare for custom rulesets.

Schema Genetator: https://github.com/GooGuTeam/custom-rulesets/tree/main/CustomRulesetMetadataGenerator

```bash
dotnet -- schemas path/to/rulesets -o schema.json
```

```bash
python scripts/generate_ruleset_attributes.py schema.json 
```
This commit is contained in:
MingxuanGame
2025-10-25 19:10:53 +08:00
committed by GitHub
parent f792d146b5
commit 2c81e22749
12 changed files with 370 additions and 85 deletions

View File

@@ -6,7 +6,7 @@ on:
- main
paths:
- "app/config.py"
- ".github/scripts/generate_config_doc.py"
- "scripts/generate_config_doc.py"
workflow_dispatch:
permissions:
@@ -42,7 +42,7 @@ jobs:
- name: Generate Markdown
run: |
cd project
python ./.github/scripts/generate_config_doc.py ${{ github.sha }} > ../wiki/Configuration.md
python ./scripts/generate_config_doc.py ${{ github.sha }} > ../wiki/Configuration.md
- name: Commit and push to Wiki
run: |

View File

@@ -2,7 +2,7 @@ import abc
from typing import TYPE_CHECKING
from app.models.mods import APIMod
from app.models.performance import BeatmapAttributes, PerformanceAttributes
from app.models.performance import DifficultyAttributes, PerformanceAttributes
from app.models.score import GameMode
if TYPE_CHECKING:
@@ -33,5 +33,5 @@ class PerformanceCalculator(abc.ABC):
@abc.abstractmethod
async def calculate_difficulty(
self, beatmap_raw: str, mods: list[APIMod] | None = None, gamemode: GameMode | None = None
) -> BeatmapAttributes:
) -> DifficultyAttributes:
raise NotImplementedError

View File

@@ -2,10 +2,10 @@ from typing import TYPE_CHECKING
from app.models.mods import APIMod
from app.models.performance import (
DIFFICULTY_CLASS,
PERFORMANCE_CLASS,
BeatmapAttributes,
DifficultyAttributes,
DifficultyAttributesUnion,
PerformanceAttributes,
PerformanceAttributesUnion,
)
from app.models.score import GameMode
@@ -17,6 +17,7 @@ from ._base import (
)
from httpx import AsyncClient, HTTPError
from pydantic import TypeAdapter
if TYPE_CHECKING:
from app.database.score import Score
@@ -56,8 +57,7 @@ class PerformanceCalculator(BasePerformanceCalculator):
)
if resp.status_code != 200:
raise PerformanceError(f"Failed to calculate performance: {resp.text}")
data = resp.json()
return PERFORMANCE_CLASS.get(score.gamemode, PerformanceAttributes).model_validate(data)
return TypeAdapter(PerformanceAttributesUnion).validate_json(resp.text)
except HTTPError as e:
raise PerformanceError(f"Failed to calculate performance: {e}") from e
except Exception as e:
@@ -65,7 +65,7 @@ class PerformanceCalculator(BasePerformanceCalculator):
async def calculate_difficulty(
self, beatmap_raw: str, mods: list[APIMod] | None = None, gamemode: GameMode | None = None
) -> BeatmapAttributes:
) -> DifficultyAttributes:
# https://github.com/GooGuTeam/osu-performance-server#post-difficulty
async with AsyncClient() as client:
try:
@@ -79,9 +79,7 @@ class PerformanceCalculator(BasePerformanceCalculator):
)
if resp.status_code != 200:
raise DifficultyError(f"Failed to calculate difficulty: {resp.text}")
data = resp.json()
ruleset_id = data.pop("ruleset", "osu")
return DIFFICULTY_CLASS.get(GameMode(ruleset_id), BeatmapAttributes).model_validate(data)
return TypeAdapter(DifficultyAttributesUnion).validate_json(resp.text)
except HTTPError as e:
raise DifficultyError(f"Failed to calculate difficulty: {e}") from e
except Exception as e:

View File

@@ -5,14 +5,12 @@ from typing import TYPE_CHECKING
from app.calculator import clamp
from app.models.mods import APIMod, parse_enum_to_str
from app.models.performance import (
DIFFICULTY_CLASS,
PERFORMANCE_CLASS,
BeatmapAttributes,
DifficultyAttributes,
ManiaPerformanceAttributes,
OsuBeatmapAttributes,
OsuDifficultyAttributes,
OsuPerformanceAttributes,
PerformanceAttributes,
TaikoBeatmapAttributes,
TaikoDifficultyAttributes,
TaikoPerformanceAttributes,
)
from app.models.score import GameMode
@@ -38,6 +36,16 @@ except ImportError:
" gu: uv add git+https://github.com/GooGuTeam/gu-pp-py.git"
)
PERFORMANCE_CLASS = {
GameMode.OSU: OsuPerformanceAttributes,
GameMode.TAIKO: TaikoPerformanceAttributes,
GameMode.MANIA: ManiaPerformanceAttributes,
}
DIFFICULTY_CLASS = {
GameMode.OSU: OsuDifficultyAttributes,
GameMode.TAIKO: TaikoDifficultyAttributes,
}
class PerformanceCalculator(BasePerformanceCalculator):
@classmethod
@@ -75,6 +83,10 @@ class PerformanceCalculator(BasePerformanceCalculator):
flashlight=attr.pp_flashlight or 0,
effective_miss_count=attr.effective_miss_count or 0,
speed_deviation=attr.speed_deviation,
combo_based_estimated_miss_count=0,
score_based_estimated_miss_count=0,
aim_estimated_slider_breaks=0,
speed_estimated_slider_breaks=0,
)
elif attr_class is TaikoPerformanceAttributes:
return TaikoPerformanceAttributes(
@@ -120,11 +132,11 @@ class PerformanceCalculator(BasePerformanceCalculator):
raise CalculateError(f"Unknown error: {e}") from e
@classmethod
def _diff_attr_to_model(cls, diff: rosu.DifficultyAttributes, gamemode: GameMode) -> BeatmapAttributes:
attr_class = DIFFICULTY_CLASS.get(gamemode, BeatmapAttributes)
def _diff_attr_to_model(cls, diff: rosu.DifficultyAttributes, gamemode: GameMode) -> DifficultyAttributes:
attr_class = DIFFICULTY_CLASS.get(gamemode, DifficultyAttributes)
if attr_class is OsuBeatmapAttributes:
return OsuBeatmapAttributes(
if attr_class is OsuDifficultyAttributes:
return OsuDifficultyAttributes(
star_rating=diff.stars,
max_combo=diff.max_combo,
aim_difficulty=diff.aim or 0,
@@ -135,23 +147,29 @@ class PerformanceCalculator(BasePerformanceCalculator):
aim_difficult_strain_count=diff.aim_difficult_strain_count or 0,
speed_difficult_strain_count=diff.speed_difficult_strain_count or 0,
flashlight_difficulty=diff.flashlight or 0,
aim_top_weighted_slider_factor=0,
speed_top_weighted_slider_factor=0,
nested_score_per_object=0,
legacy_score_base_multiplier=0,
maximum_legacy_combo_score=0,
)
elif attr_class is TaikoBeatmapAttributes:
return TaikoBeatmapAttributes(
elif attr_class is TaikoDifficultyAttributes:
return TaikoDifficultyAttributes(
star_rating=diff.stars,
max_combo=diff.max_combo,
rhythm_difficulty=diff.rhythm or 0,
mono_stamina_factor=diff.stamina or 0,
consistency_factor=0,
)
else:
return BeatmapAttributes(
return DifficultyAttributes(
star_rating=diff.stars,
max_combo=diff.max_combo,
)
async def calculate_difficulty(
self, beatmap_raw: str, mods: list[APIMod] | None = None, gamemode: GameMode | None = None
) -> BeatmapAttributes:
) -> DifficultyAttributes:
try:
map = rosu.Beatmap(content=beatmap_raw)
if gamemode is not None:

View File

@@ -8,13 +8,13 @@ from app.database.beatmap_tags import BeatmapTagVote
from app.database.failtime import FailTime, FailTimeResp
from app.models.beatmap import BeatmapRankStatus
from app.models.mods import APIMod
from app.models.performance import DIFFICULTY_CLASS, BeatmapAttributes
from app.models.performance import DifficultyAttributesUnion
from app.models.score import GameMode
from .beatmap_playcounts import BeatmapPlaycounts
from .beatmapset import Beatmapset, BeatmapsetResp
from pydantic import BaseModel
from pydantic import BaseModel, TypeAdapter
from redis.asyncio import Redis
from sqlalchemy import Column, DateTime
from sqlmodel import VARCHAR, Field, Relationship, SQLModel, col, exists, func, select
@@ -246,12 +246,10 @@ async def calculate_beatmap_attributes(
mods_: list[APIMod],
redis: Redis,
fetcher: "Fetcher",
):
attr_class = DIFFICULTY_CLASS.get(ruleset, BeatmapAttributes)
) -> DifficultyAttributesUnion:
key = f"beatmap:{beatmap_id}:{ruleset}:{hashlib.sha256(str(mods_).encode()).hexdigest()}:attributes"
if await redis.exists(key):
return attr_class.model_validate_json(await redis.get(key))
return TypeAdapter(DifficultyAttributesUnion).validate_json(await redis.get(key))
resp = await fetcher.get_or_fetch_beatmap_raw(redis, beatmap_id)
attr = await get_calculator().calculate_difficulty(resp, mods_, ruleset)

View File

@@ -1,13 +1,28 @@
from app.models.score import GameMode
# Version: 2025.10.19
# Auto-generated by scripts/generate_ruleset_attributes.py.
# Schema generated by https://github.com/GooGuTeam/custom-rulesets
# Do not edit this file directly.
# ruff: noqa: E501
from pydantic import BaseModel
from typing import Any
from pydantic import BaseModel, Field, RootModel
class OsuAttributeModels(RootModel[Any]):
root: Any = Field(..., title="osu! Attribute models")
class PerformanceAttributes(BaseModel):
pp: float
pp: float = Field(..., description="Calculated score performance points.")
class DifficultyAttributes(BaseModel):
star_rating: float = Field(..., description="The combined star rating of all skills.")
max_combo: int = Field(..., description="The maximum achievable combo.")
# https://github.com/ppy/osu/blob/9ebc5b0a35452e50bd408af1db62cfc22a57b1f4/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs
class OsuPerformanceAttributes(PerformanceAttributes):
aim: float
speed: float
@@ -15,67 +30,111 @@ class OsuPerformanceAttributes(PerformanceAttributes):
flashlight: float
effective_miss_count: float
speed_deviation: float | None = None
# 2025 Q3 update
# combo_based_estimated_miss_count: int
# score_based_estimated_miss_count: int | None = None
# aim_estimated_slider_breaks: int
# speed_estimated_slider_breaks: int
combo_based_estimated_miss_count: float
score_based_estimated_miss_count: float | None = None
aim_estimated_slider_breaks: float
speed_estimated_slider_breaks: float
class OsuDifficultyAttributes(DifficultyAttributes):
aim_difficulty: float = Field(..., description="The difficulty corresponding to the aim skill.")
aim_difficult_slider_count: float = Field(..., description="The number of Sliders weighted by difficulty.")
speed_difficulty: float = Field(..., description="The difficulty corresponding to the speed skill.")
speed_note_count: float = Field(
..., description="The number of clickable objects weighted by difficulty.\nRelated to SpeedDifficulty"
)
flashlight_difficulty: float = Field(..., description="The difficulty corresponding to the flashlight skill.")
slider_factor: float = Field(
...,
description="Describes how much of AimDifficulty is contributed to by hitcircles or sliders.\nA value closer to 1.0 indicates most of AimDifficulty is contributed by hitcircles.\nA value closer to 0.0 indicates most of AimDifficulty is contributed by sliders.",
)
aim_top_weighted_slider_factor: float = Field(
...,
description="Describes how much of AimDifficultStrainCount is contributed to by hitcircles or sliders\nA value closer to 0.0 indicates most of AimDifficultStrainCount is contributed by hitcircles\nA value closer to Infinity indicates most of AimDifficultStrainCount is contributed by sliders",
)
speed_top_weighted_slider_factor: float = Field(
...,
description="Describes how much of SpeedDifficultStrainCount is contributed to by hitcircles or sliders\nA value closer to 0.0 indicates most of SpeedDifficultStrainCount is contributed by hitcircles\nA value closer to Infinity indicates most of SpeedDifficultStrainCount is contributed by sliders",
)
aim_difficult_strain_count: float
speed_difficult_strain_count: float
nested_score_per_object: float
legacy_score_base_multiplier: float
maximum_legacy_combo_score: float
# https://github.com/ppy/osu/blob/9ebc5b0a35452e50bd408af1db62cfc22a57b1f4/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs
class TaikoPerformanceAttributes(PerformanceAttributes):
difficulty: float
accuracy: float
estimated_unstable_rate: float | None = None
# https://github.com/ppy/osu/blob/9ebc5b0a35452e50bd408af1db62cfc22a57b1f4/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceAttributes.cs
class TaikoDifficultyAttributes(DifficultyAttributes):
rhythm_difficulty: float = Field(..., description="The difficulty corresponding to the rhythm skill.")
mono_stamina_factor: float = Field(
...,
description="The ratio of stamina difficulty from mono-color (single colour) streams to total stamina difficulty.",
)
consistency_factor: float = Field(..., description="The factor corresponding to the consistency of a map.")
CatchPerformanceAttributes = PerformanceAttributes
CatchDifficultyAttributes = DifficultyAttributes
class ManiaPerformanceAttributes(PerformanceAttributes):
difficulty: float
PERFORMANCE_CLASS: dict[GameMode, type[PerformanceAttributes]] = {
GameMode.OSU: OsuPerformanceAttributes,
GameMode.MANIA: ManiaPerformanceAttributes,
GameMode.TAIKO: TaikoPerformanceAttributes,
}
ManiaDifficultyAttributes = DifficultyAttributes
class BeatmapAttributes(BaseModel):
star_rating: float
max_combo: int
HishigataPerformanceAttributes = PerformanceAttributes
# https://github.com/ppy/osu/blob/9ebc5b0a35452e50bd408af1db62cfc22a57b1f4/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs
class OsuBeatmapAttributes(BeatmapAttributes):
HishigataDifficultyAttributes = DifficultyAttributes
RushPerformanceAttributes = PerformanceAttributes
RushDifficultyAttributes = DifficultyAttributes
SentakkiPerformanceAttributes = PerformanceAttributes
SentakkiDifficultyAttributes = DifficultyAttributes
SoyokazePerformanceAttributes = PerformanceAttributes
SoyokazeDifficultyAttributes = DifficultyAttributes
class TauPerformanceAttribute(PerformanceAttributes):
aim: float
speed: float
accuracy: float
complexity: float
effective_miss_count: float
class TauDifficultyAttributes(DifficultyAttributes):
aim_difficulty: float
aim_difficult_slider_count: float
speed_difficulty: float
speed_note_count: float
flashlight_difficulty: float | None = None
complexity_difficulty: float
approach_rate: float
slider_factor: float
aim_difficult_strain_count: float
speed_difficult_strain_count: float
# 2025 Q3 update
# aim_top_weighted_slider_factor: float
# speed_top_weighted_slider_factor: float
# nested_score_per_object: float
# legacy_score_base_multiplier: float
# maximum_legacy_combo_score: float
overall_difficulty: float
# https://github.com/ppy/osu/blob/9ebc5b0a35452e50bd408af1db62cfc22a57b1f4/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs
class TaikoBeatmapAttributes(BeatmapAttributes):
rhythm_difficulty: float
mono_stamina_factor: float
# 2025 Q3 update
# consistency_factor: float
DIFFICULTY_CLASS: dict[GameMode, type[BeatmapAttributes]] = {
GameMode.OSU: OsuBeatmapAttributes,
GameMode.TAIKO: TaikoBeatmapAttributes,
}
PerformanceAttributesUnion = (
OsuPerformanceAttributes | TaikoPerformanceAttributes | ManiaPerformanceAttributes | PerformanceAttributes
)
DifficultyAttributesUnion = (
OsuDifficultyAttributes | TaikoDifficultyAttributes | TauDifficultyAttributes | DifficultyAttributes
)

View File

@@ -10,7 +10,7 @@ from app.dependencies.database import Database, Redis
from app.dependencies.fetcher import Fetcher
from app.models.beatmap import BeatmapRankStatus, Genre, Language
from app.models.mods import int_to_mods
from app.models.performance import OsuBeatmapAttributes
from app.models.performance import OsuDifficultyAttributes
from app.models.score import GameMode
from .router import AllStrModel, router
@@ -196,7 +196,7 @@ async def get_beatmaps(
)
aim_diff = None
speed_diff = None
if isinstance(attrs, OsuBeatmapAttributes):
if isinstance(attrs, OsuDifficultyAttributes):
aim_diff = attrs.aim_difficulty
speed_diff = attrs.speed_difficulty
results.append(await V1Beatmap.from_db(session, beatmap, aim_diff, speed_diff))

View File

@@ -11,7 +11,10 @@ from app.dependencies.fetcher import Fetcher
from app.dependencies.user import get_current_user
from app.helpers.asset_proxy_helper import asset_proxy_response
from app.models.mods import APIMod, int_to_mods
from app.models.performance import BeatmapAttributes, OsuBeatmapAttributes, TaikoBeatmapAttributes
from app.models.performance import (
DifficultyAttributes,
DifficultyAttributesUnion,
)
from app.models.score import (
GameMode,
)
@@ -127,7 +130,7 @@ async def batch_get_beatmaps(
"/beatmaps/{beatmap_id}/attributes",
tags=["谱面"],
name="计算谱面属性",
response_model=BeatmapAttributes | OsuBeatmapAttributes | TaikoBeatmapAttributes,
response_model=DifficultyAttributesUnion,
description=("计算谱面指定 mods / ruleset 下谱面的难度属性 (难度/PP 相关属性)。"),
)
async def get_beatmap_attributes(
@@ -166,7 +169,7 @@ async def get_beatmap_attributes(
f"{hashlib.md5(str(mods_).encode(), usedforsecurity=False).hexdigest()}:attributes"
)
if await redis.exists(key):
return BeatmapAttributes.model_validate_json(await redis.get(key)) # pyright: ignore[reportArgumentType]
return DifficultyAttributes.model_validate_json(await redis.get(key)) # pyright: ignore[reportArgumentType]
try:
return await calculate_beatmap_attributes(beatmap_id, ruleset, mods_, redis, fetcher)
except HTTPStatusError:

View File

@@ -85,7 +85,7 @@ ignore = [
[tool.ruff.lint.extend-per-file-ignores]
"tools/*.py" = ["PTH", "INP001"]
"migrations/**/*.py" = ["INP001"]
".github/**/*.py" = ["INP001"]
"scripts/**/*.py" = ["INP001"]
"app/achievements/*.py" = ["INP001", "ARG"]
"app/router/**/*.py" = ["ARG001"]
@@ -125,6 +125,7 @@ osupyparser = { git = "https://github.com/MingxuanGame/osupyparser.git" }
[dependency-groups]
dev = [
"datamodel-code-generator>=0.35.0",
"pre-commit>=4.2.0",
"pyright>=1.1.404",
"ruff>=0.12.4",

View File

@@ -0,0 +1,64 @@
import datetime
from pathlib import Path
import re
import subprocess
import sys
from datamodel_code_generator import DataModelType, generate
from datamodel_code_generator.format import Formatter
OUTPUT_FILE = Path("app/models/performance.py")
def generate_model(schema_file: Path, version: str = ""):
temp_file = OUTPUT_FILE.parent / "_temp.py"
if temp_file.exists():
temp_file.unlink()
temp_file.touch()
version = version or datetime.datetime.now().strftime("%Y.%m.%d")
generate(
input_=schema_file,
output=temp_file,
output_model_type=DataModelType.PydanticV2BaseModel,
disable_future_imports=True,
formatters=[Formatter.RUFF_CHECK, Formatter.RUFF_FORMAT],
custom_file_header=(
f"# Version: {version}\n"
"# Auto-generated by scripts/generate_ruleset_attributes.py.\n"
"# Schema generated by https://github.com/GooGuTeam/custom-rulesets\n"
"# Do not edit this file directly.\n"
"# ruff: noqa: E501\n"
),
)
code = temp_file.read_text()
output = code
performance_attributes_classes = re.findall(r"class (\w+PerformanceAttributes)", code)
performance_attributes_classes.append("PerformanceAttributes")
output += "PerformanceAttributesUnion = " + " | ".join(performance_attributes_classes) + "\n"
difficulty_attributes_classes = re.findall(r"class (\w+DifficultyAttributes)", code)
difficulty_attributes_classes.append("DifficultyAttributes")
output += "DifficultyAttributesUnion = " + " | ".join(difficulty_attributes_classes) + "\n"
OUTPUT_FILE.write_text(output)
temp_file.unlink(missing_ok=True)
subprocess.run(
["uv", "run", "ruff", "check", "--fix", "app/models/performance.py"], # nosec B607 # noqa: S607
capture_output=True,
)
subprocess.run(
["uv", "run", "ruff", "format", "app/models/performance.py"], # nosec B607 # noqa: S607
capture_output=True,
)
if __name__ == "__main__":
if len(sys.argv) < 1:
print("Usage: python scripts/generate_ruleset_attributes.py <schema_file> [version]")
sys.exit(1)
if len(sys.argv) > 2:
generate_model(Path(sys.argv[1]), sys.argv[2])
else:
generate_model(Path(sys.argv[1]))

144
uv.lock generated
View File

@@ -190,6 +190,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/ae/9a053dd9229c0fde6b1f1f33f609ccff1ee79ddda364c756a924c6d8563b/APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da", size = 64004, upload-time = "2024-11-24T19:39:24.442Z" },
]
[[package]]
name = "argcomplete"
version = "3.6.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/16/0f/861e168fc813c56a78b35f3c30d91c6757d1fd185af1110f1aec784b35d0/argcomplete-3.6.2.tar.gz", hash = "sha256:d0519b1bc867f5f4f4713c41ad0aba73a4a5f007449716b16f385f2166dc6adf", size = 73403, upload-time = "2025-04-03T04:57:03.52Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/31/da/e42d7a9d8dd33fa775f467e4028a47936da2f01e4b0e561f9ba0d74cb0ca/argcomplete-3.6.2-py3-none-any.whl", hash = "sha256:65b3133a29ad53fb42c48cf5114752c7ab66c1c38544fdf6460f450c09b42591", size = 43708, upload-time = "2025-04-03T04:57:01.591Z" },
]
[[package]]
name = "attrs"
version = "25.3.0"
@@ -265,6 +274,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
]
[[package]]
name = "black"
version = "25.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "mypy-extensions" },
{ name = "packaging" },
{ name = "pathspec" },
{ name = "platformdirs" },
{ name = "pytokens" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4b/43/20b5c90612d7bdb2bdbcceeb53d588acca3bb8f0e4c5d5c751a2c8fdd55a/black-25.9.0.tar.gz", hash = "sha256:0474bca9a0dd1b51791fcc507a4e02078a1c63f6d4e4ae5544b9848c7adfb619", size = 648393, upload-time = "2025-09-19T00:27:37.758Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/8e/319cfe6c82f7e2d5bfb4d3353c6cc85b523d677ff59edc61fdb9ee275234/black-25.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1b9dc70c21ef8b43248f1d86aedd2aaf75ae110b958a7909ad8463c4aa0880b0", size = 1742012, upload-time = "2025-09-19T00:33:08.678Z" },
{ url = "https://files.pythonhosted.org/packages/94/cc/f562fe5d0a40cd2a4e6ae3f685e4c36e365b1f7e494af99c26ff7f28117f/black-25.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e46eecf65a095fa62e53245ae2795c90bdecabd53b50c448d0a8bcd0d2e74c4", size = 1581421, upload-time = "2025-09-19T00:35:25.937Z" },
{ url = "https://files.pythonhosted.org/packages/84/67/6db6dff1ebc8965fd7661498aea0da5d7301074b85bba8606a28f47ede4d/black-25.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9101ee58ddc2442199a25cb648d46ba22cd580b00ca4b44234a324e3ec7a0f7e", size = 1655619, upload-time = "2025-09-19T00:30:49.241Z" },
{ url = "https://files.pythonhosted.org/packages/10/10/3faef9aa2a730306cf469d76f7f155a8cc1f66e74781298df0ba31f8b4c8/black-25.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:77e7060a00c5ec4b3367c55f39cf9b06e68965a4f2e61cecacd6d0d9b7ec945a", size = 1342481, upload-time = "2025-09-19T00:31:29.625Z" },
{ url = "https://files.pythonhosted.org/packages/48/99/3acfea65f5e79f45472c45f87ec13037b506522719cd9d4ac86484ff51ac/black-25.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0172a012f725b792c358d57fe7b6b6e8e67375dd157f64fa7a3097b3ed3e2175", size = 1742165, upload-time = "2025-09-19T00:34:10.402Z" },
{ url = "https://files.pythonhosted.org/packages/3a/18/799285282c8236a79f25d590f0222dbd6850e14b060dfaa3e720241fd772/black-25.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3bec74ee60f8dfef564b573a96b8930f7b6a538e846123d5ad77ba14a8d7a64f", size = 1581259, upload-time = "2025-09-19T00:32:49.685Z" },
{ url = "https://files.pythonhosted.org/packages/f1/ce/883ec4b6303acdeca93ee06b7622f1fa383c6b3765294824165d49b1a86b/black-25.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b756fc75871cb1bcac5499552d771822fd9db5a2bb8db2a7247936ca48f39831", size = 1655583, upload-time = "2025-09-19T00:30:44.505Z" },
{ url = "https://files.pythonhosted.org/packages/21/17/5c253aa80a0639ccc427a5c7144534b661505ae2b5a10b77ebe13fa25334/black-25.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:846d58e3ce7879ec1ffe816bb9df6d006cd9590515ed5d17db14e17666b2b357", size = 1343428, upload-time = "2025-09-19T00:32:13.839Z" },
{ url = "https://files.pythonhosted.org/packages/1b/46/863c90dcd3f9d41b109b7f19032ae0db021f0b2a81482ba0a1e28c84de86/black-25.9.0-py3-none-any.whl", hash = "sha256:474b34c1342cdc157d307b56c4c65bce916480c4a8f6551fdc6bf9b486a7c4ae", size = 203363, upload-time = "2025-09-19T00:27:35.724Z" },
]
[[package]]
name = "bleach"
version = "6.2.0"
@@ -511,6 +545,26 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/7c/15ad426257615f9be8caf7f97990cf3dcbb5b8dd7ed7e0db581a1c4759dd/cryptography-46.0.2-cp38-abi3-win_arm64.whl", hash = "sha256:91447f2b17e83c9e0c89f133119d83f94ce6e0fb55dd47da0a959316e6e9cfa1", size = 2918153, upload-time = "2025-10-01T00:28:51.003Z" },
]
[[package]]
name = "datamodel-code-generator"
version = "0.35.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "argcomplete" },
{ name = "black" },
{ name = "genson" },
{ name = "inflect" },
{ name = "isort" },
{ name = "jinja2" },
{ name = "packaging" },
{ name = "pydantic" },
{ name = "pyyaml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/70/e1/dbf7c2edb1b1db1f4fd472ee92f985ec97d58902512013d9c4584108329c/datamodel_code_generator-0.35.0.tar.gz", hash = "sha256:46805fa2515d3871f6bfafce9aa63128e735a7a6a4cfcbf9c27b3794ee4ea846", size = 459915, upload-time = "2025-10-09T19:26:49.837Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/ef/0ed17459fe6076219fcd45f69a0bb4bd1cb041b39095ca2946808a9b5f04/datamodel_code_generator-0.35.0-py3-none-any.whl", hash = "sha256:c356d1e4a555f86667a4262db03d4598a30caeda8f51786555fd269c8abb806b", size = 121436, upload-time = "2025-10-09T19:26:48.437Z" },
]
[[package]]
name = "distlib"
version = "0.4.0"
@@ -691,6 +745,7 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "datamodel-code-generator" },
{ name = "pre-commit" },
{ name = "pyright" },
{ name = "ruff" },
@@ -735,12 +790,22 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "datamodel-code-generator", specifier = ">=0.35.0" },
{ name = "pre-commit", specifier = ">=4.2.0" },
{ name = "pyright", specifier = ">=1.1.404" },
{ name = "ruff", specifier = ">=0.12.4" },
{ name = "types-aioboto3", extras = ["aioboto3", "essential"], specifier = ">=15.0.0" },
]
[[package]]
name = "genson"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c5/cf/2303c8ad276dcf5ee2ad6cf69c4338fd86ef0f471a5207b069adf7a393cf/genson-1.3.0.tar.gz", hash = "sha256:e02db9ac2e3fd29e65b5286f7135762e2cd8a986537c075b06fc5f1517308e37", size = 34919, upload-time = "2024-05-15T22:08:49.123Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/5c/e226de133afd8bb267ec27eead9ae3d784b95b39a287ed404caab39a5f50/genson-1.3.0-py3-none-any.whl", hash = "sha256:468feccd00274cc7e4c09e84b08704270ba8d95232aa280f65b986139cec67f7", size = 21470, upload-time = "2024-05-15T22:08:47.056Z" },
]
[[package]]
name = "greenlet"
version = "3.2.4"
@@ -851,6 +916,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
]
[[package]]
name = "inflect"
version = "7.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "more-itertools" },
{ name = "typeguard" },
]
sdist = { url = "https://files.pythonhosted.org/packages/78/c6/943357d44a21fd995723d07ccaddd78023eace03c1846049a2645d4324a3/inflect-7.5.0.tar.gz", hash = "sha256:faf19801c3742ed5a05a8ce388e0d8fe1a07f8d095c82201eb904f5d27ad571f", size = 73751, upload-time = "2024-12-28T17:11:18.897Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/eb/427ed2b20a38a4ee29f24dbe4ae2dafab198674fe9a85e3d6adf9e5f5f41/inflect-7.5.0-py3-none-any.whl", hash = "sha256:2aea70e5e70c35d8350b8097396ec155ffd68def678c7ff97f51aa69c1d92344", size = 35197, upload-time = "2024-12-28T17:11:15.931Z" },
]
[[package]]
name = "isort"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1e/82/fa43935523efdfcce6abbae9da7f372b627b27142c3419fcf13bf5b0c397/isort-6.1.0.tar.gz", hash = "sha256:9b8f96a14cfee0677e78e941ff62f03769a06d412aabb9e2a90487b3b7e8d481", size = 824325, upload-time = "2025-10-01T16:26:45.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/cc/9b681a170efab4868a032631dea1e8446d8ec718a7f657b94d49d1a12643/isort-6.1.0-py3-none-any.whl", hash = "sha256:58d8927ecce74e5087aef019f778d4081a3b6c98f15a80ba35782ca8a2097784", size = 94329, upload-time = "2025-10-01T16:26:43.291Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
@@ -1027,6 +1114,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/33/09601f476fd9d494e967f15c1e05aa1e35bdf5ee54555596e05e5c9ec8c9/maxminddb-2.8.2-cp314-cp314t-win_arm64.whl", hash = "sha256:929a00528db82ffa5aa928a9cd1a972e8f93c36243609c25574dfd920c21533b", size = 33990, upload-time = "2025-07-25T20:31:23.367Z" },
]
[[package]]
name = "more-itertools"
version = "10.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" },
]
[[package]]
name = "multidict"
version = "6.6.4"
@@ -1090,6 +1186,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/69/b547032297c7e63ba2af494edba695d781af8a0c6e89e4d06cf848b21d80/multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c", size = 12313, upload-time = "2025-08-11T12:08:46.891Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "newrelic"
version = "11.0.0"
@@ -1120,6 +1225,15 @@ name = "osupyparser"
version = "1.0.8"
source = { git = "https://github.com/MingxuanGame/osupyparser.git#f55794b775f61d8f0ea2f6bbc54a03436235df15" }
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "passlib"
version = "1.7.4"
@@ -1134,6 +1248,15 @@ bcrypt = [
{ name = "bcrypt" },
]
[[package]]
name = "pathspec"
version = "0.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" },
]
[[package]]
name = "pillow"
version = "11.3.0"
@@ -1456,6 +1579,15 @@ 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" },
]
[[package]]
name = "pytokens"
version = "0.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d4/c2/dbadcdddb412a267585459142bfd7cc241e6276db69339353ae6e241ab2b/pytokens-0.2.0.tar.gz", hash = "sha256:532d6421364e5869ea57a9523bf385f02586d4662acbcc0342afd69511b4dd43", size = 15368, upload-time = "2025-10-15T08:02:42.738Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/89/5a/c269ea6b348b6f2c32686635df89f32dbe05df1088dd4579302a6f8f99af/pytokens-0.2.0-py3-none-any.whl", hash = "sha256:74d4b318c67f4295c13782ddd9abcb7e297ec5630ad060eb90abf7ebbefe59f8", size = 12038, upload-time = "2025-10-15T08:02:41.694Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
@@ -1714,6 +1846,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610, upload-time = "2024-10-24T14:58:28.029Z" },
]
[[package]]
name = "typeguard"
version = "4.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c7/68/71c1a15b5f65f40e91b65da23b8224dad41349894535a97f63a52e462196/typeguard-4.4.4.tar.gz", hash = "sha256:3a7fd2dffb705d4d0efaed4306a704c89b9dee850b688f060a8b1615a79e5f74", size = 75203, upload-time = "2025-06-18T09:56:07.624Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/a9/e3aee762739c1d7528da1c3e06d518503f8b6c439c35549b53735ba52ead/typeguard-4.4.4-py3-none-any.whl", hash = "sha256:b5f562281b6bfa1f5492470464730ef001646128b180769880468bd84b68b09e", size = 34874, upload-time = "2025-06-18T09:56:05.999Z" },
]
[[package]]
name = "types-aioboto3"
version = "15.1.0"