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:
InfinityPacer
2026-08-29 03:04:07 +08:00
committed by GitHub
parent 92066dfad4
commit 8cd2141b6d
12 changed files with 482 additions and 35 deletions
+49 -9
View File
@@ -7,9 +7,11 @@ on:
paths: paths:
- 'pyproject.toml' - 'pyproject.toml'
- 'uv.lock' - 'uv.lock'
- 'app/__init__.py'
- 'app/doctor/dependencies.py' - 'app/doctor/dependencies.py'
- 'app/foundation/environment.py' - 'app/foundation/environment.py'
- 'app/runtime/dependencies.py' - 'app/runtime/dependencies.py'
- 'scripts/verify_runtime_profile.py'
- 'docker/Dockerfile' - 'docker/Dockerfile'
- 'docker/**' - 'docker/**'
- '.github/workflows/dependency-compat.yml' - '.github/workflows/dependency-compat.yml'
@@ -19,9 +21,11 @@ on:
paths: paths:
- 'pyproject.toml' - 'pyproject.toml'
- 'uv.lock' - 'uv.lock'
- 'app/__init__.py'
- 'app/doctor/dependencies.py' - 'app/doctor/dependencies.py'
- 'app/foundation/environment.py' - 'app/foundation/environment.py'
- 'app/runtime/dependencies.py' - 'app/runtime/dependencies.py'
- 'scripts/verify_runtime_profile.py'
- 'docker/Dockerfile' - 'docker/Dockerfile'
- 'docker/**' - 'docker/**'
- '.github/workflows/dependency-compat.yml' - '.github/workflows/dependency-compat.yml'
@@ -46,26 +50,43 @@ jobs:
- name: Linux x64 - name: Linux x64
runner: ubuntu-24.04 runner: ubuntu-24.04
python-version: '3.14' python-version: '3.14'
runtime-group: runtime-standard
expected-profile: standard
expected-system: Linux expected-system: Linux
expected-machine: x86_64 expected-machine: x86_64
- name: Linux ARM64 - name: Linux ARM64
runner: ubuntu-24.04-arm runner: ubuntu-24.04-arm
python-version: '3.14' python-version: '3.14'
runtime-group: runtime-standard
expected-profile: standard
expected-system: Linux expected-system: Linux
expected-machine: aarch64 expected-machine: aarch64
- name: macOS Intel - name: macOS Intel
runner: macos-15-intel runner: macos-15-intel
python-version: '3.14' python-version: '3.14'
runtime-group: runtime-standard
expected-profile: standard
expected-system: Darwin expected-system: Darwin
expected-machine: x86_64 expected-machine: x86_64
- name: macOS ARM - name: macOS ARM
runner: macos-15 runner: macos-15
python-version: '3.14' python-version: '3.14'
runtime-group: runtime-standard
expected-profile: standard
expected-system: Darwin expected-system: Darwin
expected-machine: arm64 expected-machine: arm64
- name: Windows x64 - name: Windows x64
runner: windows-2025 runner: windows-2025
python-version: '3.14' 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-system: Windows
expected-machine: AMD64 expected-machine: AMD64
@@ -82,22 +103,41 @@ jobs:
pyproject.toml pyproject.toml
uv.lock uv.lock
- name: Install locked runtime dependencies - name: Expose PostgreSQL build tools
run: uv sync --locked --inexact --no-dev --python ${{ matrix.python-version }} 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: env:
AUTO_UPDATE_RESOURCE: 'false'
EXPECTED_SYSTEM: ${{ matrix.expected-system }} EXPECTED_SYSTEM: ${{ matrix.expected-system }}
EXPECTED_MACHINE: ${{ matrix.expected-machine }} 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: >- run: >-
uv run --locked --no-sync python -c uv run --locked --no-sync python -m scripts.verify_runtime_profile
"import os, platform; --expected-profile ${{ matrix.expected-profile }}
assert platform.system() == os.environ['EXPECTED_SYSTEM'], (platform.system(), os.environ['EXPECTED_SYSTEM']); --expected-system ${{ matrix.expected-system }}
assert platform.machine() == os.environ['EXPECTED_MACHINE'], (platform.machine(), os.environ['EXPECTED_MACHINE']); --expected-machine ${{ matrix.expected-machine }}
import alembic, fastapi, pydantic, pydantic_settings, sqlalchemy, starlette, uvicorn"
- name: Verify locked project consistency - 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: docker-dependencies:
name: Docker dependencies / ${{ matrix.platform }} name: Docker dependencies / ${{ matrix.platform }}
+24
View File
@@ -1,7 +1,30 @@
import os
import warnings 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 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: 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() _filter_third_party_startup_warnings()
install_legacy_import_hook() install_legacy_import_hook()
+1 -1
View File
@@ -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 移植包环 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
+2 -2
View File
@@ -722,8 +722,8 @@ flowchart LR
| 指标 | 当前值 | | 指标 | 当前值 |
|---|---:| |---|---:|
| Python 模块 | 906 | | Python 模块 | 907 |
| 内部导入边 | 7,612 | | 内部导入边 | 7,618 |
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
| Application / Chain 具体 Adapter 直连 | 0 / 0 | | Application / Chain 具体 Adapter 直连 | 0 / 0 |
| Direct egress | 55(债务已清零,55 条精确 containment | | Direct egress | 55(债务已清零,55 条精确 containment |
+2
View File
@@ -47,6 +47,8 @@ profile,依赖名称、版本和 source 语义全部由 `pyproject.toml` 与 `
| CRC 加速 | `crcmod-plus` 2.3.1 | `crcmod-plus` 2.3.1 | 两套镜像统一使用继续维护且兼容 `crcmod` 导入接口的实现。`oss2` 的陈旧元数据仍声明不再维护的 `crcmod`,由 uv 在解析时排除该传递依赖;宿主不在运行时映射、卸载或替插件兼容旧分发包。 | | 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、站点解析、并发和内存验证再替换。 | | `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`。 | | 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 transportV3t 仅提供不依赖 pywin32 的 Docker SDK 路径。Docker 或 pywin32 提供兼容 `cp314t` 的组合后,重新验证 named-pipe 再解除排除。 |
| `orjson` | 3.12.0 wheel | 同版本源码构建 | V3t 使用同一锁定版本并启用 free-threaded 构建变量。上游发布覆盖 Linux amd64/arm64 的稳定 `cp314t` wheel 后可删除本地构建要求。 | | `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`。 | | 中文转换 | `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 制品。 | | 站点资源 | `cpython-314` | `cpython-314t` | 资源文件必须按解释器 ABI 独立构建和选取,不能让 V3t 复用普通 CPython 扩展,也不能影响 V2 的历史 ABI 制品。 |
+2 -2
View File
@@ -62,7 +62,6 @@ dependencies = [
"pydantic>=2.13.4,<3.0.0", "pydantic>=2.13.4,<3.0.0",
"pydantic-settings>=2.14.2,<3.0.0", "pydantic-settings>=2.14.2,<3.0.0",
"pyjwt~=2.13.0", "pyjwt~=2.13.0",
"pympler~=1.1",
"pyotp~=2.9.0", "pyotp~=2.9.0",
"pyparsing~=3.3.2", "pyparsing~=3.3.2",
"pyquery~=2.0.1", "pyquery~=2.0.1",
@@ -74,7 +73,6 @@ dependencies = [
"pytz~=2026.2", "pytz~=2026.2",
"pyvirtualdisplay~=3.0", "pyvirtualdisplay~=3.0",
"pywebpush~=2.3.0", "pywebpush~=2.3.0",
"pywin32==312 ; sys_platform == 'win32'",
"pyyaml~=6.0.3", "pyyaml~=6.0.3",
"qbittorrent-api==2026.6.0", "qbittorrent-api==2026.6.0",
"redis~=8.1.0", "redis~=8.1.0",
@@ -117,6 +115,7 @@ runtime-standard = [
"bcrypt~=4.3.0", "bcrypt~=4.3.0",
"lxml~=6.1.2", "lxml~=6.1.2",
"psycopg2-binary~=2.9.12", "psycopg2-binary~=2.9.12",
"pywin32==312 ; sys_platform == 'win32'",
] ]
runtime-free-threaded = [ runtime-free-threaded = [
"Brotli==1.2.0", "Brotli==1.2.0",
@@ -144,6 +143,7 @@ conflicts = [
], ],
] ]
exclude-dependencies = [ exclude-dependencies = [
{ package = { name = "docker" }, dependencies = ["pywin32"] },
{ package = { name = "oss2" }, dependencies = ["crcmod"] }, { package = { name = "oss2" }, dependencies = ["crcmod"] },
] ]
environments = [ environments = [
+185
View File
@@ -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
View File
@@ -1119,9 +1119,11 @@
"runtime_only": true "runtime_only": true
} }
}, },
"edge_count": 7612, "edge_count": 7618,
"edge_sha256": "15b211a9bc9400fa8a28480223372039344ae8e584bd1ac769359a808e4610be", "edge_sha256": "be924b781df81299b96c7580034b5c27bebeb97285871586b6f0b51f51a83d9b",
"edges": [ "edges": [
"app -> app.foundation",
"app -> app.foundation.environment",
"app -> app.runtime", "app -> app.runtime",
"app -> app.runtime.compat", "app -> app.runtime.compat",
"app -> app.runtime.compat.imports", "app -> app.runtime.compat.imports",
@@ -1267,6 +1269,7 @@
"app.adapters.system.plugin.package -> app.runtime", "app.adapters.system.plugin.package -> app.runtime",
"app.adapters.system.plugin.package -> app.runtime.execution", "app.adapters.system.plugin.package -> app.runtime.execution",
"app.adapters.system.plugin.package -> app.runtime.log", "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.plugin.package -> app.runtime.settings",
"app.adapters.system.resource -> app.adapters", "app.adapters.system.resource -> app.adapters",
"app.adapters.system.resource -> app.adapters.network", "app.adapters.system.resource -> app.adapters.network",
@@ -3918,6 +3921,7 @@
"app.application.plugin.install -> app.runtime", "app.application.plugin.install -> app.runtime",
"app.application.plugin.install -> app.runtime.execution", "app.application.plugin.install -> app.runtime.execution",
"app.application.plugin.install -> app.runtime.log", "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",
"app.application.plugin.install -> app.schemas.exception", "app.application.plugin.install -> app.schemas.exception",
"app.application.plugin.install -> app.schemas.plugin", "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",
"app.runtime.extensions.service_config -> app.schemas.system", "app.runtime.extensions.service_config -> app.schemas.system",
"app.runtime.extensions.service_config -> app.schemas.types", "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",
"app.runtime.observability -> app.runtime.deprecation", "app.runtime.observability -> app.runtime.deprecation",
"app.runtime.observability -> app.runtime.deprecation.policy", "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",
"app.workflow.actions.transfer_file -> app.workflow.actions" "app.workflow.actions.transfer_file -> app.workflow.actions"
], ],
"module_count": 906, "module_count": 907,
"modules": [ "modules": [
"app", "app",
"app.adapters", "app.adapters",
@@ -9505,6 +9511,7 @@
"app.runtime.localization", "app.runtime.localization",
"app.runtime.log", "app.runtime.log",
"app.runtime.loop", "app.runtime.loop",
"app.runtime.native_dependencies",
"app.runtime.observability", "app.runtime.observability",
"app.runtime.progress", "app.runtime.progress",
"app.runtime.rate", "app.runtime.rate",
+34
View File
@@ -195,3 +195,37 @@ def test_native_dependency_update_probe_has_narrow_automatic_triggers():
assert workflow["on"]["push"]["paths"] == expected_paths assert workflow["on"]["push"]["paths"] == expected_paths
assert "workflow_dispatch" in workflow["on"] assert "workflow_dispatch" in workflow["on"]
assert set(workflow["jobs"]) == {"probe"} 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"]
+114
View File
@@ -7,6 +7,7 @@ import pytest
from app.doctor import dependencies as dependency_doctor from app.doctor import dependencies as dependency_doctor
from app.foundation import environment from app.foundation import environment
from app.runtime import dependencies from app.runtime import dependencies
from scripts import verify_runtime_profile
def test_free_threaded_runtime_tracks_interpreter_build(monkeypatch): 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"] } 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): def test_standard_runtime_uses_shared_rust_text_capabilities(monkeypatch):
"""标准镜像的文本能力不得重新引入 profile 专属实现。""" """标准镜像的文本能力不得重新引入 profile 专属实现。"""
imported = [] 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): def test_full_dependency_probe_rejects_psycopg_python_fallback(monkeypatch):
"""V3t 构建不得把 psycopg 纯 Python 实现误认为可发布能力。""" """V3t 构建不得把 psycopg 纯 Python 实现误认为可发布能力。"""
modules = { modules = {
+53
View File
@@ -1,8 +1,61 @@
import warnings import warnings
import pytest
import app 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(): def test_app_installs_known_oss2_invalid_escape_warning_filter():
""" """
app 初始化过滤器应覆盖 oss2 的无效转义警告。 app 初始化过滤器应覆盖 oss2 的无效转义警告。
Generated
+6 -18
View File
@@ -28,7 +28,10 @@ conflicts = [[
]] ]]
[manifest] [manifest]
excludes = [{ package = { name = "oss2" }, dependencies = ["crcmod"] }] excludes = [
{ package = { name = "docker" }, dependencies = ["pywin32"] },
{ package = { name = "oss2" }, dependencies = ["crcmod"] },
]
[[package]] [[package]]
name = "aiofiles" name = "aiofiles"
@@ -842,7 +845,6 @@ name = "docker"
version = "7.1.0" version = "7.1.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ 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 = "requests" },
{ name = "urllib3" }, { name = "urllib3" },
] ]
@@ -1701,7 +1703,6 @@ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "pyjwt" }, { name = "pyjwt" },
{ name = "pympler" },
{ name = "pyotp" }, { name = "pyotp" },
{ name = "pyparsing" }, { name = "pyparsing" },
{ name = "pyquery" }, { name = "pyquery" },
@@ -1713,7 +1714,6 @@ dependencies = [
{ name = "pytz" }, { name = "pytz" },
{ name = "pyvirtualdisplay" }, { name = "pyvirtualdisplay" },
{ name = "pywebpush" }, { 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 = "pyyaml" },
{ name = "qbittorrent-api" }, { name = "qbittorrent-api" },
{ name = "redis" }, { name = "redis" },
@@ -1762,6 +1762,7 @@ runtime-standard = [
{ name = "brotli", version = "1.2.0", source = { registry = "https://pypi.org/simple" } }, { 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 = "lxml", version = "6.1.2", source = { registry = "https://pypi.org/simple" } },
{ name = "psycopg2-binary" }, { name = "psycopg2-binary" },
{ name = "pywin32", marker = "sys_platform == 'win32'" },
] ]
[package.metadata] [package.metadata]
@@ -1821,7 +1822,6 @@ requires-dist = [
{ name = "pydantic", specifier = ">=2.13.4,<3.0.0" }, { name = "pydantic", specifier = ">=2.13.4,<3.0.0" },
{ name = "pydantic-settings", specifier = ">=2.14.2,<3.0.0" }, { name = "pydantic-settings", specifier = ">=2.14.2,<3.0.0" },
{ name = "pyjwt", specifier = "~=2.13.0" }, { name = "pyjwt", specifier = "~=2.13.0" },
{ name = "pympler", specifier = "~=1.1" },
{ name = "pyotp", specifier = "~=2.9.0" }, { name = "pyotp", specifier = "~=2.9.0" },
{ name = "pyparsing", specifier = "~=3.3.2" }, { name = "pyparsing", specifier = "~=3.3.2" },
{ name = "pyquery", specifier = "~=2.0.1" }, { name = "pyquery", specifier = "~=2.0.1" },
@@ -1833,7 +1833,6 @@ requires-dist = [
{ name = "pytz", specifier = "~=2026.2" }, { name = "pytz", specifier = "~=2026.2" },
{ name = "pyvirtualdisplay", specifier = "~=3.0" }, { name = "pyvirtualdisplay", specifier = "~=3.0" },
{ name = "pywebpush", specifier = "~=2.3.0" }, { name = "pywebpush", specifier = "~=2.3.0" },
{ name = "pywin32", marker = "sys_platform == 'win32'", specifier = "==312" },
{ name = "pyyaml", specifier = "~=6.0.3" }, { name = "pyyaml", specifier = "~=6.0.3" },
{ name = "qbittorrent-api", specifier = "==2026.6.0" }, { name = "qbittorrent-api", specifier = "==2026.6.0" },
{ name = "redis", specifier = "~=8.1.0" }, { name = "redis", specifier = "~=8.1.0" },
@@ -1882,6 +1881,7 @@ runtime-standard = [
{ name = "brotli", specifier = "==1.2.0" }, { name = "brotli", specifier = "==1.2.0" },
{ name = "lxml", specifier = "~=6.1.2" }, { name = "lxml", specifier = "~=6.1.2" },
{ name = "psycopg2-binary", specifier = "~=2.9.12" }, { name = "psycopg2-binary", specifier = "~=2.9.12" },
{ name = "pywin32", marker = "sys_platform == 'win32'", specifier = "==312" },
] ]
[[package]] [[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" }, { 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]] [[package]]
name = "pyobjc-core" name = "pyobjc-core"
version = "12.2.2" version = "12.2.2"