This commit is contained in:
2025-07-06 18:43:00 +08:00
parent 155e8b9664
commit bcaa1a268e
9 changed files with 390 additions and 401 deletions

View File

@ -16,10 +16,12 @@ You should have received a copy of the GNU General Public License along with
this program. If not, see <https://www.gnu.org/licenses/>. this program. If not, see <https://www.gnu.org/licenses/>.
""" """
from __future__ import annotations
import abc import abc
import dataclasses import dataclasses
import re import re
from typing import ClassVar, Optional from typing import ClassVar
__all__ = [ __all__ = [
"ArcaeaApkAsset", "ArcaeaApkAsset",
@ -35,27 +37,28 @@ class ArcaeaApkAsset(metaclass=abc.ABCMeta):
zip_filename: str zip_filename: str
@classmethod @classmethod
def _get_match(cls, string: str) -> Optional[re.Match]: def _get_match(cls, string: str) -> re.Match | None:
return cls.PATTERN.match(string) return cls.PATTERN.match(string)
@classmethod @classmethod
@abc.abstractmethod @abc.abstractmethod
def from_zip_filename( def from_zip_filename(
cls, asset_zip_filename: str cls,
) -> Optional["ArcaeaApkAsset"]: ... asset_zip_filename: str,
) -> ArcaeaApkAsset | None: ...
@dataclasses.dataclass @dataclasses.dataclass
class ArcaeaApkAssetJacket(ArcaeaApkAsset): class ArcaeaApkAssetJacket(ArcaeaApkAsset):
PATTERN = re.compile( PATTERN = re.compile(
r"^assets/songs/(?P<raw_song_id>.*)/(1080_)?(?P<raw_rating_class>\d|base)(_(?P<lang>.*))?\.(jpg|png)" r"^assets/songs/(?P<raw_song_id>.*)/(1080_)?(?P<raw_rating_class>\d|base)(_(?P<lang>.*))?\.(jpg|png)",
) )
song_id: str song_id: str
rating_class: Optional[str] rating_class: str | None
lang: Optional[str] lang: str | None
@classmethod @classmethod
def from_zip_filename(cls, asset_zip_filename: str): def from_zip_filename(cls, asset_zip_filename: str) -> ArcaeaApkAssetJacket | None:
match = cls._get_match(asset_zip_filename) match = cls._get_match(asset_zip_filename)
if match is None: if match is None:
return None return None
@ -78,7 +81,10 @@ class ArcaeaApkAssetPartnerIcon(ArcaeaApkAsset):
char_id: str char_id: str
@classmethod @classmethod
def from_zip_filename(cls, asset_zip_filename: str): def from_zip_filename(
cls,
asset_zip_filename: str,
) -> ArcaeaApkAssetPartnerIcon | None:
match = cls._get_match(asset_zip_filename) match = cls._get_match(asset_zip_filename)
if match is None: if match is None:
return None return None
@ -95,7 +101,7 @@ class ArcaeaApkAssetList(ArcaeaApkAsset):
filename: str filename: str
@classmethod @classmethod
def from_zip_filename(cls, asset_zip_filename: str): def from_zip_filename(cls, asset_zip_filename: str) -> ArcaeaApkAssetList | None:
match = cls._get_match(asset_zip_filename) match = cls._get_match(asset_zip_filename)
if match is None: if match is None:
return None return None

View File

@ -1,12 +1,4 @@
from .defs import ( from .defs import ARCAEA_LANGUAGE_KEYS
ARCAEA_LANGUAGE_KEYS,
Difficulty,
DifficultyLocalization,
Pack,
PackLocalization,
Song,
SongLocalization,
)
from .packlist import ArcaeaPacklistParser from .packlist import ArcaeaPacklistParser
from .songlist import ArcaeaSonglistParser from .songlist import ArcaeaSonglistParser
@ -14,10 +6,4 @@ __all__ = [
"ARCAEA_LANGUAGE_KEYS", "ARCAEA_LANGUAGE_KEYS",
"ArcaeaPacklistParser", "ArcaeaPacklistParser",
"ArcaeaSonglistParser", "ArcaeaSonglistParser",
"Difficulty",
"DifficultyLocalization",
"Pack",
"PackLocalization",
"Song",
"SongLocalization",
] ]

View File

@ -16,11 +16,15 @@ You should have received a copy of the GNU General Public License along with
this program. If not, see <https://www.gnu.org/licenses/>. this program. If not, see <https://www.gnu.org/licenses/>.
""" """
from __future__ import annotations
import re import re
from typing import Optional
from typeguard import typechecked
def is_string_blank(string: str): @typechecked
def is_string_blank(string: str) -> bool:
""" """
Check if a string equals to a blank string after stripped. Check if a string equals to a blank string after stripped.
@ -37,14 +41,11 @@ def is_string_blank(string: str):
False False
""" """
if not isinstance(string, str):
msg = "A `str` object is expected"
raise TypeError(msg)
return string.strip() == "" return string.strip() == ""
def blank_str_to_none(string: str): @typechecked
def blank_str_to_none(string: str | None) -> str | None:
""" """
Convert a blank string (see :func:`~is_string_blank`) to None, otherwise return the Convert a blank string (see :func:`~is_string_blank`) to None, otherwise return the
original string. original string.
@ -68,14 +69,11 @@ def blank_str_to_none(string: str):
if string is None: if string is None:
return None return None
if not isinstance(string, str):
msg = "A `str` object is expected"
raise TypeError(msg)
return None if is_string_blank(string) else string return None if is_string_blank(string) else string
def replace_single_newline(text: Optional[str]): @typechecked
def replace_single_newline(text: str | None) -> str | None:
if text is None: if text is None:
return None return None

View File

@ -16,104 +16,4 @@ You should have received a copy of the GNU General Public License along with
this program. If not, see <https://www.gnu.org/licenses/>. this program. If not, see <https://www.gnu.org/licenses/>.
""" """
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from typing import Optional
ARCAEA_LANGUAGE_KEYS = ["ja", "ko", "zh-Hans", "zh-Hant"] ARCAEA_LANGUAGE_KEYS = ["ja", "ko", "zh-Hans", "zh-Hant"]
@dataclass
class Pack:
id: str
name: Optional[str] = None
description: Optional[str] = None
section: Optional[str] = None
plus_character: Optional[int] = None
append_parent_id: Optional[str] = None
is_world_extend: bool = False
@property
def is_append(self):
return self.append_parent_id is not None
@dataclass
class PackLocalization:
id: str
lang: str
name: Optional[str]
description: Optional[str]
@dataclass
class Song:
pack_id: str
id: str
added_at: datetime
idx: Optional[int] = None
title: Optional[str] = None
artist: Optional[str] = None
is_deleted: bool = False
version: Optional[str] = None
bpm: Optional[str] = None
bpm_base: Optional[Decimal] = None
is_remote: bool = False
is_unlockable_in_world: bool = False
is_beyond_unlock_state_local: bool = False
purchase: Optional[str] = None
category: Optional[str] = None
side: Optional[int] = None
bg: Optional[str] = None
bg_inverse: Optional[str] = None
bg_day: Optional[str] = None
bg_night: Optional[str] = None
source: Optional[str] = None
source_copyright: Optional[str] = None
@dataclass
class SongLocalization:
id: str
lang: str
title: Optional[str] = None
source: Optional[str] = None
has_jacket: bool = False
@dataclass
class Difficulty:
song_id: str
rating_class: int
rating: int
is_rating_plus: bool = False
chart_designer: Optional[str] = None
jacket_designer: Optional[str] = None
has_overriding_audio: bool = False
has_overriding_jacket: bool = False
jacket_night: Optional[str] = None
added_at: Optional[datetime] = None
bg: Optional[str] = None
version: Optional[str] = None
title: Optional[str] = None
artist: Optional[str] = None
is_legacy11: bool = False
@dataclass
class DifficultyLocalization:
song_id: str
rating_class: int
lang: str
title: Optional[str]
artist: Optional[str]

View File

@ -16,11 +16,15 @@ You should have received a copy of the GNU General Public License along with
this program. If not, see <https://www.gnu.org/licenses/>. this program. If not, see <https://www.gnu.org/licenses/>.
""" """
from __future__ import annotations
import logging import logging
from typing import Any
from arcaea_offline_schemas.dis.v5.dataclass import AodisPack, AodisPackLocalization
from typeguard import TypeCheckError, check_type
from ._shared import blank_str_to_none from ._shared import blank_str_to_none
from .defs import ARCAEA_LANGUAGE_KEYS, Pack, PackLocalization from .defs import ARCAEA_LANGUAGE_KEYS
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -31,121 +35,145 @@ class PacklistFormatError(Exception):
class ArcaeaPacklistParser: class ArcaeaPacklistParser:
@staticmethod @staticmethod
def _assert_root_content(root: Any): def check_root_content(root: dict) -> None:
if not isinstance(root, dict): try:
msg = f"packlist root should be `dict`, got {type(root)}" check_type(root, dict)
raise PacklistFormatError(msg) check_type(root["packs"], list[dict])
except (KeyError, TypeCheckError) as e:
packs = root.get("packs") raise PacklistFormatError from e
if packs is None:
msg = "`packs` field does not exist"
raise PacklistFormatError(msg)
if not isinstance(packs, list):
msg = f"`packs` field should be `list`, got {type(packs)}"
raise PacklistFormatError(msg)
@staticmethod @staticmethod
def _assert_pack_object(obj: Any): def check_pack_object(obj: dict) -> None:
if not isinstance(obj, dict):
msg = f"A pack object should be `dict`, got {type(obj)}"
raise PacklistFormatError(msg)
id_ = obj.get("id")
if not isinstance(id_, str):
msg = f"`id` attribute of a pack should be `str`, got value {id_!r}"
raise PacklistFormatError(msg)
@classmethod
def items(
cls, packlist_content: dict, *, skip_asserts: bool = False
) -> list[Pack | PackLocalization]:
try: try:
cls._assert_root_content(packlist_content) check_type(obj, dict)
except PacklistFormatError as e: check_type(obj["id"], str)
if skip_asserts: except (KeyError, TypeCheckError) as e:
logger.exception("Error skipped during packlist parsing:") raise PacklistFormatError from e
return []
raise e @staticmethod
def parse_pack(pack: dict) -> AodisPack:
id_ = pack["id"]
packs = packlist_content["packs"] name_localized = pack.get("name_localized", {})
results: list[Pack | PackLocalization] = [ description_localized = pack.get("description_localized", {})
Pack(
id="single",
name="Memory Archive",
description=None,
section=None,
plus_character=None,
append_parent_id=None,
)
]
for pack in packs: name = blank_str_to_none(name_localized.get("en"))
try: description = blank_str_to_none(description_localized.get("en"))
cls._assert_pack_object(pack) section = blank_str_to_none(pack.get("section"))
except PacklistFormatError as e: append_parent_id = pack.get("pack_parent")
if skip_asserts: plus_character = (
logger.exception("Error skipped during packlist parsing:") pack["plus_character"] if pack.get("plus_character", -1) > -1 else None
continue )
raise e return AodisPack(
id=id_,
name=name or "",
isWorldExtend=pack.get("is_extend_pack") is True,
description=description,
section=section,
unlocksPartner=plus_character,
appendParentId=append_parent_id,
)
name_localized = pack.get("name_localized", {}) @staticmethod
description_localized = pack.get("description_localized", {}) def parse_pack_localizations(pack: dict) -> list[AodisPackLocalization]:
results = []
id_ = pack["id"] name_localized = pack.get("name_localized", {})
name = blank_str_to_none(name_localized.get("en")) description_localized = pack.get("description_localized", {})
description = blank_str_to_none(description_localized.get("en"))
section = blank_str_to_none(pack.get("section")) id_ = pack["id"]
append_parent_id = pack.get("pack_parent")
plus_character = ( for lang_key in ARCAEA_LANGUAGE_KEYS:
pack["plus_character"] if pack.get("plus_character", -1) > -1 else None name_l10n = blank_str_to_none(name_localized.get(lang_key))
description_l10n = blank_str_to_none(
description_localized.get(lang_key),
) )
results.append( if name_l10n or description_l10n:
Pack( results.append(
id=id_, AodisPackLocalization(
name=name, id=id_,
description=description, lang=lang_key,
section=section, name=name_l10n,
plus_character=plus_character, description=description_l10n,
append_parent_id=append_parent_id, ),
) )
)
for lang_key in ARCAEA_LANGUAGE_KEYS:
name_l10n = blank_str_to_none(name_localized.get(lang_key))
description_l10n = blank_str_to_none(
description_localized.get(lang_key)
)
if name_l10n or description_l10n:
results.append(
PackLocalization(
id=id_,
lang=lang_key,
name=name_l10n,
description=description_l10n,
)
)
return results return results
@classmethod @classmethod
def packs(cls, packlist_content: dict, *, skip_asserts: bool = False) -> list[Pack]: def items(
cls,
packlist_content: dict,
*,
skip_format_checks: bool = False,
) -> list[AodisPack | AodisPackLocalization]:
try:
cls.check_root_content(packlist_content)
except PacklistFormatError:
if skip_format_checks:
logger.exception("Error skipped during packlist parsing:")
return []
raise
packs = packlist_content["packs"]
results: list[AodisPack | AodisPackLocalization] = [
AodisPack(
id="single",
name="Memory Archive",
isWorldExtend=False,
description=None,
section=None,
unlocksPartner=None,
appendParentId=None,
),
]
for pack in packs:
try:
cls.check_pack_object(pack)
except PacklistFormatError:
if skip_format_checks:
logger.exception("Error skipped during packlist parsing:")
continue
raise
results.append(cls.parse_pack(pack))
results.extend(cls.parse_pack_localizations(pack))
return results
@classmethod
def packs(
cls,
packlist_content: dict,
*,
skip_format_checks: bool = False,
) -> list[AodisPack]:
return [ return [
item item
for item in cls.items(packlist_content, skip_asserts=skip_asserts) for item in cls.items(
if isinstance(item, Pack) packlist_content,
skip_format_checks=skip_format_checks,
)
if isinstance(item, AodisPack)
] ]
@classmethod @classmethod
def pack_localizations( def pack_localizations(
cls, packlist_content: dict, *, skip_asserts: bool = False cls,
) -> list[PackLocalization]: packlist_content: dict,
*,
skip_format_checks: bool = False,
) -> list[AodisPackLocalization]:
return [ return [
item item
for item in cls.items(packlist_content, skip_asserts=skip_asserts) for item in cls.items(
if isinstance(item, PackLocalization) packlist_content,
skip_format_checks=skip_format_checks,
)
if isinstance(item, AodisPackLocalization)
] ]

View File

@ -16,19 +16,27 @@ You should have received a copy of the GNU General Public License along with
this program. If not, see <https://www.gnu.org/licenses/>. this program. If not, see <https://www.gnu.org/licenses/>.
""" """
from __future__ import annotations
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from decimal import Decimal
from typing import Any from arcaea_offline_schemas.dis.v5.dataclass import (
AodisDifficulty,
AodisDifficultyLocalization,
AodisSong,
AodisSongLocalization,
)
from arcaea_offline_schemas.dis.v5.dataclass.shared import (
AddedAtObject,
Backgrounds,
BpmObject,
)
from arcaea_offline_schemas.dis.v5.dataclass.song import Source
from typeguard import TypeCheckError, check_type
from ._shared import blank_str_to_none from ._shared import blank_str_to_none
from .defs import ( from .defs import ARCAEA_LANGUAGE_KEYS
ARCAEA_LANGUAGE_KEYS,
Difficulty,
DifficultyLocalization,
Song,
SongLocalization,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -39,170 +47,215 @@ class SonglistFormatError(Exception):
class ArcaeaSonglistParser: class ArcaeaSonglistParser:
@staticmethod @staticmethod
def _assert_root_content(root: Any): def check_root_content_format(root: dict) -> None:
if not isinstance(root, dict): try:
msg = f"songlist root should be `dict`, got {type(root)}" check_type(root, dict)
raise SonglistFormatError(msg) check_type(root["songs"], list)
except (KeyError, TypeCheckError) as e:
songs = root.get("songs") raise SonglistFormatError from e
if songs is None:
msg = "`songs` field does not exist"
raise SonglistFormatError(msg)
if not isinstance(songs, list):
msg = f"`songs` field should be `list`, got {type(songs)}"
raise SonglistFormatError(msg)
@staticmethod @staticmethod
def _assert_song_object(obj: Any): def check_song_object_format(song: dict) -> None:
if not isinstance(obj, dict): try:
msg = f"A song object should be `dict`, got {type(obj)}" check_type(song, dict)
raise SonglistFormatError(msg) check_type(song["id"], str)
id_ = obj.get("id") if deleted := song.get("deleted"):
if not isinstance(id_, str): check_type(deleted, bool)
msg = f"`id` attribute of a song should be `str`, got value {id_!r}"
raise SonglistFormatError(msg)
is_deleted = obj.get("deleted", False) bg_daynight = song.get("bg_daynight", {})
if not isinstance(is_deleted, bool): bg = blank_str_to_none(song.get("bg"))
msg = f"`deleted` attribute of a song should be `bool`, got value {id_!r}" bg_inverse = blank_str_to_none(song.get("bg_inverse"))
raise SonglistFormatError(msg) bg_day = blank_str_to_none(bg_daynight.get("day"))
bg_night = blank_str_to_none(bg_daynight.get("night"))
if (bg_inverse or bg_day or bg_night) and not bg:
msg = "One of bg_inverse, bg_day and bg_night is set without bg"
raise SonglistFormatError(msg)
# TODO: check localized objects and difficulties (strict list[dict]) and booleans except (KeyError, TypeCheckError) as e:
raise SonglistFormatError from e
# TODO(283375): localized objects & difficulties (strict list[dict]) & booleans # noqa: E501, FIX002, TD003
@staticmethod
def parse_song(song: dict) -> list[AodisSong | AodisSongLocalization]:
id_ = song["id"]
title_localized = song.get("title_localized", {})
bg_daynight = song.get("bg_daynight", {})
source_localized = song.get("source_localized", {})
jacket_localized = song.get("jacket_localized", {})
bg = blank_str_to_none(song.get("bg"))
bg_inverse = blank_str_to_none(song.get("bg_inverse"))
bg_day = blank_str_to_none(bg_daynight.get("day"))
bg_night = blank_str_to_none(bg_daynight.get("night"))
results = []
aodis_song = AodisSong(
pack_id=song["set"],
id=id_,
addedAt=AddedAtObject(
date=datetime.fromtimestamp(song["date"], tz=timezone.utc).isoformat(),
version=blank_str_to_none(song.get("version")),
),
idx=song.get("idx"),
title=blank_str_to_none(title_localized.get("en")),
artist=blank_str_to_none(song.get("artist")),
isDeleted=song.get("deleted", False),
bpm=BpmObject(
value=song["bpm_base"],
display=blank_str_to_none(song.get("bpm")),
)
if song.get("bpm_base") is not None
else None,
isRemote=song.get("remote_dl", False),
isUnlockableInWorld=song.get("world_unlock", False),
isBeyondUnlockStateLocal=song.get("byd_local_unlock", False),
purchase=blank_str_to_none(song.get("purchase")),
category=blank_str_to_none(song.get("category")),
side=song.get("side"),
background=Backgrounds(
base=bg or bg_inverse or bg_day or bg_night,
inverse=blank_str_to_none(song.get("bg_inverse")),
day=blank_str_to_none(bg_daynight.get("day")),
night=blank_str_to_none(bg_daynight.get("night")),
)
if bg is not None
else None,
source=Source(
display=source_localized["en"],
copyright=blank_str_to_none(song.get("source_copyright")),
)
if source_localized.get("en") is not None
else None,
)
results.append(aodis_song)
for lang_key in ARCAEA_LANGUAGE_KEYS:
title_l10n = blank_str_to_none(title_localized.get(lang_key))
source_l10n = blank_str_to_none(source_localized.get(lang_key))
has_localized_jacket = jacket_localized.get(lang_key)
if title_l10n or source_l10n or has_localized_jacket:
results.append(
AodisSongLocalization(
id=id_,
lang=lang_key,
title=title_l10n,
source=source_l10n,
hasJacket=has_localized_jacket,
),
)
return results
@staticmethod
def parse_difficulties(
song: dict,
) -> list[AodisDifficulty | AodisDifficultyLocalization]:
id_ = song["id"]
difficulties = song["difficulties"]
results = []
for difficulty in difficulties:
difficulty_title_localized = difficulty.get("title_localized", {})
difficulty_artist_localized = difficulty.get("artist_localized", {})
rating_class = difficulty["ratingClass"]
results.append(
AodisDifficulty(
songId=id_,
ratingClass=rating_class,
rating=difficulty["rating"],
isRatingPlus=difficulty.get("ratingPlus", False),
chartDesigner=blank_str_to_none(
difficulty.get("chartDesigner"),
),
jacketDesigner=blank_str_to_none(
difficulty.get("jacketDesigner"),
),
hasOverridingAudio=difficulty.get("audioOverride", False),
hasOverridingJacket=difficulty.get("jacketOverride", False),
jacketNight=blank_str_to_none(difficulty.get("jacket_night")),
addedAt=AddedAtObject(
date=datetime.fromtimestamp(
difficulty["date"],
tz=timezone.utc,
).isoformat(),
version=blank_str_to_none(difficulty.get("version")),
)
if difficulty.get("date")
else None,
background=Backgrounds(base=difficulty["bg"])
if difficulty.get("bg") is not None
else None,
title=blank_str_to_none(difficulty_title_localized.get("en")),
artist=blank_str_to_none(difficulty_artist_localized.get("en")),
isLegacy11=difficulty.get("legacy11", False),
),
)
for lang_key in ARCAEA_LANGUAGE_KEYS:
difficuly_title_l10n = blank_str_to_none(
difficulty_title_localized.get(lang_key),
)
difficuly_artist_l10n = blank_str_to_none(
difficulty_artist_localized.get(lang_key),
)
if difficuly_title_l10n:
results.append(
AodisDifficultyLocalization(
songId=id_,
ratingClass=rating_class,
lang=lang_key,
title=difficuly_title_l10n,
artist=difficuly_artist_l10n,
),
)
return results
@classmethod @classmethod
def items( def items(
cls, songlist_content: dict, *, skip_asserts: bool = False cls,
) -> list[Song | SongLocalization | Difficulty | DifficultyLocalization]: songlist_content: dict,
*,
skip_format_checks: bool = False,
) -> list[
AodisSong
| AodisSongLocalization
| AodisDifficulty
| AodisDifficultyLocalization
]:
try: try:
cls._assert_root_content(songlist_content) cls.check_root_content_format(songlist_content)
except SonglistFormatError as e: except SonglistFormatError:
if skip_asserts: if skip_format_checks:
logger.exception("Error skipped during songlist parsing:") logger.exception("Error skipped during songlist parsing:")
return [] return []
raise e raise
songs = songlist_content["songs"] songs = songlist_content["songs"]
results = [] results = []
for song in songs: for song in songs:
try: try:
cls._assert_song_object(song) cls.check_song_object_format(song)
except SonglistFormatError as e: except SonglistFormatError:
if skip_asserts: if skip_format_checks:
logger.exception("Error skipped during songlist parsing:") logger.exception("Error skipped during songlist parsing:")
continue continue
raise e raise
if song.get("deleted"): if song.get("deleted"):
continue # TODO: none, return... continue
title_localized = song.get("title_localized", {}) results.extend(cls.parse_song(song))
bg_daynight = song.get("bg_daynight", {}) results.extend(cls.parse_difficulties(song))
source_localized = song.get("source_localized", {})
jacket_localized = song.get("jacket_localized", {})
id_ = song["id"]
results.append(
Song(
pack_id=song["set"],
id=id_,
added_at=datetime.fromtimestamp(song["date"], tz=timezone.utc),
idx=song.get("idx", None),
title=blank_str_to_none(title_localized.get("en")),
artist=blank_str_to_none(song.get("artist")),
is_deleted=song.get("deleted", False),
version=blank_str_to_none(song.get("version")),
bpm=blank_str_to_none(song.get("bpm")),
bpm_base=Decimal(song["bpm_base"])
if song.get("bpm_base")
else None,
is_remote=song.get("remote_dl", False),
is_unlockable_in_world=song.get("world_unlock", False),
is_beyond_unlock_state_local=song.get("byd_local_unlock", False),
purchase=blank_str_to_none(song.get("purchase")),
category=blank_str_to_none(song.get("category")),
side=song.get("side"),
bg=blank_str_to_none(song.get("bg")),
bg_inverse=blank_str_to_none(song.get("bg_inverse")),
bg_day=blank_str_to_none(bg_daynight.get("day")),
bg_night=blank_str_to_none(bg_daynight.get("night")),
source=blank_str_to_none(source_localized.get("en")),
source_copyright=blank_str_to_none(song.get("source_copyright")),
)
)
for lang_key in ARCAEA_LANGUAGE_KEYS:
title_l10n = blank_str_to_none(title_localized.get(lang_key))
source_l10n = blank_str_to_none(source_localized.get(lang_key))
has_localized_jacket = jacket_localized.get(lang_key)
if title_l10n or source_l10n or has_localized_jacket:
results.append(
SongLocalization(
id=id_,
lang=lang_key,
title=title_l10n,
source=source_l10n,
has_jacket=has_localized_jacket,
)
)
difficulties = song["difficulties"]
for difficulty in difficulties:
difficulty_title_localized = difficulty.get("title_localized", {})
difficulty_artist_localized = difficulty.get("artist_localized", {})
rating_class = difficulty["ratingClass"]
results.append(
Difficulty(
song_id=id_,
rating_class=rating_class,
rating=difficulty["rating"],
is_rating_plus=difficulty.get("ratingPlus", False),
chart_designer=blank_str_to_none(
difficulty.get("chartDesigner")
),
jacket_designer=blank_str_to_none(
difficulty.get("jacketDesigner")
),
has_overriding_audio=difficulty.get("audioOverride", False),
has_overriding_jacket=difficulty.get("jacketOverride", False),
jacket_night=blank_str_to_none(difficulty.get("jacket_night")),
added_at=datetime.fromtimestamp(
difficulty["date"], tz=timezone.utc
)
if difficulty.get("date")
else None,
bg=blank_str_to_none(difficulty.get("bg")),
version=blank_str_to_none(difficulty.get("version")),
title=blank_str_to_none(difficulty_title_localized.get("en")),
artist=blank_str_to_none(difficulty_artist_localized.get("en")),
is_legacy11=difficulty.get("legacy11", False),
)
)
for lang_key in ARCAEA_LANGUAGE_KEYS:
difficuly_title_l10n = blank_str_to_none(
difficulty_title_localized.get(lang_key)
)
difficuly_artist_l10n = blank_str_to_none(
difficulty_artist_localized.get(lang_key)
)
if difficuly_title_l10n:
results.append(
DifficultyLocalization(
song_id=id_,
rating_class=rating_class,
lang=lang_key,
title=difficuly_title_l10n,
artist=difficuly_artist_l10n,
)
)
return results return results

View File

@ -16,8 +16,11 @@ You should have received a copy of the GNU General Public License along with
this program. If not, see <https://www.gnu.org/licenses/>. this program. If not, see <https://www.gnu.org/licenses/>.
""" """
import zipfile from __future__ import annotations
from typing import Any, ClassVar, Optional
from typing import TYPE_CHECKING, ClassVar
from typeguard import typechecked
from .assets import ( from .assets import (
ArcaeaApkAsset, ArcaeaApkAsset,
@ -26,6 +29,9 @@ from .assets import (
ArcaeaApkAssetPartnerIcon, ArcaeaApkAssetPartnerIcon,
) )
if TYPE_CHECKING:
import zipfile
__all__ = ["ArcaeaApkAssetsProcessor"] __all__ = ["ArcaeaApkAssetsProcessor"]
@ -36,14 +42,8 @@ class ArcaeaApkAssetsProcessor:
ArcaeaApkAssetList, ArcaeaApkAssetList,
] ]
@staticmethod
def _check_zipfile(value: Any):
if not isinstance(value, zipfile.ZipFile):
msg = f"{value!r} is not a ZipFile!"
raise TypeError(msg)
@classmethod @classmethod
def get_asset(cls, asset_zip_filename: str) -> Optional[ArcaeaApkAsset]: def get_asset(cls, asset_zip_filename: str) -> ArcaeaApkAsset | None:
asset = None asset = None
for asset_type in cls.ASSET_TYPES: for asset_type in cls.ASSET_TYPES:
if asset_ := asset_type.from_zip_filename(asset_zip_filename): if asset_ := asset_type.from_zip_filename(asset_zip_filename):
@ -57,9 +57,8 @@ class ArcaeaApkAssetsProcessor:
return cls.get_asset(asset_zip_filename) is not None return cls.get_asset(asset_zip_filename) is not None
@classmethod @classmethod
@typechecked
def get_assets(cls, zf: zipfile.ZipFile) -> dict[zipfile.ZipInfo, ArcaeaApkAsset]: def get_assets(cls, zf: zipfile.ZipFile) -> dict[zipfile.ZipInfo, ArcaeaApkAsset]:
cls._check_zipfile(zf)
matches = {} matches = {}
for info in zf.infolist(): for info in zf.infolist():
filename = info.filename filename = info.filename

View File

@ -8,11 +8,12 @@ build-backend = "setuptools.build_meta"
# dynamic = ["version"] # dynamic = ["version"]
name = "arcaea-apk-assets" name = "arcaea-apk-assets"
description = "Your package description goes here." description = "Your package description goes here."
version = "0.1.0a.dev1" version = "0.1.0a0.dev2"
requires-python = ">=3.9" requires-python = ">=3.9"
authors = [{ name = "283375", email = "log_283375@163.com" }] authors = [{ name = "283375", email = "log_283375@163.com" }]
readme = "README.md" readme = "README.md"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
dependencies = ["typeguard~=4.4"]
[project.urls] [project.urls]
Homepage = "https://github.com/283375/arcaea-apk-assets" Homepage = "https://github.com/283375/arcaea-apk-assets"
@ -20,7 +21,15 @@ Issues = "https://github.com/283375/arcaea-apk-assets/issues"
[tool.ruff.lint] [tool.ruff.lint]
select = ["ALL"] select = ["ALL"]
ignore = [ ignore = ["D"]
"D",
"E501", # line-too-long [tool.ruff.lint.extend-per-file-ignores]
"tests/**/*.py" = [
# Disable certain rules for tests
# https://github.com/astral-sh/ruff/issues/4368#issuecomment-2245567481
"S101", # asserts are allowed in tests
"S311", # Standard pseudo-random generators are not suitable for cryptographic purposes
"ARG", # Unused function args: fixtures nevertheless are functionally relevant
"FBT", # Don't care about booleans as positional arguments in tests, e.g. via @pytest.mark.parametrize()
"PLR2004", # Magic value used in comparison
] ]

View File

@ -11,17 +11,27 @@ class TestPacklistParser:
ArcaeaPacklistParser.packs(content) ArcaeaPacklistParser.packs(content)
def test_malformed(self): def test_malformed(self):
with pytest.raises(PacklistFormatError, match="does not exist"): with pytest.raises(PacklistFormatError):
ArcaeaPacklistParser.items(contents.MALFORMED_WTF) ArcaeaPacklistParser.items(contents.MALFORMED_WTF)
with pytest.raises(PacklistFormatError, match="got"): with pytest.raises(PacklistFormatError):
ArcaeaPacklistParser.items(contents.MALFORMED_ROOT) ArcaeaPacklistParser.items(contents.MALFORMED_ROOT)
assert ( assert (
len(ArcaeaPacklistParser.items(contents.MALFORMED_WTF, skip_asserts=True)) len(
ArcaeaPacklistParser.items(
contents.MALFORMED_WTF,
skip_format_checks=True,
),
)
== 0 == 0
) )
assert ( assert (
len(ArcaeaPacklistParser.items(contents.MALFORMED_ROOT, skip_asserts=True)) len(
ArcaeaPacklistParser.items(
contents.MALFORMED_ROOT,
skip_format_checks=True,
),
)
== 0 == 0
) )