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
)