mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
fix(setup): 精确同步当前站点资源 ABI (#6444)
This commit is contained in:
+119
-46
@@ -15,6 +15,7 @@ import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import sysconfig
|
||||
import tarfile
|
||||
import textwrap
|
||||
import urllib.parse
|
||||
@@ -996,32 +997,76 @@ def install_frontend(
|
||||
|
||||
|
||||
def local_resource_status() -> bool:
|
||||
return (
|
||||
SITE_RESOURCE_DIR / f"user.sites.{RESOURCE_VERSION_FLAG}.bin"
|
||||
).exists() and bool(
|
||||
list(SITE_RESOURCE_DIR.glob("sites*"))
|
||||
platform_tag, machine = _get_platform_tag()
|
||||
required_files = _get_runtime_resource_filenames(
|
||||
platform_tag,
|
||||
machine,
|
||||
_get_python_version_tag(),
|
||||
)
|
||||
return all((SITE_RESOURCE_DIR / filename).is_file() for filename in required_files)
|
||||
|
||||
|
||||
def copy_resource_files(source_dir: Path) -> list[str]:
|
||||
if not source_dir.is_dir():
|
||||
raise FileNotFoundError(f"资源目录不存在:{source_dir}")
|
||||
|
||||
copied: list[str] = []
|
||||
for source in sorted(source_dir.iterdir()):
|
||||
if source.is_dir():
|
||||
continue
|
||||
target = SITE_RESOURCE_DIR / source.name
|
||||
shutil.copy2(source, target)
|
||||
copied.append(source.name)
|
||||
platform_tag, machine = _get_platform_tag()
|
||||
python_version = _get_python_version_tag()
|
||||
selected = _require_runtime_resource_files(
|
||||
source_dir,
|
||||
platform_tag,
|
||||
machine,
|
||||
python_version,
|
||||
)
|
||||
SITE_RESOURCE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_replace_resource_files(selected)
|
||||
|
||||
if not copied:
|
||||
raise RuntimeError(f"资源目录中未找到可复制文件:{source_dir}")
|
||||
active_native_name = selected[-1].name
|
||||
for target in SITE_RESOURCE_DIR.iterdir():
|
||||
if (
|
||||
target.is_file()
|
||||
and target.name.startswith("sites.")
|
||||
and target.suffix.lower() in {".so", ".pyd", ".dylib"}
|
||||
and target.name != active_native_name
|
||||
):
|
||||
target.unlink()
|
||||
print_step(f"已同步资源文件到 {SITE_RESOURCE_DIR}")
|
||||
return copied
|
||||
return [source.name for source in selected]
|
||||
|
||||
|
||||
def _get_platform_tag() -> str:
|
||||
def _replace_resource_files(selected: list[Path]) -> None:
|
||||
"""完整暂存运行资源,并在替换失败时恢复原目标文件。"""
|
||||
with TemporaryDirectory(prefix=".sites-sync-", dir=SITE_RESOURCE_DIR) as temp_dir:
|
||||
transaction_dir = Path(temp_dir)
|
||||
staging_dir = transaction_dir / "staging"
|
||||
backup_dir = transaction_dir / "backup"
|
||||
staging_dir.mkdir()
|
||||
backup_dir.mkdir()
|
||||
for source in selected:
|
||||
shutil.copy2(source, staging_dir / source.name)
|
||||
|
||||
installed: list[Path] = []
|
||||
backups: dict[Path, Path] = {}
|
||||
try:
|
||||
for source in selected:
|
||||
target = SITE_RESOURCE_DIR / source.name
|
||||
backup = backup_dir / source.name
|
||||
if target.exists():
|
||||
os.replace(target, backup)
|
||||
backups[target] = backup
|
||||
os.replace(staging_dir / source.name, target)
|
||||
installed.append(target)
|
||||
except OSError:
|
||||
for target in reversed(installed):
|
||||
if target.exists():
|
||||
target.unlink()
|
||||
for target, backup in backups.items():
|
||||
if backup.exists():
|
||||
os.replace(backup, target)
|
||||
raise
|
||||
|
||||
|
||||
def _get_platform_tag() -> tuple[str, str]:
|
||||
system = platform.system().lower()
|
||||
machine = platform.machine().lower()
|
||||
if system == "darwin":
|
||||
@@ -1041,40 +1086,71 @@ def _get_platform_tag() -> str:
|
||||
|
||||
def _get_python_version_tag() -> str:
|
||||
version = sys.version_info
|
||||
return f"cp{version.major}{version.minor}"
|
||||
free_threaded = "t" if sysconfig.get_config_var("Py_GIL_DISABLED") else ""
|
||||
return f"cp{version.major}{version.minor}{free_threaded}"
|
||||
|
||||
|
||||
def _get_runtime_resource_filenames(
|
||||
platform_tag: str,
|
||||
machine: str,
|
||||
python_version: str,
|
||||
) -> tuple[str, str]:
|
||||
"""返回当前解释器、系统和架构唯一对应的 V3 资源文件名。"""
|
||||
python_tag = python_version.removeprefix("cp")
|
||||
data_filename = f"user.sites.{RESOURCE_VERSION_FLAG}.bin"
|
||||
if platform_tag == "windows":
|
||||
native_filename = f"sites.cp{python_tag}-win_amd64.pyd"
|
||||
elif platform_tag == "darwin":
|
||||
native_filename = f"sites.cpython-{python_tag}-darwin.so"
|
||||
elif platform_tag == "linux":
|
||||
native_filename = f"sites.cpython-{python_tag}-{machine}-linux-gnu.so"
|
||||
else:
|
||||
raise RuntimeError(f"不支持的平台标签:{platform_tag}")
|
||||
return data_filename, native_filename
|
||||
|
||||
|
||||
def _filter_resources_files(
|
||||
source_dir: Path,
|
||||
platform_tag: str,
|
||||
machine: str,
|
||||
python_version: str,
|
||||
) -> list[Path]:
|
||||
"""筛选 V3 资源中与当前 Python 平台匹配的运行文件。"""
|
||||
matched_files: list[Path] = []
|
||||
for file in source_dir.iterdir():
|
||||
if not file.is_file():
|
||||
continue
|
||||
filename = file.name
|
||||
if filename == f"user.sites.{RESOURCE_VERSION_FLAG}.bin":
|
||||
matched_files.append(file)
|
||||
continue
|
||||
if not filename.startswith("sites."):
|
||||
continue
|
||||
if platform_tag == "windows":
|
||||
if filename == f"sites.cp{python_version.replace('cp', '')}-win_amd64.pyd":
|
||||
matched_files.append(file)
|
||||
elif platform_tag == "darwin":
|
||||
if (
|
||||
filename
|
||||
== f"sites.cpython-{python_version.replace('cp', '')}-darwin.so"
|
||||
):
|
||||
matched_files.append(file)
|
||||
elif platform_tag == "linux":
|
||||
if (
|
||||
f"cpython-{python_version.replace('cp', '')}" in filename
|
||||
and "linux-gnu" in filename
|
||||
):
|
||||
matched_files.append(file)
|
||||
filenames = _get_runtime_resource_filenames(
|
||||
platform_tag,
|
||||
machine,
|
||||
python_version,
|
||||
)
|
||||
return [
|
||||
source_dir / filename
|
||||
for filename in filenames
|
||||
if (source_dir / filename).is_file()
|
||||
]
|
||||
|
||||
|
||||
def _require_runtime_resource_files(
|
||||
source_dir: Path,
|
||||
platform_tag: str,
|
||||
machine: str,
|
||||
python_version: str,
|
||||
) -> list[Path]:
|
||||
"""返回完整运行资源;缺少数据包或原生扩展时拒绝部分同步。"""
|
||||
required_names = _get_runtime_resource_filenames(
|
||||
platform_tag,
|
||||
machine,
|
||||
python_version,
|
||||
)
|
||||
matched_files = _filter_resources_files(
|
||||
source_dir,
|
||||
platform_tag,
|
||||
machine,
|
||||
python_version,
|
||||
)
|
||||
matched_names = {path.name for path in matched_files}
|
||||
missing_names = [name for name in required_names if name not in matched_names]
|
||||
if missing_names:
|
||||
missing = "、".join(missing_names)
|
||||
raise RuntimeError(f"资源目录缺少当前运行时文件:{missing}")
|
||||
return matched_files
|
||||
|
||||
|
||||
@@ -1100,15 +1176,12 @@ def _download_resources_dir() -> Path:
|
||||
f"当前平台:{platform_name}-{machine},Python 版本:{python_version}"
|
||||
)
|
||||
|
||||
matched_files = _filter_resources_files(
|
||||
matched_files = _require_runtime_resource_files(
|
||||
source_dir,
|
||||
platform_name,
|
||||
machine,
|
||||
python_version,
|
||||
)
|
||||
if not matched_files:
|
||||
raise RuntimeError(
|
||||
f"未找到匹配的 sites 资源文件:{platform_name} / {python_version}"
|
||||
)
|
||||
|
||||
staging_dir = temp_path / "staging"
|
||||
staging_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import importlib.util
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "local_setup.py"
|
||||
|
||||
|
||||
def load_local_setup_module():
|
||||
"""以隔离模块名加载本地安装脚本,避免测试间共享模块状态。"""
|
||||
module_name = f"moviepilot_local_setup_resources_{uuid.uuid4().hex}"
|
||||
spec = importlib.util.spec_from_file_location(module_name, MODULE_PATH)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("gil_disabled", "suffix"),
|
||||
[
|
||||
(0, ""),
|
||||
(1, "t"),
|
||||
],
|
||||
)
|
||||
def test_python_version_tag_distinguishes_free_threaded_abi(
|
||||
monkeypatch, gil_disabled, suffix
|
||||
):
|
||||
module = load_local_setup_module()
|
||||
monkeypatch.setattr(
|
||||
module.sysconfig,
|
||||
"get_config_var",
|
||||
lambda name: gil_disabled if name == "Py_GIL_DISABLED" else None,
|
||||
)
|
||||
|
||||
expected = f"cp{sys.version_info.major}{sys.version_info.minor}{suffix}"
|
||||
assert module._get_python_version_tag() == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("platform_tag", "machine", "python_version", "expected"),
|
||||
[
|
||||
(
|
||||
"linux",
|
||||
"x86_64",
|
||||
"cp314",
|
||||
"sites.cpython-314-x86_64-linux-gnu.so",
|
||||
),
|
||||
(
|
||||
"linux",
|
||||
"x86_64",
|
||||
"cp314t",
|
||||
"sites.cpython-314t-x86_64-linux-gnu.so",
|
||||
),
|
||||
(
|
||||
"linux",
|
||||
"aarch64",
|
||||
"cp314",
|
||||
"sites.cpython-314-aarch64-linux-gnu.so",
|
||||
),
|
||||
(
|
||||
"linux",
|
||||
"aarch64",
|
||||
"cp314t",
|
||||
"sites.cpython-314t-aarch64-linux-gnu.so",
|
||||
),
|
||||
("darwin", "arm64", "cp314", "sites.cpython-314-darwin.so"),
|
||||
("darwin", "arm64", "cp314t", "sites.cpython-314t-darwin.so"),
|
||||
("windows", "amd64", "cp314", "sites.cp314-win_amd64.pyd"),
|
||||
("windows", "amd64", "cp314t", "sites.cp314t-win_amd64.pyd"),
|
||||
],
|
||||
)
|
||||
def test_filter_resources_files_selects_exact_runtime_artifact(
|
||||
tmp_path, platform_tag, machine, python_version, expected
|
||||
):
|
||||
module = load_local_setup_module()
|
||||
filenames = {
|
||||
"user.sites.v3.bin",
|
||||
"sites.pyi",
|
||||
"sites.cpython-314-x86_64-linux-gnu.so",
|
||||
"sites.cpython-314-aarch64-linux-gnu.so",
|
||||
"sites.cpython-314t-x86_64-linux-gnu.so",
|
||||
"sites.cpython-314t-aarch64-linux-gnu.so",
|
||||
"sites.cpython-314-darwin.so",
|
||||
"sites.cpython-314t-darwin.so",
|
||||
"sites.cp314-win_amd64.pyd",
|
||||
"sites.cp314t-win_amd64.pyd",
|
||||
}
|
||||
for filename in filenames:
|
||||
(tmp_path / filename).write_bytes(filename.encode())
|
||||
|
||||
matched = module._filter_resources_files(
|
||||
tmp_path,
|
||||
platform_tag,
|
||||
machine,
|
||||
python_version,
|
||||
)
|
||||
|
||||
assert [path.name for path in matched] == ["user.sites.v3.bin", expected]
|
||||
|
||||
|
||||
def test_copy_resource_files_keeps_only_current_runtime_artifact(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
module = load_local_setup_module()
|
||||
source_dir = tmp_path / "resources.v3"
|
||||
target_dir = tmp_path / "site"
|
||||
source_dir.mkdir()
|
||||
target_dir.mkdir()
|
||||
for filename in (
|
||||
"user.sites.v3.bin",
|
||||
"sites.cpython-314-x86_64-linux-gnu.so",
|
||||
"sites.cpython-314-aarch64-linux-gnu.so",
|
||||
"sites.cpython-314t-x86_64-linux-gnu.so",
|
||||
):
|
||||
(source_dir / filename).write_text(f"source:{filename}", encoding="utf-8")
|
||||
(target_dir / "sites.pyi").write_text("typing", encoding="utf-8")
|
||||
(target_dir / "sites.cpython-313-x86_64-linux-gnu.so").write_text(
|
||||
"stale", encoding="utf-8"
|
||||
)
|
||||
(target_dir / "sites.cp314-win_amd64.pyd").write_text(
|
||||
"stale", encoding="utf-8"
|
||||
)
|
||||
(target_dir / "sites.cpython-314t-darwin.so").write_text(
|
||||
"stale", encoding="utf-8"
|
||||
)
|
||||
(target_dir / "sites.legacy.dylib").write_text("stale", encoding="utf-8")
|
||||
monkeypatch.setattr(module, "SITE_RESOURCE_DIR", target_dir)
|
||||
monkeypatch.setattr(module, "_get_platform_tag", lambda: ("linux", "x86_64"))
|
||||
monkeypatch.setattr(module, "_get_python_version_tag", lambda: "cp314t")
|
||||
|
||||
copied = module.copy_resource_files(source_dir)
|
||||
|
||||
assert copied == [
|
||||
"user.sites.v3.bin",
|
||||
"sites.cpython-314t-x86_64-linux-gnu.so",
|
||||
]
|
||||
assert sorted(path.name for path in target_dir.iterdir()) == [
|
||||
"sites.cpython-314t-x86_64-linux-gnu.so",
|
||||
"sites.pyi",
|
||||
"user.sites.v3.bin",
|
||||
]
|
||||
|
||||
|
||||
def test_copy_resource_files_rejects_partial_source_before_changing_target(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
module = load_local_setup_module()
|
||||
source_dir = tmp_path / "resources.v3"
|
||||
target_dir = tmp_path / "site"
|
||||
source_dir.mkdir()
|
||||
target_dir.mkdir()
|
||||
(source_dir / "user.sites.v3.bin").write_bytes(b"new-data")
|
||||
old_native = target_dir / "sites.cpython-314-darwin.so"
|
||||
old_data = target_dir / "user.sites.v3.bin"
|
||||
old_native.write_bytes(b"old-native")
|
||||
old_data.write_bytes(b"old-data")
|
||||
monkeypatch.setattr(module, "SITE_RESOURCE_DIR", target_dir)
|
||||
monkeypatch.setattr(module, "_get_platform_tag", lambda: ("darwin", "arm64"))
|
||||
monkeypatch.setattr(module, "_get_python_version_tag", lambda: "cp314t")
|
||||
|
||||
with pytest.raises(RuntimeError, match="sites.cpython-314t-darwin.so"):
|
||||
module.copy_resource_files(source_dir)
|
||||
|
||||
assert old_native.read_bytes() == b"old-native"
|
||||
assert old_data.read_bytes() == b"old-data"
|
||||
|
||||
|
||||
def test_copy_resource_files_keeps_target_when_staging_fails(monkeypatch, tmp_path):
|
||||
module = load_local_setup_module()
|
||||
source_dir = tmp_path / "resources.v3"
|
||||
target_dir = tmp_path / "site"
|
||||
source_dir.mkdir()
|
||||
target_dir.mkdir()
|
||||
(source_dir / "user.sites.v3.bin").write_bytes(b"new-data")
|
||||
new_native_name = "sites.cpython-314t-darwin.so"
|
||||
(source_dir / new_native_name).write_bytes(b"new-native")
|
||||
old_native = target_dir / "sites.cpython-314-darwin.so"
|
||||
old_data = target_dir / "user.sites.v3.bin"
|
||||
old_native.write_bytes(b"old-native")
|
||||
old_data.write_bytes(b"old-data")
|
||||
real_copy2 = module.shutil.copy2
|
||||
|
||||
def fail_native_staging(source, target):
|
||||
if Path(source).name == new_native_name:
|
||||
raise OSError("injected native staging failure")
|
||||
return real_copy2(source, target)
|
||||
|
||||
monkeypatch.setattr(module, "SITE_RESOURCE_DIR", target_dir)
|
||||
monkeypatch.setattr(module, "_get_platform_tag", lambda: ("darwin", "arm64"))
|
||||
monkeypatch.setattr(module, "_get_python_version_tag", lambda: "cp314t")
|
||||
monkeypatch.setattr(module.shutil, "copy2", fail_native_staging)
|
||||
|
||||
with pytest.raises(OSError, match="injected native staging failure"):
|
||||
module.copy_resource_files(source_dir)
|
||||
|
||||
assert old_native.read_bytes() == b"old-native"
|
||||
assert old_data.read_bytes() == b"old-data"
|
||||
assert not (target_dir / new_native_name).exists()
|
||||
assert not list(target_dir.glob(".sites-sync-*"))
|
||||
|
||||
|
||||
def test_copy_resource_files_rolls_back_when_commit_fails(monkeypatch, tmp_path):
|
||||
module = load_local_setup_module()
|
||||
source_dir = tmp_path / "resources.v3"
|
||||
target_dir = tmp_path / "site"
|
||||
source_dir.mkdir()
|
||||
target_dir.mkdir()
|
||||
(source_dir / "user.sites.v3.bin").write_bytes(b"new-data")
|
||||
new_native_name = "sites.cpython-314t-darwin.so"
|
||||
(source_dir / new_native_name).write_bytes(b"new-native")
|
||||
old_native = target_dir / "sites.cpython-314-darwin.so"
|
||||
old_data = target_dir / "user.sites.v3.bin"
|
||||
old_native.write_bytes(b"old-native")
|
||||
old_data.write_bytes(b"old-data")
|
||||
real_replace = module.os.replace
|
||||
|
||||
def fail_native_commit(source, target):
|
||||
if Path(source).parent.name == "staging" and Path(target).name == new_native_name:
|
||||
raise OSError("injected native commit failure")
|
||||
return real_replace(source, target)
|
||||
|
||||
monkeypatch.setattr(module, "SITE_RESOURCE_DIR", target_dir)
|
||||
monkeypatch.setattr(module, "_get_platform_tag", lambda: ("darwin", "arm64"))
|
||||
monkeypatch.setattr(module, "_get_python_version_tag", lambda: "cp314t")
|
||||
monkeypatch.setattr(module.os, "replace", fail_native_commit)
|
||||
|
||||
with pytest.raises(OSError, match="injected native commit failure"):
|
||||
module.copy_resource_files(source_dir)
|
||||
|
||||
assert old_native.read_bytes() == b"old-native"
|
||||
assert old_data.read_bytes() == b"old-data"
|
||||
assert not (target_dir / new_native_name).exists()
|
||||
|
||||
|
||||
def test_local_resource_status_requires_current_native_artifact(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
module = load_local_setup_module()
|
||||
monkeypatch.setattr(module, "SITE_RESOURCE_DIR", tmp_path)
|
||||
monkeypatch.setattr(module, "_get_platform_tag", lambda: ("darwin", "arm64"))
|
||||
monkeypatch.setattr(module, "_get_python_version_tag", lambda: "cp314t")
|
||||
(tmp_path / "user.sites.v3.bin").write_bytes(b"data")
|
||||
(tmp_path / "sites.pyi").write_text("typing", encoding="utf-8")
|
||||
(tmp_path / "sites.cpython-314-darwin.so").write_bytes(b"standard")
|
||||
|
||||
assert module.local_resource_status() is False
|
||||
|
||||
(tmp_path / "sites.cpython-314t-darwin.so").write_bytes(b"free-threaded")
|
||||
|
||||
assert module.local_resource_status() is True
|
||||
Reference in New Issue
Block a user