"""Package an existing macOS app with Unix symlinks intact (stdlib only).""" import argparse import os from pathlib import Path import stat import time import zipfile def package(app, output): app = Path(app).resolve() output = Path(output).resolve() assert app.is_dir() and app.suffix == '.app' with zipfile.ZipFile(output, 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive: def add(path): st = path.lstat() name = path.relative_to(app.parent).as_posix() is_dir = stat.S_ISDIR(st.st_mode) info = zipfile.ZipInfo(name + ('/' if is_dir else ''), time.localtime(st.st_mtime)[:6]) info.create_system = 3 info.external_attr = st.st_mode << 16 if is_dir: info.external_attr |= 0x10 info.compress_type = zipfile.ZIP_STORED if is_dir else zipfile.ZIP_DEFLATED if stat.S_ISLNK(st.st_mode): archive.writestr(info, os.readlink(path).encode('utf-8')) elif is_dir: archive.writestr(info, b'') for child in sorted(path.iterdir()): add(child) elif stat.S_ISREG(st.st_mode): with path.open('rb') as src, archive.open(info, 'w') as dst: while chunk := src.read(1024 * 1024): dst.write(chunk) else: raise ValueError(f'Unsupported file type: {path}') add(app) with zipfile.ZipFile(output) as archive: assert archive.testzip() is None for item in archive.infolist(): source = app.parent / item.filename.rstrip('/') assert (item.external_attr >> 16) == source.lstat().st_mode if source.is_symlink(): assert archive.read(item).decode('utf-8') == os.readlink(source) print(output) if __name__ == '__main__': parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('app') parser.add_argument('output') args = parser.parse_args() package(args.app, args.output)