101 lines
3.9 KiB
Python
101 lines
3.9 KiB
Python
"""Archive extraction and locating the game directory inside it."""
|
|
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
|
|
class ArchiveError(Exception):
|
|
pass
|
|
|
|
|
|
def _safe_target(root: Path, name: str) -> Path | None:
|
|
target = (root / name).resolve()
|
|
return target if target.is_relative_to(root.resolve()) else None
|
|
|
|
|
|
def extract(archive: Path, filename: str, dest: Path, *, only_scripts: bool = False) -> None:
|
|
dest.mkdir(parents=True, exist_ok=True)
|
|
if filename.lower().endswith(".zip") and zipfile.is_zipfile(archive):
|
|
_extract_zip(archive, dest, only_scripts=only_scripts)
|
|
return
|
|
seven = shutil.which("7zz") or shutil.which("7z")
|
|
if not seven:
|
|
raise ArchiveError("Для этого формата нужен 7-Zip (7zz), он не установлен")
|
|
filters = [f"-ir!*{suffix}" for suffix in SCRIPT_EXTS | {".tar"}] if only_scripts else []
|
|
proc = subprocess.run([seven, "x", "-y", "-snl-", *filters, f"-o{dest}", str(archive)],
|
|
capture_output=True, text=True)
|
|
if proc.returncode not in (0, 1): # 1 = warnings
|
|
raise ArchiveError(f"7-Zip: {proc.stderr.strip() or proc.stdout.strip()[-500:]}")
|
|
_drop_escaping_links(dest)
|
|
# .tar.gz etc. extract to a single inner .tar first
|
|
inner = list(dest.iterdir())
|
|
if len(inner) == 1 and inner[0].suffix == ".tar":
|
|
tar = inner[0]
|
|
extract(tar, tar.name, dest / "_tar", only_scripts=only_scripts)
|
|
tar.unlink()
|
|
|
|
|
|
SCRIPT_EXTS = {".rpy", ".rpyc", ".rpa", ".rpym", ".rpymc"}
|
|
|
|
|
|
def _extract_zip(archive: Path, dest: Path, *, only_scripts: bool = False) -> None:
|
|
with zipfile.ZipFile(archive) as zf:
|
|
for info in zf.infolist():
|
|
name = info.filename
|
|
if only_scripts and Path(name).suffix.lower() not in SCRIPT_EXTS:
|
|
continue
|
|
if info.flag_bits & 0x800 == 0:
|
|
# legacy zips store names in cp437; many were really written as cp866/utf-8
|
|
try:
|
|
name = name.encode("cp437").decode("utf-8")
|
|
except (UnicodeEncodeError, UnicodeDecodeError):
|
|
pass
|
|
target = _safe_target(dest, name)
|
|
if target is None:
|
|
continue
|
|
if info.is_dir():
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
continue
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
with zf.open(info) as src, target.open("wb") as out:
|
|
shutil.copyfileobj(src, out, 1 << 20)
|
|
|
|
|
|
def _drop_escaping_links(root: Path) -> None:
|
|
for dirpath, dirnames, filenames in os.walk(root):
|
|
for name in dirnames + filenames:
|
|
p = Path(dirpath, name)
|
|
if p.is_symlink():
|
|
p.unlink()
|
|
|
|
|
|
def find_game_dir(root: Path) -> Path:
|
|
"""Find the Ren'Py `game` directory (the one holding scripts or archives)."""
|
|
best: tuple[int, Path] | None = None
|
|
for dirpath, _, filenames in os.walk(root):
|
|
exts = {Path(f).suffix.lower() for f in filenames}
|
|
if not exts & {".rpy", ".rpyc", ".rpa", ".rpymc"}:
|
|
continue
|
|
p = Path(dirpath)
|
|
depth = len(p.relative_to(root).parts)
|
|
score = (0 if p.name.lower() == "game" else 100) + depth
|
|
# Only engine internals inside this distribution matter. A collection's
|
|
# parent directory may itself be named Renpy.
|
|
if p.name.lower() in ("renpy", "common") or "renpy" in [x.lower() for x in p.relative_to(root).parts]:
|
|
continue
|
|
if best is None or score < best[0]:
|
|
best = (score, p)
|
|
if best is None:
|
|
raise ArchiveError("В архиве не найдена игра Ren'Py (нет .rpy/.rpyc/.rpa)")
|
|
game = best[1]
|
|
# scripts in a subfolder of game/ — walk up to game/ itself
|
|
for parent in [game, *game.parents]:
|
|
if parent.name.lower() == "game":
|
|
return parent
|
|
if parent == root:
|
|
break
|
|
return game
|