Files
libretro/scripts/split_pack.py
T

360 lines
13 KiB
Python

#!/usr/bin/env python3
"""Publish a pack too large for one release asset as several ZIPs.
GitHub refuses a release file of 2 GiB or more. A pack over that is written
as parts, each a complete archive of whole files: any tool opens one, and
the parts extracted into the same folder are the pack. Members are copied
as stored, never recompressed, so the parts depend on the pack alone.
Each part also carries a short text file, its marker, at the archive root:
the pack it belongs to, its number, and the name and size of every part.
Extracted with the files, the markers let `install.py --check` name a part
that is missing from the folder.
Usage:
python scripts/split_pack.py dist/
python scripts/split_pack.py dist/RetroArch_v1.22.2_BIOS_Pack.zip
python scripts/split_pack.py dist/ --max-size 1900M
"""
from __future__ import annotations
import argparse
import io
import re
import struct
import sys
import zipfile
import zlib
from pathlib import Path
from artifacts import ArtifactLockBusy, artifact_lock
# "Each file included in a release must be under 2 GiB."
ASSET_LIMIT = 2 * 1024**3
PACK_SUFFIX = "_BIOS_Pack.zip"
_PART_NAME = re.compile(r"^(?P<stem>.+_BIOS_Pack)\.part(?P<index>\d+)of(?P<count>\d+)\.zip$")
_MARKER_NAME = re.compile(r"^(?P<stem>.+_BIOS_Pack)\.part(?P<index>\d+)of(?P<count>\d+)\.txt$")
# Sizes are written at a fixed width, so a marker's length, and with it the
# size of its own part, does not depend on the sizes it records.
_SIZE_WIDTH = 13
_LOCAL = struct.Struct("<4s2B4HL2L2H")
_CENTRAL = struct.Struct("<4s4B4HL2L5H2L")
_END = struct.Struct("<4s4H2LH")
_LOCAL_SIG = b"PK\x03\x04"
_CENTRAL_SIG = b"PK\x01\x02"
_END_SIG = b"PK\x05\x06"
_DATA_DESCRIPTOR = 0x08
_UTF8_NAME = 0x800
_CHUNK = 1024 * 1024
_MAX_ENTRIES = 0xFFFF
_MAX_FIELD = 0xFFFFFFFF
def is_part(name: str) -> bool:
"""Whether a file name is one part of a split pack."""
return _PART_NAME.match(name) is not None
def pack_of(name: str) -> str:
"""The pack a published file belongs to, a part folded into its archive."""
part = _PART_NAME.match(name)
return f"{part['stem']}.zip" if part else name
def part_name(pack_name: str, index: int, count: int) -> str:
return f"{pack_name[: -len('.zip')]}.part{index}of{count}.zip"
def marker_name(pack_name: str, index: int, count: int) -> str:
return f"{pack_name[: -len('.zip')]}.part{index}of{count}.txt"
def is_marker(name: str) -> bool:
"""Whether an archive member is the marker a split writes in each part."""
return _MARKER_NAME.match(name) is not None
def marker_text(pack_name: str, index: int, sizes: list[int]) -> bytes:
"""The marker of part *index* of a pack published in ``len(sizes)`` parts.
Plain text a person can read, in a form install.py parses back: the
lines that matter are `pack:`, `part:` and one `file:` per part.
"""
count = len(sizes)
lines = [
"RetroBIOS split pack",
f"This folder holds part {index} of {count} of {pack_name}.",
"Every part is needed: extract them all into this same folder.",
"The installer's --check names a part missing from the folder.",
"",
f"pack: {pack_name}",
f"part: {index} of {count}",
]
lines += [
f"file: {size:>{_SIZE_WIDTH}} {part_name(pack_name, number, count)}"
for number, size in enumerate(sizes, 1)
]
return ("\n".join(lines) + "\n").encode("utf-8")
def _marker_weight(pack_name: str, index: int, count: int) -> int:
name = len(marker_name(pack_name, index, count).encode("utf-8"))
body = len(marker_text(pack_name, index, [0] * count))
return _LOCAL.size + name + body + _CENTRAL.size + name
def _name_bytes(info: zipfile.ZipInfo) -> bytes:
encoding = "utf-8" if info.flag_bits & _UTF8_NAME else "cp437"
return info.orig_filename.encode(encoding)
def _weight(info: zipfile.ZipInfo) -> int:
"""Bytes a member adds to a part: both headers and its stored data."""
name = len(_name_bytes(info))
return _LOCAL.size + name + info.compress_size + _CENTRAL.size + name
def plan_parts(
members: list[zipfile.ZipInfo], limit: int, reserve: int = 0
) -> list[list[zipfile.ZipInfo]]:
"""Fill each part in archive order, up to the limit less *reserve*."""
parts: list[list[zipfile.ZipInfo]] = [[]]
floor = _END.size + reserve
size = floor
for info in members:
weight = _weight(info)
if floor + weight >= limit:
raise ValueError(
f"{info.filename} needs {weight:,} bytes, more than a part of "
f"{limit:,} can hold"
)
if info.compress_size > _MAX_FIELD or info.file_size > _MAX_FIELD:
raise ValueError(f"{info.filename} needs ZIP64, which a part does not use")
if size + weight >= limit or len(parts[-1]) >= _MAX_ENTRIES - 1:
parts.append([])
size = floor
parts[-1].append(info)
size += weight
return parts
def _dos_stamp(info: zipfile.ZipInfo) -> tuple[int, int]:
year, month, day, hour, minute, second = info.date_time
return (
(hour << 11) | (minute << 5) | (second // 2),
((year - 1980) << 9) | (month << 5) | day,
)
def _data_offset(source: io.BufferedReader, info: zipfile.ZipInfo) -> int:
"""Where a member's stored bytes begin, read from its own local header."""
source.seek(info.header_offset)
header = _LOCAL.unpack(source.read(_LOCAL.size))
if header[0] != _LOCAL_SIG:
raise ValueError(f"{info.filename}: no local header at its offset")
return info.header_offset + _LOCAL.size + header[10] + header[11]
def _marker_info(name: str, body: bytes) -> zipfile.ZipInfo:
info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
info.compress_type = zipfile.ZIP_STORED
info.CRC = zlib.crc32(body)
info.compress_size = info.file_size = len(body)
info.create_system = 3
info.external_attr = (0o100000 | 0o644) << 16
return info
def _write_part(
source: io.BufferedReader, members: list[zipfile.ZipInfo], dest: Path,
marker: tuple[str, bytes],
) -> None:
directory = []
marker_info = _marker_info(*marker)
with open(dest, "wb") as out:
for info in [marker_info, *members]:
name = _name_bytes(info)
flags = info.flag_bits & ~_DATA_DESCRIPTOR
dos_time, dos_date = _dos_stamp(info)
offset = out.tell()
out.write(
_LOCAL.pack(
_LOCAL_SIG, info.extract_version, info.reserved, flags,
info.compress_type, dos_time, dos_date, info.CRC,
info.compress_size, info.file_size, len(name), 0,
)
)
out.write(name)
if info is marker_info:
out.write(marker[1])
remaining = 0
else:
source.seek(_data_offset(source, info))
remaining = info.compress_size
while remaining:
chunk = source.read(min(_CHUNK, remaining))
if not chunk:
raise ValueError(f"{info.filename}: the pack ends inside it")
out.write(chunk)
remaining -= len(chunk)
directory.append(
_CENTRAL.pack(
_CENTRAL_SIG, info.create_version, info.create_system,
info.extract_version, info.reserved, flags,
info.compress_type, dos_time, dos_date, info.CRC,
info.compress_size, info.file_size, len(name), 0, 0, 0,
info.internal_attr, info.external_attr, offset,
)
+ name
)
start = out.tell()
for record in directory:
out.write(record)
out.write(
_END.pack(
_END_SIG, 0, 0, len(directory), len(directory),
out.tell() - start, start, 0,
)
)
def _identity(archive: zipfile.ZipFile) -> list[tuple[str, int, int]]:
return [
(info.filename, info.CRC, info.file_size)
for info in archive.infolist()
if not is_marker(info.filename)
]
def marker_weight(pack_name: str, count: int) -> int:
"""Bytes the largest marker of a pack in *count* parts adds to its part."""
return max(_marker_weight(pack_name, index, count) for index in (1, count))
def _plan_with_markers(
pack_name: str, members: list[zipfile.ZipInfo], limit: int
) -> list[list[zipfile.ZipInfo]]:
"""Plan the parts with room for the marker each one carries.
The marker lists every part, so its length grows with their count: the
plan is made again until the count it assumed is the count it gives.
"""
count = 1
while True:
reserve = marker_weight(pack_name, count)
plan = plan_parts(members, limit, reserve)
if len(plan) <= count:
return plan
count = len(plan)
def split_pack(zip_path: Path, limit: int = ASSET_LIMIT) -> list[Path]:
"""Replace a pack at or over the limit by parts that each fit under it.
Returns the files to publish. The pack is removed only once every part
has been read back in full and the parts together list exactly its
members, so a failure leaves the pack as it was and no part behind.
"""
zip_path = Path(zip_path)
if zip_path.stat().st_size < limit:
return [zip_path]
with zipfile.ZipFile(zip_path) as archive:
members = archive.infolist()
expected = _identity(archive)
taken = [info.filename for info in members if is_marker(info.filename)]
if taken:
raise ValueError(f"{zip_path.name} already holds {taken[0]}, a part marker name")
plan = _plan_with_markers(zip_path.name, members, limit)
count = len(plan)
parts = [
zip_path.with_name(part_name(zip_path.name, index, count))
for index in range(1, count + 1)
]
sizes = [
_END.size + _marker_weight(zip_path.name, index, count)
+ sum(_weight(info) for info in chosen)
for index, chosen in enumerate(plan, 1)
]
try:
with open(zip_path, "rb") as source:
for index, (dest, chosen) in enumerate(zip(parts, plan), 1):
marker = (marker_name(zip_path.name, index, count),
marker_text(zip_path.name, index, sizes))
_write_part(source, chosen, dest, marker)
if dest.stat().st_size != sizes[index - 1]:
raise ValueError(
f"{dest.name} is {dest.stat().st_size:,} bytes, its marker "
f"says {sizes[index - 1]:,}"
)
found: list[tuple[str, int, int]] = []
for dest in parts:
with zipfile.ZipFile(dest) as archive:
damaged = archive.testzip()
if damaged:
raise ValueError(f"{dest.name}: {damaged} does not read back")
found.extend(_identity(archive))
if dest.stat().st_size >= limit:
raise ValueError(f"{dest.name} reached the limit of {limit:,} bytes")
if found != expected:
raise ValueError(f"the parts of {zip_path.name} do not add up to it")
except (OSError, ValueError, zipfile.BadZipFile):
for dest in parts:
dest.unlink(missing_ok=True)
raise
zip_path.unlink()
return parts
def split_directory(directory: Path, limit: int = ASSET_LIMIT) -> list[Path]:
"""Split every pack of a directory that needs it. Returns what to publish."""
published: list[Path] = []
for path in sorted(Path(directory).glob(f"*{PACK_SUFFIX}")):
published.extend(split_pack(path, limit))
return published
def _size(text: str) -> int:
match = re.fullmatch(r"(\d+)([KMG]?)", text.strip().upper())
if not match:
raise argparse.ArgumentTypeError(f"not a size: {text}")
return int(match[1]) * 1024 ** " KMG".index(match[2] or " ")
def main() -> int:
parser = argparse.ArgumentParser(
allow_abbrev=False,
description="Split packs over the release asset limit into ZIP parts"
)
parser.add_argument("target", type=Path, help="a pack, or a directory of packs")
parser.add_argument(
"--max-size", type=_size, default=ASSET_LIMIT, metavar="SIZE",
help="a part stays under this many bytes (K, M, G suffixes, default 2G)",
)
args = parser.parse_args()
# The packs are rewritten in place by generate_pack under this lock: a
# build running beside the split mixes its bytes into the pack being
# read, or writes a pack that the split then deletes.
directory = args.target if args.target.is_dir() else args.target.parent
try:
with artifact_lock(str(directory)):
if args.target.is_dir():
published = split_directory(args.target, args.max_size)
else:
published = split_pack(args.target, args.max_size)
except (ArtifactLockBusy, OSError, ValueError, zipfile.BadZipFile) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
for path in published:
print(f"{path.stat().st_size:>13,} {path.name}")
return 0
if __name__ == "__main__":
from common import run_cli
run_cli(main)