From bcaa1a268ec180f02a49232db9e7f3507d697d19 Mon Sep 17 00:00:00 2001 From: 283375 Date: Sun, 6 Jul 2025 18:43:00 +0800 Subject: [PATCH] 2 --- arcaea_apk_assets/assets.py | 26 +- arcaea_apk_assets/parsers/__init__.py | 16 +- arcaea_apk_assets/parsers/_shared.py | 22 +- arcaea_apk_assets/parsers/defs.py | 100 -------- arcaea_apk_assets/parsers/packlist.py | 218 +++++++++------- arcaea_apk_assets/parsers/songlist.py | 353 +++++++++++++++----------- arcaea_apk_assets/processor.py | 21 +- pyproject.toml | 17 +- tests/test_packlist.py | 18 +- 9 files changed, 390 insertions(+), 401 deletions(-) diff --git a/arcaea_apk_assets/assets.py b/arcaea_apk_assets/assets.py index 48a1c87..6cafbe1 100644 --- a/arcaea_apk_assets/assets.py +++ b/arcaea_apk_assets/assets.py @@ -16,10 +16,12 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ +from __future__ import annotations + import abc import dataclasses import re -from typing import ClassVar, Optional +from typing import ClassVar __all__ = [ "ArcaeaApkAsset", @@ -35,27 +37,28 @@ class ArcaeaApkAsset(metaclass=abc.ABCMeta): zip_filename: str @classmethod - def _get_match(cls, string: str) -> Optional[re.Match]: + def _get_match(cls, string: str) -> re.Match | None: return cls.PATTERN.match(string) @classmethod @abc.abstractmethod def from_zip_filename( - cls, asset_zip_filename: str - ) -> Optional["ArcaeaApkAsset"]: ... + cls, + asset_zip_filename: str, + ) -> ArcaeaApkAsset | None: ... @dataclasses.dataclass class ArcaeaApkAssetJacket(ArcaeaApkAsset): PATTERN = re.compile( - r"^assets/songs/(?P.*)/(1080_)?(?P\d|base)(_(?P.*))?\.(jpg|png)" + r"^assets/songs/(?P.*)/(1080_)?(?P\d|base)(_(?P.*))?\.(jpg|png)", ) song_id: str - rating_class: Optional[str] - lang: Optional[str] + rating_class: str | None + lang: str | None @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) if match is None: return None @@ -78,7 +81,10 @@ class ArcaeaApkAssetPartnerIcon(ArcaeaApkAsset): char_id: str @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) if match is None: return None @@ -95,7 +101,7 @@ class ArcaeaApkAssetList(ArcaeaApkAsset): filename: str @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) if match is None: return None diff --git a/arcaea_apk_assets/parsers/__init__.py b/arcaea_apk_assets/parsers/__init__.py index 21a1d42..398d440 100644 --- a/arcaea_apk_assets/parsers/__init__.py +++ b/arcaea_apk_assets/parsers/__init__.py @@ -1,12 +1,4 @@ -from .defs import ( - ARCAEA_LANGUAGE_KEYS, - Difficulty, - DifficultyLocalization, - Pack, - PackLocalization, - Song, - SongLocalization, -) +from .defs import ARCAEA_LANGUAGE_KEYS from .packlist import ArcaeaPacklistParser from .songlist import ArcaeaSonglistParser @@ -14,10 +6,4 @@ __all__ = [ "ARCAEA_LANGUAGE_KEYS", "ArcaeaPacklistParser", "ArcaeaSonglistParser", - "Difficulty", - "DifficultyLocalization", - "Pack", - "PackLocalization", - "Song", - "SongLocalization", ] diff --git a/arcaea_apk_assets/parsers/_shared.py b/arcaea_apk_assets/parsers/_shared.py index 97af3c2..bbb201b 100644 --- a/arcaea_apk_assets/parsers/_shared.py +++ b/arcaea_apk_assets/parsers/_shared.py @@ -16,11 +16,15 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ +from __future__ import annotations + 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. @@ -37,14 +41,11 @@ def is_string_blank(string: str): False """ - if not isinstance(string, str): - msg = "A `str` object is expected" - raise TypeError(msg) - 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 original string. @@ -68,14 +69,11 @@ def blank_str_to_none(string: str): if string is 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 -def replace_single_newline(text: Optional[str]): +@typechecked +def replace_single_newline(text: str | None) -> str | None: if text is None: return None diff --git a/arcaea_apk_assets/parsers/defs.py b/arcaea_apk_assets/parsers/defs.py index 68ad530..ff8e03c 100644 --- a/arcaea_apk_assets/parsers/defs.py +++ b/arcaea_apk_assets/parsers/defs.py @@ -16,104 +16,4 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -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"] - - -@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] diff --git a/arcaea_apk_assets/parsers/packlist.py b/arcaea_apk_assets/parsers/packlist.py index b64d494..3be1938 100644 --- a/arcaea_apk_assets/parsers/packlist.py +++ b/arcaea_apk_assets/parsers/packlist.py @@ -16,11 +16,15 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ +from __future__ import annotations + 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 .defs import ARCAEA_LANGUAGE_KEYS, Pack, PackLocalization +from .defs import ARCAEA_LANGUAGE_KEYS logger = logging.getLogger(__name__) @@ -31,121 +35,145 @@ class PacklistFormatError(Exception): class ArcaeaPacklistParser: @staticmethod - def _assert_root_content(root: Any): - if not isinstance(root, dict): - msg = f"packlist root should be `dict`, got {type(root)}" - raise PacklistFormatError(msg) - - packs = root.get("packs") - 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) + def check_root_content(root: dict) -> None: + try: + check_type(root, dict) + check_type(root["packs"], list[dict]) + except (KeyError, TypeCheckError) as e: + raise PacklistFormatError from e @staticmethod - def _assert_pack_object(obj: Any): - 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]: + def check_pack_object(obj: dict) -> None: try: - cls._assert_root_content(packlist_content) - except PacklistFormatError as e: - if skip_asserts: - logger.exception("Error skipped during packlist parsing:") - return [] + check_type(obj, dict) + check_type(obj["id"], str) + except (KeyError, TypeCheckError) as e: + raise PacklistFormatError from e - raise e + @staticmethod + def parse_pack(pack: dict) -> AodisPack: + id_ = pack["id"] - packs = packlist_content["packs"] - results: list[Pack | PackLocalization] = [ - Pack( - id="single", - name="Memory Archive", - description=None, - section=None, - plus_character=None, - append_parent_id=None, - ) - ] + name_localized = pack.get("name_localized", {}) + description_localized = pack.get("description_localized", {}) - for pack in packs: - try: - cls._assert_pack_object(pack) - except PacklistFormatError as e: - if skip_asserts: - logger.exception("Error skipped during packlist parsing:") - continue + name = blank_str_to_none(name_localized.get("en")) + description = blank_str_to_none(description_localized.get("en")) + section = blank_str_to_none(pack.get("section")) + append_parent_id = pack.get("pack_parent") + plus_character = ( + pack["plus_character"] if pack.get("plus_character", -1) > -1 else None + ) - 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", {}) - description_localized = pack.get("description_localized", {}) + @staticmethod + def parse_pack_localizations(pack: dict) -> list[AodisPackLocalization]: + results = [] - id_ = pack["id"] - name = blank_str_to_none(name_localized.get("en")) - description = blank_str_to_none(description_localized.get("en")) - section = blank_str_to_none(pack.get("section")) - append_parent_id = pack.get("pack_parent") - plus_character = ( - pack["plus_character"] if pack.get("plus_character", -1) > -1 else None + name_localized = pack.get("name_localized", {}) + description_localized = pack.get("description_localized", {}) + + id_ = pack["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), ) - results.append( - Pack( - id=id_, - name=name, - description=description, - section=section, - plus_character=plus_character, - append_parent_id=append_parent_id, + if name_l10n or description_l10n: + results.append( + AodisPackLocalization( + id=id_, + lang=lang_key, + name=name_l10n, + description=description_l10n, + ), ) - ) - - 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 @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 [ item - for item in cls.items(packlist_content, skip_asserts=skip_asserts) - if isinstance(item, Pack) + for item in cls.items( + packlist_content, + skip_format_checks=skip_format_checks, + ) + if isinstance(item, AodisPack) ] @classmethod def pack_localizations( - cls, packlist_content: dict, *, skip_asserts: bool = False - ) -> list[PackLocalization]: + cls, + packlist_content: dict, + *, + skip_format_checks: bool = False, + ) -> list[AodisPackLocalization]: return [ item - for item in cls.items(packlist_content, skip_asserts=skip_asserts) - if isinstance(item, PackLocalization) + for item in cls.items( + packlist_content, + skip_format_checks=skip_format_checks, + ) + if isinstance(item, AodisPackLocalization) ] diff --git a/arcaea_apk_assets/parsers/songlist.py b/arcaea_apk_assets/parsers/songlist.py index ea5e859..3466683 100644 --- a/arcaea_apk_assets/parsers/songlist.py +++ b/arcaea_apk_assets/parsers/songlist.py @@ -16,19 +16,27 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ +from __future__ import annotations + import logging 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 .defs import ( - ARCAEA_LANGUAGE_KEYS, - Difficulty, - DifficultyLocalization, - Song, - SongLocalization, -) +from .defs import ARCAEA_LANGUAGE_KEYS logger = logging.getLogger(__name__) @@ -39,170 +47,215 @@ class SonglistFormatError(Exception): class ArcaeaSonglistParser: @staticmethod - def _assert_root_content(root: Any): - if not isinstance(root, dict): - msg = f"songlist root should be `dict`, got {type(root)}" - raise SonglistFormatError(msg) - - songs = root.get("songs") - 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) + def check_root_content_format(root: dict) -> None: + try: + check_type(root, dict) + check_type(root["songs"], list) + except (KeyError, TypeCheckError) as e: + raise SonglistFormatError from e @staticmethod - def _assert_song_object(obj: Any): - if not isinstance(obj, dict): - msg = f"A song object should be `dict`, got {type(obj)}" - raise SonglistFormatError(msg) + def check_song_object_format(song: dict) -> None: + try: + check_type(song, dict) + check_type(song["id"], str) - id_ = obj.get("id") - if not isinstance(id_, str): - msg = f"`id` attribute of a song should be `str`, got value {id_!r}" - raise SonglistFormatError(msg) + if deleted := song.get("deleted"): + check_type(deleted, bool) - is_deleted = obj.get("deleted", False) - if not isinstance(is_deleted, bool): - msg = f"`deleted` attribute of a song should be `bool`, got value {id_!r}" - raise SonglistFormatError(msg) + bg_daynight = song.get("bg_daynight", {}) + 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")) + 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 def items( - cls, songlist_content: dict, *, skip_asserts: bool = False - ) -> list[Song | SongLocalization | Difficulty | DifficultyLocalization]: + cls, + songlist_content: dict, + *, + skip_format_checks: bool = False, + ) -> list[ + AodisSong + | AodisSongLocalization + | AodisDifficulty + | AodisDifficultyLocalization + ]: try: - cls._assert_root_content(songlist_content) - except SonglistFormatError as e: - if skip_asserts: + cls.check_root_content_format(songlist_content) + except SonglistFormatError: + if skip_format_checks: logger.exception("Error skipped during songlist parsing:") return [] - raise e + raise songs = songlist_content["songs"] results = [] for song in songs: try: - cls._assert_song_object(song) - except SonglistFormatError as e: - if skip_asserts: + cls.check_song_object_format(song) + except SonglistFormatError: + if skip_format_checks: logger.exception("Error skipped during songlist parsing:") continue - raise e + raise if song.get("deleted"): - continue # TODO: none, return... + continue - title_localized = song.get("title_localized", {}) - bg_daynight = song.get("bg_daynight", {}) - 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, - ) - ) + results.extend(cls.parse_song(song)) + results.extend(cls.parse_difficulties(song)) return results diff --git a/arcaea_apk_assets/processor.py b/arcaea_apk_assets/processor.py index d63b5ca..271da19 100644 --- a/arcaea_apk_assets/processor.py +++ b/arcaea_apk_assets/processor.py @@ -16,8 +16,11 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import zipfile -from typing import Any, ClassVar, Optional +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar + +from typeguard import typechecked from .assets import ( ArcaeaApkAsset, @@ -26,6 +29,9 @@ from .assets import ( ArcaeaApkAssetPartnerIcon, ) +if TYPE_CHECKING: + import zipfile + __all__ = ["ArcaeaApkAssetsProcessor"] @@ -36,14 +42,8 @@ class ArcaeaApkAssetsProcessor: 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 - def get_asset(cls, asset_zip_filename: str) -> Optional[ArcaeaApkAsset]: + def get_asset(cls, asset_zip_filename: str) -> ArcaeaApkAsset | None: asset = None for asset_type in cls.ASSET_TYPES: 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 @classmethod + @typechecked def get_assets(cls, zf: zipfile.ZipFile) -> dict[zipfile.ZipInfo, ArcaeaApkAsset]: - cls._check_zipfile(zf) - matches = {} for info in zf.infolist(): filename = info.filename diff --git a/pyproject.toml b/pyproject.toml index 90cd9dd..cd14887 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,11 +8,12 @@ build-backend = "setuptools.build_meta" # dynamic = ["version"] name = "arcaea-apk-assets" description = "Your package description goes here." -version = "0.1.0a.dev1" +version = "0.1.0a0.dev2" requires-python = ">=3.9" authors = [{ name = "283375", email = "log_283375@163.com" }] readme = "README.md" license = "GPL-3.0-or-later" +dependencies = ["typeguard~=4.4"] [project.urls] Homepage = "https://github.com/283375/arcaea-apk-assets" @@ -20,7 +21,15 @@ Issues = "https://github.com/283375/arcaea-apk-assets/issues" [tool.ruff.lint] select = ["ALL"] -ignore = [ - "D", - "E501", # line-too-long +ignore = ["D"] + +[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 ] diff --git a/tests/test_packlist.py b/tests/test_packlist.py index 843726b..50bd9b9 100644 --- a/tests/test_packlist.py +++ b/tests/test_packlist.py @@ -11,17 +11,27 @@ class TestPacklistParser: ArcaeaPacklistParser.packs(content) def test_malformed(self): - with pytest.raises(PacklistFormatError, match="does not exist"): + with pytest.raises(PacklistFormatError): ArcaeaPacklistParser.items(contents.MALFORMED_WTF) - with pytest.raises(PacklistFormatError, match="got"): + with pytest.raises(PacklistFormatError): ArcaeaPacklistParser.items(contents.MALFORMED_ROOT) assert ( - len(ArcaeaPacklistParser.items(contents.MALFORMED_WTF, skip_asserts=True)) + len( + ArcaeaPacklistParser.items( + contents.MALFORMED_WTF, + skip_format_checks=True, + ), + ) == 0 ) assert ( - len(ArcaeaPacklistParser.items(contents.MALFORMED_ROOT, skip_asserts=True)) + len( + ArcaeaPacklistParser.items( + contents.MALFORMED_ROOT, + skip_format_checks=True, + ), + ) == 0 )