From 8cd2141b6dbf1d5e67c71fa68eb15a5091746b36 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sat, 29 Aug 2026 03:04:07 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=AF=E6=8C=81=20Windows=20Python=20?= =?UTF-8?q?3.14t=20=E8=BF=90=E8=A1=8C=E4=BE=9D=E8=B5=96=20(#6492)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(runtime): support Windows free-threaded dependencies * fix(runtime): close Windows profile validation gaps * chore(architecture): refresh runtime dependency baseline --- .github/workflows/dependency-compat.yml | 58 +++++- app/__init__.py | 24 +++ docs/architecture-optimization-checklist.md | 2 +- docs/architecture-overview.md | 4 +- docs/v3t-runtime-governance.md | 2 + pyproject.toml | 4 +- scripts/verify_runtime_profile.py | 185 ++++++++++++++++++ .../architecture/dependency-baseline.json | 13 +- tests/test_architecture_ci.py | 34 ++++ tests/test_runtime_dependencies.py | 114 +++++++++++ tests/test_startup_warnings.py | 53 +++++ uv.lock | 24 +-- 12 files changed, 482 insertions(+), 35 deletions(-) create mode 100644 scripts/verify_runtime_profile.py diff --git a/.github/workflows/dependency-compat.yml b/.github/workflows/dependency-compat.yml index 8c6b47501..7fd713a55 100644 --- a/.github/workflows/dependency-compat.yml +++ b/.github/workflows/dependency-compat.yml @@ -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 }} diff --git a/app/__init__.py b/app/__init__.py index 70298c772..b2558cc9d 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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() diff --git a/docs/architecture-optimization-checklist.md b/docs/architecture-optimization-checklist.md index f09889d91..9fd22504d 100644 --- a/docs/architecture-optimization-checklist.md +++ b/docs/architecture-optimization-checklist.md @@ -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 等基线均为零 | diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index ebd50bc79..70343e1df 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -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) | diff --git a/docs/v3t-runtime-governance.md b/docs/v3t-runtime-governance.md index 37958322b..d8cfbe27d 100644 --- a/docs/v3t-runtime-governance.md +++ b/docs/v3t-runtime-governance.md @@ -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 制品。 | diff --git a/pyproject.toml b/pyproject.toml index d094b3803..a353d92d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/scripts/verify_runtime_profile.py b/scripts/verify_runtime_profile.py new file mode 100644 index 000000000..d82da065f --- /dev/null +++ b/scripts/verify_runtime_profile.py @@ -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[^`]+)` requires `(?P[^`]+)`, " + 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, + ) diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index a825fa215..8f4bc994c 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -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", diff --git a/tests/test_architecture_ci.py b/tests/test_architecture_ci.py index c34ce0fb5..9abeebb64 100644 --- a/tests/test_architecture_ci.py +++ b/tests/test_architecture_ci.py @@ -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"] diff --git a/tests/test_runtime_dependencies.py b/tests/test_runtime_dependencies.py index 872c87655..68e591394 100644 --- a/tests/test_runtime_dependencies.py +++ b/tests/test_runtime_dependencies.py @@ -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 = { diff --git a/tests/test_startup_warnings.py b/tests/test_startup_warnings.py index 25f1b12c8..56b3b125d 100644 --- a/tests/test_startup_warnings.py +++ b/tests/test_startup_warnings.py @@ -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 的无效转义警告。 diff --git a/uv.lock b/uv.lock index c9e310780..77f5f1e47 100644 --- a/uv.lock +++ b/uv.lock @@ -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"