chore: type formatting + refactor help text

This commit is contained in:
relikd
2023-03-08 00:28:58 +01:00
parent 4f565b6de1
commit 3b430740dc
6 changed files with 104 additions and 70 deletions

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from typing import Union, Iterator, Optional from typing import Union, Optional
from math import sqrt from math import sqrt
from . import IcnsType, PackBytes, RawData from . import IcnsType, PackBytes, RawData
try: try:
@@ -16,6 +16,8 @@ class ArgbImage:
def from_mono(cls, data: bytes, iType: IcnsType.Media) -> 'ArgbImage': def from_mono(cls, data: bytes, iType: IcnsType.Media) -> 'ArgbImage':
''' Load monochrome 1-bit image with or without mask. ''' ''' Load monochrome 1-bit image with or without mask. '''
assert(iType.bits == 1) assert(iType.bits == 1)
assert(iType.size)
assert(iType.channels)
img = [] img = []
for byte in data: for byte in data:
for i in range(7, -1, -1): for i in range(7, -1, -1):
@@ -31,9 +33,13 @@ class ArgbImage:
self.r, self.g, self.b = img, img, img self.r, self.g, self.b = img, img, img
return self return self
def __init__(self, *, data: Optional[bytes] = None, def __init__(
file: Optional[str] = None, self,
mask: Union[bytes, str, None] = None) -> None: *,
data: Optional[bytes] = None,
file: Optional[str] = None,
mask: Union[bytes, str, None] = None,
) -> None:
''' '''
Provide either a filename or raw binary data. Provide either a filename or raw binary data.
- mask : Optional, may be either binary data or filename - 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.g = uncompressed_data[(i + 1) * per_channel:(i + 2) * per_channel]
self.b = uncompressed_data[(i + 2) * per_channel:(i + 3) * per_channel] self.b = uncompressed_data[(i + 2) * per_channel:(i + 3) * per_channel]
def load_mask(self, *, file: Optional[str] = None, def load_mask(
data: Optional[bytes] = None) -> None: self, *, file: Optional[str] = None, data: Optional[bytes] = None,
) -> None:
''' Data must be uncompressed and same length as a single channel! ''' ''' Data must be uncompressed and same length as a single channel! '''
if file: if file:
with open(file, 'rb') as fp: with open(file, 'rb') as fp:

View File

@@ -79,14 +79,18 @@ class IcnsFile:
x, y) x, y)
@staticmethod @staticmethod
def description(fname: str, *, verbose: bool = False, indent: int = 0) -> \ def description(fname: str, *, verbose: bool = False, indent: int = 0) \
str: -> str:
return IcnsFile._description( return IcnsFile._description(
RawData.parse_icns_file(fname), verbose=verbose, indent=indent) RawData.parse_icns_file(fname), verbose=verbose, indent=indent)
@staticmethod @staticmethod
def _description(enumerator: Iterable[Tuple[IcnsType.Media.KeyT, bytes]], def _description(
*, verbose: bool = False, indent: int = 0) -> str: enumerator: Iterable[Tuple[IcnsType.Media.KeyT, bytes]],
*,
verbose: bool = False,
indent: int = 0,
) -> str:
''' Expects an enumerator with (key, size, data) ''' ''' Expects an enumerator with (key, size, data) '''
txt = '' txt = ''
offset = 8 # already with icns header offset = 8 # already with icns header
@@ -117,7 +121,7 @@ class IcnsFile:
except RawData.ParserError as e: except RawData.ParserError as e:
return ' ' * indent + str(e) + os.linesep 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. ''' ''' Read .icns file and load bundled media files into memory. '''
self.media = {} # type: Dict[IcnsType.Media.KeyT, bytes] self.media = {} # type: Dict[IcnsType.Media.KeyT, bytes]
self.infile = file self.infile = file
@@ -134,9 +138,14 @@ class IcnsFile:
def has_toc(self) -> bool: def has_toc(self) -> bool:
return 'TOC ' in self.media.keys() return 'TOC ' in self.media.keys()
def add_media(self, key: Optional[IcnsType.Media.KeyT] = None, *, def add_media(
file: Optional[str] = None, data: Optional[bytes] = None, self,
force: bool = False) -> None: 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. If you provide both, data and file, data takes precedence.
However, the filename is still used for type-guessing. However, the filename is still used for type-guessing.
@@ -182,11 +191,16 @@ class IcnsFile:
for key in order: for key in order:
RawData.icns_header_write_data(fp, key, self.media[key]) RawData.icns_header_write_data(fp, key, self.media[key])
def export(self, outdir: Optional[str] = None, *, def export(
allowed_ext: str = '*', key_suffix: bool = False, self,
convert_png: bool = False, decompress: bool = False, outdir: Optional[str] = None,
recursive: bool = False) -> Dict[IcnsType.Media.KeyT, *,
Union[str, Dict]]: 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. Write all bundled media files to output directory.
@@ -266,9 +280,14 @@ class IcnsFile:
return order return order
def _export_single(self, outdir: str, key: IcnsType.Media.KeyT, def _export_single(
key_suffix: bool, decompress: bool, self,
allowed: List[str]) -> Optional[str]: 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 ''' ''' You must ensure that keys exist in self.media '''
data = self.media[key] data = self.media[key]
ext = RawData.determine_file_ext(data) ext = RawData.determine_file_ext(data)
@@ -294,9 +313,13 @@ class IcnsFile:
fp.write(data) fp.write(data)
return fname return fname
def _export_to_png(self, outdir: str, img_key: IcnsType.Media.KeyT, def _export_to_png(
mask_key: Optional[IcnsType.Media.KeyT], self,
key_suffix: bool) -> Optional[str]: 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! ''' ''' You must ensure key and mask_key exists! '''
data = self.media[img_key] data = self.media[img_key]
if RawData.determine_file_ext(data) not in ['argb', None]: if RawData.determine_file_ext(data) not in ['argb', None]:

View File

@@ -4,7 +4,7 @@ Namespace for the ICNS format.
@see https://en.wikipedia.org/wiki/Apple_Icon_Image_format @see https://en.wikipedia.org/wiki/Apple_Icon_Image_format
''' '''
import os # path 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 from . import PackBytes, RawData
@@ -17,10 +17,17 @@ class Media:
__slots__ = ['key', 'types', 'size', 'channels', 'bits', 'availability', __slots__ = ['key', 'types', 'size', 'channels', 'bits', 'availability',
'desc', 'compressable', 'retina', 'maxsize', 'ext_certain'] 'desc', 'compressable', 'retina', 'maxsize', 'ext_certain']
def __init__(self, key: KeyT, types: list, def __init__(
size: Optional[Union[int, Tuple[int, int]]] = None, self,
*, ch: Optional[int] = None, bits: Optional[int] = None, key: KeyT,
os: Optional[float] = None, desc: str = '') -> None: 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.key = key
self.types = types self.types = types
self.size = (size, size) if isinstance(size, int) else size 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.desc # guaranteed to be icon, mask, or iconmask
return self.types[-1] return self.types[-1]
def decompress(self, data: bytes, ext: Optional[str] = '-?-') -> Optional[ def decompress(self, data: bytes, ext: Optional[str] = '-?-') \
List[int]]: -> Optional[List[int]]:
''' Returns None if media is not decompressable. ''' ''' Returns None if media is not decompressable. '''
if self.compressable: if self.compressable:
if ext == '-?-': 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('icnV', ['bin'], desc='4-byte Icon Composer.app bundle version'),
Media('name', ['bin'], desc='Unknown'), Media('name', ['bin'], desc='Unknown'),
Media('info', ['plist'], desc='Info binary plist'), Media('info', ['plist'], desc='Info binary plist'),
)} # type: Dict[Media.KeyT, Media] )}
def enum_img_mask_pairs(available_keys: Iterable[Media.KeyT]) -> Iterator[ def enum_img_mask_pairs(available_keys: Iterable[Media.KeyT]) \
Tuple[Optional[str], Optional[str]]]: -> Iterator[Tuple[Optional[str], Optional[str]]]:
for mask_k, *imgs in [ # list probably never changes, ARGB FTW for mask_k, *imgs in [ # list probably never changes, ARGB FTW
('s8mk', 'is32', 'ics8', 'ics4', 'icp4'), ('s8mk', 'is32', 'ics8', 'ics4', 'icp4'),
('l8mk', 'il32', 'icl8', 'icl4', 'icp5'), ('l8mk', 'il32', 'icl8', 'icl4', 'icp5'),
@@ -186,8 +193,8 @@ def enum_img_mask_pairs(available_keys: Iterable[Media.KeyT]) -> Iterator[
yield None, mk yield None, mk
def enum_png_convertable(available_keys: Iterable[Media.KeyT]) -> Iterator[ def enum_png_convertable(available_keys: Iterable[Media.KeyT]) \
Tuple[Media.KeyT, Optional[Media.KeyT]]]: -> Iterator[Tuple[Media.KeyT, Optional[Media.KeyT]]]:
''' Yield (image-key, mask-key or None) ''' ''' Yield (image-key, mask-key or None) '''
for img in _TYPES.values(): for img in _TYPES.values():
if img.key not in available_keys: if img.key not in available_keys:
@@ -219,8 +226,8 @@ def key_from_readable(key: str) -> Media.KeyT:
'selected': 'slct', 'selected': 'slct',
'template': 'sbtp', 'template': 'sbtp',
'toc': 'TOC ', '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: def match_maxsize(total: int, typ: str) -> Media:

View File

@@ -7,7 +7,7 @@ def pack(data: List[int]) -> bytes:
buf = [] # type: List[int] buf = [] # type: List[int]
i = 0 i = 0
def flush_buf(): def flush_buf() -> None:
# write out non-repeating bytes # write out non-repeating bytes
if len(buf) > 0: if len(buf) > 0:
ret.append(len(buf) - 1) ret.append(len(buf) - 1)

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import struct # pack, unpack import struct # pack, unpack
from typing import Union, Optional, Tuple, Iterator, BinaryIO from typing import Optional, Tuple, Iterator, BinaryIO
from . import IcnsType, PackBytes 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 return data[:4], length # Fallback to bytes-string key
def icns_header_write_data(fp: BinaryIO, key: IcnsType.Media.KeyT, def icns_header_write_data(
data: bytes) -> None: fp: BinaryIO, key: IcnsType.Media.KeyT, data: bytes,
) -> None:
''' Calculates length from data. ''' ''' Calculates length from data. '''
fp.write(key.encode('utf8') if isinstance(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(struct.pack('>I', len(data) + 8))

View File

@@ -4,9 +4,8 @@ Export existing icns files or compose new ones.
''' '''
import os # path, makedirs import os # path, makedirs
import sys # path, stderr import sys # path, stderr
from typing import Iterator, Optional, Callable from typing import Iterator, Optional, Callable, List
from argparse import ArgumentParser, ArgumentTypeError, RawTextHelpFormatter from argparse import ArgumentParser, ArgumentTypeError, Namespace as ArgParams
from argparse import Namespace as ArgParams
if __name__ == '__main__': if __name__ == '__main__':
sys.path[0] = os.path.dirname(sys.path[0]) sys.path[0] = os.path.dirname(sys.path[0])
from icnsutil import __version__, IcnsFile, IcnsType, ArgbImage 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)) has_changes |= icns.remove_media(IcnsType.key_from_readable(x))
# add media # add media
for key_val in args.set or []: 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': if key_val.lower() == 'toc':
key_val = 'toc=1' key_val = 'toc=1'
if '=' not in key_val: if '=' not in key_val:
fail() raise ArgumentTypeError(
key, val = key_val.split('=') 'Expected arg format KEY=FILE - got "{}"'.format(key_val))
key, val = key_val.split('=', 1)
if not val: if not val:
fail() raise ArgumentTypeError(
'Expected arg format KEY=FILE - got "{}"'.format(key_val))
has_changes = True has_changes = True
if key.lower() == 'toc': if key.lower() == 'toc':
@@ -130,7 +128,7 @@ def cli_convert(args: ArgParams) -> None:
exit(1) 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: for x in file_arg:
if x == '-': if x == '-':
for line in sys.stdin.readlines(): for line in sys.stdin.readlines():
@@ -155,18 +153,18 @@ def main() -> None:
return path return path
# Args Parser # Args Parser
parser = ArgumentParser(description=__doc__, parser = ArgumentParser(description=__doc__)
formatter_class=RawTextHelpFormatter)
parser.set_defaults(func=lambda _: parser.print_help(sys.stdout)) parser.set_defaults(func=lambda _: parser.print_help(sys.stdout))
parser.add_argument( parser.add_argument(
'-v', '--version', action='version', version='icnsutil ' + __version__) '-v', '--version', action='version', version='icnsutil ' + __version__)
sub_parser = parser.add_subparsers(metavar='command') sub_parser = parser.add_subparsers(metavar='command')
# helper method # 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 '' desc = fn.__doc__ or ''
cmd = sub_parser.add_parser(name, aliases=[alias], cmd = sub_parser.add_parser(name, aliases=[alias],
formatter_class=RawTextHelpFormatter,
help=desc, description=desc.strip()) help=desc, description=desc.strip())
cmd.set_defaults(func=fn) cmd.set_defaults(func=fn)
return cmd return cmd
@@ -189,22 +187,20 @@ def main() -> None:
# Compose # Compose
cmd = add_command('compose', 'c', cli_compose) cmd = add_command('compose', 'c', cli_compose)
cmd.add_argument('-f', '--force', action='store_true', cmd.add_argument('-f', '--force', action='store_true',
help='force overwrite output file') help='Force overwrite output file')
cmd.add_argument('--toc', action='store_true', cmd.add_argument('--toc', action='store_true', help='''
help='write table of contents to file') 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', cmd.add_argument('target', type=str, metavar='destination',
help='Output file for newly created icns file.') help='Output file for newly created icns file.')
cmd.add_argument('source', type=PathExist('f', stdin=True), nargs='+', cmd.add_argument('source', type=PathExist('f', stdin=True), nargs='+',
metavar='src', metavar='src', help='''
help='One or more media files: png, argb, rgb, jp2, icns') One or more media files: png, argb, rgb, jp2, icns.
cmd.epilog = ''' --
Notes: Icon dimensions are read directly from file.
- TOC is optional but only a few bytes long (8b per media entry). Filename suffixes "@2x.png" and "@2x.jp2" will set the retina flag.
- Icon dimensions are read directly from file. If the suffix ends on one of these (template, selected, dark),
- Filename suffix "@2x.png" or "@2x.jp2" sets the retina flag. the file is automatically assigned to an icns file field.''')
- Use one of these suffixes to automatically assign icns files:
template, selected, dark
'''
# Update # Update
cmd = add_command('update', 'u', cli_update) cmd = add_command('update', 'u', cli_update)