mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +08:00
feat: 新增 Python 3.14t 自由线程镜像 (#6434)
* fix(resource): select free-threaded extension ABI * chore(deps): require moviepilot-rust 0.2.9 * perf: add free-threaded runtime comparison * feat: add free-threaded runtime profile * chore(deps): require moviepilot-rust 0.3.0 * test: isolate system endpoint import graph * fix(docker): preserve runtime profile during recovery * feat(runtime): expose active GIL state * feat(plugin): log GIL fallback attribution * test: refresh runtime observability dependency baseline * fix(plugin): validate the active uv runtime profile * test(runtime): expand free-threaded benchmark evidence * docs(runtime): define v3t governance gates * test(runtime): separate rust benchmark modes * docs: sync free-threaded architecture baseline * perf: add PostgreSQL driver comparison * docs(runtime): record final free-threaded evidence * fix(runtime): converge dual-profile dependency verification * fix(runtime): scope Python 3.14 warning filter * fix(runtime): match actual oss2 syntax warning * docs(runtime): refresh free-threaded benchmark evidence * test(architecture): refresh runtime dependency baseline * ci: skip unused Trivy Java database * build: exclude local verification artifacts * docs(runtime): refresh PostgreSQL driver benchmarks * docs(runtime): record plugin restore acceptance * docs(runtime): record amd64 candidate acceptance * ci: pin beta image publisher action * test(architecture): merge runtime dependency baseline * feat(runtime): expose Python GIL status * docs(runtime): document Python runtime status fields
This commit is contained in:
+20
-3
@@ -13,8 +13,8 @@
|
||||
"runtime_to_db": [],
|
||||
"workflow_to_db": []
|
||||
},
|
||||
"edge_count": 6564,
|
||||
"edge_sha256": "333de3c1ef3f7e49be96dda288a0f382bcd19a853817708453d67557d138aa80",
|
||||
"edge_count": 6579,
|
||||
"edge_sha256": "87982f9e351a23cb949bcb8a978b9c1eced6260d6c19761fccc9557b79ac90d3",
|
||||
"edges": [
|
||||
"app -> app.runtime",
|
||||
"app -> app.runtime.compat",
|
||||
@@ -62,6 +62,7 @@
|
||||
"app.adapters.external.market -> app.foundation.version",
|
||||
"app.adapters.external.market -> app.runtime",
|
||||
"app.adapters.external.market -> app.runtime.cache",
|
||||
"app.adapters.external.market -> app.runtime.dependencies",
|
||||
"app.adapters.external.market -> app.runtime.execution",
|
||||
"app.adapters.external.market -> app.runtime.log",
|
||||
"app.adapters.external.market -> app.runtime.observability",
|
||||
@@ -134,6 +135,8 @@
|
||||
"app.adapters.system.host -> app.foundation.environment",
|
||||
"app.adapters.system.host -> app.schemas",
|
||||
"app.adapters.system.host -> app.schemas.dashboard",
|
||||
"app.adapters.system.package -> app.runtime",
|
||||
"app.adapters.system.package -> app.runtime.dependencies",
|
||||
"app.adapters.system.plugin.dependency -> app.adapters",
|
||||
"app.adapters.system.plugin.dependency -> app.adapters.external",
|
||||
"app.adapters.system.plugin.dependency -> app.adapters.external.market",
|
||||
@@ -163,6 +166,8 @@
|
||||
"app.adapters.system.resource -> app.runtime.config",
|
||||
"app.adapters.system.resource -> app.runtime.log",
|
||||
"app.adapters.system.resource -> app.runtime.settings",
|
||||
"app.adapters.system.rust -> app.foundation",
|
||||
"app.adapters.system.rust -> app.foundation.environment",
|
||||
"app.adapters.system.rust -> app.runtime",
|
||||
"app.adapters.system.rust -> app.runtime.log",
|
||||
"app.adapters.system.rust -> app.runtime.settings",
|
||||
@@ -2292,6 +2297,7 @@
|
||||
"app.api.endpoints.system -> app.domain.metainfo",
|
||||
"app.api.endpoints.system -> app.foundation",
|
||||
"app.api.endpoints.system -> app.foundation.crypto",
|
||||
"app.api.endpoints.system -> app.foundation.environment",
|
||||
"app.api.endpoints.system -> app.foundation.url",
|
||||
"app.api.endpoints.system -> app.runtime",
|
||||
"app.api.endpoints.system -> app.runtime.config",
|
||||
@@ -3593,6 +3599,8 @@
|
||||
"app.db.engine -> app.db",
|
||||
"app.db.engine -> app.db.diagnostics",
|
||||
"app.db.engine -> app.db.worker",
|
||||
"app.db.engine -> app.foundation",
|
||||
"app.db.engine -> app.foundation.environment",
|
||||
"app.db.engine -> app.runtime",
|
||||
"app.db.engine -> app.runtime.config",
|
||||
"app.db.engine -> app.runtime.log",
|
||||
@@ -3927,6 +3935,8 @@
|
||||
"app.factory -> app.schemas.response",
|
||||
"app.factory -> app.startup",
|
||||
"app.factory -> app.startup.lifecycle",
|
||||
"app.foundation.text -> app.foundation",
|
||||
"app.foundation.text -> app.foundation.environment",
|
||||
"app.main -> app.adapters",
|
||||
"app.main -> app.adapters.system",
|
||||
"app.main -> app.adapters.system.host",
|
||||
@@ -5597,6 +5607,8 @@
|
||||
"app.runtime.config -> app.schemas.types",
|
||||
"app.runtime.debounce -> app.runtime",
|
||||
"app.runtime.debounce -> app.runtime.log",
|
||||
"app.runtime.dependencies -> app.foundation",
|
||||
"app.runtime.dependencies -> app.foundation.environment",
|
||||
"app.runtime.deprecation.policy -> app.runtime",
|
||||
"app.runtime.deprecation.policy -> app.runtime.deprecation",
|
||||
"app.runtime.deprecation.policy -> app.runtime.deprecation.notices",
|
||||
@@ -5749,6 +5761,7 @@
|
||||
"app.runtime.extensions.plugin.tools -> app.runtime.extensions.plugin.contracts",
|
||||
"app.runtime.extensions.plugin_manager -> app.foundation",
|
||||
"app.runtime.extensions.plugin_manager -> app.foundation.crypto",
|
||||
"app.runtime.extensions.plugin_manager -> app.foundation.environment",
|
||||
"app.runtime.extensions.plugin_manager -> app.foundation.singleton",
|
||||
"app.runtime.extensions.plugin_manager -> app.foundation.version",
|
||||
"app.runtime.extensions.plugin_manager -> app.runtime",
|
||||
@@ -6369,6 +6382,8 @@
|
||||
"app.startup.lifecycle -> app.chain.system",
|
||||
"app.startup.lifecycle -> app.db",
|
||||
"app.startup.lifecycle -> app.db.engine",
|
||||
"app.startup.lifecycle -> app.foundation",
|
||||
"app.startup.lifecycle -> app.foundation.environment",
|
||||
"app.startup.lifecycle -> app.runtime",
|
||||
"app.startup.lifecycle -> app.runtime.config",
|
||||
"app.startup.lifecycle -> app.runtime.health",
|
||||
@@ -6581,7 +6596,7 @@
|
||||
"app.workflow.actions.transfer_file -> app.workflow",
|
||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||
],
|
||||
"module_count": 810,
|
||||
"module_count": 812,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -7019,6 +7034,7 @@
|
||||
"app.db.worker",
|
||||
"app.doctor",
|
||||
"app.doctor.checks",
|
||||
"app.doctor.dependencies",
|
||||
"app.doctor.formatters",
|
||||
"app.doctor.models",
|
||||
"app.doctor.runner",
|
||||
@@ -7237,6 +7253,7 @@
|
||||
"app.runtime.config",
|
||||
"app.runtime.correlation",
|
||||
"app.runtime.debounce",
|
||||
"app.runtime.dependencies",
|
||||
"app.runtime.deprecation",
|
||||
"app.runtime.deprecation.notices",
|
||||
"app.runtime.deprecation.policy",
|
||||
|
||||
@@ -8700,6 +8700,11 @@
|
||||
"name": "TimerUtils",
|
||||
"target": "app.runtime.scheduling.TimerUtils"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "convert",
|
||||
"target": "app.foundation.text.convert"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "cut",
|
||||
|
||||
@@ -11,10 +11,11 @@ def test_runtime_image_installs_postgresql_18_client_from_pgdg() -> None:
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert re.search(
|
||||
r"^FROM python:3\.14\.7-slim-trixie AS base$",
|
||||
r'^ARG MOVIEPILOT_PYTHON_VERSION="3\.14\.7"$',
|
||||
dockerfile,
|
||||
re.MULTILINE,
|
||||
)
|
||||
assert "FROM python:${MOVIEPILOT_PYTHON_VERSION}-slim-trixie AS base" in dockerfile
|
||||
assert "https://www.postgresql.org/media/keys/ACCC4CF8.asc" in dockerfile
|
||||
for curl_option in (
|
||||
"--connect-timeout 10",
|
||||
|
||||
@@ -5,13 +5,18 @@ import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
import psycopg2
|
||||
from psycopg2 import sql
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
|
||||
try:
|
||||
import psycopg2 as postgres_driver
|
||||
from psycopg2 import sql
|
||||
except ModuleNotFoundError:
|
||||
import psycopg as postgres_driver
|
||||
from psycopg import sql
|
||||
|
||||
|
||||
MIGRATION_MODULE = "database.versions.93f8cb6a4d1e_2_2_4"
|
||||
MEDIA_TABLES = (
|
||||
@@ -370,7 +375,7 @@ def test_current_schema_reaches_current_alembic_head_on_postgresql(
|
||||
port = os.getenv(f"{prefix}PORT", "5432")
|
||||
password = os.getenv(f"{prefix}PASSWORD", "")
|
||||
schema = f"p1_db1_{uuid.uuid4().hex}"
|
||||
with psycopg2.connect(
|
||||
with postgres_driver.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
dbname=database,
|
||||
@@ -400,7 +405,7 @@ def test_current_schema_reaches_current_alembic_head_on_postgresql(
|
||||
try:
|
||||
_run_current_schema_chain(repository, environment)
|
||||
finally:
|
||||
with psycopg2.connect(
|
||||
with postgres_driver.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
dbname=database,
|
||||
|
||||
@@ -2,13 +2,20 @@ import importlib
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import psycopg2
|
||||
from psycopg2 import sql
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
|
||||
try:
|
||||
import psycopg2 as postgres_driver
|
||||
from psycopg2 import sql
|
||||
POSTGRESQL_SQLALCHEMY_DRIVER = "postgresql+psycopg2"
|
||||
except ModuleNotFoundError:
|
||||
import psycopg as postgres_driver
|
||||
from psycopg import sql
|
||||
POSTGRESQL_SQLALCHEMY_DRIVER = "postgresql+psycopg"
|
||||
|
||||
|
||||
E6_MIGRATION = "database.versions.e6a1c4b8d2f0_2_2_13"
|
||||
F7_MIGRATION = "database.versions.f7b2d5c9a301_2_2_14"
|
||||
@@ -636,7 +643,7 @@ def test_e6_f7_round_trip_on_postgresql(monkeypatch) -> None:
|
||||
port = os.getenv(f"{prefix}PORT", "5432")
|
||||
password = os.getenv(f"{prefix}PASSWORD", "")
|
||||
schema = f"p1_db1_roundtrip_{uuid.uuid4().hex}"
|
||||
with psycopg2.connect(
|
||||
with postgres_driver.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
dbname=database,
|
||||
@@ -654,7 +661,7 @@ def test_e6_f7_round_trip_on_postgresql(monkeypatch) -> None:
|
||||
try:
|
||||
engine = sa.create_engine(
|
||||
sa.URL.create(
|
||||
"postgresql+psycopg2",
|
||||
POSTGRESQL_SQLALCHEMY_DRIVER,
|
||||
username=username,
|
||||
password=password,
|
||||
host=host,
|
||||
@@ -667,7 +674,7 @@ def test_e6_f7_round_trip_on_postgresql(monkeypatch) -> None:
|
||||
finally:
|
||||
if engine is not None:
|
||||
engine.dispose()
|
||||
with psycopg2.connect(
|
||||
with postgres_driver.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
dbname=database,
|
||||
|
||||
@@ -223,6 +223,19 @@ def test_pg_sync_engine_applies_pool_settings(monkeypatch):
|
||||
assert captured["url"].startswith("postgresql")
|
||||
|
||||
|
||||
def test_pg_sync_engine_uses_psycopg_on_free_threaded_python(monkeypatch):
|
||||
"""free-threaded 运行时不能加载会重新启用 GIL 的 psycopg2 扩展。"""
|
||||
monkeypatch.setattr(engine_module, "is_free_threaded_runtime", lambda: True)
|
||||
captured = {}
|
||||
monkeypatch.setattr(engine_module, "create_engine",
|
||||
lambda **kw: captured.update(kw) or MagicMock())
|
||||
monkeypatch.setattr(engine_module, "_register_database_error_logging", lambda *_a: None)
|
||||
|
||||
engine_module._get_postgresql_engine(is_async=False)
|
||||
|
||||
assert captured["url"].startswith("postgresql+psycopg://")
|
||||
|
||||
|
||||
def test_pg_async_engine_pooled_omits_poolclass(monkeypatch):
|
||||
"""
|
||||
池化的异步引擎不得指定 poolclass:SQLAlchemy 需自行选用异步适配的
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
@@ -35,8 +36,10 @@ def test_dockerfile_control_bundle_build_checks_fail_closed() -> None:
|
||||
assert "COPY --from=uv /uv /usr/local/bin/uv" in dockerfile
|
||||
assert "COPY pyproject.toml uv.lock ./" in dockerfile
|
||||
assert "python3 -m venv --without-pip ${VENV_PATH}" in dockerfile
|
||||
assert 'sysconfig.get_path("purelib")' in dockerfile
|
||||
assert "UV_PROJECT_ENVIRONMENT=${VENV_PATH} uv sync" in dockerfile
|
||||
for option in ("--locked", "--no-dev", "--no-install-project"):
|
||||
assert "PYTHON_THREAD_INHERIT_CONTEXT=0" in dockerfile
|
||||
for option in ("--locked", "--no-default-groups", "--group", "--no-install-project"):
|
||||
assert option in dockerfile
|
||||
assert "uv-pip-compat" not in dockerfile
|
||||
assert "requirements.in" not in dockerfile
|
||||
@@ -58,6 +61,81 @@ def test_dockerfile_control_bundle_build_checks_fail_closed() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_update_sync_selects_target_interpreter_runtime_group(tmp_path: Path) -> None:
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
(project / "pyproject.toml").write_text(
|
||||
"""
|
||||
[project]
|
||||
name = "moviepilot"
|
||||
version = "0"
|
||||
|
||||
[dependency-groups]
|
||||
runtime-standard = ["standard"]
|
||||
runtime-free-threaded = ["free-threaded"]
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(project / "uv.lock").write_text("version = 1\n", encoding="utf-8")
|
||||
runtime_selector = project / "app" / "runtime" / "dependencies.py"
|
||||
runtime_selector.parent.mkdir(parents=True)
|
||||
runtime_selector.write_text("print('runtime-free-threaded')\n", encoding="utf-8")
|
||||
venv_bin = tmp_path / "venv" / "bin"
|
||||
venv_bin.mkdir(parents=True)
|
||||
python_bin = venv_bin / "python3"
|
||||
python_bin.write_text(
|
||||
"#!/bin/bash\ncat >/dev/null\nprintf '%s\\n' runtime-free-threaded\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
python_bin.chmod(0o755)
|
||||
uv_bin = tmp_path / "uv"
|
||||
uv_log = tmp_path / "uv.log"
|
||||
uv_bin.write_text(
|
||||
f"#!/bin/bash\nprintf '%s\\n' \"$*\" > {shlex.quote(str(uv_log))}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
uv_bin.chmod(0o755)
|
||||
script = textwrap.dedent(
|
||||
f"""\
|
||||
CONFIG_DIR="$1"
|
||||
VENV_PATH="$2"
|
||||
UV_BIN="$3"
|
||||
source {UPDATER!s}
|
||||
PACKAGE_ENV=()
|
||||
UV_OPTIONS=()
|
||||
sync_project_dependencies_for "$4"
|
||||
"""
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"bash",
|
||||
"-c",
|
||||
script,
|
||||
"runtime-profile-test",
|
||||
str(tmp_path / "config"),
|
||||
str(tmp_path / "venv"),
|
||||
str(uv_bin),
|
||||
str(project),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
command = uv_log.read_text(encoding="utf-8")
|
||||
assert "--no-default-groups --group runtime-free-threaded" in command
|
||||
|
||||
|
||||
def test_build_profiles_use_full_runtime_capability_probe() -> None:
|
||||
"""两套依赖 profile 必须经过统一的完整运行能力验证。"""
|
||||
dockerfile = (ROOT / "docker" / "Dockerfile").read_text(encoding="utf-8")
|
||||
|
||||
assert dockerfile.count(
|
||||
'RUN "${VENV_PATH}/bin/python" /tmp/moviepilot-runtime-dependencies.py --full'
|
||||
) == 2
|
||||
assert "moviepilot_rust.jieba_cut" not in dockerfile
|
||||
assert "moviepilot_rust.zhconv_fast" not in dockerfile
|
||||
|
||||
|
||||
def _run_launcher(
|
||||
tmp_path: Path,
|
||||
source: Path,
|
||||
@@ -915,6 +993,9 @@ def test_failed_dependency_sync_does_not_replace_program_files(tmp_path: Path) -
|
||||
encoding="utf-8",
|
||||
)
|
||||
uv_bin.chmod(0o755)
|
||||
python_bin = tmp_path / "venv" / "bin" / "python3"
|
||||
python_bin.parent.mkdir(parents=True)
|
||||
python_bin.symlink_to(Path(sys.executable))
|
||||
live_app = tmp_path / "app"
|
||||
live_public = tmp_path / "public"
|
||||
(live_app / "app" / "plugins").mkdir(parents=True)
|
||||
|
||||
@@ -315,11 +315,15 @@ def test_browser_install_is_centralized_in_startup() -> None:
|
||||
browser = (ROOT / "docker" / "browser.sh").read_text(encoding="utf-8")
|
||||
updater = (ROOT / "docker" / "update.sh").read_text(encoding="utf-8")
|
||||
startup = entrypoint.split("# 使用env配置", 1)[1]
|
||||
updater_source = 'source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/update.sh"'
|
||||
|
||||
assert "-m cloakbrowser install" not in entrypoint
|
||||
assert browser.count("-m cloakbrowser install") == 2
|
||||
assert "-m cloakbrowser install" not in updater
|
||||
assert startup.index('source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/update.sh"') < startup.index(
|
||||
assert startup.count(updater_source) == 1
|
||||
assert startup.index(updater_source) < startup.index(
|
||||
'if [ "${MOVIEPILOT_BOOTSTRAP_UPDATE_DONE:-0}" != "1" ]'
|
||||
) < startup.index(
|
||||
'source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/browser.sh"'
|
||||
) < startup.index("resolve_browser_cache_dir") < startup.index("ensure_browser_kernel")
|
||||
|
||||
@@ -589,6 +593,46 @@ def test_backend_ready_timeout_accepts_leading_zero_decimal(tmp_path: Path) -> N
|
||||
assert "MoviePilot Web 已可访问" in output
|
||||
|
||||
|
||||
def test_backend_dependency_recovery_uses_runtime_profile_sync(tmp_path: Path) -> None:
|
||||
"""启动自愈必须复用按当前解释器选择依赖组的同步入口。"""
|
||||
venv_bin = tmp_path / "venv" / "bin"
|
||||
venv_bin.mkdir(parents=True)
|
||||
python_bin = venv_bin / "python3"
|
||||
python_bin.write_text(
|
||||
"#!/bin/bash\n[ -f \"${RECOVERY_MARKER}\" ]\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
python_bin.chmod(0o755)
|
||||
marker = tmp_path / "recovered"
|
||||
|
||||
output = _run_entrypoint_case(
|
||||
tmp_path,
|
||||
"""
|
||||
INFO() { printf '[INFO] %s\\n' "$1"; }
|
||||
WARN() { printf '[WARN] %s\\n' "$1"; }
|
||||
configure_package_route() {
|
||||
PACKAGE_LOG="test-route"
|
||||
printf 'configured\\n'
|
||||
}
|
||||
sync_project_dependencies_for() {
|
||||
printf 'sync:%s\\n' "$1"
|
||||
touch "${RECOVERY_MARKER}"
|
||||
}
|
||||
ensure_backend_runtime_dependencies
|
||||
printf 'route-ready:%s\\n' "${PACKAGE_ROUTE_READY}"
|
||||
""",
|
||||
env={
|
||||
"VENV_PATH": str(tmp_path / "venv"),
|
||||
"RECOVERY_MARKER": str(marker),
|
||||
},
|
||||
)
|
||||
|
||||
assert "configured" in output
|
||||
assert "sync:/app" in output
|
||||
assert "route-ready:true" in output
|
||||
assert marker.exists()
|
||||
|
||||
|
||||
def test_backend_failure_keepalive_contract_is_explicit() -> None:
|
||||
"""后端异常默认保活诊断,显式关闭后才退出容器。"""
|
||||
content = (ROOT / "docker" / "entrypoint.sh").read_text(encoding="utf-8")
|
||||
|
||||
@@ -21,6 +21,7 @@ def test_build_context_excludes_runtime_state_and_keeps_release_inputs() -> None
|
||||
for pattern in (
|
||||
".venv/",
|
||||
".worktrees/",
|
||||
".artifacts/",
|
||||
".build/",
|
||||
".agent-work/",
|
||||
".runtime/",
|
||||
@@ -141,5 +142,6 @@ def test_custom_frontend_directory_is_stable_but_artifacts_remain_untracked() ->
|
||||
assert (ROOT / "frontend-dist" / ".gitkeep").is_file()
|
||||
assert "frontend-dist/*" in gitignore
|
||||
assert "!frontend-dist/.gitkeep" in gitignore
|
||||
assert ".artifacts/" in gitignore
|
||||
assert "COPY frontend-dist/ /tmp/frontend-dist/" in dockerfile
|
||||
assert "! -name '.gitkeep'" in dockerfile
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
"""free-threaded 镜像 A/B harness 的无 Docker 合同测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "perf" / "free_threaded_ab.py"
|
||||
|
||||
|
||||
def load_harness(name: str = "free_threaded_ab_test"):
|
||||
"""从脚本路径加载 harness,避免把 scripts 变成运行时 package。"""
|
||||
spec = importlib.util.spec_from_file_location(name, SCRIPT)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def digest_image(repository: str, character: str) -> str:
|
||||
"""构造测试用不可变镜像引用。"""
|
||||
return f"registry.example/{repository}@sha256:{character * 64}"
|
||||
|
||||
|
||||
def valid_preflight(variant: str) -> dict:
|
||||
"""构造满足目标镜像合同的 preflight。"""
|
||||
common = {
|
||||
"python_version": "3.14.7",
|
||||
"python_implementation": "CPython",
|
||||
"moviepilot_rust_version": "0.3.0",
|
||||
"rust_available": True,
|
||||
"has_jieba_cut": True,
|
||||
"packages": {},
|
||||
"native": {},
|
||||
"installed_packages": ["moviepilot-rust==0.3.0"],
|
||||
"installed_packages_sha256": "package-hash",
|
||||
"native_distributions": [],
|
||||
"uv_pip_check": {
|
||||
"returncode": 1,
|
||||
"stderr": "The package `oss2` requires `crcmod>=1.7`, but it's not installed",
|
||||
},
|
||||
"uv_project_sync_check": {"returncode": 0},
|
||||
}
|
||||
if variant == "v3":
|
||||
common.update(
|
||||
{
|
||||
"gil_disabled": False,
|
||||
"gil_enabled": True,
|
||||
"thread_inherit_context": 0,
|
||||
"has_zhconv_fast": False,
|
||||
"gil_enabled_after_imports": True,
|
||||
"packages": {
|
||||
"bcrypt": "4.3.0",
|
||||
"brotli": "1.2.0",
|
||||
"crcmod": None,
|
||||
"crcmod-plus": "2.3.1",
|
||||
"lxml": "6.1.2",
|
||||
"orjson": "3.12.0",
|
||||
"psycopg": None,
|
||||
"psycopg2-binary": "2.9.12",
|
||||
"zhconv-rs": "0.4.1",
|
||||
},
|
||||
"native": {
|
||||
"crcmod_extension": True,
|
||||
},
|
||||
"imports": {
|
||||
"moviepilot-rust": {"imported": True, "gil_after": True},
|
||||
"psycopg2-binary": {"imported": True, "gil_after": True},
|
||||
},
|
||||
}
|
||||
)
|
||||
else:
|
||||
common.update(
|
||||
{
|
||||
"gil_disabled": True,
|
||||
"gil_enabled": False,
|
||||
"thread_inherit_context": 0,
|
||||
"has_zhconv_fast": True,
|
||||
"gil_enabled_after_imports": False,
|
||||
"packages": {
|
||||
"bcrypt": "5.0.0",
|
||||
"brotli": "1.2.0",
|
||||
"crcmod": None,
|
||||
"lxml": "7.0.0b1",
|
||||
"orjson": "3.12.0",
|
||||
"crcmod-plus": "2.3.1",
|
||||
"psycopg": "3.3.4",
|
||||
"psycopg2-binary": None,
|
||||
"zhconv-rs": None,
|
||||
},
|
||||
"native": {
|
||||
"crcmod_extension": True,
|
||||
"psycopg_impl": "c",
|
||||
},
|
||||
"imports": {
|
||||
"moviepilot-rust": {"imported": True, "gil_after": False},
|
||||
"psycopg": {"imported": True, "gil_after": False},
|
||||
},
|
||||
}
|
||||
)
|
||||
expected_imports = {
|
||||
"asyncpg",
|
||||
"bcrypt",
|
||||
"brotli",
|
||||
"crcmod-plus",
|
||||
"cryptography",
|
||||
"greenlet",
|
||||
"lxml",
|
||||
"moviepilot-rust",
|
||||
"orjson",
|
||||
"oss2",
|
||||
"pillow",
|
||||
"pillow-avif-plugin",
|
||||
"pydantic-core",
|
||||
"site-resource",
|
||||
"zstandard",
|
||||
}
|
||||
expected_imports.update(
|
||||
{"psycopg2-binary", "zhconv-rs"} if variant == "v3" else {"psycopg"}
|
||||
)
|
||||
common["imports"] = {
|
||||
name: {"imported": True, "gil_after": variant == "v3"}
|
||||
for name in expected_imports
|
||||
}
|
||||
return common
|
||||
|
||||
|
||||
def sample(harness, variant: str, index: int, multiplier: float = 1.0) -> dict:
|
||||
"""构造可汇总的真实字段形状。"""
|
||||
checksum = "same-checksum"
|
||||
application = {
|
||||
"rust_on": {
|
||||
"seconds": 0.8 * multiplier,
|
||||
"checksum": checksum,
|
||||
}
|
||||
}
|
||||
if variant == "v3":
|
||||
application["rust_off"] = {"seconds": 1.0, "checksum": checksum}
|
||||
throughput = 100.0 if variant == "v3" else 120.0
|
||||
return {
|
||||
"variant": variant,
|
||||
"sample_index": index,
|
||||
"startup": {
|
||||
"ready_seconds": 10.0 * multiplier,
|
||||
"idle": {
|
||||
"engine": {"working_set_bytes": 100_000_000},
|
||||
"processes": {
|
||||
"totals": {
|
||||
"rss_kib": 100_000,
|
||||
"pss_kib": 90_000,
|
||||
"uss_kib": 80_000,
|
||||
"threads": 20,
|
||||
},
|
||||
"main_python": {"pid": 1},
|
||||
},
|
||||
},
|
||||
"api": {
|
||||
endpoint: {
|
||||
"status": 200,
|
||||
"p50_ms": 1.0,
|
||||
"p95_ms": 2.0,
|
||||
"max_ms": 3.0,
|
||||
**(
|
||||
{
|
||||
"runtime": {
|
||||
"gil_enabled": variant == "v3",
|
||||
"rust_enabled": True,
|
||||
"rust_required": variant == "v3t",
|
||||
}
|
||||
}
|
||||
if endpoint == "system_env"
|
||||
else {}
|
||||
),
|
||||
}
|
||||
for endpoint in (
|
||||
"health_ready",
|
||||
"dashboard_statistic",
|
||||
"subscribe_list",
|
||||
"system_env",
|
||||
)
|
||||
},
|
||||
},
|
||||
"hotspots": {
|
||||
"fixture_sha256": harness.FIXTURE_SHA256,
|
||||
"jieba_cut": {"available": True},
|
||||
"gil_enabled_after_hotspots": variant == "v3",
|
||||
"application": application,
|
||||
"rust_concurrency": {
|
||||
"1": {"throughput_ops_s": throughput, "checksum": checksum},
|
||||
"32": {"throughput_ops_s": throughput, "checksum": checksum},
|
||||
},
|
||||
"python_concurrency": {
|
||||
"1": {"throughput_ops_s": throughput, "checksum": checksum},
|
||||
"32": {"throughput_ops_s": throughput, "checksum": checksum},
|
||||
},
|
||||
},
|
||||
"postgresql": {
|
||||
"probe": "app.db.engine._sync_postgresql_driver",
|
||||
"result": {
|
||||
"driver": None if variant == "v3" else "psycopg",
|
||||
"scheme": "postgresql" if variant == "v3" else "postgresql+psycopg",
|
||||
},
|
||||
},
|
||||
"sqlite": {
|
||||
"sync": {"throughput_ops_s": 1000.0, "checksum": checksum},
|
||||
"async": {"throughput_ops_s": 900.0, "checksum": checksum},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def result_for_evaluation(harness, ft_multiplier: float = 1.0) -> dict:
|
||||
"""按平衡顺序构造三组 A/B。"""
|
||||
samples = [
|
||||
sample(harness, variant, index, ft_multiplier if variant == "v3t" else 1.0)
|
||||
for variant, index in harness.SAMPLE_ORDER
|
||||
]
|
||||
return {
|
||||
"samples": samples,
|
||||
"workers": [1, 32],
|
||||
"thresholds": {
|
||||
"max_startup_ratio": 1.25,
|
||||
"max_v3_rust_over_python_ratio": 1.10,
|
||||
"max_v3t_rust_over_v3_rust_ratio": 1.25,
|
||||
"min_ft_max_worker_throughput_ratio": 1.05,
|
||||
"max_idle_memory_ratio": 1.25,
|
||||
"max_api_p95_ratio": 1.25,
|
||||
},
|
||||
"preflight": {
|
||||
variant: {"payload": valid_preflight(variant)}
|
||||
for variant in ("v3", "v3t")
|
||||
},
|
||||
"images": {
|
||||
"v3": {"size_bytes": 600_000_000},
|
||||
"v3t": {"size_bytes": 630_000_000},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_digest_inputs_and_public_image_names_are_strict() -> None:
|
||||
"""拒绝可变 tag、短 digest、旧 v3t 名称及参数传反。"""
|
||||
harness = load_harness("free_threaded_ab_images")
|
||||
standard = digest_image("moviepilot-v3", "a")
|
||||
free_threaded = digest_image("moviepilot-v3t", "b")
|
||||
|
||||
assert harness.immutable_image(standard) == standard
|
||||
harness.assert_expected_repository(standard, "moviepilot-v3")
|
||||
harness.assert_expected_repository(free_threaded, "moviepilot-v3t")
|
||||
|
||||
for invalid in ("moviepilot-v3:latest", "moviepilot-v3@sha256:abc"):
|
||||
with pytest.raises(argparse.ArgumentTypeError):
|
||||
harness.immutable_image(invalid)
|
||||
with pytest.raises(harness.HarnessInvalid, match="moviepilot-v3t"):
|
||||
harness.assert_expected_repository(
|
||||
digest_image("moviepilot-v3-ft", "c"), "moviepilot-v3t"
|
||||
)
|
||||
|
||||
|
||||
def test_local_image_id_is_an_immutable_offline_fallback() -> None:
|
||||
"""未拉取时允许用本地 image ID 验收尚未发布的候选。"""
|
||||
harness = load_harness("free_threaded_ab_local_image")
|
||||
digest = f"sha256:{'a' * 64}"
|
||||
reference = f"moviepilot-v3@{digest}"
|
||||
image = SimpleNamespace(
|
||||
id=digest,
|
||||
attrs={
|
||||
"Id": digest,
|
||||
"RepoDigests": [],
|
||||
"Config": {
|
||||
"Labels": {
|
||||
"org.moviepilot.source-revision": "local-source",
|
||||
"org.opencontainers.image.version": "3.0.0-local",
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
images = Mock()
|
||||
images.get.side_effect = [RuntimeError("no manifest digest"), image]
|
||||
client = SimpleNamespace(images=images)
|
||||
|
||||
identity = harness.image_identity(client, reference, pull=False)
|
||||
|
||||
assert identity["image_id"] == digest
|
||||
assert identity["runtime_reference"] == digest
|
||||
assert identity["source_revision"] == "local-source"
|
||||
assert identity["version"] == "3.0.0-local"
|
||||
assert images.get.call_args_list[1].args == (digest,)
|
||||
|
||||
|
||||
def test_docker_client_uses_current_cli_context_when_default_socket_fails(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""Docker Desktop 等非默认 socket 由当前 CLI context 统一定位。"""
|
||||
harness = load_harness("free_threaded_ab_docker_context")
|
||||
monkeypatch.delenv("DOCKER_HOST", raising=False)
|
||||
default_client = Mock()
|
||||
default_client.ping.side_effect = RuntimeError("default socket missing")
|
||||
context_client = Mock()
|
||||
docker_module = SimpleNamespace(
|
||||
from_env=Mock(return_value=default_client),
|
||||
DockerClient=Mock(return_value=context_client),
|
||||
)
|
||||
monkeypatch.setattr(harness, "docker", docker_module)
|
||||
run = Mock(return_value=SimpleNamespace(stdout="unix:///current/docker.sock\n"))
|
||||
monkeypatch.setattr(harness.subprocess, "run", run)
|
||||
|
||||
assert harness.require_docker_client() is context_client
|
||||
docker_module.DockerClient.assert_called_once_with(
|
||||
base_url="unix:///current/docker.sock"
|
||||
)
|
||||
context_client.ping.assert_called_once_with()
|
||||
|
||||
|
||||
def test_fixture_and_sample_order_are_stable() -> None:
|
||||
"""固定 seed、内容 hash 和交替顺序防止样本漂移。"""
|
||||
harness = load_harness("free_threaded_ab_fixture")
|
||||
|
||||
assert harness.fixture_hash(harness.build_fixture()) == harness.FIXTURE_SHA256
|
||||
assert harness.FIXTURE_SHA256 == harness.EXPECTED_FIXTURE_SHA256
|
||||
assert len(harness.FIXTURE) == 64
|
||||
assert harness.SAMPLE_ORDER == (
|
||||
("v3", 1),
|
||||
("v3t", 1),
|
||||
("v3t", 2),
|
||||
("v3", 2),
|
||||
("v3", 3),
|
||||
("v3t", 3),
|
||||
)
|
||||
|
||||
|
||||
def test_preflight_enforces_runtime_and_native_profiles() -> None:
|
||||
"""标准与 FT 镜像必须满足互斥 ABI、GIL、Rust 和原生依赖合同。"""
|
||||
harness = load_harness("free_threaded_ab_preflight")
|
||||
|
||||
assert harness.validate_preflight("v3", valid_preflight("v3")) == []
|
||||
assert harness.validate_preflight("v3t", valid_preflight("v3t")) == []
|
||||
|
||||
leaked = valid_preflight("v3")
|
||||
leaked["has_zhconv_fast"] = True
|
||||
assert any("has_zhconv_fast" in item for item in harness.validate_preflight("v3", leaked))
|
||||
|
||||
unsafe = valid_preflight("v3t")
|
||||
unsafe["gil_enabled_after_imports"] = True
|
||||
unsafe["packages"]["psycopg2-binary"] = "2.9.12"
|
||||
errors = harness.validate_preflight("v3t", unsafe)
|
||||
assert any("GIL" in item or "gil_enabled_after_imports" in item for item in errors)
|
||||
assert any("标准原生依赖" in item for item in errors)
|
||||
|
||||
incomplete = valid_preflight("v3t")
|
||||
incomplete["imports"].pop("lxml")
|
||||
errors = harness.validate_preflight("v3t", incomplete)
|
||||
assert any("缺少核心组件导入结果" in item and "lxml" in item for item in errors)
|
||||
|
||||
|
||||
def test_evaluation_distinguishes_invalid_regression_and_pass() -> None:
|
||||
"""合同错误优先 invalid,有效性能失败才归为 regression。"""
|
||||
harness = load_harness("free_threaded_ab_evaluation")
|
||||
|
||||
summary, invalid, regressions = harness.evaluate_samples(result_for_evaluation(harness))
|
||||
assert summary["ratios"]["ft_max_worker_throughput_over_v3"] == pytest.approx(1.2)
|
||||
assert set(summary["application_seconds"]) == {
|
||||
"v3_python",
|
||||
"v3_rust",
|
||||
"v3t_rust",
|
||||
}
|
||||
assert summary["ratios"]["v3_rust_over_python"] == pytest.approx(0.8)
|
||||
assert summary["ratios"]["v3t_rust_over_v3_rust"] == pytest.approx(1.0)
|
||||
assert summary["installed_packages"]["v3"]["count"] == 1
|
||||
assert invalid == []
|
||||
assert regressions == []
|
||||
|
||||
regression = result_for_evaluation(harness, ft_multiplier=1.5)
|
||||
_, invalid, regressions = harness.evaluate_samples(regression)
|
||||
assert invalid == []
|
||||
assert any("startup" in item for item in regressions)
|
||||
assert any("V3t Rust" in item for item in regressions)
|
||||
|
||||
v3_rust_regression = result_for_evaluation(harness)
|
||||
for item in v3_rust_regression["samples"]:
|
||||
if item["variant"] == "v3":
|
||||
item["hotspots"]["application"]["rust_on"]["seconds"] = 1.2
|
||||
_, invalid, regressions = harness.evaluate_samples(v3_rust_regression)
|
||||
assert invalid == []
|
||||
assert any("标准镜像启用 Rust" in item for item in regressions)
|
||||
|
||||
broken = result_for_evaluation(harness)
|
||||
broken["samples"][0]["postgresql"]["result"]["scheme"] = "postgresql+psycopg"
|
||||
summary, invalid, regressions = harness.evaluate_samples(broken)
|
||||
assert summary == {}
|
||||
assert any("PostgreSQL" in item for item in invalid)
|
||||
assert regressions == []
|
||||
|
||||
incomplete_api = result_for_evaluation(harness)
|
||||
incomplete_api["samples"][0]["startup"]["api"].pop("subscribe_list")
|
||||
summary, invalid, regressions = harness.evaluate_samples(incomplete_api)
|
||||
assert summary == {}
|
||||
assert any("缺少 API 样本" in item and "subscribe_list" in item for item in invalid)
|
||||
assert regressions == []
|
||||
|
||||
|
||||
def test_markdown_and_exit_codes_preserve_machine_verdict(tmp_path: Path, monkeypatch) -> None:
|
||||
"""JSON verdict、Markdown 摘要与 0/1/2 进程状态保持一致。"""
|
||||
harness = load_harness("free_threaded_ab_exit")
|
||||
base = {
|
||||
"schema_version": harness.SCHEMA_VERSION,
|
||||
"fixture": {"sha256": harness.FIXTURE_SHA256, "count": 64},
|
||||
"source_revision": "abc123",
|
||||
"images": {
|
||||
"v3": {"reference": digest_image("moviepilot-v3", "a")},
|
||||
"v3t": {"reference": digest_image("moviepilot-v3t", "b")},
|
||||
},
|
||||
"summary": {},
|
||||
"invalid_reasons": [],
|
||||
"regressions": [],
|
||||
}
|
||||
markdown = harness.build_markdown({**base, "verdict": "pass"})
|
||||
assert "moviepilot-v3t@sha256" in markdown
|
||||
assert harness.FIXTURE_SHA256 in markdown
|
||||
|
||||
evaluated = result_for_evaluation(harness)
|
||||
summary, invalid, regressions = harness.evaluate_samples(evaluated)
|
||||
report = harness.build_markdown(
|
||||
{
|
||||
**base,
|
||||
"verdict": "pass",
|
||||
"summary": summary,
|
||||
"invalid_reasons": invalid,
|
||||
"regressions": regressions,
|
||||
}
|
||||
)
|
||||
assert "V3 + Python | V3 + Rust | V3t + Rust" in report
|
||||
assert "Pure Python CPU probe" in report
|
||||
assert "Direct Rust ABI probe" in report
|
||||
|
||||
argv = [
|
||||
"--standard-image",
|
||||
digest_image("moviepilot-v3", "a"),
|
||||
"--free-threaded-image",
|
||||
digest_image("moviepilot-v3t", "b"),
|
||||
"--campaign",
|
||||
"fake",
|
||||
"--output-dir",
|
||||
str(tmp_path),
|
||||
]
|
||||
for verdict, expected in (("pass", 0), ("regression", 1), ("invalid", 2)):
|
||||
monkeypatch.setattr(
|
||||
harness,
|
||||
"execute_campaign",
|
||||
lambda _args, value=verdict: {"verdict": value},
|
||||
)
|
||||
assert harness.main(argv) == expected
|
||||
@@ -1,9 +1,21 @@
|
||||
from app.foundation.text import cut
|
||||
|
||||
|
||||
def test_cut_accepts_legacy_hmm_argument():
|
||||
"""验证分词封装支持旧 jieba.cut 的 HMM 参数名。"""
|
||||
def test_cut_accepts_hmm_argument():
|
||||
"""验证分词入口支持公开的 HMM 参数名。"""
|
||||
words = cut("台湾后台测试", HMM=False)
|
||||
|
||||
assert "".join(words) == "台湾后台测试"
|
||||
assert "后台" in words
|
||||
|
||||
|
||||
def test_cut_preserves_full_mode_contract():
|
||||
assert cut("南京市长江大桥", cut_all=True) == [
|
||||
"南京",
|
||||
"南京市",
|
||||
"京市",
|
||||
"市长",
|
||||
"长江",
|
||||
"长江大桥",
|
||||
"大桥",
|
||||
]
|
||||
|
||||
@@ -199,6 +199,19 @@ def test_lifespan_propagates_logger_nonconvergence(monkeypatch):
|
||||
)
|
||||
|
||||
|
||||
def test_runtime_gil_status_warns_when_free_threaded_runtime_enables_gil(monkeypatch):
|
||||
"""free-threaded 运行时退化为 GIL 模式时必须留下明确诊断。"""
|
||||
monkeypatch.setattr(lifecycle, "is_free_threaded_runtime", lambda: True)
|
||||
monkeypatch.setattr(lifecycle, "is_gil_enabled", lambda: True)
|
||||
warning = MagicMock()
|
||||
monkeypatch.setattr(lifecycle.logger, "warning", warning)
|
||||
|
||||
lifecycle._log_runtime_gil_status()
|
||||
|
||||
warning.assert_called_once()
|
||||
assert "已启用 GIL" in warning.call_args.args[0]
|
||||
|
||||
|
||||
def test_lifespan_validation_failure_does_not_clear_outer_loop_owner(monkeypatch):
|
||||
"""当前生命周期尚未取得 owner 时,启动失败不得清理外层登记。"""
|
||||
_patch_lifespan(monkeypatch)
|
||||
|
||||
@@ -7,6 +7,7 @@ from app.adapters.system.package import (
|
||||
PackageInstallRequest,
|
||||
build_package_install_env,
|
||||
build_package_install_strategies,
|
||||
build_project_sync_strategies,
|
||||
redact_url,
|
||||
)
|
||||
|
||||
@@ -129,6 +130,31 @@ def test_build_strategies_passes_all_manifests_to_one_uv_process(tmp_path):
|
||||
assert command[second_requirement + 1] == str(legacy)
|
||||
|
||||
|
||||
def test_project_sync_selects_current_runtime_group(tmp_path, monkeypatch):
|
||||
"""主项目恢复必须显式选择当前解释器对应的互斥运行依赖组。"""
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
pyproject = project / "pyproject.toml"
|
||||
pyproject.write_text("[project]\nname='moviepilot'\nversion='0'\n", encoding="utf-8")
|
||||
uv_bin = tmp_path / "venv" / "bin" / "uv"
|
||||
uv_bin.parent.mkdir(parents=True)
|
||||
uv_bin.write_text("", encoding="utf-8")
|
||||
request = PackageInstallRequest(
|
||||
dependency_files=(pyproject,),
|
||||
python_bin=tmp_path / "venv" / "bin" / "python",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.adapters.system.package.runtime_sync_arguments",
|
||||
lambda: ("--no-default-groups", "--group", "runtime-free-threaded"),
|
||||
)
|
||||
|
||||
command = build_project_sync_strategies(request)[0].command
|
||||
|
||||
assert command.count("--group") == 1
|
||||
assert command[command.index("--group") + 1] == "runtime-free-threaded"
|
||||
assert "--no-default-groups" in command
|
||||
|
||||
|
||||
def test_redact_url_removes_userinfo():
|
||||
assert redact_url("https://user:pass@mirror.example/simple") == "https://mirror.example/simple"
|
||||
|
||||
|
||||
+114
-4
@@ -1302,6 +1302,79 @@ demo = { index = "private" }
|
||||
assert "主程序核心依赖" in message
|
||||
assert "fastapi" in message
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("runtime_group", "installed_package", "installed_version", "requirement"),
|
||||
[
|
||||
("runtime-standard", "lxml", "6.1.2", "lxml>=7.1"),
|
||||
("runtime-free-threaded", "psycopg", "3.3.4", "psycopg>=3.4"),
|
||||
],
|
||||
)
|
||||
def test_uv_install_rejects_runtime_profile_root_upgrade(
|
||||
self,
|
||||
runtime_group,
|
||||
installed_package,
|
||||
installed_version,
|
||||
requirement,
|
||||
):
|
||||
"""插件不得升级当前解释器 profile 中经过 ABI/GIL 验证的根包。"""
|
||||
from app.adapters.external.market import PluginHelper
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
requirements_file = Path(temp_dir) / "requirements.txt"
|
||||
requirements_file.write_text(f"{requirement}\n", encoding="utf-8")
|
||||
with patch(
|
||||
"app.runtime.dependencies.runtime_dependency_group",
|
||||
return_value=runtime_group,
|
||||
):
|
||||
success, message = PluginHelper._PluginHelper__validate_runtime_dependency_conflicts(
|
||||
requirements_file,
|
||||
{installed_package: Version(installed_version)},
|
||||
)
|
||||
|
||||
assert not success
|
||||
assert "主程序核心依赖" in message
|
||||
assert installed_package in message
|
||||
|
||||
def test_runtime_healthcheck_preserves_plugin_upgrade_semantics(self, tmp_path):
|
||||
"""运行环境诊断不应把允许的插件依赖升级强制还原到宿主锁版本。"""
|
||||
from app.adapters.external.market import PluginHelper
|
||||
|
||||
uv_bin = tmp_path / "uv"
|
||||
with patch("app.adapters.external.market.find_uv", return_value=uv_bin):
|
||||
command = PluginHelper._PluginHelper__build_runtime_uv_check_command()
|
||||
|
||||
assert command == [
|
||||
str(uv_bin),
|
||||
"pip",
|
||||
"check",
|
||||
"--python",
|
||||
sys.executable,
|
||||
]
|
||||
|
||||
def test_plugin_runtime_healthcheck_uses_full_capability_probe(self, tmp_path):
|
||||
"""共享 venv 发生变更时必须验证 ABI 敏感原生能力。"""
|
||||
from app.adapters.external.market import PluginHelper
|
||||
|
||||
uv_bin = tmp_path / "uv"
|
||||
commands = []
|
||||
|
||||
def execute(command):
|
||||
commands.append(command)
|
||||
return True, "ok"
|
||||
|
||||
with patch("app.adapters.external.market.find_uv", return_value=uv_bin), patch(
|
||||
"app.adapters.external.market.SystemUtils.execute_with_subprocess",
|
||||
side_effect=execute,
|
||||
):
|
||||
PluginHelper._PluginHelper__run_runtime_healthcheck()
|
||||
|
||||
assert [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"app.doctor.dependencies",
|
||||
"--full",
|
||||
] in commands
|
||||
|
||||
def test_uv_install_allows_changing_non_runtime_dependency(self):
|
||||
"""
|
||||
验证非主程序依赖即便已安装,插件后续仍可调整其版本约束。
|
||||
@@ -1415,7 +1488,7 @@ demo = { index = "private" }
|
||||
if uv_check_count == 2:
|
||||
return False, "broken"
|
||||
return True, "healthy"
|
||||
if len(cmd) >= 3 and cmd[1] == "-c":
|
||||
if len(cmd) >= 3 and cmd[1:3] == ["-m", "app.doctor.dependencies"]:
|
||||
return True, "probe ok"
|
||||
raise AssertionError(f"unexpected command: {cmd}")
|
||||
|
||||
@@ -1425,7 +1498,9 @@ demo = { index = "private" }
|
||||
return_value={"fastapi": Version("0.115.14")}
|
||||
):
|
||||
with patch("app.adapters.external.market.SystemUtils.execute_with_subprocess", side_effect=fake_execute):
|
||||
with patch("app.adapters.system.package.find_uv", return_value=uv_bin):
|
||||
with patch("app.adapters.external.market.find_uv", return_value=uv_bin), patch(
|
||||
"app.adapters.system.package.find_uv", return_value=uv_bin
|
||||
):
|
||||
success, message = PluginHelper.install_packages_with_fallback(requirements_file)
|
||||
|
||||
assert not success
|
||||
@@ -1444,11 +1519,17 @@ demo = { index = "private" }
|
||||
|
||||
health_snapshots = [
|
||||
{
|
||||
"uv check": (False, "existing issue before install"),
|
||||
"uv check": (
|
||||
False,
|
||||
"before summary\nThe package `oss2` requires `crcmod>=1.7`, but it's not installed",
|
||||
),
|
||||
"核心依赖导入检查": (True, "ok"),
|
||||
},
|
||||
{
|
||||
"uv check": (False, "same issue with different command summary"),
|
||||
"uv check": (
|
||||
False,
|
||||
"after summary\nThe package `oss2` requires `crcmod>=1.7`, but it's not installed",
|
||||
),
|
||||
"核心依赖导入检查": (True, "ok"),
|
||||
},
|
||||
]
|
||||
@@ -1527,6 +1608,35 @@ demo = { index = "private" }
|
||||
assert "核心依赖导入检查失败" in message
|
||||
repair_mock.assert_called_once()
|
||||
|
||||
def test_preexisting_uv_diagnostic_does_not_hide_new_package_error(self):
|
||||
"""既有第三方元数据告警不能遮蔽插件安装新增的依赖错误。"""
|
||||
from app.adapters.external.market import PluginHelper
|
||||
|
||||
existing_error = "The package `oss2` requires `crcmod>=1.7`, but it's not installed"
|
||||
added_error = "The package `demo` requires `missing>=1`, but it's not installed"
|
||||
|
||||
message = PluginHelper._PluginHelper__runtime_health_regression_message(
|
||||
{"uv check": (False, existing_error)},
|
||||
{"uv check": (False, f"{existing_error}\n{added_error}")},
|
||||
)
|
||||
|
||||
assert added_error in message
|
||||
assert existing_error not in message
|
||||
|
||||
def test_uv_diagnostic_parser_handles_executor_prefix(self):
|
||||
"""执行器把首条错误拼在命令摘要后时仍应识别完整诊断项。"""
|
||||
from app.adapters.external.market import PluginHelper
|
||||
|
||||
package_error = "The package `demo` requires `missing>=1`, but it's not installed"
|
||||
message = f"命令:uv pip check,执行失败,返回码:1,错误输出:{package_error}"
|
||||
|
||||
issues = PluginHelper._PluginHelper__runtime_health_error_lines(
|
||||
"uv check",
|
||||
message,
|
||||
)
|
||||
|
||||
assert issues == {package_error}
|
||||
|
||||
def test_failed_install_repairs_runtime_before_returning_error(self):
|
||||
"""
|
||||
安装策略失败后如果主运行环境异常,应先恢复主程序依赖再返回失败。
|
||||
|
||||
@@ -189,6 +189,35 @@ async def test_quiesce_timeout_retains_admitted_owner_and_nested_reload(
|
||||
assert manager.finalize_plugins() is True
|
||||
|
||||
|
||||
def test_reload_attributes_gil_transition_to_plugin(
|
||||
plugin_manager: PluginManager,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""运行期插件加载导致 GIL 退化时应记录插件归因。"""
|
||||
states = iter((False, True))
|
||||
plugin_manager._plugin_lifecycle.reload = MagicMock(
|
||||
return_value=PluginRuntimeStatus.ACTIVE
|
||||
)
|
||||
warning = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"app.runtime.extensions.plugin_manager.is_free_threaded_runtime",
|
||||
lambda: True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.runtime.extensions.plugin_manager.is_gil_enabled",
|
||||
lambda: next(states),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.runtime.extensions.plugin_manager.logger.warning",
|
||||
warning,
|
||||
)
|
||||
|
||||
assert plugin_manager.reload_plugin("DemoPlugin") is PluginRuntimeStatus.ACTIVE
|
||||
|
||||
warning.assert_called_once()
|
||||
assert warning.call_args.args[1] == "DemoPlugin"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quiesce_inside_mutation_fails_without_sealing(
|
||||
plugin_manager: PluginManager,
|
||||
|
||||
@@ -13,7 +13,7 @@ from app.sdk.network import RequestUtils, RssHelper, SitesHelper
|
||||
from app.sdk.plugins import ModuleManager, PluginManager
|
||||
from app.sdk.services import NotificationHelper
|
||||
from app.sdk.utilities import StringUtils as UtilityStringUtils
|
||||
from app.sdk.utilities import decrypt, encrypt
|
||||
from app.sdk.utilities import convert, decrypt, encrypt
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parents[1]
|
||||
@@ -30,6 +30,7 @@ def test_sdk_exports_canonical_plugin_interfaces():
|
||||
"app.domain.string"
|
||||
).StringUtils
|
||||
from app.foundation.crypto import CryptoJsUtils
|
||||
from app.foundation.text import convert as canonical_convert
|
||||
from app.runtime.extensions.module_manager import ModuleManager as CanonicalModuleManager
|
||||
from app.runtime.extensions.plugin_manager import PluginManager as CanonicalPluginManager
|
||||
from app.adapters.network.http import RequestUtils as CanonicalRequestUtils
|
||||
@@ -61,6 +62,7 @@ def test_sdk_exports_canonical_plugin_interfaces():
|
||||
assert UtilityStringUtils is LegacyDomainStringUtils
|
||||
assert decrypt is CryptoJsUtils.decrypt
|
||||
assert encrypt is CryptoJsUtils.encrypt
|
||||
assert convert is canonical_convert
|
||||
assert ModuleManager is CanonicalModuleManager
|
||||
assert PluginManager is CanonicalPluginManager
|
||||
assert CanonicalPluginManager.__module__ == "app.runtime.extensions.plugin_manager"
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""PostgreSQL 驱动 A/B harness 的无 Docker 合同测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "perf" / "postgresql_driver_ab.py"
|
||||
|
||||
|
||||
def load_harness():
|
||||
"""从脚本路径加载 harness,避免把 scripts 变成运行时 package。"""
|
||||
spec = importlib.util.spec_from_file_location("postgresql_driver_ab_test", SCRIPT)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def sample(variant: str, value: float) -> dict:
|
||||
"""构造满足汇总合同的单个样本。"""
|
||||
return {
|
||||
"variant": variant,
|
||||
"postgresql_settings": {"server_version": "18.4"},
|
||||
"sql_contract": {"fixture_sha256": "fixture", "sql_sha256": "sql"},
|
||||
"serial_query": {"throughput_ops_s": value},
|
||||
"concurrent_query": {"throughput_ops_s": value * 2},
|
||||
"batch_write": {"seconds": 10 / value},
|
||||
"long_transaction": {"parallel_short_query_seconds": 1 / value},
|
||||
}
|
||||
|
||||
|
||||
def test_campaign_and_image_arguments_are_strict() -> None:
|
||||
"""公开参数不得生成不安全名称或接受可变镜像标签。"""
|
||||
harness = load_harness()
|
||||
|
||||
assert harness.normalize_campaign("PG-AB.1") == "pg-ab.1"
|
||||
with pytest.raises(argparse.ArgumentTypeError):
|
||||
harness.normalize_campaign("../escape")
|
||||
with pytest.raises(argparse.ArgumentTypeError):
|
||||
harness.immutable_image("moviepilot-v3:latest")
|
||||
assert harness.immutable_image(f"moviepilot-v3@sha256:{'a' * 64}")
|
||||
|
||||
|
||||
def test_sample_order_covers_all_balanced_permutations() -> None:
|
||||
"""六轮中每个方案必须各占两次首、中、末位置。"""
|
||||
harness = load_harness()
|
||||
|
||||
orders = [harness.sample_order(index) for index in range(6)]
|
||||
|
||||
assert len(set(orders)) == 6
|
||||
for variant in harness.VARIANTS:
|
||||
assert [order[0] for order in orders].count(variant) == 2
|
||||
assert [order[1] for order in orders].count(variant) == 2
|
||||
assert [order[2] for order in orders].count(variant) == 2
|
||||
|
||||
|
||||
def test_validate_sample_enforces_driver_and_gil_contracts() -> None:
|
||||
"""V3t 必须使用 psycopg C 且在基准结束后仍关闭 GIL。"""
|
||||
harness = load_harness()
|
||||
valid = {
|
||||
"runtime": {
|
||||
"python_version": "3.14.7",
|
||||
"implementation": "c",
|
||||
"gil_before_import": False,
|
||||
"gil_after_import": False,
|
||||
"gil_after_benchmark": False,
|
||||
},
|
||||
"long_transaction": {
|
||||
"parallel_short_query_value": "payload-00002",
|
||||
"parallel_short_query_seconds": 0.01,
|
||||
"contended_update_seconds": 0.2,
|
||||
"requested_seconds": 0.25,
|
||||
},
|
||||
}
|
||||
|
||||
harness.validate_sample("v3t_psycopg3_c", valid)
|
||||
valid["runtime"]["gil_after_benchmark"] = True
|
||||
with pytest.raises(harness.HarnessInvalid, match="GIL"):
|
||||
harness.validate_sample("v3t_psycopg3_c", valid)
|
||||
|
||||
|
||||
def test_summary_keeps_three_variants_separate() -> None:
|
||||
"""汇总必须保留三方案,不能把两个标准 V3 驱动合并。"""
|
||||
harness = load_harness()
|
||||
samples = []
|
||||
for variant, _driver in harness.VARIANTS:
|
||||
samples.extend((sample(variant, 10.0), sample(variant, 20.0), sample(variant, 30.0)))
|
||||
|
||||
summary = harness.summarize(samples)
|
||||
|
||||
assert set(summary["metrics"]) == {variant for variant, _ in harness.VARIANTS}
|
||||
assert summary["metrics"]["v3_psycopg2"]["serial_query_throughput_ops_s"] == 20
|
||||
assert (
|
||||
summary["ratios"]["v3t_psycopg3_c"]
|
||||
["serial_query_throughput_over_v3_psycopg2"]
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
def test_campaign_requires_complete_matching_results() -> None:
|
||||
"""三方案缺样本或业务结果不同都不能生成性能结论。"""
|
||||
harness = load_harness()
|
||||
samples = []
|
||||
for variant, _driver in harness.VARIANTS:
|
||||
for _ in range(3):
|
||||
item = sample(variant, 10.0)
|
||||
item["serial_query"]["checksum"] = "same"
|
||||
item["concurrent_query"]["checksums"] = ["same"]
|
||||
samples.append(item)
|
||||
|
||||
harness.validate_campaign(samples, rounds=3)
|
||||
samples[-1]["serial_query"]["checksum"] = "different"
|
||||
with pytest.raises(harness.HarnessInvalid, match="校验和"):
|
||||
harness.validate_campaign(samples, rounds=3)
|
||||
@@ -3,19 +3,23 @@
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from scripts.normalize_audit_requirements import normalize_requirements
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOCKERFILE = ROOT / "docker" / "Dockerfile"
|
||||
RELEASE_WORKFLOW = ROOT / ".github" / "workflows" / "build-v3.yml"
|
||||
BETA_WORKFLOW = ROOT / ".github" / "workflows" / "beta.yml"
|
||||
TRIVY_IGNORE = ROOT / ".trivyignore.yaml"
|
||||
|
||||
|
||||
def _load_workflow() -> dict:
|
||||
"""以 YAML 1.2 解析正式发布工作流。"""
|
||||
def _load_workflow(path: Path = RELEASE_WORKFLOW) -> dict:
|
||||
"""以 YAML 1.2 解析镜像发布工作流。"""
|
||||
yaml = YAML(typ="safe")
|
||||
return yaml.load(RELEASE_WORKFLOW.read_text(encoding="utf-8"))
|
||||
return yaml.load(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _steps_by_name(workflow: dict) -> dict[str, dict]:
|
||||
@@ -31,29 +35,77 @@ def test_base_image_uses_refreshable_tag_and_apt_does_not_upgrade_in_place() ->
|
||||
"""基础镜像允许获得上游更新,构建阶段不得无边界升级整套 Debian。"""
|
||||
dockerfile = DOCKERFILE.read_text(encoding="utf-8")
|
||||
|
||||
assert "FROM python:3.14.7-slim-trixie AS base" in dockerfile
|
||||
assert "python:3.14.7-slim-trixie@sha256:" not in dockerfile
|
||||
assert 'ARG MOVIEPILOT_PYTHON_VERSION="3.14.7"' in dockerfile
|
||||
assert "FROM python:${MOVIEPILOT_PYTHON_VERSION}-slim-trixie AS base" in dockerfile
|
||||
assert "python:${MOVIEPILOT_PYTHON_VERSION}-slim-trixie@sha256:" not in dockerfile
|
||||
free_threaded_stage = dockerfile.split(
|
||||
"FROM prepare_venv_common AS prepare_venv_free-threaded",
|
||||
maxsplit=1,
|
||||
)[1]
|
||||
assert "ARG MOVIEPILOT_PYTHON_VERSION" in free_threaded_stage
|
||||
assert 'uv python install --no-bin "${MOVIEPILOT_PYTHON_VERSION}t"' in free_threaded_stage
|
||||
assert "apt-get upgrade" not in dockerfile
|
||||
assert "\n util-linux \\\n" in dockerfile
|
||||
|
||||
|
||||
def test_release_audits_locked_runtime_dependencies_before_building() -> None:
|
||||
"""发布构建前必须审计带哈希的锁定运行时依赖。"""
|
||||
workflow = _load_workflow()
|
||||
steps = workflow["jobs"]["Docker-build"]["steps"]
|
||||
names = [step.get("name") for step in steps]
|
||||
audit = _steps_by_name(workflow)["Audit locked Python dependencies"]["run"]
|
||||
"""正式版和 Beta 构建前必须分别审计两套锁定运行依赖。"""
|
||||
for workflow_path in (RELEASE_WORKFLOW, BETA_WORKFLOW):
|
||||
workflow = _load_workflow(workflow_path)
|
||||
steps = workflow["jobs"]["Docker-build"]["steps"]
|
||||
names = [step.get("name") for step in steps]
|
||||
audit = _steps_by_name(workflow)["Audit locked Python dependencies"]["run"]
|
||||
|
||||
assert names.index("Audit locked Python dependencies") < names.index("Build amd64 candidate")
|
||||
assert "uv export --quiet --locked --no-dev --no-emit-project" in audit
|
||||
assert "pip-audit==2.10.1" in audit
|
||||
for option in ("--require-hashes", "--disable-pip", "--strict"):
|
||||
assert option in audit
|
||||
first_candidate = next(name for name in names if name and name.startswith("Build "))
|
||||
assert names.index("Audit locked Python dependencies") < names.index(first_candidate)
|
||||
assert "--group runtime-standard" in audit
|
||||
assert "--group runtime-free-threaded" in audit
|
||||
assert "scripts/normalize_audit_requirements.py" in audit
|
||||
assert "pip-audit==2.10.1" in audit
|
||||
for option in ("--require-hashes", "--no-deps", "--disable-pip", "--strict"):
|
||||
assert option in audit
|
||||
|
||||
|
||||
def test_direct_url_audit_requirement_uses_version_from_matching_lock_source(tmp_path: Path) -> None:
|
||||
"""URL 依赖的漏洞审计版本必须来自同名且同来源的锁文件条目。"""
|
||||
lock_file = tmp_path / "uv.lock"
|
||||
lock_file.write_text(
|
||||
"""
|
||||
version = 1
|
||||
|
||||
[[package]]
|
||||
name = "Brotli"
|
||||
version = "1.2.0"
|
||||
source = { url = "https://example.com/brotli.tar.gz" }
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
exported = (
|
||||
"brotli @ https://example.com/brotli.tar.gz ; python_version >= '3.14' \\\n"
|
||||
" # via httpx\n"
|
||||
)
|
||||
|
||||
normalized = normalize_requirements(exported, lock_file)
|
||||
|
||||
assert "brotli==1.2.0 ; python_version >= '3.14' \\" in normalized
|
||||
assert "@ https://example.com/brotli.tar.gz" not in normalized
|
||||
|
||||
|
||||
def test_direct_url_audit_requirement_rejects_unlocked_source(tmp_path: Path) -> None:
|
||||
"""不能把未匹配锁文件来源的 URL 依赖伪装为已审计版本。"""
|
||||
lock_file = tmp_path / "uv.lock"
|
||||
lock_file.write_text("version = 1\npackage = []\n", encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="无法在锁文件中定位精确版本"):
|
||||
normalize_requirements("demo @ https://example.com/demo.tar.gz\n", lock_file)
|
||||
|
||||
|
||||
def test_release_scans_both_architectures_before_registry_login_and_publish() -> None:
|
||||
"""任一架构的最终漏洞扫描失败时都不得登录仓库或发布镜像。"""
|
||||
"""两个 Python 变体的各架构扫描都必须在登录仓库和发布前完成。"""
|
||||
workflow = _load_workflow()
|
||||
trivy_env = workflow["jobs"]["Docker-build"]["env"]
|
||||
assert trivy_env["TRIVY_SKIP_DIRS"] == "/usr/share/java"
|
||||
assert trivy_env["TRIVY_SKIP_JAVA_DB_UPDATE"] == "true"
|
||||
steps = workflow["jobs"]["Docker-build"]["steps"]
|
||||
names = [step.get("name") for step in steps]
|
||||
indexed = _steps_by_name(workflow)
|
||||
@@ -61,6 +113,14 @@ def test_release_scans_both_architectures_before_registry_login_and_publish() ->
|
||||
expected_candidates = {
|
||||
"Build amd64 candidate": ("linux/amd64", "moviepilot-v3-candidate:linux-amd64"),
|
||||
"Build arm64 candidate": ("linux/arm64/v8", "moviepilot-v3-candidate:linux-arm64"),
|
||||
"Build free-threaded amd64 candidate": (
|
||||
"linux/amd64",
|
||||
"moviepilot-v3t-candidate:linux-amd64",
|
||||
),
|
||||
"Build free-threaded arm64 candidate": (
|
||||
"linux/arm64/v8",
|
||||
"moviepilot-v3t-candidate:linux-arm64",
|
||||
),
|
||||
}
|
||||
for name, (platform, tag) in expected_candidates.items():
|
||||
build = indexed[name]["with"]
|
||||
@@ -70,10 +130,14 @@ def test_release_scans_both_architectures_before_registry_login_and_publish() ->
|
||||
assert build["tags"] == tag
|
||||
assert build["pull"] is True
|
||||
assert "no-cache-filters" not in build
|
||||
expected_variant = "free-threaded" if "free-threaded" in name else "standard"
|
||||
assert f"MOVIEPILOT_PYTHON_VARIANT={expected_variant}" in build["build-args"]
|
||||
|
||||
for name in (
|
||||
"Scan amd64 candidate vulnerabilities",
|
||||
"Scan arm64 candidate vulnerabilities",
|
||||
"Scan free-threaded amd64 candidate vulnerabilities",
|
||||
"Scan free-threaded arm64 candidate vulnerabilities",
|
||||
):
|
||||
scan = indexed[name]
|
||||
assert scan["with"]["cache-dir"] == "${{ runner.temp }}/trivy"
|
||||
@@ -91,11 +155,19 @@ def test_release_scans_both_architectures_before_registry_login_and_publish() ->
|
||||
"exit-code": 1,
|
||||
}.items()
|
||||
|
||||
last_scan = names.index("Scan arm64 candidate vulnerabilities")
|
||||
last_scan = max(
|
||||
names.index(name)
|
||||
for name in (
|
||||
"Scan amd64 candidate vulnerabilities",
|
||||
"Scan arm64 candidate vulnerabilities",
|
||||
"Scan free-threaded amd64 candidate vulnerabilities",
|
||||
"Scan free-threaded arm64 candidate vulnerabilities",
|
||||
)
|
||||
)
|
||||
assert last_scan < names.index("Login DockerHub")
|
||||
assert last_scan < names.index("Login GitHub Container Registry")
|
||||
assert last_scan < names.index("Publish multi-architecture image")
|
||||
|
||||
assert last_scan < names.index("Publish free-threaded multi-architecture image")
|
||||
|
||||
def test_vulnerability_ignores_are_scoped_justified_and_time_bounded() -> None:
|
||||
"""漏洞豁免必须限定制品范围,并保留复查期限和接受理由。"""
|
||||
@@ -118,5 +190,84 @@ def test_publish_reuses_scanned_architecture_caches_without_refreshing_base() ->
|
||||
assert publish["platforms"] == "linux/amd64\nlinux/arm64/v8\n"
|
||||
assert publish["push"] is True
|
||||
assert publish["pull"] is False
|
||||
assert "scope=moviepilot-v3-docker-amd64" in publish["cache-from"]
|
||||
assert "scope=moviepilot-v3-docker-arm64" in publish["cache-from"]
|
||||
assert "scope=moviepilot-v3-standard-docker-amd64" in publish["cache-from"]
|
||||
assert "scope=moviepilot-v3-standard-docker-arm64" in publish["cache-from"]
|
||||
|
||||
|
||||
def test_release_publishes_free_threaded_image_with_separate_metadata_and_cache() -> None:
|
||||
"""free-threaded 发布必须使用 v3t 命名、参数和独立缓存。"""
|
||||
workflow = _load_workflow()
|
||||
indexed = _steps_by_name(workflow)
|
||||
|
||||
metadata = indexed["Docker Meta free-threaded"]
|
||||
publish = indexed["Publish free-threaded multi-architecture image"]
|
||||
|
||||
assert "moviepilot-v3t" in metadata["with"]["images"]
|
||||
assert "MOVIEPILOT_PYTHON_VARIANT=free-threaded" in publish["with"]["build-args"]
|
||||
assert "scope=moviepilot-v3t-docker-amd64" in publish["with"]["cache-from"]
|
||||
assert "scope=moviepilot-v3t-docker-arm64" in publish["with"]["cache-from"]
|
||||
|
||||
|
||||
def test_release_promotes_latest_only_after_both_versioned_images() -> None:
|
||||
"""只有两个版本制品都发布成功后才可移动 latest 标签。"""
|
||||
workflow = _load_workflow()
|
||||
steps = workflow["jobs"]["Docker-build"]["steps"]
|
||||
names = [step.get("name") for step in steps]
|
||||
indexed = _steps_by_name(workflow)
|
||||
|
||||
assert "value=latest" not in indexed["Docker Meta"]["with"]["tags"]
|
||||
assert "value=latest" not in indexed["Docker Meta free-threaded"]["with"]["tags"]
|
||||
assert names.index("Publish multi-architecture image") < names.index("Promote latest image pair")
|
||||
assert names.index("Publish free-threaded multi-architecture image") < names.index(
|
||||
"Promote latest image pair"
|
||||
)
|
||||
promote = indexed["Promote latest image pair"]["run"]
|
||||
assert "moviepilot-v3:latest" not in promote
|
||||
assert '"${image}:latest"' in promote
|
||||
assert '"${image}:${app_version}"' in promote
|
||||
|
||||
|
||||
def test_beta_applies_the_same_variant_scan_and_publish_contract() -> None:
|
||||
"""Beta 也必须在发布两个变体前完成各架构漏洞扫描。"""
|
||||
workflow = _load_workflow(BETA_WORKFLOW)
|
||||
trivy_env = workflow["jobs"]["Docker-build"]["env"]
|
||||
assert trivy_env["TRIVY_SKIP_DIRS"] == "/usr/share/java"
|
||||
assert trivy_env["TRIVY_SKIP_JAVA_DB_UPDATE"] == "true"
|
||||
steps = workflow["jobs"]["Docker-build"]["steps"]
|
||||
names = [step.get("name") for step in steps]
|
||||
indexed = _steps_by_name(workflow)
|
||||
|
||||
assert workflow["on"]["workflow_dispatch"] is None
|
||||
for name in (
|
||||
"Build standard amd64 candidate",
|
||||
"Build standard arm64 candidate",
|
||||
"Build free-threaded amd64 candidate",
|
||||
"Build free-threaded arm64 candidate",
|
||||
):
|
||||
assert indexed[name]["with"]["load"] is True
|
||||
assert indexed[name]["with"]["push"] is False
|
||||
|
||||
scan_names = (
|
||||
"Scan standard amd64 candidate vulnerabilities",
|
||||
"Scan standard arm64 candidate vulnerabilities",
|
||||
"Scan free-threaded amd64 candidate vulnerabilities",
|
||||
"Scan free-threaded arm64 candidate vulnerabilities",
|
||||
)
|
||||
publish_names = (
|
||||
"Publish standard multi-architecture image",
|
||||
"Publish free-threaded multi-architecture image",
|
||||
)
|
||||
last_scan = max(names.index(name) for name in scan_names)
|
||||
assert all(last_scan < names.index(name) for name in publish_names)
|
||||
assert "MOVIEPILOT_PYTHON_VARIANT=standard" in indexed[publish_names[0]]["with"]["build-args"]
|
||||
assert "MOVIEPILOT_PYTHON_VARIANT=free-threaded" in indexed[publish_names[1]]["with"]["build-args"]
|
||||
assert "scope=moviepilot-v3-standard-docker-amd64" in indexed[publish_names[0]]["with"]["cache-from"]
|
||||
assert "scope=moviepilot-v3t-docker-amd64" in indexed[publish_names[1]]["with"]["cache-from"]
|
||||
assert "value=beta-${{ github.run_id }}-${{ github.run_attempt }}" in indexed["Docker Meta"]["with"]["tags"]
|
||||
assert "value=beta-${{ github.run_id }}-${{ github.run_attempt }}" in indexed[
|
||||
"Docker Meta free-threaded"
|
||||
]["with"]["tags"]
|
||||
assert all(names.index(name) < names.index("Promote beta image pair") for name in publish_names)
|
||||
promote = indexed["Promote beta image pair"]["run"]
|
||||
assert '"${image}:beta"' in promote
|
||||
assert '"${image}:${candidate}"' in promote
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.runtime.config import settings
|
||||
from app.adapters.system.resource import (
|
||||
ResourceHelper,
|
||||
@@ -21,6 +23,33 @@ def test_resource_helper_uses_v3_only():
|
||||
assert ResourceHelper._resource_target == Path("app/application/site")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("system", "machine", "gil_disabled", "expected"),
|
||||
[
|
||||
("Linux", "x86_64", 0, "sites.cpython-314-x86_64-linux-gnu.so"),
|
||||
("Linux", "aarch64", 1, "sites.cpython-314t-aarch64-linux-gnu.so"),
|
||||
("Darwin", "arm64", 1, "sites.cpython-314t-darwin.so"),
|
||||
("Windows", "AMD64", 1, "sites.cp314t-win_amd64.pyd"),
|
||||
],
|
||||
)
|
||||
def test_resource_helper_selects_runtime_abi(
|
||||
monkeypatch,
|
||||
system,
|
||||
machine,
|
||||
gil_disabled,
|
||||
expected,
|
||||
):
|
||||
"""资源下载文件名必须区分普通解释器与 free-threaded ABI。"""
|
||||
monkeypatch.setattr("app.adapters.system.resource.platform.system", lambda: system)
|
||||
monkeypatch.setattr("app.adapters.system.resource.platform.machine", lambda: machine)
|
||||
monkeypatch.setattr(
|
||||
"app.adapters.system.resource.sysconfig.get_config_var",
|
||||
lambda name: gil_disabled if name == "Py_GIL_DISABLED" else None,
|
||||
)
|
||||
|
||||
assert ResourceHelper._get_needed_files()[-1] == expected
|
||||
|
||||
|
||||
def test_resource_helper_preserves_no_argument_check_contract(monkeypatch):
|
||||
"""旧插件无参数调用 check 时应使用启动层注入版本,不反向导入站点应用。"""
|
||||
provider_calls = []
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.doctor import dependencies as dependency_doctor
|
||||
from app.foundation import environment
|
||||
from app.runtime import dependencies
|
||||
|
||||
|
||||
def test_free_threaded_runtime_tracks_interpreter_build(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
environment.sysconfig,
|
||||
"get_config_var",
|
||||
lambda name: 1 if name == "Py_GIL_DISABLED" else None,
|
||||
)
|
||||
|
||||
assert environment.is_free_threaded_runtime() is True
|
||||
|
||||
|
||||
def test_gil_status_tracks_current_interpreter_state(monkeypatch):
|
||||
monkeypatch.setattr(environment.sys, "_is_gil_enabled", lambda: False)
|
||||
|
||||
assert environment.is_gil_enabled() is False
|
||||
|
||||
|
||||
def test_runtime_dependency_group_tracks_interpreter_abi(monkeypatch):
|
||||
monkeypatch.setattr(dependencies, "is_free_threaded_runtime", lambda: False)
|
||||
assert dependencies.runtime_dependency_group() == "runtime-standard"
|
||||
|
||||
monkeypatch.setattr(dependencies, "is_free_threaded_runtime", lambda: True)
|
||||
assert dependencies.runtime_dependency_group() == "runtime-free-threaded"
|
||||
|
||||
|
||||
def test_runtime_requirements_include_project_and_active_group(tmp_path: Path, monkeypatch):
|
||||
project_file = tmp_path / "pyproject.toml"
|
||||
project_file.write_text(
|
||||
"""
|
||||
[project]
|
||||
dependencies = ["shared==1"]
|
||||
|
||||
[dependency-groups]
|
||||
runtime-standard = ["standard==2"]
|
||||
runtime-free-threaded = ["free-threaded==3"]
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
dependencies,
|
||||
"runtime_dependency_group",
|
||||
lambda: "runtime-free-threaded",
|
||||
)
|
||||
|
||||
assert list(dependencies.iter_runtime_requirement_strings(project_file)) == [
|
||||
"shared==1",
|
||||
"free-threaded==3",
|
||||
]
|
||||
assert list(dependencies.iter_runtime_profile_requirement_strings(project_file)) == [
|
||||
"free-threaded==3",
|
||||
]
|
||||
|
||||
|
||||
def test_runtime_profiles_share_gil_safe_crcmod_distribution():
|
||||
project_file = Path(__file__).resolve().parents[1] / "pyproject.toml"
|
||||
with project_file.open("rb") as file:
|
||||
document = tomllib.load(file)
|
||||
|
||||
assert "crcmod-plus==2.3.1" in document["project"]["dependencies"]
|
||||
groups = document["dependency-groups"]
|
||||
assert all(
|
||||
not requirement.lower().startswith("crcmod")
|
||||
for group in ("runtime-standard", "runtime-free-threaded")
|
||||
for requirement in groups[group]
|
||||
)
|
||||
assert {
|
||||
"package": {"name": "oss2"},
|
||||
"dependencies": ["crcmod"],
|
||||
} in document["tool"]["uv"]["exclude-dependencies"]
|
||||
|
||||
|
||||
def test_full_dependency_probe_rejects_psycopg_python_fallback(monkeypatch):
|
||||
"""V3t 构建不得把 psycopg 纯 Python 实现误认为可发布能力。"""
|
||||
modules = {
|
||||
"moviepilot_rust": SimpleNamespace(
|
||||
is_available=lambda: True,
|
||||
jieba_cut=lambda _value: ["中文", "分词"],
|
||||
zhconv_fast=lambda value, _target: value,
|
||||
),
|
||||
"crcmod.crcmod": SimpleNamespace(_usingExtension=True),
|
||||
"psycopg": SimpleNamespace(pq=SimpleNamespace(__impl__="python")),
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
dependency_doctor,
|
||||
"import_module",
|
||||
lambda name: modules.get(name, SimpleNamespace()),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
dependency_doctor.sysconfig,
|
||||
"get_config_var",
|
||||
lambda name: 1 if name == "Py_GIL_DISABLED" else None,
|
||||
)
|
||||
monkeypatch.setattr(dependency_doctor.sys, "_is_gil_enabled", lambda: False)
|
||||
|
||||
with pytest.raises(RuntimeError, match="psycopg C 实现不可用"):
|
||||
dependency_doctor.main(full=True)
|
||||
@@ -27,6 +27,7 @@ def test_rust_accel_runtime_switch_disables_fast_paths(monkeypatch):
|
||||
RUST_ACCEL 关闭时,即便扩展可用也应回退到 Python 路径。
|
||||
"""
|
||||
monkeypatch.setattr(settings, "RUST_ACCEL", False)
|
||||
monkeypatch.setattr(rust_accel, "is_required", lambda: False)
|
||||
monkeypatch.setattr(rust_accel, "_moviepilot_rust", _DummyRustExtension())
|
||||
|
||||
assert rust_accel.is_available()
|
||||
@@ -34,11 +35,23 @@ def test_rust_accel_runtime_switch_disables_fast_paths(monkeypatch):
|
||||
assert rust_accel.parse_filter_rule("HDR") is None
|
||||
|
||||
|
||||
def test_free_threaded_runtime_requires_rust_acceleration(monkeypatch):
|
||||
"""free-threaded 运行时不能通过配置关闭 Rust 快路径。"""
|
||||
monkeypatch.setattr(settings, "RUST_ACCEL", False)
|
||||
monkeypatch.setattr(rust_accel, "is_required", lambda: True)
|
||||
monkeypatch.setattr(rust_accel, "_moviepilot_rust", _DummyRustExtension())
|
||||
|
||||
assert rust_accel.is_config_enabled()
|
||||
assert rust_accel.is_enabled()
|
||||
assert rust_accel.status()["required"] is True
|
||||
|
||||
|
||||
def test_rust_accel_status_reports_enabled_state(monkeypatch):
|
||||
"""
|
||||
状态接口应同时体现扩展可用性和配置开关后的实际启用状态。
|
||||
"""
|
||||
monkeypatch.setattr(settings, "RUST_ACCEL", True)
|
||||
monkeypatch.setattr(rust_accel, "is_required", lambda: False)
|
||||
monkeypatch.setattr(rust_accel, "_moviepilot_rust", _DummyRustExtension())
|
||||
|
||||
assert rust_accel.status()["available"] is True
|
||||
|
||||
@@ -7,26 +7,47 @@ def test_app_installs_known_oss2_invalid_escape_warning_filter():
|
||||
"""
|
||||
app 初始化过滤器应覆盖 oss2 的无效转义警告。
|
||||
"""
|
||||
app._filter_third_party_startup_warnings()
|
||||
action, message, category, module, lineno = warnings.filters[0]
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
app._filter_third_party_startup_warnings()
|
||||
warnings.warn_explicit(
|
||||
f'"{chr(92)}&" is an invalid escape sequence.',
|
||||
SyntaxWarning,
|
||||
filename="oss2/api.py",
|
||||
lineno=703,
|
||||
module="oss2.api",
|
||||
)
|
||||
|
||||
assert action == "ignore"
|
||||
assert message.match("invalid escape sequence '\\&'")
|
||||
assert category is SyntaxWarning
|
||||
assert module is None
|
||||
assert lineno == 0
|
||||
assert caught == []
|
||||
|
||||
|
||||
def test_app_does_not_hide_other_invalid_escape_warnings():
|
||||
"""其他无效转义仍应暴露,避免过滤器遮蔽新问题。"""
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
app._filter_third_party_startup_warnings()
|
||||
warnings.warn_explicit(
|
||||
f'"{chr(92)}q" is an invalid escape sequence.',
|
||||
SyntaxWarning,
|
||||
filename="app/example.py",
|
||||
lineno=1,
|
||||
module="app.example",
|
||||
)
|
||||
|
||||
assert len(caught) == 1
|
||||
|
||||
|
||||
def test_app_installs_google_genai_python314_warning_filter():
|
||||
"""app 初始化过滤器应覆盖 Google GenAI SDK 的 Python 3.14 弃用警告。"""
|
||||
app._filter_third_party_startup_warnings()
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
app._filter_third_party_startup_warnings()
|
||||
warnings.warn_explicit(
|
||||
"'_UnionGenericAlias' is deprecated and slated for removal in Python 3.17",
|
||||
DeprecationWarning,
|
||||
filename="google/genai/types.py",
|
||||
lineno=1,
|
||||
module="google.genai.types",
|
||||
)
|
||||
|
||||
assert any(
|
||||
action == "ignore"
|
||||
and message.match("'_UnionGenericAlias' is deprecated and slated for removal in Python 3.17")
|
||||
and category is DeprecationWarning
|
||||
and module is not None
|
||||
and module.match("google.genai.types")
|
||||
and lineno == 0
|
||||
for action, message, category, module, lineno in warnings.filters
|
||||
)
|
||||
assert caught == []
|
||||
|
||||
@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from app.runtime.config import settings as runtime_settings
|
||||
from app.testing import stub_modules
|
||||
from app.testing.stub import restore_modules, snapshot_modules
|
||||
|
||||
|
||||
def _stub(name: str, **attrs) -> tuple:
|
||||
@@ -30,7 +31,7 @@ class _DummyError(Exception):
|
||||
self.duration_ms = duration_ms
|
||||
|
||||
|
||||
# 在 import 期用占位模块替换重依赖/外部模块,import 完由 stub_modules 精确还原,避免污染其它用例
|
||||
# 被测模块会绑定 import 期的桩对象,退出后需同时还原这期间加载的 app 模块图。
|
||||
_STUB_MODULES = dict([
|
||||
_stub("pillow_avif"),
|
||||
_stub("aiofiles"),
|
||||
@@ -64,8 +65,12 @@ _STUB_MODULES = dict([
|
||||
_stub("version", APP_VERSION="test", FRONTEND_VERSION="frontend-test"),
|
||||
])
|
||||
|
||||
with stub_modules(_STUB_MODULES):
|
||||
from app.api.endpoints import system as system_endpoint
|
||||
_APP_MODULES = snapshot_modules("app")
|
||||
try:
|
||||
with stub_modules(_STUB_MODULES):
|
||||
from app.api.endpoints import system as system_endpoint
|
||||
finally:
|
||||
restore_modules(_APP_MODULES, "app")
|
||||
|
||||
|
||||
class NettestSecurityTest(unittest.TestCase):
|
||||
@@ -75,12 +80,50 @@ class NettestSecurityTest(unittest.TestCase):
|
||||
"""
|
||||
with patch.object(system_endpoint.rust_accel, "is_available", return_value=True), patch.object(
|
||||
system_endpoint.rust_accel, "is_enabled", return_value=False
|
||||
), patch.object(
|
||||
system_endpoint.rust_accel, "is_required", return_value=True
|
||||
), patch.object(
|
||||
system_endpoint, "is_free_threaded_runtime", return_value=True
|
||||
), patch.object(
|
||||
system_endpoint, "is_gil_enabled", return_value=False
|
||||
):
|
||||
resp = asyncio.run(system_endpoint.get_env_setting(_="token"))
|
||||
|
||||
self.assertTrue(resp.success)
|
||||
self.assertTrue(resp.data["RUST_ACCEL_AVAILABLE"])
|
||||
self.assertFalse(resp.data["RUST_ACCEL_ENABLED"])
|
||||
self.assertTrue(resp.data["RUST_ACCEL_REQUIRED"])
|
||||
self.assertTrue(resp.data["PYTHON_FREE_THREADED"])
|
||||
self.assertFalse(resp.data["PYTHON_GIL_ENABLED"])
|
||||
|
||||
def test_get_user_global_setting_reports_runtime_variant(self):
|
||||
"""登录后的全局设置应提供导航所需的解释器类型。"""
|
||||
runtime_config = SimpleNamespace(
|
||||
snapshot=Mock(return_value={}),
|
||||
get=Mock(return_value=False),
|
||||
)
|
||||
with patch.object(
|
||||
system_endpoint, "get_runtime_settings", return_value=runtime_config
|
||||
), patch.object(
|
||||
system_endpoint.MoviePilotServerHelper,
|
||||
"async_is_admin_user",
|
||||
new=AsyncMock(return_value=False),
|
||||
create=True,
|
||||
), patch.object(
|
||||
system_endpoint.MoviePilotServerHelper,
|
||||
"get_user_uuid",
|
||||
return_value="user-id",
|
||||
create=True,
|
||||
), patch.object(
|
||||
system_endpoint, "is_free_threaded_runtime", return_value=True
|
||||
), patch.object(
|
||||
system_endpoint, "is_gil_enabled", return_value=False
|
||||
):
|
||||
resp = asyncio.run(system_endpoint.get_user_global_setting(_="token"))
|
||||
|
||||
self.assertTrue(resp.success)
|
||||
self.assertTrue(resp.data["PYTHON_FREE_THREADED"])
|
||||
self.assertFalse(resp.data["PYTHON_GIL_ENABLED"])
|
||||
|
||||
def test_fetch_image_allows_signed_private_url(self):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user