diff --git a/scripts/local_setup.py b/scripts/local_setup.py index 00119b835..df21e0320 100644 --- a/scripts/local_setup.py +++ b/scripts/local_setup.py @@ -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) diff --git a/tests/test_local_setup_resources.py b/tests/test_local_setup_resources.py new file mode 100644 index 000000000..a99334275 --- /dev/null +++ b/tests/test_local_setup_resources.py @@ -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