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:
InfinityPacer
2026-08-24 17:48:14 +08:00
committed by GitHub
parent 88dce4ca8e
commit 326b5cf3ad
63 changed files with 5294 additions and 253 deletions
+20 -3
View File
@@ -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",
+2 -1
View File
@@ -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",
+9 -4
View File
@@ -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,
+13
View File
@@ -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 需自行选用异步适配的
+82 -1
View File
@@ -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)
+45 -1
View File
@@ -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")
+2
View File
@@ -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
+457
View File
@@ -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
+14 -2
View File
@@ -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) == [
"南京",
"南京市",
"京市",
"市长",
"长江",
"长江大桥",
"大桥",
]
+13
View File
@@ -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)
+26
View File
@@ -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
View File
@@ -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):
"""
安装策略失败后如果主运行环境异常应先恢复主程序依赖再返回失败
+29
View File
@@ -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,
+3 -1
View File
@@ -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"
+120
View File
@@ -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)
+171 -20
View File
@@ -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
+29
View File
@@ -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 = []
+106
View File
@@ -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)
+13
View File
@@ -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
+38 -17
View File
@@ -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 == []
+46 -3
View File
@@ -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):
"""