mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-24 12:49:03 -05:00
360 lines
13 KiB
Python
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)
|