From 63d2fc4f42b03ea3494942ef4cdbaa435ee40fec Mon Sep 17 00:00:00 2001 From: relikd Date: Thu, 30 Sep 2021 01:31:27 +0200 Subject: [PATCH] types! --- README.md | 11 +-- icnsutil/ArgbImage.py | 49 +++++++------ icnsutil/IcnsFile.py | 104 +++++++++++++++------------ icnsutil/IcnsType.py | 154 +++++++++++++++++++++------------------- icnsutil/PackBytes.py | 17 +++-- icnsutil/RawData.py | 37 +++++----- icnsutil/cli.py | 27 +++---- setup.py | 2 +- tests/format-support.py | 12 ++-- tests/test_icnsutil.py | 25 ++++--- 10 files changed, 240 insertions(+), 198 deletions(-) diff --git a/README.md b/README.md index 8cdce7e..6ad98bf 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ Here are two tools to open icns files directly in your browser. Both tools can b - The [inspector] shows the structure of an icns file (useful to understand byte-unpacking in ARGB and 24-bit RGB files). - The [viewer] displays icons in ARGB or 24-bit RGB file format. -[inspector]: https://relikd.github.io/icnsutil/html/inspector.html [viewer]: https://relikd.github.io/icnsutil/html/viewer.html +[inspector]: https://relikd.github.io/icnsutil/html/inspector.html +[viewer]: https://relikd.github.io/icnsutil/html/viewer.html ## Usage @@ -29,16 +30,16 @@ positional arguments: ```sh # extract -./cli.py e ExistingIcon.icns -o ./outdir/ +icnsutil e ExistingIcon.icns -o ./outdir/ # compose -./cli.py c NewIcon.icns 16x16.png 16x16@2x.png *.jp2 +icnsutil c NewIcon.icns 16x16.png 16x16@2x.png *.jp2 # print -./cli.py p ExistingIcon.icns +icnsutil p ExistingIcon.icns # verify valid format -./cli.py t ExistingIcon.icns +icnsutil t ExistingIcon.icns ``` diff --git a/icnsutil/ArgbImage.py b/icnsutil/ArgbImage.py index 8f15088..b6cff42 100644 --- a/icnsutil/ArgbImage.py +++ b/icnsutil/ArgbImage.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +from typing import Union, Iterator, Optional from . import IcnsType, PackBytes try: from PIL import Image @@ -11,7 +12,8 @@ class ArgbImage: __slots__ = ['a', 'r', 'g', 'b', 'size', 'channels'] @classmethod - def from_mono(cls, data, iType): + def from_mono(cls, data: bytes, iType: IcnsType.Media) -> 'ArgbImage': + ''' Load monochrome 1-bit image with or without mask. ''' assert(iType.bits == 1) img = [] for byte in data: @@ -28,11 +30,15 @@ class ArgbImage: self.r, self.g, self.b = img, img, img return self - def __init__(self, *, data=None, file=None, mask=None): + def __init__(self, *, data: Optional[bytes] = None, + file: Optional[str] = None, + mask: Union[bytes, str, None] = None) -> None: ''' Provide either a filename or raw binary data. - mask : Optional, may be either binary data or filename ''' + self.size = (0, 0) + self.channels = 0 if file: self.load_file(file) elif data: @@ -40,12 +46,12 @@ class ArgbImage: else: raise AttributeError('Neither data nor file provided.') if mask: - if type(mask) == bytes: + if isinstance(mask, bytes): self.load_mask(data=mask) else: self.load_mask(file=mask) - def load_file(self, fname): + def load_file(self, fname: str) -> None: with open(fname, 'rb') as fp: if fp.read(4) == b'\x89PNG': self._load_png(fname) @@ -60,49 +66,50 @@ class ArgbImage: tmp = e # ignore previous exception to create a new one raise type(tmp)('{} File: "{}"'.format(str(tmp), fname)) - def load_data(self, data): + def load_data(self, data: bytes) -> None: ''' Has support for ARGB and RGB-channels files. ''' is_argb = data[:4] == b'ARGB' if is_argb or data[:4] == b'\x00\x00\x00\x00': data = data[4:] # remove ARGB and it32 header - data = PackBytes.unpack(data) - iType = IcnsType.match_maxsize(len(data), 'argb' if is_argb else 'rgb') - if not iType: - raise ValueError('No (A)RGB image data. Could not determine size.') + idat = PackBytes.unpack(data) + iType = IcnsType.match_maxsize(len(idat), 'argb' if is_argb else 'rgb') self.size = iType.size - self.channels = iType.channels - self.a, self.r, self.g, self.b = iType.split_channels(data) + self.channels = iType.channels or 0 + self.a, self.r, self.g, self.b = iType.split_channels(idat) - def load_mask(self, *, file=None, data=None): + def load_mask(self, *, file: Optional[str] = None, + data: Optional[bytes] = None) -> None: ''' Data must be uncompressed and same length as a single channel! ''' if file: with open(file, 'rb') as fp: data = fp.read() + else: + assert(isinstance(data, bytes)) if not data: raise AttributeError('Neither data nor file provided.') assert(len(data) == len(self.r)) - self.a = data + self.a = list(data) - def mask_data(self, bits=8, *, compress=False): + def mask_data(self, bits: int = 8, *, compress: bool = False) -> bytes: if bits == 8: # default for rgb and argb return PackBytes.pack(self.a) if compress else bytes(self.a) return bytes(PackBytes.msb_stream(self.a, bits=bits)) - def rgb_data(self, *, compress=True): + def rgb_data(self, *, compress: bool = True) -> bytes: return b''.join(self._raw_rgb_channels(compress=compress)) - def argb_data(self, *, compress=True): + def argb_data(self, *, compress: bool = True) -> bytes: return b'ARGB' + self.mask_data(compress=compress) + \ b''.join(self._raw_rgb_channels(compress=compress)) - def _raw_rgb_channels(self, *, compress=True): + def _raw_rgb_channels(self, *, compress: bool = True) -> Iterator[bytes]: for x in (self.r, self.g, self.b): - yield PackBytes.pack(x) if compress else bytes(x) + yield (PackBytes.pack(x) if compress else bytes(x)) - def _load_png(self, fname): + def _load_png(self, fname: str) -> None: if not PIL_ENABLED: raise ImportError('Install Pillow to support PNG conversion.') img = Image.open(fname, mode='r') @@ -126,7 +133,7 @@ class ArgbImage: self.g.append(g) self.b.append(b) - def write_png(self, fname): + def write_png(self, fname: str) -> None: if not PIL_ENABLED: raise ImportError('Install Pillow to support PNG conversion.') img = Image.new(mode='RGBA', size=self.size) @@ -138,7 +145,7 @@ class ArgbImage: (x, y), (self.r[i], self.g[i], self.b[i], self.a[i])) img.save(fname) - def __repr__(self): + def __repr__(self) -> str: typ = ['', 'Mono', 'Mono with Mask', 'RGB', 'RGBA'][self.channels] return '<{}: {}x{} {}>'.format( type(self).__name__, self.size[0], self.size[1], typ) diff --git a/icnsutil/IcnsFile.py b/icnsutil/IcnsFile.py index e640104..c2249da 100644 --- a/icnsutil/IcnsFile.py +++ b/icnsutil/IcnsFile.py @@ -2,13 +2,14 @@ import os # path, makedirs, remove import struct # unpack float in _description() from sys import stderr +from typing import Iterator, Iterable, Tuple, Optional, List, Dict, Union from . import RawData, IcnsType from .ArgbImage import ArgbImage class IcnsFile: @staticmethod - def verify(fname): + def verify(fname: str) -> Iterator[str]: ''' Yields an error message for each issue. You can check for validity with `is_invalid = any(obj.verify())` @@ -22,7 +23,7 @@ class IcnsFile: try: iType = IcnsType.get(key) except NotImplementedError: - yield 'Unsupported icns type: {}'.format(key) + yield 'Unsupported icns type: ' + str(key) continue ext = RawData.determine_file_ext(data) @@ -32,7 +33,7 @@ class IcnsFile: # Check whether stored type is an expected file format if not (iType.is_type(ext) if ext else iType.is_binary()): yield 'Unexpected type for key {}: {} != {}'.format( - key, ext or 'binary', iType.types) + str(key), ext or 'binary', iType.types) if ext in ['png', 'jp2', 'icns', 'plist']: continue @@ -40,16 +41,16 @@ class IcnsFile: # Check whether uncompressed size is equal to expected maxsize if key == 'it32' and data[:4] != b'\x00\x00\x00\x00': # TODO: check whether other it32 headers exist - yield 'Unexpected it32 data header: {}'.format(data[:4]) - data = iType.decompress(data, ext) # ignores non-compressable + yield 'Unexpected it32 data header: ' + str(data[:4]) + udata = iType.decompress(data, ext) or data # Check expected uncompressed maxsize - if iType.maxsize and len(data) != iType.maxsize: + if iType.maxsize and len(udata) != iType.maxsize: yield 'Invalid data length for {}: {} != {}'.format( - key, len(data), iType.maxsize) + str(key), len(udata), iType.maxsize) # if file is not an icns file except RawData.ParserError as e: - yield e + yield str(e) return # Check total size after enum. Enum may raise exception and break early @@ -76,12 +77,14 @@ class IcnsFile: x, y) @staticmethod - def description(fname, *, verbose=False, indent=0): + def description(fname: str, *, verbose: bool = False, indent: int = 0) -> \ + str: return IcnsFile._description( RawData.parse_icns_file(fname), verbose=verbose, indent=indent) @staticmethod - def _description(enumerator, *, verbose=False, indent=0): + def _description(enumerator: Iterable[Tuple[IcnsType.Media.KeyT, bytes]], + *, verbose: bool = False, indent: int = 0) -> str: ''' Expects an enumerator with (key, size, data) ''' txt = '' offset = 8 # already with icns header @@ -89,7 +92,7 @@ class IcnsFile: for key, data in enumerator: size = len(data) txt += os.linesep + ' ' * indent - txt += '{}: {} bytes'.format(key, size) + txt += '{}: {} bytes'.format(str(key), size) if verbose: txt += ', offset: {}'.format(offset) offset += size + 8 @@ -112,9 +115,9 @@ class IcnsFile: except RawData.ParserError as e: return ' ' * indent + str(e) + os.linesep - def __init__(self, file=None): + def __init__(self, file: str = None) -> None: ''' Read .icns file and load bundled media files into memory. ''' - self.media = {} + self.media = {} # type: Dict[IcnsType.Media.KeyT, bytes] self.infile = file if not file: # create empty image return @@ -124,29 +127,31 @@ class IcnsFile: IcnsType.get(key) except NotImplementedError: print('Warning: unknown media type: {}, {} bytes, "{}"'.format( - key, len(data), file), file=stderr) + str(key), len(data), file), file=stderr) - def add_media(self, key=None, *, file=None, data=None, force=False): + def add_media(self, key: Optional[IcnsType.Media.KeyT] = None, *, + file: Optional[str] = None, data: Optional[bytes] = None, + force: bool = False) -> None: ''' If you provide both, data and file, data takes precedence. However, the filename is still used for type-guessing. - Declare retina images with suffix "@2x.png". - Declare icns file with suffix "-dark", "-template", or "-selected" ''' - assert(not key or len(key) == 4) # did you miss file= or data=? if file and not data: with open(file, 'rb') as fp: data = fp.read() - + if not data: + raise AttributeError('Did you miss file= or data= attribute?') if not key: # Determine ICNS type key = IcnsType.guess(data, file).key # Check if type is unique if not force and key in self.media.keys(): - raise KeyError( - 'Image with identical key "{}". File: {}'.format(key, file)) + raise KeyError('Image with identical key "{}". File: {}'.format( + str(key), file)) self.media[key] = data - def write(self, fname, *, toc=True): + def write(self, fname: str, *, toc: bool = True) -> None: ''' Create a new ICNS file from stored media. ''' # Rebuild TOC to ensure soundness order = self._make_toc(enabled=toc) @@ -157,8 +162,11 @@ class IcnsFile: for key in order: RawData.icns_header_write_data(fp, key, self.media[key]) - def export(self, outdir=None, *, allowed_ext=None, key_suffix=False, - convert_png=False, decompress=False, recursive=False): + def export(self, outdir: Optional[str] = None, *, + allowed_ext: str = '*', key_suffix: bool = False, + convert_png: bool = False, decompress: bool = False, + recursive: bool = False) -> Dict[IcnsType.Media.KeyT, + Union[str, Dict]]: ''' Write all bundled media files to output directory. @@ -177,52 +185,52 @@ class IcnsFile: elif not os.path.isdir(outdir): raise OSError('"{}" is not a directory. Abort.'.format(outdir)) - exported_files = {'_': self.infile} + export_files = {} # type: Dict[IcnsType.Media.KeyT, Union[str, Dict]] + if self.infile: + export_files['_'] = self.infile keys = list(self.media.keys()) # Convert to PNG if convert_png: - # keys = [x for x in keys if x not in []] for imgk, maskk in IcnsType.enum_png_convertable(keys): fname = self._export_to_png(outdir, imgk, maskk, key_suffix) if not fname: continue - exported_files[imgk] = fname + export_files[imgk] = fname if maskk: - exported_files[maskk] = fname + export_files[maskk] = fname if maskk in keys: keys.remove(maskk) keys.remove(imgk) # prepare filter - if type(allowed_ext) == str: - allowed_ext = [allowed_ext] + allowed = [] if allowed_ext == '*' else allowed_ext.split(',') if recursive: - cleanup = allowed_ext and 'icns' not in allowed_ext + cleanup = allowed and 'icns' not in allowed if cleanup: - allowed_ext.append('icns') + allowed.append('icns') # Export remaining for key in keys: - fname = self._export_single(outdir, key, key_suffix, decompress, - allowed_ext) + fname = self._export_single(outdir, key, key_suffix, + decompress, allowed) if fname: - exported_files[key] = fname + export_files[key] = fname # repeat for all icns if recursive: - for key, fname in exported_files.items(): - if key == '_' or not fname.endswith('.icns'): + for old_key, old_name in export_files.items(): + assert(isinstance(old_name, str)) + if not old_name.endswith('.icns') or old_key == '_': continue - prev_fname = exported_files[key] - exported_files[key] = IcnsFile(fname).export( + export_files[old_key] = IcnsFile(old_name).export( allowed_ext=allowed_ext, key_suffix=key_suffix, convert_png=convert_png, decompress=decompress, recursive=True) if cleanup: - os.remove(prev_fname) - return exported_files + os.remove(old_name) + return export_files - def _make_toc(self, *, enabled): + def _make_toc(self, *, enabled: bool) -> List[IcnsType.Media.KeyT]: # Rebuild TOC to ensure soundness if 'TOC ' in self.media.keys(): del(self.media['TOC ']) @@ -238,7 +246,9 @@ class IcnsFile: return order - def _export_single(self, outdir, key, key_suffix, decompress, allowed_ext): + def _export_single(self, outdir: str, key: IcnsType.Media.KeyT, + key_suffix: bool, decompress: bool, + allowed: List[str]) -> Optional[str]: ''' You must ensure that keys exist in self.media ''' data = self.media[key] ext = RawData.determine_file_ext(data) @@ -249,7 +259,7 @@ class IcnsFile: iType = IcnsType.get(key) fname = iType.filename(key_only=key_suffix) if decompress: - data = iType.decompress(data, ext) # ignores non-compressable + data = iType.decompress(data, ext) or data # type: ignore if not ext: # overwrite ext after (decompress requires None) ext = 'rgb' if iType.compressable else 'bin' except NotImplementedError: # If key unkown, export anyway @@ -257,14 +267,16 @@ class IcnsFile: if not ext: ext = 'unknown' - if allowed_ext and ext not in allowed_ext: + if allowed and ext not in allowed: return None fname = os.path.join(outdir, fname + '.' + ext) with open(fname, 'wb') as fp: fp.write(data) return fname - def _export_to_png(self, outdir, img_key, mask_key, key_suffix): + def _export_to_png(self, outdir: str, img_key: IcnsType.Media.KeyT, + mask_key: Optional[IcnsType.Media.KeyT], + key_suffix: bool) -> Optional[str]: ''' You must ensure key and mask_key exists! ''' data = self.media[img_key] if RawData.determine_file_ext(data) not in ['argb', None]: @@ -279,11 +291,11 @@ class IcnsFile: ArgbImage(data=data, mask=mask_data).write_png(fname) return fname - def __repr__(self): + def __repr__(self) -> str: lst = ', '.join(str(k) for k in self.media.keys()) return '<{}: file={}, [{}]>'.format( type(self).__name__, self.infile, lst) - def __str__(self): + def __str__(self) -> str: return 'File: ' + (self.infile or '-mem-') + os.linesep \ + IcnsFile._description(self.media.items(), indent=2) diff --git a/icnsutil/IcnsType.py b/icnsutil/IcnsType.py index 8da36e0..eb19280 100644 --- a/icnsutil/IcnsType.py +++ b/icnsutil/IcnsType.py @@ -4,7 +4,7 @@ Namespace for the ICNS format. @see https://en.wikipedia.org/wiki/Apple_Icon_Image_format ''' import os # path -# import icnsutil # PackBytes, RawData +from typing import Union, Optional, Tuple, Iterator, List, Iterable, Dict from . import PackBytes, RawData @@ -13,14 +13,17 @@ class CanNotDetermine(Exception): class Media: + KeyT = Union[str, bytes] __slots__ = ['key', 'types', 'size', 'channels', 'bits', 'availability', 'desc', 'compressable', 'retina', 'maxsize', 'ext_certain'] - def __init__(self, key, types, size=None, *, - ch=None, bits=None, os=None, desc=''): + def __init__(self, key: KeyT, types: list, + size: Optional[Union[int, Tuple[int, int]]] = None, + *, ch: Optional[int] = None, bits: Optional[int] = None, + os: Optional[float] = None, desc: str = '') -> None: self.key = key - self.types = types if type(types) == list else [types] - self.size = (size, size) if type(size) == int else size + self.types = types + self.size = (size, size) if isinstance(size, int) else size self.availability = os self.desc = desc # computed properties @@ -34,24 +37,25 @@ class Media: bits = 8 self.channels = ch self.bits = bits - self.maxsize = None - if size and ch and bits: + self.maxsize = None # type: Optional[int] + if self.size and ch and bits: self.maxsize = self.size[0] * self.size[1] * ch * bits // 8 self.ext_certain = all(x in ['png', 'argb', 'plist', 'jp2', 'icns'] for x in self.types) - def is_type(self, typ): + def is_type(self, typ: str) -> bool: return typ in self.types def is_binary(self) -> bool: return any(x in self.types for x in ['rgb', 'bin']) - def fallback_ext(self): + def fallback_ext(self) -> str: if self.channels in [1, 2]: return self.desc # guaranteed to be icon, mask, or iconmask return self.types[-1] - def split_channels(self, uncompressed_data): + def split_channels(self, uncompressed_data: List[int]) -> Iterator[ + List[int]]: if self.channels not in [3, 4]: raise NotImplementedError('Only RGB and ARGB data supported.') if len(uncompressed_data) != self.maxsize: @@ -64,26 +68,28 @@ class Media: for i in range(self.channels): yield uncompressed_data[per_channel * i:per_channel * (i + 1)] - def decompress(self, data, ext='-?-'): - if not self.compressable: - return data - if ext == '-?-': - ext = RawData.determine_file_ext(data) - if ext == 'argb': - return PackBytes.unpack(data[4:]) # remove ARGB header - if ext is None or ext == 'rgb': # RGB files dont have a magic number - if self.key == 'it32': - data = data[4:] - return PackBytes.unpack(data) - return data + def decompress(self, data: bytes, ext: Optional[str] = '-?-') -> Optional[ + List[int]]: + ''' Returns None if media is not decompressable. ''' + if self.compressable: + if ext == '-?-': + ext = RawData.determine_file_ext(data) + if ext == 'argb': + return PackBytes.unpack(data[4:]) # remove ARGB header + if ext is None or ext == 'rgb': # RGB files dont have magic number + if self.key == 'it32': + data = data[4:] + return PackBytes.unpack(data) + return None - def filename(self, *, key_only=False, size_only=False): + def filename(self, *, key_only: bool = False, size_only: bool = False) \ + -> str: if key_only: if os.path.exists(__file__.upper()): # check case senstive if self.key in ['sb24', 'icsb']: - return self.key + '-a' + return self.key + '-a' # type: ignore elif self.key in ['SB24', 'icsB']: - return self.key + '-b' + return self.key + '-b' # type: ignore return str(self.key) # dont return directy, may be b''-str else: if self.is_type('icns'): @@ -106,11 +112,11 @@ class Media: suffix += '-mask{}b'.format(self.bits) return '{}x{}{}'.format(w, h, suffix) - def __repr__(self): + def __repr__(self) -> str: return '<{}: {}, {}.{}>'.format( - type(self).__name__, self.key, self.filename(), self.types[0]) + type(self).__name__, str(self.key), self.filename(), self.types[0]) - def __str__(self): + def __str__(self) -> str: T = '' if self.size: T += '{}x{}, '.format(*self.size) @@ -120,36 +126,36 @@ class Media: if self.desc: T += self.desc + ', ' return '{}: {}macOS {}+'.format( - self.key, T, self.availability or '?') + str(self.key), T, self.availability or '?') _TYPES = {x.key: x for x in ( # Read support for these: - Media('ICON', 'bin', 32, ch=1, bits=1, os=1.0, desc='icon'), - Media('ICN#', 'bin', 32, ch=2, bits=1, os=6.0, desc='iconmask'), - Media('icm#', 'bin', (16, 12), ch=2, bits=1, os=6.0, desc='iconmask'), - Media('icm4', 'bin', (16, 12), ch=1, bits=4, os=7.0, desc='icon'), - Media('icm8', 'bin', (16, 12), ch=1, bits=8, os=7.0, desc='icon'), - Media('ics#', 'bin', 16, ch=2, bits=1, os=6.0, desc='iconmask'), - Media('ics4', 'bin', 16, ch=1, bits=4, os=7.0, desc='icon'), - Media('ics8', 'bin', 16, ch=1, bits=8, os=7.0, desc='icon'), - Media('is32', 'rgb', 16, os=8.5), - Media('s8mk', 'bin', 16, ch=1, bits=8, os=8.5, desc='mask'), - Media('icl4', 'bin', 32, ch=1, bits=4, os=7.0, desc='icon'), - Media('icl8', 'bin', 32, ch=1, bits=8, os=7.0, desc='icon'), - Media('il32', 'rgb', 32, os=8.5), - Media('l8mk', 'bin', 32, ch=1, bits=8, os=8.5, desc='mask'), - Media('ich#', 'bin', 48, ch=2, bits=1, os=8.5, desc='iconmask'), - Media('ich4', 'bin', 48, ch=1, bits=4, os=8.5, desc='icon'), - Media('ich8', 'bin', 48, ch=1, bits=8, os=8.5, desc='icon'), - Media('ih32', 'rgb', 48, os=8.5), - Media('h8mk', 'bin', 48, ch=1, bits=8, os=8.5, desc='mask'), - Media('it32', 'rgb', 128, os=10.0), - Media('t8mk', 'bin', 128, ch=1, bits=8, os=10.0, desc='mask'), + Media('ICON', ['bin'], 32, ch=1, bits=1, os=1.0, desc='icon'), + Media('ICN#', ['bin'], 32, ch=2, bits=1, os=6.0, desc='iconmask'), + Media('icm#', ['bin'], (16, 12), ch=2, bits=1, os=6.0, desc='iconmask'), + Media('icm4', ['bin'], (16, 12), ch=1, bits=4, os=7.0, desc='icon'), + Media('icm8', ['bin'], (16, 12), ch=1, bits=8, os=7.0, desc='icon'), + Media('ics#', ['bin'], 16, ch=2, bits=1, os=6.0, desc='iconmask'), + Media('ics4', ['bin'], 16, ch=1, bits=4, os=7.0, desc='icon'), + Media('ics8', ['bin'], 16, ch=1, bits=8, os=7.0, desc='icon'), + Media('is32', ['rgb'], 16, os=8.5), + Media('s8mk', ['bin'], 16, ch=1, bits=8, os=8.5, desc='mask'), + Media('icl4', ['bin'], 32, ch=1, bits=4, os=7.0, desc='icon'), + Media('icl8', ['bin'], 32, ch=1, bits=8, os=7.0, desc='icon'), + Media('il32', ['rgb'], 32, os=8.5), + Media('l8mk', ['bin'], 32, ch=1, bits=8, os=8.5, desc='mask'), + Media('ich#', ['bin'], 48, ch=2, bits=1, os=8.5, desc='iconmask'), + Media('ich4', ['bin'], 48, ch=1, bits=4, os=8.5, desc='icon'), + Media('ich8', ['bin'], 48, ch=1, bits=8, os=8.5, desc='icon'), + Media('ih32', ['rgb'], 48, os=8.5), + Media('h8mk', ['bin'], 48, ch=1, bits=8, os=8.5, desc='mask'), + Media('it32', ['rgb'], 128, os=10.0), + Media('t8mk', ['bin'], 128, ch=1, bits=8, os=10.0, desc='mask'), # Write support for these: Media('icp4', ['png', 'jp2', 'rgb'], 16, os=10.7), Media('icp5', ['png', 'jp2', 'rgb'], 32, os=10.7), - Media('icp6', 'png', 64, os=10.7), + Media('icp6', ['png'], 64, os=10.7), Media('ic07', ['png', 'jp2'], 128, os=10.7), Media('ic08', ['png', 'jp2'], 256, os=10.5), Media('ic09', ['png', 'jp2'], 512, os=10.5), @@ -165,36 +171,37 @@ _TYPES = {x.key: x for x in ( Media('sb24', ['png', 'jp2'], 24), Media('SB24', ['png', 'jp2'], 48, desc='24x24@2x'), # ICNS media files - Media('sbtp', 'icns', desc='template'), - Media('slct', 'icns', desc='selected'), - Media(b'\xFD\xD9\x2F\xA8', 'icns', os=10.14, desc='dark'), + Media('sbtp', ['icns'], desc='template'), + Media('slct', ['icns'], desc='selected'), + Media(b'\xFD\xD9\x2F\xA8', ['icns'], os=10.14, desc='dark'), # Meta types: - Media('TOC ', 'bin', os=10.7, desc='Table of Contents'), - Media('icnV', 'bin', desc='4-byte Icon Composer.app bundle version'), - Media('name', 'bin', desc='Unknown'), - Media('info', 'plist', desc='Info binary plist'), -)} + Media('TOC ', ['bin'], os=10.7, desc='Table of Contents'), + Media('icnV', ['bin'], desc='4-byte Icon Composer.app bundle version'), + Media('name', ['bin'], desc='Unknown'), + Media('info', ['plist'], desc='Info binary plist'), +)} # type: Dict[Media.KeyT, Media] -def enum_img_mask_pairs(available_keys): +def enum_img_mask_pairs(available_keys: Iterable[Media.KeyT]) -> Iterator[ + Tuple[Optional[str], Optional[str]]]: for mask_k, *imgs in [ # list probably never changes, ARGB FTW ('s8mk', 'is32', 'ics8', 'ics4', 'icp4'), ('l8mk', 'il32', 'icl8', 'icl4', 'icp5'), ('h8mk', 'ih32', 'ich8', 'ich4'), ('t8mk', 'it32'), ]: - if mask_k not in available_keys: - mask_k = None + mk = mask_k if mask_k in available_keys else None any_img = False for img_k in imgs: if img_k in available_keys: any_img = True - yield img_k, mask_k - if mask_k and not any_img: - yield None, mask_k + yield img_k, mk + if mk and not any_img: + yield None, mk -def enum_png_convertable(available_keys): +def enum_png_convertable(available_keys: Iterable[Media.KeyT]) -> Iterator[ + Tuple[Media.KeyT, Optional[Media.KeyT]]]: ''' Yield (image-key, mask-key or None) ''' for img in _TYPES.values(): if img.key not in available_keys: @@ -212,22 +219,21 @@ def enum_png_convertable(available_keys): yield img.key, mask_key -def get(key): +def get(key: Media.KeyT) -> Media: try: return _TYPES[key] except KeyError: pass - raise NotImplementedError('Unsupported icns type "{}"'.format(key)) + raise NotImplementedError('Unsupported icns type "' + str(key) + '"') -def match_maxsize(maxsize, typ): - for x in _TYPES.values(): - if x.is_type(typ) and x.maxsize == maxsize: - return x # TODO: handle cases with multiple options? eg: is32 icp4 - return None +def match_maxsize(total: int, typ: str) -> Media: + assert(typ == 'argb' or typ == 'rgb') + ret = [x for x in _TYPES.values() if x.is_type(typ) and x.maxsize == total] + return ret[0] # TODO: handle cases with multiple options? eg: is32 icp4 -def guess(data, filename=None): +def guess(data: bytes, filename: Optional[str] = None) -> Media: ''' Guess icns media type by analyzing the raw data + file naming convention. Use: diff --git a/icnsutil/PackBytes.py b/icnsutil/PackBytes.py index 0f70544..8917d37 100644 --- a/icnsutil/PackBytes.py +++ b/icnsutil/PackBytes.py @@ -1,7 +1,10 @@ #!/usr/bin/env python3 -def pack(data): - ret = [] - buf = [] +from typing import List, Iterator, Union + + +def pack(data: List[int]) -> bytes: + ret = [] # type: List[int] + buf = [] # type: List[int] i = 0 def flush_buf(): @@ -37,8 +40,8 @@ def pack(data): return bytes(ret) -def unpack(data): - ret = [] +def unpack(data: bytes) -> List[int]: + ret = [] # type: List[int] i = 0 end = len(data) while i < end: @@ -52,7 +55,7 @@ def unpack(data): return ret -def get_size(data): +def get_size(data: bytes) -> int: count = 0 i = 0 end = len(data) @@ -67,7 +70,7 @@ def get_size(data): return count -def msb_stream(data, *, bits): +def msb_stream(data: Union[bytes, List[int]], *, bits: int) -> Iterator[int]: if bits not in [1, 2, 4]: raise NotImplementedError('Unsupported bit-size.') c = 0 diff --git a/icnsutil/RawData.py b/icnsutil/RawData.py index a2f254e..44de8cf 100644 --- a/icnsutil/RawData.py +++ b/icnsutil/RawData.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import struct # pack, unpack +from typing import Union, Optional, Tuple, Iterator, BinaryIO from . import IcnsType, PackBytes @@ -7,7 +8,7 @@ class ParserError(Exception): pass -def determine_file_ext(data): +def determine_file_ext(data: bytes) -> Optional[str]: ''' Data should be at least 8 bytes long. Returns one of: png, argb, plist, jp2, icns, None @@ -28,12 +29,14 @@ def determine_file_ext(data): return None -def determine_image_size(data, ext=None): +def determine_image_size(data: bytes, ext: Optional[str] = None) \ + -> Optional[Tuple[int, int]]: ''' Supports PNG, ARGB, and Jpeg 2000 image data. ''' if not ext: ext = determine_file_ext(data) if ext == 'png': - return struct.unpack('>II', data[16:24]) + w, h = struct.unpack('>II', data[16:24]) + return w, h elif ext == 'argb': total = PackBytes.get_size(data[4:]) # without ARGB header return IcnsType.match_maxsize(total, 'argb').size @@ -43,7 +46,8 @@ def determine_image_size(data, ext=None): return IcnsType.match_maxsize(PackBytes.get_size(data), 'rgb').size elif ext == 'jp2': if data[:4] == b'\xFF\x4F\xFF\x51': - return struct.unpack('>II', data[8:16]) + w, h = struct.unpack('>II', data[8:16]) + return w, h len_ftype = struct.unpack('>I', data[12:16])[0] # file header + type box + header box (super box) + image header box offset = 12 + len_ftype + 8 + 8 @@ -52,7 +56,7 @@ def determine_image_size(data, ext=None): return None # icns does not support other image types except binary -def is_icns_without_header(data): +def is_icns_without_header(data: bytes) -> bool: ''' Returns True even if icns header is missing. ''' offset = 0 for i in range(2): # test n keys if they exist @@ -69,32 +73,33 @@ def is_icns_without_header(data): return True -def icns_header_read(data): +def icns_header_read(data: bytes) -> Tuple[IcnsType.Media.KeyT, int]: ''' Returns icns type name and data length (incl. +8 for header) ''' - assert(type(data) == bytes) + assert(isinstance(data, bytes)) if len(data) != 8: - return None, 0 + return '', 0 + length = struct.unpack('>I', data[4:])[0] try: - name = data[:4].decode('utf8') + return data[:4].decode('utf8'), length except UnicodeDecodeError: - name = data[:4] # Fallback to bytes-string key - return name, struct.unpack('>I', data[4:])[0] + return data[:4], length # Fallback to bytes-string key -def icns_header_write_data(fp, key, data): +def icns_header_write_data(fp: BinaryIO, key: IcnsType.Media.KeyT, + data: bytes) -> None: ''' Calculates length from data. ''' - fp.write(key.encode('utf8') if type(key) == str else key) + fp.write(key.encode('utf8') if isinstance(key, str) else key) fp.write(struct.pack('>I', len(data) + 8)) fp.write(data) -def icns_header_w_len(key, length): +def icns_header_w_len(key: IcnsType.Media.KeyT, length: int) -> bytes: ''' Adds +8 to length. ''' - name = key.encode('utf8') if type(key) == str else key + name = key.encode('utf8') if isinstance(key, str) else key return name + struct.pack('>I', length + 8) -def parse_icns_file(fname): +def parse_icns_file(fname: str) -> Iterator[Tuple[IcnsType.Media.KeyT, bytes]]: ''' Parse file and yield media entries: (key, data) :raises: diff --git a/icnsutil/cli.py b/icnsutil/cli.py index 67589fe..1eacee8 100755 --- a/icnsutil/cli.py +++ b/icnsutil/cli.py @@ -4,13 +4,14 @@ Export existing icns files or compose new ones. ''' import os # path, makedirs import sys # path, stderr +from typing import Iterator, Optional from argparse import ArgumentParser, ArgumentTypeError, RawTextHelpFormatter if __name__ == '__main__': sys.path[0] = os.path.dirname(sys.path[0]) from icnsutil import __version__, IcnsFile -def cli_extract(args): +def cli_extract(args) -> None: ''' Read and extract contents of icns file(s). ''' multiple = len(args.file) > 1 or '-' in args.file for i, fname in enumerate(enum_with_stdin(args.file)): @@ -20,13 +21,13 @@ def cli_extract(args): out = os.path.join(out, str(i)) os.makedirs(out, exist_ok=True) - pred = 'png' if args.png_only else None IcnsFile(fname).export( - out, allowed_ext=pred, recursive=args.recursive, - convert_png=args.convert, key_suffix=args.keys) + out, allowed_ext='png' if args.png_only else '*', + recursive=args.recursive, convert_png=args.convert, + key_suffix=args.keys) -def cli_compose(args): +def cli_compose(args) -> None: ''' Create new icns file from provided image files. ''' dest = args.target if not os.path.splitext(dest)[1]: @@ -35,24 +36,24 @@ def cli_compose(args): print( 'File "{}" already exists. Force overwrite with -f.'.format(dest), file=sys.stderr) - return 1 + return img = IcnsFile() for x in enum_with_stdin(args.source): img.add_media(file=x) img.write(dest, toc=not args.no_toc) -def cli_print(args): +def cli_print(args) -> None: ''' Print contents of icns file(s). ''' for fname in enum_with_stdin(args.file): print('File:', fname) print(IcnsFile.description(fname, verbose=args.verbose, indent=2)) -def cli_verify(args): +def cli_verify(args) -> None: ''' Test if icns file is valid. ''' for fname in enum_with_stdin(args.file): - is_valid = True + is_valid = True # type: Optional[bool] if not args.quiet: print('File:', fname) is_valid = None @@ -65,7 +66,7 @@ def cli_verify(args): print('OK') -def enum_with_stdin(file_arg): +def enum_with_stdin(file_arg: list) -> Iterator[str]: for x in file_arg: if x == '-': for line in sys.stdin.readlines(): @@ -74,13 +75,13 @@ def enum_with_stdin(file_arg): yield x -def main(): +def main() -> None: class PathExist: - def __init__(self, kind=None, stdin=False): + def __init__(self, kind: Optional[str] = None, stdin: bool = False): self.kind = kind self.stdin = stdin - def __call__(self, path): + def __call__(self, path: str) -> str: if self.stdin and path == '-': return '-' if not os.path.exists(path) or \ diff --git a/setup.py b/setup.py index 221bc47..a83f815 100644 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ setup( }, long_description_content_type="text/markdown", long_description=longdesc, - python_requires='>=3.2', + python_requires='>=3.5', keywords=[ 'icns', 'icon', diff --git a/tests/format-support.py b/tests/format-support.py index 8bed8f1..b825612 100644 --- a/tests/format-support.py +++ b/tests/format-support.py @@ -8,7 +8,7 @@ if __name__ == '__main__': from icnsutil import IcnsFile, PackBytes -def main(): +def main() -> None: # generate_raw_rgb() generate_icns() generate_random_it32_header() @@ -30,8 +30,8 @@ INFO = { } -def generate_raw_rgb(): - def testpattern(w, h, *, ch, compress=True): +def generate_raw_rgb() -> None: + def testpattern(w: int, h: int, ch: int, compress: bool = True) -> bytes: ARGB = ch == 4 sz = w * h if compress: @@ -60,7 +60,7 @@ def generate_raw_rgb(): fp.write(rgb_data) -def generate_icns(): +def generate_icns() -> None: os.makedirs('format-support-icns', exist_ok=True) with ZipFile('format-support-raw.zip') as Zip: for s, keys in INFO.items(): @@ -88,14 +88,14 @@ def generate_icns(): toc=False) -def generate_random_it32_header(): +def generate_random_it32_header() -> None: print('testing random it32 header') os.makedirs('format-support-it32', exist_ok=True) with ZipFile('format-support-raw.zip') as Zip: with Zip.open('128x128.rgb') as f: data = f.read() - def random_header(): + def random_header() -> bytes: return bytes([randint(0, 255), randint(0, 255), randint(0, 255), randint(0, 255)]) diff --git a/tests/test_icnsutil.py b/tests/test_icnsutil.py index 54f4482..24020a8 100644 --- a/tests/test_icnsutil.py +++ b/tests/test_icnsutil.py @@ -2,6 +2,7 @@ import unittest import shutil # rmtree import os # chdir, listdir, makedirs, path, remove +from typing import Optional, Dict, Any if __name__ == '__main__': import sys sys.path[0] = os.path.dirname(sys.path[0]) @@ -41,7 +42,7 @@ class TestArgbImage(unittest.TestCase): self.assertEqual(img.g, [0] * w * w) self.assertEqual(img.b, [255] * w * w) # Test setting mask manually - img.load_mask(data=[117] * w * w) + img.load_mask(data=b'\x75' * w * w) self.assertEqual(img.size, (w, w)) self.assertEqual(img.a, [117] * w * w) self.assertEqual(img.r, [128] * w * w) @@ -70,8 +71,10 @@ class TestArgbImage(unittest.TestCase): @unittest.skipUnless(PIL_ENABLED, 'PIL_ENABLED == False') def test_attributes(self): + img = ArgbImage(file='rgb.icns.png') + self.assertTrue(img.channels != 0) # will raise AttributeError if _load_png didnt init all attrributes - str(ArgbImage(file='rgb.icns.png')) + str(img) def test_data_getter(self): img = ArgbImage(file='rgb.icns.argb') @@ -319,9 +322,6 @@ class TestIcnsType(unittest.TestCase): def test_match_maxsize(self): for typ, size, key in [ - ('bin', 512, 'icl4'), - ('bin', 192, 'icm8'), - ('png', 768, 'icp4'), ('rgb', 768, 'is32'), ('rgb', 3072, 'il32'), ('rgb', 6912, 'ih32'), @@ -332,6 +332,13 @@ class TestIcnsType(unittest.TestCase): ]: iType = IcnsType.match_maxsize(size, typ) self.assertEqual(iType.key, key, msg=f'{typ} ({size}) != {key}') + for typ, size, key in [ + ('bin', 512, 'icl4'), + ('bin', 192, 'icm8'), + ('png', 768, 'icp4'), + ]: + with self.assertRaises(AssertionError): + IcnsType.match_maxsize(size, typ) def test_decompress(self): # Test ARGB deflate @@ -347,7 +354,7 @@ class TestIcnsType(unittest.TestCase): d = IcnsType.get('it32').decompress(data) self.assertEqual(len(d), 1966) # decompress removes 4-byte it32-header d = IcnsType.get('ic04').decompress(data, ext='png') - self.assertEqual(len(d), 705) # if png, dont decompress + self.assertEqual(d, None) # if png, dont decompress def test_exceptions(self): with self.assertRaises(NotImplementedError): @@ -417,10 +424,10 @@ class TestRawData(unittest.TestCase): ####################### class TestExport(unittest.TestCase): - INFILE = None - OUTDIR = None # set in setUpClass + INFILE = None # type: Optional[str] + OUTDIR = None # type: Optional[str] # set in setUpClass CLEANUP = True # for debugging purposes - ARGS = {} + ARGS = {} # type: Dict[str, Any] @classmethod def setUpClass(cls):