diff --git a/icnsutil/ArgbImage.py b/icnsutil/ArgbImage.py index d88c92e..bb94c4e 100644 --- a/icnsutil/ArgbImage.py +++ b/icnsutil/ArgbImage.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -from typing import Union, Iterator, Optional +from typing import Union, Optional from math import sqrt from . import IcnsType, PackBytes, RawData try: @@ -16,6 +16,8 @@ class ArgbImage: def from_mono(cls, data: bytes, iType: IcnsType.Media) -> 'ArgbImage': ''' Load monochrome 1-bit image with or without mask. ''' assert(iType.bits == 1) + assert(iType.size) + assert(iType.channels) img = [] for byte in data: for i in range(7, -1, -1): @@ -31,9 +33,13 @@ class ArgbImage: self.r, self.g, self.b = img, img, img return self - def __init__(self, *, data: Optional[bytes] = None, - file: Optional[str] = None, - mask: Union[bytes, str, None] = None) -> 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 @@ -91,8 +97,9 @@ class ArgbImage: self.g = uncompressed_data[(i + 1) * per_channel:(i + 2) * per_channel] self.b = uncompressed_data[(i + 2) * per_channel:(i + 3) * per_channel] - def load_mask(self, *, file: Optional[str] = None, - data: Optional[bytes] = None) -> 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: diff --git a/icnsutil/IcnsFile.py b/icnsutil/IcnsFile.py index 8a60ec2..ccb061a 100644 --- a/icnsutil/IcnsFile.py +++ b/icnsutil/IcnsFile.py @@ -79,14 +79,18 @@ class IcnsFile: x, y) @staticmethod - def description(fname: str, *, verbose: bool = False, indent: int = 0) -> \ - str: + 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: Iterable[Tuple[IcnsType.Media.KeyT, bytes]], - *, verbose: bool = False, indent: int = 0) -> str: + 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 @@ -117,7 +121,7 @@ class IcnsFile: except RawData.ParserError as e: return ' ' * indent + str(e) + os.linesep - def __init__(self, file: str = None) -> None: + def __init__(self, file: Optional[str] = None) -> None: ''' Read .icns file and load bundled media files into memory. ''' self.media = {} # type: Dict[IcnsType.Media.KeyT, bytes] self.infile = file @@ -134,9 +138,14 @@ class IcnsFile: def has_toc(self) -> bool: return 'TOC ' in self.media.keys() - def add_media(self, key: Optional[IcnsType.Media.KeyT] = None, *, - file: Optional[str] = None, data: Optional[bytes] = None, - force: bool = False) -> None: + 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. @@ -182,11 +191,16 @@ class IcnsFile: for key in order: RawData.icns_header_write_data(fp, key, self.media[key]) - 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]]: + 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. @@ -266,9 +280,14 @@ class IcnsFile: return order - def _export_single(self, outdir: str, key: IcnsType.Media.KeyT, - key_suffix: bool, decompress: bool, - allowed: List[str]) -> Optional[str]: + 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) @@ -294,9 +313,13 @@ class IcnsFile: fp.write(data) return fname - def _export_to_png(self, outdir: str, img_key: IcnsType.Media.KeyT, - mask_key: Optional[IcnsType.Media.KeyT], - key_suffix: bool) -> Optional[str]: + 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]: diff --git a/icnsutil/IcnsType.py b/icnsutil/IcnsType.py index 3998f54..d649c47 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 -from typing import Union, Optional, Tuple, Iterator, List, Iterable, Dict +from typing import Union, Optional, Tuple, Iterator, List, Iterable from . import PackBytes, RawData @@ -17,10 +17,17 @@ class Media: __slots__ = ['key', 'types', 'size', 'channels', 'bits', 'availability', 'desc', 'compressable', 'retina', 'maxsize', 'ext_certain'] - 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: + def __init__( + self, + key: KeyT, + types: List[str], + size: Union[int, Tuple[int, int], None] = None, + *, + ch: Optional[int] = None, + bits: Optional[int] = None, + os: Optional[float] = None, + desc: str = '', + ) -> None: self.key = key self.types = types self.size = (size, size) if isinstance(size, int) else size @@ -54,8 +61,8 @@ class Media: return self.desc # guaranteed to be icon, mask, or iconmask return self.types[-1] - def decompress(self, data: bytes, ext: Optional[str] = '-?-') -> Optional[ - List[int]]: + def decompress(self, data: bytes, ext: Optional[str] = '-?-') \ + -> Optional[List[int]]: ''' Returns None if media is not decompressable. ''' if self.compressable: if ext == '-?-': @@ -165,11 +172,11 @@ _TYPES = {x.key: x for x in ( 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: Iterable[Media.KeyT]) -> Iterator[ - Tuple[Optional[str], Optional[str]]]: +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'), @@ -186,8 +193,8 @@ def enum_img_mask_pairs(available_keys: Iterable[Media.KeyT]) -> Iterator[ yield None, mk -def enum_png_convertable(available_keys: Iterable[Media.KeyT]) -> Iterator[ - Tuple[Media.KeyT, Optional[Media.KeyT]]]: +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: @@ -219,8 +226,8 @@ def key_from_readable(key: str) -> Media.KeyT: 'selected': 'slct', 'template': 'sbtp', 'toc': 'TOC ', - } # type: Dict[str, Media.KeyT] - return key_mapping.get(key.lower(), key) + } + return key_mapping.get(key.lower(), key) # type: ignore[return-value] def match_maxsize(total: int, typ: str) -> Media: diff --git a/icnsutil/PackBytes.py b/icnsutil/PackBytes.py index 4d1f5c9..e2260a1 100644 --- a/icnsutil/PackBytes.py +++ b/icnsutil/PackBytes.py @@ -7,7 +7,7 @@ def pack(data: List[int]) -> bytes: buf = [] # type: List[int] i = 0 - def flush_buf(): + def flush_buf() -> None: # write out non-repeating bytes if len(buf) > 0: ret.append(len(buf) - 1) diff --git a/icnsutil/RawData.py b/icnsutil/RawData.py index bc4aa61..0f42ccc 100644 --- a/icnsutil/RawData.py +++ b/icnsutil/RawData.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 import struct # pack, unpack -from typing import Union, Optional, Tuple, Iterator, BinaryIO +from typing import Optional, Tuple, Iterator, BinaryIO from . import IcnsType, PackBytes @@ -85,8 +85,9 @@ def icns_header_read(data: bytes) -> Tuple[IcnsType.Media.KeyT, int]: return data[:4], length # Fallback to bytes-string key -def icns_header_write_data(fp: BinaryIO, key: IcnsType.Media.KeyT, - data: bytes) -> None: +def icns_header_write_data( + fp: BinaryIO, key: IcnsType.Media.KeyT, data: bytes, +) -> None: ''' Calculates length from data. ''' fp.write(key.encode('utf8') if isinstance(key, str) else key) fp.write(struct.pack('>I', len(data) + 8)) diff --git a/icnsutil/cli.py b/icnsutil/cli.py index 70628b0..88d5d18 100755 --- a/icnsutil/cli.py +++ b/icnsutil/cli.py @@ -4,9 +4,8 @@ Export existing icns files or compose new ones. ''' import os # path, makedirs import sys # path, stderr -from typing import Iterator, Optional, Callable -from argparse import ArgumentParser, ArgumentTypeError, RawTextHelpFormatter -from argparse import Namespace as ArgParams +from typing import Iterator, Optional, Callable, List +from argparse import ArgumentParser, ArgumentTypeError, Namespace as ArgParams if __name__ == '__main__': sys.path[0] = os.path.dirname(sys.path[0]) from icnsutil import __version__, IcnsFile, IcnsType, ArgbImage @@ -53,16 +52,15 @@ def cli_update(args: ArgParams) -> None: has_changes |= icns.remove_media(IcnsType.key_from_readable(x)) # add media for key_val in args.set or []: - def fail(): - raise ArgumentTypeError( - 'Expected arg format KEY=FILE - got "{}"'.format(key_val)) if key_val.lower() == 'toc': key_val = 'toc=1' if '=' not in key_val: - fail() - key, val = key_val.split('=') + raise ArgumentTypeError( + 'Expected arg format KEY=FILE - got "{}"'.format(key_val)) + key, val = key_val.split('=', 1) if not val: - fail() + raise ArgumentTypeError( + 'Expected arg format KEY=FILE - got "{}"'.format(key_val)) has_changes = True if key.lower() == 'toc': @@ -130,7 +128,7 @@ def cli_convert(args: ArgParams) -> None: exit(1) -def enum_with_stdin(file_arg: list) -> Iterator[str]: +def enum_with_stdin(file_arg: List[str]) -> Iterator[str]: for x in file_arg: if x == '-': for line in sys.stdin.readlines(): @@ -155,18 +153,18 @@ def main() -> None: return path # Args Parser - parser = ArgumentParser(description=__doc__, - formatter_class=RawTextHelpFormatter) + parser = ArgumentParser(description=__doc__) parser.set_defaults(func=lambda _: parser.print_help(sys.stdout)) parser.add_argument( '-v', '--version', action='version', version='icnsutil ' + __version__) sub_parser = parser.add_subparsers(metavar='command') # helper method - def add_command(name: str, alias: str, fn: Callable[[ArgParams], None]): + def add_command( + name: str, alias: str, fn: Callable[[ArgParams], None] + ) -> ArgumentParser: desc = fn.__doc__ or '' cmd = sub_parser.add_parser(name, aliases=[alias], - formatter_class=RawTextHelpFormatter, help=desc, description=desc.strip()) cmd.set_defaults(func=fn) return cmd @@ -189,22 +187,20 @@ def main() -> None: # Compose cmd = add_command('compose', 'c', cli_compose) cmd.add_argument('-f', '--force', action='store_true', - help='force overwrite output file') - cmd.add_argument('--toc', action='store_true', - help='write table of contents to file') + help='Force overwrite output file') + cmd.add_argument('--toc', action='store_true', help=''' + Write table of contents to file. + TOC is optional and uses just a few bytes (8b per media entry).''') cmd.add_argument('target', type=str, metavar='destination', help='Output file for newly created icns file.') cmd.add_argument('source', type=PathExist('f', stdin=True), nargs='+', - metavar='src', - help='One or more media files: png, argb, rgb, jp2, icns') - cmd.epilog = ''' -Notes: -- TOC is optional but only a few bytes long (8b per media entry). -- Icon dimensions are read directly from file. -- Filename suffix "@2x.png" or "@2x.jp2" sets the retina flag. -- Use one of these suffixes to automatically assign icns files: - template, selected, dark -''' + metavar='src', help=''' + One or more media files: png, argb, rgb, jp2, icns. + -- + Icon dimensions are read directly from file. + Filename suffixes "@2x.png" and "@2x.jp2" will set the retina flag. + If the suffix ends on one of these (template, selected, dark), + the file is automatically assigned to an icns file field.''') # Update cmd = add_command('update', 'u', cli_update)