- Rate-limit failed logins (8 per 15 min per IP or username, 429 + Retry-After) - Add ruff/mypy config and GitHub Actions workflow (ruff, pytest, tsc, tests, build) - Apply ruff autofixes (import order, pyupgrade) - Extract rpy_helpers.py, engine-util.ts, TranslationParts.tsx and translation-types.ts from oversized modules - Add dependency-free React UI tests - Refresh README and ARCHITECTURE (Naninovel/Tomie, status and plans) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
82 lines
4.3 KiB
Python
82 lines
4.3 KiB
Python
"""Read-only compatibility check, shared by uploads, CLI and the converter."""
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
from app.converter.archive import extract
|
|
from app.converter.compatibility import CompatibilityError, Issue, detect
|
|
|
|
|
|
def probe_archive(source: Path, filename: str) -> dict:
|
|
"""Cheap distribution check before queueing: list entries, never extract media."""
|
|
if zipfile.is_zipfile(source):
|
|
with zipfile.ZipFile(source) as archive:
|
|
entries = [entry.filename.replace("\\", "/") for entry in archive.infolist()]
|
|
else:
|
|
seven = shutil.which("7zz") or shutil.which("7z")
|
|
if not seven:
|
|
from app.converter.archive import ArchiveError
|
|
raise ArchiveError("Для проверки этого архива нужен 7-Zip")
|
|
try:
|
|
process = subprocess.run([seven, "l", "-slt", "-ba", "-sccUTF-8", str(source)],
|
|
capture_output=True, encoding="utf-8", errors="replace", timeout=120)
|
|
except subprocess.TimeoutExpired:
|
|
from app.converter.archive import ArchiveError
|
|
raise ArchiveError("Превышено время чтения списка файлов архива") from None
|
|
if process.returncode:
|
|
from app.converter.archive import ArchiveError
|
|
raise ArchiveError("Не удалось прочитать архив: " + (process.stderr or process.stdout)[-500:])
|
|
entries = [line[7:].replace("\\", "/") for line in process.stdout.splitlines() if line.startswith("Path = ")]
|
|
from app.converter.compatibility import IGNORED
|
|
entries = [entry.lower() for entry in entries
|
|
if not any(part.lower() in IGNORED for part in Path(entry).parts)]
|
|
engines = set()
|
|
if any("naninovel" in entry.rsplit("/", 1)[-1] and entry.endswith(".dll") for entry in entries):
|
|
engines.add("naninovel")
|
|
elif any("_data/managed/" in entry or "_data/il2cpp_data/" in entry or entry.endswith("_data/data.unity3d") for entry in entries):
|
|
engines.add("unity")
|
|
if any(entry.rsplit("/", 1)[-1] == "data.win" for entry in entries):
|
|
engines.add("gamemaker")
|
|
if any(entry.endswith((".pck", "/project.godot")) or entry == "project.godot" for entry in entries):
|
|
engines.add("godot")
|
|
if any(entry.endswith((".rgss3a", ".rgss2a", ".rgssad", "/rpg_core.js", "/rmmz_core.js")) for entry in entries):
|
|
engines.add("rpgmaker")
|
|
if any(entry.endswith((".rpy", ".rpyc", ".rpa")) and "renpy/" not in entry for entry in entries):
|
|
engines.add("renpy")
|
|
if len(engines) != 1:
|
|
raise CompatibilityError("ambiguous" if engines else "unknown", [Issue(
|
|
"multiple_engines" if engines else "unknown_engine",
|
|
"В архиве несколько движков: " + ", ".join(sorted(engines)) if engines else
|
|
"Движок не распознан; нужен полный дистрибутив игры")])
|
|
engine = engines.pop()
|
|
if engine not in {"renpy", "naninovel"}:
|
|
raise CompatibilityError(engine, [Issue("unsupported_engine", f"Движок {engine} пока не поддерживается")])
|
|
return {"engine": engine, "scriptsChecked": False}
|
|
|
|
|
|
def inspect_directory(root: Path, *, work: Path | None = None) -> dict:
|
|
detection = detect(root)
|
|
if detection.engine == "renpy":
|
|
from app.converter.analyze import analyze
|
|
report = analyze(detection.root)
|
|
issues = [Issue(**issue) for issue in report.pop("issues", [])]
|
|
if not report["hasStart"]:
|
|
issues.append(Issue("missing_entry", "Не найдена точка входа start"))
|
|
if issues:
|
|
raise CompatibilityError("renpy", issues)
|
|
return {"engine": "renpy", "compatible": True, **report}
|
|
from app.converter.naninovel import inspect_naninovel
|
|
return inspect_naninovel(detection.root, work=work)
|
|
|
|
|
|
def inspect_archive(source: Path, filename: str) -> dict:
|
|
if source.is_dir():
|
|
return inspect_directory(source)
|
|
with tempfile.TemporaryDirectory(prefix="pgames-preflight-") as directory:
|
|
root = Path(directory)
|
|
probe = probe_archive(source, filename)
|
|
extract(source, filename, root, only_scripts=probe["engine"] == "renpy")
|
|
return inspect_directory(root, work=root / "_inspection")
|