feat: 使用 uv 锁定主程序依赖并强化插件恢复边界 (#6364)

This commit is contained in:
InfinityPacer
2026-08-20 12:17:19 +08:00
committed by GitHub
parent 27ae1b5290
commit 23f5d59c74
59 changed files with 6804 additions and 1797 deletions
+8
View File
@@ -14,6 +14,14 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: '0.12.5'
- name: Verify dependency lock
run: uv lock --check
- name: Release version
id: release_version
run: |
+8
View File
@@ -22,6 +22,14 @@ jobs:
fetch-depth: 0
fetch-tags: true
- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: '0.12.5'
- name: Verify dependency lock
run: uv lock --check
- name: Release version
id: release_version
run: |
+164
View File
@@ -0,0 +1,164 @@
name: Dependency Compatibility
on:
pull_request:
branches:
- v3
paths:
- 'pyproject.toml'
- 'uv.lock'
- 'docker/Dockerfile'
- 'docker/entrypoint.sh'
- 'docker/update.sh'
- '.github/workflows/dependency-compat.yml'
push:
branches:
- v3
paths:
- 'pyproject.toml'
- 'uv.lock'
- 'docker/Dockerfile'
- 'docker/entrypoint.sh'
- 'docker/update.sh'
- '.github/workflows/dependency-compat.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: dependency-compat-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
install:
name: ${{ matrix.name }} / Python ${{ matrix.python-version }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- name: Linux x64
runner: ubuntu-24.04
python-version: '3.12'
expected-system: Linux
expected-machine: x86_64
- name: Linux x64
runner: ubuntu-24.04
python-version: '3.13'
expected-system: Linux
expected-machine: x86_64
- name: Linux ARM64
runner: ubuntu-24.04-arm
python-version: '3.12'
expected-system: Linux
expected-machine: aarch64
- name: macOS Intel
runner: macos-15-intel
python-version: '3.12'
expected-system: Darwin
expected-machine: x86_64
- name: macOS ARM
runner: macos-15
python-version: '3.12'
expected-system: Darwin
expected-machine: arm64
- name: Windows x64
runner: windows-2025
python-version: '3.12'
expected-system: Windows
expected-machine: AMD64
- name: Linux x64
runner: ubuntu-24.04
python-version: '3.14'
expected-system: Linux
expected-machine: x86_64
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: '0.12.5'
python-version: ${{ matrix.python-version }}
enable-cache: true
cache-dependency-glob: |
pyproject.toml
uv.lock
- name: Install locked runtime dependencies
run: uv sync --locked --inexact --no-dev --python ${{ matrix.python-version }}
- name: Verify environment and core imports
env:
EXPECTED_SYSTEM: ${{ matrix.expected-system }}
EXPECTED_MACHINE: ${{ matrix.expected-machine }}
run: >-
uv run --locked --no-sync python -c
"import os, platform;
assert platform.system() == os.environ['EXPECTED_SYSTEM'], (platform.system(), os.environ['EXPECTED_SYSTEM']);
assert platform.machine() == os.environ['EXPECTED_MACHINE'], (platform.machine(), os.environ['EXPECTED_MACHINE']);
import alembic, fastapi, pydantic, pydantic_settings, sqlalchemy, starlette, uvicorn"
- name: Verify installed dependency consistency
run: uv pip check
docker-dependencies:
name: Docker dependencies / ${{ matrix.platform }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-24.04
platform: linux/amd64
cache-scope: linux-amd64
image-tag: moviepilot-dependency-gate:linux-amd64
expected-machine: x86_64
- runner: ubuntu-24.04-arm
platform: linux/arm64
cache-scope: linux-arm64
image-tag: moviepilot-dependency-gate:linux-arm64
expected-machine: aarch64
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Build locked dependency stage
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: docker/Dockerfile
target: prepare_venv
platforms: ${{ matrix.platform }}
load: true
push: false
tags: ${{ matrix.image-tag }}
cache-from: type=gha,scope=dependency-compat-${{ matrix.cache-scope }}
cache-to: type=gha,scope=dependency-compat-${{ matrix.cache-scope }},mode=max
- name: Verify dependency image
env:
IMAGE_TAG: ${{ matrix.image-tag }}
EXPECTED_MACHINE: ${{ matrix.expected-machine }}
run: >-
docker run --rm
-e EXPECTED_MACHINE
"${IMAGE_TAG}"
/opt/venv/bin/python -c
"import os, platform;
assert platform.machine() == os.environ['EXPECTED_MACHINE'], (platform.machine(), os.environ['EXPECTED_MACHINE']);
import alembic, fastapi, pydantic, pydantic_settings, sqlalchemy, starlette, uvicorn"
- name: Verify pinned uv version
env:
IMAGE_TAG: ${{ matrix.image-tag }}
run: docker run --rm "${IMAGE_TAG}" uv --version | grep -F 'uv 0.12.5'
+15 -19
View File
@@ -4,6 +4,9 @@ on:
# 允许手动触发
workflow_dispatch:
permissions:
contents: read
jobs:
pylint:
runs-on: ubuntu-latest
@@ -13,25 +16,18 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: '0.12.5'
python-version: '3.12'
cache: 'pip'
- name: Cache pip dependencies
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.in', '**/requirements-dev.in', '**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
enable-cache: true
cache-dependency-glob: |
pyproject.toml
uv.lock
- name: Install dependencies
run: |
python -m pip install --upgrade pip setuptools wheel
# Pylint 属于开发/静态检查依赖,统一通过 dev 入口安装。
pip install -r requirements-dev.in
run: uv sync --locked
- name: Verify pylint config
run: |
@@ -51,22 +47,22 @@ jobs:
# 检查主要目录 - 只关注错误,如果有错误则退出
echo "📂 检查 app/ 目录..."
pylint app/ --output-format=colorized --reports=yes --score=yes
uv run --locked --no-sync pylint app/ --output-format=colorized --reports=yes --score=yes
# 检查根目录的Python文件
echo "📂 检查根目录 Python 文件..."
for file in $(find . -name "*.py" -not -path "./.*" -not -path "./.venv/*" -not -path "./build/*" -not -path "./dist/*" -not -path "./tests/*" -not -path "./docs/*" -not -path "./__pycache__/*" -maxdepth 1); do
echo "检查文件: $file"
pylint "$file" --output-format=colorized || exit 1
uv run --locked --no-sync pylint "$file" --output-format=colorized || exit 1
done
# 生成详细报告
echo "📊 生成 Pylint 详细报告..."
pylint app/ --output-format=json > pylint-report.json || true
uv run --locked --no-sync pylint app/ --output-format=json > pylint-report.json || true
# 显示评分(仅供参考)
echo "📈 Pylint 评分(仅供参考):"
pylint app/ --score=yes --reports=no | tail -2 || true
uv run --locked --no-sync pylint app/ --score=yes --reports=no | tail -2 || true
- name: Upload pylint report
uses: actions/upload-artifact@v4
+8 -5
View File
@@ -50,13 +50,16 @@ jobs:
uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: pip
cache-dependency-path: scripts/site_adapter_collector_requirements.txt
- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: '0.12.5'
enable-cache: true
cache-dependency-glob: scripts/site_adapter_collector_requirements.txt
- name: Install build dependencies
run: |
python -m pip install --upgrade pip setuptools wheel
pip install -r scripts/site_adapter_collector_requirements.txt
run: uv pip install --system --requirement scripts/site_adapter_collector_requirements.txt
- name: Build single-file collector
run: |
+13 -20
View File
@@ -28,36 +28,29 @@ jobs:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v6
- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: '0.12.5'
python-version: '3.12'
cache: 'pip'
- name: Cache pip dependencies
uses: actions/cache@v5
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.in', '**/requirements-dev.in', '**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
enable-cache: true
cache-dependency-glob: |
pyproject.toml
uv.lock
- name: Install dependencies
run: |
python -m pip install --upgrade pip setuptools wheel
# 单测需要开发/测试依赖;运行时入口 requirements.in 不携带测试与构建辅助工具。
pip install -r requirements-dev.in
run: uv sync --locked
- name: Run tests
timeout-minutes: 10
run: |
# tests/run.py 以 pytest 跑 tests 全量;tests/conftest.py 在收集前把 CONFIG_DIR
# 指向临时库并建表;CI 额外生成覆盖率报告,便于后续补测和回归分析。
python -m coverage erase
python -m coverage run tests/run.py
python -m coverage report
python -m coverage json
python -m coverage xml
uv run --locked --no-sync python -m coverage erase
uv run --locked --no-sync python -m coverage run tests/run.py
uv run --locked --no-sync python -m coverage report
uv run --locked --no-sync python -m coverage json
uv run --locked --no-sync python -m coverage xml
- name: Upload coverage report
if: always()
+3 -3
View File
@@ -38,11 +38,11 @@ For work that changes or reviews repository behavior, identify the domains actua
### Quality and Security
* **Primary Reference:** `docs/rules/11-quality-and-security.md`
* **Required Constraints:** All code changes must pass the relevant pytest tests and pylint checks. Dependency changes require a passing safety scan.
* **Required Constraints:** All code changes must pass the relevant pytest tests and pylint checks. Dependency changes require a current `uv.lock`, locked environment verification, and a passing manual Safety scan.
### Testing
* **Primary Reference:** `docs/testing.md`
* **Required Constraints:** pytest is the only runner; `tests/conftest.py` isolates each run to a temporary `CONFIG_DIR`. Tests must not touch the real database, network, or external services (TMDB, LLM catalogs, downloaders, media servers, MP server) — mock at the boundary or replay recorded responses; the bar is zero real outbound traffic. Tests must restore any process-level state they stub (`sys.modules`, singletons, caches, settings). New tests must be pytest-native (function + `assert` + fixtures); do not add new `unittest.TestCase`. Convert existing `TestCase` files to pytest-native opportunistically when you modify them. Before opening a PR to `v3` that changes product code, test infrastructure, dependencies, or runtime behavior, run the full suite locally (`python tests/run.py`) with zero real network calls. The changed path must pass; any unrelated failure must be reported and reproduced against the current `upstream/v3` baseline instead of silently expanding the PR. Documentation-only changes use applicable text and structure checks; the `.github/workflows/test.yml` gate still runs the full suite on every PR/push to `v3`.
* **Required Constraints:** pytest is the only runner; `tests/conftest.py` isolates each run to a temporary `CONFIG_DIR`. Tests must not touch the real database, network, or external services (TMDB, LLM catalogs, downloaders, media servers, MP server) — mock at the boundary or replay recorded responses; the bar is zero real outbound traffic. Tests must restore any process-level state they stub (`sys.modules`, singletons, caches, settings). New tests must be pytest-native (function + `assert` + fixtures); do not add new `unittest.TestCase`. Convert existing `TestCase` files to pytest-native opportunistically when you modify them. Before opening a PR to `v3` that changes product code, test infrastructure, dependencies, or runtime behavior, run the full suite locally (`uv run --locked --no-sync python tests/run.py`) with zero real network calls. The changed path must pass; any unrelated failure must be reported and reproduced against the current `upstream/v3` baseline instead of silently expanding the PR. Documentation-only changes use applicable text and structure checks; the `.github/workflows/test.yml` gate still runs the full suite on every PR/push to `v3`.
### Commands and Development Workflow
* **Primary Reference:** `docs/rules/03-commands.md`
@@ -156,4 +156,4 @@ For the full documentation map and cross-references, refer to:
**[Documentation Hub Index](./docs/rules/README.md)**
*Last Updated: 2026-08-16*
*Last Updated: 2026-08-19*
+145 -453
View File
@@ -30,7 +30,17 @@ from requests import Response
from app.runtime.cache import cached, is_fresh
from app.runtime.config import settings
from app.adapters.system.package import PackageInstallRequest, build_package_install_strategies
from app.adapters.system.package import (
PackageInstallRequest,
build_package_install_strategies,
build_project_sync_strategies,
find_uv,
)
from app.adapters.system.plugin.manifest import (
PluginDependencyManifestError,
load_dependency_file,
load_dependency_manifest,
)
from app.runtime.log import logger
from app.adapters.network.http import RequestUtils, AsyncRequestUtils
from app.foundation.singleton import WeakSingleton
@@ -160,8 +170,8 @@ class PluginHelper(metaclass=WeakSingleton):
"""
_base_url = "https://raw.githubusercontent.com/{user}/{repo}/main/"
# 串行化运行期依赖安装,避免多个 pip 子进程和导入缓存刷新互相踩踏。
_pip_install_lock = threading.Lock()
# 串行化运行期依赖安装,避免多个包安装子进程和导入缓存刷新互相踩踏。
_package_install_lock = threading.Lock()
# 同仓库的并发 Release 请求共享任务;事件循环参与键控,避免热重载或测试循环切换后复用失效任务。
_release_task_lock = threading.Lock()
_release_tasks: Dict[Tuple[asyncio.AbstractEventLoop, str, bool], asyncio.Task] = {}
@@ -339,7 +349,7 @@ class PluginHelper(metaclass=WeakSingleton):
if not isinstance(raw_specifier, str):
return False, (
f"插件限定的系统版本范围 {PLUGIN_SYSTEM_VERSION_FIELD} 必须是字符串,"
f"请使用 pip 依赖版本格式,例如 >=2.12.0,<3"
f"请使用 PEP 440 版本范围格式,例如 >=2.12.0,<3"
)
system_version = cls.get_current_system_version()
@@ -351,7 +361,7 @@ class PluginHelper(metaclass=WeakSingleton):
except InvalidSpecifier:
return False, (
f"插件限定的系统版本范围格式不正确:{raw_specifier}"
f"请使用 pip 依赖版本格式,例如 >=2.12.0,<3"
f"请使用 PEP 440 版本范围格式,例如 >=2.12.0,<3"
)
if specifier_set.contains(system_version, prereleases=True):
@@ -804,13 +814,7 @@ class PluginHelper(metaclass=WeakSingleton):
release_version: Optional[str] = None, force_install: bool = False) \
-> Tuple[bool, str]:
"""
安装插件,包括依赖安装和文件下载,相关资源支持自动降级策略
1. 检查并获取插件的指定版本,确认版本兼容性
2. 从 GitHub 获取文件列表(包括 requirements.txt
3. 删除旧的插件目录(如非强制安装则进行备份)
4. 下载并预安装 requirements.txt 中的依赖(如果存在)
5. 下载并安装插件的其他文件
6. 再次尝试安装依赖(确保安装完整)
安装插件,包括版本检查、内容准备、生效清单依赖安装和失败恢复。
:param pid: 插件 ID
:param repo_url: 插件仓库地址
:param package_version: 首选插件版本 (如 "v2", "v3"),如不指定则默认使用系统配置的版本
@@ -994,13 +998,12 @@ class PluginHelper(metaclass=WeakSingleton):
return None, "插件数据解析失败"
def __download_files(self, pid: str, file_list: List[dict], user_repo: str,
package_version: Optional[str] = None, skip_requirements: bool = False) -> Tuple[bool, str]:
package_version: Optional[str] = None) -> Tuple[bool, str]:
"""
下载插件文件
:param pid: 插件 ID
:param file_list: 要下载的文件列表,包含文件的元数据(包括下载链接)
:param user_repo: GitHub 仓库的 user/repo 路径
:param skip_requirements: 是否跳过 requirements.txt 文件的下载
:return: (是否成功, 错误信息)
"""
if not file_list:
@@ -1013,10 +1016,6 @@ class PluginHelper(metaclass=WeakSingleton):
current_pid, current_file_list = stack.pop()
for item in current_file_list:
# 跳过 requirements.txt 的下载
if skip_requirements and item.get("name") == "requirements.txt":
continue
if item.get("download_url"):
logger.debug(f"正在下载文件:{item.get('path')}")
res = self.__request_with_fallback(item.get('download_url'),
@@ -1047,53 +1046,22 @@ class PluginHelper(metaclass=WeakSingleton):
return True, ""
def __download_and_install_requirements(self, requirements_file_info: dict, pid: str, user_repo: str) \
-> Tuple[bool, str]:
"""
下载并安装 requirements.txt 文件中的依赖
:param requirements_file_info: requirements.txt 文件的元数据信息
:param pid: 插件 ID
:param user_repo: GitHub 仓库的 user/repo 路径
:return: (是否成功, 错误信息)
"""
# 下载 requirements.txt
res = self.__request_with_fallback(requirements_file_info.get("download_url"),
headers=settings.REPO_GITHUB_HEADERS(repo=user_repo))
if not res:
return False, "requirements.txt 文件下载失败"
elif res.status_code != 200:
return False, f"下载 requirements.txt 文件失败:{res.status_code}"
requirements_txt = res.text
if requirements_txt.strip():
# 保存并安装依赖
requirements_file_path = PLUGIN_DIR / pid.lower() / "requirements.txt"
requirements_file_path.parent.mkdir(parents=True, exist_ok=True)
with open(requirements_file_path, "w", encoding="utf-8") as f:
f.write(requirements_txt)
return self.pip_install_with_fallback(requirements_file_path)
return True, "" # 如果 requirements.txt 为空,视作成功
def __install_dependencies_if_required(self, pid: str) -> Tuple[bool, bool, str]:
"""
安装插件依赖。
:param pid: 插件 ID
:return: (是否存在依赖,安装是否成功, 错误信息)
"""
# 定位插件目录和依赖文件
plugin_dir = PLUGIN_DIR / pid.lower()
requirements_file = plugin_dir / "requirements.txt"
# 检查是否存在 requirements.txt 文件
if requirements_file.exists():
try:
manifest = load_dependency_manifest(plugin_dir)
except PluginDependencyManifestError as error:
logger.error(f"{pid} 依赖清单无效:{error}")
return True, False, str(error)
if manifest is not None:
logger.info(f"{pid} 存在依赖,开始尝试安装依赖")
success, error_message = self.pip_install_with_fallback(requirements_file)
if success:
return True, True, ""
else:
return True, False, error_message
success, error_message = self.install_packages_with_fallback(manifest.path)
return True, success, "" if success else error_message
return False, False, "不存在依赖"
@@ -1199,21 +1167,16 @@ class PluginHelper(metaclass=WeakSingleton):
return list(dict.fromkeys(wheels_dirs))
@staticmethod
def __build_runtime_pip_command(*args: str) -> List[str]:
"""
优先使用当前解释器同目录的 pip 入口,以便 uv-pip-compat 能接管兼容命令。
"""
pip_name = "pip.exe" if sys.platform == "win32" else "pip"
pip_bin = Path(sys.executable).with_name(pip_name)
if pip_bin.exists():
return [str(pip_bin), *args]
return [sys.executable, "-m", "pip", *args]
def __build_runtime_uv_command(*args: str) -> List[str]:
"""构造绑定当前解释器环境的 uv pip 命令。"""
uv_bin = find_uv(Path(sys.executable))
if not uv_bin:
return []
return [str(uv_bin), "pip", *args, "--python", sys.executable]
@staticmethod
def __format_pkg_name_for_pip(name: str) -> str:
"""
将内部统一使用的下划线包名转回 pip 更常见的连字符写法,便于日志和约束文件阅读。
"""
def __format_package_name(name: str) -> str:
"""将内部包名转换为依赖清单常用的连字符形式。"""
return name.replace("_", "-")
@staticmethod
@@ -1234,79 +1197,26 @@ class PluginHelper(metaclass=WeakSingleton):
@classmethod
def __parse_project_requirement_roots(
cls,
requirements_file: Path,
visited_files: Optional[Set[Path]] = None
project_file: Path,
) -> Dict[str, Set[str]]:
"""
解析主项目 requirements 文件,收集根依赖及其启用的 extras。
支持递归处理 -r/--requirement,忽略索引、约束等 pip 选项。
"""
"""解析主项目 pyproject,收集当前平台生效的根依赖和 extras。"""
roots = {}
if visited_files is None:
visited_files = set()
try:
requirements_file = requirements_file.resolve()
except Exception:
requirements_file = Path(requirements_file)
if requirements_file in visited_files:
return roots
visited_files.add(requirements_file)
if not requirements_file.exists():
logger.warning(f"主项目依赖文件不存在:{requirements_file}")
if not project_file.exists():
logger.warning(f"主项目依赖文件不存在:{project_file}")
return roots
try:
with open(requirements_file, "r", encoding="utf-8", errors="replace") as f:
for raw_line in f:
line = raw_line.strip()
if not line or line.startswith("#"):
continue
include_path = None
if line.startswith("-r"):
include_path = line[2:].strip() if line != "-r" else ""
elif line.startswith("--requirement"):
include_path = line[len("--requirement"):].strip()
if include_path is not None:
if include_path.startswith("="):
include_path = include_path[1:].strip()
if not include_path:
logger.debug(f"忽略无法识别的 requirements 引用:{line}")
continue
included_roots = cls.__parse_project_requirement_roots(
requirements_file.parent / include_path,
visited_files
)
for package_name, extras in included_roots.items():
roots.setdefault(package_name, set()).update(extras)
continue
if line.startswith((
"-c", "--constraint", "-i", "--index-url", "--extra-index-url",
"-f", "--find-links", "--trusted-host", "--no-index"
)):
continue
try:
requirement = Requirement(line)
except Exception as err:
logger.debug(f"无法解析主项目依赖项 '{line}'{err}")
continue
if not cls.__marker_matches(requirement.marker):
continue
package_name = cls.__standardize_pkg_name(requirement.name)
roots.setdefault(package_name, set()).update(
extra.lower() for extra in requirement.extras
)
manifest = load_dependency_file(project_file)
for requirement in manifest.dependencies:
if not cls.__marker_matches(requirement.marker):
continue
package_name = cls.__standardize_pkg_name(requirement.name)
roots.setdefault(package_name, set()).update(
extra.lower() for extra in requirement.extras
)
return roots
except Exception as e:
logger.error(f"解析主项目依赖文件失败:{requirements_file} - {e}")
logger.error(f"解析主项目依赖文件失败:{project_file} - {e}")
return {}
@classmethod
@@ -1354,7 +1264,7 @@ class PluginHelper(metaclass=WeakSingleton):
"""
仅收集主程序依赖图中的已安装包版本。
主项目 requirements 中声明的根依赖及其当前已安装的传递依赖都会被冻结,
主项目 pyproject 中声明的根依赖及其当前已安装的传递依赖都会被冻结,
未被主程序依赖图引用的插件自带包允许后续插件按需升级或降级。
"""
if installed_packages is None:
@@ -1365,11 +1275,8 @@ class PluginHelper(metaclass=WeakSingleton):
if package_name in cls._protected_runtime_packages
}
root_requirements_file = settings.ROOT_PATH / "requirements.txt"
if not root_requirements_file.exists():
root_requirements_file = settings.ROOT_PATH / "requirements.in"
root_requirements = cls.__parse_project_requirement_roots(root_requirements_file)
project_file = settings.ROOT_PATH / "pyproject.toml"
root_requirements = cls.__parse_project_requirement_roots(project_file)
if not root_requirements:
return protected_packages
@@ -1450,59 +1357,50 @@ class PluginHelper(metaclass=WeakSingleton):
@classmethod
def __validate_runtime_dependency_conflicts(
cls,
requirements_file: Path,
dependency_file: Path,
protected_packages: Dict[str, Version]
) -> Tuple[bool, str]:
"""
在真正执行 pip 前,先拦截插件对主程序依赖的显式覆盖请求。
在真正执行安装前,先拦截插件对主程序依赖的显式覆盖请求。
共享 venv 场景下,仅冻结主程序依赖;插件新增依赖、以及插件之间共享的额外依赖,
允许后续安装继续调整版本。
"""
conflicts = []
try:
with open(requirements_file, "r", encoding="utf-8", errors="replace") as f:
for raw_line in f:
line = raw_line.strip()
if not line or line.startswith("#"):
continue
try:
requirement = Requirement(line)
except Exception as err:
logger.debug(f"无法解析依赖项 '{line}',跳过运行环境冲突预检:{err}")
continue
manifest = load_dependency_file(dependency_file)
for requirement in manifest.dependencies:
if not cls.__marker_matches(requirement.marker):
continue
if not cls.__marker_matches(requirement.marker):
continue
package_name = cls.__standardize_pkg_name(requirement.name)
installed_version = protected_packages.get(package_name)
if installed_version is None:
continue
package_name = cls.__standardize_pkg_name(requirement.name)
installed_version = protected_packages.get(package_name)
if installed_version is None:
continue
if requirement.url:
conflicts.append((
package_name,
str(installed_version),
f"来自 {requirement.url} 的同名包",
package_name in cls._protected_runtime_packages,
))
continue
if requirement.url:
if requirement.specifier and not requirement.specifier.contains(
installed_version,
prereleases=True
):
is_core = package_name in cls._protected_runtime_packages
# 非核心包的纯升级冲突允许放行,由安装约束控制实际版本。
if is_core or not cls.__is_upgrade_only_conflict(
requirement.specifier, installed_version):
conflicts.append((
package_name,
str(installed_version),
f"来自 {requirement.url} 的同名包",
package_name in cls._protected_runtime_packages,
str(requirement.specifier),
is_core,
))
continue
if requirement.specifier and not requirement.specifier.contains(
installed_version,
prereleases=True
):
is_core = package_name in cls._protected_runtime_packages
# 非核心包的纯升级冲突(插件要求更新版本)允许放行,由 pip 约束文件控制实际安装
if is_core or not cls.__is_upgrade_only_conflict(
requirement.specifier, installed_version):
conflicts.append((
package_name,
str(installed_version),
str(requirement.specifier),
is_core,
))
except Exception as e:
logger.error(f"执行运行环境依赖冲突预检时发生错误:{e}")
return False, f"插件依赖预检失败:{e}"
@@ -1516,7 +1414,7 @@ class PluginHelper(metaclass=WeakSingleton):
details = []
for package_name, installed_version, expected, _is_protected in sorted(conflicts, key=sort_key)[:5]:
details.append(
f"{cls.__format_pkg_name_for_pip(package_name)} 当前为 {installed_version}"
f"{cls.__format_package_name(package_name)} 当前为 {installed_version}"
f"插件要求 {expected}"
)
if len(conflicts) > 5:
@@ -1546,10 +1444,10 @@ class PluginHelper(metaclass=WeakSingleton):
for package_name, version in sorted(protected_packages.items()):
if package_name in cls._protected_runtime_packages:
# 核心包严格锁定,插件不得改写
temp_file.write(f"{cls.__format_pkg_name_for_pip(package_name)}=={version}\n")
temp_file.write(f"{cls.__format_package_name(package_name)}=={version}\n")
else:
# 非核心主程序依赖:允许升级,但禁止降级
temp_file.write(f"{cls.__format_pkg_name_for_pip(package_name)}>={version}\n")
temp_file.write(f"{cls.__format_package_name(package_name)}>={version}\n")
return Path(temp_file.name)
@staticmethod
@@ -1563,22 +1461,22 @@ class PluginHelper(metaclass=WeakSingleton):
@classmethod
def __build_package_install_request(
cls,
requirements_file: Path,
dependency_file: Path,
find_links_dirs: Optional[List[Path]] = None,
constraints_file: Optional[Path] = None,
purpose: str = "plugin",
) -> PackageInstallRequest:
"""
将 MoviePilot 运行配置转换为 pip/uv 安装请求,统一缓存、镜像和代理语义。
将 MoviePilot 运行配置转换为 uv 安装请求,统一缓存、镜像和代理语义。
"""
return PackageInstallRequest(
requirements_file=requirements_file,
dependency_file=dependency_file,
python_bin=Path(sys.executable),
find_links_dirs=find_links_dirs or [],
constraints_file=constraints_file,
config_dir=settings.CONFIG_PATH,
package_cache_root=settings.PACKAGE_CACHE_PATH,
pip_index_url=settings.PIP_PROXY or None,
package_index_url=settings.PIP_PROXY or None,
proxy_url=settings.PROXY_HOST or None,
purpose=purpose,
)
@@ -1616,11 +1514,14 @@ class PluginHelper(metaclass=WeakSingleton):
"""
执行全部运行环境自检并返回逐项结果,避免前一项失败遮蔽后续异常。
"""
checks = [
("pip check", cls.__build_runtime_pip_command("check")),
("核心依赖导入检查", [sys.executable, "-c", cls._runtime_import_probe]),
]
health_snapshot = {}
uv_check = cls.__build_runtime_uv_command("check")
if uv_check:
checks = [("uv check", uv_check)]
else:
health_snapshot["uv check"] = (False, "未找到 uv 可执行文件")
checks = []
checks.append(("核心依赖导入检查", [sys.executable, "-c", cls._runtime_import_probe]))
for check_name, command in checks:
success, message = SystemUtils.execute_with_subprocess(command)
health_snapshot[check_name] = (success, message)
@@ -1645,22 +1546,29 @@ class PluginHelper(metaclass=WeakSingleton):
def __repair_main_runtime_dependencies(cls, snapshot_file: Optional[Path] = None) -> Tuple[bool, str]:
"""
依赖安装后如果发现主运行环境已异常,优先恢复主程序依赖快照;
若快照不可用,再按主项目依赖重新安装进行自愈
若快照不可用,再按主项目锁定依赖恢复运行环境
"""
repair_target = snapshot_file
repair_desc = "主程序依赖快照"
if repair_target and not repair_target.exists():
repair_target = None
if repair_target is None:
repair_target = settings.ROOT_PATH / "requirements.txt"
repair_desc = "主程序 requirements.txt"
repair_target = settings.ROOT_PATH / "pyproject.toml"
repair_desc = "主程序 uv.lock"
if not repair_target.exists():
return False, f"恢复依赖文件不存在:{repair_target}"
if snapshot_file is None and not (settings.ROOT_PATH / "uv.lock").exists():
return False, f"恢复依赖文件不存在:{settings.ROOT_PATH / 'uv.lock'}"
last_error = ""
request = cls.__build_package_install_request(repair_target, purpose="runtime-repair")
for strategy in build_package_install_strategies(request):
logger.warning(f"[PIP] 运行环境异常,尝试使用策略:{strategy.strategy_name} 恢复{repair_desc}")
strategies = (
build_package_install_strategies(request)
if snapshot_file is not None
else build_project_sync_strategies(request)
)
for strategy in strategies:
logger.warning(f"[UV] 运行环境异常,尝试使用策略:{strategy.strategy_name} 恢复{repair_desc}")
success, message = SystemUtils.execute_with_subprocess(
strategy.command,
env=strategy.env,
@@ -1670,20 +1578,20 @@ class PluginHelper(metaclass=WeakSingleton):
cls.__refresh_import_system()
return True, message
last_error = message
logger.error(f"[PIP] 使用策略:{strategy.strategy_name} 恢复{repair_desc}失败:{message}")
logger.error(f"[UV] 使用策略:{strategy.strategy_name} 恢复{repair_desc}失败:{message}")
return False, last_error or f"恢复{repair_desc}失败"
@classmethod
def pip_install_with_fallback(cls,
requirements_file: Path,
find_links_dirs: Optional[List[Path]] = None) -> Tuple[bool, str]:
def install_packages_with_fallback(cls,
dependency_file: Path,
find_links_dirs: Optional[List[Path]] = None) -> Tuple[bool, str]:
"""
使用自动降级策略安装依赖,并确保新安装的包可被动态导入
:param requirements_file: 依赖的 requirements.txt 文件路径
:param dependency_file: 插件依赖清单路径
:param find_links_dirs: 额外的本地 wheels 目录列表
:return: (是否成功, 错误信息)
"""
wheels_dir = requirements_file.parent / "wheels"
wheels_dir = dependency_file.parent / "wheels"
candidate_dirs = []
if wheels_dir.is_dir():
candidate_dirs.append(wheels_dir)
@@ -1705,15 +1613,15 @@ class PluginHelper(metaclass=WeakSingleton):
if resolved_dirs:
for local_wheels_dir in resolved_dirs:
logger.debug(f"[PIP] 发现可用的 wheels 目录: {local_wheels_dir},将优先从本地安装。")
logger.debug(f"[UV] 发现可用的 wheels 目录: {local_wheels_dir},将优先从本地安装。")
else:
logger.debug(f"[PIP] 未发现可用的 wheels 目录,将仅使用在线源。")
logger.debug("[UV] 未发现可用的 wheels 目录,将仅使用在线源。")
installed_packages = cls.__get_installed_packages()
protected_packages = cls.__get_protected_runtime_packages(installed_packages)
check_ok, check_message = cls.__validate_runtime_dependency_conflicts(requirements_file, protected_packages)
check_ok, check_message = cls.__validate_runtime_dependency_conflicts(dependency_file, protected_packages)
if not check_ok:
logger.error(f"[PIP] 运行环境冲突预检失败:{check_message}")
logger.error(f"[UV] 运行环境冲突预检失败:{check_message}")
return False, check_message
constraints_file = None
@@ -1721,11 +1629,11 @@ class PluginHelper(metaclass=WeakSingleton):
try:
constraints_file = cls.__create_runtime_constraints_file(protected_packages)
except Exception as e:
logger.error(f"[PIP] 创建运行环境约束文件失败:{e}")
logger.error(f"[UV] 创建运行环境约束文件失败:{e}")
return False, f"创建运行环境约束文件失败:{e}"
request = cls.__build_package_install_request(
requirements_file,
dependency_file,
find_links_dirs=resolved_dirs,
constraints_file=constraints_file,
purpose="plugin",
@@ -1733,20 +1641,20 @@ class PluginHelper(metaclass=WeakSingleton):
strategies = build_package_install_strategies(request)
try:
# pip 会修改当前解释器的 site-packages,安装与缓存刷新必须串行,避免运行态模块被并发安装窗口污染
with cls._pip_install_lock:
# 安装器会修改当前解释器的 site-packages,安装与缓存刷新必须串行。
with cls._package_install_lock:
loaded_modules_before_install = set(sys.modules.keys())
baseline_health = cls.__run_runtime_healthcheck()
baseline_health_message = cls.__runtime_health_regression_message({}, baseline_health)
if baseline_health_message:
logger.warning(
f"[PIP] 安装前运行环境已存在异常,本次安装仅拦截新增异常:{baseline_health_message}"
f"[UV] 安装前运行环境已存在异常,本次安装仅拦截新增异常:{baseline_health_message}"
)
# 遍历策略进行安装
last_error = ""
for strategy in strategies:
logger.debug(
f"[PIP] 尝试使用策略:{strategy.strategy_name} 安装依赖,"
f"[UV] 尝试使用策略:{strategy.strategy_name} 安装依赖,"
f"命令:{' '.join(strategy.safe_log_command)}"
)
success, message = SystemUtils.execute_with_subprocess(
@@ -1755,14 +1663,14 @@ class PluginHelper(metaclass=WeakSingleton):
safe_command=strategy.safe_log_command,
)
if success:
logger.debug(f"[PIP] 策略:{strategy.strategy_name} 安装依赖成功,输出:{message}")
logger.debug(f"[UV] 策略:{strategy.strategy_name} 安装依赖成功,输出:{message}")
current_health = cls.__run_runtime_healthcheck()
health_message = cls.__runtime_health_regression_message(
baseline_health,
current_health
)
if health_message:
logger.error(f"[PIP] 依赖安装后运行环境自检失败:{health_message}")
logger.error(f"[UV] 依赖安装后运行环境自检失败:{health_message}")
repair_ok, repair_message = cls.__repair_main_runtime_dependencies(
constraints_file if protected_packages else None
)
@@ -1778,7 +1686,7 @@ class PluginHelper(metaclass=WeakSingleton):
f"依赖安装后运行环境自检失败,已自动恢复主程序依赖:{health_message}"
)
logger.error(
f"[PIP] 主程序依赖恢复后仍未通过健康检查:{restored_message}"
f"[UV] 主程序依赖恢复后仍未通过健康检查:{restored_message}"
)
return False, (
f"依赖安装后运行环境自检失败,恢复主程序依赖后仍异常:"
@@ -1792,14 +1700,14 @@ class PluginHelper(metaclass=WeakSingleton):
remaining_health_message = cls.__runtime_health_regression_message({}, current_health)
if remaining_health_message:
logger.warning(
f"[PIP] 依赖安装成功,安装前已有的运行环境异常仍然存在:"
f"[UV] 依赖安装成功,安装前已有的运行环境异常仍然存在:"
f"{remaining_health_message}"
)
cls.__refresh_import_system()
loaded_modules_after_install = set(sys.modules.keys())
loaded_modules_during_install = loaded_modules_after_install - loaded_modules_before_install
logger.debug(f"[PIP] 已刷新导入系统,新加载的模块: {loaded_modules_during_install}")
logger.debug(f"[UV] 已刷新导入系统,新加载的模块: {loaded_modules_during_install}")
return True, message
last_error = message
@@ -1807,7 +1715,7 @@ class PluginHelper(metaclass=WeakSingleton):
constraints_file if protected_packages else None,
baseline_health
)
logger.error(f"[PIP] 策略:{strategy.strategy_name} 安装依赖失败,错误信息:{message}")
logger.error(f"[UV] 策略:{strategy.strategy_name} 安装依赖失败,错误信息:{message}")
if not repair_ok or repair_message:
return False, (
f"策略 {strategy.strategy_name} 安装依赖失败:{message}"
@@ -1818,8 +1726,8 @@ class PluginHelper(metaclass=WeakSingleton):
constraints_file.unlink(missing_ok=True)
if last_error:
return False, f"[PIP] 所有策略均安装依赖失败:{last_error}"
return False, "[PIP] 所有策略均安装依赖失败,请检查网络连接、PIP 配置或插件依赖约束"
return False, f"[UV] 所有策略均安装依赖失败:{last_error}"
return False, "[UV] 所有策略均安装依赖失败,请检查网络连接、包源配置或插件依赖约束"
@staticmethod
def __request_with_fallback(url: str,
@@ -2162,98 +2070,6 @@ class PluginHelper(metaclass=WeakSingleton):
logger.error(f"获取已安装的包时发生错误:{e}")
return {}
def __find_plugin_dependencies(self) -> Dict[str, str]:
"""
收集所有插件的依赖项
遍历 plugins 目录下的所有插件,查找存在 requirements.txt 的插件目录
,并解析其中的依赖项,同时将所有插件的依赖项合并到字典中,方便后续统一处理
:return: 依赖项字典,格式为 {package_name: set(version_specifiers)}
"""
dependencies = {}
try:
install_plugins = {
plugin_id.lower() # 对应插件的小写目录名
for plugin_id in _installed_plugins_provider() or []
}
for plugin_dir in PLUGIN_DIR.iterdir():
if plugin_dir.is_dir():
requirements_file = plugin_dir / "requirements.txt"
if requirements_file.exists():
if plugin_dir.name not in install_plugins:
# 这个插件不在安装列表中 忽略它的依赖
logger.debug(f"忽略插件 {plugin_dir.name} 的依赖")
continue
# 解析当前插件的 requirements.txt,获取依赖项
plugin_deps = self.__parse_requirements(requirements_file)
for pkg_name, version_specifiers in plugin_deps.items():
if pkg_name in dependencies:
# 更新已存在的包的版本约束集合
dependencies[pkg_name].update(version_specifiers)
else:
# 添加新的包及其版本约束
dependencies[pkg_name] = set(version_specifiers)
return self.__merge_dependencies(dependencies)
except Exception as e:
logger.error(f"收集插件依赖项时发生错误:{e}")
return {}
def __parse_requirements(self, requirements_file: Path) -> Dict[str, List[str]]:
"""
解析 requirements.txt 文件,返回依赖项字典
使用 packaging 库解析每一行依赖项,提取包名和版本约束
对于无法解析的行,记录警告日志,便于后续检查
:param requirements_file: requirements.txt 文件的路径
:return: 依赖项字典,格式为 {package_name: [version_specifier]}
"""
dependencies = {}
try:
with open(requirements_file, "r", encoding="utf-8", errors="replace") as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
# 使用 packaging 库解析依赖项
try:
req = Requirement(line)
pkg_name = self.__standardize_pkg_name(req.name)
version_specifier = str(req.specifier)
if pkg_name in dependencies:
dependencies[pkg_name].append(version_specifier)
else:
dependencies[pkg_name] = [version_specifier]
except Exception as e:
logger.debug(f"无法解析依赖项 '{line}'{e}")
return dependencies
except Exception as e:
logger.error(f"解析 requirements.txt 时发生错误:{e}")
return {}
@staticmethod
def __merge_dependencies(dependencies: Dict[str, Set[str]]) -> Dict[str, str]:
"""
合并依赖项,选择每个包的最高版本要求
对于多个插件依赖同一包的情况,合并其版本约束,取交集以满足所有插件的要求
如果交集为空,表示存在版本冲突,需要根据策略进行处理
:param dependencies: 依赖项字典,格式为 {package_name: set(version_specifiers)}
:return: 合并后的依赖项字典,格式为 {package_name: version_specifiers}
"""
try:
merged_dependencies = {}
for pkg_name, version_specifiers in dependencies.items():
# 合并版本约束
spec_set = SpecifierSet()
for specifier in version_specifiers:
try:
if specifier:
spec_set &= SpecifierSet(specifier)
except InvalidSpecifier as e:
logger.error(f"发生版本约束冲突:{e}")
# 将合并后的版本约束添加到结果字典
merged_dependencies[pkg_name] = str(spec_set) if spec_set else ''
return merged_dependencies
except Exception as e:
logger.error(f"合并依赖项时发生错误:{e}")
return {}
@staticmethod
def __standardize_pkg_name(name: str) -> str:
"""
@@ -2519,14 +2335,12 @@ class PluginHelper(metaclass=WeakSingleton):
return None, "插件数据解析失败"
async def __async_download_files(self, pid: str, file_list: List[dict], user_repo: str,
package_version: Optional[str] = None,
skip_requirements: bool = False) -> Tuple[bool, str]:
package_version: Optional[str] = None) -> Tuple[bool, str]:
"""
异步下载插件文件
:param pid: 插件 ID
:param file_list: 要下载的文件列表,包含文件的元数据(包括下载链接)
:param user_repo: GitHub 仓库的 user/repo 路径
:param skip_requirements: 是否跳过 requirements.txt 文件的下载
:return: (是否成功, 错误信息)
"""
if not file_list:
@@ -2539,10 +2353,6 @@ class PluginHelper(metaclass=WeakSingleton):
current_pid, current_file_list = stack.pop()
for item in current_file_list:
# 跳过 requirements.txt 的下载
if skip_requirements and item.get("name") == "requirements.txt":
continue
if item.get("download_url"):
logger.debug(f"正在下载文件:{item.get('path')}")
res = await self.__async_request_with_fallback(item.get('download_url'),
@@ -2573,45 +2383,16 @@ class PluginHelper(metaclass=WeakSingleton):
return True, ""
async def __async_download_and_install_requirements(self, requirements_file_info: dict, pid: str, user_repo: str) \
-> Tuple[bool, str]:
"""
异步下载并安装 requirements.txt 文件中的依赖
:param requirements_file_info: requirements.txt 文件的元数据信息
:param pid: 插件 ID
:param user_repo: GitHub 仓库的 user/repo 路径
:return: (是否成功, 错误信息)
"""
# 下载 requirements.txt
res = await self.__async_request_with_fallback(requirements_file_info.get("download_url"),
headers=settings.REPO_GITHUB_HEADERS(repo=user_repo))
if not res:
return False, "requirements.txt 文件下载失败"
elif res.status_code != 200:
return False, f"下载 requirements.txt 文件失败:{res.status_code}"
requirements_txt = res.text
if requirements_txt.strip():
# 保存并安装依赖
requirements_file_path = AsyncPath(PLUGIN_DIR) / pid.lower() / "requirements.txt"
await requirements_file_path.parent.mkdir(parents=True, exist_ok=True)
async with aiofiles.open(requirements_file_path, "w", encoding="utf-8") as f:
await f.write(requirements_txt)
return await self.__async_pip_install_with_fallback(Path(requirements_file_path))
return True, "" # 如果 requirements.txt 为空,视作成功
async def __async_pip_install_with_fallback(
async def __async_install_packages_with_fallback(
self,
requirements_file: Path,
dependency_file: Path,
find_links_dirs: Optional[List[Path]] = None) -> Tuple[bool, str]:
"""
在线程池中执行插件依赖安装,避免同步 pip 子进程阻塞事件循环。
在线程池中执行插件依赖安装,避免同步包安装子进程阻塞事件循环。
"""
return await asyncio.to_thread(
self.pip_install_with_fallback,
requirements_file,
self.install_packages_with_fallback,
dependency_file,
find_links_dirs
)
@@ -2691,18 +2472,16 @@ class PluginHelper(metaclass=WeakSingleton):
:param pid: 插件 ID
:return: (是否存在依赖,安装是否成功, 错误信息)
"""
# 定位插件目录和依赖文件
plugin_dir = AsyncPath(PLUGIN_DIR) / pid.lower()
requirements_file = plugin_dir / "requirements.txt"
# 检查是否存在 requirements.txt 文件
if await requirements_file.exists():
plugin_dir = PLUGIN_DIR / pid.lower()
try:
manifest = load_dependency_manifest(plugin_dir)
except PluginDependencyManifestError as error:
logger.error(f"{pid} 依赖清单无效:{error}")
return True, False, str(error)
if manifest is not None:
logger.info(f"{pid} 存在依赖,开始尝试安装依赖")
success, error_message = await self.__async_pip_install_with_fallback(Path(requirements_file))
if success:
return True, True, ""
else:
return True, False, error_message
success, error_message = await self.__async_install_packages_with_fallback(manifest.path)
return True, success, "" if success else error_message
return False, False, "不存在依赖"
@@ -2717,73 +2496,6 @@ class PluginHelper(metaclass=WeakSingleton):
plugin_dir=PLUGIN_DIR,
).async_install(dependencies)
async def __async_find_plugin_dependencies(self) -> Dict[str, str]:
"""
异步收集所有插件的依赖项
遍历 plugins 目录下的所有插件,查找存在 requirements.txt 的插件目录
,并解析其中的依赖项,同时将所有插件的依赖项合并到字典中,方便后续统一处理
:return: 依赖项字典,格式为 {package_name: set(version_specifiers)}
"""
dependencies = {}
try:
install_plugins = {
plugin_id.lower() # 对应插件的小写目录名
for plugin_id in _installed_plugins_provider() or []
}
plugin_dir_path = AsyncPath(PLUGIN_DIR)
async for plugin_dir in plugin_dir_path.iterdir():
if await plugin_dir.is_dir():
requirements_file = plugin_dir / "requirements.txt"
if await requirements_file.exists():
if plugin_dir.name not in install_plugins:
# 这个插件不在安装列表中 忽略它的依赖
logger.debug(f"忽略插件 {plugin_dir.name} 的依赖")
continue
# 解析当前插件的 requirements.txt,获取依赖项
plugin_deps = await self.__async_parse_requirements(requirements_file)
for pkg_name, version_specifiers in plugin_deps.items():
if pkg_name in dependencies:
# 更新已存在的包的版本约束集合
dependencies[pkg_name].update(version_specifiers)
else:
# 添加新的包及其版本约束
dependencies[pkg_name] = set(version_specifiers)
return self.__merge_dependencies(dependencies)
except Exception as e:
logger.error(f"收集插件依赖项时发生错误:{e}")
return {}
async def __async_parse_requirements(self, requirements_file: AsyncPath) -> Dict[str, List[str]]:
"""
异步解析 requirements.txt 文件,返回依赖项字典
使用 packaging 库解析每一行依赖项,提取包名和版本约束
对于无法解析的行,记录警告日志,便于后续检查
:param requirements_file: requirements.txt 文件的路径
:return: 依赖项字典,格式为 {package_name: [version_specifier]}
"""
dependencies = {}
try:
async with aiofiles.open(requirements_file, "r", encoding="utf-8", errors="replace") as f:
async for line in f:
line = str(line).strip()
if line and not line.startswith('#'):
# 使用 packaging 库解析依赖项
try:
req = Requirement(line)
pkg_name = self.__standardize_pkg_name(req.name)
version_specifier = str(req.specifier)
if pkg_name in dependencies:
dependencies[pkg_name].append(version_specifier)
else:
dependencies[pkg_name] = [version_specifier]
except Exception as e:
logger.debug(f"无法解析依赖项 '{line}'{e}")
return dependencies
except Exception as e:
logger.error(f"解析 requirements.txt 时发生错误:{e}")
return {}
async def async_find_missing_dependencies(self) -> List[str]:
"""兼容旧异步市场入口,转发到独立依赖适配器。"""
installer = importlib.import_module(
@@ -2799,13 +2511,7 @@ class PluginHelper(metaclass=WeakSingleton):
release_version: Optional[str] = None,
force_install: bool = False) -> Tuple[bool, str]:
"""
异步安装插件,包括依赖安装和文件下载,相关资源支持自动降级策略
1. 检查并获取插件的指定版本,确认版本兼容性
2. 从 GitHub 获取文件列表(包括 requirements.txt
3. 删除旧的插件目录(如非强制安装则进行备份)
4. 下载并预安装 requirements.txt 中的依赖(如果存在)
5. 下载并安装插件的其他文件
6. 再次尝试安装依赖(确保安装完整)
异步安装插件,包括版本检查、内容准备、生效清单依赖安装和失败恢复。
:param pid: 插件 ID
:param repo_url: 插件仓库地址
:param package_version: 首选插件版本 (如 "v2", "v3"),如不指定则默认使用系统配置的版本
@@ -2957,14 +2663,7 @@ class PluginHelper(metaclass=WeakSingleton):
file_list, msg = self.__get_file_list(pid, user_repo, package_version)
if not file_list:
return False, msg
requirements_file_info = next((f for f in file_list if f.get("name") == "requirements.txt"), None)
if requirements_file_info:
ok, m = self.__download_and_install_requirements(requirements_file_info, pid, user_repo)
if not ok:
logger.debug(f"{pid} 依赖预安装失败:{m}")
else:
logger.debug(f"{pid} 依赖预安装成功")
ok, m = self.__download_files(pid, file_list, user_repo, package_version, True)
ok, m = self.__download_files(pid, file_list, user_repo, package_version)
if not ok:
return False, m
return True, ""
@@ -2977,14 +2676,7 @@ class PluginHelper(metaclass=WeakSingleton):
file_list, msg = await self.__async_get_file_list(pid, user_repo, package_version)
if not file_list:
return False, msg
requirements_file_info = next((f for f in file_list if f.get("name") == "requirements.txt"), None)
if requirements_file_info:
ok, m = await self.__async_download_and_install_requirements(requirements_file_info, pid, user_repo)
if not ok:
logger.debug(f"{pid} 依赖预安装失败:{m}")
else:
logger.debug(f"{pid} 依赖预安装成功")
ok, m = await self.__async_download_files(pid, file_list, user_repo, package_version, True)
ok, m = await self.__async_download_files(pid, file_list, user_repo, package_version)
if not ok:
return False, m
return True, ""
+4 -4
View File
@@ -98,23 +98,23 @@ class SystemUtils:
@staticmethod
def execute_with_subprocess(
pip_command: list,
command: list,
env: Optional[dict[str, str]] = None,
safe_command: Optional[list[str]] = None,
) -> Tuple[bool, str]:
"""
执行命令并捕获标准输出和错误输出,记录日志。
:param pip_command: 要执行的命令,以列表形式提供
:param command: 要执行的命令,以列表形式提供
:param env: 传递给子进程的环境变量
:param safe_command: 用于错误信息展示的脱敏命令
:return: (命令是否成功, 输出信息或错误信息)
"""
display_command = safe_command or pip_command
display_command = safe_command or command
try:
# 使用 subprocess.run 捕获标准输出和标准错误
result = subprocess.run(
pip_command,
command,
check=True,
text=True,
stdout=subprocess.PIPE,
+51 -39
View File
@@ -4,26 +4,22 @@ import os
import shutil
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal
from urllib.parse import urlsplit, urlunsplit
PackageBackend = Literal["uv", "pip"]
@dataclass(frozen=True)
class PackageInstallRequest:
"""
Python 包安装请求,集中描述依赖文件、工具缓存、代理和本地 wheels 候选源。
"""
requirements_file: Path
dependency_file: Path
python_bin: Path
find_links_dirs: list[Path] = field(default_factory=list)
constraints_file: Path | None = None
config_dir: Path = Path("/config")
package_cache_root: Path | None = None
pip_index_url: str | None = None
package_index_url: str | None = None
proxy_url: str | None = None
purpose: str = "plugin"
@@ -35,7 +31,6 @@ class PackageInstallStrategy:
"""
strategy_name: str
backend: PackageBackend
command: list[str]
env: dict[str, str]
safe_log_command: list[str]
@@ -61,7 +56,7 @@ def redact_command(command: list[str]) -> list[str]:
def build_package_install_env(request: PackageInstallRequest, include_moviepilot_proxy: bool = True) -> dict[str, str]:
"""
构造 pip/uv 安装子进程环境,默认把包下载缓存放到持久化配置目录。
构造 uv 安装子进程环境,默认把包下载缓存放到持久化配置目录。
"""
env = os.environ.copy()
config_dir = Path(request.config_dir)
@@ -71,7 +66,6 @@ def build_package_install_env(request: PackageInstallRequest, include_moviepilot
else:
package_cache_root = Path(env.get("PACKAGE_CACHE_ROOT") or config_dir / ".cache")
env.setdefault("PACKAGE_CACHE_ROOT", str(package_cache_root))
env.setdefault("PIP_CACHE_DIR", str(package_cache_root / "pip"))
env.setdefault("UV_CACHE_DIR", str(package_cache_root / "uv"))
proxy = (request.proxy_url or "").strip()
if proxy and include_moviepilot_proxy:
@@ -80,9 +74,9 @@ def build_package_install_env(request: PackageInstallRequest, include_moviepilot
return env
def _find_uv(python_bin: Path) -> Path | None:
def find_uv(python_bin: Path) -> Path | None:
"""
优先使用解释器同目录 uv,保证虚拟环境内 wrapper 与真实安装环境一致
优先使用解释器同目录 uv,保证安装器与目标运行环境使用同一版本
"""
uv_name = "uv.exe" if os.name == "nt" else "uv"
sibling = python_bin.with_name(uv_name)
@@ -98,12 +92,12 @@ def _base_install_args(request: PackageInstallRequest) -> list[str]:
args.extend(["--find-links", str(directory)])
if request.constraints_file:
args.extend(["-c", str(request.constraints_file)])
args.extend(["-r", str(request.requirements_file)])
args.extend(["-r", str(request.dependency_file)])
return args
def _network_variants(request: PackageInstallRequest) -> list[tuple[str, bool, bool]]:
has_index = bool((request.pip_index_url or "").strip())
has_index = bool((request.package_index_url or "").strip())
has_proxy = bool((request.proxy_url or "").strip())
variants: list[tuple[str, bool, bool]] = []
if has_index and has_proxy:
@@ -118,49 +112,67 @@ def _network_variants(request: PackageInstallRequest) -> list[tuple[str, bool, b
def _build_uv_command(uv_bin: Path, request: PackageInstallRequest, use_index: bool) -> list[str]:
command = [str(uv_bin), "pip", "install", "--python", str(request.python_bin)]
if use_index and request.pip_index_url:
command.extend(["--default-index", request.pip_index_url])
if use_index and request.package_index_url:
command.extend(["--default-index", request.package_index_url])
command.extend(_base_install_args(request))
return command
def _build_pip_command(request: PackageInstallRequest, use_index: bool) -> list[str]:
command = [str(request.python_bin), "-m", "pip", "install"]
if use_index and request.pip_index_url:
command.extend(["-i", request.pip_index_url])
command.extend(_base_install_args(request))
def _build_uv_sync_command(uv_bin: Path, request: PackageInstallRequest, use_index: bool) -> list[str]:
command = [
str(uv_bin),
"sync",
"--project",
str(request.dependency_file.parent),
"--locked",
"--no-dev",
"--no-install-project",
"--inexact",
]
if use_index and request.package_index_url:
command.extend(["--default-index", request.package_index_url])
return command
def build_package_install_strategies(request: PackageInstallRequest) -> list[PackageInstallStrategy]:
"""
uv 优先、pip 兜底顺序构造网络降级策略。
为固定 uv 安装器构造镜像、代理和直连降级策略。
"""
strategies: list[PackageInstallStrategy] = []
variants = _network_variants(request)
uv_bin = _find_uv(Path(request.python_bin))
if uv_bin:
for variant_name, use_index, use_proxy in variants:
command = _build_uv_command(uv_bin, request, use_index)
env = build_package_install_env(request, include_moviepilot_proxy=use_proxy)
strategies.append(
PackageInstallStrategy(
strategy_name=f"uv:{variant_name}",
backend="uv",
command=command,
env=env,
safe_log_command=redact_command(command),
)
)
uv_bin = find_uv(Path(request.python_bin))
if not uv_bin:
return strategies
for variant_name, use_index, use_proxy in variants:
command = _build_pip_command(request, use_index)
command = _build_uv_command(uv_bin, request, use_index)
env = build_package_install_env(request, include_moviepilot_proxy=use_proxy)
strategies.append(
PackageInstallStrategy(
strategy_name=f"pip:{variant_name}",
backend="pip",
strategy_name=f"uv:{variant_name}",
command=command,
env=env,
safe_log_command=redact_command(command),
)
)
return strategies
def build_project_sync_strategies(request: PackageInstallRequest) -> list[PackageInstallStrategy]:
"""为主项目锁定依赖恢复构造 uv 网络降级策略。"""
uv_bin = find_uv(Path(request.python_bin))
if not uv_bin:
return []
strategies = []
project_environment = request.python_bin.parent.parent
for variant_name, use_index, use_proxy in _network_variants(request):
command = _build_uv_sync_command(uv_bin, request, use_index)
env = build_package_install_env(request, include_moviepilot_proxy=use_proxy)
env["UV_PROJECT_ENVIRONMENT"] = str(project_environment)
strategies.append(
PackageInstallStrategy(
strategy_name=f"uv:{variant_name}",
command=command,
env=env,
safe_log_command=redact_command(command),
+187 -56
View File
@@ -1,23 +1,41 @@
"""插件 requirements 聚合和 Python 依赖安装适配器。"""
"""插件 Python 依赖聚合和安装适配器。"""
from __future__ import annotations
import asyncio
import json
from collections.abc import Callable
from importlib.metadata import distributions
from dataclasses import dataclass, field
from importlib.metadata import PackageNotFoundError, distribution, distributions
from pathlib import Path
from typing import Any, Optional
from urllib.parse import urlsplit
from packaging.markers import default_environment
from packaging.requirements import Requirement
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.version import InvalidVersion, Version
from app.adapters.system.plugin.manifest import (
PluginDependencyManifestError,
load_dependency_manifest,
)
from app.runtime.config import settings
from app.runtime.log import logger
@dataclass
class _RequirementGroup:
"""聚合同一包和安装来源的 extras 与版本约束。"""
name: str # PEP 503 规范化后的包名
url: Optional[str] # direct reference 来源;为空表示从索引安装
extras: set[str] = field(default_factory=set) # 所有插件要求启用的 extras
specifiers: set[str] = field(default_factory=set) # 待求交集的版本约束
class PluginDependencyInstaller:
"""独立负责插件依赖扫描、约束合并和 pip 安装。"""
"""独立负责插件依赖扫描、约束合并和安装。"""
def __init__(
self,
@@ -26,7 +44,7 @@ class PluginDependencyInstaller:
installed_plugins_provider: Optional[Callable[[], list[str]]] = None,
plugin_dir: Optional[Path] = None,
) -> None:
"""保存 pip 端口和启动层提供的已安装插件读取器。"""
"""保存包安装端口和启动层提供的已安装插件读取器。"""
if helper is None:
from app.adapters.external.market import PluginHelper
@@ -71,49 +89,168 @@ class PluginDependencyInstaller:
return installed
@classmethod
def _parse_requirements(cls, requirements_file: Path) -> dict[str, list[str]]:
"""解析一个 requirements 文件中的包名和版本约束"""
dependencies: dict[str, list[str]] = {}
def _installed_distribution(cls, package_name: str) -> Any | None:
"""读取一个包的元数据,用于校验 extras 和 direct URL 来源"""
try:
for line in requirements_file.read_text(
encoding="utf-8",
errors="replace",
).splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
try:
requirement = Requirement(line)
except Exception as err:
logger.debug(f"无法解析依赖项 '{line}'{err}")
continue
package_name = cls._standardize(requirement.name)
dependencies.setdefault(package_name, []).append(
str(requirement.specifier)
return distribution(package_name)
except PackageNotFoundError:
return None
def _requirement_satisfied(
self,
requirement: Requirement,
installed: dict[str, Version],
*,
seen: Optional[set[tuple[str, tuple[str, ...], Optional[str]]]] = None,
) -> bool:
"""同时校验版本、extras 及 direct URL,不把同名包误认为同一制品。"""
package_name = self._standardize(requirement.name)
installed_version = installed.get(package_name)
try:
if installed_version is None or not SpecifierSet(
requirement.specifier
).contains(installed_version, prereleases=True):
return False
except InvalidSpecifier as err:
logger.error(f"依赖 {package_name} 约束无效:{err}")
return False
installed_distribution = self._installed_distribution(package_name)
if installed_distribution is None:
return False if requirement.extras or requirement.url else True
if requirement.url and not self._direct_url_matches(
installed_distribution, requirement.url
):
return False
requested_extras = {
self._standardize_extra(extra) for extra in requirement.extras
}
if requested_extras:
provided_extras = {
self._standardize_extra(extra)
for extra in installed_distribution.metadata.get_all(
"Provides-Extra"
)
except Exception as err:
logger.error(f"解析 requirements.txt 时发生错误:{err}")
return dependencies
or []
}
if not requested_extras.issubset(provided_extras):
return False
marker_key = (package_name, tuple(sorted(requested_extras)), requirement.url)
if seen is None:
seen = set()
if marker_key in seen:
return True
seen.add(marker_key)
for raw_dependency in installed_distribution.metadata.get_all(
"Requires-Dist"
) or []:
try:
extra_dependency = Requirement(raw_dependency)
except Exception as err:
logger.debug(
f"无法解析已安装包 {package_name} 的依赖项 '{raw_dependency}'{err}"
)
continue
if not self._marker_matches_for_extras(
extra_dependency, requested_extras
):
continue
if not self._requirement_satisfied(
extra_dependency, installed, seen=seen
):
return False
return True
@classmethod
def _merge(cls, dependencies: dict[str, set[str]]) -> dict[str, str]:
"""求同一包多来源约束的交集,保留冲突约束供 pip 处理。"""
merged: dict[str, str] = {}
for package_name, specifiers in dependencies.items():
def _marker_matches_for_extras(
cls, requirement: Requirement, extras: set[str]
) -> bool:
"""判断已安装发行版声明的可选依赖是否属于当前请求的 extra。"""
if requirement.marker is None:
return True
environment = default_environment()
if "extra" in str(requirement.marker):
return any(
requirement.marker.evaluate({**environment, "extra": extra})
for extra in extras
)
return requirement.marker.evaluate(environment)
@staticmethod
def _standardize_extra(name: str) -> str:
"""按 PEP 685 兼容规则标准化 extra 名称。"""
return (name or "").lower().replace("-", "_").replace(".", "_")
@staticmethod
def _direct_url_matches(installed_distribution: Any, required_url: str) -> bool:
"""校验安装发行版记录的 PEP 610 URL 与清单来源一致。"""
try:
payload = installed_distribution.read_text("direct_url.json")
if not payload:
return False
direct_url = json.loads(payload).get("url")
if not isinstance(direct_url, str):
return False
return PluginDependencyInstaller._canonical_direct_url(
required_url
) == PluginDependencyInstaller._canonical_direct_url(direct_url)
except (AttributeError, json.JSONDecodeError, TypeError, ValueError):
return False
@staticmethod
def _canonical_direct_url(value: str) -> tuple[str, str, str, str, str]:
"""规范化来源 URL,同时保留 fragment 中可能存在的校验信息。"""
parsed = urlsplit(value)
netloc = parsed.netloc.rsplit("@", 1)[-1].lower()
return (
parsed.scheme.lower(),
netloc,
parsed.path.rstrip("/"),
parsed.query,
parsed.fragment,
)
@classmethod
def _merge(cls, dependencies: list[Requirement]) -> list[Requirement]:
"""按包和安装来源合并 extras 与约束,保留完整安装目标。"""
groups: dict[tuple[str, Optional[str]], _RequirementGroup] = {}
for requirement in dependencies:
package_name = cls._standardize(requirement.name)
key = (package_name, requirement.url)
group = groups.setdefault(
key,
_RequirementGroup(name=package_name, url=requirement.url),
)
group.extras.update(requirement.extras)
group.specifiers.add(str(requirement.specifier))
merged: list[Requirement] = []
for group in groups.values():
spec_set = SpecifierSet()
for specifier in specifiers:
for specifier in group.specifiers:
if not specifier:
continue
try:
spec_set &= SpecifierSet(specifier)
except InvalidSpecifier as err:
logger.error(f"发生版本约束冲突:{err}")
merged[package_name] = str(spec_set) if spec_set else ""
target = group.name
if group.extras:
target += f"[{','.join(sorted(group.extras))}]"
if group.url:
target += f" @ {group.url}"
elif spec_set:
target += str(spec_set)
merged.append(Requirement(target))
return merged
def _plugin_dependencies(self) -> dict[str, str]:
"""扫描已安装插件的 requirements 并合并版本约束。"""
dependencies: dict[str, set[str]] = {}
def _plugin_dependencies(self) -> list[Requirement]:
"""扫描已安装插件的生效依赖清单并合并版本约束。"""
dependencies: list[Requirement] = []
installed_plugins = {
plugin_id.lower()
for plugin_id in self._installed_plugins_provider() or []
@@ -121,20 +258,20 @@ class PluginDependencyInstaller:
try:
plugin_dirs = list(self._plugin_dir.iterdir())
except (FileNotFoundError, OSError):
return {}
return []
for plugin_dir in plugin_dirs:
if not plugin_dir.is_dir():
continue
requirements_file = plugin_dir / "requirements.txt"
if not requirements_file.is_file():
continue
if plugin_dir.name not in installed_plugins:
logger.debug(f"忽略插件 {plugin_dir.name} 的依赖")
continue
for package_name, specifiers in self._parse_requirements(
requirements_file
).items():
dependencies.setdefault(package_name, set()).update(specifiers)
manifest = load_dependency_manifest(plugin_dir)
if manifest is None:
continue
for requirement in manifest.dependencies:
if requirement.marker and not requirement.marker.evaluate():
continue
dependencies.append(requirement)
return self._merge(dependencies)
def find_missing(self) -> list[str]:
@@ -143,18 +280,12 @@ class PluginDependencyInstaller:
required = self._plugin_dependencies()
installed = self._installed_packages()
missing = []
for package_name, specifier in required.items():
installed_version = installed.get(package_name)
try:
satisfied = installed_version is not None and SpecifierSet(
specifier
).contains(installed_version, prereleases=True)
except InvalidSpecifier as err:
logger.error(f"依赖 {package_name} 约束无效:{err}")
satisfied = False
if not satisfied:
missing.append(f"{package_name}{specifier}")
for requirement in required:
if not self._requirement_satisfied(requirement, installed):
missing.append(str(requirement))
return missing
except PluginDependencyManifestError:
raise
except Exception as err:
logger.error(f"收集所有需要安装或更新的依赖项时发生错误:{err}")
return []
@@ -173,7 +304,7 @@ class PluginDependencyInstaller:
return list(dict.fromkeys(result))
def install(self, dependencies: list[str]) -> tuple[bool, str]:
"""把依赖写入临时 requirements 并调用现有 pip 健康检查策略。"""
"""把依赖写入临时 requirements 并调用统一包安装策略。"""
if not dependencies:
return False, "没有传入需要安装的依赖项"
requirements_file = (
@@ -187,7 +318,7 @@ class PluginDependencyInstaller:
"".join(f"{dependency}\n" for dependency in dependencies),
encoding="utf-8",
)
return self._helper.pip_install_with_fallback(
return self._helper.install_packages_with_fallback(
requirements_file,
self._wheels_dirs(),
)
@@ -202,5 +333,5 @@ class PluginDependencyInstaller:
return await asyncio.to_thread(self.find_missing)
async def async_install(self, dependencies: list[str]) -> tuple[bool, str]:
"""在线程池中安装依赖,复用同步 pip 健康检查策略。"""
"""在线程池中安装依赖,复用同步包安装策略。"""
return await asyncio.to_thread(self.install, dependencies)
+162
View File
@@ -0,0 +1,162 @@
"""插件 Python 依赖清单的选择和解析。"""
from __future__ import annotations
import tomllib
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from packaging.requirements import Requirement
from app.runtime.log import logger
PYPROJECT_FILENAME = "pyproject.toml"
REQUIREMENTS_FILENAME = "requirements.txt"
DEPENDENCY_MANIFEST_PRIORITY = (
PYPROJECT_FILENAME,
REQUIREMENTS_FILENAME,
)
DEPENDENCY_MANIFEST_FILENAMES = frozenset(
DEPENDENCY_MANIFEST_PRIORITY
)
class PluginDependencyManifestError(ValueError):
"""表示生效的现代依赖清单无法安全消费。"""
@dataclass(frozen=True)
class PluginDependencyManifest:
"""保存插件当前生效的依赖清单及其已解析依赖。"""
path: Path
dependencies: tuple[Requirement, ...]
def select_dependency_manifest(plugin_dir: Path) -> Path | None:
"""按现代清单优先级返回插件当前生效的依赖文件。"""
pyproject_file = plugin_dir / PYPROJECT_FILENAME
if pyproject_file.is_file():
return pyproject_file
requirements_file = plugin_dir / REQUIREMENTS_FILENAME
if requirements_file.is_file():
return requirements_file
return None
def dependency_manifest_status(event_path: Path) -> bool | None:
"""判断文件事件是否改变生效清单,非清单文件返回 None。"""
if event_path.name not in DEPENDENCY_MANIFEST_FILENAMES:
return None
active_manifest = select_dependency_manifest(event_path.parent)
if event_path.is_file():
return active_manifest == event_path
if active_manifest is None:
return True
return DEPENDENCY_MANIFEST_PRIORITY.index(
event_path.name
) < DEPENDENCY_MANIFEST_PRIORITY.index(active_manifest.name)
def load_dependency_manifest(
plugin_dir: Path,
) -> PluginDependencyManifest | None:
"""读取插件当前生效的依赖清单,现代清单无效时拒绝回退。"""
manifest_path = select_dependency_manifest(plugin_dir)
if manifest_path is None:
return None
return load_dependency_file(manifest_path)
def load_dependency_file(path: Path) -> PluginDependencyManifest:
"""读取指定依赖文件,pyproject 严格校验,其余文件保持旧格式兼容。"""
if path.name == PYPROJECT_FILENAME:
dependencies = _load_pyproject_dependencies(path)
else:
dependencies = _load_requirements_dependencies(path)
return PluginDependencyManifest(
path=path,
dependencies=dependencies,
)
def _load_pyproject_dependencies(path: Path) -> tuple[Requirement, ...]:
"""严格读取 PEP 621 ``project.dependencies``。"""
try:
with path.open("rb") as file:
document = tomllib.load(file)
except (OSError, tomllib.TOMLDecodeError) as err:
raise PluginDependencyManifestError(
f"插件依赖清单 {path.name} 无法解析:{err}"
) from err
project = document.get("project")
if not isinstance(project, Mapping):
raise PluginDependencyManifestError(
f"插件依赖清单 {path.name} 缺少 [project] 表"
)
name = project.get("name")
if not isinstance(name, str) or not name.strip():
raise PluginDependencyManifestError(
f"插件依赖清单 {path.name} 的 project.name 必须是非空字符串"
)
dynamic = project.get("dynamic", [])
if not isinstance(dynamic, list) or not all(
isinstance(item, str) for item in dynamic
):
raise PluginDependencyManifestError(
f"插件依赖清单 {path.name} 的 project.dynamic 必须是字符串数组"
)
if "dependencies" in dynamic:
raise PluginDependencyManifestError(
f"插件依赖清单 {path.name} 不支持动态 dependencies"
)
version = project.get("version")
if "version" in dynamic:
if version is not None:
raise PluginDependencyManifestError(
f"插件依赖清单 {path.name} 不能同时静态和动态声明 version"
)
elif not isinstance(version, str) or not version.strip():
raise PluginDependencyManifestError(
f"插件依赖清单 {path.name} 必须声明非空 project.version"
"或将 version 加入 project.dynamic"
)
raw_dependencies = project.get("dependencies", [])
if not isinstance(raw_dependencies, list) or not all(
isinstance(item, str) for item in raw_dependencies
):
raise PluginDependencyManifestError(
f"插件依赖清单 {path.name} 的 project.dependencies 必须是字符串数组"
)
dependencies: list[Requirement] = []
for item in raw_dependencies:
try:
dependencies.append(Requirement(item))
except Exception as err:
raise PluginDependencyManifestError(
f"插件依赖清单 {path.name} 包含无效依赖项 {item!r}{err}"
) from err
return tuple(dependencies)
def _load_requirements_dependencies(path: Path) -> tuple[Requirement, ...]:
"""按旧行为逐行读取 requirements,忽略无法解析的兼容内容。"""
dependencies: list[Requirement] = []
try:
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError as err:
logger.error(f"解析 requirements.txt 时发生错误:{err}")
return ()
for line in lines:
line = line.strip()
if not line or line.startswith("#"):
continue
try:
dependencies.append(Requirement(line))
except Exception as err:
logger.debug(f"无法解析依赖项 '{line}'{err}")
return tuple(dependencies)
+1 -1
View File
@@ -64,7 +64,7 @@ def _patch_gemini_thought_signature():
logger.error(
f"langchain-google-genai 版本 {_version or '未知'} 过旧,"
f"不支持 Gemini 2.5+ 模型的 thought_signature 处理,"
f"升级到 4.2.3+pip install langchain-google-genai~=4.2.3"
f"恢复 MoviePilot 锁定依赖或更新主程序"
)
return
-1
View File
@@ -329,7 +329,6 @@ def _best_effort_auto_update() -> None:
update_env = os.environ.copy()
package_cache_root = Path(update_env.get("PACKAGE_CACHE_ROOT", "").strip() or settings.PACKAGE_CACHE_PATH)
update_env.setdefault("PACKAGE_CACHE_ROOT", str(package_cache_root))
update_env.setdefault("PIP_CACHE_DIR", str(package_cache_root / "pip"))
update_env.setdefault("UV_CACHE_DIR", str(package_cache_root / "uv"))
if settings.PIP_PROXY:
update_env["PIP_PROXY"] = settings.PIP_PROXY
+7 -7
View File
@@ -332,7 +332,7 @@
"样本未识别到有效集数,智能生成失败": "No valid episode number was recognized from the sample, and intelligent generation failed",
"不存在依赖": "No dependencies exist",
"主运行环境已恢复": "Main runtime environment has been restored",
"[PIP] 所有策略均安装依赖失败,请检查网络连接、PIP 配置或插件依赖约束": "[PIP] All dependency installation strategies failed. Please check the network connection, PIP configuration, or plugin dependency constraints",
"[UV] 所有策略均安装依赖失败,请检查网络连接、包源配置或插件依赖约束": "[UV] All dependency installation strategies failed. Please check the network connection, package index configuration, or plugin dependency constraints",
"可执行文件模式下,只能安装本地插件": "In executable mode, only local plugins can be installed",
"不支持的插件仓库地址格式": "Unsupported plugin repository URL format",
"本地插件来源与插件ID不匹配": "Local plugin source does not match the plugin ID",
@@ -948,12 +948,12 @@
"target": "Cookie decryption failed: {reason}"
},
{
"source": "插件限定的系统版本范围 {range} 必须是字符串,请使用 pip 依赖版本格式,例如 >=2.12.0,<3",
"target": "The plugin system version range {range} must be a string. Use pip dependency version format, for example >=2.12.0,<3"
"source": "插件限定的系统版本范围 {range} 必须是字符串,请使用 PEP 440 版本范围格式,例如 >=2.12.0,<3",
"target": "The plugin system version range {range} must be a string. Use PEP 440 version specifier format, for example >=2.12.0,<3"
},
{
"source": "插件限定的系统版本范围格式不正确:{range},请使用 pip 依赖版本格式,例如 >=2.12.0,<3",
"target": "The plugin system version range format is invalid: {range}. Use pip dependency version format, for example >=2.12.0,<3"
"source": "插件限定的系统版本范围格式不正确:{range},请使用 PEP 440 版本范围格式,例如 >=2.12.0,<3",
"target": "The plugin system version range format is invalid: {range}. Use PEP 440 version specifier format, for example >=2.12.0,<3"
},
{
"source": "当前 MoviePilot 版本 {version} 无法解析,已拒绝安装带版本限制的插件",
@@ -988,8 +988,8 @@
"target": "Failed to recover {desc}"
},
{
"source": "[PIP] 所有策略均安装依赖失败:{reason}",
"target": "[PIP] All dependency installation strategies failed: {reason}"
"source": "[UV] 所有策略均安装依赖失败:{reason}",
"target": "[UV] All dependency installation strategies failed: {reason}"
},
{
"source": "{pid} 未声明 Release 安装,无法安装指定版本",
+7 -7
View File
@@ -328,7 +328,7 @@
"样本未识别到有效集数,智能生成失败": "樣本未識別到有效集數,智慧產生失敗",
"不存在依赖": "不存在依賴",
"主运行环境已恢复": "主執行環境已恢復",
"[PIP] 所有策略均安装依赖失败,请检查网络连接、PIP 配置或插件依赖约束": "[PIP] 所有策略均安裝依賴失敗,請檢查網路連線、PIP 設定或插件依賴約束",
"[UV] 所有策略均安装依赖失败,请检查网络连接、包源配置或插件依赖约束": "[UV] 所有策略均安裝依賴失敗,請檢查網路連線、套件來源設定或插件依賴約束",
"可执行文件模式下,只能安装本地插件": "可執行檔模式下,只能安裝本機插件",
"不支持的插件仓库地址格式": "不支援的插件倉庫位址格式",
"本地插件来源与插件ID不匹配": "本機插件來源與插件 ID 不匹配",
@@ -944,12 +944,12 @@
"target": "cookie 解密失敗:{reason}"
},
{
"source": "插件限定的系统版本范围 {range} 必须是字符串,请使用 pip 依赖版本格式,例如 >=2.12.0,<3",
"target": "插件限定的系統版本範圍 {range} 必須是字串,請使用 pip 依賴版本格式,例如 >=2.12.0,<3"
"source": "插件限定的系统版本范围 {range} 必须是字符串,请使用 PEP 440 版本范围格式,例如 >=2.12.0,<3",
"target": "插件限定的系統版本範圍 {range} 必須是字串,請使用 PEP 440 版本範圍格式,例如 >=2.12.0,<3"
},
{
"source": "插件限定的系统版本范围格式不正确:{range},请使用 pip 依赖版本格式,例如 >=2.12.0,<3",
"target": "插件限定的系統版本範圍格式不正確:{range},請使用 pip 依賴版本格式,例如 >=2.12.0,<3"
"source": "插件限定的系统版本范围格式不正确:{range},请使用 PEP 440 版本范围格式,例如 >=2.12.0,<3",
"target": "插件限定的系統版本範圍格式不正確:{range},請使用 PEP 440 版本範圍格式,例如 >=2.12.0,<3"
},
{
"source": "当前 MoviePilot 版本 {version} 无法解析,已拒绝安装带版本限制的插件",
@@ -984,8 +984,8 @@
"target": "恢復{desc}失敗"
},
{
"source": "[PIP] 所有策略均安装依赖失败:{reason}",
"target": "[PIP] 所有策略均安裝依賴失敗:{reason}"
"source": "[UV] 所有策略均安装依赖失败:{reason}",
"target": "[UV] 所有策略均安裝依賴失敗:{reason}"
},
{
"source": "{pid} 未声明 Release 安装,无法安装指定版本",
+21 -4
View File
@@ -8,12 +8,12 @@ from collections.abc import Callable
from pathlib import Path
from typing import Any, Optional
FederatedChangeResolver = Callable[[Path], Optional[tuple[str, Optional[dict], bool]]]
RuntimePluginResolver = Callable[[Path], Optional[str]]
LocalCandidateResolver = Callable[[Path], Optional[dict]]
LocalPluginSync = Callable[[str, Optional[dict]], bool]
PluginReloader = Callable[[str], Any]
DependencyManifestStatus = Callable[[Path], Optional[bool]]
WatchFunction = Callable[..., Any]
@@ -77,6 +77,7 @@ class PluginChangeMonitor:
local_candidate: LocalCandidateResolver,
sync_local: LocalPluginSync,
reload_plugin: PluginReloader,
dependency_manifest_status: DependencyManifestStatus,
watch: WatchFunction,
log: Any,
) -> None:
@@ -90,6 +91,7 @@ class PluginChangeMonitor:
self._local_candidate = local_candidate
self._sync_local = sync_local
self._reload_plugin = reload_plugin
self._dependency_manifest_status = dependency_manifest_status
self._watch = watch
self._logger = log
@@ -120,8 +122,12 @@ class PluginChangeMonitor:
event_path = Path(path_str)
if "__pycache__" in event_path.parts:
continue
if event_path.name == "requirements.txt":
self._handle_requirements_change(event_path)
manifest_status = self._dependency_manifest_status(event_path)
if manifest_status is not None:
self._handle_dependency_manifest_change(
event_path,
active=manifest_status,
)
continue
federated_change = self._federated_change(event_path)
@@ -203,7 +209,12 @@ class PluginChangeMonitor:
exc_info=True,
)
def _handle_requirements_change(self, event_path: Path) -> None:
def _handle_dependency_manifest_change(
self,
event_path: Path,
*,
active: bool,
) -> None:
"""记录依赖文件变化,但不在监控线程中隐式安装依赖。"""
candidate = self._local_candidate(event_path)
if not candidate:
@@ -214,6 +225,12 @@ class PluginChangeMonitor:
f"但跳过处理:{candidate.get('skip_reason')}"
)
return
if not active:
self._logger.debug(
f"检测到本地插件 {candidate.get('id')} 非生效依赖文件变化:"
f"{event_path.name}"
)
return
self._logger.warning(
f"检测到本地插件 {candidate.get('id')} 依赖文件变化,"
"请重新安装本地插件以安装依赖"
+1 -1
View File
@@ -63,7 +63,7 @@ class PluginSyncService:
def install_one(plugin: Any) -> None:
"""安装一个插件并记录结果。"""
started = time.time()
state, message = self._install(plugin.id, plugin.repo_url, True)
state, message = self._install(plugin.id, plugin.repo_url, False)
elapsed = time.time() - started
if state:
self._report(plugin_id=plugin.id, repo_url=plugin.repo_url)
+2
View File
@@ -16,6 +16,7 @@ class PluginSystemServices:
market: Any,
package: Any,
dependency: Any,
dependency_manifest_status: Callable[[Path], Optional[bool]],
compatible_flags: Callable[[Optional[str]], list[str]],
frozen: Callable[[], bool],
) -> None:
@@ -23,6 +24,7 @@ class PluginSystemServices:
self.market = market
self.package = package
self.dependency = dependency
self.dependency_manifest_status = dependency_manifest_status
self.compatible_flags = compatible_flags
self.frozen = frozen
+3
View File
@@ -421,6 +421,9 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
local_candidate=self._get_local_plugin_candidate_from_path,
sync_local=self._sync_local_plugin_if_installed,
reload_plugin=self.reload_plugin,
dependency_manifest_status=(
get_plugin_system().dependency_manifest_status
),
watch=watch,
log=logger,
).run()
+2
View File
@@ -34,6 +34,7 @@ from app.adapters.external.market import (
configure_installed_plugins_provider,
)
from app.adapters.system.plugin.dependency import PluginDependencyInstaller
from app.adapters.system.plugin.manifest import dependency_manifest_status
from app.adapters.system.plugin.package import PluginPackageManager
from app.adapters.system.host import SystemUtils
from app.db.oper.plugindata import PluginDataOper
@@ -82,6 +83,7 @@ def configure_plugin_services() -> None:
) or [],
plugin_dir=Path(settings.ROOT_PATH) / "app" / "plugins",
),
dependency_manifest_status=dependency_manifest_status,
compatible_flags=lambda flag: (
[flag] + VERSION_BACKWARD_COMPATIBLE_FLAGS.get(flag, [])
if flag else []
+12 -16
View File
@@ -1,3 +1,6 @@
FROM ghcr.io/astral-sh/uv:0.12.5@sha256:e85be844203885286c60ffad8a858d48afb6c5a5c237ca0e67f12e74b8f174b1 AS uv
FROM python:3.12.13-slim-bookworm AS base
@@ -92,21 +95,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
jq \
wget
# 安装 Python 构建依赖并创建虚拟环境
# 按锁文件创建主程序虚拟环境
WORKDIR /app
COPY requirements.in requirements.in
COPY scripts/uv-pip-compat.sh /usr/local/bin/uv-pip-compat
RUN python3 -m venv ${VENV_PATH} \
&& env UV_INSTALL_DIR=/usr/local/bin sh -c "$(curl -LsSf https://astral.sh/uv/install.sh)" \
&& chmod +x /usr/local/bin/uv-pip-compat \
&& ln -sf /usr/local/bin/uv ${VENV_PATH}/bin/uv \
&& ln -sf /usr/local/bin/uv-pip-compat ${VENV_PATH}/bin/pip \
&& ln -sf /usr/local/bin/uv-pip-compat ${VENV_PATH}/bin/pip3 \
&& ln -sf /usr/local/bin/uv-pip-compat ${VENV_PATH}/bin/pip3.12 \
&& ln -sf /usr/local/bin/uv-pip-compat ${VENV_PATH}/bin/pip-compile \
&& ln -sf /usr/local/bin/uv-pip-compat ${VENV_PATH}/bin/pip-sync \
&& pip-compile requirements.in -o requirements.txt \
&& pip install -r requirements.txt
COPY --from=uv /uv /usr/local/bin/uv
COPY pyproject.toml uv.lock ./
RUN python3 -m venv --without-pip ${VENV_PATH} \
&& UV_PROJECT_ENVIRONMENT=${VENV_PATH} uv sync \
--locked \
--no-dev \
--no-install-project
# 下载准备代码
FROM prepare_package AS prepare_code
@@ -145,8 +142,7 @@ COPY --from=mwader/static-ffmpeg:8.1.1 /ffprobe /usr/local/bin/
# python 环境
COPY --from=prepare_venv --chmod=777 ${VENV_PATH} ${VENV_PATH}
COPY --from=prepare_venv /usr/local/bin/uv /usr/local/bin/uv
COPY --from=prepare_venv /usr/local/bin/uv-pip-compat /usr/local/bin/uv-pip-compat
COPY --from=uv /uv /usr/local/bin/uv
# 浏览器运行依赖
RUN playwright install-deps chromium \
+51 -5
View File
@@ -35,6 +35,7 @@ function is_truthy_value() {
# 设置虚拟环境路径(兼容群晖等系统必须这样配置)
VENV_PATH="${VENV_PATH:-/opt/venv}"
export PATH="${VENV_PATH}/bin:$PATH"
UV_BIN="${UV_BIN:-/usr/local/bin/uv}"
# 校正设置目录
CONFIG_DIR="${CONFIG_DIR:-/config}"
@@ -42,9 +43,8 @@ CONFIG_DIR="${CONFIG_DIR:-/config}"
function apply_package_cache_env() {
PACKAGE_CACHE_ROOT="${PACKAGE_CACHE_ROOT:-${CONFIG_DIR}/.cache}"
export PACKAGE_CACHE_ROOT
export PIP_CACHE_DIR="${PIP_CACHE_DIR:-${PACKAGE_CACHE_ROOT}/pip}"
export UV_CACHE_DIR="${UV_CACHE_DIR:-${PACKAGE_CACHE_ROOT}/uv}"
mkdir -p "${PIP_CACHE_DIR}" "${UV_CACHE_DIR}"
mkdir -p "${UV_CACHE_DIR}"
}
function run_package_command() {
@@ -346,12 +346,20 @@ function ensure_backend_runtime_dependencies() {
fi
WARN "→ 检测到后端核心依赖异常,开始尝试恢复主程序依赖..."
local -a pip_cmd=("${VENV_PATH}/bin/pip" "install" "-r" "/app/requirements.txt")
local -a uv_cmd=(
"${UV_BIN}" sync
--project /app
--locked
--no-dev
--no-install-project
--inexact
)
if [ -n "${PIP_PROXY}" ]; then
pip_cmd+=("-i" "${PIP_PROXY}")
uv_cmd+=(--default-index "${PIP_PROXY}")
fi
if ! run_package_command "${pip_cmd[@]}" > /dev/stdout 2> /dev/stderr; then
if ! run_package_command env "UV_PROJECT_ENVIRONMENT=${VENV_PATH}" \
"${uv_cmd[@]}" > /dev/stdout 2> /dev/stderr; then
ERROR "→ 自动恢复主程序依赖失败,后端无法启动。"
diagnostic_keepalive 1
fi
@@ -463,6 +471,41 @@ function correct_config_permissions() {
done < <(find "${CONFIG_DIR}" -mindepth 1 -maxdepth 1 -print0)
}
function correct_package_cache_permissions() {
local cache_dir="${UV_CACHE_DIR:-}"
[ -n "${cache_dir}" ] || return 0
if [[ "${cache_dir}" != /* ]]; then
ERROR "→ UV_CACHE_DIR 必须是绝对目录:${cache_dir}"
return 1
fi
local resolved_cache
local resolved_config
resolved_cache="$(python3 -c 'import os, sys; print(os.path.normpath(sys.argv[1]))' "${cache_dir}")"
resolved_config="$(python3 -c 'import os, sys; print(os.path.normpath(sys.argv[1]))' "${CONFIG_DIR}")"
case "${resolved_cache}/" in
"${resolved_config}/"*) return 0 ;;
esac
case "${resolved_cache}" in
/|/app|/public|/opt|/usr|/etc|/var|/home|/root|"${VENV_PATH}")
ERROR "→ UV_CACHE_DIR 不能使用受管根目录:${resolved_cache}"
return 1
;;
esac
if ! mkdir -p -- "${resolved_cache}" \
|| ! chown -R moviepilot:moviepilot "${resolved_cache}"; then
ERROR "→ uv 缓存目录权限修复失败:${resolved_cache}"
return 1
fi
if ! gosu moviepilot:moviepilot sh -c \
'probe="$1/.moviepilot-write-test.$$"; : > "${probe}" && rm -f "${probe}"' \
sh "${resolved_cache}"; then
ERROR "→ uv 缓存目录不可写:${resolved_cache}"
return 1
fi
}
function chown_plugin_runtime_path() {
local plugin_path="${1:-}"
[ -n "${plugin_path}" ] || return 0
@@ -492,6 +535,9 @@ function correct_file_permissions() {
chown_plugin_runtime_path /app/app/plugins
correct_home_permissions
correct_config_permissions
if ! correct_package_cache_permissions; then
return 1
fi
chown -R moviepilot:moviepilot \
/var/lib/nginx \
/var/log/nginx
+56 -44
View File
@@ -23,26 +23,27 @@ function WARN() {
# 设置虚拟环境路径(兼容群晖等系统必须这样配置)
VENV_PATH="${VENV_PATH:-/opt/venv}"
export PATH="${VENV_PATH}/bin:$PATH"
UV_BIN="${UV_BIN:-/usr/local/bin/uv}"
CONFIG_DIR="${CONFIG_DIR:-/config}"
function apply_package_cache_env() {
PACKAGE_CACHE_ROOT="${PACKAGE_CACHE_ROOT:-${CONFIG_DIR}/.cache}"
export PACKAGE_CACHE_ROOT
export PIP_CACHE_DIR="${PIP_CACHE_DIR:-${PACKAGE_CACHE_ROOT}/pip}"
export UV_CACHE_DIR="${UV_CACHE_DIR:-${PACKAGE_CACHE_ROOT}/uv}"
mkdir -p "${PIP_CACHE_DIR}" "${UV_CACHE_DIR}"
mkdir -p "${UV_CACHE_DIR}"
}
apply_package_cache_env
PIP_ENV=()
PACKAGE_ENV=()
UV_OPTIONS=()
MOVIEPILOT_UPDATE_RESULT="noop"
function set_package_proxy_env() {
PIP_ENV=()
PACKAGE_ENV=()
if [[ -n "${PROXY_HOST}" ]]; then
PIP_ENV=(
PACKAGE_ENV=(
"HTTP_PROXY=${PROXY_HOST}"
"HTTPS_PROXY=${PROXY_HOST}"
"http_proxy=${PROXY_HOST}"
@@ -77,6 +78,34 @@ function download_and_unzip() {
fi
}
function sync_project_dependencies() {
INFO "检测到依赖变化,正在更新虚拟环境..."
configure_package_route || return 1
INFO "依赖源:${PACKAGE_LOG}"
local -a uv_cmd=(
"${UV_BIN}" sync
--project "${TMP_PATH}/App"
--locked
--inexact
--no-dev
--no-install-project
--python "${VENV_PATH}/bin/python3"
)
uv_cmd+=("${UV_OPTIONS[@]}")
if ! env "${PACKAGE_ENV[@]}" \
"UV_PROJECT_ENVIRONMENT=${VENV_PATH}" \
"UV_LINK_MODE=copy" "${uv_cmd[@]}"; then
ERROR "依赖同步失败,当前程序依赖未完成更新"
return 1
fi
INFO "依赖更新成功"
}
function dependency_manifests_changed() {
! cmp -s /app/pyproject.toml "${TMP_PATH}/App/pyproject.toml" \
|| ! cmp -s /app/uv.lock "${TMP_PATH}/App/uv.lock"
}
# 下载程序资源,$1: 后端版本路径
function install_backend_and_download_resources() {
# 更新后端程序
@@ -88,28 +117,15 @@ function install_backend_and_download_resources() {
# 检查依赖是否有变化
INFO "→ 检查依赖变化..."
if [ -f "${TMP_PATH}/App/requirements.in" ]; then
if ! cmp -s /app/requirements.in "${TMP_PATH}/App/requirements.in"; then
INFO "检测到依赖变化,正在更新虚拟环境..."
configure_pip_route
INFO "PIP${PIP_LOG}"
local compiled_requirements="${TMP_PATH}/requirements.txt"
if ! env "${PIP_ENV[@]}" ${VENV_PATH}/bin/pip-compile \
"${TMP_PATH}/App/requirements.in" -o "${compiled_requirements}"; then
ERROR "依赖编译失败,当前程序依赖未变更"
return 1
fi
if ! env "${PIP_ENV[@]}" ${VENV_PATH}/bin/pip install ${PIP_OPTIONS} \
-r "${compiled_requirements}"; then
ERROR "依赖安装失败,当前程序依赖清单未变更"
return 1
fi
INFO "依赖更新成功"
if [ -f "${TMP_PATH}/App/pyproject.toml" ] && [ -f "${TMP_PATH}/App/uv.lock" ]; then
if dependency_manifests_changed; then
sync_project_dependencies || return 1
else
INFO "依赖无变化,跳过依赖更新"
fi
else
WARN "未找到requirements.in文件,跳过依赖检查"
ERROR "更新包缺少 pyproject.toml 或 uv.lock,拒绝替换当前程序"
return 1
fi
# 如果是"heads/v3.zip",则查找v3开头的最新版本号
@@ -159,7 +175,6 @@ function install_backend_and_download_resources() {
resource_source_dir=/app/app/application/site
for legacy_resource_dir in /app/app/infrastructure /app/app/adapters/network /app/app/helper; do
if [ ! -d "${resource_source_dir}" ] && [ -d "${legacy_resource_dir}" ]; then
# 升级时允许读取历史目录,恢复目标始终使用 canonical 站点应用目录。
resource_source_dir="${legacy_resource_dir}"
fi
done
@@ -221,19 +236,16 @@ function install_backend_and_download_resources() {
return 0
}
function probe_pip_package() {
function probe_package_index() {
local probe_env=(
"UV_NO_CACHE=1"
"PIP_NO_CACHE_DIR=1"
"UV_HTTP_TIMEOUT=5"
"PIP_DEFAULT_TIMEOUT=5"
"UV_HTTP_RETRIES=0"
"PIP_RETRIES=0"
)
local package_index="${1:-}"
local use_proxy="${2:-false}"
local probe_dir
local -a probe_args=(install)
local -a probe_args=(pip install)
if [[ "${use_proxy}" = "true" ]]; then
probe_env+=(
@@ -247,7 +259,7 @@ function probe_pip_package() {
# 包源探针必须使用独立目标目录,避免修改主程序与插件共享的虚拟环境。
probe_args+=(--target "${probe_dir}" --no-deps)
if [[ -n "${package_index}" ]]; then
probe_args+=(-i "${package_index}")
probe_args+=(--default-index "${package_index}")
fi
probe_args+=(pip-hello-world)
@@ -257,22 +269,22 @@ function probe_pip_package() {
trap 'exit 130' INT
trap 'exit 143' TERM
timeout --kill-after=2s 10s env "${probe_env[@]}" \
"${VENV_PATH}/bin/pip" "${probe_args[@]}" > /dev/null 2>&1
"${UV_BIN}" "${probe_args[@]}" > /dev/null 2>&1
)
}
function test_connectivity_pip() {
function test_connectivity_package() {
case "$1" in
0)
if [[ -n "${PIP_PROXY}" ]]; then
if [[ -n "${PROXY_HOST}" ]]; then
probe_pip_package "${PIP_PROXY}" true
probe_package_index "${PIP_PROXY}" true
else
probe_pip_package "${PIP_PROXY}" false
probe_package_index "${PIP_PROXY}" false
fi
if [[ $? -eq 0 ]]; then
PIP_OPTIONS="-i ${PIP_PROXY}"
PIP_LOG="镜像代理模式"
UV_OPTIONS=(--default-index "${PIP_PROXY}")
PACKAGE_LOG="镜像代理模式"
set_package_proxy_env
return 0
fi
@@ -281,9 +293,9 @@ function test_connectivity_pip() {
;;
1)
if [[ -n "${PROXY_HOST}" ]]; then
if probe_pip_package "" true; then
PIP_OPTIONS=""
PIP_LOG="全局代理模式"
if probe_package_index "" true; then
UV_OPTIONS=()
PACKAGE_LOG="全局代理模式"
set_package_proxy_env
return 0
fi
@@ -291,9 +303,9 @@ function test_connectivity_pip() {
return 1
;;
2)
PIP_ENV=()
PIP_OPTIONS=""
PIP_LOG="不使用代理"
PACKAGE_ENV=()
UV_OPTIONS=()
PACKAGE_LOG="不使用代理"
return 0
;;
esac
@@ -329,10 +341,10 @@ function test_connectivity_github() {
esac
}
function configure_pip_route() {
function configure_package_route() {
local retries=0
while true; do
if test_connectivity_pip "${retries}"; then
if test_connectivity_package "${retries}"; then
return 0
fi
retries=$((retries + 1))
+6 -5
View File
@@ -11,7 +11,7 @@ curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v3/scripts/bootst
脚本会自动:
- 检测操作系统
- 自动检查并尽量安装 `git``curl``Python 3.11+`
- 自动检查并尽量安装 `git``curl``uv 0.12.5``Python 3.12+`
- 克隆 `MoviePilot`
- 安装后端依赖
- 按当前仓库 `version.py` 中的 `FRONTEND_VERSION` 下载对应前端 release 的 `dist.zip`
@@ -24,8 +24,8 @@ curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v3/scripts/bootst
说明:
- 如果系统里已经有可用的 `Python 3.11+`,脚本会优先直接复用本地解释器
- 如果系统里没有可用`Python 3.11+`,脚本会再尝试自动补齐运行环境
- 如果系统里已经有可用的 `Python 3.12+`,脚本会优先直接复用本地解释器
- 如果系统里没有可用解释器,脚本会通过固定版本的 uv 安装 Python 3.12
- Linux 下安装系统依赖时通常需要 `sudo`
- 复用已有仓库时,脚本现在只会因为已跟踪源码改动而阻止自动更新,不会再被 `.DS_Store` 之类未跟踪文件卡住
@@ -156,7 +156,7 @@ moviepilot commands
```shell
moviepilot install deps
moviepilot install deps --python python3.11
moviepilot install deps --python python3.12
moviepilot install deps --venv /path/to/venv
moviepilot install deps --recreate
moviepilot install deps --config-dir /path/to/moviepilot-config
@@ -164,7 +164,8 @@ moviepilot install deps --config-dir /path/to/moviepilot-config
说明:
- 默认会自动选择本地已安装的 `Python 3.11+` 解释器
- 默认会自动选择本地已安装的 `Python 3.12+` 解释器
- 安装器要求 `uv 0.12.5`,并按仓库提交的 `uv.lock` 同步依赖;不会在本地重新解析一套未锁定结果
- `moviepilot_rust` 加速扩展通过 `moviepilot-rust` PyPI 依赖安装,主项目本地安装不需要 Rust toolchain
- 安装完成后可在前端“高级设置 - 实验室”中关闭或重新开启 Rust 加速;如果后端未加载扩展,该开关会保持关闭且不可操作
+64 -54
View File
@@ -6,54 +6,41 @@
在开始之前,请确保您的系统已安装以下软件:
- **Python 3.11 或更高版本**
- **pip** (Python 包管理器)
- **Python 3.12+**
- **uv 0.12.5**Python 版本、虚拟环境和依赖锁定工具)
- **Git** (用于版本控制)
- **RAR 解压工具**:本地开发如需测试或使用 `.rar` 字幕包解压,请安装 `unar``unrar``7z``bsdtar` 之一;Docker 镜像会内置 `unar`
Rust 加速扩展通过 `moviepilot-rust` PyPI 包安装,主项目本地开发不再需要 Rust toolchain。需要修改或发布 Rust 扩展时,请在 `MoviePilot-Rust` 仓库中构建。
### 1. 创建虚拟环境
### 1. 创建锁定环境
在项目根目录下创建并激活虚拟环境
仓库通过 `pyproject.toml` 声明直接依赖,并提交统一的 `uv.lock`。在项目根目录执行
- 在 Windows 上:
```bash
uv sync --locked
```
```bash
python -m venv venv
.\venv\Scripts\activate
```
`uv` 会创建或更新 `.venv`,并安装运行时与默认 `dev` 依赖组。命令中的
`--locked` 会在 `pyproject.toml``uv.lock` 不一致时直接失败,避免开发环境静默解析出一套
未提交的依赖结果。只需要生产运行依赖时使用:
- 在 macOS/Linux 上:
```bash
uv sync --locked --no-dev --no-install-project
```
```bash
python3 -m venv venv
source venv/bin/activate
```
### 2. 依赖分层与事实源
虚拟环境确保项目的依赖项与系统全局环境隔离,防止冲突。
主程序只维护以下依赖事实源:
### 2. 依赖分层与安装
主程序依赖按使用场景分层,避免运行时镜像携带只在开发、测试或构建时需要的工具:
| 文件 | 用途 | 典型安装场景 |
| 位置 | 用途 | 维护方式 |
| --- | --- | --- |
| `requirements.in` | 主程序运行依赖。只放启动、后台任务、插件运行框架和内置功能在生产环境需要导入的包。 | Docker 镜像、CLI 本地运行、运行时依赖自愈。 |
| `requirements-dev.in` | 开发、测试、静态检查和源码构建辅助依赖。 | CI 单测、本地跑测、Pylint、显式源码构建。 |
| `requirements.txt` | 兼容入口,默认只委托到 `requirements.in`。它不是跨平台完整锁文件,不应在本地开发机上直接维护一份平台相关锁定结果。 | 旧脚本、Docker 运行时恢复、CLI 安装入口。 |
| `pyproject.toml``[project].dependencies` | 主程序生产运行依赖。 | 开发者按直接依赖的兼容范围维护。 |
| `pyproject.toml``[dependency-groups].dev` | pytest、覆盖率、Pylint 和源码构建等开发工具。 | 不进入 Docker 生产运行环境。 |
| `uv.lock` | Python 3.12+ 和受支持平台共享的完整解析结果。 | 修改 `pyproject.toml` 后由 `uv lock` 更新并提交。 |
运行主程序只需要安装运行时依赖:
```bash
pip install -r requirements.txt
```
开发、测试、静态检查或执行源码编译时安装开发依赖入口:
```bash
pip install -r requirements-dev.in
```
主程序不再维护 `requirements.in``requirements-dev.in``requirements.txt`,也不生成
平台专属的 requirements 锁文件。Docker、CLI 和 CI 都以提交的 `uv.lock` 为安装输入。
### 2.1 本地启动脚本
@@ -96,10 +83,41 @@ chmod +x scripts/start-local.sh
新增或升级依赖时,先确认依赖属于哪个层级:
1. **运行时依赖**:被 `app/` 生产代码直接导入,或是生产功能、后台任务、插件框架启动必需,写入 `requirements.in`
2. **开发 / 测试 / 静态检查 / 构建依赖**:只用于单测、覆盖率、lint 辅助、源码构建等,不应进入生产运行时,写入 `requirements-dev.in`
3. **工具依赖**`pip-tools``uv``safety` 这类安装或审计工具不属于主程序运行依赖,按脚本或 CI 场景显式安装
4. **插件依赖**:由插件声明并在插件安装阶段处理,不直接并入主程序 `requirements.in`
1. **运行时依赖**:被 `app/` 生产代码直接导入,或是生产功能、后台任务、插件框架启动必需,写入 `[project].dependencies`
2. **开发 / 测试 / 静态检查 / 构建依赖**:只用于单测、覆盖率、lint 辅助、源码构建等,写入 `[dependency-groups].dev`
3. **工具依赖**仓库要求使用 `uv 0.12.5`;不应为了安装工具而把它加入主程序运行依赖
4. **插件依赖**:由插件清单声明并在插件安装阶段处理,不直接并入主程序依赖
修改后更新并校验锁文件:
```bash
uv lock
uv lock --check
uv sync --locked
uv pip check
```
`uv.lock` 同时覆盖 Linux x86_64/arm64、macOS x86_64/arm64 和 Windows x64。统一锁文件只
固定解析结果,不能替代这些平台的真实安装门禁;平台条件依赖变更必须通过对应 CI 环境验证。
### 3.1 插件依赖清单
新插件可以在插件根目录使用 `pyproject.toml`,宿主只读取 `[project].dependencies` 作为运行依赖:
```toml
[project]
name = "example-plugin"
version = "1.0.0"
dependencies = ["example-package>=1,<2"]
```
插件依赖遵循以下合同:
- `pyproject.toml` 优先于历史 `requirements.txt`;两者同时存在时只读取前者;
- `[dependency-groups]` 属于插件自身的开发、测试或构建环境,宿主不安装其中内容;
- `pyproject.toml` 存在但格式或依赖声明无效时直接报错,不回退到 `requirements.txt`
- 仅有 `requirements.txt` 的历史插件继续按原方式安装;
- 宿主不消费插件自己的 `uv.lock`,因为多个插件共享同一主程序环境,不能分别同步独立锁文件。
### 4. 准备资源与插件目录
@@ -131,44 +149,36 @@ python -m scripts.generate_plugin_market_default \
### 5. 运行安全检查
我们使用 `safety` 工具检查依赖项中是否存在已知安全漏洞。更新运行时依赖后,应至少检查运行时入口;更新开发测试依赖时,也应覆盖开发入口。
#### 安装 safety
您可以使用以下命令安装 `safety`
```bash
pip install safety
```
我们使用 `safety` 工具检查 `pyproject.toml``uv.lock` 中是否存在已知安全漏洞。该检查是
依赖变更的人工门禁,当前不属于自动 CI。
#### 执行安全检查
运行以下命令检查运行时入口
可以通过 `uvx` 在隔离工具环境中运行 `safety`,无需把它加入主程序依赖
```bash
safety check -r requirements.txt --policy-file=safety.policy.yml > safety_report.txt
uvx safety scan --target . --policy-file safety.policy.yml
```
这将生成一个名为 `safety_report.txt` 的报告文件,您可以查看其中的漏洞报告并进行相应处理
Safety 直接识别项目清单和锁文件,不需要生成或维护 requirements 文件
### 6. 提交代码前的检查
在提交代码之前,请确保完成以下步骤:
1. **确认依赖分层正确**:运行时包进入 `requirements.in`;测试、覆盖率、静态检查和构建辅助进入 `requirements-dev.in`;插件依赖不并入主程序运行时依赖。
1. **确认依赖分层正确**:运行时包进入 `[project].dependencies`;测试、覆盖率、静态检查和构建辅助进入 `[dependency-groups].dev`;插件依赖不并入主程序运行时依赖。
2. **运行安全检查**:确保 `safety` 检查通过,没有新的安全漏洞。
3. **运行测试**:如果项目中包含测试,请确保所有测试都通过。运行以下命令以执行测试:
```bash
pytest
uv run --locked --no-sync pytest
```
### 7. 参考资源
- [pip-tools 官方文档](https://github.com/jazzband/pip-tools)
- [uv 官方文档](https://docs.astral.sh/uv/)
- [safety 官方文档](https://pyup.io/safety/)
- [Safety CLI 官方文档](https://docs.safetycli.com/)
- [MoviePilot-Resources](https://github.com/jxxghp/MoviePilot-Resources)
- [MoviePilot-Plugins](https://github.com/jxxghp/MoviePilot-Plugins)
@@ -608,7 +608,7 @@ app/application/plugin/folders.py # 插件文件夹清理用例
- `PluginManager()` 仍返回同一实例,`app.sdk.plugins.PluginManager` 身份测试保持。
- 启停、更新、热重载、配置更新、动态路由刷新顺序不变。
- PluginManager 本身不再直接导入 DB、市场 client、pip、压缩包和备份实现;具体安装阶段由 Application command 和注入的包/依赖端口完成。
- PluginManager 本身不再直接导入 DB、市场 client、包管理器、压缩包和备份实现;具体安装阶段由 Application command 和注入的包/依赖端口完成。
- 所有旧公共方法在 V3 保留,内部只做委托。
### 6.9 外部 Adapter 直接持久化并承载业务用例
@@ -619,7 +619,7 @@ app/application/plugin/folders.py # 插件文件夹清理用例
- 市场索引和发布信息请求。
- 插件包下载、解压、校验、备份和恢复。
- requirements 解析、冲突判断、pip 安装与降级策略。
- 插件 `pyproject.toml` / `requirements.txt` 选择、约束判断、uv 安装与降级策略。
- 同步/异步重复实现。
- 市场缓存、旧同步/异步安装入口和旧私有方法兼容。
@@ -1384,7 +1384,7 @@ done_when: []
### 12.2 持续门禁与同职责域细化(阶段 3-5)
- 本轮纳入阶段 3 的写端点不再直接持有数据库事务;当前机器基线中的 endpoint→Session、endpoint→Model、Application→DB 和目标 Adapter/Runtime→DB 边均为 0。后续只允许防止这些边重新引入,不再把历史边数量当作未完成任务。
- PluginManager 不直接做市场、pip、压缩包和备份实现;外部 Adapter 不导入 Oper。
- PluginManager 不直接做市场、包管理、压缩包和备份实现;外部 Adapter 不导入 Oper。
- 重点 Chain 的垂直切片和 `ChainBase` 脱离真实 Runtime 的单测属于同一职责域内的持续细化,不再作为跨层拆分阻塞项。
### 12.3 长期 ABI、性能与实现预算(阶段 6-7)
+14 -11
View File
@@ -4,8 +4,9 @@
| Item | Detail |
|---|---|
| Language | Python 3.11+ |
| CI Python version | Python 3.12 |
| Language | Python 3.12+ |
| Primary CI Python version | Python 3.12 |
| Dependency compatibility CI | Supported platform matrix on Python 3.12, plus newer interpreter coverage on Linux x86_64 |
| Async runtime | asyncio (native), integrated with FastAPI/Uvicorn |
---
@@ -104,11 +105,12 @@
| Item | Detail |
|---|---|
| Runtime source | `requirements.in` — production/runtime dependencies only |
| Dev/test/lint/build source | `requirements-dev.in` — includes runtime plus pytest, coverage tooling, pylint, and build support |
| Compatibility entry | `requirements.txt` — delegates to `requirements.in`; not a committed cross-platform lock |
| Runtime install | `pip install -r requirements.txt` |
| Dev/test/lint/build install | `pip install -r requirements-dev.in` |
| Project metadata | `pyproject.toml` — runtime dependencies in `[project].dependencies`, development tooling in `[dependency-groups].dev` |
| Lock | `uv.lock` — committed resolution for Python 3.12+ and supported platforms |
| Package manager | uv 0.12.5 |
| Runtime install | `uv sync --locked --no-dev --no-install-project` |
| Dev/test/lint/build install | `uv sync --locked` |
| Supported platforms | Linux x86_64/arm64, macOS x86_64/arm64, Windows x64 |
---
@@ -127,9 +129,10 @@
| Tool | Purpose | Command |
|---|---|---|
| pytest | Test runner | `pytest tests/test_xxx.py` |
| pylint | Static analysis | `pylint app/` |
| safety | Dependency vulnerability scan | `safety check -r requirements.txt --policy-file=safety.policy.yml` |
| pytest | Test runner | `uv run --locked --no-sync pytest tests/test_xxx.py` |
| pylint | Static analysis | `uv run --locked --no-sync pylint app/` |
| uv | Lock and environment consistency | `uv lock --check && uv pip check` |
| safety | Manual dependency vulnerability scan | `uvx safety scan --target . --policy-file safety.policy.yml` |
---
@@ -142,4 +145,4 @@
| Frontend | Vue/TypeScript SPA served from `public/`; source in `MoviePilot-Frontend` repo |
| Frontend proxy | Local Node `service.js` proxies `/api` and `/cookiecloud` to the backend |
*Last Updated: 2026-05-25*
*Last Updated: 2026-08-19*
+26 -30
View File
@@ -7,16 +7,11 @@ This document is the project command reference, not an exhaustive shell allowlis
## Development Environment Setup
```bash
# Create and activate virtual environment
python3 -m venv venv
source venv/bin/activate # macOS / Linux
.\venv\Scripts\activate # Windows
# Create the locked development/test environment
uv sync --locked
# Install runtime dependencies
pip install -r requirements.txt
# Install development/test/lint/build dependencies
pip install -r requirements-dev.in
# Create a runtime-only environment
uv sync --locked --no-dev --no-install-project
```
---
@@ -24,17 +19,21 @@ pip install -r requirements-dev.in
## Dependency Management
```bash
# Install runtime dependencies
pip install -r requirements.txt
# Verify that project metadata and lock agree
uv lock --check
# Install test/lint/build dependencies
pip install -r requirements-dev.in
# Update the lock after editing pyproject.toml
uv lock
# Verify installed dependency consistency
uv pip check
```
**Rules:**
- Runtime dependencies belong in `requirements.in`.
- Test, coverage, lint, and explicit build tooling belong in `requirements-dev.in`.
- `requirements.txt` is a compatibility entry that delegates to `requirements.in`; do not replace it with a local cross-platform lock file.
- Runtime dependencies belong in `[project].dependencies` in `pyproject.toml`.
- Test, coverage, lint, and explicit build tooling belong in `[dependency-groups].dev`.
- Commit the updated `uv.lock`; do not maintain or generate main-program requirements files.
- Use uv 0.12.5 and Python 3.12+.
---
@@ -42,16 +41,16 @@ pip install -r requirements-dev.in
```bash
# Run a specific test file
pytest tests/test_xxx.py
uv run --locked --no-sync pytest tests/test_xxx.py
# Run all tests
pytest
uv run --locked --no-sync pytest
# Run tests with verbose output
pytest -v tests/test_xxx.py
uv run --locked --no-sync pytest -v tests/test_xxx.py
# Run a specific test function
pytest tests/test_xxx.py::test_function_name
uv run --locked --no-sync pytest tests/test_xxx.py::test_function_name
```
**Rules:**
@@ -65,10 +64,10 @@ pytest tests/test_xxx.py::test_function_name
```bash
# Run pylint on the application package
pylint app/
uv run --locked --no-sync pylint app/
# Run pylint on a specific module
pylint app/chain/download.py
uv run --locked --no-sync pylint app/chain/download.py
```
**Rules:**
@@ -80,15 +79,12 @@ pylint app/chain/download.py
## Security Scan
```bash
# Run safety check against the runtime compatibility entry
safety check -r requirements.txt --policy-file=safety.policy.yml
# Save report to file
safety check -r requirements.txt --policy-file=safety.policy.yml > safety_report.txt
# Scan pyproject.toml and uv.lock
uvx safety scan --target . --policy-file=safety.policy.yml
```
**Rules:**
- Run after runtime dependency changes; include `requirements-dev.in` when development/test/lint/build dependencies change.
- Run manually after runtime or development dependency changes; this is not currently an automated CI job.
- No new high-severity vulnerabilities may be introduced.
---
@@ -132,7 +128,7 @@ curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v3/scripts/bootst
# Install backend dependencies
moviepilot install deps
moviepilot install deps --python python3.11
moviepilot install deps --python python3.12
moviepilot install deps --venv /path/to/venv
moviepilot install deps --recreate
@@ -307,4 +303,4 @@ python -m scripts.generate_plugin_market_default \
- The marked list must be nonempty and include `jxxghp/MoviePilot-Plugins`.
- This command rewrites only `ConfigModel.PLUGIN_MARKET`; inspect the resulting diff before committing or packaging.
*Last Updated: 2026-08-06*
*Last Updated: 2026-08-19*
+3 -3
View File
@@ -11,7 +11,7 @@
## Python Version and Typing
- Target: **Python 3.11+**. CI runs Python 3.12.
- Target: **Python 3.12+**. Python 3.12 is the primary CI version; compatibility CI also verifies newer interpreters.
- **Type annotations are required** on all public methods and function signatures.
- Use `Optional[X]` for nullable types (do not use `X | None` — keep consistency with the existing codebase style).
- Use `Union[X, Y]` for multi-type parameters.
@@ -114,11 +114,11 @@ except:
## What Not To Do
- Do not introduce new third-party libraries without placing them in the correct dependency entry: runtime packages in `requirements.in`, test/lint/build tooling in `requirements-dev.in`.
- Do not introduce new third-party libraries without placing them in the correct `pyproject.toml` dependency group and updating `uv.lock`: runtime packages belong in `[project].dependencies`, test/lint/build tooling in `[dependency-groups].dev`.
- Do not use `requests` or `httpx` directly for external HTTP calls - host code uses `RequestUtils` from `app/adapters/network/http.py`; plugins use `app.sdk.network`.
- Do not issue raw SQLAlchemy queries from chains, modules, or endpoints — use the Oper classes in `app/db/oper/`.
- Do not add TODO or FIXME without context. Only keep one if it is genuinely deferred and cannot be addressed in the current task.
- Do not add noisy markers like `# change starts here`, `# important`, or `# this is a fix`.
- Do not write comments that restate what the code already clearly says.
*Last Updated: 2026-08-14*
*Last Updated: 2026-08-19*
+7 -7
View File
@@ -6,10 +6,10 @@
```bash
# Minimum: run tests directly related to the change
pytest tests/test_<domain>.py
uv run --locked --no-sync pytest tests/test_<domain>.py
# If the change affects common modules, startup flow, CLI, or agent runtime
pytest
uv run --locked --no-sync pytest
```
### When to Expand Scope
@@ -42,7 +42,7 @@ Run the full test suite when changing:
## Static Analysis
```bash
pylint app/
uv run --locked --no-sync pylint app/
```
- After any Python code change, ensure no new **error-level** pylint issues are introduced.
@@ -54,10 +54,10 @@ pylint app/
## Dependency Security Scan
```bash
safety check -r requirements.txt --policy-file=safety.policy.yml
uvx safety scan --target . --policy-file safety.policy.yml
```
- Run after runtime dependency changes; scan the development dependency entry as well when `requirements-dev.in` changes.
- Run manually after runtime or development dependency changes; Safety scans `pyproject.toml` and `uv.lock` directly, and this check is not currently an automated CI job.
- No new high-severity vulnerabilities may be introduced.
- If a vulnerability cannot be patched immediately, document it explicitly in the PR description.
@@ -131,11 +131,11 @@ Before marking any task as complete:
- [ ] Related pytest tests pass
- [ ] No new pylint error-level issues in `pylint app/`
- [ ] If dependencies changed: the package is in the correct runtime or dev dependency entry, and `safety check` passes for the affected entry
- [ ] If dependencies changed: the package is in the correct `pyproject.toml` group, `uv.lock` is current, locked sync and `uv pip check` pass, and the manual Safety scan passes
- [ ] If CLI behavior changed: `docs/cli.md` and related tests are updated
- [ ] If MCP/API behavior changed: `docs/mcp-api.md` and related skill files are updated
- [ ] If database schema changed: a new Alembic migration exists under `database/versions/`
- [ ] No secrets are included in code, logs, or committed files
- [ ] Public or cross-module contracts and non-obvious business behavior have useful Chinese documentation
*Last Updated: 2026-08-13*
*Last Updated: 2026-08-19*
@@ -101,10 +101,10 @@ ci: improve docker build cache
When updating a dependency:
1. Decide the dependency layer: runtime packages go to `requirements.in`; test, coverage, lint, and explicit build tooling go to `requirements-dev.in`.
2. Keep `requirements.txt` as the compatibility entry that delegates to `requirements.in`; do not commit a locally generated cross-platform lock file.
3. Run `safety check -r requirements.txt --policy-file=safety.policy.yml`; include the dev dependency entry when `requirements-dev.in` changed.
4. Run the full test suite: `pytest`.
1. Decide the dependency layer: runtime packages go to `[project].dependencies`; test, coverage, lint, and explicit build tooling go to `[dependency-groups].dev`.
2. Run `uv lock`, commit the updated `uv.lock`, and verify it with `uv lock --check`.
3. Run `uv sync --locked`, `uv pip check`, and the manual `uvx safety scan --target . --policy-file safety.policy.yml` check.
4. Run the full test suite: `uv run --locked --no-sync pytest`.
---
@@ -120,4 +120,4 @@ moviepilot update frontend
Bootstrap installer changes live in `scripts/bootstrap-local.sh`. Only modify this script if the task explicitly involves the bootstrap flow.
*Last Updated: 2026-05-25*
*Last Updated: 2026-08-19*
+1 -1
View File
@@ -29,7 +29,7 @@ chmod +x moviepilot-site-collector-linux
当前自动构建产物尚未接入 Windows 或 Apple 代码签名。Windows SmartScreen 或 macOS Gatekeeper 可能因此显示安全提示。仅在文件来自 MoviePilot 官方 GitHub Release,且校验摘要一致时运行;不要从聊天、网盘或第三方站点接收采集器。
如果系统阻止运行,可改用随 MoviePilot 源码提供的本地采集脚本;该方式需要 Python 3.11 及完整后端依赖,不适合作为普通用户的首选路径。
如果系统阻止运行,可改用随 MoviePilot 源码提供的本地采集脚本;该方式需要 Python 3.12+ 及完整后端依赖,不适合作为普通用户的首选路径。
## 维护者发布流程
+9 -8
View File
@@ -7,15 +7,16 @@
pytest 是唯一运行入口。`tests/conftest.py` 在收集前完成隔离引导,因此任何方式启动 pytest 都会自动隔离。
```bash
pytest tests # 全量
pytest tests/test_xxx.py # 单文件
pytest tests/test_xxx.py::SomeTest::test_y # 单用例
python tests/run.py # 等价于 pytest 全量(参数透传)
uv run --locked --no-sync pytest tests # 全量
uv run --locked --no-sync pytest tests/test_xxx.py # 单文件
uv run --locked --no-sync pytest tests/test_xxx.py::SomeTest::test_y # 单用例
uv run --locked --no-sync python tests/run.py # 等价于 pytest 全量(参数透传)
```
- 不再使用 `python -m unittest discover`:它不导入 `tests` 包、收不到纯函数用例,且绕过 `conftest.py` 的隔离。
- 不再依赖 `python tests/test_xxx.py` 直跑:所有 `if __name__ == "__main__": unittest.main()` 尾巴已移除。
- **复现 CI 用干净环境**建议用一个仅 `pip install -r requirements-dev.in` 的虚拟环境运行,避免本地额外包或编译产物掩盖问题。
- **复现 CI 用干净环境**使用 `uv sync --locked``uv.lock` 创建环境,再以
`uv run --locked --no-sync` 运行测试,避免本地额外包、未锁定解析结果或编译产物掩盖问题。
## 隔离模型(`tests/conftest.py`
@@ -138,6 +139,6 @@ def test_recognize_prefers_explicit_identity(sample_meta, monkeypatch):
## CI 与 PR
- **门禁**`.github/workflows/test.yml` 在指向 `v3``pull_request` / `push` 及手动触发时, `python tests/run.py` 跑全量单测。
- **PR**:产品代码、测试基础设施、依赖或运行行为发生变化时,运行 `python tests/run.py`,确认本次改动涉及的路径通过且 socket 探针零真实出站。若存在无关失败,必须在当前 `upstream/v3` 基线上独立复现并在 PR 中如实说明;不得静默扩大当前 PR 去修复基线问题。纯文档变更按实际内容执行文本、结构和 diff 检查,CI 仍会运行全量门禁。
- 复现 CI 用仅安装 `requirements-dev.in` 的干净环境;`requirements.in` 只承载运行时依赖,pytest 与覆盖率插件由开发依赖入口提供
- **门禁**`.github/workflows/test.yml` 在指向 `v3``pull_request` / `push` 及手动触发时, `uv.lock` 同步环境并用 `tests/run.py` 跑全量单测。
- **PR**:产品代码、测试基础设施、依赖或运行行为发生变化时,运行 `uv run --locked --no-sync python tests/run.py`,确认本次改动涉及的路径通过且 socket 探针零真实出站。若存在无关失败,必须在当前 `upstream/v3` 基线上独立复现并在 PR 中如实说明;不得静默扩大当前 PR 去修复基线问题。纯文档变更按实际内容执行文本、结构和 diff 检查,CI 仍会运行全量门禁。
- 复现 CI 使用 `uv sync --locked`;主程序运行依赖位于 `[project].dependencies`pytest 与覆盖率工具位于默认 `dev` 依赖组
+156 -26
View File
@@ -104,7 +104,7 @@ Usage:
Options:
deps:
--python PYTHON 用于创建虚拟环境的 Python 解释器,默认自动选择本地 3.11+ 版本
--python PYTHON 用于创建虚拟环境的 Python 解释器,默认自动选择本地 3.12+ 版本
--venv PATH 虚拟环境目录,默认 ./venv
--recreate 删除并重建虚拟环境
--config-dir PATH 指定配置目录
@@ -144,7 +144,7 @@ show_setup_help() {
Usage: moviepilot setup [OPTIONS]
Options:
--python PYTHON 用于创建虚拟环境的 Python 解释器,默认自动选择本地 3.11+ 版本
--python PYTHON 用于创建虚拟环境的 Python 解释器,默认自动选择本地 3.12+ 版本
--venv PATH 虚拟环境目录,默认 ./venv
--recreate 删除并重建虚拟环境
--frontend-version TAG 前端版本,默认使用 version.py 中的 FRONTEND_VERSION
@@ -190,7 +190,7 @@ Options:
--ref REF 后端 Git 版本,默认 latest
--frontend-version TAG 前端版本,默认使用 version.py 中的 FRONTEND_VERSION
--node-version VER 本地 Node 运行时版本,默认 20.12.1
--python PYTHON 用于安装后端依赖的 Python 解释器,默认自动选择本地 3.11+ 版本
--python PYTHON 用于安装后端依赖的 Python 解释器,默认自动选择本地 3.12+ 版本
--venv PATH 虚拟环境目录,默认 ./venv
--recreate 删除并重建虚拟环境
--skip-resources 更新 all 时跳过资源同步
@@ -258,60 +258,184 @@ python_version_ok() {
local python_bin="$1"
"$python_bin" - <<'PY' >/dev/null 2>&1
import sys
raise SystemExit(0 if sys.version_info >= (3, 11) else 1)
raise SystemExit(0 if sys.version_info >= (3, 12) else 1)
PY
}
try_python_candidate() {
local candidate="$1"
local python_path=""
local path_entry
local python_path
local found=1
python_path="$(command -v "$candidate" 2>/dev/null || true)"
if [ -n "$python_path" ] && python_version_ok "$python_path"; then
printf '%s\n' "$python_path"
return 0
if [[ "$candidate" == */* ]]; then
if [ -x "$candidate" ] && python_version_ok "$candidate"; then
printf '%s\n' "$candidate"
return 0
fi
return 1
fi
return 1
local -a path_entries
IFS=: read -r -a path_entries <<< "${PATH:-}"
for path_entry in "${path_entries[@]}"; do
[ -n "$path_entry" ] || path_entry="."
python_path="$path_entry/$candidate"
if [ -x "$python_path" ] && [ ! -d "$python_path" ] && python_version_ok "$python_path"; then
printf '%s\n' "$python_path"
found=0
fi
done
return "$found"
}
find_system_python() {
find_system_python_candidates() {
local minor
local uv_bin
local uv_python
for minor in 20 19 18 17 16 15 14 13 12 11; do
for minor in 20 19 18 17 16 15 14 13 12; do
if try_python_candidate "python3.$minor"; then
return 0
:
fi
done
if try_python_candidate python3; then
return 0
:
fi
if try_python_candidate python; then
return 0
:
fi
for uv_bin in "$(command -v uv 2>/dev/null || true)" "$HOME/.local/bin/uv"; do
if [ -n "$uv_bin" ] && [ -x "$uv_bin" ]; then
for minor in 20 19 18 17 16 15 14 13 12 11; do
for minor in 20 19 18 17 16 15 14 13 12; do
uv_python="$("$uv_bin" python find "3.$minor" 2>/dev/null || true)"
if [ -n "$uv_python" ] && python_version_ok "$uv_python"; then
printf '%s\n' "$uv_python"
return 0
fi
done
fi
done
}
resolve_target_venv() {
local target_venv="${1:-$ROOT/venv}"
local target_parent
if [[ "$target_venv" != /* ]]; then
target_venv="$ROOT/$target_venv"
fi
if [ -d "$target_venv" ]; then
target_venv="$(cd -P "$target_venv" 2>/dev/null && pwd || true)"
else
target_parent="$(cd -P "$(dirname "$target_venv")" 2>/dev/null && pwd || true)"
if [ -n "$target_parent" ]; then
target_venv="$target_parent/$(basename "$target_venv")"
fi
fi
printf '%s\n' "$target_venv"
}
canonicalize_executable_path() {
local candidate="$1"
local candidate_dir
candidate_dir="$(cd -P "$(dirname "$candidate")" 2>/dev/null && pwd || true)"
if [ -n "$candidate_dir" ]; then
printf '%s/%s\n' "$candidate_dir" "$(basename "$candidate")"
fi
}
path_is_in_target_venv() {
local candidate="$1"
local target_venv="$2"
local candidate_path
candidate_path="$(canonicalize_executable_path "$candidate")"
target_venv="$(resolve_target_venv "$target_venv")"
[ -n "$candidate_path" ] && [ -n "$target_venv" ] \
&& [[ "$candidate_path" == "$target_venv"/* ]]
}
find_external_python() {
local candidates
local candidate
local target_venv
target_venv="$(resolve_target_venv "${1:-$ROOT/venv}")"
candidates="$(find_system_python_candidates || true)"
while IFS= read -r candidate; do
[ -n "$candidate" ] || continue
candidate="$(canonicalize_executable_path "$candidate")"
if path_is_in_target_venv "$candidate" "$target_venv"; then
continue
fi
printf '%s\n' "$candidate"
return 0
done <<< "$candidates"
return 1
}
require_bootstrap_python() {
if [ -n "$BOOTSTRAP_PYTHON" ]; then
local python_bin="${1:-$BOOTSTRAP_PYTHON}"
if [ -n "$python_bin" ]; then
return 0
fi
echo "未找到可用的 Python 3.11+ 解释器,请先安装 Python 3.11 或更高版本" >&2
echo "未找到可用的 Python 3.12+ 解释器,请先安装 Python 3.12 或更高版本" >&2
exit 1
}
bootstrap_python_for_args() {
local explicit_python=""
local recreate=false
local target_venv="$ROOT/venv"
while [ "$#" -gt 0 ]; do
case "$1" in
--recreate)
recreate=true
;;
--python)
if [ "$#" -gt 1 ]; then
explicit_python="$2"
shift
fi
;;
--python=*)
explicit_python="${1#--python=}"
;;
--venv)
if [ "$#" -gt 1 ]; then
target_venv="$2"
shift
fi
;;
--venv=*)
target_venv="${1#--venv=}"
;;
esac
shift
done
if [ "$recreate" = true ]; then
if [ -n "$explicit_python" ]; then
local explicit_candidates
local resolved_python
explicit_candidates="$(try_python_candidate "$explicit_python" || true)"
while IFS= read -r resolved_python; do
[ -n "$resolved_python" ] || continue
resolved_python="$(canonicalize_executable_path "$resolved_python")"
if ! path_is_in_target_venv "$resolved_python" "$target_venv"; then
printf '%s\n' "$resolved_python"
return 0
fi
done <<< "$explicit_candidates"
fi
local external_python
external_python="$(find_external_python "$target_venv" || true)"
if [ -z "$external_python" ]; then
echo "重建虚拟环境需要 venv 外部的 Python 3.12+ 解释器" >&2
return 1
fi
printf '%s\n' "$external_python"
return 0
fi
printf '%s\n' "$BOOTSTRAP_PYTHON"
}
default_config_dir() {
case "$(uname -s)" in
Darwin)
@@ -431,10 +555,11 @@ fi
export CONFIG_DIR
BOOTSTRAP_PYTHON=""
EXTERNAL_BOOTSTRAP_PYTHON="$(find_external_python || true)"
if [ -x "$VENV_PYTHON" ]; then
BOOTSTRAP_PYTHON="$VENV_PYTHON"
else
BOOTSTRAP_PYTHON="$(find_system_python || true)"
BOOTSTRAP_PYTHON="$EXTERNAL_BOOTSTRAP_PYTHON"
fi
cd "$ROOT"
@@ -454,18 +579,21 @@ case "${1:-}" in
;;
install)
shift
require_bootstrap_python
case "${1:-}" in
deps)
shift
exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" install-deps "$@"
INSTALL_DEPS_PYTHON="$(bootstrap_python_for_args "$@")" || exit 1
require_bootstrap_python "$INSTALL_DEPS_PYTHON"
exec "$INSTALL_DEPS_PYTHON" "$SETUP_SCRIPT" install-deps "$@"
;;
frontend)
shift
require_bootstrap_python
exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" install-frontend "$@"
;;
resources)
shift
require_bootstrap_python
exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" install-resources "$@"
;;
*)
@@ -481,8 +609,9 @@ case "${1:-}" in
;;
setup)
shift
require_bootstrap_python
exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" setup "$@"
SETUP_PYTHON="$(bootstrap_python_for_args "$@")" || exit 1
require_bootstrap_python "$SETUP_PYTHON"
exec "$SETUP_PYTHON" "$SETUP_SCRIPT" setup "$@"
;;
uninstall)
shift
@@ -492,8 +621,9 @@ case "${1:-}" in
;;
update)
shift
require_bootstrap_python
exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" update "$@"
UPDATE_PYTHON="$(bootstrap_python_for_args "$@")" || exit 1
require_bootstrap_python "$UPDATE_PYTHON"
exec "$UPDATE_PYTHON" "$SETUP_SCRIPT" update "$@"
;;
startup)
shift
+129
View File
@@ -0,0 +1,129 @@
[project]
name = "moviepilot"
# MoviePilot 不作为 Python 包发布,产品版本继续由 version.py 管理。
version = "0"
requires-python = ">=3.12"
dependencies = [
"aiofiles~=25.1.0",
"aioshutil~=1.6",
"aiosqlite~=0.22.1",
"alembic~=1.18.4",
"anitopy~=2.1.1",
"anthropic~=0.109.1",
"anyio~=4.13.0",
"apscheduler~=3.11.2",
"asyncpg~=0.31.0",
"bcrypt~=4.3.0",
"beautifulsoup4~=4.15.0",
"boto3~=1.42.42",
"cachetools~=7.1.4",
"chardet~=7.4.3",
"click~=8.4.1",
"cloakbrowser~=0.5.3",
"cn2an~=0.5.24",
"cryptography~=49.0.0",
"dateparser~=1.4.0",
"ddgs~=9.14.4",
"discord.py==2.7.1",
"docker~=7.1.0",
"fast-bencode~=1.1.8",
"fastapi~=0.136.3",
"google-genai~=2.8.0",
"httpx[http2,socks]~=0.28.1",
"jieba-next~=1.0.0rc1",
"jinja2~=3.1.6",
"langchain~=1.3.15",
"langchain-anthropic~=1.4.6",
"langchain-aws~=1.6.2",
"langchain-community~=0.4.2",
"langchain-core~=1.5.4",
"langchain-deepseek~=1.1.0",
"langchain-google-genai~=4.2.5",
"langchain-openai~=1.3.2",
"langgraph~=1.2.11",
"langgraph-checkpoint~=4.2.0",
"lark-oapi~=1.6.8",
"lxml~=6.1.1",
"moviepilot-rust~=0.2.8",
"mutagen~=1.47.0",
"openai~=2.41.1",
"oss2~=2.19.1",
"packaging~=26.2",
"parse~=1.22.1",
"pillow~=12.2.0",
"pillow-avif-plugin~=1.5.5",
"pinyin2hanzi~=0.1.1",
"plexapi~=4.18.1",
"psutil~=7.2.2",
"psycopg2-binary~=2.9.12",
"pycryptodome~=3.23.0",
"pydantic>=2.13.4,<3.0.0",
"pydantic-settings>=2.14.2,<3.0.0",
"pyjwt~=2.13.0",
"pympler~=1.1",
"pyotp~=2.9.0",
"pyparsing~=3.3.2",
"pyquery~=2.0.1",
"pystray~=0.19.5",
"pytelegrambotapi~=4.34.0",
"python-dateutil~=2.9.0.post0",
"python-dotenv~=1.2.2",
"python-multipart~=0.0.32",
"pytz~=2026.2",
"pyvirtualdisplay~=3.0",
"pywebpush~=2.3.0",
"pywin32==312 ; sys_platform == 'win32'",
"pyyaml~=6.0.3",
"qbittorrent-api==2026.6.0",
"redis~=8.0.0",
"regex~=2026.5.9",
"requests[socks]~=2.34.2",
"rsa~=4.9.1",
"ruamel-yaml~=0.19.1",
"setproctitle~=1.3.7",
"setuptools~=82.0.1",
"slack-bolt~=1.28.0",
"slack-sdk~=3.42.0",
"smbprotocol~=1.16.1",
"sqlalchemy~=2.0.50",
"starlette~=1.3.1",
"telegramify-markdown~=1.2.0",
"torrentool~=1.2.0",
"tqdm~=4.68.2",
"transmission-rpc~=7.0.11",
"urllib3~=2.7.0",
"uvicorn~=0.49.0",
"watchdog~=6.0.0",
"watchfiles~=1.2.0",
"webauthn~=2.8.0",
"websocket-client~=1.9.0",
"zhconv-rs~=0.4.1",
]
[dependency-groups]
dev = [
"cython~=3.2.5",
"pylint~=4.0.6",
"pytest~=9.0.3",
"pytest-asyncio~=1.4.0",
"pytest-cov~=7.1.0",
"pytest-timeout~=2.4.0",
]
[tool.uv]
package = false
required-version = "==0.12.5"
environments = [
"sys_platform == 'linux' and platform_machine == 'x86_64'",
"sys_platform == 'linux' and platform_machine == 'aarch64'",
"sys_platform == 'darwin' and platform_machine == 'x86_64'",
"sys_platform == 'darwin' and platform_machine == 'arm64'",
"sys_platform == 'win32' and platform_machine == 'AMD64'",
]
required-environments = [
"sys_platform == 'linux' and platform_machine == 'x86_64'",
"sys_platform == 'linux' and platform_machine == 'aarch64'",
"sys_platform == 'darwin' and platform_machine == 'x86_64'",
"sys_platform == 'darwin' and platform_machine == 'arm64'",
"sys_platform == 'win32' and platform_machine == 'AMD64'",
]
-9
View File
@@ -1,9 +0,0 @@
-r requirements.in
Cython~=3.2.5
pylint~=4.0.6
pytest~=9.0.3
pytest-asyncio~=1.4.0
pytest-cov~=7.1.0
pytest-timeout~=2.4.0
uv~=0.11.23
-95
View File
@@ -1,95 +0,0 @@
moviepilot-rust~=0.2.8
pydantic>=2.13.4,<3.0.0
pydantic-settings>=2.14.2,<3.0.0
SQLAlchemy~=2.0.50
uvicorn~=0.49.0
fastapi~=0.136.3
PyJWT~=2.13.0
python-multipart~=0.0.32
aiofiles~=25.1.0
aioshutil~=1.6
alembic~=1.18.4
anyio~=4.13.0
bcrypt~=4.3.0
regex~=2026.5.9
cn2an~=0.5.24
dateparser~=1.4.0
python-dateutil~=2.9.0.post0
zhconv-rs~=0.4.1
anitopy~=2.1.1
requests[socks]~=2.34.2
urllib3~=2.7.0
lxml~=6.1.1
pyquery~=2.0.1
ruamel.yaml~=0.19.1
PyYAML~=6.0.3
APScheduler~=3.11.2
cryptography~=49.0.0
pytz~=2026.2
pycryptodome~=3.23.0
qbittorrent-api==2026.6.0
plexapi~=4.18.1
transmission-rpc~=7.0.11
Jinja2~=3.1.6
mutagen~=1.47.0
pyparsing~=3.3.2
beautifulsoup4~=4.15.0
pillow~=12.2.0
pillow-avif-plugin~=1.5.5
pyTelegramBotAPI~=4.34.0
telegramify-markdown~=1.2.0
cloakbrowser~=0.5.3
torrentool~=1.2.0
fast-bencode~=1.1.8
slack-bolt~=1.28.0
slack-sdk~=3.42.0
discord.py==2.7.1
chardet~=7.4.3
starlette~=1.3.1
PyVirtualDisplay~=3.0
psutil~=7.2.2
python-dotenv~=1.2.2
watchfiles~=1.2.0
watchdog~=6.0.0
click~=8.4.1
parse~=1.22.1
docker~=7.1.0
pywin32==312; platform_system == "Windows"
cachetools~=7.1.4
pystray~=0.19.5
pyotp~=2.9.0
webauthn~=2.8.0
Pinyin2Hanzi~=0.1.1
pywebpush~=2.3.0
aiosqlite~=0.22.1
psycopg2-binary~=2.9.12
asyncpg~=0.31.0
jieba-next~=1.0.0rc1
rsa~=4.9.1
redis~=8.0.0
async_timeout~=5.0.1; python_full_version < "3.11.3"
packaging~=26.2
oss2~=2.19.1
tqdm~=4.68.2
setuptools~=82.0.1
pympler~=1.1
smbprotocol~=1.16.1
setproctitle~=1.3.7
httpx[socks,http2]~=0.28.1
langchain~=1.3.15
langchain-core~=1.5.4
langchain-community~=0.4.2
langchain-anthropic~=1.4.6
langchain-aws~=1.6.2
boto3~=1.42.42
langchain-openai~=1.3.2
langchain-google-genai~=4.2.5
langchain-deepseek~=1.1.0
langgraph~=1.2.11
langgraph-checkpoint~=4.2.0
anthropic~=0.109.1
openai~=2.41.1
google-genai~=2.8.0
ddgs~=9.14.4
websocket-client~=1.9.0
lark-oapi~=1.6.8
-1
View File
@@ -1 +0,0 @@
-r requirements.in
+20 -17
View File
@@ -16,6 +16,7 @@ SUPERUSER=""
SUPERUSER_PASSWORD=""
OS_NAME="Unknown"
PYTHON_BIN=""
UV_VERSION="0.12.5"
BREW_BIN=""
PACKAGE_MANAGER=""
PACKAGE_INDEX_UPDATED="false"
@@ -177,7 +178,7 @@ python_version_ok() {
local python_bin="$1"
"$python_bin" - <<'PY' >/dev/null 2>&1
import sys
raise SystemExit(0 if sys.version_info >= (3, 11) else 1)
raise SystemExit(0 if sys.version_info >= (3, 12) else 1)
PY
}
@@ -195,7 +196,7 @@ try_python_candidate() {
find_python() {
local minor=""
for minor in 20 19 18 17 16 15 14 13 12 11; do
for minor in 20 19 18 17 16 15 14 13 12; do
if try_python_candidate "python3.$minor"; then
return 0
fi
@@ -227,20 +228,20 @@ find_uv_python() {
python_install_hint() {
case "$OS_NAME" in
macOS)
echo "脚本已尝试自动安装 Git、curl 和 Python 3.11+。" >&2
echo "如果自动安装失败,请先安装 Homebrew,或手动执行:brew install git curl python@3.11" >&2
echo "脚本已尝试自动安装 Git、curl 和 Python 3.12+。" >&2
echo "如果自动安装失败,请先安装 Homebrew,或手动执行:brew install git curl python@3.12" >&2
;;
Linux*)
echo "脚本已尝试自动安装 Git、curl 和 Python 3.11+。" >&2
echo "如果自动安装失败,请先安装 Git、curl、Python 3.11+,并确保包含 venv 模块。" >&2
echo "例如 Debian/Ubuntu: sudo apt install git curl python3.11 python3.11-venv" >&2
echo "例如 Fedora/RHEL: sudo dnf install git curl python3.11" >&2
echo "脚本已尝试自动安装 Git、curl 和 Python 3.12+。" >&2
echo "如果自动安装失败,请先安装 Git、curl、Python 3.12+。" >&2
echo "例如 Debian/Ubuntu: sudo apt install git curl python3.12" >&2
echo "例如 Fedora/RHEL: sudo dnf install git curl python3.12" >&2
;;
Windows)
echo "推荐在 WSL、Linux 或 macOS 终端中运行此脚本。" >&2
;;
*)
echo "请先安装 Git、curl、Python 3.11 或更高版本。" >&2
echo "请先安装 Git、curl、Python 3.12+。" >&2
;;
esac
}
@@ -384,17 +385,19 @@ ensure_base_tools() {
}
ensure_uv() {
if command -v uv >/dev/null 2>&1; then
if command -v uv >/dev/null 2>&1 \
&& [[ "$(uv --version 2>/dev/null | awk '{print $2}')" == "${UV_VERSION}" ]]; then
return 0
fi
echo "==> 自动安装 uv,用于补齐 Python 3.11+ 运行时"
env UV_INSTALL_DIR="$HOME/.local/bin" sh -c "$(curl -LsSf https://astral.sh/uv/install.sh)"
echo "==> 自动安装 uv ${UV_VERSION}"
env UV_INSTALL_DIR="$HOME/.local/bin" sh -c "$(curl -LsSf "https://astral.sh/uv/${UV_VERSION}/install.sh")"
export PATH="$HOME/.local/bin:$PATH"
hash -r
if ! command -v uv >/dev/null 2>&1; then
echo "uv 安装失败,无法继续自动安装 Python。" >&2
if ! command -v uv >/dev/null 2>&1 \
|| [[ "$(uv --version 2>/dev/null | awk '{print $2}')" != "${UV_VERSION}" ]]; then
echo "uv ${UV_VERSION} 安装失败,无法继续自动安装 Python。" >&2
return 1
fi
}
@@ -412,11 +415,11 @@ ensure_python() {
return 0
fi
echo "==> 未找到可用的 Python 3.11+,开始自动安装独立 Python 运行时"
uv python install 3.11
echo "==> 未找到可用的 Python 3.12+,开始自动安装 Python 3.12"
uv python install 3.12
PYTHON_BIN="$(find_uv_python "$(command -v uv)" || true)"
if [[ -z "$PYTHON_BIN" ]] || ! python_version_ok "$PYTHON_BIN"; then
echo "自动安装 Python 3.11+ 失败。" >&2
echo "自动安装 Python 3.12 失败。" >&2
return 1
fi
}
+76 -95
View File
@@ -5,22 +5,22 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "${TMP_DIR}"' EXIT
mkdir -p "${TMP_DIR}/venv/bin" "${TMP_DIR}/config"
mkdir -p "${TMP_DIR}/bin" "${TMP_DIR}/venv/bin" "${TMP_DIR}/config"
cat > "${TMP_DIR}/venv/bin/pip" <<'SH'
cat > "${TMP_DIR}/bin/uv" <<'SH'
#!/usr/bin/env bash
printf 'argv=%s\n' "$*" >> "${MP_FAKE_PIP_LOG}"
printf 'HTTP_PROXY=%s\n' "${HTTP_PROXY:-}" >> "${MP_FAKE_PIP_LOG}"
printf 'HTTPS_PROXY=%s\n' "${HTTPS_PROXY:-}" >> "${MP_FAKE_PIP_LOG}"
printf 'PACKAGE_CACHE_ROOT=%s\n' "${PACKAGE_CACHE_ROOT:-}" >> "${MP_FAKE_PIP_LOG}"
printf 'PIP_CACHE_DIR=%s\n' "${PIP_CACHE_DIR:-}" >> "${MP_FAKE_PIP_LOG}"
printf 'UV_CACHE_DIR=%s\n' "${UV_CACHE_DIR:-}" >> "${MP_FAKE_PIP_LOG}"
if [ "${MP_FAKE_PIP_FAIL:-}" = "1" ]; then
printf 'argv=%s\n' "$*" >> "${MP_FAKE_UV_LOG}"
printf 'HTTP_PROXY=%s\n' "${HTTP_PROXY:-}" >> "${MP_FAKE_UV_LOG}"
printf 'HTTPS_PROXY=%s\n' "${HTTPS_PROXY:-}" >> "${MP_FAKE_UV_LOG}"
printf 'PACKAGE_CACHE_ROOT=%s\n' "${PACKAGE_CACHE_ROOT:-}" >> "${MP_FAKE_UV_LOG}"
printf 'UV_CACHE_DIR=%s\n' "${UV_CACHE_DIR:-}" >> "${MP_FAKE_UV_LOG}"
printf 'UV_PROJECT_ENVIRONMENT=%s\n' "${UV_PROJECT_ENVIRONMENT:-}" >> "${MP_FAKE_UV_LOG}"
if [ "${MP_FAKE_UV_FAIL:-}" = "1" ]; then
exit 1
fi
exit 0
SH
chmod +x "${TMP_DIR}/venv/bin/pip"
chmod +x "${TMP_DIR}/bin/uv"
assert_contains() {
local needle="$1"
@@ -42,52 +42,50 @@ assert_not_contains() {
fi
}
UPDATE_FUNCS="${TMP_DIR}/update-functions.sh"
awk '
BEGIN {capture=1}
/^if \[\[ "\$\{MOVIEPILOT_AUTO_UPDATE\}"/ {capture=0}
capture {print}
' "${ROOT}/docker/update.sh" > "${UPDATE_FUNCS}"
# macOS 默认不提供 GNU timeout;模拟器只需保留被执行命令的参数和环境。
timeout() {
if [[ "${1:-}" == --kill-after=* ]]; then
shift
fi
shift
"$@"
}
MP_FAKE_PIP_LOG="${TMP_DIR}/update.log"
export MP_FAKE_PIP_LOG
MP_FAKE_UV_LOG="${TMP_DIR}/update.log"
export MP_FAKE_UV_LOG
export UV_BIN="${TMP_DIR}/bin/uv"
export VENV_PATH="${TMP_DIR}/venv"
export CONFIG_DIR="${TMP_DIR}/config"
export MOVIEPILOT_AUTO_UPDATE=false
export PIP_PROXY="https://mirror.example/simple"
export PROXY_HOST="http://proxy.example:7890"
unset PACKAGE_CACHE_ROOT PIP_CACHE_DIR UV_CACHE_DIR HTTP_PROXY HTTPS_PROXY http_proxy https_proxy
source "${UPDATE_FUNCS}" >/dev/null
unset PACKAGE_CACHE_ROOT UV_CACHE_DIR HTTP_PROXY HTTPS_PROXY http_proxy https_proxy
source "${ROOT}/docker/update.sh" >/dev/null
: > "${MP_FAKE_PIP_LOG}"
test_connectivity_pip 0
assert_contains "argv=install -i https://mirror.example/simple pip-hello-world" "${MP_FAKE_PIP_LOG}"
assert_contains "HTTPS_PROXY=http://proxy.example:7890" "${MP_FAKE_PIP_LOG}"
assert_contains "PACKAGE_CACHE_ROOT=${TMP_DIR}/config/.cache" "${MP_FAKE_PIP_LOG}"
assert_contains "PIP_CACHE_DIR=${TMP_DIR}/config/.cache/pip" "${MP_FAKE_PIP_LOG}"
assert_contains "UV_CACHE_DIR=${TMP_DIR}/config/.cache/uv" "${MP_FAKE_PIP_LOG}"
if [[ "${PIP_OPTIONS}" != "-i ${PIP_PROXY}" ]]; then
echo "mirror branch must preserve index option: ${PIP_OPTIONS}" >&2
exit 1
fi
if [[ "${PIP_OPTIONS}" == *"--proxy"* ]]; then
echo "PIP_OPTIONS must not contain --proxy: ${PIP_OPTIONS}" >&2
: > "${MP_FAKE_UV_LOG}"
test_connectivity_package 0
assert_contains "argv=pip install --target " "${MP_FAKE_UV_LOG}"
assert_contains "--no-deps --default-index https://mirror.example/simple pip-hello-world" "${MP_FAKE_UV_LOG}"
assert_contains "HTTPS_PROXY=http://proxy.example:7890" "${MP_FAKE_UV_LOG}"
assert_contains "PACKAGE_CACHE_ROOT=${TMP_DIR}/config/.cache" "${MP_FAKE_UV_LOG}"
assert_contains "UV_CACHE_DIR=${TMP_DIR}/config/.cache/uv" "${MP_FAKE_UV_LOG}"
if [[ "${UV_OPTIONS[*]}" != "--default-index ${PIP_PROXY}" ]]; then
echo "mirror branch must preserve uv index option: ${UV_OPTIONS[*]}" >&2
exit 1
fi
if [[ -n "${HTTP_PROXY:-}" || -n "${HTTPS_PROXY:-}" || -n "${http_proxy:-}" || -n "${https_proxy:-}" ]]; then
echo "pip connectivity must not leak PROXY_HOST into parent proxy env" >&2
echo "package connectivity must not leak PROXY_HOST into parent proxy env" >&2
env | grep -E '^(HTTP_PROXY|HTTPS_PROXY|http_proxy|https_proxy)=' >&2 || true
exit 1
fi
assert_not_contains "user:pass" "${MP_FAKE_PIP_LOG}"
: > "${MP_FAKE_PIP_LOG}"
: > "${MP_FAKE_UV_LOG}"
PIP_PROXY=""
test_connectivity_pip 1
assert_contains "argv=install pip-hello-world" "${MP_FAKE_PIP_LOG}"
assert_contains "HTTPS_PROXY=http://proxy.example:7890" "${MP_FAKE_PIP_LOG}"
if [[ -n "${PIP_OPTIONS}" ]]; then
echo "proxy branch must keep PIP_OPTIONS empty: ${PIP_OPTIONS}" >&2
test_connectivity_package 1
assert_contains "argv=pip install --target " "${MP_FAKE_UV_LOG}"
assert_contains "HTTPS_PROXY=http://proxy.example:7890" "${MP_FAKE_UV_LOG}"
if [[ ${#UV_OPTIONS[@]} -ne 0 ]]; then
echo "proxy branch must keep UV_OPTIONS empty: ${UV_OPTIONS[*]}" >&2
exit 1
fi
if [[ -n "${HTTP_PROXY:-}" || -n "${HTTPS_PROXY:-}" || -n "${http_proxy:-}" || -n "${https_proxy:-}" ]]; then
@@ -96,66 +94,54 @@ if [[ -n "${HTTP_PROXY:-}" || -n "${HTTPS_PROXY:-}" || -n "${http_proxy:-}" || -
exit 1
fi
MP_FAKE_PIP_LOG="${TMP_DIR}/update-explicit-standard-proxy.log"
export MP_FAKE_PIP_LOG
MP_FAKE_UV_LOG="${TMP_DIR}/update-explicit-standard-proxy.log"
export MP_FAKE_UV_LOG
(
export VENV_PATH="${TMP_DIR}/venv"
export CONFIG_DIR="${TMP_DIR}/config"
export MOVIEPILOT_AUTO_UPDATE=false
export PIP_PROXY=""
export PROXY_HOST="http://proxy.example:7890"
export HTTP_PROXY="http://explicit.example:8080"
export HTTPS_PROXY="http://explicit.example:8080"
export http_proxy="http://explicit.example:8080"
export https_proxy="http://explicit.example:8080"
source "${UPDATE_FUNCS}" >/dev/null
test_connectivity_pip 1
source "${ROOT}/docker/update.sh" >/dev/null
test_connectivity_package 1
if [[ "${HTTP_PROXY}" != "http://explicit.example:8080" || "${HTTPS_PROXY}" != "http://explicit.example:8080" ]]; then
echo "explicit standard proxy env must be preserved" >&2
env | grep -E '^(HTTP_PROXY|HTTPS_PROXY|http_proxy|https_proxy)=' >&2 || true
exit 1
fi
)
assert_contains "HTTPS_PROXY=http://proxy.example:7890" "${MP_FAKE_PIP_LOG}"
assert_contains "HTTPS_PROXY=http://proxy.example:7890" "${MP_FAKE_UV_LOG}"
MP_FAKE_PIP_LOG="${TMP_DIR}/update-explicit-cache.log"
export MP_FAKE_PIP_LOG
MP_FAKE_UV_LOG="${TMP_DIR}/update-explicit-cache.log"
export MP_FAKE_UV_LOG
(
export VENV_PATH="${TMP_DIR}/venv"
export CONFIG_DIR="${TMP_DIR}/config"
export MOVIEPILOT_AUTO_UPDATE=false
export PACKAGE_CACHE_ROOT="${TMP_DIR}/update-custom-package-cache"
export PIP_CACHE_DIR="${TMP_DIR}/explicit-pip-cache"
export UV_CACHE_DIR="${TMP_DIR}/explicit-uv-cache"
export PIP_PROXY="https://mirror.example/simple"
export PROXY_HOST="http://proxy.example:7890"
source "${UPDATE_FUNCS}" >/dev/null
test_connectivity_pip 0
source "${ROOT}/docker/update.sh" >/dev/null
test_connectivity_package 0
)
assert_contains "PACKAGE_CACHE_ROOT=${TMP_DIR}/update-custom-package-cache" "${MP_FAKE_UV_LOG}"
assert_contains "UV_CACHE_DIR=${TMP_DIR}/explicit-uv-cache" "${MP_FAKE_UV_LOG}"
assert_contains "PACKAGE_CACHE_ROOT=${TMP_DIR}/update-custom-package-cache" "${MP_FAKE_PIP_LOG}"
assert_contains "PIP_CACHE_DIR=${TMP_DIR}/explicit-pip-cache" "${MP_FAKE_PIP_LOG}"
assert_contains "UV_CACHE_DIR=${TMP_DIR}/explicit-uv-cache" "${MP_FAKE_PIP_LOG}"
MP_FAKE_PIP_LOG="${TMP_DIR}/update-fallback-no-proxy.log"
export MP_FAKE_PIP_LOG
MP_FAKE_UV_LOG="${TMP_DIR}/update-fallback-no-proxy.log"
export MP_FAKE_UV_LOG
(
export VENV_PATH="${TMP_DIR}/venv"
export CONFIG_DIR="${TMP_DIR}/config"
export MOVIEPILOT_AUTO_UPDATE=false
export PIP_PROXY="https://mirror.example/simple"
export PROXY_HOST="http://proxy.example:7890"
unset PACKAGE_CACHE_ROOT PIP_CACHE_DIR UV_CACHE_DIR HTTP_PROXY HTTPS_PROXY http_proxy https_proxy
source "${UPDATE_FUNCS}" >/dev/null
MP_FAKE_PIP_FAIL=1 test_connectivity_pip 0 && exit 1
if [[ -n "${HTTPS_PROXY:-}" || -n "${https_proxy:-}" ]]; then
echo "mirror failure must not leak proxy env" >&2
env | grep -E '^(HTTP_PROXY|HTTPS_PROXY|http_proxy|https_proxy)=' >&2 || true
exit 1
fi
test_connectivity_pip 2
if [[ "${PIP_LOG}" != "不使用代理" ]]; then
echo "fallback branch must report direct mode: ${PIP_LOG}" >&2
unset PACKAGE_CACHE_ROOT UV_CACHE_DIR HTTP_PROXY HTTPS_PROXY http_proxy https_proxy
source "${ROOT}/docker/update.sh" >/dev/null
MP_FAKE_UV_FAIL=1 test_connectivity_package 0 && exit 1
test_connectivity_package 2
if [[ "${PACKAGE_LOG}" != "不使用代理" ]]; then
echo "fallback branch must report direct mode: ${PACKAGE_LOG}" >&2
exit 1
fi
)
@@ -183,13 +169,13 @@ exit 0
SH
chmod +x "${TMP_DIR}/venv/bin/python3"
MP_FAKE_PIP_LOG="${TMP_DIR}/entrypoint.log"
MP_FAKE_UV_LOG="${TMP_DIR}/entrypoint.log"
MP_FAKE_PYTHON_COUNT="${TMP_DIR}/python-count"
export MP_FAKE_PIP_LOG MP_FAKE_PYTHON_COUNT
export MP_FAKE_UV_LOG MP_FAKE_PYTHON_COUNT
(
export VENV_PATH="${TMP_DIR}/venv"
export CONFIG_DIR="${TMP_DIR}/config"
unset PACKAGE_CACHE_ROOT PIP_CACHE_DIR UV_CACHE_DIR HTTP_PROXY HTTPS_PROXY http_proxy https_proxy
unset PACKAGE_CACHE_ROOT UV_CACHE_DIR HTTP_PROXY HTTPS_PROXY http_proxy https_proxy
export PIP_PROXY=""
export PROXY_HOST="http://proxy.example:7890"
source "${ENTRYPOINT_FUNCS}"
@@ -197,25 +183,24 @@ export MP_FAKE_PIP_LOG MP_FAKE_PYTHON_COUNT
ensure_backend_runtime_dependencies
if [[ -n "${HTTP_PROXY:-}" || -n "${HTTPS_PROXY:-}" || -n "${http_proxy:-}" || -n "${https_proxy:-}" ]]; then
echo "dependency recovery must not leak PROXY_HOST into parent proxy env" >&2
env | grep -E '^(HTTP_PROXY|HTTPS_PROXY|http_proxy|https_proxy)=' >&2 || true
exit 1
fi
) >/dev/null
assert_contains "argv=install -r /app/requirements.txt" "${MP_FAKE_PIP_LOG}"
assert_contains "HTTPS_PROXY=http://proxy.example:7890" "${MP_FAKE_PIP_LOG}"
assert_contains "PACKAGE_CACHE_ROOT=${TMP_DIR}/config/.cache" "${MP_FAKE_PIP_LOG}"
assert_contains "PIP_CACHE_DIR=${TMP_DIR}/config/.cache/pip" "${MP_FAKE_PIP_LOG}"
assert_contains "UV_CACHE_DIR=${TMP_DIR}/config/.cache/uv" "${MP_FAKE_PIP_LOG}"
assert_not_contains "--proxy" "${MP_FAKE_PIP_LOG}"
assert_contains "argv=sync --project /app --locked --no-dev --no-install-project --inexact" "${MP_FAKE_UV_LOG}"
assert_contains "HTTPS_PROXY=http://proxy.example:7890" "${MP_FAKE_UV_LOG}"
assert_contains "PACKAGE_CACHE_ROOT=${TMP_DIR}/config/.cache" "${MP_FAKE_UV_LOG}"
assert_contains "UV_CACHE_DIR=${TMP_DIR}/config/.cache/uv" "${MP_FAKE_UV_LOG}"
assert_contains "UV_PROJECT_ENVIRONMENT=${TMP_DIR}/venv" "${MP_FAKE_UV_LOG}"
assert_not_contains "requirements" "${MP_FAKE_UV_LOG}"
MP_FAKE_PIP_LOG="${TMP_DIR}/entrypoint-explicit-standard-proxy.log"
MP_FAKE_UV_LOG="${TMP_DIR}/entrypoint-explicit-standard-proxy.log"
MP_FAKE_PYTHON_COUNT="${TMP_DIR}/python-count-explicit-standard-proxy"
export MP_FAKE_PIP_LOG MP_FAKE_PYTHON_COUNT
export MP_FAKE_UV_LOG MP_FAKE_PYTHON_COUNT
(
export VENV_PATH="${TMP_DIR}/venv"
export CONFIG_DIR="${TMP_DIR}/config"
unset PACKAGE_CACHE_ROOT PIP_CACHE_DIR UV_CACHE_DIR
unset PACKAGE_CACHE_ROOT UV_CACHE_DIR
export PIP_PROXY=""
export PROXY_HOST="http://proxy.example:7890"
export HTTP_PROXY="http://explicit.example:8080"
@@ -227,32 +212,28 @@ export MP_FAKE_PIP_LOG MP_FAKE_PYTHON_COUNT
ensure_backend_runtime_dependencies
if [[ "${HTTP_PROXY}" != "http://explicit.example:8080" || "${HTTPS_PROXY}" != "http://explicit.example:8080" ]]; then
echo "dependency recovery must preserve explicit standard proxy env" >&2
env | grep -E '^(HTTP_PROXY|HTTPS_PROXY|http_proxy|https_proxy)=' >&2 || true
exit 1
fi
) >/dev/null
assert_contains "HTTPS_PROXY=http://proxy.example:7890" "${MP_FAKE_UV_LOG}"
assert_contains "HTTPS_PROXY=http://proxy.example:7890" "${MP_FAKE_PIP_LOG}"
MP_FAKE_PIP_LOG="${TMP_DIR}/entrypoint-app-env.log"
MP_FAKE_UV_LOG="${TMP_DIR}/entrypoint-app-env.log"
MP_FAKE_PYTHON_COUNT="${TMP_DIR}/python-count-app-env"
cat > "${TMP_DIR}/config/app.env" <<EOF
PACKAGE_CACHE_ROOT='${TMP_DIR}/app-env-custom-package-cache'
PROXY_HOST='http://proxy.example:7890'
EOF
export MP_FAKE_PIP_LOG MP_FAKE_PYTHON_COUNT
export MP_FAKE_UV_LOG MP_FAKE_PYTHON_COUNT
(
export VENV_PATH="${TMP_DIR}/venv"
export CONFIG_DIR="${TMP_DIR}/config"
unset PACKAGE_CACHE_ROOT PIP_CACHE_DIR UV_CACHE_DIR PIP_PROXY PROXY_HOST
unset PACKAGE_CACHE_ROOT UV_CACHE_DIR PIP_PROXY PROXY_HOST
source "${ENTRYPOINT_FUNCS}"
load_config_from_app_env
apply_package_cache_env
ensure_backend_runtime_dependencies
) >/dev/null
assert_contains "PACKAGE_CACHE_ROOT=${TMP_DIR}/app-env-custom-package-cache" "${MP_FAKE_PIP_LOG}"
assert_contains "PIP_CACHE_DIR=${TMP_DIR}/app-env-custom-package-cache/pip" "${MP_FAKE_PIP_LOG}"
assert_contains "UV_CACHE_DIR=${TMP_DIR}/app-env-custom-package-cache/uv" "${MP_FAKE_PIP_LOG}"
assert_contains "PACKAGE_CACHE_ROOT=${TMP_DIR}/app-env-custom-package-cache" "${MP_FAKE_UV_LOG}"
assert_contains "UV_CACHE_DIR=${TMP_DIR}/app-env-custom-package-cache/uv" "${MP_FAKE_UV_LOG}"
echo "Docker package env simulation passed"
+6 -7
View File
@@ -21,7 +21,6 @@ def sample(name: str, request: PackageInstallRequest) -> None:
print(rendered)
assert all("--proxy" not in arg for arg in strategy.command)
assert "user:pass" not in rendered
assert strategy.env["PIP_CACHE_DIR"].endswith("/.cache/pip")
assert strategy.env["UV_CACHE_DIR"].endswith("/.cache/uv")
if strategy.strategy_name.endswith("代理") or strategy.strategy_name.endswith("镜像+代理"):
assert strategy.env["HTTPS_PROXY"] == "http://proxy.example:7890"
@@ -35,31 +34,31 @@ def main() -> None:
samples = {
"plain": PackageInstallRequest(
requirements_file=requirements,
dependency_file=requirements,
python_bin=python_bin,
config_dir=config_dir,
),
"mirror": PackageInstallRequest(
requirements_file=requirements,
dependency_file=requirements,
python_bin=python_bin,
config_dir=config_dir,
pip_index_url="https://user:pass@mirror.example/simple",
package_index_url="https://user:pass@mirror.example/simple",
),
"proxy": PackageInstallRequest(
requirements_file=requirements,
dependency_file=requirements,
python_bin=python_bin,
config_dir=config_dir,
proxy_url="http://proxy.example:7890",
),
"mirror_proxy_wheels": PackageInstallRequest(
requirements_file=requirements,
dependency_file=requirements,
python_bin=python_bin,
config_dir=config_dir,
find_links_dirs=[
root / "plugins.v2" / "demo" / "wheels",
root / "plugins.v2" / "other" / "wheels",
],
pip_index_url="https://user:pass@mirror.example/simple",
package_index_url="https://user:pass@mirror.example/simple",
proxy_url="http://proxy.example:7890",
),
}
+90 -89
View File
@@ -33,10 +33,9 @@ PUBLIC_DIR = ROOT / "public"
RUNTIME_DIR = ROOT / ".runtime"
NODE_DIR = RUNTIME_DIR / "node"
INSTALL_ENV_FILE = ROOT / ".moviepilot.env"
MIN_PYTHON_VERSION = (3, 11)
SUPPORTED_PYTHON_TEXT = (
f"Python {MIN_PYTHON_VERSION[0]}.{MIN_PYTHON_VERSION[1]} 或更高版本"
)
MIN_PYTHON_VERSION = (3, 12)
SUPPORTED_PYTHON_TEXT = "Python 3.12+"
UV_VERSION = "0.12.5"
CONFIG_DIR = LEGACY_CONFIG_DIR
LOG_DIR = CONFIG_DIR / "logs"
@@ -525,12 +524,10 @@ def build_package_install_env() -> dict[str, str]:
env = os.environ.copy()
package_cache_root = env.get("PACKAGE_CACHE_ROOT", "").strip() or str(CONFIG_DIR / ".cache")
env.setdefault("PACKAGE_CACHE_ROOT", package_cache_root)
env.setdefault("PIP_CACHE_DIR", os.path.join(package_cache_root, "pip"))
env.setdefault("UV_CACHE_DIR", os.path.join(package_cache_root, "uv"))
index_url = env.get("PIP_PROXY", "").strip()
if index_url:
env["PIP_INDEX_URL"] = index_url
env["UV_DEFAULT_INDEX"] = index_url
proxy = env.get("PROXY_HOST", "").strip()
@@ -620,67 +617,36 @@ def get_venv_bin_dir(venv_dir: Path) -> Path:
return venv_dir / "bin"
def get_venv_pip(venv_dir: Path) -> Path:
if os.name == "nt":
return get_venv_bin_dir(venv_dir) / "pip.exe"
return get_venv_bin_dir(venv_dir) / "pip"
def require_uv() -> Path:
"""返回仓库要求版本的 uv,避免不同安装入口使用不同解析器。"""
uv_command = shutil.which("uv")
if not uv_command:
raise RuntimeError(
f"未找到 uv {UV_VERSION},请先安装后重新执行。"
)
uv_bin = Path(uv_command).expanduser().resolve()
version = capture([str(uv_bin), "--version"])
if version.split()[:2] != ["uv", UV_VERSION]:
raise RuntimeError(
f"MoviePilot 需要 uv {UV_VERSION},当前为 {version or '未知版本'}"
)
return uv_bin
def _ensure_uv_available_for_venv(venv_dir: Path, venv_python: Path) -> Optional[Path]:
if os.name == "nt":
return None
def expose_uv_to_venv(uv_bin: Path, venv_dir: Path) -> Path:
"""让运行时能从虚拟环境旁定位同一 uv 二进制。"""
venv_bin = get_venv_bin_dir(venv_dir)
uv_bin = venv_bin / "uv"
if uv_bin.exists():
return uv_bin
system_uv = shutil.which("uv")
if system_uv:
uv_target = Path(system_uv).expanduser().resolve()
print_step(f"复用系统 uv{uv_target}")
if uv_bin.exists() or uv_bin.is_symlink():
uv_bin.unlink()
uv_bin.symlink_to(uv_target)
return uv_bin
print_step("当前未检测到 uv,先在虚拟环境内安装 uv")
command = [str(venv_python), "-m", "pip", "install", "--upgrade", "pip", "uv"]
run(command, env=build_package_install_env(), safe_command=redact_command(command))
if uv_bin.exists():
return uv_bin
raise RuntimeError("uv 安装完成,但虚拟环境中未找到 uv 可执行文件")
def configure_venv_pip_compat(venv_dir: Path, venv_python: Path) -> Path:
"""
在虚拟环境中安装 uv 并保持 pip 命令兼容供现有安装流程复用
"""
runtime_uv = venv_bin / ("uv.exe" if os.name == "nt" else "uv")
venv_bin.mkdir(parents=True, exist_ok=True)
if runtime_uv.resolve() == uv_bin.resolve():
return runtime_uv
if runtime_uv.exists() or runtime_uv.is_symlink():
runtime_uv.unlink()
if os.name == "nt":
return get_venv_pip(venv_dir)
_ensure_uv_available_for_venv(venv_dir, venv_python)
venv_bin = get_venv_bin_dir(venv_dir)
wrapper_src = ROOT / "scripts" / "uv-pip-compat.sh"
wrapper_dst = venv_bin / "uv-pip-compat"
shutil.copy2(wrapper_src, wrapper_dst)
wrapper_dst.chmod(0o755)
python_version = get_python_version(str(venv_python))
compat_links = {
"pip",
"pip3",
f"pip{python_version[0]}",
f"pip{python_version[0]}.{python_version[1]}",
"pip-compile",
"pip-sync",
}
for link_name in compat_links:
link_path = venv_bin / link_name
if link_path.exists() or link_path.is_symlink():
link_path.unlink()
link_path.symlink_to(wrapper_dst.name)
return get_venv_pip(venv_dir)
shutil.copy2(uv_bin, runtime_uv)
else:
runtime_uv.symlink_to(uv_bin)
return runtime_uv
def ensure_supported_python(python_bin: str) -> None:
@@ -692,6 +658,12 @@ def ensure_supported_python(python_bin: str) -> None:
)
def resolve_python_path(python_bin: str) -> Path:
"""解析解释器命令,优先按 PATH 找到实际执行文件。"""
resolved = shutil.which(python_bin)
return Path(resolved or python_bin).expanduser().resolve()
def ensure_local_dirs() -> None:
for path in (CONFIG_DIR, LOG_DIR, CACHE_DIR, TEMP_DIR, COOKIE_DIR, RUNTIME_DIR):
path.mkdir(parents=True, exist_ok=True)
@@ -2743,33 +2715,62 @@ def install_deps(*, python_bin: str, venv_dir: Path, recreate: bool) -> Path:
"""
ensure_supported_python(python_bin)
venv_dir = venv_dir.expanduser().resolve()
if recreate:
requested_python = resolve_python_path(python_bin)
executing_python = Path(sys.executable).expanduser().resolve()
if requested_python.is_relative_to(venv_dir) or executing_python.is_relative_to(
venv_dir
):
raise RuntimeError(
"重建虚拟环境需要使用 venv 外部的 Python 3.12+ 解释器。"
)
uv_bin = require_uv()
temporary_uv_dir: Optional[TemporaryDirectory[str]] = None
if recreate and venv_dir.exists() and uv_bin.is_relative_to(venv_dir):
temporary_uv_dir = TemporaryDirectory(prefix="moviepilot-uv-")
temporary_uv = Path(temporary_uv_dir.name) / uv_bin.name
shutil.copy2(uv_bin, temporary_uv)
uv_bin = temporary_uv
venv_python = get_venv_python(venv_dir)
venv_pip = get_venv_pip(venv_dir)
print_step(f"使用 Python 解释器:{python_bin}")
try:
if recreate and venv_dir.exists():
print_step(f"删除已有虚拟环境:{venv_dir}")
shutil.rmtree(venv_dir)
if recreate and venv_dir.exists():
print_step(f"删除已有虚拟环境:{venv_dir}")
shutil.rmtree(venv_dir)
if venv_python.exists():
print_step(f"复用已有虚拟环境:{venv_dir}")
else:
print_step(f"创建虚拟环境:{venv_dir}")
if not venv_python.exists():
print_step(f"创建虚拟环境:{venv_dir}")
run([python_bin, "-m", "venv", str(venv_dir)])
else:
print_step(f"复用已有虚拟环境:{venv_dir}")
if os.name == "nt":
print_step("升级 pip")
command = [str(venv_python), "-m", "pip", "install", "--upgrade", "pip"]
run(command, env=build_package_install_env(), safe_command=redact_command(command))
else:
print_step("为虚拟环境配置 uv 兼容 pip 命令")
venv_pip = configure_venv_pip_compat(venv_dir, venv_python)
print_step("安装项目依赖")
command = [str(venv_pip), "install", "-r", str(ROOT / "requirements.txt")]
run(command, env=build_package_install_env(), safe_command=redact_command(command))
install_browser_runtime(venv_python)
return venv_python
print_step("同步项目锁定依赖")
command = [
str(uv_bin),
"sync",
"--project",
str(ROOT),
"--locked",
"--no-dev",
"--no-install-project",
"--python",
python_bin,
]
env = build_package_install_env()
env["UV_PROJECT_ENVIRONMENT"] = str(venv_dir)
run(command, env=env, safe_command=redact_command(command))
if temporary_uv_dir is not None:
runtime_uv = get_venv_bin_dir(venv_dir) / (
"uv.exe" if os.name == "nt" else "uv"
)
runtime_uv.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(uv_bin, runtime_uv)
else:
expose_uv_to_venv(uv_bin, venv_dir)
install_browser_runtime(venv_python)
return venv_python
finally:
if temporary_uv_dir is not None:
temporary_uv_dir.cleanup()
def install_browser_runtime(venv_python: Path) -> None:
@@ -3718,7 +3719,7 @@ def build_parser() -> argparse.ArgumentParser:
install_parser.add_argument(
"--python",
default=DEFAULT_BOOTSTRAP_PYTHON,
help="用于创建虚拟环境的 Python 解释器,默认自动选择本地 3.11+ 版本",
help="用于创建虚拟环境的 Python 解释器,默认自动选择本地 3.12+ 版本",
)
install_parser.add_argument(
"--venv", default=str(ROOT / "venv"), help="虚拟环境目录"
@@ -3780,7 +3781,7 @@ def build_parser() -> argparse.ArgumentParser:
setup_parser.add_argument(
"--python",
default=DEFAULT_BOOTSTRAP_PYTHON,
help="用于创建虚拟环境的 Python 解释器,默认自动选择本地 3.11+ 版本",
help="用于创建虚拟环境的 Python 解释器,默认自动选择本地 3.12+ 版本",
)
setup_parser.add_argument("--venv", default=str(ROOT / "venv"), help="虚拟环境目录")
setup_parser.add_argument(
@@ -3852,7 +3853,7 @@ def build_parser() -> argparse.ArgumentParser:
update_parser.add_argument(
"--python",
default=DEFAULT_BOOTSTRAP_PYTHON,
help="用于安装后端依赖的 Python 解释器,默认自动选择本地 3.11+ 版本",
help="用于安装后端依赖的 Python 解释器,默认自动选择本地 3.12+ 版本",
)
update_parser.add_argument(
"--venv", default=str(ROOT / "venv"), help="虚拟环境目录"
+2 -2
View File
@@ -40,9 +40,9 @@ ROLE_LABEL = "org.moviepilot.perf.role"
SOURCE_LABEL = "org.moviepilot.perf.source-commit"
SUBSTRATE_LABEL = "org.moviepilot.perf.substrate"
CRITICAL_SUBSTRATE_PATHS = (
"requirements.in",
"pyproject.toml",
"uv.lock",
"docker/Dockerfile",
"scripts/uv-pip-compat.sh",
)
SEED_COMPATIBILITY_PATHS = ("database/versions",)
AGENT_HEAVY_MODULE_PREFIXES = (
-141
View File
@@ -1,141 +0,0 @@
#!/bin/sh
set -eu
SCRIPT_PATH="$0"
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd)
COMMAND_NAME=$(basename -- "$SCRIPT_PATH")
if [ "${COMMAND_NAME}" = "uv-pip-compat" ] || [ "${COMMAND_NAME}" = "uv-pip-compat.sh" ]; then
if [ "$#" -eq 0 ]; then
echo "用法: uv-pip-compat <pip|pip-compile|pip-sync> [args...]" >&2
exit 2
fi
COMMAND_NAME="$1"
shift
fi
if [ -x "${SCRIPT_DIR}/uv" ]; then
UV_BIN="${SCRIPT_DIR}/uv"
elif command -v uv >/dev/null 2>&1; then
UV_BIN=$(command -v uv)
else
echo "未找到 uv,可执行 pip 兼容层无法继续运行。" >&2
exit 127
fi
has_environment_option() {
while [ "$#" -gt 0 ]; do
case "$1" in
-p|--python|--python=*|-p*|--system|--user|\
-t|--target|--target=*|-t*|--prefix|--prefix=*)
return 0
;;
--)
return 1
;;
esac
shift
done
return 1
}
normalize_pip_proxy_args() {
output_file="$1"
shift
original_args_file=$(mktemp)
: > "${output_file}"
trap 'rm -f "${original_args_file}"' EXIT HUP INT TERM
for arg in "$@"; do
printf '%s\n' "$arg" >> "${original_args_file}"
done
skip_next=0
while IFS= read -r arg; do
if [ "${skip_next}" -eq 1 ]; then
proxy_value="${arg}"
export HTTP_PROXY="${proxy_value}"
export HTTPS_PROXY="${proxy_value}"
export http_proxy="${proxy_value}"
export https_proxy="${proxy_value}"
skip_next=0
continue
fi
case "$arg" in
--proxy)
skip_next=1
;;
--proxy=*)
proxy_value="${arg#--proxy=}"
export HTTP_PROXY="${proxy_value}"
export HTTPS_PROXY="${proxy_value}"
export http_proxy="${proxy_value}"
export https_proxy="${proxy_value}"
;;
*)
printf '%s\n' "$arg" >> "${output_file}"
;;
esac
done < "${original_args_file}"
rm -f "${original_args_file}"
trap - EXIT HUP INT TERM
}
uv_pip_with_venv_python() {
command_name="$1"
shift
if [ -x "${SCRIPT_DIR}/python" ] && ! has_environment_option "$@"; then
# uv 不会仅凭 pip 软链接位置锁定 venv,本地安装也不会激活 venv。
# 因此需要在会读取或改写环境的 pip 子命令上显式绑定当前 venv 解释器。
exec "${UV_BIN}" pip "${command_name}" --python "${SCRIPT_DIR}/python" "$@"
fi
exec "${UV_BIN}" pip "${command_name}" "$@"
}
case "${COMMAND_NAME}" in
pip|pip3|pip3.*)
if [ "$#" -eq 0 ]; then
exec "${UV_BIN}" pip --help
fi
case "$1" in
-V|--version|version)
exec "${UV_BIN}" --version
;;
help)
shift
exec "${UV_BIN}" help pip "$@"
;;
check|freeze|install|list|show|sync|tree|uninstall)
pip_command="$1"
shift
if [ "${pip_command}" = "install" ]; then
normalized_file=$(mktemp)
normalize_pip_proxy_args "${normalized_file}" "$@"
set --
while IFS= read -r arg; do
set -- "$@" "$arg"
done < "${normalized_file}"
rm -f "${normalized_file}"
fi
uv_pip_with_venv_python "${pip_command}" "$@"
;;
*)
exec "${UV_BIN}" pip "$@"
;;
esac
;;
pip-compile)
exec "${UV_BIN}" pip compile "$@"
;;
pip-sync)
uv_pip_with_venv_python sync "$@"
;;
*)
echo "不支持的 pip 兼容命令入口:${COMMAND_NAME}" >&2
exit 2
;;
esac
+2
View File
@@ -148,6 +148,7 @@ def configure_plugin_system_services():
)
from app.adapters.external.plugin.client import PluginMarketClient
from app.adapters.system.plugin.dependency import PluginDependencyInstaller
from app.adapters.system.plugin.manifest import dependency_manifest_status
from app.adapters.system.plugin.package import PluginPackageManager
from app.runtime.extensions.plugin.system import (
PluginSystemServices,
@@ -160,6 +161,7 @@ def configure_plugin_system_services():
market=PluginMarketClient(helper),
package=PluginPackageManager(helper),
dependency=PluginDependencyInstaller(helper),
dependency_manifest_status=dependency_manifest_status,
compatible_flags=lambda flag: (
[flag] + VERSION_BACKWARD_COMPATIBLE_FLAGS.get(flag, [])
if flag else []
+12 -3
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [],
"workflow_to_db": []
},
"edge_count": 6061,
"edge_sha256": "022ba984b711776539b4dc120d0f2e92645f61b28b59dd6be3c32a64499d08ce",
"edge_count": 6069,
"edge_sha256": "a47f8e0ee6d4855e106fefae05d5fa27beea9a1fd5710009260615d232af3c03",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -52,6 +52,8 @@
"app.adapters.external.market -> app.adapters.system",
"app.adapters.external.market -> app.adapters.system.host",
"app.adapters.external.market -> app.adapters.system.package",
"app.adapters.external.market -> app.adapters.system.plugin",
"app.adapters.external.market -> app.adapters.system.plugin.manifest",
"app.adapters.external.market -> app.foundation",
"app.adapters.external.market -> app.foundation.singleton",
"app.adapters.external.market -> app.foundation.url",
@@ -122,9 +124,14 @@
"app.adapters.system.plugin.dependency -> app.adapters",
"app.adapters.system.plugin.dependency -> app.adapters.external",
"app.adapters.system.plugin.dependency -> app.adapters.external.market",
"app.adapters.system.plugin.dependency -> app.adapters.system",
"app.adapters.system.plugin.dependency -> app.adapters.system.plugin",
"app.adapters.system.plugin.dependency -> app.adapters.system.plugin.manifest",
"app.adapters.system.plugin.dependency -> app.runtime",
"app.adapters.system.plugin.dependency -> app.runtime.config",
"app.adapters.system.plugin.dependency -> app.runtime.log",
"app.adapters.system.plugin.manifest -> app.runtime",
"app.adapters.system.plugin.manifest -> app.runtime.log",
"app.adapters.system.plugin.package -> app.adapters",
"app.adapters.system.plugin.package -> app.adapters.external",
"app.adapters.system.plugin.package -> app.adapters.external.market",
@@ -5862,6 +5869,7 @@
"app.startup.plugins_initializer -> app.adapters.system.host",
"app.startup.plugins_initializer -> app.adapters.system.plugin",
"app.startup.plugins_initializer -> app.adapters.system.plugin.dependency",
"app.startup.plugins_initializer -> app.adapters.system.plugin.manifest",
"app.startup.plugins_initializer -> app.adapters.system.plugin.package",
"app.startup.plugins_initializer -> app.api",
"app.startup.plugins_initializer -> app.api.endpoints",
@@ -6078,7 +6086,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
"module_count": 752,
"module_count": 753,
"modules": [
"app",
"app.adapters",
@@ -6112,6 +6120,7 @@
"app.adapters.system.package",
"app.adapters.system.plugin",
"app.adapters.system.plugin.dependency",
"app.adapters.system.plugin.manifest",
"app.adapters.system.plugin.package",
"app.adapters.system.resource",
"app.adapters.system.rust",
+2 -4
View File
@@ -119,7 +119,7 @@ class CliAutoUpdateTests(unittest.TestCase):
module.settings.PIP_PROXY = "https://mirror.example/simple"
run_result = SimpleNamespace(returncode=0, stdout="ok")
with patch.dict(module.os.environ, {"HTTPS_PROXY": "http://old.example:8080"}, clear=False), patch.object(
with patch.dict(module.os.environ, {"HTTPS_PROXY": "http://old.example:8080"}, clear=True), patch.object(
module, "_auto_update_mode", return_value="release"
), patch.object(module, "_resolve_auto_update_targets", return_value="v2.10.12"), patch.object(
module.subprocess, "run", return_value=run_result
@@ -132,7 +132,6 @@ class CliAutoUpdateTests(unittest.TestCase):
self.assertEqual(env["HTTPS_PROXY"], "http://proxy.example:7890")
self.assertEqual(env["PIP_PROXY"], "https://mirror.example/simple")
self.assertEqual(env["PACKAGE_CACHE_ROOT"], str(module.settings.PACKAGE_CACHE_PATH))
self.assertEqual(env["PIP_CACHE_DIR"], str(module.settings.PACKAGE_CACHE_PATH / "pip"))
self.assertEqual(env["UV_CACHE_DIR"], str(module.settings.PACKAGE_CACHE_PATH / "uv"))
def test_best_effort_auto_update_derives_tool_cache_from_existing_root(self):
@@ -145,7 +144,7 @@ class CliAutoUpdateTests(unittest.TestCase):
{
"PACKAGE_CACHE_ROOT": str(package_cache_root),
},
clear=False,
clear=True,
), patch.object(module, "_auto_update_mode", return_value="release"), patch.object(
module, "_resolve_auto_update_targets", return_value="v2.10.12"
), patch.object(module.subprocess, "run", return_value=run_result) as run_mock, patch.object(
@@ -155,5 +154,4 @@ class CliAutoUpdateTests(unittest.TestCase):
env = run_mock.call_args.kwargs["env"]
self.assertEqual(env["PACKAGE_CACHE_ROOT"], str(package_cache_root))
self.assertEqual(env["PIP_CACHE_DIR"], str(package_cache_root / "pip"))
self.assertEqual(env["UV_CACHE_DIR"], str(package_cache_root / "uv"))
+145 -103
View File
@@ -27,6 +27,20 @@ def _write_bundle(path: Path, label: str, *, extra_files: tuple[str, ...] = ())
def test_dockerfile_control_bundle_build_checks_fail_closed() -> None:
dockerfile = (ROOT / "docker" / "Dockerfile").read_text(encoding="utf-8")
assert (
"FROM ghcr.io/astral-sh/uv:0.12.5@sha256:"
"e85be844203885286c60ffad8a858d48afb6c5a5c237ca0e67f12e74b8f174b1 AS uv"
in dockerfile
)
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 "UV_PROJECT_ENVIRONMENT=${VENV_PATH} uv sync" in dockerfile
for option in ("--locked", "--no-dev", "--no-install-project"):
assert option in dockerfile
assert "uv-pip-compat" not in dockerfile
assert "requirements.in" not in dockerfile
assert "${VENV_PATH}/bin/pip" not in dockerfile
assert "-exec cp -f -t /usr/local/lib/moviepilot/control {} +" in dockerfile
assert "bash -n /entrypoint.sh" in dockerfile
assert 'ENTRYPOINT [ "/usr/bin/tini", "-g", "--", "/entrypoint.sh" ]' in dockerfile
@@ -540,7 +554,7 @@ def test_updater_package_proxy_stays_command_scoped(tmp_path: Path) -> None:
source {UPDATER!s}
set_package_proxy_env
printf '%s|%s|%s|%s\\n' "${{HTTP_PROXY-unset}}" "${{HTTPS_PROXY-unset}}" "${{http_proxy-unset}}" "${{https_proxy-unset}}"
printf '%s\\n' "${{PIP_ENV[*]}}"
printf '%s\\n' "${{PACKAGE_ENV[*]}}"
"""
)
env = dict(os.environ)
@@ -578,7 +592,7 @@ def test_updater_exposes_explicit_result(
INFO() {{ :; }}
WARN() {{ :; }}
ERROR() {{ :; }}
test_connectivity_pip() {{ PIP_LOG=test; return 0; }}
test_connectivity_package() {{ PACKAGE_LOG=test; return 0; }}
test_connectivity_github() {{ GITHUB_LOG=test; return 0; }}
install_backend_and_download_resources() {{
if [ "${{INSTALL_RESULT}}" = success ]; then
@@ -605,7 +619,7 @@ def test_updater_exposes_explicit_result(
def test_release_noop_preserves_prerelease_selection_without_probing_package_index(
tmp_path: Path,
) -> None:
pip_probe = tmp_path / "pip-probe"
package_probe = tmp_path / "package-probe"
curl_log = tmp_path / "curl.log"
comparison_log = tmp_path / "comparison.log"
script = textwrap.dedent(
@@ -613,14 +627,14 @@ def test_release_noop_preserves_prerelease_selection_without_probing_package_ind
CONFIG_DIR="$1"
MOVIEPILOT_AUTO_UPDATE=release
PIP_PROXY= PROXY_HOST= GITHUB_PROXY= GITHUB_TOKEN=
PIP_PROBE="$2"
PACKAGE_PROBE="$2"
CURL_LOG="$3"
COMPARISON_LOG="$4"
source {UPDATER!s}
INFO() {{ :; }}
WARN() {{ :; }}
ERROR() {{ :; }}
test_connectivity_pip() {{ touch "${{PIP_PROBE}}"; return 0; }}
test_connectivity_package() {{ touch "${{PACKAGE_PROBE}}"; return 0; }}
test_connectivity_github() {{ CURL_OPTIONS=-sL; GITHUB_LOG=test; return 0; }}
compare_versions() {{ printf '%s|%s\n' "$1" "$2" > "${{COMPARISON_LOG}}"; return 1; }}
grep() {{
@@ -653,7 +667,7 @@ def test_release_noop_preserves_prerelease_selection_without_probing_package_ind
script,
"release-noop-test",
str(tmp_path / "config"),
str(pip_probe),
str(package_probe),
str(curl_log),
str(comparison_log),
],
@@ -663,7 +677,7 @@ def test_release_noop_preserves_prerelease_selection_without_probing_package_ind
)
assert result.stdout == "noop\n"
assert not pip_probe.exists()
assert not package_probe.exists()
curl_args = curl_log.read_text(encoding="utf-8")
assert "/releases" in curl_args
assert "/releases/latest" not in curl_args
@@ -675,51 +689,68 @@ def test_release_noop_preserves_prerelease_selection_without_probing_package_ind
@pytest.mark.parametrize(
("dependencies_changed", "expected_route_calls", "expected_install_calls"),
((False, 0, 0), (True, 1, 1)),
("pyproject_changed", "lock_changed", "expected_route_calls", "expected_sync_calls"),
(
(False, False, 0, 0),
(True, False, 1, 1),
(False, True, 1, 1),
(True, True, 1, 1),
),
)
def test_package_route_is_only_configured_for_changed_dependencies(
tmp_path: Path,
dependencies_changed: bool,
pyproject_changed: bool,
lock_changed: bool,
expected_route_calls: int,
expected_install_calls: int,
expected_sync_calls: int,
) -> None:
venv_bin = tmp_path / "venv" / "bin"
venv_bin.mkdir(parents=True)
pip_log = tmp_path / "pip.log"
uv_bin = tmp_path / "bin" / "uv"
uv_bin.parent.mkdir(parents=True)
uv_log = tmp_path / "uv.log"
route_log = tmp_path / "route.log"
for executable in ("pip", "pip-compile"):
path = venv_bin / executable
path.write_text(
"#!/bin/bash\nprintf '%s\\n' \"$*\" >> \"${PIP_TEST_LOG}\"\n",
encoding="utf-8",
)
path.chmod(0o755)
uv_bin.write_text(
"#!/bin/bash\n"
"printf '%s|%s\\n' \"${UV_PROJECT_ENVIRONMENT:-}\" \"$*\" >> \"${UV_TEST_LOG}\"\n",
encoding="utf-8",
)
uv_bin.chmod(0o755)
update_tree = tmp_path / "update" / "App"
update_tree.mkdir(parents=True)
(update_tree / "requirements.in").write_text("new-package==1\n", encoding="utf-8")
(update_tree / "version.py").write_text("FRONTEND_VERSION = ''\n", encoding="utf-8")
(update_tree / "pyproject.toml").write_text("[project]\n", encoding="utf-8")
(update_tree / "uv.lock").write_text("version = 1\n", encoding="utf-8")
script = textwrap.dedent(
f"""\
CONFIG_DIR="$1"
VENV_PATH="$2"
TMP_PATH="$3"
ROUTE_LOG="$4"
DEPENDENCIES_CHANGED="$5"
PYPROJECT_CHANGED="$5"
LOCK_CHANGED="$6"
UV_BIN="$7"
PIP_PROXY= PROXY_HOST=
source {UPDATER!s}
INFO() {{ :; }}
WARN() {{ :; }}
ERROR() {{ :; }}
download_and_unzip() {{ return 0; }}
cmp() {{ [ "${{DEPENDENCIES_CHANGED}}" = false ]; }}
cp() {{ return 0; }}
configure_pip_route() {{ printf 'route\n' >> "${{ROUTE_LOG}}"; PIP_LOG=test; }}
install_backend_and_download_resources tags/v3.0.1.zip || true
cmp() {{
case "$2" in
*/pyproject.toml) [ "${{PYPROJECT_CHANGED}}" = false ] ;;
*/uv.lock) [ "${{LOCK_CHANGED}}" = false ] ;;
esac
}}
configure_package_route() {{
printf 'route\n' >> "${{ROUTE_LOG}}"
PACKAGE_LOG=test
PACKAGE_ENV=()
UV_OPTIONS=()
}}
if dependency_manifests_changed; then
sync_project_dependencies
fi
"""
)
env = {**os.environ, "PIP_TEST_LOG": str(pip_log)}
env = {**os.environ, "UV_TEST_LOG": str(uv_log)}
result = subprocess.run(
[
"bash",
@@ -730,7 +761,9 @@ def test_package_route_is_only_configured_for_changed_dependencies(
str(tmp_path / "venv"),
str(tmp_path / "update"),
str(route_log),
str(dependencies_changed).lower(),
str(pyproject_changed).lower(),
str(lock_changed).lower(),
str(uv_bin),
],
text=True,
capture_output=True,
@@ -740,72 +773,98 @@ def test_package_route_is_only_configured_for_changed_dependencies(
assert result.stderr == ""
route_calls = route_log.read_text(encoding="utf-8").splitlines() if route_log.exists() else []
install_calls = pip_log.read_text(encoding="utf-8").splitlines() if pip_log.exists() else []
sync_calls = uv_log.read_text(encoding="utf-8").splitlines() if uv_log.exists() else []
assert len(route_calls) == expected_route_calls
compile_calls = [call for call in install_calls if not call.startswith("install ")]
package_install_calls = [call for call in install_calls if call.startswith("install ")]
assert len(package_install_calls) == expected_install_calls
if dependencies_changed:
assert compile_calls == [
f"{update_tree / 'requirements.in'} -o {tmp_path / 'update' / 'requirements.txt'}"
]
assert package_install_calls == [
f"install -r {tmp_path / 'update' / 'requirements.txt'}"
assert len(sync_calls) == expected_sync_calls
if expected_sync_calls:
assert sync_calls == [
f"{tmp_path / 'venv'}|sync --project {update_tree} "
f"--locked --inexact --no-dev --no-install-project "
f"--python {tmp_path / 'venv' / 'bin' / 'python3'}"
]
@pytest.mark.parametrize("failure", ("compile", "install", "post_install"))
def test_failed_dependency_update_does_not_overwrite_current_manifests(
@pytest.mark.parametrize("missing_manifest", ("pyproject.toml", "uv.lock"))
def test_dependency_update_requires_complete_uv_manifests(
tmp_path: Path,
failure: str,
missing_manifest: str,
) -> None:
venv_bin = tmp_path / "venv" / "bin"
venv_bin.mkdir(parents=True)
command_log = tmp_path / "commands.log"
copy_log = tmp_path / "copies.log"
for executable in ("pip", "pip-compile"):
path = venv_bin / executable
path.write_text(
"#!/bin/bash\n"
'printf \'%s|%s\\n\' "$(basename "$0")" "$*" >> "${COMMAND_LOG}"\n'
f'[[ "$(basename "$0")" == "pip-compile" && "{failure}" == "compile" ]] && exit 1\n'
f'[[ "$(basename "$0")" == "pip" && "{failure}" == "install" ]] && exit 1\n'
"exit 0\n",
encoding="utf-8",
)
path.chmod(0o755)
update_tree = tmp_path / "update" / "App"
update_tree.mkdir(parents=True)
(update_tree / "requirements.in").write_text("new-package==1\n", encoding="utf-8")
(update_tree / "version.py").write_text(
"FRONTEND_VERSION = 'v3.0.0'\n",
(update_tree / "pyproject.toml").write_text("[project]\n", encoding="utf-8")
(update_tree / "uv.lock").write_text("version = 1\n", encoding="utf-8")
(update_tree / missing_manifest).unlink()
route_marker = tmp_path / "route-called"
copy_marker = tmp_path / "copy-called"
script = textwrap.dedent(
f"""\
CONFIG_DIR="$1"
TMP_PATH="$2"
ROUTE_MARKER="$3"
COPY_MARKER="$4"
PIP_PROXY= PROXY_HOST=
source {UPDATER!s}
INFO() {{ :; }}
WARN() {{ :; }}
ERROR() {{ :; }}
download_and_unzip() {{ return 0; }}
configure_package_route() {{ touch "${{ROUTE_MARKER}}"; }}
cp() {{ touch "${{COPY_MARKER}}"; }}
! install_backend_and_download_resources tags/v3.0.1.zip
"""
)
subprocess.run(
[
"bash",
"-c",
script,
"incomplete-manifest-test",
str(tmp_path / "config"),
str(tmp_path / "update"),
str(route_marker),
str(copy_marker),
],
text=True,
capture_output=True,
check=True,
)
assert not route_marker.exists()
assert not copy_marker.exists()
def test_failed_dependency_sync_does_not_replace_program_files(tmp_path: Path) -> None:
uv_bin = tmp_path / "bin" / "uv"
uv_bin.parent.mkdir(parents=True)
uv_bin.write_text(
"#!/bin/bash\nprintf '%s\\n' \"$*\" >> \"${UV_LOG}\"\nexit 1\n",
encoding="utf-8",
)
uv_bin.chmod(0o755)
update_tree = tmp_path / "update" / "App"
update_tree.mkdir(parents=True)
(update_tree / "pyproject.toml").write_text("[project]\n", encoding="utf-8")
(update_tree / "uv.lock").write_text("version = 1\n", encoding="utf-8")
copy_log = tmp_path / "copies.log"
uv_log = tmp_path / "uv.log"
script = textwrap.dedent(
f"""\
CONFIG_DIR="$1"
VENV_PATH="$2"
TMP_PATH="$3"
COPY_LOG="$4"
UV_BIN="$4"
COPY_LOG="$5"
PIP_PROXY= PROXY_HOST=
FAILURE={failure}
source {UPDATER!s}
INFO() {{ :; }}
WARN() {{ :; }}
ERROR() {{ :; }}
download_and_unzip() {{
if [[ "${{FAILURE}}" = post_install ]] && [[ "$2" = dist ]]; then
return 1
fi
return 0
}}
download_and_unzip() {{ return 0; }}
cmp() {{ return 1; }}
cp() {{ printf '%s\n' "$*" >> "${{COPY_LOG}}"; }}
configure_pip_route() {{ PIP_LOG=test; }}
configure_package_route() {{ PACKAGE_LOG=test; PACKAGE_ENV=(); UV_OPTIONS=(); }}
install_backend_and_download_resources tags/v3.0.1.zip || true
if [[ "${{FAILURE}}" = post_install ]]; then
install_backend_and_download_resources tags/v3.0.1.zip || true
fi
"""
)
@@ -818,38 +877,20 @@ def test_failed_dependency_update_does_not_overwrite_current_manifests(
str(tmp_path / "config"),
str(tmp_path / "venv"),
str(tmp_path / "update"),
str(uv_bin),
str(copy_log),
],
text=True,
capture_output=True,
check=True,
env={**os.environ, "COMMAND_LOG": str(command_log)},
env={**os.environ, "UV_LOG": str(uv_log)},
)
assert not copy_log.exists()
commands = command_log.read_text(encoding="utf-8").splitlines()
assert commands[0] == (
f"pip-compile|{update_tree / 'requirements.in'} "
f"-o {tmp_path / 'update' / 'requirements.txt'}"
assert uv_log.read_text(encoding="utf-8") == (
f"sync --project {update_tree} --locked --inexact --no-dev "
f"--no-install-project --python {tmp_path / 'venv' / 'bin' / 'python3'}\n"
)
assert all("/app/requirements" not in command for command in commands)
if failure == "compile":
assert len(commands) == 1
elif failure == "install":
assert commands[1] == f"pip|install -r {tmp_path / 'update' / 'requirements.txt'}"
else:
assert commands == [
(
f"pip-compile|{update_tree / 'requirements.in'} "
f"-o {tmp_path / 'update' / 'requirements.txt'}"
),
f"pip|install -r {tmp_path / 'update' / 'requirements.txt'}",
(
f"pip-compile|{update_tree / 'requirements.in'} "
f"-o {tmp_path / 'update' / 'requirements.txt'}"
),
f"pip|install -r {tmp_path / 'update' / 'requirements.txt'}",
]
def test_package_index_probe_is_cacheless_and_bounded(tmp_path: Path) -> None:
@@ -859,11 +900,12 @@ def test_package_index_probe_is_cacheless_and_bounded(tmp_path: Path) -> None:
CONFIG_DIR="$1"
VENV_PATH="$2"
TIMEOUT_LOG="$3"
UV_BIN="$4"
PIP_PROXY=https://packages.example/simple
PROXY_HOST=
source {UPDATER!s}
timeout() {{ printf '%s\n' "$*" > "${{TIMEOUT_LOG}}"; return 124; }}
test_connectivity_pip 0 || true
test_connectivity_package 0 || true
"""
)
@@ -876,6 +918,7 @@ def test_package_index_probe_is_cacheless_and_bounded(tmp_path: Path) -> None:
str(tmp_path / "config"),
str(tmp_path / "venv"),
str(timeout_log),
"/fake/uv",
],
text=True,
capture_output=True,
@@ -885,13 +928,12 @@ def test_package_index_probe_is_cacheless_and_bounded(tmp_path: Path) -> None:
command = timeout_log.read_text(encoding="utf-8")
assert command.startswith("--kill-after=2s 10s env ")
assert "UV_NO_CACHE=1" in command
assert "PIP_NO_CACHE_DIR=1" in command
assert "UV_HTTP_TIMEOUT=5" in command
assert "PIP_DEFAULT_TIMEOUT=5" in command
assert "UV_HTTP_RETRIES=0" in command
assert "PIP_RETRIES=0" in command
assert "pip install --target " in command
assert " --no-deps -i https://packages.example/simple pip-hello-world" in command
assert "/fake/uv pip install --target " in command
assert (
" --no-deps --default-index https://packages.example/simple pip-hello-world" in command
)
assert "uninstall" not in command
probe_dir = Path(command.split("--target ", 1)[1].split(" ", 1)[0])
assert not probe_dir.exists()
@@ -32,6 +32,18 @@ def _write_fake_chown(tmp_path: Path) -> Path:
encoding="utf-8",
)
chown.chmod(0o755)
gosu = fake_bin / "gosu"
gosu.write_text(
textwrap.dedent(
"""\
#!/usr/bin/env bash
shift
exec "$@"
"""
),
encoding="utf-8",
)
gosu.chmod(0o755)
return fake_bin
@@ -78,6 +90,7 @@ def _run_permission_case(tmp_path: Path, body: str, env: dict[str, str] | None =
"PUID": str(os.getuid()),
"PGID": str(os.getgid()),
}
case_env.pop("UV_CACHE_DIR", None)
if env:
case_env.update(env)
@@ -428,6 +441,52 @@ def test_runtime_writable_paths_are_still_corrected(tmp_path: Path) -> None:
assert not any(f"{tmp_path}/public" in line for line in lines)
def test_external_package_cache_is_repaired_without_chowning_parent(
tmp_path: Path,
) -> None:
external_cache = tmp_path / "package-cache" / "uv"
log = _run_permission_case(
tmp_path,
"""
gosu() { shift; "$@"; }
UV_CACHE_DIR="${EXTERNAL_CACHE}" HOME="${HOME_DIR}" correct_file_permissions
""",
env={"EXTERNAL_CACHE": str(external_cache)},
)
assert f"-R moviepilot:moviepilot {external_cache}" in log.splitlines()
assert not any(
line.endswith(str(external_cache.parent)) for line in log.splitlines()
)
def test_external_package_cache_write_probe_failure_is_fatal(tmp_path: Path) -> None:
output = _run_entrypoint_case(
tmp_path,
"""
ERROR() { printf '%s\n' "$1"; }
chown() { :; }
gosu() { return 1; }
CONFIG_DIR="${CASE_CONFIG_DIR}"
VENV_PATH="${CASE_VENV_PATH}"
UV_CACHE_DIR="${CASE_CACHE_DIR}"
if correct_package_cache_permissions; then
printf 'unexpected-success\n'
else
printf 'rejected\n'
fi
""",
env={
"CASE_CONFIG_DIR": str(tmp_path / "config"),
"CASE_VENV_PATH": str(tmp_path / "venv"),
"CASE_CACHE_DIR": str(tmp_path / "external-cache"),
},
)
assert "uv 缓存目录不可写" in output
assert output.endswith("rejected\n")
def test_explicit_browser_cache_subtree_is_not_scanned_by_permission_repair(
tmp_path: Path,
) -> None:
+174 -96
View File
@@ -59,20 +59,31 @@ class LocalSetupConfigDirTests(unittest.TestCase):
self.assertIsNone(result)
prompt_mock.assert_not_called()
def test_supported_python_accepts_versions_newer_than_current_ci(self):
module = load_local_setup_module()
with patch.object(module, "get_python_version", return_value=(3, 15, 0)):
module.ensure_supported_python("python3.15")
def test_supported_python_rejects_versions_below_3_12(self):
module = load_local_setup_module()
with patch.object(module, "get_python_version", return_value=(3, 11, 9)):
with self.assertRaisesRegex(RuntimeError, r"Python 3\.12\+"):
module.ensure_supported_python("python3.11")
def test_install_deps_installs_browser_runtime(self):
module = load_local_setup_module()
with tempfile.TemporaryDirectory() as temp_dir:
venv_dir = (Path(temp_dir) / "venv").resolve()
root = Path(temp_dir)
venv_dir = (root / "venv").resolve()
venv_python = venv_dir / "bin" / "python"
venv_pip = venv_dir / "bin" / "pip"
uv_bin = root / "tools" / "uv"
with patch.object(module, "ensure_supported_python"), \
patch.object(
module,
"configure_venv_pip_compat",
return_value=venv_pip,
), \
patch.object(module, "require_uv", return_value=uv_bin), \
patch.object(module, "expose_uv_to_venv") as expose_uv, \
patch.object(module, "run") as run_mock, \
patch.object(module, "install_browser_runtime") as install_browser:
result = module.install_deps(
@@ -82,13 +93,23 @@ class LocalSetupConfigDirTests(unittest.TestCase):
)
self.assertEqual(result, venv_python)
run_mock.assert_any_call(["python3", "-m", "venv", str(venv_dir)])
self.assertTrue(
any(
call.args[0] == [str(venv_pip), "install", "-r", str(module.ROOT / "requirements.txt")]
for call in run_mock.call_args_list
)
command = run_mock.call_args.args[0]
self.assertEqual(
command,
[
str(uv_bin),
"sync",
"--project",
str(module.ROOT),
"--locked",
"--no-dev",
"--no-install-project",
"--python",
"python3",
],
)
self.assertEqual(run_mock.call_args.kwargs["env"]["UV_PROJECT_ENVIRONMENT"], str(venv_dir))
expose_uv.assert_called_once_with(uv_bin, venv_dir)
install_browser.assert_called_once_with(venv_python)
def test_package_install_env_maps_proxy_cache_and_index(self):
@@ -101,17 +122,17 @@ class LocalSetupConfigDirTests(unittest.TestCase):
"PIP_PROXY": "https://user:pass@mirror.example/simple",
"PACKAGE_CACHE_ROOT": str(Path(temp_dir) / "custom-package-cache"),
},
clear=False,
clear=True,
):
module.CONFIG_DIR = Path(temp_dir)
env = module.build_package_install_env()
self.assertEqual(env["HTTPS_PROXY"], "http://proxy.example:7890")
self.assertEqual(env["PACKAGE_CACHE_ROOT"], str(Path(temp_dir) / "custom-package-cache"))
self.assertEqual(env["PIP_CACHE_DIR"], str(Path(temp_dir) / "custom-package-cache" / "pip"))
self.assertEqual(env["UV_CACHE_DIR"], str(Path(temp_dir) / "custom-package-cache" / "uv"))
self.assertEqual(env["PIP_INDEX_URL"], "https://user:pass@mirror.example/simple")
self.assertEqual(env["UV_DEFAULT_INDEX"], "https://user:pass@mirror.example/simple")
self.assertNotIn("PIP_CACHE_DIR", env)
self.assertNotIn("PIP_INDEX_URL", env)
def test_package_install_env_defaults_cache_to_config_dir(self):
module = load_local_setup_module()
@@ -125,26 +146,24 @@ class LocalSetupConfigDirTests(unittest.TestCase):
env = module.build_package_install_env()
self.assertEqual(env["PACKAGE_CACHE_ROOT"], str(Path(temp_dir) / ".cache"))
self.assertEqual(env["PIP_CACHE_DIR"], str(Path(temp_dir) / ".cache" / "pip"))
self.assertEqual(env["UV_CACHE_DIR"], str(Path(temp_dir) / ".cache" / "uv"))
self.assertNotIn("PIP_CACHE_DIR", env)
def test_package_install_env_preserves_explicit_cache_dirs(self):
def test_package_install_env_preserves_explicit_uv_cache_dir(self):
module = load_local_setup_module()
with tempfile.TemporaryDirectory() as temp_dir, patch.dict(
module.os.environ,
{
"PIP_CACHE_DIR": "/custom/pip-cache",
"UV_CACHE_DIR": "/custom/uv-cache",
"PACKAGE_CACHE_ROOT": "/custom/custom-package-cache",
},
clear=False,
clear=True,
):
module.CONFIG_DIR = Path(temp_dir)
env = module.build_package_install_env()
self.assertEqual(env["PACKAGE_CACHE_ROOT"], "/custom/custom-package-cache")
self.assertEqual(env["PIP_CACHE_DIR"], "/custom/pip-cache")
self.assertEqual(env["UV_CACHE_DIR"], "/custom/uv-cache")
def test_run_redacts_safe_command(self):
@@ -153,19 +172,15 @@ class LocalSetupConfigDirTests(unittest.TestCase):
with patch.object(module.subprocess, "run"), patch("builtins.print") as print_mock:
module.run(
[
"python",
"-m",
"pip",
"install",
"-i",
"uv",
"sync",
"--default-index",
"https://user:pass@mirror.example/simple",
],
safe_command=[
"python",
"-m",
"pip",
"install",
"-i",
"uv",
"sync",
"--default-index",
"https://mirror.example/simple",
],
)
@@ -178,22 +193,22 @@ class LocalSetupConfigDirTests(unittest.TestCase):
module = load_local_setup_module()
command = [
"pip",
"install",
"--index-url=https://user:pass@mirror.example/simple",
"uv",
"sync",
"--default-index=https://user:pass@mirror.example/simple",
]
redacted = module.redact_command(command)
self.assertIn("--index-url=https://mirror.example/simple", redacted)
self.assertIn("--default-index=https://mirror.example/simple", redacted)
self.assertNotIn("user:pass", " ".join(redacted))
def test_redact_command_handles_url_query_equals(self):
module = load_local_setup_module()
command = [
"pip",
"install",
"uv",
"sync",
"https://user:pass@mirror.example/simple?token=abc",
]
@@ -202,45 +217,115 @@ class LocalSetupConfigDirTests(unittest.TestCase):
self.assertIn("https://mirror.example/simple?token=abc", redacted)
self.assertNotIn("user:pass", " ".join(redacted))
def test_uv_bootstrap_uses_package_env_and_index_without_visible_secret(self):
def test_require_uv_accepts_repository_version(self):
module = load_local_setup_module()
calls = []
uv_bin = Path("/opt/moviepilot/bin/uv")
with tempfile.TemporaryDirectory() as temp_dir, patch.dict(
module.os.environ,
{
"PROXY_HOST": "http://proxy.example:7890",
"PIP_PROXY": "https://user:pass@mirror.example/simple",
"PACKAGE_CACHE_ROOT": str(Path(temp_dir) / "custom-package-cache"),
},
clear=False,
with patch.object(module.shutil, "which", return_value=str(uv_bin)), patch.object(
module, "capture", return_value=f"uv {module.UV_VERSION} (test-target)"
):
result = module.require_uv()
self.assertEqual(result, uv_bin.resolve())
def test_windows_expose_uv_keeps_existing_source_when_target_is_same(self):
module = load_local_setup_module()
with tempfile.TemporaryDirectory() as temp_dir:
venv_dir = Path(temp_dir) / "venv"
venv_python = venv_dir / "bin" / "python"
uv_bin = venv_dir / "Scripts" / "uv.exe"
uv_bin.parent.mkdir(parents=True)
uv_bin.write_bytes(b"uv-binary")
with patch.object(module.os, "name", "nt"):
result = module.expose_uv_to_venv(uv_bin, venv_dir)
self.assertEqual(result, uv_bin)
self.assertEqual(uv_bin.read_bytes(), b"uv-binary")
def test_recreate_preserves_uv_located_inside_old_venv(self):
module = load_local_setup_module()
commands = []
with tempfile.TemporaryDirectory() as temp_dir:
venv_dir = (Path(temp_dir) / "venv").resolve()
uv_bin = venv_dir / "bin" / "uv"
venv_python.parent.mkdir(parents=True)
venv_python.write_text("", encoding="utf-8")
module.CONFIG_DIR = Path(temp_dir) / "config"
uv_bin.parent.mkdir(parents=True)
uv_bin.write_bytes(b"uv-binary")
def fake_run(command, cwd=None, env=None, safe_command=None):
calls.append((command, env, safe_command))
uv_bin.write_text("", encoding="utf-8")
def fake_run(command, **_kwargs):
self.assertTrue(Path(command[0]).is_file())
commands.append(command)
with patch.object(module.shutil, "which", return_value=None), \
with patch.object(module, "ensure_supported_python"), \
patch.object(module, "require_uv", return_value=uv_bin), \
patch.object(module, "install_browser_runtime"), \
patch.object(module, "run", side_effect=fake_run):
module._ensure_uv_available_for_venv(venv_dir, venv_python)
module.install_deps(
python_bin="python.exe",
venv_dir=venv_dir,
recreate=True,
)
command, env, safe_command = calls[0]
self.assertEqual(command, [str(venv_python), "-m", "pip", "install", "--upgrade", "pip", "uv"])
self.assertEqual(env["PIP_INDEX_URL"], "https://user:pass@mirror.example/simple")
self.assertEqual(env["UV_DEFAULT_INDEX"], "https://user:pass@mirror.example/simple")
self.assertEqual(env["HTTPS_PROXY"], "http://proxy.example:7890")
self.assertEqual(env["PACKAGE_CACHE_ROOT"], str(Path(temp_dir) / "custom-package-cache"))
self.assertEqual(env["PIP_CACHE_DIR"], str(Path(temp_dir) / "custom-package-cache" / "pip"))
self.assertEqual(env["UV_CACHE_DIR"], str(Path(temp_dir) / "custom-package-cache" / "uv"))
self.assertNotIn("user:pass", " ".join(safe_command or command))
self.assertEqual(len(commands), 1)
self.assertNotEqual(Path(commands[0][0]), uv_bin)
self.assertNotIn("--inexact", commands[0])
self.assertEqual(uv_bin.read_bytes(), b"uv-binary")
def test_windows_pip_upgrade_uses_package_env(self):
def test_recreate_rejects_python_from_target_venv(self):
module = load_local_setup_module()
with tempfile.TemporaryDirectory() as temp_dir:
venv_dir = (Path(temp_dir) / "venv").resolve()
python_bin = venv_dir / "bin" / "python"
python_bin.parent.mkdir(parents=True)
python_bin.touch()
with patch.object(module, "ensure_supported_python"), \
self.assertRaisesRegex(RuntimeError, "venv 外部"):
module.install_deps(
python_bin=str(python_bin),
venv_dir=venv_dir,
recreate=True,
)
def test_recreate_rejects_current_python_inside_target_venv(self):
module = load_local_setup_module()
with tempfile.TemporaryDirectory() as temp_dir:
venv_dir = (Path(temp_dir) / "venv").resolve()
running_python = venv_dir / "bin" / "python"
running_python.parent.mkdir(parents=True)
running_python.touch()
with patch.object(module, "ensure_supported_python"), patch.object(
module.sys, "executable", str(running_python)
), self.assertRaisesRegex(RuntimeError, "venv 外部"):
module.install_deps(
python_bin="/usr/bin/python3",
venv_dir=venv_dir,
recreate=True,
)
def test_recreate_resolves_python_command_through_path(self):
module = load_local_setup_module()
with tempfile.TemporaryDirectory() as temp_dir:
venv_dir = (Path(temp_dir) / "venv").resolve()
path_python = venv_dir / "bin" / "python"
path_python.parent.mkdir(parents=True)
path_python.touch()
with patch.object(module, "ensure_supported_python"), patch.object(
module.shutil, "which", return_value=str(path_python)
), self.assertRaisesRegex(RuntimeError, "venv 外部"):
module.install_deps(
python_bin="python3",
venv_dir=venv_dir,
recreate=True,
)
def test_windows_install_deps_uses_uv_without_pip_bootstrap(self):
module = load_local_setup_module()
calls = []
@@ -251,15 +336,12 @@ class LocalSetupConfigDirTests(unittest.TestCase):
"PIP_PROXY": "https://user:pass@mirror.example/simple",
"PACKAGE_CACHE_ROOT": str(Path(temp_dir) / "custom-package-cache"),
},
clear=False,
clear=True,
):
root = Path(temp_dir)
venv_dir = root / "venv"
venv_python = venv_dir / "Scripts" / "python.exe"
venv_pip = venv_dir / "Scripts" / "pip.exe"
venv_pip.parent.mkdir(parents=True)
venv_python.write_text("", encoding="utf-8")
venv_pip.write_text("", encoding="utf-8")
uv_bin = root / "tools" / "uv.exe"
module.CONFIG_DIR = root / "config"
def fake_run(command, cwd=None, env=None, safe_command=None):
@@ -267,23 +349,24 @@ class LocalSetupConfigDirTests(unittest.TestCase):
with patch.object(module.os, "name", "nt"), \
patch.object(module, "ensure_supported_python"), \
patch.object(module, "require_uv", return_value=uv_bin), \
patch.object(module, "expose_uv_to_venv"), \
patch.object(module, "install_browser_runtime"), \
patch.object(module, "run", side_effect=fake_run):
module.install_deps(python_bin="python", venv_dir=venv_dir, recreate=False)
pip_upgrade = [
item for item in calls
if item[0][1:] == ["-m", "pip", "install", "--upgrade", "pip"]
][0]
self.assertEqual(pip_upgrade[1]["PIP_INDEX_URL"], "https://user:pass@mirror.example/simple")
self.assertEqual(pip_upgrade[1]["UV_DEFAULT_INDEX"], "https://user:pass@mirror.example/simple")
self.assertEqual(pip_upgrade[1]["HTTPS_PROXY"], "http://proxy.example:7890")
self.assertEqual(pip_upgrade[1]["PACKAGE_CACHE_ROOT"], str(Path(temp_dir) / "custom-package-cache"))
self.assertEqual(pip_upgrade[1]["PIP_CACHE_DIR"], str(Path(temp_dir) / "custom-package-cache" / "pip"))
self.assertEqual(pip_upgrade[1]["UV_CACHE_DIR"], str(Path(temp_dir) / "custom-package-cache" / "uv"))
self.assertNotIn("user:pass", " ".join(pip_upgrade[2] or pip_upgrade[0]))
self.assertEqual(len(calls), 1)
command, env, safe_command = calls[0]
self.assertEqual(command[:2], [str(uv_bin), "sync"])
self.assertNotIn("pip", command)
self.assertEqual(env["UV_PROJECT_ENVIRONMENT"], str(venv_dir.resolve()))
self.assertEqual(env["UV_DEFAULT_INDEX"], "https://user:pass@mirror.example/simple")
self.assertEqual(env["HTTPS_PROXY"], "http://proxy.example:7890")
self.assertEqual(env["PACKAGE_CACHE_ROOT"], str(Path(temp_dir) / "custom-package-cache"))
self.assertEqual(env["UV_CACHE_DIR"], str(Path(temp_dir) / "custom-package-cache" / "uv"))
self.assertNotIn("user:pass", " ".join(safe_command or command))
def test_install_deps_uses_package_env_for_project_requirements(self):
def test_install_deps_uses_package_env_for_project_lock(self):
module = load_local_setup_module()
calls = []
@@ -294,26 +377,21 @@ class LocalSetupConfigDirTests(unittest.TestCase):
):
root = Path(temp_dir)
venv_dir = root / "venv"
venv_python = venv_dir / "bin" / "python"
venv_pip = venv_dir / "bin" / "pip"
venv_pip.parent.mkdir(parents=True)
venv_python.write_text("", encoding="utf-8")
venv_pip.write_text("", encoding="utf-8")
uv_bin = root / "tools" / "uv"
module.CONFIG_DIR = root / "config"
def fake_run(command, cwd=None, env=None, safe_command=None):
calls.append((command, env, safe_command))
with patch.object(module, "ensure_supported_python"), \
patch.object(module, "configure_venv_pip_compat", return_value=venv_pip), \
patch.object(module, "require_uv", return_value=uv_bin), \
patch.object(module, "expose_uv_to_venv"), \
patch.object(module, "install_browser_runtime"), \
patch.object(module, "run", side_effect=fake_run):
module.install_deps(python_bin="python3", venv_dir=venv_dir, recreate=False)
project_install = [
item for item in calls
if item[0][:2] == [str(venv_pip), "install"] and "-r" in item[0]
][0]
self.assertEqual(project_install[1]["PIP_INDEX_URL"], "https://user:pass@mirror.example/simple")
self.assertEqual(project_install[1]["UV_DEFAULT_INDEX"], "https://user:pass@mirror.example/simple")
self.assertNotIn("user:pass", " ".join(project_install[2] or project_install[0]))
project_sync = calls[0]
self.assertEqual(project_sync[0][:2], [str(uv_bin), "sync"])
self.assertIn("--locked", project_sync[0])
self.assertEqual(project_sync[1]["UV_DEFAULT_INDEX"], "https://user:pass@mirror.example/simple")
self.assertNotIn("user:pass", " ".join(project_sync[2] or project_sync[0]))
+167
View File
@@ -0,0 +1,167 @@
from pathlib import Path
import os
import stat
import subprocess
import pytest
LAUNCHER = Path(__file__).resolve().parents[1] / "moviepilot"
@pytest.mark.parametrize(
"arguments",
[
("install", "deps", "--recreate"),
("setup", "--recreate"),
("update", "backend", "--recreate"),
],
)
def test_recreate_commands_use_external_bootstrap_python(tmp_path, arguments):
"""所有会删除 venv 的 launcher 入口都不能由目标 venv Python 执行。"""
root = tmp_path / "moviepilot"
root.mkdir()
launcher = root / "moviepilot"
launcher.write_text(LAUNCHER.read_text(encoding="utf-8"), encoding="utf-8")
launcher.chmod(launcher.stat().st_mode | stat.S_IXUSR)
(root / "scripts").mkdir()
(root / "scripts" / "local_setup.py").write_text("# test stub\n", encoding="utf-8")
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
record = tmp_path / "record"
external_python = bin_dir / "python3.12"
external_python.write_text(
"#!/bin/sh\n"
"if [ \"$1\" = \"-\" ]; then exit 0; fi\n"
"printf '%s\\n' \"$0 $*\" > \"$MOVIEPILOT_TEST_RECORD\"\n",
encoding="utf-8",
)
external_python.chmod(external_python.stat().st_mode | stat.S_IXUSR)
venv_python = root / "venv" / "bin" / "python"
venv_python.parent.mkdir(parents=True)
venv_script = (
"#!/bin/sh\n"
"printf '%s\\n' \"$0 $*\" > \"$MOVIEPILOT_TEST_RECORD\"\n"
)
venv_python.write_text(venv_script, encoding="utf-8")
venv_python.chmod(venv_python.stat().st_mode | stat.S_IXUSR)
venv_alias = venv_python.with_name("python3.12")
venv_alias.write_text(venv_script, encoding="utf-8")
venv_alias.chmod(venv_alias.stat().st_mode | stat.S_IXUSR)
env = os.environ.copy()
env["PATH"] = f"{venv_python.parent}:{bin_dir}:/usr/bin:/bin"
env["MOVIEPILOT_TEST_RECORD"] = str(record)
subprocess.run(
[str(launcher), *arguments],
cwd=root,
env=env,
check=True,
capture_output=True,
text=True,
)
invocation = record.read_text(encoding="utf-8")
assert invocation.startswith(f"{external_python} ")
def test_recreate_accepts_explicit_external_python(tmp_path):
"""显式指定的外部 Python 可作为重建命令的执行解释器。"""
root = tmp_path / "moviepilot"
root.mkdir()
launcher = root / "moviepilot"
launcher.write_text(LAUNCHER.read_text(encoding="utf-8"), encoding="utf-8")
launcher.chmod(launcher.stat().st_mode | stat.S_IXUSR)
(root / "scripts").mkdir()
(root / "scripts" / "local_setup.py").write_text("# test stub\n", encoding="utf-8")
explicit_python = tmp_path / "custom-python"
record = tmp_path / "record"
explicit_python.write_text(
"#!/bin/sh\n"
"if [ \"$1\" = \"-\" ]; then exit 0; fi\n"
"printf '%s\\n' \"$0 $*\" > \"$MOVIEPILOT_TEST_RECORD\"\n",
encoding="utf-8",
)
explicit_python.chmod(explicit_python.stat().st_mode | stat.S_IXUSR)
env = os.environ.copy()
env["PATH"] = "/usr/bin:/bin"
env["MOVIEPILOT_TEST_RECORD"] = str(record)
subprocess.run(
[
str(launcher),
"install",
"deps",
"--recreate",
"--python",
str(explicit_python),
],
cwd=root,
env=env,
check=True,
capture_output=True,
text=True,
)
assert record.read_text(encoding="utf-8").startswith(f"{explicit_python} ")
@pytest.mark.parametrize(
"arguments",
[
("install", "deps", "--recreate"),
("setup", "--recreate"),
("update", "backend", "--recreate"),
],
)
@pytest.mark.parametrize("python_option", [(), ("--python", "python3.12")])
def test_recreate_excludes_custom_venv_from_external_bootstrap(
tmp_path, arguments, python_option
):
"""自定义 --venv 目录中的解释器也不能执行重建流程。"""
root = tmp_path / "moviepilot"
root.mkdir()
launcher = root / "moviepilot"
launcher.write_text(LAUNCHER.read_text(encoding="utf-8"), encoding="utf-8")
launcher.chmod(launcher.stat().st_mode | stat.S_IXUSR)
(root / "scripts").mkdir()
(root / "scripts" / "local_setup.py").write_text("# test stub\n", encoding="utf-8")
target_bin = tmp_path / "custom-venv" / "bin"
target_bin.mkdir(parents=True)
external_bin = tmp_path / "external-bin"
external_bin.mkdir()
record = tmp_path / "record"
target_python = target_bin / "python3.12"
external_python = external_bin / "python3.12"
for python_path in (target_python, external_python):
python_path.write_text(
"#!/bin/sh\n"
"if [ \"$1\" = \"-\" ]; then exit 0; fi\n"
"printf '%s\\n' \"$0 $*\" > \"$MOVIEPILOT_TEST_RECORD\"\n",
encoding="utf-8",
)
python_path.chmod(python_path.stat().st_mode | stat.S_IXUSR)
env = os.environ.copy()
env["PATH"] = f"{target_bin}:{external_bin}:/usr/bin:/bin"
env["MOVIEPILOT_TEST_RECORD"] = str(record)
subprocess.run(
[
str(launcher),
*arguments,
"--venv",
str(target_bin.parent),
*python_option,
],
cwd=root,
env=env,
check=True,
capture_output=True,
text=True,
)
assert record.read_text(encoding="utf-8").startswith(f"{external_python} ")
+9 -18
View File
@@ -17,10 +17,10 @@ def test_build_env_maps_proxy_and_cache(tmp_path, monkeypatch):
monkeypatch.delenv("PACKAGE_CACHE_ROOT", raising=False)
monkeypatch.setenv("HTTP_PROXY", "http://old.example:8080")
request = PackageInstallRequest(
requirements_file=tmp_path / "requirements.txt",
dependency_file=tmp_path / "requirements.txt",
python_bin=Path("/venv/bin/python"),
config_dir=tmp_path / "config",
pip_index_url="https://user:pass@mirror.example/simple",
package_index_url="https://user:pass@mirror.example/simple",
proxy_url="http://proxy.example:7890",
)
@@ -31,16 +31,14 @@ def test_build_env_maps_proxy_and_cache(tmp_path, monkeypatch):
assert env["http_proxy"] == "http://proxy.example:7890"
assert env["https_proxy"] == "http://proxy.example:7890"
assert env["PACKAGE_CACHE_ROOT"] == str(tmp_path / "config" / ".cache")
assert env["PIP_CACHE_DIR"] == str(tmp_path / "config" / ".cache" / "pip")
assert env["UV_CACHE_DIR"] == str(tmp_path / "config" / ".cache" / "uv")
def test_build_env_uses_package_cache_root_and_preserves_tool_cache_overrides(tmp_path, monkeypatch):
monkeypatch.setenv("PACKAGE_CACHE_ROOT", str(tmp_path / "custom-package-cache"))
monkeypatch.setenv("PIP_CACHE_DIR", "/custom/pip")
monkeypatch.delenv("UV_CACHE_DIR", raising=False)
request = PackageInstallRequest(
requirements_file=tmp_path / "requirements.txt",
dependency_file=tmp_path / "requirements.txt",
python_bin=Path("/venv/bin/python"),
config_dir=tmp_path / "config",
)
@@ -48,7 +46,6 @@ def test_build_env_uses_package_cache_root_and_preserves_tool_cache_overrides(tm
env = build_package_install_env(request)
assert env["PACKAGE_CACHE_ROOT"] == str(tmp_path / "custom-package-cache")
assert env["PIP_CACHE_DIR"] == "/custom/pip"
assert env["UV_CACHE_DIR"] == str(tmp_path / "custom-package-cache" / "uv")
@@ -62,11 +59,11 @@ def test_build_strategies_prefers_uv_network_matrix_and_preserves_find_links(tmp
uv_bin.write_text("", encoding="utf-8")
request = PackageInstallRequest(
requirements_file=req,
dependency_file=req,
python_bin=tmp_path / "venv" / "bin" / "python",
find_links_dirs=[wheels],
config_dir=tmp_path / "config",
pip_index_url="https://mirror.example/simple",
package_index_url="https://mirror.example/simple",
proxy_url="http://proxy.example:7890",
)
@@ -77,10 +74,6 @@ def test_build_strategies_prefers_uv_network_matrix_and_preserves_find_links(tmp
"uv:镜像",
"uv:代理",
"uv:直连",
"pip:镜像+代理",
"pip:镜像",
"pip:代理",
"pip:直连",
]
assert strategies[0].command[:3] == [str(uv_bin), "pip", "install"]
assert "--python" in strategies[0].command
@@ -93,23 +86,21 @@ def test_build_strategies_prefers_uv_network_matrix_and_preserves_find_links(tmp
key for key, value in strategies[1].env.items() if value == "http://proxy.example:7890"
}
assert "--default-index" not in strategies[2].command
assert strategies[4].backend == "pip"
assert "-i" in strategies[4].command
def test_build_strategies_uses_pip_only_when_uv_missing(tmp_path):
def test_build_strategies_fail_closed_when_uv_missing(tmp_path):
req = tmp_path / "requirements.txt"
req.write_text("demo\n", encoding="utf-8")
request = PackageInstallRequest(
requirements_file=req,
dependency_file=req,
python_bin=tmp_path / "venv" / "bin" / "python",
config_dir=tmp_path / "config",
)
with patch("app.adapters.system.package._find_uv", return_value=None):
with patch("app.adapters.system.package.find_uv", return_value=None):
strategies = build_package_install_strategies(request)
assert [strategy.strategy_name for strategy in strategies] == ["pip:直连"]
assert strategies == []
def test_redact_url_removes_userinfo():
+366 -4
View File
@@ -2,9 +2,13 @@ from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from packaging.requirements import Requirement
from packaging.utils import canonicalize_name
from packaging.version import Version
from app.adapters.system.plugin.dependency import PluginDependencyInstaller
from app.adapters.system.plugin.manifest import load_dependency_file
def _write_requirements(root: Path, plugin_id: str, content: str) -> None:
@@ -14,6 +18,15 @@ def _write_requirements(root: Path, plugin_id: str, content: str) -> None:
(plugin_dir / "requirements.txt").write_text(content, encoding="utf-8")
def _write_pyproject(root: Path, plugin_id: str, content: str) -> Path:
"""写入一个测试插件的 pyproject 依赖清单。"""
plugin_dir = root / plugin_id.lower()
plugin_dir.mkdir(parents=True, exist_ok=True)
pyproject_file = plugin_dir / "pyproject.toml"
pyproject_file.write_text(content, encoding="utf-8")
return plugin_dir
def test_find_missing_merges_only_installed_plugin_constraints(tmp_path, monkeypatch):
"""依赖扫描只覆盖安装清单,并合并同名包的多插件约束。"""
plugin_root = tmp_path / "plugins"
@@ -58,10 +71,353 @@ def test_find_missing_skips_satisfied_constraints(tmp_path, monkeypatch):
assert installer.find_missing() == []
def test_find_missing_preserves_merged_extras(tmp_path, monkeypatch):
"""同一包的多插件约束合并后必须保留全部 extras。"""
plugin_root = tmp_path / "plugins"
_write_requirements(plugin_root, "Alpha", "Demo-Pkg[alpha]>=2\n")
_write_requirements(plugin_root, "Beta", "demo.pkg[beta]<4\n")
installer = PluginDependencyInstaller(
Mock(),
installed_plugins_provider=lambda: ["Alpha", "Beta"],
plugin_dir=plugin_root,
)
monkeypatch.setattr(installer, "_installed_packages", lambda: {})
missing = installer.find_missing()
assert len(missing) == 1
requirement = Requirement(missing[0])
assert requirement.name == "demo_pkg"
assert requirement.extras == {"alpha", "beta"}
assert ">=2" in str(requirement.specifier)
assert "<4" in str(requirement.specifier)
def test_find_missing_preserves_direct_url(tmp_path, monkeypatch):
"""缺失的 direct URL 依赖必须按原安装来源返回。"""
plugin_root = tmp_path / "plugins"
direct_url = "https://example.com/packages/demo_pkg-2.0.0-py3-none-any.whl"
_write_requirements(
plugin_root,
"Alpha",
f"Demo-Pkg[feature] @ {direct_url}\n",
)
installer = PluginDependencyInstaller(
Mock(),
installed_plugins_provider=lambda: ["Alpha"],
plugin_dir=plugin_root,
)
monkeypatch.setattr(installer, "_installed_packages", lambda: {})
missing = installer.find_missing()
assert len(missing) == 1
requirement = Requirement(missing[0])
assert canonicalize_name(requirement.name) == canonicalize_name("Demo-Pkg")
assert requirement.extras == {"feature"}
assert requirement.url == direct_url
def test_find_missing_does_not_accept_base_package_for_extra(
tmp_path, monkeypatch
):
"""已安装基础包但未安装其 extra 依赖时必须继续恢复。"""
plugin_root = tmp_path / "plugins"
_write_requirements(plugin_root, "Alpha", "Demo[feature]>=1\n")
installer = PluginDependencyInstaller(
Mock(),
installed_plugins_provider=lambda: ["Alpha"],
plugin_dir=plugin_root,
)
monkeypatch.setattr(
installer, "_installed_packages", lambda: {"demo": Version("2.0")}
)
metadata = SimpleNamespace(
get_all=lambda key: {"Provides-Extra": ["feature"], "Requires-Dist": [
"feature-dependency>=1; extra == 'feature'"
]}.get(key, []),
)
monkeypatch.setattr(
installer,
"_installed_distribution",
lambda package_name: SimpleNamespace(metadata=metadata)
if package_name == "demo"
else None,
)
assert installer.find_missing() == ["demo[feature]>=1"]
def test_find_missing_accepts_satisfied_extra_dependencies(tmp_path, monkeypatch):
"""已安装 extra 及其依赖时不得重复恢复。"""
plugin_root = tmp_path / "plugins"
_write_requirements(plugin_root, "Alpha", "Demo[feature]>=1\n")
installer = PluginDependencyInstaller(
Mock(),
installed_plugins_provider=lambda: ["Alpha"],
plugin_dir=plugin_root,
)
monkeypatch.setattr(
installer,
"_installed_packages",
lambda: {"demo": Version("2.0"), "feature_dependency": Version("1.2")},
)
metadata = SimpleNamespace(
get_all=lambda key: {"Provides-Extra": ["feature"], "Requires-Dist": [
"feature-dependency>=1; extra == 'feature'"
]}.get(key, []),
)
monkeypatch.setattr(
installer,
"_installed_distribution",
lambda package_name: SimpleNamespace(metadata=metadata)
if package_name == "demo"
else None,
)
assert installer.find_missing() == []
def test_find_missing_rejects_missing_transitive_extra_dependency(
tmp_path, monkeypatch
):
"""extra 的传递依赖缺失时不能只因根包已安装就跳过恢复。"""
plugin_root = tmp_path / "plugins"
_write_requirements(plugin_root, "Alpha", "Demo[feature]>=1\n")
installer = PluginDependencyInstaller(
Mock(),
installed_plugins_provider=lambda: ["Alpha"],
plugin_dir=plugin_root,
)
monkeypatch.setattr(
installer,
"_installed_packages",
lambda: {"demo": Version("2.0"), "bridge": Version("1.0")},
)
metadata_by_name = {
"demo": SimpleNamespace(
metadata=SimpleNamespace(
get_all=lambda key: {
"Provides-Extra": ["feature"],
"Requires-Dist": ["bridge>=1; extra == 'feature'"],
}.get(key, [])
)
),
"bridge": SimpleNamespace(
metadata=SimpleNamespace(
get_all=lambda key: {
"Requires-Dist": ["leaf>=1"],
}.get(key, [])
)
),
}
monkeypatch.setattr(
installer,
"_installed_distribution",
lambda package_name: metadata_by_name.get(package_name),
)
assert installer.find_missing() == ["demo[feature]>=1"]
def test_find_missing_rejects_same_name_package_from_wrong_direct_url(
tmp_path, monkeypatch
):
"""存在不同 PEP 610 来源时,同名包不能满足 direct URL 依赖。"""
plugin_root = tmp_path / "plugins"
required_url = "https://example.com/packages/demo-2.0.0-py3-none-any.whl"
installed_url = "https://mirror.example.com/packages/demo-2.0.0-py3-none-any.whl"
_write_requirements(plugin_root, "Alpha", f"Demo @ {required_url}\n")
installer = PluginDependencyInstaller(
Mock(),
installed_plugins_provider=lambda: ["Alpha"],
plugin_dir=plugin_root,
)
monkeypatch.setattr(
installer, "_installed_packages", lambda: {"demo": Version("2.0")}
)
metadata = SimpleNamespace(get_all=lambda _key: [])
monkeypatch.setattr(
installer,
"_installed_distribution",
lambda _package_name: SimpleNamespace(
metadata=metadata,
read_text=lambda _name: '{"url": "' + installed_url + '"}',
),
)
assert installer.find_missing() == [f"demo @ {required_url}"]
def test_find_missing_accepts_matching_direct_url(tmp_path, monkeypatch):
"""同名包且 PEP 610 来源一致时应视为已满足。"""
plugin_root = tmp_path / "plugins"
direct_url = "https://example.com/packages/demo-2.0.0-py3-none-any.whl"
_write_requirements(plugin_root, "Alpha", f"Demo @ {direct_url}\n")
installer = PluginDependencyInstaller(
Mock(),
installed_plugins_provider=lambda: ["Alpha"],
plugin_dir=plugin_root,
)
monkeypatch.setattr(
installer, "_installed_packages", lambda: {"demo": Version("2.0")}
)
metadata = SimpleNamespace(get_all=lambda _key: [])
monkeypatch.setattr(
installer,
"_installed_distribution",
lambda _package_name: SimpleNamespace(
metadata=metadata,
read_text=lambda _name: '{"url": "' + direct_url + '"}',
),
)
assert installer.find_missing() == []
def test_find_missing_prefers_pyproject_project_dependencies(
tmp_path,
monkeypatch,
):
"""现代清单优先,且只消费 project.dependencies。"""
plugin_root = tmp_path / "plugins"
plugin_dir = _write_pyproject(
plugin_root,
"Alpha",
"""
[project]
name = "alpha"
version = "1.0.0"
dependencies = ["Modern-Pkg>=2"]
[dependency-groups]
dev = ["group-only>=1"]
""",
)
(plugin_dir / "requirements.txt").write_text(
"legacy-only>=1\n",
encoding="utf-8",
)
(plugin_dir / "uv.lock").write_text(
'package = [{ name = "lock-only", version = "1.0.0" }]\n',
encoding="utf-8",
)
installer = PluginDependencyInstaller(
Mock(),
installed_plugins_provider=lambda: ["Alpha"],
plugin_dir=plugin_root,
)
monkeypatch.setattr(installer, "_installed_packages", lambda: {})
missing = installer.find_missing()
assert missing == ["modern_pkg>=2"]
@pytest.mark.parametrize(
"pyproject",
[
"[project\n",
'[project]\ndependencies = "demo>=2"\n',
'[project]\ndependencies = ["not a requirement !!!"]\n',
'[project]\ndynamic = ["dependencies"]\n',
],
)
def test_find_missing_fails_closed_for_invalid_pyproject(
tmp_path,
monkeypatch,
pyproject,
):
"""现代清单无效时不得回退并消费旧 requirements。"""
plugin_root = tmp_path / "plugins"
plugin_dir = _write_pyproject(plugin_root, "Alpha", pyproject)
(plugin_dir / "requirements.txt").write_text(
"legacy-only>=1\n",
encoding="utf-8",
)
installer = PluginDependencyInstaller(
Mock(),
installed_plugins_provider=lambda: ["Alpha"],
plugin_dir=plugin_root,
)
monkeypatch.setattr(installer, "_installed_packages", lambda: {})
with pytest.raises(ValueError, match="pyproject.toml"):
installer.find_missing()
@pytest.mark.parametrize(
"pyproject",
[
'[project]\nversion = "1.0.0"\ndependencies = ["demo>=2"]\n',
'[project]\nname = "alpha"\ndependencies = ["demo>=2"]\n',
'[project]\nname = " "\nversion = "1.0.0"\n'
'dependencies = ["demo>=2"]\n',
'[project]\nname = "alpha"\nversion = " "\n'
'dependencies = ["demo>=2"]\n',
],
)
def test_find_missing_fails_closed_without_required_project_identity(
tmp_path,
monkeypatch,
pyproject,
):
"""现代清单缺少 uv 消费所需的 name 或 version 时必须拒绝安装。"""
plugin_root = tmp_path / "plugins"
_write_pyproject(plugin_root, "Alpha", pyproject)
installer = PluginDependencyInstaller(
Mock(),
installed_plugins_provider=lambda: ["Alpha"],
plugin_dir=plugin_root,
)
monkeypatch.setattr(installer, "_installed_packages", lambda: {})
with pytest.raises(ValueError, match="pyproject.toml"):
installer.find_missing()
def test_find_missing_accepts_dynamic_project_version(tmp_path, monkeypatch):
"""version 由构建后端动态提供时仍可消费静态 dependencies。"""
plugin_root = tmp_path / "plugins"
_write_pyproject(
plugin_root,
"Alpha",
'[project]\nname = "alpha"\ndynamic = ["version"]\n'
'dependencies = ["demo>=2"]\n',
)
installer = PluginDependencyInstaller(
Mock(),
installed_plugins_provider=lambda: ["Alpha"],
plugin_dir=plugin_root,
)
monkeypatch.setattr(installer, "_installed_packages", lambda: {})
assert installer.find_missing() == ["demo>=2"]
def test_load_dependency_file_accepts_custom_legacy_filename(tmp_path):
"""临时或自定义命名的旧格式依赖文件复用统一解析器。"""
dependency_file = tmp_path / "plugin-dependencies.txt"
dependency_file.write_text("Demo-Pkg>=2\n", encoding="utf-8")
manifest = load_dependency_file(dependency_file)
assert manifest.path == dependency_file
assert [str(requirement) for requirement in manifest.dependencies] == [
"Demo-Pkg>=2"
]
def test_install_uses_adapter_owned_temporary_requirements(tmp_path, monkeypatch):
"""批量依赖文件由依赖适配器创建并在 pip 返回后清理。"""
"""批量依赖文件由依赖适配器创建并在安装返回后清理。"""
helper = Mock()
helper.pip_install_with_fallback.return_value = (True, "installed")
installed_contents = []
def _install_packages(dependency_file, _wheels_dirs):
installed_contents.append(dependency_file.read_text(encoding="utf-8"))
return True, "installed"
helper.install_packages_with_fallback.side_effect = _install_packages
monkeypatch.setattr(
"app.adapters.system.plugin.dependency.settings",
SimpleNamespace(ROOT_PATH=tmp_path, TEMP_PATH=tmp_path / "temp"),
@@ -72,9 +428,15 @@ def test_install_uses_adapter_owned_temporary_requirements(tmp_path, monkeypatch
plugin_dir=tmp_path / "plugins",
)
result = installer.install(["demo>=2", "other"])
result = installer.install([
"demo[feature] @ https://example.com/demo.whl",
"other",
])
assert result == (True, "installed")
requirements_file = helper.pip_install_with_fallback.call_args.args[0]
assert installed_contents == [
"demo[feature] @ https://example.com/demo.whl\nother\n"
]
requirements_file = helper.install_packages_with_fallback.call_args.args[0]
assert requirements_file.name == "requirements.txt"
assert not requirements_file.exists()
+224 -144
View File
@@ -1,5 +1,6 @@
import asyncio
import io
import os
import stat
import sys
import tempfile
@@ -102,6 +103,14 @@ def _build_release_zip_member(name: str, *, symlink: bool = False) -> bytes:
return buffer.getvalue()
def _create_fake_uv(root: Path) -> Path:
"""创建仅供命令构造测试定位的 uv 可执行文件。"""
uv_bin = root / "venv" / "bin" / "uv"
uv_bin.parent.mkdir(parents=True, exist_ok=True)
uv_bin.write_text("", encoding="utf-8")
return uv_bin
def _patch_release_install_settings(monkeypatch, tmp_path: Path) -> None:
"""隔离 release 安装根目录,并阻止测试误触真实根路径。"""
monkeypatch.setattr("app.adapters.external.market.settings", SimpleNamespace(
@@ -979,7 +988,7 @@ class TestPluginHelper:
assert not annotated["system_version_compatible"]
assert "当前版本" in annotated["system_version_message"]
def test_pip_install_keeps_modules_imported_during_install(self):
def test_uv_install_keeps_modules_imported_during_install(self):
"""
验证依赖安装窗口内被其他任务导入的运行态模块不会被误删
"""
@@ -1002,14 +1011,14 @@ class TestPluginHelper:
requirements_file = Path(temp_dir) / "requirements.txt"
requirements_file.write_text("demo-package\n", encoding="utf-8")
with patch("app.adapters.external.market.SystemUtils.execute_with_subprocess", side_effect=fake_execute):
success, message = PluginHelper.pip_install_with_fallback(requirements_file)
success, message = PluginHelper.install_packages_with_fallback(requirements_file)
assert success
assert "ok" == message
for module_name in module_names:
assert module_name in sys.modules
def test_pip_install_builds_uv_strategy_without_proxy_argument(self):
def test_uv_install_builds_uv_strategy_without_proxy_argument(self):
"""
插件依赖安装优先使用 uv 传输代理只进入子进程环境
"""
@@ -1032,17 +1041,17 @@ class TestPluginHelper:
uv_bin.parent.mkdir(parents=True)
uv_bin.write_text("", encoding="utf-8")
with patch("app.adapters.system.package._find_uv", return_value=uv_bin), \
with patch("app.adapters.system.package.find_uv", return_value=uv_bin), \
patch.object(PluginHelper, "_PluginHelper__get_protected_runtime_packages", return_value={}), \
patch.object(
PluginHelper,
"_PluginHelper__run_runtime_healthcheck",
return_value={"pip check": (True, "ok"), "核心依赖导入检查": (True, "ok")},
return_value={"uv check": (True, "ok"), "核心依赖导入检查": (True, "ok")},
), \
patch("app.adapters.external.market.SystemUtils.execute_with_subprocess", side_effect=fake_execute), \
patch("app.adapters.external.market.settings.PROXY_HOST", "http://proxy.example:7890"), \
patch("app.adapters.external.market.settings.PIP_PROXY", "https://user:pass@mirror.example/simple"):
success, message = PluginHelper.pip_install_with_fallback(req)
success, message = PluginHelper.install_packages_with_fallback(req)
assert success
assert message == "ok"
@@ -1053,9 +1062,9 @@ class TestPluginHelper:
assert env["HTTPS_PROXY"] == "http://proxy.example:7890"
assert "user:pass" not in " ".join(safe_command)
def test_pip_install_serializes_concurrent_calls(self):
def test_uv_install_serializes_concurrent_calls(self):
"""
验证多个依赖安装请求会复用同一把锁串行执行 pip
验证多个依赖安装请求会复用同一把锁串行执行 uv
"""
try:
from app.adapters.external.market import PluginHelper
@@ -1082,7 +1091,7 @@ class TestPluginHelper:
def worker(requirements_file: Path):
try:
start_event.wait()
PluginHelper.pip_install_with_fallback(requirements_file)
PluginHelper.install_packages_with_fallback(requirements_file)
except Exception as err: # pragma: no cover - 仅用于并发测试失败诊断
errors.append(err)
@@ -1144,9 +1153,9 @@ class TestPluginHelper:
"bcrypt": Version("4.0.1"),
} == protected_packages
def test_pip_install_rejects_conflicting_runtime_dependency(self):
def test_uv_install_rejects_conflicting_runtime_dependency(self):
"""
验证插件如果试图覆盖主程序核心依赖会在真正执行 pip 前被直接拒绝
验证插件如果试图覆盖主程序核心依赖会在真正执行安装前被直接拒绝
"""
try:
from app.adapters.external.market import PluginHelper
@@ -1161,13 +1170,13 @@ class TestPluginHelper:
"_PluginHelper__get_protected_runtime_packages",
return_value={"fastapi": Version("0.115.14")}
):
success, message = PluginHelper.pip_install_with_fallback(requirements_file)
success, message = PluginHelper.install_packages_with_fallback(requirements_file)
assert not success
assert "主程序核心依赖" in message
assert "fastapi" in message
def test_pip_install_allows_changing_non_runtime_dependency(self):
def test_uv_install_allows_changing_non_runtime_dependency(self):
"""
验证非主程序依赖即便已安装插件后续仍可调整其版本约束
"""
@@ -1178,16 +1187,19 @@ class TestPluginHelper:
seen_install_commands = []
def fake_execute(cmd, env=None, safe_command=None):
if cmd[:4] == [sys.executable, "-m", "pip", "install"]:
seen_install_commands.append(cmd)
assert "-c" not in cmd
return True, "ok"
return True, "ok"
with tempfile.TemporaryDirectory() as temp_dir:
requirements_file = Path(temp_dir) / "requirements.txt"
root = Path(temp_dir)
requirements_file = root / "requirements.txt"
requirements_file.write_text("demo-package>=2\n", encoding="utf-8")
uv_bin = _create_fake_uv(root)
def fake_execute(cmd, env=None, safe_command=None):
if cmd[:3] == [str(uv_bin), "pip", "install"]:
seen_install_commands.append(cmd)
assert "-c" not in cmd
return True, "ok"
return True, "ok"
with patch.object(
PluginHelper,
"_PluginHelper__get_installed_packages",
@@ -1199,14 +1211,14 @@ class TestPluginHelper:
return_value={}
):
with patch("app.adapters.external.market.SystemUtils.execute_with_subprocess", side_effect=fake_execute):
with patch("app.adapters.system.package._find_uv", return_value=None):
success, message = PluginHelper.pip_install_with_fallback(requirements_file)
with patch("app.adapters.system.package.find_uv", return_value=uv_bin):
success, message = PluginHelper.install_packages_with_fallback(requirements_file)
assert success
assert "ok" == message
assert 1 == len(seen_install_commands)
def test_pip_install_uses_runtime_constraints_file(self):
def test_uv_install_uses_runtime_constraints_file(self):
"""
验证插件依赖安装会固定主程序依赖的当前版本防止共享 venv 被改写
"""
@@ -1217,34 +1229,37 @@ class TestPluginHelper:
seen_constraints = []
def fake_execute(cmd, env=None, safe_command=None):
if cmd[:4] == [sys.executable, "-m", "pip", "install"]:
constraint_index = cmd.index("-c") + 1
constraint_file = Path(cmd[constraint_index])
seen_constraints.append(constraint_file)
assert constraint_file.exists()
assert "fastapi==0.115.14" in constraint_file.read_text(encoding="utf-8")
return True, "ok"
return True, "ok"
with tempfile.TemporaryDirectory() as temp_dir:
requirements_file = Path(temp_dir) / "requirements.txt"
root = Path(temp_dir)
requirements_file = root / "requirements.txt"
requirements_file.write_text("demo-package\n", encoding="utf-8")
uv_bin = _create_fake_uv(root)
def fake_execute(cmd, env=None, safe_command=None):
if cmd[:3] == [str(uv_bin), "pip", "install"]:
constraint_index = cmd.index("-c") + 1
constraint_file = Path(cmd[constraint_index])
seen_constraints.append(constraint_file)
assert constraint_file.exists()
assert "fastapi==0.115.14" in constraint_file.read_text(encoding="utf-8")
return True, "ok"
return True, "ok"
with patch.object(
PluginHelper,
"_PluginHelper__get_protected_runtime_packages",
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=None):
success, message = PluginHelper.pip_install_with_fallback(requirements_file)
with patch("app.adapters.system.package.find_uv", return_value=uv_bin):
success, message = PluginHelper.install_packages_with_fallback(requirements_file)
assert success
assert "ok" == message
assert 1 == len(seen_constraints)
assert not seen_constraints[0].exists()
def test_pip_install_repairs_runtime_when_healthcheck_fails(self):
def test_uv_install_repairs_runtime_when_healthcheck_fails(self):
"""
验证插件依赖安装后若破坏运行环境会先恢复主程序依赖再向上层返回失败
"""
@@ -1254,43 +1269,45 @@ class TestPluginHelper:
pytest.skip(f"missing dependency: {exc}")
repair_commands = []
pip_check_count = 0
pip_check_cmd = PluginHelper._PluginHelper__build_runtime_pip_command("check")
def fake_execute(cmd, env=None, safe_command=None):
nonlocal pip_check_count
if cmd[:4] == [sys.executable, "-m", "pip", "install"]:
if "-c" not in cmd:
repair_commands.append(cmd)
return True, "repaired"
return True, "installed"
if cmd == pip_check_cmd:
pip_check_count += 1
if pip_check_count == 2:
return False, "broken"
return True, "healthy"
if len(cmd) >= 3 and cmd[1] == "-c":
return True, "probe ok"
raise AssertionError(f"unexpected command: {cmd}")
uv_check_count = 0
with tempfile.TemporaryDirectory() as temp_dir:
requirements_file = Path(temp_dir) / "requirements.txt"
root = Path(temp_dir)
requirements_file = root / "requirements.txt"
requirements_file.write_text("demo-package\n", encoding="utf-8")
uv_bin = _create_fake_uv(root)
def fake_execute(cmd, env=None, safe_command=None):
nonlocal uv_check_count
if cmd[:3] == [str(uv_bin), "pip", "install"]:
if "-c" not in cmd:
repair_commands.append(cmd)
return True, "repaired"
return True, "installed"
if cmd[1:3] == ["pip", "check"]:
uv_check_count += 1
if uv_check_count == 2:
return False, "broken"
return True, "healthy"
if len(cmd) >= 3 and cmd[1] == "-c":
return True, "probe ok"
raise AssertionError(f"unexpected command: {cmd}")
with patch.object(
PluginHelper,
"_PluginHelper__get_protected_runtime_packages",
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=None):
success, message = PluginHelper.pip_install_with_fallback(requirements_file)
with patch("app.adapters.system.package.find_uv", return_value=uv_bin):
success, message = PluginHelper.install_packages_with_fallback(requirements_file)
assert not success
assert "已自动恢复主程序依赖" in message
assert 1 == len(repair_commands)
assert "runtime-constraints-" in repair_commands[0][-1]
def test_pip_install_allows_preexisting_healthcheck_failure(self):
def test_uv_install_allows_preexisting_healthcheck_failure(self):
"""
安装前已存在且安装后未新增的环境异常不应归因于本次插件依赖安装
"""
@@ -1301,19 +1318,21 @@ class TestPluginHelper:
health_snapshots = [
{
"pip check": (False, "existing issue before install"),
"uv check": (False, "existing issue before install"),
"核心依赖导入检查": (True, "ok"),
},
{
"pip check": (False, "same issue with different command summary"),
"uv check": (False, "same issue with different command summary"),
"核心依赖导入检查": (True, "ok"),
},
]
with tempfile.TemporaryDirectory() as temp_dir:
requirements_file = Path(temp_dir) / "requirements.txt"
root = Path(temp_dir)
requirements_file = root / "requirements.txt"
requirements_file.write_text("demo-package\n", encoding="utf-8")
with patch("app.adapters.system.package._find_uv", return_value=None), \
uv_bin = _create_fake_uv(root)
with patch("app.adapters.system.package.find_uv", return_value=uv_bin), \
patch.object(PluginHelper, "_PluginHelper__get_protected_runtime_packages", return_value={}), \
patch.object(
PluginHelper,
@@ -1325,7 +1344,7 @@ class TestPluginHelper:
"app.adapters.external.market.SystemUtils.execute_with_subprocess",
return_value=(True, "installed"),
):
success, message = PluginHelper.pip_install_with_fallback(requirements_file)
success, message = PluginHelper.install_packages_with_fallback(requirements_file)
assert success
assert message == "installed"
@@ -1342,23 +1361,25 @@ class TestPluginHelper:
health_snapshots = [
{
"pip check": (False, "existing issue"),
"uv check": (False, "existing issue"),
"核心依赖导入检查": (True, "ok"),
},
{
"pip check": (False, "existing issue"),
"uv check": (False, "existing issue"),
"核心依赖导入检查": (False, "import failed"),
},
{
"pip check": (False, "existing issue"),
"uv check": (False, "existing issue"),
"核心依赖导入检查": (True, "ok"),
},
]
with tempfile.TemporaryDirectory() as temp_dir:
requirements_file = Path(temp_dir) / "requirements.txt"
root = Path(temp_dir)
requirements_file = root / "requirements.txt"
requirements_file.write_text("demo-package\n", encoding="utf-8")
with patch("app.adapters.system.package._find_uv", return_value=None), \
uv_bin = _create_fake_uv(root)
with patch("app.adapters.system.package.find_uv", return_value=uv_bin), \
patch.object(PluginHelper, "_PluginHelper__get_protected_runtime_packages", return_value={}), \
patch.object(
PluginHelper,
@@ -1374,7 +1395,7 @@ class TestPluginHelper:
"app.adapters.external.market.SystemUtils.execute_with_subprocess",
return_value=(True, "installed"),
):
success, message = PluginHelper.pip_install_with_fallback(requirements_file)
success, message = PluginHelper.install_packages_with_fallback(requirements_file)
assert not success
assert "核心依赖导入检查失败" in message
@@ -1400,16 +1421,17 @@ class TestPluginHelper:
root = Path(temp_dir)
req = root / "plugin-requirements.txt"
req.write_text("demo\n", encoding="utf-8")
uv_bin = _create_fake_uv(root)
with patch("app.adapters.system.package._find_uv", return_value=None), \
with patch("app.adapters.system.package.find_uv", return_value=uv_bin), \
patch.object(PluginHelper, "_PluginHelper__get_protected_runtime_packages", return_value={}), \
patch.object(
PluginHelper,
"_PluginHelper__run_runtime_healthcheck",
side_effect=[
{"pip check": (True, "ok"), "核心依赖导入检查": (True, "ok")},
{"pip check": (False, "broken"), "核心依赖导入检查": (True, "ok")},
{"pip check": (True, "ok"), "核心依赖导入检查": (True, "ok")},
{"uv check": (True, "ok"), "核心依赖导入检查": (True, "ok")},
{"uv check": (False, "broken"), "核心依赖导入检查": (True, "ok")},
{"uv check": (True, "ok"), "核心依赖导入检查": (True, "ok")},
],
), \
patch.object(
@@ -1419,7 +1441,7 @@ class TestPluginHelper:
or (True, "runtime repaired"),
), \
patch("app.adapters.external.market.SystemUtils.execute_with_subprocess", side_effect=fake_execute):
success, message = PluginHelper.pip_install_with_fallback(req)
success, message = PluginHelper.install_packages_with_fallback(req)
assert not success
assert "partial failure" in message or "恢复" in message
@@ -1453,15 +1475,15 @@ class TestPluginHelper:
uv_bin.parent.mkdir(parents=True)
uv_bin.write_text("", encoding="utf-8")
with patch("app.adapters.system.package._find_uv", return_value=uv_bin), \
with patch("app.adapters.system.package.find_uv", return_value=uv_bin), \
patch.object(PluginHelper, "_PluginHelper__get_protected_runtime_packages", return_value={}), \
patch.object(
PluginHelper,
"_PluginHelper__run_runtime_healthcheck",
side_effect=[
{"pip check": (True, "ok"), "核心依赖导入检查": (True, "ok")},
{"pip check": (False, "broken"), "核心依赖导入检查": (True, "ok")},
{"pip check": (True, "ok"), "核心依赖导入检查": (True, "ok")},
{"uv check": (True, "ok"), "核心依赖导入检查": (True, "ok")},
{"uv check": (False, "broken"), "核心依赖导入检查": (True, "ok")},
{"uv check": (True, "ok"), "核心依赖导入检查": (True, "ok")},
],
), \
patch.object(
@@ -1473,7 +1495,7 @@ class TestPluginHelper:
patch("app.adapters.external.market.settings.PIP_PROXY", "https://mirror.example/simple"), \
patch("app.adapters.external.market.settings.PROXY_HOST", "http://proxy.example:7890"), \
patch("app.adapters.external.market.SystemUtils.execute_with_subprocess", side_effect=fake_execute):
success, message = PluginHelper.pip_install_with_fallback(req)
success, message = PluginHelper.install_packages_with_fallback(req)
assert not success
assert "resolver failed" in message
@@ -1504,7 +1526,8 @@ class TestPluginHelper:
uv_bin.parent.mkdir(parents=True)
uv_bin.write_text("", encoding="utf-8")
with patch("app.adapters.system.package._find_uv", return_value=uv_bin), \
with patch("app.adapters.system.package.find_uv", return_value=uv_bin), \
patch.dict(os.environ, {}, clear=True), \
patch("app.adapters.external.market.settings.CONFIG_DIR", str(root / "config")), \
patch("app.adapters.external.market.settings.PACKAGE_CACHE_ROOT", str(root / "custom-package-cache")), \
patch("app.adapters.external.market.settings.PIP_PROXY", "https://user:pass@mirror.example/simple"), \
@@ -1519,14 +1542,13 @@ class TestPluginHelper:
assert command[:3] == [str(uv_bin), "pip", "install"]
assert "--proxy" not in command
assert env["PACKAGE_CACHE_ROOT"] == str(root / "custom-package-cache")
assert env["PIP_CACHE_DIR"] == str(root / "custom-package-cache" / "pip")
assert env["UV_CACHE_DIR"] == str(root / "custom-package-cache" / "uv")
assert env["HTTPS_PROXY"] == "http://proxy.example:7890"
assert "user:pass" not in " ".join(safe_command)
def test_async_pip_install_runs_in_threadpool(self):
def test_async_package_install_runs_in_threadpool(self):
"""
验证异步安装路径会把同步 pip 安装派发到线程池避免阻塞事件循环
验证异步安装路径会把同步安装派发到线程池避免阻塞事件循环
"""
try:
from app.adapters.external.market import PluginHelper
@@ -1539,7 +1561,7 @@ class TestPluginHelper:
calls = []
async def run_install():
return await helper._PluginHelper__async_pip_install_with_fallback(
return await helper._PluginHelper__async_install_packages_with_fallback(
requirements_file,
find_links_dirs
)
@@ -1554,7 +1576,7 @@ class TestPluginHelper:
assert success
assert "ok" == message
assert 1 == len(calls)
assert helper.pip_install_with_fallback == calls[0][0]
assert helper.install_packages_with_fallback == calls[0][0]
assert (requirements_file, find_links_dirs) == calls[0][1]
assert {} == calls[0][2]
@@ -2468,10 +2490,99 @@ class TestPluginHelper:
assert "dependency failed" == message
assert ["remove", "restore"] == calls
def test_prepare_content_via_filelist_sync_preinstalls_requirements_and_downloads(self, monkeypatch):
"""
文件列表安装会先尝试 requirements 预安装再下载插件文件
"""
def test_install_flow_sync_restores_backup_for_invalid_modern_manifest(self, tmp_path, monkeypatch):
"""现代清单无效时恢复旧插件目录。"""
from app.adapters.external import market as market_module
plugin_root = tmp_path / "plugins"
plugin_dir = plugin_root / PLUGIN_ID.lower()
plugin_dir.mkdir(parents=True)
(plugin_dir / "old.txt").write_text("old", encoding="utf-8")
monkeypatch.setattr(market_module, "PLUGIN_DIR", plugin_root)
monkeypatch.setattr(market_module.settings, "CONFIG_DIR", str(tmp_path))
def prepare_content():
plugin_dir.mkdir(parents=True)
(plugin_dir / "pyproject.toml").write_text(
"[project]\nname = 'demo'\n",
encoding="utf-8",
)
return True, ""
success, message = market_module.PluginHelper()._PluginHelper__install_flow_sync(
PLUGIN_ID,
False,
prepare_content,
)
assert not success
assert "project.version" in message
assert (plugin_dir / "old.txt").read_text(encoding="utf-8") == "old"
assert not (plugin_dir / "pyproject.toml").exists()
def test_install_dependencies_prefers_plugin_pyproject(self, tmp_path, monkeypatch):
"""同步安装入口只消费双清单中的 pyproject。"""
from app.adapters.external import market as market_module
plugin_root = tmp_path / "plugins"
plugin_dir = plugin_root / "demoplugin"
plugin_dir.mkdir(parents=True)
pyproject_file = plugin_dir / "pyproject.toml"
pyproject_file.write_text(
'[project]\nname = "demo"\nversion = "1.0.0"\ndependencies = ["modern>=1"]\n',
encoding="utf-8",
)
(plugin_dir / "requirements.txt").write_text("legacy>=1\n", encoding="utf-8")
helper = market_module.PluginHelper()
seen = []
monkeypatch.setattr(market_module, "PLUGIN_DIR", plugin_root)
monkeypatch.setattr(
helper,
"install_packages_with_fallback",
lambda path: seen.append(path) or (True, ""),
)
result = helper._PluginHelper__install_dependencies_if_required("DemoPlugin")
assert result == (True, True, "")
assert seen == [pyproject_file]
def test_async_install_dependencies_prefers_plugin_pyproject(self, tmp_path, monkeypatch):
"""异步安装入口只消费双清单中的 pyproject。"""
from app.adapters.external import market as market_module
plugin_root = tmp_path / "plugins"
plugin_dir = plugin_root / "demoplugin"
plugin_dir.mkdir(parents=True)
pyproject_file = plugin_dir / "pyproject.toml"
pyproject_file.write_text(
'[project]\nname = "demo"\nversion = "1.0.0"\ndependencies = ["modern>=1"]\n',
encoding="utf-8",
)
(plugin_dir / "requirements.txt").write_text("legacy>=1\n", encoding="utf-8")
helper = market_module.PluginHelper()
seen = []
async def fake_install(path, _find_links=None):
seen.append(path)
return True, ""
monkeypatch.setattr(market_module, "PLUGIN_DIR", plugin_root)
monkeypatch.setattr(
helper,
"_PluginHelper__async_install_packages_with_fallback",
fake_install,
)
result = asyncio.run(
helper._PluginHelper__async_install_dependencies_if_required("DemoPlugin")
)
assert result == (True, True, "")
assert seen == [pyproject_file]
def test_prepare_content_via_filelist_sync_downloads_dependency_manifests_once(self, monkeypatch):
"""文件列表准备会完整下载内容,依赖由统一安装流程处理。"""
try:
from app.adapters.external.market import PluginHelper
except ModuleNotFoundError as exc:
@@ -2479,55 +2590,28 @@ class TestPluginHelper:
helper = PluginHelper()
calls = []
requirements = {"name": "requirements.txt", "download_url": "https://example.com/requirements.txt"}
file_list = [requirements, {"name": "__init__.py", "download_url": "https://example.com/__init__.py"}]
file_list = [
{"name": "pyproject.toml", "download_url": "https://example.com/pyproject.toml"},
{"name": "requirements.txt", "download_url": "https://example.com/requirements.txt"},
{"name": "__init__.py", "download_url": "https://example.com/__init__.py"},
]
monkeypatch.setattr(helper, "_PluginHelper__get_file_list", lambda *_args: (file_list, ""))
monkeypatch.setattr(
helper,
"_PluginHelper__download_and_install_requirements",
lambda *_args: calls.append("requirements") or (True, ""),
)
def fake_download(*args):
calls.append(args)
return True, ""
monkeypatch.setattr(
helper,
"_PluginHelper__download_files",
lambda *_args: calls.append("download") or (True, ""),
fake_download,
)
success, message = helper._PluginHelper__prepare_content_via_filelist_sync("demoplugin", "demo/repo", "v2")
assert success
assert "" == message
assert ["requirements", "download"] == calls
def test_prepare_content_via_filelist_sync_continues_when_requirements_preinstall_fails(self, monkeypatch):
"""
requirements 预安装失败不阻断文件下载最终依赖安装由统一流程兜底
"""
try:
from app.adapters.external.market import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
helper = PluginHelper()
calls = []
file_list = [{"name": "requirements.txt"}, {"name": "__init__.py"}]
monkeypatch.setattr(helper, "_PluginHelper__get_file_list", lambda *_args: (file_list, ""))
monkeypatch.setattr(
helper,
"_PluginHelper__download_and_install_requirements",
lambda *_args: calls.append("requirements") or (False, "preinstall failed"),
)
monkeypatch.setattr(
helper,
"_PluginHelper__download_files",
lambda *_args: calls.append("download") or (True, ""),
)
success, message = helper._PluginHelper__prepare_content_via_filelist_sync("demoplugin", "demo/repo", "v2")
assert success
assert "" == message
assert ["requirements", "download"] == calls
assert calls == [("demoplugin", file_list, "demo/repo", "v2")]
def test_prepare_content_via_filelist_sync_reports_missing_file_list(self, monkeypatch):
"""
@@ -2564,10 +2648,8 @@ class TestPluginHelper:
assert not success
assert "download failed" == message
def test_async_prepare_content_via_filelist_preinstalls_requirements_and_downloads(self, monkeypatch):
"""
异步文件列表安装会先尝试 requirements 预安装再下载插件文件
"""
def test_async_prepare_content_via_filelist_downloads_dependency_manifests_once(self, monkeypatch):
"""异步文件列表准备会完整下载内容,依赖由统一安装流程处理。"""
try:
from app.adapters.external.market import PluginHelper
except ModuleNotFoundError as exc:
@@ -2575,22 +2657,20 @@ class TestPluginHelper:
helper = PluginHelper()
calls = []
requirements = {"name": "requirements.txt", "download_url": "https://example.com/requirements.txt"}
file_list = [requirements, {"name": "__init__.py", "download_url": "https://example.com/__init__.py"}]
file_list = [
{"name": "pyproject.toml", "download_url": "https://example.com/pyproject.toml"},
{"name": "requirements.txt", "download_url": "https://example.com/requirements.txt"},
{"name": "__init__.py", "download_url": "https://example.com/__init__.py"},
]
async def fake_file_list(*_args):
return file_list, ""
async def fake_requirements(*_args):
calls.append("requirements")
return True, ""
async def fake_download(*_args):
calls.append("download")
async def fake_download(*args):
calls.append(args)
return True, ""
monkeypatch.setattr(helper, "_PluginHelper__async_get_file_list", fake_file_list)
monkeypatch.setattr(helper, "_PluginHelper__async_download_and_install_requirements", fake_requirements)
monkeypatch.setattr(helper, "_PluginHelper__async_download_files", fake_download)
success, message = asyncio.run(
@@ -2599,7 +2679,7 @@ class TestPluginHelper:
assert success
assert "" == message
assert ["requirements", "download"] == calls
assert calls == [("demoplugin", file_list, "demo/repo", "v2")]
def test_async_prepare_content_via_filelist_reports_missing_file_list(self, monkeypatch):
"""
+160
View File
@@ -452,11 +452,171 @@ def test_local_requirements_change_still_does_not_sync_or_reload(
reload_spy = Mock()
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
monkeypatch.setattr(plugin_manager, "reload_plugin", reload_spy)
log = Mock()
monkeypatch.setattr("app.runtime.extensions.plugin_manager.logger", log)
plugin_manager._run_file_watcher()
sync_spy.assert_not_called()
reload_spy.assert_not_called()
log.warning.assert_called_once()
def test_local_pyproject_change_prompts_reinstall_without_sync_or_reload(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
) -> None:
"""生效的现代依赖清单变化只提示重新安装。"""
repo_path, source_file = _build_local_plugin_repo(tmp_path)
pyproject_file = source_file.parent / "pyproject.toml"
pyproject_file.write_text(
'[project]\ndependencies = ["example==1.0.0"]\n',
encoding="utf-8",
)
_configure_local_watcher(
monkeypatch,
tmp_path,
repo_path,
{(Change.modified, str(pyproject_file))},
)
monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.11"))
sync_spy = Mock()
reload_spy = Mock()
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
monkeypatch.setattr(plugin_manager, "reload_plugin", reload_spy)
log = Mock()
monkeypatch.setattr("app.runtime.extensions.plugin_manager.logger", log)
plugin_manager._run_file_watcher()
sync_spy.assert_not_called()
reload_spy.assert_not_called()
log.warning.assert_called_once()
def test_local_inactive_requirements_change_is_debug_only(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
) -> None:
"""现代清单生效时,旧 requirements 变化不提示重新安装。"""
repo_path, source_file = _build_local_plugin_repo(tmp_path)
(source_file.parent / "pyproject.toml").write_text(
'[project]\ndependencies = ["example==1.0.0"]\n',
encoding="utf-8",
)
requirements_file = source_file.parent / "requirements.txt"
requirements_file.write_text("legacy==1.0.0\n", encoding="utf-8")
_configure_local_watcher(
monkeypatch,
tmp_path,
repo_path,
{(Change.modified, str(requirements_file))},
)
monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.11"))
sync_spy = Mock()
reload_spy = Mock()
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
monkeypatch.setattr(plugin_manager, "reload_plugin", reload_spy)
log = Mock()
monkeypatch.setattr("app.runtime.extensions.plugin_manager.logger", log)
plugin_manager._run_file_watcher()
sync_spy.assert_not_called()
reload_spy.assert_not_called()
log.warning.assert_not_called()
log.debug.assert_called_once()
def test_deleting_active_pyproject_prompts_for_requirements_takeover(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
) -> None:
"""删除现代清单后旧清单接管时必须提示重新安装。"""
repo_path, source_file = _build_local_plugin_repo(tmp_path)
requirements_file = source_file.parent / "requirements.txt"
requirements_file.write_text("legacy==1.0.0\n", encoding="utf-8")
pyproject_file = source_file.parent / "pyproject.toml"
pyproject_file.write_text(
'[project]\nname = "demo"\nversion = "1.0.0"\n'
'dependencies = ["modern==2.0.0"]\n',
encoding="utf-8",
)
pyproject_file.unlink()
_configure_local_watcher(
monkeypatch,
tmp_path,
repo_path,
{(Change.deleted, str(pyproject_file))},
)
monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.11"))
log = Mock()
monkeypatch.setattr("app.runtime.extensions.plugin_manager.logger", log)
plugin_manager._run_file_watcher()
log.warning.assert_called_once()
log.debug.assert_not_called()
def test_deleting_only_active_requirements_prompts_reinstall(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
) -> None:
"""删除唯一生效的旧清单时必须提示依赖集合已变化。"""
repo_path, source_file = _build_local_plugin_repo(tmp_path)
requirements_file = source_file.parent / "requirements.txt"
requirements_file.write_text("legacy==1.0.0\n", encoding="utf-8")
requirements_file.unlink()
_configure_local_watcher(
monkeypatch,
tmp_path,
repo_path,
{(Change.deleted, str(requirements_file))},
)
monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.11"))
log = Mock()
monkeypatch.setattr("app.runtime.extensions.plugin_manager.logger", log)
plugin_manager._run_file_watcher()
log.warning.assert_called_once()
log.debug.assert_not_called()
def test_deleting_inactive_requirements_is_debug_only(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
) -> None:
"""现代清单仍生效时,删除旧清单不得提示重新安装。"""
repo_path, source_file = _build_local_plugin_repo(tmp_path)
(source_file.parent / "pyproject.toml").write_text(
'[project]\nname = "demo"\nversion = "1.0.0"\n'
'dependencies = ["modern==2.0.0"]\n',
encoding="utf-8",
)
requirements_file = source_file.parent / "requirements.txt"
requirements_file.write_text("legacy==1.0.0\n", encoding="utf-8")
requirements_file.unlink()
_configure_local_watcher(
monkeypatch,
tmp_path,
repo_path,
{(Change.deleted, str(requirements_file))},
)
monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.11"))
log = Mock()
monkeypatch.setattr("app.runtime.extensions.plugin_manager.logger", log)
plugin_manager._run_file_watcher()
log.warning.assert_not_called()
log.debug.assert_called_once()
def test_local_python_change_still_syncs_and_reloads_plugin(
+32
View File
@@ -0,0 +1,32 @@
"""插件市场同步服务用例。"""
from types import SimpleNamespace
from unittest.mock import Mock
from app.runtime.extensions.plugin.sync import PluginSyncService
def test_market_sync_keeps_install_rollback_enabled() -> None:
"""自动更新插件时保留旧版本,失败后可由安装器恢复。"""
plugin = SimpleNamespace(
id="DemoPlugin",
repo_url="https://example.com/plugins",
plugin_name="Demo",
plugin_version="1.0.0",
system_version_compatible=True,
)
install = Mock(return_value=(True, ""))
service = PluginSyncService(
frozen=lambda: False,
installed_plugins=lambda: [plugin.id],
online_plugins=lambda: [plugin],
local_plugins=lambda: [],
merge_plugins=lambda items, *_args: items,
plugin_exists=lambda *_args: False,
install=install,
report=Mock(),
log=Mock(),
)
assert service.sync() == [plugin.id]
install.assert_called_once_with(plugin.id, plugin.repo_url, False)
-131
View File
@@ -1,131 +0,0 @@
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
WRAPPER = ROOT / "scripts" / "uv-pip-compat.sh"
def run_wrapper_with_env(link_name: str, *args: str) -> tuple[list[str], dict[str, str]]:
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
venv_bin = temp_path / "venv" / "bin"
venv_bin.mkdir(parents=True)
(venv_bin / "python").write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
(venv_bin / "python").chmod(0o755)
argv_file = temp_path / "argv.txt"
env_file = temp_path / "env.txt"
uv_bin = venv_bin / "uv"
uv_bin.write_text(
"#!/bin/sh\n"
f"for arg in \"$@\"; do printf '%s\\n' \"$arg\" >> '{argv_file}'; done\n"
"for name in HTTP_PROXY HTTPS_PROXY http_proxy https_proxy; do\n"
" eval \"value=\\${$name:-}\"\n"
f" printf '%s=%s\\n' \"$name\" \"$value\" >> '{env_file}'\n"
"done\n",
encoding="utf-8",
)
uv_bin.chmod(0o755)
wrapper_path = venv_bin / "uv-pip-compat"
shutil.copy2(WRAPPER, wrapper_path)
wrapper_path.chmod(0o755)
link_path = venv_bin / link_name
link_path.symlink_to(wrapper_path.name)
subprocess.run(
[str(link_path), *args],
check=True,
env={
**os.environ,
"PATH": f"{venv_bin}{os.pathsep}{os.environ.get('PATH', '')}",
},
)
env_lines = dict(line.split("=", 1) for line in env_file.read_text(encoding="utf-8").splitlines())
return argv_file.read_text(encoding="utf-8").splitlines(), env_lines
def test_pip_install_converts_proxy_argument_to_env():
argv, env_lines = run_wrapper_with_env("pip", "install", "--proxy", "http://proxy.example:7890", "demo")
assert "--proxy" not in argv
assert "http://proxy.example:7890" not in argv
assert env_lines["HTTPS_PROXY"] == "http://proxy.example:7890"
assert env_lines["HTTP_PROXY"] == "http://proxy.example:7890"
def test_pip_install_converts_proxy_equals_argument_to_env():
argv, env_lines = run_wrapper_with_env("pip", "install", "--proxy=http://proxy.example:7890", "demo")
assert "--proxy=http://proxy.example:7890" not in argv
assert env_lines["https_proxy"] == "http://proxy.example:7890"
class UvPipCompatTests(unittest.TestCase):
def run_wrapper(self, link_name: str, *args: str) -> list[str]:
with tempfile.TemporaryDirectory() as temp_dir:
venv_bin = Path(temp_dir) / "venv" / "bin"
venv_bin.mkdir(parents=True)
(venv_bin / "python").write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
(venv_bin / "python").chmod(0o755)
argv_file = Path(temp_dir) / "argv.txt"
uv_bin = venv_bin / "uv"
uv_bin.write_text(
"#!/bin/sh\n"
# 测试只关心兼容层传给 uv 的参数,逐行记录可以避免 shell 转义差异干扰断言。
f"for arg in \"$@\"; do printf '%s\\n' \"$arg\" >> '{argv_file}'; done\n",
encoding="utf-8",
)
uv_bin.chmod(0o755)
wrapper_path = venv_bin / "uv-pip-compat"
shutil.copy2(WRAPPER, wrapper_path)
wrapper_path.chmod(0o755)
link_path = venv_bin / link_name
link_path.symlink_to(wrapper_path.name)
subprocess.run(
[str(link_path), *args],
check=True,
env={
**os.environ,
"PATH": f"{venv_bin}{os.pathsep}{os.environ.get('PATH', '')}",
},
)
return argv_file.read_text(encoding="utf-8").splitlines()
def test_pip_install_binds_venv_python(self):
argv = self.run_wrapper("pip", "install", "-r", "requirements.txt")
self.assertEqual(
[
"pip",
"install",
"--python",
argv[3],
"-r",
"requirements.txt",
],
argv,
)
self.assertTrue(argv[3].endswith("/venv/bin/python"))
def test_pip_install_keeps_explicit_environment(self):
argv = self.run_wrapper("pip", "install", "--system", "demo-package")
self.assertEqual(["pip", "install", "--system", "demo-package"], argv)
def test_pip_sync_binds_venv_python(self):
argv = self.run_wrapper("pip-sync", "requirements.txt")
self.assertEqual(["pip", "sync", "--python", argv[3], "requirements.txt"], argv)
self.assertTrue(argv[3].endswith("/venv/bin/python"))
Generated
+3904
View File
File diff suppressed because it is too large Load Diff