From ed4ad3416a54f71aef47df909e5ba00531aa7c45 Mon Sep 17 00:00:00 2001 From: CoolCat467 <52022020+CoolCat467@users.noreply.github.com> Date: Wed, 5 Feb 2025 22:48:06 -0600 Subject: [PATCH 1/4] Fix reported mypy issues --- pyproject.toml | 20 +++++++++--- rsrcdump/__init__.py | 6 ++-- rsrcdump/__main__.py | 20 +++++++----- rsrcdump/adf.py | 4 +-- rsrcdump/jsonio.py | 19 +++++++---- rsrcdump/packutils.py | 11 +++++-- rsrcdump/pict.py | 39 +++++++++++++---------- rsrcdump/resconverters.py | 64 +++++++++++++++++++++++++++++--------- rsrcdump/resfork.py | 20 ++++++------ rsrcdump/sndtoaiff.py | 10 ++++-- rsrcdump/structtemplate.py | 23 ++++++++------ rsrcdump/textio.py | 4 +-- 12 files changed, 162 insertions(+), 78 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6d09faf..2a9159f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,7 @@ +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + [tool.poetry] name = "rsrcdump" version = "0.1.0" @@ -13,7 +17,15 @@ python = "^3.10" [tool.poetry.scripts] rsrcdump = "rsrcdump.__main__:main" - -[build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" +[tool.mypy] +files = ["rsrcdump/",] +show_column_numbers = true +show_error_codes = true +show_traceback = true +disallow_any_decorated = true +disallow_any_unimported = true +ignore_missing_imports = true +local_partial_types = true +no_implicit_optional = true +strict = true +warn_unreachable = true diff --git a/rsrcdump/__init__.py b/rsrcdump/__init__.py index a6bfa18..670c173 100644 --- a/rsrcdump/__init__.py +++ b/rsrcdump/__init__.py @@ -7,17 +7,19 @@ from rsrcdump.resconverters import standard_converters, StructConverter, Base16Converter -def load(data_or_path: bytes | PathLike) -> ResourceFork: +def load(data_or_path: bytes | PathLike[str]) -> ResourceFork: + data: bytes if type(data_or_path) is not bytes: path = data_or_path with open(path, 'rb') as f: data = f.read() else: - data: bytes = data_or_path + data = data_or_path try: adf_entries = unpack_adf(data) adf_resfork = adf_entries[ADF_ENTRYNUM_RESOURCEFORK] + assert isinstance(adf_resfork, bytes) fork = ResourceFork.from_bytes(adf_resfork) except NotADFError: fork = ResourceFork.from_bytes(data) diff --git a/rsrcdump/__main__.py b/rsrcdump/__main__.py index 3ab6a1c..e992dcb 100644 --- a/rsrcdump/__main__.py +++ b/rsrcdump/__main__.py @@ -10,7 +10,7 @@ from rsrcdump.textio import set_global_encoding, parse_type_name from rsrcdump.resconverters import standard_converters, StructConverter, Base16Converter -def main(): +def main() -> None: description = ( "Extract resources from a Macintosh resource fork. " "https://github.com/jorio/rsrcdump" @@ -99,24 +99,26 @@ def main(): for template_arg in struct_specs: converter, restype = StructConverter.from_template_string_with_typename(template_arg) if converter: + assert restype is not None converters[restype] = converter - def load_resmap(): + def load_resmap() -> tuple[ResourceFork, dict[int, int | bytes]]: with open(inpath, 'rb') as file: data = file.read() try: adf_entries = unpack_adf(data) adf_resfork = adf_entries[ADF_ENTRYNUM_RESOURCEFORK] + assert isinstance(adf_resfork, bytes) fork = ResourceFork.from_bytes(adf_resfork) return fork, adf_entries except NotADFError: fork = ResourceFork.from_bytes(data) - return fork, [] + return fork, {} - def do_list(): + def do_list() -> int: fork, adf_entries = load_resmap() print(F"{'Type':4} {'ID':6} {'Size':8} {'Name'}") print(F"{'-'*4} {'-'*6} {'-'*8} {'-'*32}") @@ -128,7 +130,7 @@ def do_list(): return 0 - def do_extract(): + def do_extract() -> int: outpath = args.o # Generate an output path if we're not given one @@ -142,12 +144,13 @@ def do_extract(): fork, adf_entries = load_resmap() - metadata = {} + metadata: dict[str, dict[int, str]] = {} if adf_entries: metadata["adf"] = {} del adf_entries[ADF_ENTRYNUM_RESOURCEFORK] for adf_entry_num, adf_entry in adf_entries.items(): + assert isinstance(adf_entry, bytes) metadata["adf"][adf_entry_num] = base64.b16encode(adf_entry).decode("ascii") return resource_fork_to_json( @@ -159,7 +162,7 @@ def do_extract(): metadata=metadata) - def do_pack(): + def do_pack() -> int: outpath = args.o # Generate an output path if we're not given one @@ -180,7 +183,8 @@ def do_pack(): converters=converters, only_types=only_types, skip_types=skip_types, - encoding=args.encoding) + #encoding=args.encoding + ) binary_fork = fork.pack() diff --git a/rsrcdump/adf.py b/rsrcdump/adf.py index 449f3b2..f0e0afe 100644 --- a/rsrcdump/adf.py +++ b/rsrcdump/adf.py @@ -14,7 +14,7 @@ class NotADFError(ValueError): pass -def unpack_adf(adf_data: bytes) -> dict[int, bytes]: +def unpack_adf(adf_data: bytes) -> dict[int, int | bytes]: u = Unpacker(adf_data) magic, version, filler, num_entries = u.unpack(">LL16sH") @@ -30,7 +30,7 @@ def unpack_adf(adf_data: bytes) -> dict[int, bytes]: for _ in range(num_entries): entry_offsets.append(u.unpack(">LLL")) - entries = {0: filler} # Entry #0 is invalid -- use it for the filler + entries: dict[int, int | bytes] = {0: filler} # Entry #0 is invalid -- use it for the filler for entry_id, offset, length in entry_offsets: u.seek(offset) diff --git a/rsrcdump/jsonio.py b/rsrcdump/jsonio.py index 3e889c4..66c04ff 100644 --- a/rsrcdump/jsonio.py +++ b/rsrcdump/jsonio.py @@ -3,6 +3,7 @@ import base64 import os import json +from collections.abc import Mapping from rsrcdump.resconverters import ResourceConverter, Base16Converter from rsrcdump.textio import get_global_encoding, sanitize_type_name, sanitize_resource_name, parse_type_name @@ -10,11 +11,13 @@ class JSONEncoderBase16Fallback(json.JSONEncoder): - def default(self, o: Any): + __slots__ = () + + def default(self, o: bool | bytes) -> object: if isinstance(o, bytes): return base64.b16encode(o).decode('ascii') else: - return JSONEncoderBase16Fallback(self, o) + return super().default(o) def resource_fork_to_json( @@ -23,11 +26,11 @@ def resource_fork_to_json( include_types: list[bytes] = [], exclude_types: list[bytes] = [], converters: dict[bytes, ResourceConverter] = {}, - metadata: Any = None, + metadata: Any | None = None, quiet: bool = False, ) -> int: - json_blob: dict = {'_metadata': { + json_blob: dict[str, dict[str | bytes | int, int | dict[str, Any]]] = {'_metadata': { 'junk1': fork.junk_nextresmap, 'junk2': fork.junk_filerefnum, 'file_attributes': fork.file_attributes @@ -75,7 +78,7 @@ def resource_fork_to_json( obj = converter.unpack(res, fork) separate_file = bool(converter.separate_file) except BaseException as convert_exception: - errors.append(f"Failed to convert {res_type_key} #{res_id}: {convert_exception}") + errors.append(f"Failed to convert {res_type_key} #{res_id!r}: {convert_exception}") if not quiet: print("!!!", errors[-1]) wrapper['conversion_error'] = str(convert_exception) @@ -91,8 +94,12 @@ def resource_fork_to_json( else: sanitized_name = "" if sanitized_name: + if isinstance(res_id, bytes): + res_id = res_id.decode("utf-8") filename = F"{res_id}.{sanitized_name}{ext}" else: + if isinstance(res_id, bytes): + res_id = res_id.decode("utf-8") filename = F"{res_id}{ext}" wrapper['file'] = F"{res_dirname}/{filename}" with open(os.path.join(res_dirpath, filename), 'wb') as extfile: @@ -118,7 +125,7 @@ def resource_fork_to_json( def json_to_resource_fork( - json_blob: dict, + json_blob: dict[str, Any], converters: dict[bytes, ResourceConverter], only_types: list[bytes] = [], skip_types: list[bytes] = [], diff --git a/rsrcdump/packutils.py b/rsrcdump/packutils.py index c35e94a..6f9726f 100644 --- a/rsrcdump/packutils.py +++ b/rsrcdump/packutils.py @@ -4,20 +4,22 @@ import struct class Unpacker: + __slots__ = ("data", "offset") + def __init__(self, data: bytes, offset: int=0) -> None: self.data = data self.offset = offset - def unpack(self, fmt: str) -> tuple: + def unpack(self, fmt: str) -> tuple[int, ...]: record_length = struct.calcsize(fmt) fields = struct.unpack_from(fmt, self.data, self.offset) self.offset += record_length return fields - def seek(self, offset: int): + def seek(self, offset: int) -> None: self.offset = offset - def skip(self, n: int): + def skip(self, n: int) -> None: self.offset += n def read(self, size: int) -> bytes: @@ -41,7 +43,10 @@ def eof(self) -> bool: def remaining(self) -> int: return len(self.data) - self.offset + class WritePlaceholder: + __slots__ = ("stream", "fmt", "position", "committed") + def __init__(self, stream: BytesIO, fmt: str) -> None: self.stream = stream self.fmt = fmt diff --git a/rsrcdump/pict.py b/rsrcdump/pict.py index 82d17be..aa1676e 100644 --- a/rsrcdump/pict.py +++ b/rsrcdump/pict.py @@ -5,14 +5,18 @@ import struct from ctypes import ArgumentError from dataclasses import dataclass +from typing import TYPE_CHECKING from rsrcdump.packutils import Unpacker from rsrcdump.structtemplate import StructTemplate from rsrcdump.textio import get_global_encoding +if TYPE_CHECKING: + from typing_extensions import Self + class PICTError(BaseException): - pass + __slots__ = () @dataclass(frozen=True) @@ -23,26 +27,26 @@ class PICTRect: right: int @property - def width(self): + def width(self) -> int: return self.right - self.left @property - def height(self): + def height(self) -> int: return self.bottom - self.top def offset(self, dy: int = 0, dx: int = 0) -> PICTRect: return PICTRect(self.top + dy, self.left + dx, self.bottom + dy, self.right + dx) - def intersect(self, r: PICTRect): - t = max(self.top, r.top) - l = max(self.left, r.left) - b = min(self.bottom, r.bottom) - r = min(self.right, r.right) + def intersect(self, rect: PICTRect) -> Self: + t = max(self.top, rect.top) + l = max(self.left, rect.left) + b = min(self.bottom, rect.bottom) + r = min(self.right, rect.right) b = max(t, b) r = max(l, r) - return PICTRect(t, l, b, r) + return self.__class__(t, l, b, r) def __repr__(self) -> str: return f"({self.left},{self.top} {self.width}x{self.height})" @@ -330,7 +334,7 @@ class Bitmap(Xmap): frame_r: int @property - def pixelsize(self) -> int: + def pixelsize(self) -> int: # type: ignore[override] return 1 @@ -586,6 +590,7 @@ def read_pict_bits(u: Unpacker, opcode: int) -> tuple[PICTRect, bytes]: if maskrgn_bits: mask_8bit = unpack_maskrgn(maskrgn_rect, maskrgn_bits) + assert palette is not None bgra = read_pixmap_image_data(u, raster, palette) # Apply mask @@ -642,7 +647,7 @@ def get_reserved_opcode_size(k: int) -> int: return -1 -def crop_32bit(src_data: bytes, src_rect: PICTRect, dst_rect: PICTRect): +def crop_32bit(src_data: bytes, src_rect: PICTRect, dst_rect: PICTRect) -> bytes: intersection = src_rect.intersect(dst_rect) src_io = io.BytesIO(src_data) @@ -661,7 +666,7 @@ def crop_32bit(src_data: bytes, src_rect: PICTRect, dst_rect: PICTRect): return dst_io.getvalue() -def blit_32bit(src_rect: PICTRect, src_data: bytes, dst_rect: PICTRect, dst_data: bytes): +def blit_32bit(src_rect: PICTRect, src_data: bytes, dst_rect: PICTRect, dst_data: bytes) -> bytes: intersection = src_rect.intersect(dst_rect) src_dy, src_dx = intersection.top - src_rect.top, intersection.left - src_rect.left @@ -685,7 +690,7 @@ def blit_32bit(src_rect: PICTRect, src_data: bytes, dst_rect: PICTRect, dst_data return dst_io.getvalue() -def apply_8bit_mask_on_32bit_image(msk_rect: PICTRect, msk_data: bytes, dst_rect: PICTRect, dst_data: bytes): +def apply_8bit_mask_on_32bit_image(msk_rect: PICTRect, msk_data: bytes, dst_rect: PICTRect, dst_data: bytes) -> bytes: intersection = dst_rect.intersect(msk_rect) msk_dy, msk_dx = intersection.top - msk_rect.top, intersection.left - msk_rect.left @@ -785,9 +790,10 @@ def convert_pict_to_image(data: bytes) -> tuple[int, int, bytes]: if opcode not in (Op.LongComment, Op.LongText, Op.ShortComment, Op.DefHilite): print(F"!!! skipping PICT opcode {opcode_name} at offset {u.offset}") - template = opcode_templates[opcode] + template = opcode_templates[Op(opcode)] values = u.unpack(template.format) annotated = template.tag_values(values) + assert isinstance(annotated, dict) # Skip rest of variable-length records if "len" in annotated: @@ -795,9 +801,10 @@ def convert_pict_to_image(data: bytes) -> tuple[int, int, bytes]: # text = u.read(annotated["len"]).decode(get_global_encoding(), "replace") # print(F"{opcode_name} text contents: {text}") # continue - u.skip(annotated["len"]) + u.skip(int(annotated["len"])) elif "datalen" in annotated: - u.skip(annotated["datalen"] - template.record_length) + datalen = int(annotated["datalen"]) + u.skip(datalen - template.record_length) else: raise PICTError(F"unsupported PICT opcode {opcode_name}") diff --git a/rsrcdump/resconverters.py b/rsrcdump/resconverters.py index 4b3ea46..8dac0f5 100644 --- a/rsrcdump/resconverters.py +++ b/rsrcdump/resconverters.py @@ -1,5 +1,7 @@ +from __future__ import annotations + import base64 -from typing import Any, Callable +from typing import Any, Callable, TYPE_CHECKING from rsrcdump.icons import convert_4bit_icon_to_bgra, convert_8bit_icon_to_bgra, convert_1bit_icon_to_bgra from rsrcdump.packutils import Unpacker @@ -10,41 +12,50 @@ from rsrcdump.structtemplate import StructTemplate from rsrcdump.textio import get_global_encoding, parse_type_name +if TYPE_CHECKING: + from typing_extensions import Self + class ResourceConverter: """ Base class for all resource converters. """ + __slots__ = ("separate_file", "json_key") + separate_file: str - def __init__(self, separate_file: str = ""): + def __init__(self, separate_file: str = "") -> None: self.separate_file = separate_file self.json_key = "obj" def unpack(self, res: Resource, fork: ResourceFork) -> Any: return res.data - def pack(self, obj: Any): + def pack(self, obj: Any) -> bytes: raise NotImplementedError("JSON->Binary packing not implemented in " + self.__class__.__name__) - + class Base16Converter(ResourceConverter): """ Converts arbitrary data to base-16. """ - def __init__(self): + __slots__ = () + + def __init__(self) -> None: super().__init__() self.json_key = "data" - - def unpack(self, res: Resource, fork: ResourceFork) -> Any: + + def unpack(self, res: Resource, fork: ResourceFork) -> str: return base64.b16encode(res.data).decode('ascii') - def pack(self, obj: Any) -> bytes: + def pack(self, obj: str) -> bytes: assert isinstance(obj, str) return base64.b16decode(obj) class StructConverter(ResourceConverter): - @staticmethod - def from_template_string_with_typename(template_arg: str): + __slots__ = ("template",) + + @classmethod + def from_template_string_with_typename(cls, template_arg: str) -> tuple[Self | None, bytes | None]: template_arg = template_arg.strip() if not template_arg or template_arg.startswith("//"): # skip blank lines return None, None @@ -55,9 +66,9 @@ def from_template_string_with_typename(template_arg: str): restype = parse_type_name(split[0]) formatstr = split[1] template = StructTemplate.from_template_string(formatstr) - return StructConverter(template), restype + return cls(template), restype - def __init__(self, template: StructTemplate): + def __init__(self, template: StructTemplate) -> None: super().__init__() self.template = template @@ -93,6 +104,8 @@ def pack(self, obj: Any) -> bytes: class SingleStringConverter(ResourceConverter): """ Converts STR to a string. """ + __slots__ = () + def unpack(self, res: Resource, fork: ResourceFork) -> str: u = Unpacker(res.data) result = u.unpack_pstr(get_global_encoding(), 'replace') @@ -102,6 +115,8 @@ def unpack(self, res: Resource, fork: ResourceFork) -> str: class StringListConverter(ResourceConverter): """ Converts STR# to a list of strings. """ + __slots__ = () + def unpack(self, res: Resource, fork: ResourceFork) -> list[str]: u = Unpacker(res.data) str_list = [] @@ -115,6 +130,8 @@ def unpack(self, res: Resource, fork: ResourceFork) -> list[str]: class TextConverter(ResourceConverter): """ Converts TEXT to a string. """ + __slots__ = () + def unpack(self, res: Resource, fork: ResourceFork) -> str: return res.data.decode(get_global_encoding(), 'replace') @@ -122,6 +139,8 @@ def unpack(self, res: Resource, fork: ResourceFork) -> str: class SoundToAiffConverter(ResourceConverter): """ Converts snd to an AIFF-C file. """ + __slots__ = () + def __init__(self) -> None: super().__init__(separate_file='.aiff') @@ -132,6 +151,8 @@ def unpack(self, res: Resource, fork: ResourceFork) -> bytes: class PictConverter(ResourceConverter): """ Converts a raster PICT to a PNG file. """ + __slots__ = () + def __init__(self) -> None: super().__init__(separate_file='.png') @@ -143,6 +164,8 @@ def unpack(self, res: Resource, fork: ResourceFork) -> bytes: class CicnConverter(ResourceConverter): """ Converts cicn (arbitrary-sized color icon with embedded palette) to a PNG file. """ + __slots__ = () + def __init__(self) -> None: super().__init__(separate_file='.png') @@ -154,6 +177,8 @@ def unpack(self, res: Resource, fork: ResourceFork) -> bytes: class PpatConverter(ResourceConverter): """ Converts ppat to a PNG file. """ + __slots__ = () + def __init__(self) -> None: super().__init__(separate_file='.png') @@ -164,6 +189,9 @@ def unpack(self, res: Resource, fork: ResourceFork) -> bytes: class SicnConverter(ResourceConverter): """ Converts sicn to a PNG file. """ + + __slots__ = () + def __init__(self) -> None: super().__init__(separate_file='.png') @@ -175,7 +203,9 @@ def unpack(self, res: Resource, fork: ResourceFork) -> bytes: class TemplateConverter(ResourceConverter): """ Parses TMPL resources. """ - def unpack(self, res: Resource, fork: ResourceFork) -> list[dict[str, str | bytes]]: + __slots__ = () + + def unpack(self, res: Resource, fork: ResourceFork) -> list[dict[str, str]]: u = Unpacker(res.data) fields = [] while not u.eof(): @@ -188,7 +218,9 @@ def unpack(self, res: Resource, fork: ResourceFork) -> list[dict[str, str | byte class FileDumper(ResourceConverter): preprocess: Callable[[bytes], bytes] | None - def __init__(self, extension: str, preprocess: Callable[[bytes], bytes]=None) -> None: + __slots__ = ("preprocess",) + + def __init__(self, extension: str, preprocess: Callable[[bytes], bytes] | None = None) -> None: super().__init__(extension) self.preprocess = preprocess @@ -207,9 +239,11 @@ class IconConverter(ResourceConverter): (icl8, ics8, icl4, ics4, ICN#, ics#). """ + __slots__ = () + def __init__(self) -> None: super().__init__(separate_file='.png') - + def unpack(self, res: Resource, fork: ResourceFork) -> bytes: if res.type in [b'icl8', b'icl4', b'ICN#']: width, height = 32, 32 diff --git a/rsrcdump/resfork.py b/rsrcdump/resfork.py index 0143e4b..5449cf7 100644 --- a/rsrcdump/resfork.py +++ b/rsrcdump/resfork.py @@ -1,6 +1,7 @@ from dataclasses import dataclass, field from struct import unpack_from, pack, calcsize from io import BytesIO +from typing import cast from rsrcdump.packutils import Unpacker, WritePlaceholder from rsrcdump.textio import get_global_encoding, sanitize_type_name, parse_type_name @@ -10,7 +11,7 @@ class InvalidResourceFork(ValueError): - pass + __slots__ = () @dataclass @@ -43,17 +44,17 @@ def desc(self) -> str: return f"{sanitize_type_name(self.type)}#{self.num}" @property - def type_str(self, errors='replace') -> str: + def type_str(self, errors: str='replace') -> str: return self.type.decode(get_global_encoding(), errors) @property - def name_str(self, errors='replace') -> str: + def name_str(self, errors: str='replace') -> str: return self.name.decode(get_global_encoding(), errors) @dataclass class ResourceFork: - tree: dict[ResType, dict[int, Resource]] = field(default_factory=dict) + tree: dict[ResType, dict[str | bytes | int, Resource]] = field(default_factory=dict) "Map of all resources in the resource fork." junk_nextresmap: int = 0 @@ -65,7 +66,7 @@ class ResourceFork: file_attributes: int = 0 "Finder file attributes." - def ordered_flat_list(self): + def ordered_flat_list(self) -> list[Resource]: flat = [] for res_type in self.tree: for res_id in self.tree[res_type]: @@ -82,7 +83,7 @@ def __repr__(self) -> str: s += ")" return s - def __getitem__(self, key: str | bytes) -> dict[int, Resource]: + def __getitem__(self, key: str | bytes) -> dict[str | bytes | int, Resource]: if type(key) is str: key = parse_type_name(key) if type(key) is not bytes: @@ -117,13 +118,13 @@ def from_bytes(data: bytes) -> 'ResourceFork': u_types = Unpacker(u_map.data[typelist_offset_in_map:]) u_names = Unpacker(u_map.data[namelist_offset_in_map:]) - order = [] + order: list[tuple[ResType, int, int]] = [] for i in range(num_types): - res_type, res_count, reslist_offset = u_map.unpack(">4sHH") + res_type, res_count, reslist_offset = cast(tuple[ResType, int, int], u_map.unpack(">4sHH")) res_count += 1 - assert res_type not in fork.tree, F"{res_type} already seen" + assert res_type not in fork.tree, F"{res_type!r} already seen" fork.tree[res_type] = {} u_types.seek(reslist_offset) @@ -217,6 +218,7 @@ def pack(self) -> bytes: for res_type in self.tree: wp_res_list_offsets[res_type].commit(stream.tell() - res_list_offset) for res_id in self.tree[res_type]: + assert isinstance(res_id, int) res = self.tree[res_type][res_id] # Write resource ID diff --git a/rsrcdump/sndtoaiff.py b/rsrcdump/sndtoaiff.py index 7534bad..4fdf014 100644 --- a/rsrcdump/sndtoaiff.py +++ b/rsrcdump/sndtoaiff.py @@ -1,4 +1,4 @@ -from typing import Optional, Final +from typing import Optional, Final, cast from types import TracebackType import math @@ -47,6 +47,8 @@ def calcsize(self, num_channels: int, num_packets: int) -> int: } class IFFChunkWriter: + __slots__ = ("stream", "length_placeholder", "start_of_chunk") + def __init__(self, stream: io.BytesIO, chunk_type: bytes) -> None: assert type(chunk_type) is bytes assert len(chunk_type) == 4 @@ -142,12 +144,16 @@ def convert_snd_to_aiff(data: bytes, name: str) -> bytes: zero, union_int, sample_rate_fixed, loop_start, loop_end, encoding, base_note = u.unpack(">iiLLLBb") assert 0 == zero + compression_type: bytes + num_packets: int + num_channels: int + if encoding == kSampledSoundEncoding_stdSH: compression_type = b'raw ' num_channels = 1 num_packets = union_int elif encoding == kSampledSoundEncoding_cmpSH: - num_packets, compression_type = u.unpack(">i14x4s20x") + num_packets, compression_type = cast(tuple[int, bytes], u.unpack(">i14x4s20x")) num_channels = union_int if compression_type == b'\0\0\0\0': compression_type = default_compression_type diff --git a/rsrcdump/structtemplate.py b/rsrcdump/structtemplate.py index 41bd4f0..9bf9cad 100644 --- a/rsrcdump/structtemplate.py +++ b/rsrcdump/structtemplate.py @@ -1,6 +1,11 @@ +from __future__ import annotations + import base64 import struct -from typing import Any, Generator +from typing import Any, Generator, TYPE_CHECKING + +if TYPE_CHECKING: + from typing_extensions import Self class StructTemplate: @@ -10,8 +15,8 @@ class StructTemplate: field_names: list[str] is_list: bool - @staticmethod - def from_template_string(template): + @classmethod + def from_template_string(cls, template: str) -> Self: split = template.split(":", 2) formatstr = split.pop(0) @@ -22,7 +27,7 @@ def from_template_string(template): fieldnames = [] assert formatstr - return StructTemplate(formatstr, fieldnames) + return cls(formatstr, fieldnames) @staticmethod def split_struct_format_fields(fmt: str) -> Generator[str, None, None]: @@ -53,7 +58,7 @@ def split_struct_format_fields(fmt: str) -> Generator[str, None, None]: else: raise ValueError(f"Unsupported struct format character '{c}'") - def __init__(self, fmt: str, user_field_names: list[str]): + def __init__(self, fmt: str, user_field_names: list[str]) -> None: if not fmt.startswith(("!", ">", "<", "@", "=")): # struct.unpack needs to know what endianness to work in; default to big-endian fmt = ">" + fmt @@ -90,7 +95,7 @@ def unpack_record(self, data: bytes, offset: int) -> Any: values = struct.unpack_from(self.format, data, offset) return self.tag_values(values) - def tag_values(self, values: tuple): + def tag_values(self, values: tuple[str | int, ...]) -> dict[str, str | int] | str | int | tuple[str | int, ...]: if self.field_names: # We have some field names: return name-tagged values in a dict assert len(self.field_names) == len(values) @@ -108,7 +113,7 @@ def tag_values(self, values: tuple): # Multiple-element structure but no field names: return the tuple return values - def pack(self, obj: Any) -> bytes: + def pack(self, obj: list[str] | str) -> bytes: if not self.is_list: return self.pack_record(obj) else: @@ -118,8 +123,8 @@ def pack(self, obj: Any) -> bytes: buf += self.pack_record(item) return buf - def pack_record(self, json_obj: Any) -> bytes: - def process_json_field(_field_format, _field_value): + def pack_record(self, json_obj: list[str] | dict[str, str] | str) -> bytes: + def process_json_field(_field_format: str, _field_value: str | bytes) -> str | bytes: if _field_format.endswith("s"): return base64.b16decode(_field_value) else: diff --git a/rsrcdump/textio.py b/rsrcdump/textio.py index f4d5267..2ef5774 100644 --- a/rsrcdump/textio.py +++ b/rsrcdump/textio.py @@ -7,7 +7,7 @@ def get_global_encoding() -> str: return GLOBAL_ENCODING -def set_global_encoding(encoding: str) -> str: +def set_global_encoding(encoding: str) -> None: global GLOBAL_ENCODING GLOBAL_ENCODING = encoding @@ -29,7 +29,7 @@ def parse_type_name(sane_name: str) -> bytes: return restype -def sanitize_resource_name(name: str | bytes) -> str: +def sanitize_resource_name(name: str) -> str: sanitized = "" for c in name: if c in 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-': From e907f639196aefa7a50e89997ba1e4d8c7c1be4c Mon Sep 17 00:00:00 2001 From: CoolCat467 <52022020+CoolCat467@users.noreply.github.com> Date: Sun, 9 Feb 2025 17:15:53 -0600 Subject: [PATCH 2/4] Fixes from review comments Co-authored-by: Iliyas Jorio --- rsrcdump/__main__.py | 2 +- rsrcdump/adf.py | 8 ++++---- rsrcdump/jsonio.py | 18 ++++++++++++------ rsrcdump/packutils.py | 2 +- rsrcdump/resfork.py | 4 ++-- rsrcdump/structtemplate.py | 17 +++++++++++------ 6 files changed, 31 insertions(+), 20 deletions(-) diff --git a/rsrcdump/__main__.py b/rsrcdump/__main__.py index e992dcb..7d5969e 100644 --- a/rsrcdump/__main__.py +++ b/rsrcdump/__main__.py @@ -103,7 +103,7 @@ def main() -> None: converters[restype] = converter - def load_resmap() -> tuple[ResourceFork, dict[int, int | bytes]]: + def load_resmap() -> tuple[ResourceFork, dict[int, bytes]]: with open(inpath, 'rb') as file: data = file.read() diff --git a/rsrcdump/adf.py b/rsrcdump/adf.py index f0e0afe..387cc70 100644 --- a/rsrcdump/adf.py +++ b/rsrcdump/adf.py @@ -1,4 +1,4 @@ -from typing import Final +from typing import Final, cast from io import BytesIO from struct import pack @@ -14,10 +14,10 @@ class NotADFError(ValueError): pass -def unpack_adf(adf_data: bytes) -> dict[int, int | bytes]: +def unpack_adf(adf_data: bytes) -> dict[int, bytes]: u = Unpacker(adf_data) - magic, version, filler, num_entries = u.unpack(">LL16sH") + magic, version, filler, num_entries = cast(tuple[int, int, bytes, int], u.unpack(">LL16sH")) if ADF_MAGIC != magic: raise NotADFError("AppleDouble magic number not found") @@ -30,7 +30,7 @@ def unpack_adf(adf_data: bytes) -> dict[int, int | bytes]: for _ in range(num_entries): entry_offsets.append(u.unpack(">LLL")) - entries: dict[int, int | bytes] = {0: filler} # Entry #0 is invalid -- use it for the filler + entries: dict[int, bytes] = {0: filler} # Entry #0 is invalid -- use it for the filler for entry_id, offset, length in entry_offsets: u.seek(offset) diff --git a/rsrcdump/jsonio.py b/rsrcdump/jsonio.py index 66c04ff..d3123a5 100644 --- a/rsrcdump/jsonio.py +++ b/rsrcdump/jsonio.py @@ -30,7 +30,7 @@ def resource_fork_to_json( quiet: bool = False, ) -> int: - json_blob: dict[str, dict[str | bytes | int, int | dict[str, Any]]] = {'_metadata': { + json_blob: dict[str, Any] = {'_metadata': { 'junk1': fork.junk_nextresmap, 'junk2': fork.junk_filerefnum, 'file_attributes': fork.file_attributes @@ -57,6 +57,7 @@ def resource_fork_to_json( res_dirpath = os.path.join(outpath + "_resources", res_dirname) for res_id, res in res_dir.items(): + assert isinstance(res_id, (int, bytes)) if not quiet: print(F"{res.type_str:4} {res.num:6} {len(res.data):8} {res.name_str}") @@ -94,12 +95,18 @@ def resource_fork_to_json( else: sanitized_name = "" if sanitized_name: - if isinstance(res_id, bytes): - res_id = res_id.decode("utf-8") + # Subclass of "int" and "bytes" cannot exist: would + # have incompatible method signatures + # Statement is unreachable + ##if isinstance(res_id, bytes): + ## res_id = res_id.decode("utf-8") filename = F"{res_id}.{sanitized_name}{ext}" else: - if isinstance(res_id, bytes): - res_id = res_id.decode("utf-8") + # Subclass of "int" and "bytes" cannot exist: would + # have incompatible method signatures + # Statement is unreachable + ##if isinstance(res_id, bytes): + ## res_id = res_id.decode("utf-8") filename = F"{res_id}{ext}" wrapper['file'] = F"{res_dirname}/{filename}" with open(os.path.join(res_dirpath, filename), 'wb') as extfile: @@ -176,4 +183,3 @@ def json_to_resource_fork( fork.tree[res_type][res_num] = res return fork - diff --git a/rsrcdump/packutils.py b/rsrcdump/packutils.py index 6f9726f..745fd17 100644 --- a/rsrcdump/packutils.py +++ b/rsrcdump/packutils.py @@ -10,7 +10,7 @@ def __init__(self, data: bytes, offset: int=0) -> None: self.data = data self.offset = offset - def unpack(self, fmt: str) -> tuple[int, ...]: + def unpack(self, fmt: str) -> tuple[Any, ...]: record_length = struct.calcsize(fmt) fields = struct.unpack_from(fmt, self.data, self.offset) self.offset += record_length diff --git a/rsrcdump/resfork.py b/rsrcdump/resfork.py index 5449cf7..52c841c 100644 --- a/rsrcdump/resfork.py +++ b/rsrcdump/resfork.py @@ -54,7 +54,7 @@ def name_str(self, errors: str='replace') -> str: @dataclass class ResourceFork: - tree: dict[ResType, dict[str | bytes | int, Resource]] = field(default_factory=dict) + tree: dict[ResType, dict[int, Resource]] = field(default_factory=dict) "Map of all resources in the resource fork." junk_nextresmap: int = 0 @@ -83,7 +83,7 @@ def __repr__(self) -> str: s += ")" return s - def __getitem__(self, key: str | bytes) -> dict[str | bytes | int, Resource]: + def __getitem__(self, key: str | bytes) -> dict[int, Resource]: if type(key) is str: key = parse_type_name(key) if type(key) is not bytes: diff --git a/rsrcdump/structtemplate.py b/rsrcdump/structtemplate.py index 9bf9cad..d23516f 100644 --- a/rsrcdump/structtemplate.py +++ b/rsrcdump/structtemplate.py @@ -2,10 +2,14 @@ import base64 import struct -from typing import Any, Generator, TYPE_CHECKING +from typing import Any, TYPE_CHECKING, TypeVar +from collections.abc import Generator if TYPE_CHECKING: - from typing_extensions import Self + from typing_extensions import Self, TypeAlias + +T = TypeVar("T") +JSONScalar: TypeAlias = str | bytes | int | float class StructTemplate: @@ -95,7 +99,7 @@ def unpack_record(self, data: bytes, offset: int) -> Any: values = struct.unpack_from(self.format, data, offset) return self.tag_values(values) - def tag_values(self, values: tuple[str | int, ...]) -> dict[str, str | int] | str | int | tuple[str | int, ...]: + def tag_values(self, values: tuple[T, ...]) -> dict[str, T] | T | tuple[T, ...]: if self.field_names: # We have some field names: return name-tagged values in a dict assert len(self.field_names) == len(values) @@ -113,7 +117,7 @@ def tag_values(self, values: tuple[str | int, ...]) -> dict[str, str | int] | st # Multiple-element structure but no field names: return the tuple return values - def pack(self, obj: list[str] | str) -> bytes: + def pack(self, obj: list[JSONScalar] | dict[str, JSONScalar] | JSONScalar) -> bytes: if not self.is_list: return self.pack_record(obj) else: @@ -123,9 +127,10 @@ def pack(self, obj: list[str] | str) -> bytes: buf += self.pack_record(item) return buf - def pack_record(self, json_obj: list[str] | dict[str, str] | str) -> bytes: - def process_json_field(_field_format: str, _field_value: str | bytes) -> str | bytes: + def pack_record(self, json_obj: list[JSONScalar] | dict[str, JSONScalar] | JSONScalar) -> bytes: + def process_json_field(_field_format: str, _field_value: JSONScalar) -> JSONScalar: if _field_format.endswith("s"): + assert isinstance(_field_value, (str, bytes)) return base64.b16decode(_field_value) else: return _field_value From 0e63227e903b8113616806d1cb1b7573831d063e Mon Sep 17 00:00:00 2001 From: CoolCat467 <52022020+CoolCat467@users.noreply.github.com> Date: Mon, 10 Feb 2025 16:33:54 -0600 Subject: [PATCH 3/4] More fixes from review comments Co-authored-by: Iliyas Jorio # --- rsrcdump/jsonio.py | 11 ----------- rsrcdump/structtemplate.py | 7 ++++--- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/rsrcdump/jsonio.py b/rsrcdump/jsonio.py index d3123a5..618e5b6 100644 --- a/rsrcdump/jsonio.py +++ b/rsrcdump/jsonio.py @@ -57,7 +57,6 @@ def resource_fork_to_json( res_dirpath = os.path.join(outpath + "_resources", res_dirname) for res_id, res in res_dir.items(): - assert isinstance(res_id, (int, bytes)) if not quiet: print(F"{res.type_str:4} {res.num:6} {len(res.data):8} {res.name_str}") @@ -95,18 +94,8 @@ def resource_fork_to_json( else: sanitized_name = "" if sanitized_name: - # Subclass of "int" and "bytes" cannot exist: would - # have incompatible method signatures - # Statement is unreachable - ##if isinstance(res_id, bytes): - ## res_id = res_id.decode("utf-8") filename = F"{res_id}.{sanitized_name}{ext}" else: - # Subclass of "int" and "bytes" cannot exist: would - # have incompatible method signatures - # Statement is unreachable - ##if isinstance(res_id, bytes): - ## res_id = res_id.decode("utf-8") filename = F"{res_id}{ext}" wrapper['file'] = F"{res_dirname}/{filename}" with open(os.path.join(res_dirpath, filename), 'wb') as extfile: diff --git a/rsrcdump/structtemplate.py b/rsrcdump/structtemplate.py index d23516f..ef41030 100644 --- a/rsrcdump/structtemplate.py +++ b/rsrcdump/structtemplate.py @@ -9,7 +9,8 @@ from typing_extensions import Self, TypeAlias T = TypeVar("T") -JSONScalar: TypeAlias = str | bytes | int | float +JSONScalar: TypeAlias = str | bytes | int | float | bool +JSONPackable: TypeAlias = list[JSONScalar] | dict[str, JSONScalar] | JSONScalar class StructTemplate: @@ -117,7 +118,7 @@ def tag_values(self, values: tuple[T, ...]) -> dict[str, T] | T | tuple[T, ...]: # Multiple-element structure but no field names: return the tuple return values - def pack(self, obj: list[JSONScalar] | dict[str, JSONScalar] | JSONScalar) -> bytes: + def pack(self, obj: JSONPackable) -> bytes: if not self.is_list: return self.pack_record(obj) else: @@ -127,7 +128,7 @@ def pack(self, obj: list[JSONScalar] | dict[str, JSONScalar] | JSONScalar) -> by buf += self.pack_record(item) return buf - def pack_record(self, json_obj: list[JSONScalar] | dict[str, JSONScalar] | JSONScalar) -> bytes: + def pack_record(self, json_obj: JSONPackable) -> bytes: def process_json_field(_field_format: str, _field_value: JSONScalar) -> JSONScalar: if _field_format.endswith("s"): assert isinstance(_field_value, (str, bytes)) From a515832d64bdce9e8b233c13b50246a6cdaa0558 Mon Sep 17 00:00:00 2001 From: CoolCat467 <52022020+CoolCat467@users.noreply.github.com> Date: Mon, 10 Feb 2025 16:42:52 -0600 Subject: [PATCH 4/4] Add `py.typed` so mypy knows about typing if used as a module --- pyproject.toml | 3 +++ rsrcdump/py.typed | 0 2 files changed, 3 insertions(+) create mode 100644 rsrcdump/py.typed diff --git a/pyproject.toml b/pyproject.toml index 2a9159f..3e5625d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,9 @@ description = "Extract and convert Mac resource forks" authors = ["Iliyas Jorio "] license = "MIT" readme = "README.md" +classifiers = [ + "Typing :: Typed", +] [tool.poetry.dependencies] python = "^3.10" diff --git a/rsrcdump/py.typed b/rsrcdump/py.typed new file mode 100644 index 0000000..e69de29