Compare commits

..

2 Commits

Author SHA1 Message Date
bcaa1a268e 2 2025-07-06 18:43:00 +08:00
155e8b9664 who cares 2025-07-05 14:27:26 +08:00
14 changed files with 482 additions and 411 deletions

4
.gitignore vendored
View File

@ -1,3 +1,7 @@
.vscode/
.debug/
debug*.py
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]

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/>.
"""
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<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
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

View File

@ -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",
]

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/>.
"""
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

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/>.
"""
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]

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/>.
"""
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,70 +35,28 @@ 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
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,
)
]
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
raise e
@staticmethod
def parse_pack(pack: dict) -> AodisPack:
id_ = pack["id"]
name_localized = pack.get("name_localized", {})
description_localized = pack.get("description_localized", {})
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"))
@ -103,49 +65,115 @@ class ArcaeaPacklistParser:
pack["plus_character"] if pack.get("plus_character", -1) > -1 else None
)
results.append(
Pack(
return AodisPack(
id=id_,
name=name,
name=name or "",
isWorldExtend=pack.get("is_extend_pack") is True,
description=description,
section=section,
plus_character=plus_character,
append_parent_id=append_parent_id,
)
unlocksPartner=plus_character,
appendParentId=append_parent_id,
)
@staticmethod
def parse_pack_localizations(pack: dict) -> list[AodisPackLocalization]:
results = []
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)
description_localized.get(lang_key),
)
if name_l10n or description_l10n:
results.append(
PackLocalization(
AodisPackLocalization(
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)
]

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/>.
"""
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,102 +47,90 @@ 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)
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)
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)
# TODO: check localized objects and difficulties (strict list[dict]) and booleans
@classmethod
def items(
cls, songlist_content: dict, *, skip_asserts: bool = False
) -> list[Song | SongLocalization | Difficulty | DifficultyLocalization]:
def check_song_object_format(song: dict) -> None:
try:
cls._assert_root_content(songlist_content)
except SonglistFormatError as e:
if skip_asserts:
logger.exception("Error skipped during songlist parsing:")
return []
check_type(song, dict)
check_type(song["id"], str)
raise e
if deleted := song.get("deleted"):
check_type(deleted, bool)
songs = songlist_content["songs"]
results = []
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)
for song in songs:
try:
cls._assert_song_object(song)
except SonglistFormatError as e:
if skip_asserts:
logger.exception("Error skipped during songlist parsing:")
continue
except (KeyError, TypeCheckError) as e:
raise SonglistFormatError from e
raise e
if song.get("deleted"):
continue # TODO: none, return...
# 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", {})
id_ = song["id"]
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.append(
Song(
results = []
aodis_song = AodisSong(
pack_id=song["set"],
id=id_,
added_at=datetime.fromtimestamp(song["date"], tz=timezone.utc),
idx=song.get("idx", None),
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")),
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")
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,
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),
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"),
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")),
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))
@ -143,66 +139,123 @@ class ArcaeaSonglistParser:
if title_l10n or source_l10n or has_localized_jacket:
results.append(
SongLocalization(
AodisSongLocalization(
id=id_,
lang=lang_key,
title=title_l10n,
source=source_l10n,
has_jacket=has_localized_jacket,
)
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(
Difficulty(
song_id=id_,
rating_class=rating_class,
AodisDifficulty(
songId=id_,
ratingClass=rating_class,
rating=difficulty["rating"],
is_rating_plus=difficulty.get("ratingPlus", False),
chart_designer=blank_str_to_none(
difficulty.get("chartDesigner")
isRatingPlus=difficulty.get("ratingPlus", False),
chartDesigner=blank_str_to_none(
difficulty.get("chartDesigner"),
),
jacket_designer=blank_str_to_none(
difficulty.get("jacketDesigner")
jacketDesigner=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
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,
bg=blank_str_to_none(difficulty.get("bg")),
version=blank_str_to_none(difficulty.get("version")),
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")),
is_legacy11=difficulty.get("legacy11", False),
)
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)
difficulty_title_localized.get(lang_key),
)
difficuly_artist_l10n = blank_str_to_none(
difficulty_artist_localized.get(lang_key)
difficulty_artist_localized.get(lang_key),
)
if difficuly_title_l10n:
results.append(
DifficultyLocalization(
song_id=id_,
rating_class=rating_class,
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_format_checks: bool = False,
) -> list[
AodisSong
| AodisSongLocalization
| AodisDifficulty
| AodisDifficultyLocalization
]:
try:
cls.check_root_content_format(songlist_content)
except SonglistFormatError:
if skip_format_checks:
logger.exception("Error skipped during songlist parsing:")
return []
raise
songs = songlist_content["songs"]
results = []
for song in songs:
try:
cls.check_song_object_format(song)
except SonglistFormatError:
if skip_format_checks:
logger.exception("Error skipped during songlist parsing:")
continue
raise
if song.get("deleted"):
continue
results.extend(cls.parse_song(song))
results.extend(cls.parse_difficulties(song))
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/>.
"""
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

7
noxfile.py Normal file
View File

@ -0,0 +1,7 @@
import nox
@nox.session
def tests(session: nox.Session) -> None:
session.install("pytest")
session.run("pytest", "--doctest-modules", "arcaea_apk_assets")

View File

@ -8,27 +8,28 @@ build-backend = "setuptools.build_meta"
# dynamic = ["version"]
name = "arcaea-apk-assets"
description = "Your package description goes here."
version = "0.1.0a"
version = "0.1.0a0.dev2"
requires-python = ">=3.9"
authors = [
{name = "283375", email = "log_283375@163.com"},
]
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"
Issues = "https://github.com/283375/arcaea-apk-assets/issues"
[tool.ruff.lint]
select = [
"E", # pycodestyle
"F", # Pyflakes
"UP", # pyupgrade
"B", # flake8-bugbear
"SIM", # flake8-simplify
"I", # isort
"RET", # flake8-return
"EM", # flake8-errmsg
"CPY", # flake8-copyright
"RUF", # Ruff-specific rules
]
ignore = [
"E501", # line-too-long
select = ["ALL"]
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
]

0
tests/__init__.py Normal file
View File

View File

View File

@ -0,0 +1,52 @@
# ruff: noqa: RUF001
NORMAL = {
"packs": [
{
"id": "test",
"section": "mainstory",
"is_extend_pack": False,
"is_active_extend_pack": False,
"custom_banner": True,
"small_pack_image": False,
"plus_character": -1,
"name_localized": {"en": "Test Collection 1"},
"description_localized": {
"en": "DIVINE GRACE, AWAITS YOU IN A LOST WORLD, OF SILENT SONG...",
"ja": "「光」も「対立」も全都有、「钙麻」失われた世界で、あなたを待ち受ける⋯",
"ko": "고통이 고통이라는 이유로 그 자체를 사랑하고 소유하려는 자는 없다",
"zh-Hant": "神的恩典,在一個失去的世界等你,介詞安靜的歌曲……",
"zh-Hans": "神的恩典,在一个失去的世界等你,介词安静的歌曲……",
},
},
{
"id": "test_other_properties",
"section": "archive",
"is_extend_pack": True,
"is_active_extend_pack": False,
"custom_banner": True,
"small_pack_image": True,
"plus_character": 123,
"name_localized": {"en": "Test Collection 1 - Archive"},
"description_localized": {
"en": "A FADING LIGHT, AWAITS YOU IN A PURE MEMORY WORLD, OF MUSICAL CONFLICT...",
"ja": "「消えゆく光」が、「ピュア・メモリー」に満ちた世界が、あなたを待ち受ける⋯",
"ko": "혹은 아무런 즐거움도 생기지 않는 고통을 회피하는 사람을 누가 탓할 수 있겠는가?",
"zh-Hant": "一個消色的光,在一個“純 淨 回 憶”的世界等你,介詞音樂的衝突……",
"zh-Hans": "一个消色的光,在一个“纯 净 回 忆”的世界等你,介词音乐的冲突……",
},
},
]
}
MALFORMED_WTF = {
"wow": [],
"random": {},
"parts!": "huh?",
}
MALFORMED_ROOT = {
"packs": {
"pack1": "something",
"pack2": "otherthing",
},
}

37
tests/test_packlist.py Normal file
View File

@ -0,0 +1,37 @@
import pytest
from arcaea_apk_assets.parsers.packlist import ArcaeaPacklistParser, PacklistFormatError
from tests.contents import packlist as contents
class TestPacklistParser:
def test_normal(self):
content = contents.NORMAL
ArcaeaPacklistParser.packs(content)
def test_malformed(self):
with pytest.raises(PacklistFormatError):
ArcaeaPacklistParser.items(contents.MALFORMED_WTF)
with pytest.raises(PacklistFormatError):
ArcaeaPacklistParser.items(contents.MALFORMED_ROOT)
assert (
len(
ArcaeaPacklistParser.items(
contents.MALFORMED_WTF,
skip_format_checks=True,
),
)
== 0
)
assert (
len(
ArcaeaPacklistParser.items(
contents.MALFORMED_ROOT,
skip_format_checks=True,
),
)
== 0
)