mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-10 18:06:48 +08:00
fix: 支持 Windows Python 3.14t 运行依赖 (#6492)
* fix(runtime): support Windows free-threaded dependencies * fix(runtime): close Windows profile validation gaps * chore(architecture): refresh runtime dependency baseline
This commit is contained in:
@@ -7,9 +7,11 @@ on:
|
||||
paths:
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
- 'app/__init__.py'
|
||||
- 'app/doctor/dependencies.py'
|
||||
- 'app/foundation/environment.py'
|
||||
- 'app/runtime/dependencies.py'
|
||||
- 'scripts/verify_runtime_profile.py'
|
||||
- 'docker/Dockerfile'
|
||||
- 'docker/**'
|
||||
- '.github/workflows/dependency-compat.yml'
|
||||
@@ -19,9 +21,11 @@ on:
|
||||
paths:
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
- 'app/__init__.py'
|
||||
- 'app/doctor/dependencies.py'
|
||||
- 'app/foundation/environment.py'
|
||||
- 'app/runtime/dependencies.py'
|
||||
- 'scripts/verify_runtime_profile.py'
|
||||
- 'docker/Dockerfile'
|
||||
- 'docker/**'
|
||||
- '.github/workflows/dependency-compat.yml'
|
||||
@@ -46,26 +50,43 @@ jobs:
|
||||
- name: Linux x64
|
||||
runner: ubuntu-24.04
|
||||
python-version: '3.14'
|
||||
runtime-group: runtime-standard
|
||||
expected-profile: standard
|
||||
expected-system: Linux
|
||||
expected-machine: x86_64
|
||||
- name: Linux ARM64
|
||||
runner: ubuntu-24.04-arm
|
||||
python-version: '3.14'
|
||||
runtime-group: runtime-standard
|
||||
expected-profile: standard
|
||||
expected-system: Linux
|
||||
expected-machine: aarch64
|
||||
- name: macOS Intel
|
||||
runner: macos-15-intel
|
||||
python-version: '3.14'
|
||||
runtime-group: runtime-standard
|
||||
expected-profile: standard
|
||||
expected-system: Darwin
|
||||
expected-machine: x86_64
|
||||
- name: macOS ARM
|
||||
runner: macos-15
|
||||
python-version: '3.14'
|
||||
runtime-group: runtime-standard
|
||||
expected-profile: standard
|
||||
expected-system: Darwin
|
||||
expected-machine: arm64
|
||||
- name: Windows x64
|
||||
runner: windows-2025
|
||||
python-version: '3.14'
|
||||
runtime-group: runtime-standard
|
||||
expected-profile: standard
|
||||
expected-system: Windows
|
||||
expected-machine: AMD64
|
||||
- name: Windows x64 free-threaded
|
||||
runner: windows-2025
|
||||
python-version: '3.14t'
|
||||
runtime-group: runtime-free-threaded
|
||||
expected-profile: free-threaded
|
||||
expected-system: Windows
|
||||
expected-machine: AMD64
|
||||
|
||||
@@ -82,22 +103,41 @@ jobs:
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
|
||||
- name: Install locked runtime dependencies
|
||||
run: uv sync --locked --inexact --no-dev --python ${{ matrix.python-version }}
|
||||
- name: Expose PostgreSQL build tools
|
||||
if: matrix.expected-profile == 'free-threaded'
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (-not $env:PGBIN -or -not (Test-Path -LiteralPath $env:PGBIN -PathType Container)) {
|
||||
throw 'Windows free-threaded profile requires a valid PGBIN directory'
|
||||
}
|
||||
"$env:PGBIN" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
|
||||
- name: Verify environment and core imports
|
||||
- name: Install locked runtime dependencies
|
||||
run: >-
|
||||
uv sync --locked --inexact --no-default-groups
|
||||
--group ${{ matrix.runtime-group }}
|
||||
--python ${{ matrix.python-version }}
|
||||
|
||||
- name: Verify runtime profile
|
||||
env:
|
||||
AUTO_UPDATE_RESOURCE: 'false'
|
||||
EXPECTED_SYSTEM: ${{ matrix.expected-system }}
|
||||
EXPECTED_MACHINE: ${{ matrix.expected-machine }}
|
||||
EXPECTED_PROFILE: ${{ matrix.expected-profile }}
|
||||
CONFIG_DIR: ${{ runner.temp }}/moviepilot-runtime-smoke
|
||||
MOVIEPILOT_SAFE_MODE: 'true'
|
||||
SUPERUSER_PASSWORD: moviepilot-ci-only
|
||||
run: >-
|
||||
uv run --locked --no-sync python -c
|
||||
"import os, platform;
|
||||
assert platform.system() == os.environ['EXPECTED_SYSTEM'], (platform.system(), os.environ['EXPECTED_SYSTEM']);
|
||||
assert platform.machine() == os.environ['EXPECTED_MACHINE'], (platform.machine(), os.environ['EXPECTED_MACHINE']);
|
||||
import alembic, fastapi, pydantic, pydantic_settings, sqlalchemy, starlette, uvicorn"
|
||||
uv run --locked --no-sync python -m scripts.verify_runtime_profile
|
||||
--expected-profile ${{ matrix.expected-profile }}
|
||||
--expected-system ${{ matrix.expected-system }}
|
||||
--expected-machine ${{ matrix.expected-machine }}
|
||||
|
||||
- name: Verify locked project consistency
|
||||
run: uv sync --locked --offline --inexact --no-dev --check --python ${{ matrix.python-version }}
|
||||
run: >-
|
||||
uv sync --locked --offline --inexact --no-default-groups
|
||||
--group ${{ matrix.runtime-group }}
|
||||
--check --python ${{ matrix.python-version }}
|
||||
|
||||
docker-dependencies:
|
||||
name: Docker dependencies / ${{ matrix.platform }}
|
||||
|
||||
@@ -1,7 +1,30 @@
|
||||
import os
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
from app.foundation.environment import (
|
||||
is_free_threaded_runtime,
|
||||
is_windows,
|
||||
)
|
||||
from app.runtime.compat.imports import install_legacy_import_hook
|
||||
|
||||
_windows_dll_directory_handles: list[object] = []
|
||||
|
||||
|
||||
def _configure_free_threaded_windows_native_dependencies() -> None:
|
||||
"""为 Windows free-threaded 运行时注册外部原生依赖目录。"""
|
||||
if not is_windows() or not is_free_threaded_runtime():
|
||||
return
|
||||
configured_path = os.getenv("PGBIN")
|
||||
if not configured_path:
|
||||
return
|
||||
directory = Path(configured_path)
|
||||
if not directory.is_dir():
|
||||
return
|
||||
_windows_dll_directory_handles.append(
|
||||
getattr(os, "add_dll_directory")(str(directory))
|
||||
)
|
||||
|
||||
|
||||
def _filter_third_party_startup_warnings() -> None:
|
||||
"""
|
||||
@@ -20,5 +43,6 @@ def _filter_third_party_startup_warnings() -> None:
|
||||
)
|
||||
|
||||
|
||||
_configure_free_threaded_windows_native_dependencies()
|
||||
_filter_third_party_startup_warnings()
|
||||
install_legacy_import_hook()
|
||||
|
||||
@@ -70,7 +70,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
|
||||
|
||||
| 指标 | 当前值 | 解释 |
|
||||
|---|---:|---|
|
||||
| 宿主 Python 模块 / 内部依赖边 | 906 / 7,612 | `dependency-baseline.json` 当前快照 |
|
||||
| 宿主 Python 模块 / 内部依赖边 | 907 / 7,618 | `dependency-baseline.json` 当前快照 |
|
||||
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
|
||||
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
|
||||
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
|
||||
|
||||
@@ -722,8 +722,8 @@ flowchart LR
|
||||
|
||||
| 指标 | 当前值 |
|
||||
|---|---:|
|
||||
| Python 模块 | 906 |
|
||||
| 内部导入边 | 7,612 |
|
||||
| Python 模块 | 907 |
|
||||
| 内部导入边 | 7,618 |
|
||||
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
|
||||
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
|
||||
| Direct egress | 55(债务已清零,55 条精确 containment) |
|
||||
|
||||
@@ -47,6 +47,8 @@ profile,依赖名称、版本和 source 语义全部由 `pyproject.toml` 与 `
|
||||
| CRC 加速 | `crcmod-plus` 2.3.1 | `crcmod-plus` 2.3.1 | 两套镜像统一使用继续维护且兼容 `crcmod` 导入接口的实现。`oss2` 的陈旧元数据仍声明不再维护的 `crcmod`,由 uv 在解析时排除该传递依赖;宿主不在运行时映射、卸载或替插件兼容旧分发包。 |
|
||||
| `lxml` | 6.1.2 | 7.0.0b1 | V3t 暂用提供目标 ABI 的预发布版本,是当前最高风险项。稳定版提供 `cp314t` wheel 后,需通过 XML、HTML、RSS、站点解析、并发和内存验证再替换。 |
|
||||
| PostgreSQL 同步驱动 | `psycopg2-binary` 2.x | `psycopg[c]` 3.3.4 | 当前分叉同时受 ABI 与实测性能影响,不要求仅为版本统一而收敛。若上游能力或性能变化,必须重跑三方案 PostgreSQL A/B 后再决策。异步路径继续使用 `asyncpg`。 |
|
||||
| Windows PostgreSQL 客户端 | `psycopg2-binary` 自包含 libpq | `psycopg[c]` 链接本机 libpq | Windows 3.14t 源码环境必须安装 PostgreSQL 客户端开发工具并提供有效 `PGBIN`;启动时只为 free-threaded 解释器注册该 DLL 目录。上游发布自包含的 `cp314t` Windows wheel 后,应优先移除此宿主前提。 |
|
||||
| Windows Docker SDK | `pywin32` 312 | 不安装 `pywin32` | 标准 Windows 保留 Docker named-pipe transport;V3t 仅提供不依赖 pywin32 的 Docker SDK 路径。Docker 或 pywin32 提供兼容 `cp314t` 的组合后,重新验证 named-pipe 再解除排除。 |
|
||||
| `orjson` | 3.12.0 wheel | 同版本源码构建 | V3t 使用同一锁定版本并启用 free-threaded 构建变量。上游发布覆盖 Linux amd64/arm64 的稳定 `cp314t` wheel 后可删除本地构建要求。 |
|
||||
| 中文转换 | `moviepilot-rust.zhconv_fast()` | `moviepilot-rust.zhconv_fast()` | 从 0.3.3 起两套 ABI 使用同一 MediaWiki 转换实现;主程序统一经 `app.foundation.text.convert()`,插件统一经 SDK,不再安装独立 `zhconv-rs`。 |
|
||||
| 站点资源 | `cpython-314` | `cpython-314t` | 资源文件必须按解释器 ABI 独立构建和选取,不能让 V3t 复用普通 CPython 扩展,也不能影响 V2 的历史 ABI 制品。 |
|
||||
|
||||
+2
-2
@@ -62,7 +62,6 @@ dependencies = [
|
||||
"pydantic>=2.13.4,<3.0.0",
|
||||
"pydantic-settings>=2.14.2,<3.0.0",
|
||||
"pyjwt~=2.13.0",
|
||||
"pympler~=1.1",
|
||||
"pyotp~=2.9.0",
|
||||
"pyparsing~=3.3.2",
|
||||
"pyquery~=2.0.1",
|
||||
@@ -74,7 +73,6 @@ dependencies = [
|
||||
"pytz~=2026.2",
|
||||
"pyvirtualdisplay~=3.0",
|
||||
"pywebpush~=2.3.0",
|
||||
"pywin32==312 ; sys_platform == 'win32'",
|
||||
"pyyaml~=6.0.3",
|
||||
"qbittorrent-api==2026.6.0",
|
||||
"redis~=8.1.0",
|
||||
@@ -117,6 +115,7 @@ runtime-standard = [
|
||||
"bcrypt~=4.3.0",
|
||||
"lxml~=6.1.2",
|
||||
"psycopg2-binary~=2.9.12",
|
||||
"pywin32==312 ; sys_platform == 'win32'",
|
||||
]
|
||||
runtime-free-threaded = [
|
||||
"Brotli==1.2.0",
|
||||
@@ -144,6 +143,7 @@ conflicts = [
|
||||
],
|
||||
]
|
||||
exclude-dependencies = [
|
||||
{ package = { name = "docker" }, dependencies = ["pywin32"] },
|
||||
{ package = { name = "oss2" }, dependencies = ["crcmod"] },
|
||||
]
|
||||
environments = [
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""跨平台 CI 使用的运行依赖 profile 验证入口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import sysconfig
|
||||
from importlib import import_module
|
||||
from importlib.util import find_spec
|
||||
from pathlib import Path
|
||||
|
||||
from packaging.requirements import InvalidRequirement, Requirement
|
||||
from packaging.utils import canonicalize_name
|
||||
|
||||
from app.doctor import dependencies as dependency_doctor
|
||||
from app.runtime.dependencies import runtime_excluded_dependency_pairs
|
||||
|
||||
_UV_MISSING_DEPENDENCY_PATTERN = re.compile(
|
||||
r"The package `(?P<package>[^`]+)` requires `(?P<requirement>[^`]+)`, "
|
||||
r"but [^\r\n;]+"
|
||||
)
|
||||
_UV_PIP_CHECK_SUMMARY_PATTERNS = (
|
||||
re.compile(r"^Using Python .+ environment at: .+$"),
|
||||
re.compile(r"^Checked \d+ packages? in .+$"),
|
||||
re.compile(r"^Found \d+ incompatibilit(?:y|ies)$"),
|
||||
)
|
||||
|
||||
|
||||
def _uv_health_errors(message: str, project_file: Path) -> set[str]:
|
||||
"""返回未被项目 uv 排除策略覆盖的依赖健康诊断。"""
|
||||
excluded_pairs = runtime_excluded_dependency_pairs(project_file)
|
||||
errors: set[str] = set()
|
||||
for line in {item.strip() for item in message.splitlines() if item.strip()}:
|
||||
matches = list(_UV_MISSING_DEPENDENCY_PATTERN.finditer(line))
|
||||
if not matches:
|
||||
if not any(pattern.fullmatch(line) for pattern in _UV_PIP_CHECK_SUMMARY_PATTERNS):
|
||||
errors.add(line)
|
||||
continue
|
||||
for match in matches:
|
||||
try:
|
||||
dependency_name = Requirement(match.group("requirement")).name
|
||||
except InvalidRequirement:
|
||||
errors.add(match.group(0))
|
||||
continue
|
||||
pair = (
|
||||
canonicalize_name(match.group("package")),
|
||||
canonicalize_name(dependency_name),
|
||||
)
|
||||
if pair not in excluded_pairs:
|
||||
errors.add(match.group(0))
|
||||
return errors
|
||||
|
||||
|
||||
def verify_uv_environment(project_file: Path = Path("pyproject.toml")) -> None:
|
||||
"""执行 uv 元数据健康检查,仅接受项目显式声明的排除边。"""
|
||||
uv = shutil.which("uv")
|
||||
if not uv:
|
||||
raise RuntimeError("未找到 uv 可执行文件")
|
||||
result = subprocess.run(
|
||||
[uv, "pip", "check", "--python", sys.executable],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return
|
||||
errors = _uv_health_errors(
|
||||
"\n".join((result.stdout, result.stderr)),
|
||||
project_file.resolve(),
|
||||
)
|
||||
if errors:
|
||||
raise RuntimeError("uv 依赖健康检查失败:" + " | ".join(sorted(errors)))
|
||||
|
||||
|
||||
def verify_platform_profile(
|
||||
*,
|
||||
expected_profile: str,
|
||||
expected_system: str,
|
||||
expected_machine: str,
|
||||
) -> None:
|
||||
"""验证运行平台、解释器 ABI 和 Windows Docker 能力边界。"""
|
||||
current_system = platform.system()
|
||||
current_machine = platform.machine()
|
||||
if current_system != expected_system:
|
||||
raise RuntimeError(f"运行系统不匹配:{current_system} != {expected_system}")
|
||||
if current_machine != expected_machine:
|
||||
raise RuntimeError(f"机器架构不匹配:{current_machine} != {expected_machine}")
|
||||
|
||||
free_threaded = sysconfig.get_config_var("Py_GIL_DISABLED") == 1
|
||||
expected_free_threaded = expected_profile == "free-threaded"
|
||||
if free_threaded != expected_free_threaded:
|
||||
raise RuntimeError(f"解释器 profile 不匹配:free-threaded={free_threaded}")
|
||||
if sys._is_gil_enabled() is expected_free_threaded:
|
||||
raise RuntimeError("解释器 GIL 状态与运行 profile 不匹配")
|
||||
|
||||
import_module("docker")
|
||||
if find_spec("pympler") is not None:
|
||||
raise RuntimeError("运行环境仍包含已移除的 Pympler")
|
||||
if current_system != "Windows":
|
||||
return
|
||||
|
||||
docker_transport = import_module("docker.transport")
|
||||
win32_modules = ("pywintypes", "win32api", "win32file", "win32pipe")
|
||||
installed_win32_modules = {
|
||||
module_name for module_name in win32_modules if find_spec(module_name) is not None
|
||||
}
|
||||
if expected_free_threaded:
|
||||
if installed_win32_modules:
|
||||
raise RuntimeError(
|
||||
"Windows free-threaded profile 意外安装 pywin32:"
|
||||
+ ", ".join(sorted(installed_win32_modules))
|
||||
)
|
||||
if getattr(docker_transport, "NpipeHTTPAdapter", None) is not None:
|
||||
raise RuntimeError("Windows free-threaded profile 意外启用了 Docker named-pipe 能力")
|
||||
return
|
||||
|
||||
missing_modules = set(win32_modules) - installed_win32_modules
|
||||
if missing_modules:
|
||||
raise RuntimeError(
|
||||
"Windows standard profile 缺少 pywin32 模块:"
|
||||
+ ", ".join(sorted(missing_modules))
|
||||
)
|
||||
for module_name in win32_modules:
|
||||
import_module(module_name)
|
||||
if getattr(docker_transport, "NpipeHTTPAdapter", None) is None:
|
||||
raise RuntimeError("Windows standard profile 缺少 Docker named-pipe 能力")
|
||||
|
||||
|
||||
def verify_application_lifecycle() -> None:
|
||||
"""在隔离配置下运行 FastAPI lifespan 并验证 readiness。"""
|
||||
from app.testing.bootstrap import ensure_sites_stub, isolate_config_dir
|
||||
|
||||
isolate_config_dir()
|
||||
ensure_sites_stub()
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.factory import create_app
|
||||
|
||||
with TestClient(create_app()) as client:
|
||||
response = client.get("/health/ready")
|
||||
if response.status_code != 200 or response.json() != {"status": "ready"}:
|
||||
raise RuntimeError(f"应用 readiness 验证失败:{response.text}")
|
||||
if sysconfig.get_config_var("Py_GIL_DISABLED") == 1 and sys._is_gil_enabled():
|
||||
raise RuntimeError("应用启动后启用了 GIL")
|
||||
|
||||
|
||||
def main(
|
||||
*,
|
||||
expected_profile: str,
|
||||
expected_system: str,
|
||||
expected_machine: str,
|
||||
) -> None:
|
||||
"""验证锁定环境、原生能力和应用生命周期。"""
|
||||
verify_platform_profile(
|
||||
expected_profile=expected_profile,
|
||||
expected_system=expected_system,
|
||||
expected_machine=expected_machine,
|
||||
)
|
||||
dependency_doctor.main(full=True)
|
||||
verify_uv_environment()
|
||||
if expected_system == "Windows":
|
||||
verify_application_lifecycle()
|
||||
if sysconfig.get_config_var("Py_GIL_DISABLED") == 1 and sys._is_gil_enabled():
|
||||
raise RuntimeError("完整运行依赖验证后启用了 GIL")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--expected-profile",
|
||||
required=True,
|
||||
choices=("standard", "free-threaded"),
|
||||
)
|
||||
parser.add_argument("--expected-system", required=True)
|
||||
parser.add_argument("--expected-machine", required=True)
|
||||
arguments = parser.parse_args()
|
||||
main(
|
||||
expected_profile=arguments.expected_profile,
|
||||
expected_system=arguments.expected_system,
|
||||
expected_machine=arguments.expected_machine,
|
||||
)
|
||||
+10
-3
@@ -1119,9 +1119,11 @@
|
||||
"runtime_only": true
|
||||
}
|
||||
},
|
||||
"edge_count": 7612,
|
||||
"edge_sha256": "15b211a9bc9400fa8a28480223372039344ae8e584bd1ac769359a808e4610be",
|
||||
"edge_count": 7618,
|
||||
"edge_sha256": "be924b781df81299b96c7580034b5c27bebeb97285871586b6f0b51f51a83d9b",
|
||||
"edges": [
|
||||
"app -> app.foundation",
|
||||
"app -> app.foundation.environment",
|
||||
"app -> app.runtime",
|
||||
"app -> app.runtime.compat",
|
||||
"app -> app.runtime.compat.imports",
|
||||
@@ -1267,6 +1269,7 @@
|
||||
"app.adapters.system.plugin.package -> app.runtime",
|
||||
"app.adapters.system.plugin.package -> app.runtime.execution",
|
||||
"app.adapters.system.plugin.package -> app.runtime.log",
|
||||
"app.adapters.system.plugin.package -> app.runtime.native_dependencies",
|
||||
"app.adapters.system.plugin.package -> app.runtime.settings",
|
||||
"app.adapters.system.resource -> app.adapters",
|
||||
"app.adapters.system.resource -> app.adapters.network",
|
||||
@@ -3918,6 +3921,7 @@
|
||||
"app.application.plugin.install -> app.runtime",
|
||||
"app.application.plugin.install -> app.runtime.execution",
|
||||
"app.application.plugin.install -> app.runtime.log",
|
||||
"app.application.plugin.install -> app.runtime.native_dependencies",
|
||||
"app.application.plugin.install -> app.schemas",
|
||||
"app.application.plugin.install -> app.schemas.exception",
|
||||
"app.application.plugin.install -> app.schemas.plugin",
|
||||
@@ -7699,6 +7703,8 @@
|
||||
"app.runtime.extensions.service_config -> app.schemas",
|
||||
"app.runtime.extensions.service_config -> app.schemas.system",
|
||||
"app.runtime.extensions.service_config -> app.schemas.types",
|
||||
"app.runtime.native_dependencies -> app.runtime",
|
||||
"app.runtime.native_dependencies -> app.runtime.log",
|
||||
"app.runtime.observability -> app.runtime",
|
||||
"app.runtime.observability -> app.runtime.deprecation",
|
||||
"app.runtime.observability -> app.runtime.deprecation.policy",
|
||||
@@ -8735,7 +8741,7 @@
|
||||
"app.workflow.actions.transfer_file -> app.workflow",
|
||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||
],
|
||||
"module_count": 906,
|
||||
"module_count": 907,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -9505,6 +9511,7 @@
|
||||
"app.runtime.localization",
|
||||
"app.runtime.log",
|
||||
"app.runtime.loop",
|
||||
"app.runtime.native_dependencies",
|
||||
"app.runtime.observability",
|
||||
"app.runtime.progress",
|
||||
"app.runtime.rate",
|
||||
|
||||
@@ -195,3 +195,37 @@ def test_native_dependency_update_probe_has_narrow_automatic_triggers():
|
||||
assert workflow["on"]["push"]["paths"] == expected_paths
|
||||
assert "workflow_dispatch" in workflow["on"]
|
||||
assert set(workflow["jobs"]) == {"probe"}
|
||||
|
||||
|
||||
def test_dependency_compatibility_covers_windows_free_threaded_profile():
|
||||
"""依赖门禁必须真实安装并检查 Windows free-threaded profile。"""
|
||||
workflow = _load_workflow("dependency-compat.yml")
|
||||
matrix = workflow["jobs"]["install"]["strategy"]["matrix"]["include"]
|
||||
free_threaded = next(item for item in matrix if item["expected-profile"] == "free-threaded")
|
||||
|
||||
assert free_threaded == {
|
||||
"name": "Windows x64 free-threaded",
|
||||
"runner": "windows-2025",
|
||||
"python-version": "3.14t",
|
||||
"runtime-group": "runtime-free-threaded",
|
||||
"expected-profile": "free-threaded",
|
||||
"expected-system": "Windows",
|
||||
"expected-machine": "AMD64",
|
||||
}
|
||||
install = _step_commands(workflow, "install")
|
||||
assert "--group ${{ matrix.runtime-group }}" in install
|
||||
assert "python -m scripts.verify_runtime_profile" in install
|
||||
assert "MOVIEPILOT_SAFE_MODE" in str(workflow["jobs"]["install"]["steps"])
|
||||
expected_paths = {
|
||||
"app/__init__.py",
|
||||
"scripts/verify_runtime_profile.py",
|
||||
}
|
||||
assert expected_paths <= set(workflow["on"]["pull_request"]["paths"])
|
||||
assert expected_paths <= set(workflow["on"]["push"]["paths"])
|
||||
postgres_step = next(
|
||||
step
|
||||
for step in workflow["jobs"]["install"]["steps"]
|
||||
if step.get("name") == "Expose PostgreSQL build tools"
|
||||
)
|
||||
assert postgres_step["if"] == "matrix.expected-profile == 'free-threaded'"
|
||||
assert "Test-Path -LiteralPath $env:PGBIN -PathType Container" in postgres_step["run"]
|
||||
|
||||
@@ -7,6 +7,7 @@ import pytest
|
||||
from app.doctor import dependencies as dependency_doctor
|
||||
from app.foundation import environment
|
||||
from app.runtime import dependencies
|
||||
from scripts import verify_runtime_profile
|
||||
|
||||
|
||||
def test_free_threaded_runtime_tracks_interpreter_build(monkeypatch):
|
||||
@@ -79,6 +80,26 @@ def test_runtime_profiles_share_gil_safe_crcmod_distribution():
|
||||
} in document["tool"]["uv"]["exclude-dependencies"]
|
||||
|
||||
|
||||
def test_windows_runtime_profiles_isolate_pywin32_dependency():
|
||||
"""标准 Windows 保留 named-pipe 依赖,free-threaded profile 不解析 pywin32。"""
|
||||
project_file = Path(__file__).resolve().parents[1] / "pyproject.toml"
|
||||
with project_file.open("rb") as file:
|
||||
document = tomllib.load(file)
|
||||
|
||||
dependencies = document["project"]["dependencies"]
|
||||
groups = document["dependency-groups"]
|
||||
assert not any(requirement.lower().startswith("pympler") for requirement in dependencies)
|
||||
assert "pywin32==312 ; sys_platform == 'win32'" in groups["runtime-standard"]
|
||||
assert not any(
|
||||
requirement.lower().startswith("pywin32")
|
||||
for requirement in groups["runtime-free-threaded"]
|
||||
)
|
||||
assert {
|
||||
"package": {"name": "docker"},
|
||||
"dependencies": ["pywin32"],
|
||||
} in document["tool"]["uv"]["exclude-dependencies"]
|
||||
|
||||
|
||||
def test_standard_runtime_uses_shared_rust_text_capabilities(monkeypatch):
|
||||
"""标准镜像的文本能力不得重新引入 profile 专属实现。"""
|
||||
imported = []
|
||||
@@ -118,6 +139,99 @@ exclude-dependencies = [
|
||||
}
|
||||
|
||||
|
||||
def test_runtime_profile_probe_accepts_only_declared_uv_exclusions(tmp_path: Path):
|
||||
"""CI 健康检查只能忽略 uv 配置中明确排除的依赖边。"""
|
||||
project_file = tmp_path / "pyproject.toml"
|
||||
project_file.write_text(
|
||||
"""
|
||||
[tool.uv]
|
||||
exclude-dependencies = [
|
||||
{ package = { name = "docker" }, dependencies = ["pywin32"] },
|
||||
]
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
ignored = "The package `docker` requires `pywin32>=304`, but it's not installed"
|
||||
actionable = "The package `demo` requires `missing>=1`, but it's not installed"
|
||||
summary = "\n".join((
|
||||
"Using Python 3.14.7 environment at: .venv",
|
||||
"Checked 189 packages in 3ms",
|
||||
"Found 2 incompatibilities",
|
||||
))
|
||||
|
||||
assert verify_runtime_profile._uv_health_errors(
|
||||
f"{summary}\n{ignored}\n{actionable}",
|
||||
project_file,
|
||||
) == {actionable}
|
||||
|
||||
|
||||
def test_runtime_profile_probe_loads_standard_windows_named_pipe(monkeypatch):
|
||||
"""标准 Windows 必须真实加载 pywin32 与 Docker named-pipe adapter。"""
|
||||
imported = []
|
||||
win32_modules = {"pywintypes", "win32api", "win32file", "win32pipe"}
|
||||
|
||||
def fake_import_module(name: str):
|
||||
imported.append(name)
|
||||
if name == "docker.transport":
|
||||
return SimpleNamespace(NpipeHTTPAdapter=object())
|
||||
return SimpleNamespace()
|
||||
|
||||
monkeypatch.setattr(verify_runtime_profile.platform, "system", lambda: "Windows")
|
||||
monkeypatch.setattr(verify_runtime_profile.platform, "machine", lambda: "AMD64")
|
||||
monkeypatch.setattr(
|
||||
verify_runtime_profile.sysconfig,
|
||||
"get_config_var",
|
||||
lambda _name: 0,
|
||||
)
|
||||
monkeypatch.setattr(verify_runtime_profile.sys, "_is_gil_enabled", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
verify_runtime_profile,
|
||||
"find_spec",
|
||||
lambda name: object() if name in win32_modules else None,
|
||||
)
|
||||
monkeypatch.setattr(verify_runtime_profile, "import_module", fake_import_module)
|
||||
|
||||
verify_runtime_profile.verify_platform_profile(
|
||||
expected_profile="standard",
|
||||
expected_system="Windows",
|
||||
expected_machine="AMD64",
|
||||
)
|
||||
|
||||
assert win32_modules <= set(imported)
|
||||
assert "docker.transport" in imported
|
||||
|
||||
|
||||
def test_runtime_profile_probe_rejects_pywin32_from_windows_free_threaded(
|
||||
monkeypatch,
|
||||
):
|
||||
"""Windows 3.14t 不得继承标准 ABI 的 pywin32。"""
|
||||
monkeypatch.setattr(verify_runtime_profile.platform, "system", lambda: "Windows")
|
||||
monkeypatch.setattr(verify_runtime_profile.platform, "machine", lambda: "AMD64")
|
||||
monkeypatch.setattr(
|
||||
verify_runtime_profile.sysconfig,
|
||||
"get_config_var",
|
||||
lambda _name: 1,
|
||||
)
|
||||
monkeypatch.setattr(verify_runtime_profile.sys, "_is_gil_enabled", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
verify_runtime_profile,
|
||||
"find_spec",
|
||||
lambda name: object() if name == "win32api" else None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
verify_runtime_profile,
|
||||
"import_module",
|
||||
lambda _name: SimpleNamespace(),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="意外安装 pywin32"):
|
||||
verify_runtime_profile.verify_platform_profile(
|
||||
expected_profile="free-threaded",
|
||||
expected_system="Windows",
|
||||
expected_machine="AMD64",
|
||||
)
|
||||
|
||||
|
||||
def test_full_dependency_probe_rejects_psycopg_python_fallback(monkeypatch):
|
||||
"""V3t 构建不得把 psycopg 纯 Python 实现误认为可发布能力。"""
|
||||
modules = {
|
||||
|
||||
@@ -1,8 +1,61 @@
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
|
||||
import app
|
||||
|
||||
|
||||
def test_app_registers_pg_bin_for_windows_free_threaded(tmp_path, monkeypatch):
|
||||
"""Windows free-threaded 启动时应注册 PostgreSQL DLL 目录。"""
|
||||
handle = object()
|
||||
registered = []
|
||||
monkeypatch.setattr(app, "is_windows", lambda: True)
|
||||
monkeypatch.setattr(app, "is_free_threaded_runtime", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
app.os,
|
||||
"add_dll_directory",
|
||||
lambda path: registered.append(path) or handle,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(app, "_windows_dll_directory_handles", [])
|
||||
monkeypatch.setenv("PGBIN", str(tmp_path))
|
||||
|
||||
app._configure_free_threaded_windows_native_dependencies()
|
||||
|
||||
assert registered == [str(tmp_path)]
|
||||
assert app._windows_dll_directory_handles == [handle]
|
||||
|
||||
|
||||
def test_app_skips_pg_bin_for_standard_runtime(monkeypatch):
|
||||
"""标准 Windows 运行时不应执行 free-threaded 专属 DLL 注册。"""
|
||||
monkeypatch.setattr(app, "is_windows", lambda: True)
|
||||
monkeypatch.setattr(app, "is_free_threaded_runtime", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
app.os,
|
||||
"add_dll_directory",
|
||||
lambda _path: pytest.fail("must not register a DLL directory"),
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setenv("PGBIN", "C:/PostgreSQL/bin")
|
||||
|
||||
app._configure_free_threaded_windows_native_dependencies()
|
||||
|
||||
|
||||
def test_app_skips_missing_pg_bin_for_windows_free_threaded(monkeypatch):
|
||||
"""无效外部目录不能污染 Windows DLL 搜索路径。"""
|
||||
monkeypatch.setattr(app, "is_windows", lambda: True)
|
||||
monkeypatch.setattr(app, "is_free_threaded_runtime", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
app.os,
|
||||
"add_dll_directory",
|
||||
lambda _path: pytest.fail("must not register a missing DLL directory"),
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setenv("PGBIN", "Z:/missing/postgresql/bin")
|
||||
|
||||
app._configure_free_threaded_windows_native_dependencies()
|
||||
|
||||
|
||||
def test_app_installs_known_oss2_invalid_escape_warning_filter():
|
||||
"""
|
||||
app 初始化过滤器应覆盖 oss2 的无效转义警告。
|
||||
|
||||
@@ -28,7 +28,10 @@ conflicts = [[
|
||||
]]
|
||||
|
||||
[manifest]
|
||||
excludes = [{ package = { name = "oss2" }, dependencies = ["crcmod"] }]
|
||||
excludes = [
|
||||
{ package = { name = "docker" }, dependencies = ["pywin32"] },
|
||||
{ package = { name = "oss2" }, dependencies = ["crcmod"] },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiofiles"
|
||||
@@ -842,7 +845,6 @@ name = "docker"
|
||||
version = "7.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pywin32", marker = "sys_platform == 'win32' or (extra == 'group-10-moviepilot-runtime-free-threaded' and extra == 'group-10-moviepilot-runtime-standard')" },
|
||||
{ name = "requests" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
@@ -1701,7 +1703,6 @@ dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "pympler" },
|
||||
{ name = "pyotp" },
|
||||
{ name = "pyparsing" },
|
||||
{ name = "pyquery" },
|
||||
@@ -1713,7 +1714,6 @@ dependencies = [
|
||||
{ name = "pytz" },
|
||||
{ name = "pyvirtualdisplay" },
|
||||
{ name = "pywebpush" },
|
||||
{ name = "pywin32", marker = "sys_platform == 'win32' or (extra == 'group-10-moviepilot-runtime-free-threaded' and extra == 'group-10-moviepilot-runtime-standard')" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "qbittorrent-api" },
|
||||
{ name = "redis" },
|
||||
@@ -1762,6 +1762,7 @@ runtime-standard = [
|
||||
{ name = "brotli", version = "1.2.0", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "lxml", version = "6.1.2", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "psycopg2-binary" },
|
||||
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
@@ -1821,7 +1822,6 @@ requires-dist = [
|
||||
{ name = "pydantic", specifier = ">=2.13.4,<3.0.0" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.14.2,<3.0.0" },
|
||||
{ name = "pyjwt", specifier = "~=2.13.0" },
|
||||
{ name = "pympler", specifier = "~=1.1" },
|
||||
{ name = "pyotp", specifier = "~=2.9.0" },
|
||||
{ name = "pyparsing", specifier = "~=3.3.2" },
|
||||
{ name = "pyquery", specifier = "~=2.0.1" },
|
||||
@@ -1833,7 +1833,6 @@ requires-dist = [
|
||||
{ name = "pytz", specifier = "~=2026.2" },
|
||||
{ name = "pyvirtualdisplay", specifier = "~=3.0" },
|
||||
{ name = "pywebpush", specifier = "~=2.3.0" },
|
||||
{ name = "pywin32", marker = "sys_platform == 'win32'", specifier = "==312" },
|
||||
{ name = "pyyaml", specifier = "~=6.0.3" },
|
||||
{ name = "qbittorrent-api", specifier = "==2026.6.0" },
|
||||
{ name = "redis", specifier = "~=8.1.0" },
|
||||
@@ -1882,6 +1881,7 @@ runtime-standard = [
|
||||
{ name = "brotli", specifier = "==1.2.0" },
|
||||
{ name = "lxml", specifier = "~=6.1.2" },
|
||||
{ name = "psycopg2-binary", specifier = "~=2.9.12" },
|
||||
{ name = "pywin32", marker = "sys_platform == 'win32'", specifier = "==312" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2504,18 +2504,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/b0/3a8040e53df6c5c1e04b0e23ed53fdbeb64f333723a334d313fba2f581ce/pylint-4.0.7-py3-none-any.whl", hash = "sha256:be4a3111557a614411ed1fc89347ce4a8e1013a59e1f33d11485227a02e3304d", size = 539710, upload-time = "2026-08-09T19:13:21.228Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pympler"
|
||||
version = "1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pywin32", marker = "sys_platform == 'win32' or (extra == 'group-10-moviepilot-runtime-free-threaded' and extra == 'group-10-moviepilot-runtime-standard')" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dd/37/c384631908029676d8e7213dd956bb686af303a80db7afbc9be36bc49495/pympler-1.1.tar.gz", hash = "sha256:1eaa867cb8992c218430f1708fdaccda53df064144d1c5656b1e6f1ee6000424", size = 179954, upload-time = "2024-06-28T19:56:06.563Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/79/4f/a6a2e2b202d7fd97eadfe90979845b8706676b41cbd3b42ba75adf329d1f/Pympler-1.1-py3-none-any.whl", hash = "sha256:5b223d6027d0619584116a0cbc28e8d2e378f7a79c1e5e024f9ff3b673c58506", size = 165766, upload-time = "2024-06-28T19:56:05.087Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyobjc-core"
|
||||
version = "12.2.2"
|
||||
|
||||
Reference in New Issue
Block a user