feat: 新增 Python 3.14t 自由线程镜像 (#6434)

* fix(resource): select free-threaded extension ABI

* chore(deps): require moviepilot-rust 0.2.9

* perf: add free-threaded runtime comparison

* feat: add free-threaded runtime profile

* chore(deps): require moviepilot-rust 0.3.0

* test: isolate system endpoint import graph

* fix(docker): preserve runtime profile during recovery

* feat(runtime): expose active GIL state

* feat(plugin): log GIL fallback attribution

* test: refresh runtime observability dependency baseline

* fix(plugin): validate the active uv runtime profile

* test(runtime): expand free-threaded benchmark evidence

* docs(runtime): define v3t governance gates

* test(runtime): separate rust benchmark modes

* docs: sync free-threaded architecture baseline

* perf: add PostgreSQL driver comparison

* docs(runtime): record final free-threaded evidence

* fix(runtime): converge dual-profile dependency verification

* fix(runtime): scope Python 3.14 warning filter

* fix(runtime): match actual oss2 syntax warning

* docs(runtime): refresh free-threaded benchmark evidence

* test(architecture): refresh runtime dependency baseline

* ci: skip unused Trivy Java database

* build: exclude local verification artifacts

* docs(runtime): refresh PostgreSQL driver benchmarks

* docs(runtime): record plugin restore acceptance

* docs(runtime): record amd64 candidate acceptance

* ci: pin beta image publisher action

* test(architecture): merge runtime dependency baseline

* feat(runtime): expose Python GIL status

* docs(runtime): document Python runtime status fields
This commit is contained in:
InfinityPacer
2026-08-24 17:48:14 +08:00
committed by GitHub
parent 88dce4ca8e
commit 326b5cf3ad
63 changed files with 5294 additions and 253 deletions
+1
View File
@@ -92,6 +92,7 @@ test_*
*_test.py
# Build artifacts
.artifacts/
build/
.build/
dist/
+213 -5
View File
@@ -10,6 +10,9 @@ jobs:
Docker-build:
runs-on: ubuntu-latest
name: Build Docker Image
env:
TRIVY_SKIP_DIRS: /usr/share/java
TRIVY_SKIP_JAVA_DB_UPDATE: "true"
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -22,6 +25,25 @@ jobs:
- name: Verify dependency lock
run: uv lock --check
- name: Audit locked Python dependencies
run: |
uv export --quiet --locked --no-default-groups --group runtime-standard \
--no-emit-project --output-file /tmp/moviepilot-audit-standard.txt
uvx --from pip-audit==2.10.1 pip-audit \
--require-hashes --disable-pip --strict --progress-spinner off \
--requirement /tmp/moviepilot-audit-standard.txt
uv export --quiet --locked --no-default-groups --group runtime-free-threaded \
--no-emit-project --no-hashes \
--output-file /tmp/moviepilot-audit-free-threaded.txt
python3 scripts/normalize_audit_requirements.py \
--lock uv.lock \
--input /tmp/moviepilot-audit-free-threaded.txt \
--output /tmp/moviepilot-audit-free-threaded-normalized.txt
uvx --from pip-audit==2.10.1 pip-audit \
--no-deps --disable-pip --strict --progress-spinner off \
--requirement /tmp/moviepilot-audit-free-threaded-normalized.txt
- name: Release version
id: release_version
run: |
@@ -97,7 +119,17 @@ jobs:
${{ secrets.DOCKER_USERNAME }}/moviepilot-v3
ghcr.io/${{ github.repository }}-v3
tags: |
type=raw,value=beta
type=raw,value=beta-${{ github.run_id }}-${{ github.run_attempt }}
- name: Docker Meta free-threaded
id: meta_ft
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.DOCKER_USERNAME }}/moviepilot-v3t
ghcr.io/${{ github.repository }}-v3t
tags: |
type=raw,value=beta-${{ github.run_id }}-${{ github.run_attempt }}
- name: Set Up QEMU
uses: docker/setup-qemu-action@v3
@@ -105,6 +137,134 @@ jobs:
- name: Set Up Buildx
uses: docker/setup-buildx-action@v3
- name: Build standard amd64 candidate
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: docker/Dockerfile
platforms: linux/amd64
load: true
push: false
pull: true
tags: moviepilot-v3-candidate:linux-amd64
build-args: |
MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }}
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
MOVIEPILOT_PYTHON_VARIANT=standard
cache-from: type=gha,scope=moviepilot-v3-standard-docker-amd64,version=2
cache-to: type=gha,scope=moviepilot-v3-standard-docker-amd64,mode=max,version=2
- name: Scan standard amd64 candidate vulnerabilities
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
with:
image-ref: moviepilot-v3-candidate:linux-amd64
version: v0.70.0
cache-dir: ${{ runner.temp }}/trivy
scanners: vuln
vuln-type: os,library
severity: HIGH,CRITICAL
ignore-unfixed: true
trivyignores: .trivyignore.yaml
exit-code: 1
- name: Build standard arm64 candidate
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: docker/Dockerfile
platforms: linux/arm64/v8
load: true
push: false
pull: true
tags: moviepilot-v3-candidate:linux-arm64
build-args: |
MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }}
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
MOVIEPILOT_PYTHON_VARIANT=standard
cache-from: type=gha,scope=moviepilot-v3-standard-docker-arm64,version=2
cache-to: type=gha,scope=moviepilot-v3-standard-docker-arm64,mode=max,version=2
- name: Scan standard arm64 candidate vulnerabilities
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
with:
image-ref: moviepilot-v3-candidate:linux-arm64
version: v0.70.0
cache-dir: ${{ runner.temp }}/trivy
scanners: vuln
vuln-type: os,library
severity: HIGH,CRITICAL
ignore-unfixed: true
trivyignores: .trivyignore.yaml
exit-code: 1
- name: Build free-threaded amd64 candidate
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: docker/Dockerfile
platforms: linux/amd64
load: true
push: false
pull: true
tags: moviepilot-v3t-candidate:linux-amd64
build-args: |
MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }}
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
MOVIEPILOT_PYTHON_VARIANT=free-threaded
cache-from: type=gha,scope=moviepilot-v3t-docker-amd64,version=2
cache-to: type=gha,scope=moviepilot-v3t-docker-amd64,mode=max,version=2
- name: Scan free-threaded amd64 candidate vulnerabilities
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
with:
image-ref: moviepilot-v3t-candidate:linux-amd64
version: v0.70.0
cache-dir: ${{ runner.temp }}/trivy
scanners: vuln
vuln-type: os,library
severity: HIGH,CRITICAL
ignore-unfixed: true
trivyignores: .trivyignore.yaml
exit-code: 1
- name: Build free-threaded arm64 candidate
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: docker/Dockerfile
platforms: linux/arm64/v8
load: true
push: false
pull: true
tags: moviepilot-v3t-candidate:linux-arm64
build-args: |
MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }}
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
MOVIEPILOT_PYTHON_VARIANT=free-threaded
cache-from: type=gha,scope=moviepilot-v3t-docker-arm64,version=2
cache-to: type=gha,scope=moviepilot-v3t-docker-arm64,mode=max,version=2
- name: Scan free-threaded arm64 candidate vulnerabilities
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
with:
image-ref: moviepilot-v3t-candidate:linux-arm64
version: v0.70.0
cache-dir: ${{ runner.temp }}/trivy
scanners: vuln
vuln-type: os,library
severity: HIGH,CRITICAL
ignore-unfixed: true
trivyignores: .trivyignore.yaml
exit-code: 1
- name: Login DockerHub
uses: docker/login-action@v3
with:
@@ -118,8 +278,8 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Image
uses: docker/build-push-action@v7
- name: Publish standard multi-architecture image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: docker/Dockerfile
@@ -133,6 +293,7 @@ jobs:
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
MOVIEPILOT_PYTHON_VARIANT=standard
labels: |
${{ steps.meta.outputs.labels }}
org.opencontainers.image.revision=${{ env.SOURCE_COMMIT }}
@@ -143,5 +304,52 @@ jobs:
org.moviepilot.resources-revision=${{ steps.payloads.outputs.resources_revision }}
org.moviepilot.plugin-market-wiki-revision=${{ steps.plugin_market.outputs.wiki_commit }}
org.moviepilot.models-catalog-digest=${{ steps.models_catalog.outputs.digest }}
cache-from: type=gha,scope=moviepilot-v3-docker,version=2
cache-to: type=gha,scope=moviepilot-v3-docker,mode=max,version=2
cache-from: |
type=gha,scope=moviepilot-v3-standard-docker-amd64,version=2
type=gha,scope=moviepilot-v3-standard-docker-arm64,version=2
- name: Publish free-threaded multi-architecture image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: docker/Dockerfile
platforms: |
linux/amd64
linux/arm64/v8
push: true
pull: false
tags: ${{ steps.meta_ft.outputs.tags }}
build-args: |
MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }}
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
MOVIEPILOT_PYTHON_VARIANT=free-threaded
labels: |
${{ steps.meta_ft.outputs.labels }}
org.opencontainers.image.revision=${{ env.SOURCE_COMMIT }}
org.moviepilot.source-revision=${{ env.SOURCE_COMMIT }}
org.moviepilot.frontend-version=${{ steps.release_version.outputs.frontend_version }}
org.moviepilot.frontend-digest=${{ steps.payloads.outputs.frontend_digest }}
org.moviepilot.plugins-revision=${{ steps.payloads.outputs.plugins_revision }}
org.moviepilot.resources-revision=${{ steps.payloads.outputs.resources_revision }}
org.moviepilot.plugin-market-wiki-revision=${{ steps.plugin_market.outputs.wiki_commit }}
org.moviepilot.models-catalog-digest=${{ steps.models_catalog.outputs.digest }}
cache-from: |
type=gha,scope=moviepilot-v3t-docker-amd64,version=2
type=gha,scope=moviepilot-v3t-docker-arm64,version=2
- name: Promote beta image pair
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
run: |
candidate="beta-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
for image in \
"${DOCKER_USERNAME}/moviepilot-v3" \
"ghcr.io/${GITHUB_REPOSITORY}-v3" \
"${DOCKER_USERNAME}/moviepilot-v3t" \
"ghcr.io/${GITHUB_REPOSITORY}-v3t"; do
docker buildx imagetools create \
--tag "${image}:beta" \
"${image}:${candidate}"
done
+152 -14
View File
@@ -15,6 +15,9 @@ jobs:
Docker-build:
runs-on: ubuntu-latest
name: Build Docker Image
env:
TRIVY_SKIP_DIRS: /usr/share/java
TRIVY_SKIP_JAVA_DB_UPDATE: "true"
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -32,14 +35,22 @@ jobs:
- name: Audit locked Python dependencies
run: |
uv export --quiet --locked --no-dev --no-emit-project \
--output-file /tmp/moviepilot-audit-requirements.txt
uv export --quiet --locked --no-default-groups --group runtime-standard \
--no-emit-project --output-file /tmp/moviepilot-audit-standard.txt
uvx --from pip-audit==2.10.1 pip-audit \
--require-hashes \
--disable-pip \
--strict \
--progress-spinner off \
--requirement /tmp/moviepilot-audit-requirements.txt
--require-hashes --disable-pip --strict --progress-spinner off \
--requirement /tmp/moviepilot-audit-standard.txt
uv export --quiet --locked --no-default-groups --group runtime-free-threaded \
--no-emit-project --no-hashes \
--output-file /tmp/moviepilot-audit-free-threaded.txt
python3 scripts/normalize_audit_requirements.py \
--lock uv.lock \
--input /tmp/moviepilot-audit-free-threaded.txt \
--output /tmp/moviepilot-audit-free-threaded-normalized.txt
uvx --from pip-audit==2.10.1 pip-audit \
--no-deps --disable-pip --strict --progress-spinner off \
--requirement /tmp/moviepilot-audit-free-threaded-normalized.txt
- name: Release version
id: release_version
@@ -130,7 +141,16 @@ jobs:
ghcr.io/${{ github.repository }}-v3
tags: |
type=raw,value=${{ env.app_version }}
type=raw,value=latest
- name: Docker Meta free-threaded
id: meta_ft
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.DOCKER_USERNAME }}/moviepilot-v3t
ghcr.io/${{ github.repository }}-v3t
tags: |
type=raw,value=${{ env.app_version }}
- name: Set Up QEMU
uses: docker/setup-qemu-action@v3
@@ -153,8 +173,9 @@ jobs:
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
cache-from: type=gha,scope=moviepilot-v3-docker-amd64,version=2
cache-to: type=gha,scope=moviepilot-v3-docker-amd64,mode=max,version=2
MOVIEPILOT_PYTHON_VARIANT=standard
cache-from: type=gha,scope=moviepilot-v3-standard-docker-amd64,version=2
cache-to: type=gha,scope=moviepilot-v3-standard-docker-amd64,mode=max,version=2
- name: Scan amd64 candidate vulnerabilities
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
@@ -184,8 +205,9 @@ jobs:
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
cache-from: type=gha,scope=moviepilot-v3-docker-arm64,version=2
cache-to: type=gha,scope=moviepilot-v3-docker-arm64,mode=max,version=2
MOVIEPILOT_PYTHON_VARIANT=standard
cache-from: type=gha,scope=moviepilot-v3-standard-docker-arm64,version=2
cache-to: type=gha,scope=moviepilot-v3-standard-docker-arm64,mode=max,version=2
- name: Scan arm64 candidate vulnerabilities
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
@@ -200,6 +222,70 @@ jobs:
trivyignores: .trivyignore.yaml
exit-code: 1
- name: Build free-threaded amd64 candidate
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: docker/Dockerfile
platforms: linux/amd64
load: true
push: false
pull: true
tags: moviepilot-v3t-candidate:linux-amd64
build-args: |
MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }}
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
MOVIEPILOT_PYTHON_VARIANT=free-threaded
cache-from: type=gha,scope=moviepilot-v3t-docker-amd64,version=2
cache-to: type=gha,scope=moviepilot-v3t-docker-amd64,mode=max,version=2
- name: Scan free-threaded amd64 candidate vulnerabilities
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
with:
image-ref: moviepilot-v3t-candidate:linux-amd64
version: v0.70.0
cache-dir: ${{ runner.temp }}/trivy
scanners: vuln
vuln-type: os,library
severity: HIGH,CRITICAL
ignore-unfixed: true
trivyignores: .trivyignore.yaml
exit-code: 1
- name: Build free-threaded arm64 candidate
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: docker/Dockerfile
platforms: linux/arm64/v8
load: true
push: false
pull: true
tags: moviepilot-v3t-candidate:linux-arm64
build-args: |
MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }}
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
MOVIEPILOT_PYTHON_VARIANT=free-threaded
cache-from: type=gha,scope=moviepilot-v3t-docker-arm64,version=2
cache-to: type=gha,scope=moviepilot-v3t-docker-arm64,mode=max,version=2
- name: Scan free-threaded arm64 candidate vulnerabilities
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
with:
image-ref: moviepilot-v3t-candidate:linux-arm64
version: v0.70.0
cache-dir: ${{ runner.temp }}/trivy
scanners: vuln
vuln-type: os,library
severity: HIGH,CRITICAL
ignore-unfixed: true
trivyignores: .trivyignore.yaml
exit-code: 1
- name: Login DockerHub
uses: docker/login-action@v3
with:
@@ -229,6 +315,7 @@ jobs:
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
MOVIEPILOT_PYTHON_VARIANT=standard
labels: |
${{ steps.meta.outputs.labels }}
org.opencontainers.image.revision=${{ steps.release_snapshot.outputs.release_commit }}
@@ -241,8 +328,59 @@ jobs:
org.moviepilot.plugin-market-wiki-revision=${{ steps.plugin_market.outputs.wiki_commit }}
org.moviepilot.models-catalog-digest=${{ steps.models_catalog.outputs.digest }}
cache-from: |
type=gha,scope=moviepilot-v3-docker-amd64,version=2
type=gha,scope=moviepilot-v3-docker-arm64,version=2
type=gha,scope=moviepilot-v3-standard-docker-amd64,version=2
type=gha,scope=moviepilot-v3-standard-docker-arm64,version=2
- name: Publish free-threaded multi-architecture image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: docker/Dockerfile
platforms: |
linux/amd64
linux/arm64/v8
push: true
pull: false
tags: ${{ steps.meta_ft.outputs.tags }}
build-args: |
MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }}
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
MOVIEPILOT_PYTHON_VARIANT=free-threaded
labels: |
${{ steps.meta_ft.outputs.labels }}
org.opencontainers.image.revision=${{ steps.release_snapshot.outputs.release_commit }}
org.moviepilot.source-revision=${{ env.SOURCE_COMMIT }}
org.moviepilot.release-snapshot-revision=${{ steps.release_snapshot.outputs.release_commit }}
org.moviepilot.frontend-version=${{ steps.release_version.outputs.frontend_version }}
org.moviepilot.frontend-digest=${{ steps.payloads.outputs.frontend_digest }}
org.moviepilot.plugins-revision=${{ steps.payloads.outputs.plugins_revision }}
org.moviepilot.resources-revision=${{ steps.payloads.outputs.resources_revision }}
org.moviepilot.plugin-market-wiki-revision=${{ steps.plugin_market.outputs.wiki_commit }}
org.moviepilot.models-catalog-digest=${{ steps.models_catalog.outputs.digest }}
cache-from: |
type=gha,scope=moviepilot-v3t-docker-amd64,version=2
type=gha,scope=moviepilot-v3t-docker-arm64,version=2
- name: Promote latest image pair
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
run: |
for image in \
"${DOCKER_USERNAME}/moviepilot-v3" \
"ghcr.io/${GITHUB_REPOSITORY}-v3"; do
docker buildx imagetools create \
--tag "${image}:latest" \
"${image}:${app_version}"
done
for image in \
"${DOCKER_USERNAME}/moviepilot-v3t" \
"ghcr.io/${GITHUB_REPOSITORY}-v3t"; do
docker buildx imagetools create \
--tag "${image}:latest" \
"${image}:${app_version}"
done
- name: Generate Changelog
id: changelog
+39 -8
View File
@@ -7,6 +7,9 @@ on:
paths:
- 'pyproject.toml'
- 'uv.lock'
- 'app/doctor/dependencies.py'
- 'app/foundation/environment.py'
- 'app/runtime/dependencies.py'
- 'docker/Dockerfile'
- 'docker/**'
- '.github/workflows/dependency-compat.yml'
@@ -16,6 +19,9 @@ on:
paths:
- 'pyproject.toml'
- 'uv.lock'
- 'app/doctor/dependencies.py'
- 'app/foundation/environment.py'
- 'app/runtime/dependencies.py'
- 'docker/Dockerfile'
- 'docker/**'
- '.github/workflows/dependency-compat.yml'
@@ -91,8 +97,8 @@ jobs:
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
- name: Verify locked project consistency
run: uv sync --locked --offline --inexact --no-dev --check --python ${{ matrix.python-version }}
docker-dependencies:
name: Docker dependencies / ${{ matrix.platform }}
@@ -104,13 +110,27 @@ jobs:
include:
- runner: ubuntu-24.04
platform: linux/amd64
cache-scope: linux-amd64
image-tag: moviepilot-dependency-gate:linux-amd64
python-variant: standard
cache-scope: linux-amd64-standard
image-tag: moviepilot-dependency-gate:linux-amd64-standard
expected-machine: x86_64
- runner: ubuntu-24.04-arm
platform: linux/arm64
cache-scope: linux-arm64
image-tag: moviepilot-dependency-gate:linux-arm64
python-variant: standard
cache-scope: linux-arm64-standard
image-tag: moviepilot-dependency-gate:linux-arm64-standard
expected-machine: aarch64
- runner: ubuntu-24.04
platform: linux/amd64
python-variant: free-threaded
cache-scope: linux-amd64-free-threaded
image-tag: moviepilot-dependency-gate:linux-amd64-free-threaded
expected-machine: x86_64
- runner: ubuntu-24.04-arm
platform: linux/arm64
python-variant: free-threaded
cache-scope: linux-arm64-free-threaded
image-tag: moviepilot-dependency-gate:linux-arm64-free-threaded
expected-machine: aarch64
steps:
@@ -127,6 +147,8 @@ jobs:
file: docker/Dockerfile
target: prepare_venv
platforms: ${{ matrix.platform }}
build-args: |
MOVIEPILOT_PYTHON_VARIANT=${{ matrix.python-variant }}
load: true
push: false
tags: ${{ matrix.image-tag }}
@@ -137,14 +159,23 @@ jobs:
env:
IMAGE_TAG: ${{ matrix.image-tag }}
EXPECTED_MACHINE: ${{ matrix.expected-machine }}
EXPECTED_VARIANT: ${{ matrix.python-variant }}
run: >-
docker run --rm
-e EXPECTED_MACHINE
-e EXPECTED_VARIANT
"${IMAGE_TAG}"
/opt/venv/bin/python -c
"import os, platform;
"import os, platform, sys, sysconfig;
assert platform.machine() == os.environ['EXPECTED_MACHINE'], (platform.machine(), os.environ['EXPECTED_MACHINE']);
import alembic, fastapi, pydantic, pydantic_settings, sqlalchemy, starlette, uvicorn"
expected_free_threaded = os.environ['EXPECTED_VARIANT'] == 'free-threaded';
assert (sysconfig.get_config_var('Py_GIL_DISABLED') == 1) is expected_free_threaded;
assert sys._is_gil_enabled() == (not expected_free_threaded);
import alembic, fastapi, moviepilot_rust, pydantic, pydantic_settings, sqlalchemy, starlette, uvicorn;
assert moviepilot_rust.is_available();
assert moviepilot_rust.jieba_cut('中文分词');
assert not expected_free_threaded or callable(moviepilot_rust.zhconv_fast);
assert sys._is_gil_enabled() == (not expected_free_threaded)"
- name: Verify pinned uv version
env:
+1
View File
@@ -29,6 +29,7 @@ config/.cache/
# 运行期设置持久化目录(settings 写回 app.env 的落点)与本地验证产物
app/config/
.verify_tmp/
.artifacts/
.runtime/
public/
.moviepilot.env
+1 -1
View File
@@ -15,7 +15,7 @@ def _filter_third_party_startup_warnings() -> None:
)
warnings.filterwarnings(
"ignore",
message=r"invalid escape sequence '\\&'",
message=r'"\\&" is an invalid escape sequence\..*',
category=SyntaxWarning,
)
+66 -19
View File
@@ -31,6 +31,10 @@ from importlib.metadata import distributions
from requests import Response
from app.runtime.cache import cached, is_fresh
from app.runtime.dependencies import (
iter_runtime_profile_requirement_strings,
iter_runtime_requirement_strings,
)
from app.runtime.settings import RuntimeSettingsCompat
from app.adapters.system.package import (
PackageInstallRequest,
@@ -205,10 +209,7 @@ class PluginHelper(metaclass=WeakSingleton):
"starlette",
"uvicorn",
})
_runtime_import_probe = (
"import alembic, fastapi, pydantic, pydantic_core, pydantic_settings, "
"sqlalchemy, starlette, uvicorn; from pydantic import BaseModel, Field"
)
_runtime_import_probe = "app.doctor.dependencies"
@staticmethod
def is_local_repo_url(repo_url: Optional[str]) -> bool:
@@ -1316,12 +1317,12 @@ class PluginHelper(metaclass=WeakSingleton):
return list(dict.fromkeys(wheels_dirs))
@staticmethod
def __build_runtime_uv_command(*args: str) -> List[str]:
"""构造绑定当前解释器环境的 uv pip 命令。"""
def __build_runtime_uv_check_command() -> List[str]:
"""构造绑定当前解释器环境的 uv 依赖诊断命令。"""
uv_bin = find_uv(Path(sys.executable))
if not uv_bin:
return []
return [str(uv_bin), "pip", *args, "--python", sys.executable]
return [str(uv_bin), "pip", "check", "--python", sys.executable]
@staticmethod
def __format_package_name(name: str) -> str:
@@ -1355,8 +1356,8 @@ class PluginHelper(metaclass=WeakSingleton):
return roots
try:
manifest = load_dependency_file(project_file)
for requirement in manifest.dependencies:
for raw_requirement in iter_runtime_requirement_strings(project_file):
requirement = Requirement(raw_requirement)
if not cls.__marker_matches(requirement.marker):
continue
package_name = cls.__standardize_pkg_name(requirement.name)
@@ -1469,6 +1470,20 @@ class PluginHelper(metaclass=WeakSingleton):
return protected_packages
@classmethod
def __get_strict_runtime_packages(cls) -> Set[str]:
"""返回核心包及当前 ABI profile 中不得被插件改写的根包。"""
packages = set(cls._protected_runtime_packages)
project_file = settings.ROOT_PATH / "pyproject.toml"
try:
for raw_requirement in iter_runtime_profile_requirement_strings(project_file):
requirement = Requirement(raw_requirement)
if cls.__marker_matches(requirement.marker):
packages.add(cls.__standardize_pkg_name(requirement.name))
except Exception as error:
logger.error(f"解析运行依赖 profile 失败:{project_file} - {error}")
return packages
@staticmethod
def __is_upgrade_only_conflict(specifier_set: SpecifierSet, installed_version: Version) -> bool:
"""
@@ -1516,6 +1531,7 @@ class PluginHelper(metaclass=WeakSingleton):
允许后续安装继续调整版本。
"""
conflicts = []
strict_packages = cls.__get_strict_runtime_packages()
try:
manifest = load_dependency_file(dependency_file)
for requirement in manifest.dependencies:
@@ -1532,7 +1548,7 @@ class PluginHelper(metaclass=WeakSingleton):
package_name,
str(installed_version),
f"来自 {requirement.url} 的同名包",
package_name in cls._protected_runtime_packages,
package_name in strict_packages,
))
continue
@@ -1540,7 +1556,7 @@ class PluginHelper(metaclass=WeakSingleton):
installed_version,
prereleases=True
):
is_core = package_name in cls._protected_runtime_packages
is_core = package_name in strict_packages
# 非核心包的纯升级冲突允许放行,由安装约束控制实际版本。
if is_core or not cls.__is_upgrade_only_conflict(
requirement.specifier, installed_version):
@@ -1590,9 +1606,10 @@ class PluginHelper(metaclass=WeakSingleton):
suffix=".txt",
delete=False
) as temp_file:
strict_packages = cls.__get_strict_runtime_packages()
for package_name, version in sorted(protected_packages.items()):
if package_name in cls._protected_runtime_packages:
# 核心包严格锁定,插件不得改写
if package_name in strict_packages:
# 核心与 ABI profile 根包严格锁定,插件不得改写
temp_file.write(f"{cls.__format_package_name(package_name)}=={version}\n")
else:
# 非核心主程序依赖:允许升级,但禁止降级
@@ -1697,31 +1714,60 @@ class PluginHelper(metaclass=WeakSingleton):
执行全部运行环境自检并返回逐项结果,避免前一项失败遮蔽后续异常。
"""
health_snapshot = {}
uv_check = cls.__build_runtime_uv_command("check")
uv_check = cls.__build_runtime_uv_check_command()
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]))
checks.append(("核心依赖导入检查", [
sys.executable,
"-m",
cls._runtime_import_probe,
"--full",
]))
for check_name, command in checks:
success, message = SystemUtils.execute_with_subprocess(command)
health_snapshot[check_name] = (success, message)
return health_snapshot
@staticmethod
def __runtime_health_error_lines(check_name: str, message: str) -> set[str]:
"""提取稳定诊断项,忽略执行器附加的命令摘要。"""
lines = {line.strip() for line in message.splitlines() if line.strip()}
if check_name != "uv check":
return lines
package_errors = set(re.findall(
r"The package `[^`]+` requires `[^`]+`, but [^\r\n;]+",
message,
))
return package_errors or lines
@staticmethod
def __runtime_health_regression_message(
baseline_health: Dict[str, Tuple[bool, str]],
current_health: Dict[str, Tuple[bool, str]]
) -> str:
"""
汇总相对基线从正常变为异常的检查项,不解析第三方工具的错误文本
汇总相对基线新增的异常;已有诊断失败不能遮蔽后续新增错误
"""
regressions = []
for check_name, (success, message) in current_health.items():
baseline_success = baseline_health.get(check_name, (True, ""))[0]
baseline_success, baseline_message = baseline_health.get(check_name, (True, ""))
if baseline_success and not success:
regressions.append(f"{check_name}失败:{message}")
elif not baseline_success and not success:
baseline_lines = PluginHelper.__runtime_health_error_lines(
check_name,
baseline_message,
)
current_lines = PluginHelper.__runtime_health_error_lines(
check_name,
message,
)
added_lines = sorted(current_lines - baseline_lines)
if added_lines:
regressions.append(f"{check_name}新增错误:{' | '.join(added_lines)}")
return "".join(regressions)
@classmethod
@@ -2573,7 +2619,7 @@ class PluginHelper(metaclass=WeakSingleton):
async def __async_run_runtime_healthcheck(cls) -> Dict[str, Tuple[bool, str]]:
"""异步执行插件安装后的运行环境检查。"""
health_snapshot: Dict[str, Tuple[bool, str]] = {}
uv_check = cls.__build_runtime_uv_command("check")
uv_check = cls.__build_runtime_uv_check_command()
if uv_check:
checks = [("uv check", uv_check)]
else:
@@ -2581,8 +2627,9 @@ class PluginHelper(metaclass=WeakSingleton):
checks = []
checks.append(("核心依赖导入检查", [
sys.executable,
"-c",
"-m",
cls._runtime_import_probe,
"--full",
]))
for check_name, command in checks:
health_snapshot[check_name] = (
+3
View File
@@ -6,6 +6,8 @@ from dataclasses import dataclass, field
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
from app.runtime.dependencies import runtime_sync_arguments
@dataclass(frozen=True)
class PackageInstallRequest:
@@ -132,6 +134,7 @@ def _build_uv_sync_command(uv_bin: Path, request: PackageInstallRequest, use_ind
"--no-dev",
"--no-install-project",
"--inexact",
*runtime_sync_arguments(),
]
if use_index and request.package_index_url:
command.extend(["--default-index", request.package_index_url])
+3 -1
View File
@@ -1,6 +1,7 @@
import json
import platform
import sys
import sysconfig
from pathlib import Path
from typing import Callable
@@ -63,7 +64,8 @@ class ResourceHelper:
def _get_python_version_tag() -> str:
"""返回资源文件名使用的 CPython ABI 标签。"""
version = sys.version_info
return f"cp{version.major}{version.minor}"
free_threaded = "t" if sysconfig.get_config_var("Py_GIL_DISABLED") else ""
return f"cp{version.major}{version.minor}{free_threaded}"
@staticmethod
def _get_machine_tag() -> str:
+8 -1
View File
@@ -2,6 +2,7 @@ import logging
from functools import lru_cache
from typing import List, Optional, Tuple
from app.foundation.environment import is_free_threaded_runtime
from app.runtime.log import logger, log_settings
from app.runtime.settings import get_runtime_setting
@@ -19,6 +20,11 @@ def _rust_accel_enabled() -> bool:
return bool(get_runtime_setting("RUST_ACCEL"))
def is_required() -> bool:
"""free-threaded 运行时必须使用不会重新启用 GIL 的 Rust 快路径。"""
return is_free_threaded_runtime()
def is_available() -> bool:
"""
判断 Rust 扩展是否可用。
@@ -30,7 +36,7 @@ def is_config_enabled() -> bool:
"""
判断系统配置是否允许使用 Rust 加速。
"""
return _rust_accel_enabled()
return is_required() or _rust_accel_enabled()
def is_enabled() -> bool:
@@ -47,6 +53,7 @@ def status() -> dict:
return {
"available": is_available(),
"enabled": is_enabled(),
"required": is_required(),
"import_error": str(_import_error) if _import_error else "",
}
+7
View File
@@ -71,6 +71,7 @@ from app.schemas.event import ConfigChangeEventData
from app.schemas.exception import PluginMutationRejectedError
from app.schemas.types import SystemConfigKey, EventType
from app.foundation.crypto import HashUtils
from app.foundation.environment import is_free_threaded_runtime, is_gil_enabled
from app.adapters.network.http import RequestUtils, AsyncRequestUtils
from app.adapters.system import rust as rust_accel
from app.application.security.url import SecurityUtils
@@ -802,6 +803,8 @@ async def get_user_global_setting(_: ApiPrincipal = Depends(get_current_active_u
"USER_UNIQUE_ID": MoviePilotServerHelper.get_user_uuid(),
"SUBSCRIBE_SHARE_MANAGE": share_admin,
"WORKFLOW_SHARE_MANAGE": share_admin,
"PYTHON_FREE_THREADED": is_free_threaded_runtime(),
"PYTHON_GIL_ENABLED": is_gil_enabled(),
}
)
return _SchemaResponse(success=True, data=info)
@@ -827,8 +830,12 @@ async def get_env_setting(
"AUTH_VERSION": SitesHelper().auth_version,
"INDEXER_VERSION": SitesHelper().indexer_version,
"FRONTEND_VERSION": SystemChain().get_frontend_version(),
"RUST_ACCEL": rust_accel.is_config_enabled(),
"RUST_ACCEL_AVAILABLE": rust_accel.is_available(),
"RUST_ACCEL_ENABLED": rust_accel.is_enabled(),
"RUST_ACCEL_REQUIRED": rust_accel.is_required(),
"PYTHON_FREE_THREADED": is_free_threaded_runtime(),
"PYTHON_GIL_ENABLED": is_gil_enabled(),
}
)
return _SchemaResponse(success=True, data=info)
+7 -1
View File
@@ -12,6 +12,7 @@ from sqlalchemy.engine import Engine as SyncEngine
from sqlalchemy.ext.asyncio import AsyncEngine as SaAsyncEngine, create_async_engine
from sqlalchemy.pool import Pool
from app.foundation.environment import is_free_threaded_runtime
from app.runtime.config import settings
from app.db.diagnostics import _register_database_error_logging
from app.db.worker import DATABASE_WORKER_MAX_WORKERS
@@ -24,6 +25,11 @@ def _database_backend_label() -> str:
return "postgresql" if settings.DB_TYPE.lower() == "postgresql" else "sqlite"
def _sync_postgresql_driver() -> Optional[str]:
"""free-threaded 解释器使用不会重新启用 GIL 的 psycopg 驱动。"""
return "psycopg" if is_free_threaded_runtime() else None
def _register_database_pool_metrics(engine: SyncEngine) -> None:
"""在 SQLAlchemy 池 checkout/checkin 边界维护当前借出连接数。"""
if not isinstance(engine.pool, Pool):
@@ -156,7 +162,7 @@ def _get_postgresql_engine(is_async: bool = False, pooled: bool = False):
"""
获取PostgreSQL数据库引擎
"""
db_url = settings.DB_POSTGRESQL_URL()
db_url = settings.DB_POSTGRESQL_URL(_sync_postgresql_driver())
# PostgreSQL连接参数。允许部署侧注入驱动级参数,
# 例如经 PgBouncer 事务模式接入时 asyncpg 需要 statement_cache_size=0
+92
View File
@@ -0,0 +1,92 @@
"""主程序运行依赖的轻量可用性探针。"""
import argparse
from importlib import import_module
import sys
import sysconfig
import warnings
CORE_MODULES = (
"alembic",
"cloakbrowser",
"fastapi",
"pydantic",
"pydantic_core",
"pydantic_settings",
"sqlalchemy",
"starlette",
"uvicorn",
)
NATIVE_MODULES = (
("asyncpg", "asyncpg"),
("bcrypt", "bcrypt._bcrypt"),
("brotli", "brotli"),
("crcmod", "crcmod.crcmod"),
("cryptography", "cryptography.hazmat.bindings._rust"),
("greenlet", "greenlet._greenlet"),
("lxml", "lxml.etree"),
("orjson", "orjson"),
("oss2", "oss2"),
("pillow", "PIL._imaging"),
("pillow-avif-plugin", "pillow_avif"),
("pydantic-core", "pydantic_core._pydantic_core"),
("zstandard", "zstandard"),
)
def _verify_text_capabilities(*, free_threaded: bool) -> None:
"""验证标准与 free-threaded profile 共同依赖的文本能力。"""
moviepilot_rust = import_module("moviepilot_rust")
if not moviepilot_rust.is_available() or not moviepilot_rust.jieba_cut("中文分词"):
raise RuntimeError("中文分词运行依赖不可用")
if free_threaded:
converted = moviepilot_rust.zhconv_fast("后台", "zh-hant")
else:
converted = import_module("zhconv_rs").zhconv("后台", "zh-hant")
if not converted:
raise RuntimeError("中文转换运行依赖不可用")
def _verify_native_profile(*, free_threaded: bool) -> None:
"""验证 ABI 敏感依赖提供预期的原生能力。"""
warnings.filterwarnings(
"ignore",
message=r'"\\&" is an invalid escape sequence\..*',
category=SyntaxWarning,
)
imported = {}
profile_modules = (
(("psycopg", "psycopg"),)
if free_threaded
else (("psycopg2", "psycopg2"), ("zhconv-rs", "zhconv_rs"))
)
for name, module_name in (*NATIVE_MODULES, *profile_modules):
imported[name] = import_module(module_name)
if free_threaded and sys._is_gil_enabled():
raise RuntimeError(f"原生依赖 {name} 启用了 GIL")
if not imported["crcmod"]._usingExtension:
raise RuntimeError("crcmod 原生实现不可用")
if free_threaded and imported["psycopg"].pq.__impl__ != "c":
raise RuntimeError("psycopg C 实现不可用")
def main(*, full: bool = False) -> None:
"""验证 Web 栈和启动关键能力可导入、可执行。"""
free_threaded = sysconfig.get_config_var("Py_GIL_DISABLED") == 1
for module_name in CORE_MODULES:
import_module(module_name)
_verify_text_capabilities(free_threaded=free_threaded)
if full:
_verify_native_profile(free_threaded=free_threaded)
if free_threaded and sys._is_gil_enabled():
raise RuntimeError("核心运行依赖启用了 GIL")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--full", action="store_true")
main(full=parser.parse_args().full)
+11
View File
@@ -3,6 +3,7 @@
import os
import platform
import sys
import sysconfig
from pathlib import Path
from typing import Optional
@@ -17,6 +18,16 @@ def is_frozen() -> bool:
return bool(getattr(sys, "frozen", False))
def is_free_threaded_runtime() -> bool:
"""判断当前解释器是否为 CPython free-threaded 构建。"""
return sysconfig.get_config_var("Py_GIL_DISABLED") == 1
def is_gil_enabled() -> bool:
"""返回当前 CPython 进程是否实际启用了 GIL。"""
return sys._is_gil_enabled()
def is_windows() -> bool:
"""判断当前操作系统是否为 Windows。"""
return os.name == "nt"
+13 -7
View File
@@ -4,19 +4,25 @@ import random
import re
from typing import Generator, List, Optional, Union
from jieba_next import cut as jieba_next_cut
from zhconv_rs import zhconv as _zhconv # pylint: disable=no-name-in-module
import moviepilot_rust
from app.foundation.environment import is_free_threaded_runtime
if is_free_threaded_runtime():
_zhconv = moviepilot_rust.zhconv_fast
else:
import zhconv_rs
_zhconv = zhconv_rs.zhconv
def cut(text: str, HMM: bool = True, cut_all: bool = False) -> list[str]:
"""
使用 jieba-next 执行中文分词,并兼容 jieba.cut 的常用参数名。
"""
return list(jieba_next_cut(text, HMM=HMM, cut_all=cut_all))
"""通过统一原生入口执行中文分词。"""
return moviepilot_rust.jieba_cut(text, hmm=HMM, cut_all=cut_all)
def convert(text: str, target: str) -> str:
"""使用 zhconv-rs 执行中文简繁转换,并隔离第三方包的函数名差异"""
"""通过统一入口执行 MediaWiki 中文简繁转换"""
return _zhconv(text, target)
+8 -1
View File
@@ -16,6 +16,7 @@ from dotenv import set_key, unset_key
from pydantic import BaseModel, Field, ConfigDict, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from app.foundation.environment import is_free_threaded_runtime
from app.runtime.log import (
LogConfigModel,
configure_log_settings,
@@ -596,7 +597,7 @@ class ConfigModel(BaseModel):
# ==================== 性能配置 ====================
# 大内存模式
BIG_MEMORY_MODE: bool = False
# Rust 加速总开关,关闭时所有 Rust 快路径回退到 Python 实现
# Rust 加速总开关,free-threaded 运行时固定启用
RUST_ACCEL: bool = True
# 是否启用编码探测的性能模式
ENCODING_DETECTION_PERFORMANCE_MODE: bool = True
@@ -996,6 +997,12 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
converted_value, needs_update = self.generic_type_converter(
value, original_value, field.annotation, field.default, key
)
if (
key == "RUST_ACCEL"
and is_free_threaded_runtime()
and converted_value is not True
):
return False, "free-threaded 运行时必须启用 Rust 加速"
# 如果没有抛出异常,则统一使用 converted_value 进行更新
if needs_update or str(value) != str(converted_value):
success, message = self.update_env_config(key, value, converted_value)
+56
View File
@@ -0,0 +1,56 @@
"""按解释器 ABI 选择主程序运行依赖 profile。"""
from __future__ import annotations
import tomllib
from collections.abc import Iterable
from pathlib import Path
from app.foundation.environment import is_free_threaded_runtime
RUNTIME_STANDARD_GROUP = "runtime-standard"
RUNTIME_FREE_THREADED_GROUP = "runtime-free-threaded"
def runtime_dependency_group() -> str:
"""返回当前解释器必须使用的互斥运行依赖组。"""
if is_free_threaded_runtime():
return RUNTIME_FREE_THREADED_GROUP
return RUNTIME_STANDARD_GROUP
def runtime_sync_arguments() -> tuple[str, ...]:
"""返回 uv sync 选择当前运行依赖组所需的稳定参数。"""
return "--no-default-groups", "--group", runtime_dependency_group()
def iter_runtime_requirement_strings(project_file: Path) -> Iterable[str]:
"""读取主项目依赖及当前运行 profile 的根依赖声明。"""
with project_file.open("rb") as file:
document = tomllib.load(file)
project = document.get("project") or {}
for requirement in project.get("dependencies") or ():
if isinstance(requirement, str):
yield requirement
groups = document.get("dependency-groups") or {}
for requirement in groups.get(runtime_dependency_group()) or ():
if isinstance(requirement, str):
yield requirement
def iter_runtime_profile_requirement_strings(project_file: Path) -> Iterable[str]:
"""读取当前解释器 profile 的根依赖声明。"""
with project_file.open("rb") as file:
document = tomllib.load(file)
groups = document.get("dependency-groups") or {}
for requirement in groups.get(runtime_dependency_group()) or ():
if isinstance(requirement, str):
yield requirement
if __name__ == "__main__":
print(runtime_dependency_group())
+38 -5
View File
@@ -23,6 +23,7 @@ from app.schemas.plugin import Plugin as _SchemaPlugin
from app.schemas.plugin import PluginDashboard as _SchemaPluginDashboard
from app.schemas.plugin import PluginInstance, PluginRuntimeStatus
from app.foundation.crypto import RSAUtils
from app.foundation.environment import is_free_threaded_runtime, is_gil_enabled
from app.foundation.singleton import Singleton
from app.foundation.version import compare_version
from app.runtime.execution import run_in_threadpool_to_completion
@@ -90,6 +91,24 @@ def _unavailable_plugin_catalog_factory(_manager: "PluginManager") -> Any:
raise RuntimeError("插件目录应用服务尚未由启动组合根装配")
def _warn_if_plugin_enabled_gil(
*,
gil_enabled_before: bool,
plugin_id: Optional[str],
) -> None:
"""记录插件加载使 free-threaded 进程重新启用 GIL 的真实转换。"""
if (
not is_free_threaded_runtime()
or gil_enabled_before
or not is_gil_enabled()
):
return
logger.warning(
"加载插件%s后 free-threaded 运行时已启用 GIL,请检查原生扩展兼容性",
plugin_id or "集合",
)
_legacy_diagnostics_configurator: LegacyDiagnosticsConfigurator = (
_ignore_legacy_diagnostics
)
@@ -363,7 +382,14 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
enabled=settings.DEBUG,
emitter=logger.warning,
)
return self._plugin_lifecycle.start(pid)
gil_enabled_before = is_gil_enabled()
try:
return self._plugin_lifecycle.start(pid)
finally:
_warn_if_plugin_enabled_gil(
gil_enabled_before=gil_enabled_before,
plugin_id=pid,
)
except PluginMutationRejectedError as error:
logger.warning(str(error))
if pid:
@@ -719,10 +745,17 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
try:
with self.mutation("重新加载插件"):
with self._plugin_quiesce_lock:
return self._plugin_lifecycle.reload(
plugin_id,
EventType.PluginReload,
)
gil_enabled_before = is_gil_enabled()
try:
return self._plugin_lifecycle.reload(
plugin_id,
EventType.PluginReload,
)
finally:
_warn_if_plugin_enabled_gil(
gil_enabled_before=gil_enabled_before,
plugin_id=plugin_id,
)
except PluginMutationRejectedError as error:
logger.warning(str(error))
return PluginRuntimeStatus.LOAD_FAILED
+2 -1
View File
@@ -2,7 +2,7 @@
from app.foundation.crypto import CryptoJsUtils
from app.foundation.dom import DomUtils
from app.foundation.text import cut
from app.foundation.text import convert, cut
from app.foundation.reflection import ObjectUtils
from app.foundation.singleton import Singleton
from app.adapters.system.host import SystemUtils
@@ -28,6 +28,7 @@ __all__ = [
"SystemUtils",
"TimerUtils",
"cut",
"convert",
"decrypt",
"encrypt",
"log_execution_time",
+19
View File
@@ -29,6 +29,7 @@ from app.application.plugin.lifecycle import plugin_lifecycle
from app.application.plugin.runtime import get_plugin_manager
from app.runtime.config import global_vars
from app.runtime.settings import RuntimeSettingsCompat
from app.foundation.environment import is_free_threaded_runtime, is_gil_enabled
settings = RuntimeSettingsCompat()
from app.runtime.health import get_application_health
@@ -88,6 +89,7 @@ async def init_extra():
if settings.MOVIEPILOT_SAFE_MODE:
SystemHelper().set_system_modified()
SystemChain().restart_finish()
_log_runtime_gil_status()
return
plugin_manager = get_plugin_manager()
try:
@@ -102,6 +104,7 @@ async def init_extra():
finally:
plugin_manager.set_plugin_settling(False)
plugin_manager.start_monitor()
_log_runtime_gil_status()
# 设置系统已修改标志
SystemHelper().set_system_modified()
# 重启完成
@@ -110,6 +113,22 @@ async def init_extra():
await MoviePilotServerHelper.async_report_usage()
def _log_runtime_gil_status() -> None:
"""在核心模块和插件完成导入后记录解释器的实际并发模式。"""
free_threaded = is_free_threaded_runtime()
gil_enabled = is_gil_enabled()
if free_threaded and gil_enabled:
logger.warning(
"Python free-threaded 运行时已启用 GIL,请检查此前的原生扩展兼容告警"
)
return
logger.info(
"Python运行时:%sGIL=%s",
"free-threaded" if free_threaded else "standard",
"enabled" if gil_enabled else "disabled",
)
async def run_shutdown_step(
name: str,
callback: Callable[[], object],
+74 -11
View File
@@ -1,5 +1,8 @@
# syntax=docker/dockerfile:1
ARG MOVIEPILOT_PYTHON_VARIANT="standard"
ARG MOVIEPILOT_PYTHON_VERSION="3.14.7"
FROM ghcr.io/astral-sh/uv:0.12.5@sha256:e85be844203885286c60ffad8a858d48afb6c5a5c237ca0e67f12e74b8f174b1 AS uv
@@ -9,7 +12,10 @@ FROM rclone/rclone:1.75.0@sha256:b06aed988cf5967de7c25be5925240983981c757f4ed1ac
FROM mwader/static-ffmpeg:8.1.1@sha256:735f84b905e00d5c618b667f0b053f83b1096f5fc404c607e6134bf2275a0e0a AS ffmpeg
FROM python:3.14.7-slim-trixie AS base
FROM python:${MOVIEPILOT_PYTHON_VERSION}-slim-trixie AS base
FROM rust:slim-trixie AS rust_toolchain
# 准备外部制品所需的最小工具集
@@ -33,6 +39,7 @@ ENV LANG="C.UTF-8" \
PUID=0 \
PGID=0 \
UMASK=000 \
PYTHON_THREAD_INHERIT_CONTEXT=0 \
VENV_PATH="/opt/venv"
ENV PATH="${VENV_PATH}/bin:${PATH}"
@@ -94,8 +101,8 @@ RUN apt-get update \
/var/tmp/*
# 准备 python 环境
FROM base AS prepare_venv
# 准备 Python 环境的公共输入
FROM base AS prepare_venv_common
# 设置环境变量
ENV LANG="C.UTF-8" \
@@ -112,15 +119,65 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
jq \
wget
# 按锁文件创建主程序虚拟环境
WORKDIR /app
COPY --from=uv /uv /usr/local/bin/uv
COPY pyproject.toml uv.lock ./
RUN python3 -m venv --without-pip ${VENV_PATH} \
# 准备标准 Python 运行环境
FROM prepare_venv_common AS prepare_venv_standard
RUN mkdir -p /opt/python \
&& python3 -m venv --without-pip ${VENV_PATH} \
&& UV_PROJECT_ENVIRONMENT=${VENV_PATH} uv sync \
--locked \
--no-dev \
--no-install-project
--locked \
--no-default-groups \
--group runtime-standard \
--no-install-project
# free-threaded 原生依赖需要 Rust 和 C 构建工具链。
FROM prepare_venv_common AS prepare_venv_free-threaded
ARG MOVIEPILOT_PYTHON_VERSION
ENV CARGO_HOME="/usr/local/cargo" \
RUSTUP_HOME="/usr/local/rustup" \
UV_PYTHON_INSTALL_DIR="/opt/python"
ENV PATH="${CARGO_HOME}/bin:${VENV_PATH}/bin:${PATH}"
COPY --from=rust_toolchain /usr/local/cargo /usr/local/cargo
COPY --from=rust_toolchain /usr/local/rustup /usr/local/rustup
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq-dev \
&& rm -rf /var/lib/apt/lists/* \
&& uv python install --no-bin "${MOVIEPILOT_PYTHON_VERSION}t" \
&& python_bin="$(uv python find --managed-python "${MOVIEPILOT_PYTHON_VERSION}t")" \
&& "${python_bin}" -m venv --without-pip ${VENV_PATH} \
&& UV_PROJECT_ENVIRONMENT=${VENV_PATH} uv sync \
--locked \
--no-default-groups \
--group runtime-free-threaded \
--no-install-project \
--python "${python_bin}"
FROM prepare_venv_standard AS verify_venv_standard
COPY app/doctor/dependencies.py /tmp/moviepilot-runtime-dependencies.py
RUN "${VENV_PATH}/bin/python" /tmp/moviepilot-runtime-dependencies.py --full
FROM prepare_venv_free-threaded AS verify_venv_free-threaded
COPY app/doctor/dependencies.py /tmp/moviepilot-runtime-dependencies.py
RUN "${VENV_PATH}/bin/python" /tmp/moviepilot-runtime-dependencies.py --full
ARG MOVIEPILOT_PYTHON_VARIANT
FROM verify_venv_${MOVIEPILOT_PYTHON_VARIANT} AS prepare_venv
# 准备后端源码
FROM base AS prepare_backend
@@ -184,10 +241,15 @@ FROM prepare_payload AS prepare_resources
ARG TARGETARCH
ARG MOVIEPILOT_RESOURCES_REF="main"
ARG MOVIEPILOT_PYTHON_VARIANT="standard"
RUN set -eu; \
test -n "${MOVIEPILOT_RESOURCES_REF}"; \
python_ver="$(python3 -c 'import sys; print(f"cpython-{sys.version_info.major}{sys.version_info.minor}")')"; \
case "${MOVIEPILOT_PYTHON_VARIANT}" in \
standard) python_ver="$(python3 -c 'import sys; print(f"cpython-{sys.version_info.major}{sys.version_info.minor}")')" ;; \
free-threaded) python_ver="cpython-314t" ;; \
*) printf 'Unsupported Python variant: %s\n' "${MOVIEPILOT_PYTHON_VARIANT}" >&2; exit 1 ;; \
esac; \
target_arch="${TARGETARCH:-$(uname -m)}"; \
case "${target_arch}" in \
arm64|aarch64) suffix="aarch64-linux-gnu" ;; \
@@ -230,6 +292,7 @@ COPY --from=rclone /usr/local/bin/rclone /usr/bin/rclone
# python 环境
COPY --from=prepare_venv --chmod=777 ${VENV_PATH} ${VENV_PATH}
COPY --from=prepare_venv /opt/python /opt/python
COPY --from=uv /uv /usr/local/bin/uv
# 浏览器运行依赖
@@ -252,8 +315,8 @@ COPY --from=prepare_control /bundle/bin/moviepilot /usr/local/bin/moviepilot
RUN mkdir -p ${HOME} \
&& groupadd -r moviepilot -g 918 \
&& useradd -r moviepilot -g moviepilot -d ${HOME} -s /bin/bash -u 918 \
&& python_ver=$(python3 -V | awk '{print $2}') \
&& echo "/app/" > ${VENV_PATH}/lib/python${python_ver%.*}/site-packages/app.pth \
&& site_packages="$(${VENV_PATH}/bin/python -c 'import sysconfig; print(sysconfig.get_path("purelib"))')" \
&& echo "/app/" > "${site_packages}/app.pth" \
&& echo 'fs.inotify.max_user_watches=5242880' >> /etc/sysctl.conf \
&& echo 'fs.inotify.max_user_instances=5242880' >> /etc/sysctl.conf \
&& echo "zh_CN.UTF-8 UTF-8" >> /etc/locale.gen \
+10 -17
View File
@@ -337,34 +337,27 @@ function diagnostic_keepalive() {
# 插件依赖和主程序共用同一套 venv 时,历史安装记录可能已经污染环境,
# 这里优先在真正拉起后端前做一次自愈,避免容器反复起不来。
function ensure_backend_runtime_dependencies() {
local probe_code="import alembic, cloakbrowser, fastapi, pydantic, pydantic_core, pydantic_settings, sqlalchemy, starlette, uvicorn; from pydantic import BaseModel, Field"
local probe_module="app.doctor.dependencies"
INFO "→ 启动前检查后端核心依赖..."
if "${VENV_PATH}/bin/python3" -c "${probe_code}" >/dev/null 2>&1; then
if "${VENV_PATH}/bin/python3" -m "${probe_module}" >/dev/null 2>&1; then
INFO "→ 后端核心依赖检查通过。"
return 0
fi
WARN "→ 检测到后端核心依赖异常,开始尝试恢复主程序依赖..."
local -a uv_cmd=(
"${UV_BIN}" sync
--project /app
--locked
--no-dev
--no-install-project
--inexact
)
if [ -n "${PIP_PROXY}" ]; then
uv_cmd+=(--default-index "${PIP_PROXY}")
if ! configure_package_route; then
ERROR "→ 无法选择可用的主程序依赖源,后端无法启动。"
diagnostic_keepalive 1
fi
if ! run_package_command env "UV_PROJECT_ENVIRONMENT=${VENV_PATH}" \
"${uv_cmd[@]}" > /dev/stdout 2> /dev/stderr; then
PACKAGE_ROUTE_READY="true"
INFO "依赖源:${PACKAGE_LOG}"
if ! sync_project_dependencies_for "/app" > /dev/stdout 2> /dev/stderr; then
ERROR "→ 自动恢复主程序依赖失败,后端无法启动。"
diagnostic_keepalive 1
fi
if ! "${VENV_PATH}/bin/python3" -c "${probe_code}" >/dev/null 2>&1; then
if ! "${VENV_PATH}/bin/python3" -m "${probe_module}" >/dev/null 2>&1; then
ERROR "→ 主程序依赖恢复后仍然异常,后端无法启动。"
diagnostic_keepalive 1
fi
@@ -575,8 +568,8 @@ render_nginx_config
# 自动更新,控制脚本由 launcher 固化到同一代运行目录,源码替换不会改变本轮执行内容。
cd /
source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/update.sh"
if [ "${MOVIEPILOT_BOOTSTRAP_UPDATE_DONE:-0}" != "1" ]; then
source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/update.sh"
if ! recover_pending_update; then
ERROR "→ 上一次容器更新未能恢复,容器将保持运行以便执行 moviepilot doctor。"
diagnostic_keepalive 1
+13 -1
View File
@@ -131,6 +131,8 @@ function update_pending_state() {
function sync_project_dependencies_for() {
local project_dir="$1"
local runtime_group
local runtime_selector
local -a uv_cmd=(
"${UV_BIN}" sync
--project "${project_dir}"
@@ -140,6 +142,16 @@ function sync_project_dependencies_for() {
--no-install-project
--python "${VENV_PATH}/bin/python3"
)
runtime_group=""
if grep -Eq '^runtime-(standard|free-threaded)[[:space:]]*=' "${project_dir}/pyproject.toml"; then
runtime_selector="${project_dir}/app/runtime/dependencies.py"
[ -f "${runtime_selector}" ] || return 1
runtime_group="$("${VENV_PATH}/bin/python3" "${runtime_selector}")" || return 1
[ -n "${runtime_group}" ] || return 1
fi
if [ -n "${runtime_group}" ]; then
uv_cmd+=(--no-default-groups --group "${runtime_group}")
fi
uv_cmd+=("${UV_OPTIONS[@]}")
env "${PACKAGE_ENV[@]}" \
"UV_PROJECT_ENVIRONMENT=${VENV_PATH}" \
@@ -304,7 +316,7 @@ function stage_runtime_payload() {
cp -a "${resource_file}" "${stage_resource_dir}/" || return 1
done
python_version="$(python3 -c 'import sys; print(f"cpython-{sys.version_info.major}{sys.version_info.minor}")')" || return 1
python_version="$("${VENV_PATH}/bin/python3" -c 'import sys, sysconfig; print(f"cpython-{sys.version_info.major}{sys.version_info.minor}{"t" if sysconfig.get_config_var("Py_GIL_DISABLED") == 1 else ""}")')" || return 1
arch="$(uname -m)"
if [ "${arch}" = "aarch64" ]; then
arch_suffix="aarch64-linux-gnu"
+3 -2
View File
@@ -653,8 +653,8 @@ flowchart LR
| 指标 | 当前值 |
|---|---:|
| Python 模块 | 810 |
| 内部导入边 | 6,560 |
| Python 模块 | 811 |
| 内部导入边 | 6,572 |
| 非平凡 SCC | 1(仅隔离的 TMDB 移植包) |
| Module Contract V2 spec | 212(其中 211 个进入 `run_module` 观察面) |
| Event Contract | 53 |
@@ -705,6 +705,7 @@ flowchart LR
| [`docs/rules/10-data-and-persistent.md`](rules/10-data-and-persistent.md) | 数据模型、迁移与缓存规范 |
| [`docs/subscribe-lifecycle.md`](subscribe-lifecycle.md) | 订阅生命周期详解 |
| [`docs/mcp-api.md`](mcp-api.md) | MCP 工具端点说明 |
| [`docs/v3t-runtime-governance.md`](v3t-runtime-governance.md) | V3/V3t 运行依赖、故障恢复、GIL 可观测性与兼容退场门禁 |
| [`docs/refactor/backend-architecture-governance.md`](refactor/backend-architecture-governance.md) | 分阶段架构治理、边界门禁与迁移验收 |
| [`docs/refactor/backend-module-refactor-compatibility.md`](refactor/backend-module-refactor-compatibility.md) | 模块迁移与插件兼容层实施矩阵 |
| [`docs/refactor/backend-architecture-next-stage.md`](refactor/backend-architecture-next-stage.md) | 对标优秀 Python 后端后的二阶段任务、验收与回滚方案 |
+5 -5
View File
@@ -11,7 +11,7 @@ curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v3/scripts/bootst
脚本会自动:
- 检测操作系统
- 自动检查并尽量安装 `git``curl``uv 0.12.5``Python 3.12+`
- 自动检查并尽量安装 `git``curl``uv 0.12.5``Python 3.14+`
- 克隆 `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.12+`,脚本会优先直接复用本地解释器
- 如果系统里没有可用解释器,脚本会通过固定版本的 uv 安装 Python 3.12
- 如果系统里已经有可用的 `Python 3.14+`,脚本会优先直接复用本地解释器
- 如果系统里没有可用解释器,脚本会通过固定版本的 uv 安装 Python 3.14
- Linux 下安装系统依赖时通常需要 `sudo`
- 复用已有仓库时,脚本现在只会因为已跟踪源码改动而阻止自动更新,不会再被 `.DS_Store` 之类未跟踪文件卡住
@@ -156,7 +156,7 @@ moviepilot commands
```shell
moviepilot install deps
moviepilot install deps --python python3.12
moviepilot install deps --python python3.14
moviepilot install deps --venv /path/to/venv
moviepilot install deps --recreate
moviepilot install deps --config-dir /path/to/moviepilot-config
@@ -164,7 +164,7 @@ moviepilot install deps --config-dir /path/to/moviepilot-config
说明:
- 默认会自动选择本地已安装的 `Python 3.12+` 解释器
- 默认会自动选择本地已安装的 `Python 3.14+` 解释器
- 安装器要求 `uv 0.12.5`,并按仓库提交的 `uv.lock` 同步依赖;不会在本地重新解析一套未锁定结果
- `moviepilot_rust` 加速扩展通过 `moviepilot-rust` PyPI 依赖安装,主项目本地安装不需要 Rust toolchain
- 安装完成后可在前端“高级设置 - 实验室”中关闭或重新开启 Rust 加速;如果后端未加载扩展,该开关会保持关闭且不可操作
+14 -8
View File
@@ -6,7 +6,7 @@
在开始之前,请确保您的系统已安装以下软件:
- **Python 3.12+**
- **Python 3.14+**
- **uv 0.12.5**(Python 版本、虚拟环境和依赖锁定工具)
- **Git** (用于版本控制)
- **RAR 解压工具**:本地开发如需测试或使用 `.rar` 字幕包解压,请安装 `unar``unrar``7z``bsdtar` 之一;Docker 镜像会内置 `unar`
@@ -35,9 +35,10 @@ uv sync --locked --no-dev --no-install-project
| 位置 | 用途 | 维护方式 |
| --- | --- | --- |
| `pyproject.toml``[project].dependencies` | 主程序生产运行依赖。 | 开发者按直接依赖的兼容范围维护。 |
| `pyproject.toml``[project].dependencies` | 两套 Python 运行时共享的主程序生产依赖。 | 开发者按直接依赖的兼容范围维护。 |
| `pyproject.toml``[dependency-groups].dev` | pytest、覆盖率、Pylint 和源码构建等开发工具。 | 不进入 Docker 生产运行环境。 |
| `uv.lock` | Python 3.12+ 和受支持平台共享的完整解析结果。 | 修改 `pyproject.toml` 后由 `uv lock` 更新并提交。 |
| `pyproject.toml` `[dependency-groups].runtime-*` | 标准与 free-threaded 解释器互斥的 ABI 敏感运行依赖。 | 只放两套运行时确实不同的直接依赖。 |
| `uv.lock` | Python 3.14+、两套运行时 profile 和受支持平台共享的完整解析结果。 | 修改 `pyproject.toml` 后由 `uv lock` 更新并提交。 |
主程序不再维护 `requirements.in``requirements-dev.in``requirements.txt`,也不生成
平台专属的 requirements 锁文件。Docker、CLI 和 CI 都以提交的 `uv.lock` 为安装输入。
@@ -86,10 +87,11 @@ chmod +x scripts/start-local.sh
新增或升级依赖时,先确认依赖属于哪个层级:
1. **运行时依赖**:被 `app/` 生产代码直接导入,或是生产功能、后台任务、插件框架启动必需,写入 `[project].dependencies`
2. **开发 / 测试 / 静态检查 / 构建依赖**:只用于单测、覆盖率、lint 辅助、源码构建等,写入 `[dependency-groups].dev`
3. **工具依赖**:仓库要求使用 `uv 0.12.5`;不应为了安装工具而把它加入主程序运行依赖
4. **插件依赖**由插件清单声明并在插件安装阶段处理,不直接并入主程序依赖。
1. **共享运行时依赖**:被 `app/` 生产代码直接导入,或是生产功能、后台任务、插件框架启动必需,写入 `[project].dependencies`
2. **ABI 敏感运行依赖**:标准与 free-threaded 解释器必须选择不同制品或版本时,分别写入 `runtime-standard``runtime-free-threaded`;两组保持互斥并由运行时统一选择
3. **开发 / 测试 / 静态检查 / 构建依赖**:只用于单测、覆盖率、lint 辅助、源码构建等,写入 `[dependency-groups].dev`
4. **工具依赖**仓库要求使用 `uv 0.12.5`;不应为了安装工具而把它加入主程序运行依赖。
5. **插件依赖**:由插件清单声明并在插件安装阶段处理,不直接并入主程序依赖。
修改后更新并校验锁文件:
@@ -97,9 +99,13 @@ chmod +x scripts/start-local.sh
uv lock
uv lock --check
uv sync --locked
uv pip check
uv sync --locked --offline --inexact --no-dev --check
```
`uv pip check` 可用于查看第三方包元数据诊断,但不作为项目依赖合同:`oss2` 已停止维护,其元数据仍
声明旧 `crcmod`,而主程序统一使用保持相同导入接口的 `crcmod-plus`。项目一致性以锁文件和上述
`uv sync --check` 结果为准。
`uv.lock` 同时覆盖 Linux x86_64/arm64、macOS x86_64/arm64 和 Windows x64。统一锁文件只
固定解析结果,不能替代这些平台的真实安装门禁;平台条件依赖变更必须通过对应 CI 环境验证。
@@ -25,7 +25,7 @@
3. `PluginManager` 的加载、生命周期、注册表、投影、存储、目录、路径、同步、依赖、克隆和文件监控分别由 `app/runtime/extensions/plugin/` 下的单职责组件承担;旧管理器只保留 V3 ABI 门面和兼容调用顺序。
4. 动态插件 API 使用专用 raw 路由;主程序统一响应信封不进入插件 `get_api()`。前端 `pluginApi` 对非 `Response` envelope 的 payload 原样交付调用方。
5. 旧插件导入仅由 `app/runtime/compat/manifest.py` 精确映射;canonical 模块不复制旧 Manager/Helper/Oper 导出。`app/plugins/` 仍是运行时副本,继续排除在宿主架构扫描之外。
6. 2026-08-24 当前机器基线为 810 个宿主 Python 模块、6,560 条内部导入边;数据库边界、Adapter→DB、Runtime→DB、Application→DB 及新增 API/Agent/Chain 目标边均为 0。架构门禁、插件兼容快照和基线脚本均已重新生成。
6. 2026-08-24 当前机器基线为 811 个宿主 Python 模块、6,572 条内部导入边;数据库边界、Adapter→DB、Runtime→DB、Application→DB 及新增 API/Agent/Chain 目标边均为 0。架构门禁、插件兼容快照和基线脚本均已重新生成。
7. 订阅写入统一归入 `app/application/subscription/write.py`;插件动态路由和文件夹操作统一归入 `app/application/plugin/routes.py``folders.py`。重构期间新增且未形成插件 ABI 的 `app/application/subscribe.py``app/application/plugins.py` 已直接删除,不进入 compat manifest。
8. 2026-08-24 完成 Module Contract V2 宿主观察面收口:212 个 spec 均使用可执行的显式 aggregation
`legacy` 只保留为未知第三方自定义方法的开放 fallback;插件方法名、kwargs、优先级和异常隔离 ABI 不变。
@@ -106,7 +106,7 @@ MoviePilot V3 已经完成一轮重要基础工作:原 `app/core`、`app/helpe
### 4.3 模块规模
排除 `app/plugins/` 后,2026-08-24 当前静态扫描得到 810 个 Python 模块、6,560 条内部导入边。下表保留 2026-08-18 收口时的一级目录规模快照(代码行数包含注释和空行,用于趋势比较而非质量评分):
排除 `app/plugins/` 后,2026-08-24 当前静态扫描得到 811 个 Python 模块、6,572 条内部导入边。下表保留 2026-08-18 收口时的一级目录规模快照(代码行数包含注释和空行,用于趋势比较而非质量评分):
| 一级目录 | 约代码行数 | Python 文件数 | 判断 |
| --- | ---: | ---: | --- |
@@ -154,8 +154,8 @@ MoviePilot V3 已经完成一轮重要基础工作:原 `app/core`、`app/helpe
| 指标 | 初始审计 | 当前基线 | 说明 |
| --- | ---: | ---: | --- |
| Python 模块数 | 约 654 | 810 | 增量来自单一职责的 Application、Runtime、Adapter、插件组件和维护用例模块 |
| 内部导入边 | 约 5,623 | 6,560 | 显式端口增加模块数但移除了反向边;边数不作为单独质量目标 |
| Python 模块数 | 约 654 | 811 | 增量来自单一职责的 Application、Runtime、Adapter、插件组件和维护用例模块 |
| 内部导入边 | 约 5,623 | 6,572 | 显式端口增加模块数但移除了反向边;边数不作为单独质量目标 |
| SCC 数 | 14 | 1 | 自有代码 SCC 已归零,仅保留 TMDB 移植包内部隔离例外 |
| `adapters -> db` | 存在 | 0 | `PluginHelper``MoviePilotServerHelper` 的本地数据读取已移到组合根/Application |
| `runtime -> db` | 存在 | 0 | 插件存储、服务配置均改为启动注入 |
+5 -5
View File
@@ -4,9 +4,9 @@
| Item | Detail |
|---|---|
| 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 |
| Language | Python 3.14+ |
| Primary CI Python version | Python 3.14 |
| Dependency compatibility CI | Python 3.14 supported-platform matrix plus Linux amd64/arm64 standard and free-threaded Docker profiles |
| Async runtime | asyncio (native), integrated with FastAPI/Uvicorn |
---
@@ -106,7 +106,7 @@
| Item | Detail |
|---|---|
| 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 |
| Lock | `uv.lock` — committed resolution for Python 3.14+ 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` |
@@ -131,7 +131,7 @@
|---|---|---|
| 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` |
| uv | Lock and environment consistency | `uv lock --check && uv sync --locked --offline --inexact --no-dev --check` |
| pip-audit | Locked dependency vulnerability scan | `uv export --quiet --locked --no-dev --no-emit-project -o /tmp/moviepilot-audit-requirements.txt && uvx --from pip-audit==2.10.1 pip-audit --require-hashes --disable-pip --strict --progress-spinner off -r /tmp/moviepilot-audit-requirements.txt` |
---
+4 -3
View File
@@ -25,15 +25,16 @@ uv lock --check
# Update the lock after editing pyproject.toml
uv lock
# Verify installed dependency consistency
uv pip check
# Verify the installed environment against the locked project
uv sync --locked --offline --inexact --no-dev --check
```
**Rules:**
- 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+.
- `uv pip check` is diagnostic only because unmaintained third-party metadata may name a compatible superseded distribution.
- Use uv 0.12.5 and Python 3.14+.
---
+1 -1
View File
@@ -11,7 +11,7 @@
## Python Version and Typing
- Target: **Python 3.12+**. Python 3.12 is the primary CI version; compatibility CI also verifies newer interpreters.
- Target: **Python 3.14+**. Python 3.14 is the primary CI version; dependency CI also verifies supported platforms and both Linux runtime profiles.
- **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.
+1 -1
View File
@@ -136,7 +136,7 @@ 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 `pyproject.toml` group, `uv.lock` is current, locked sync and `uv pip check` pass, and the locked runtime dependency audit passes
- [ ] If dependencies changed: the package is in the correct `pyproject.toml` group, `uv.lock` is current, the locked project consistency check and runtime dependency audit pass
- [ ] 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/`
@@ -103,7 +103,7 @@ When updating a dependency:
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 locked runtime dependency audit documented in `03-commands.md`.
3. Run `uv sync --locked`, the locked project consistency check, and the runtime dependency audit documented in `03-commands.md`.
4. Run the full test suite: `uv run --locked --no-sync pytest`.
---
+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.12+ 及完整后端依赖,不适合作为普通用户的首选路径。
如果系统阻止运行,可改用随 MoviePilot 源码提供的本地采集脚本;该方式需要 Python 3.14+ 及完整后端依赖,不适合作为普通用户的首选路径。
## 维护者发布流程
+243
View File
@@ -0,0 +1,243 @@
# MoviePilot V3t 运行时治理
> 状态:持续维护。本文定义 `moviepilot-v3``moviepilot-v3t` 的产品边界、运行依赖分层、
> 故障恢复合同和兼容项退场门禁。具体版本以 `pyproject.toml``uv.lock` 为准。
## 1. 治理目标
MoviePilot 同一版本提供两套镜像:
- `moviepilot-v3` 使用标准 CPython 3.14,是默认稳定镜像和故障回滚基线。
- `moviepilot-v3t` 使用 CPython 3.14 free-threaded 构建,用于获得多线程 CPU 并行能力;
Rust 能力固定启用,不能在运行时关闭。
两套镜像必须来自相同源码 revision、使用相同产品版本和同一份 `uv.lock`。V3t 不替换默认镜像,
也不在业务代码中维护一套平行实现;解释器、原生 ABI 或插件不兼容时,应切回同版本标准 V3。
正式发布按同版本制品对验收和晋升 `latest`;任一变体未通过构建、扫描或发布时,本次版本不移动
两边的 `latest`。这不会回退已经发布的标准 V3,且避免两个 `latest` 指向不同源码版本。
V3t 的目标不是让所有请求都更快。它主要改善可并行的 Python CPU 热点,同时验证主程序、原生扩展
和插件生态在 free-threaded 解释器下的正确性。启动、内存、普通 API 和数据库路径不得为获得局部
并发收益而出现不可接受的退化。
## 2. 单一依赖事实源
`pyproject.toml` 中的 `project.dependencies` 是两套镜像共享的运行依赖,ABI 敏感依赖放在两个互斥组:
- `runtime-standard`
- `runtime-free-threaded`
`tool.uv.conflicts` 保证两个组不能同时解析。Docker 的两个构建 stage 分别固定选择对应组;源码升级、
启动恢复和插件安装后的宿主恢复由 `app.runtime.dependencies.runtime_dependency_group()` 根据当前
解释器的 `Py_GIL_DISABLED` 构建标志选择 profile。
依赖选择不得改为镜像标签判断,也不得在业务模块中按包名散落 V3/V3t 分支。Dockerfile 只选择运行
profile,依赖名称、版本和 source 语义全部由 `pyproject.toml``uv.lock` 管理。
## 3. 当前原生依赖矩阵
下表中的版本用于解释当前分叉原因,不代替锁文件。版本变化后应同步更新本表的治理状态。
| 能力 | 标准 V3 | V3t | 当前处理与上游解除条件 |
| --- | --- | --- | --- |
| Python | CPython 3.14 | CPython 3.14t | 跟随同一 Python 3.14 patch 版本;升级后必须重新验证解释器 ABI、GIL 状态、启动与完整测试。 |
| `moviepilot-rust` | `cp314-abi3` | `cp314t` | 同一包版本按 wheel tag 选择;两套 ABI 和 V2 使用的 `cp311-abi3` 必须在发布链中保持独立可用。 |
| `bcrypt` | 4.x | 5.x | V3t 使用提供 free-threaded 制品的版本;当同一稳定版本同时满足两套 ABI 与密码合同后可合并约束。 |
| Brotli | 1.2.0 wheel | 同版本固定源码构建 | V3t 当前只对该 profile 使用固定上游源码。上游提供可复现的稳定 `cp314t` wheel 后,验证导入、压缩结果、并发与 GIL,再删除 source 覆盖。 |
| CRC 加速 | `crcmod-plus` 2.3.1 | `crcmod-plus` 2.3.1 | 两套镜像统一使用继续维护且兼容 `crcmod` 导入接口的实现。`oss2` 的陈旧元数据仍声明不再维护的 `crcmod`,由 uv 在解析时排除该传递依赖;宿主不在运行时映射、卸载或替插件兼容旧分发包。 |
| `lxml` | 6.1.2 | 7.0.0b1 | V3t 暂用提供目标 ABI 的预发布版本,是当前最高风险项。稳定版提供 `cp314t` wheel 后,需通过 XML、HTML、RSS、站点解析、并发和内存验证再替换。 |
| PostgreSQL 同步驱动 | `psycopg2-binary` 2.x | `psycopg[c]` 3.3.4 | 当前分叉同时受 ABI 与实测性能影响,不要求仅为版本统一而收敛。若上游能力或性能变化,必须重跑三方案 PostgreSQL A/B 后再决策。异步路径继续使用 `asyncpg`。 |
| `orjson` | 3.12.0 wheel | 同版本源码构建 | V3t 使用同一锁定版本并启用 free-threaded 构建变量。上游发布覆盖 Linux amd64/arm64 的稳定 `cp314t` wheel 后可删除本地构建要求。 |
| 中文转换 | `zhconv-rs` | `moviepilot-rust.zhconv_fast()` | 主程序统一经 `app.foundation.text.convert()`,插件统一经 SDK;不得让调用方感知后端差异。只有语义、性能、体积和 ABI 均更优时才考虑统一实现。 |
| 站点资源 | `cpython-314` | `cpython-314t` | 资源文件必须按解释器 ABI 独立构建和选取,不能让 V3t 复用普通 CPython 扩展,也不能影响 V2 的历史 ABI 制品。 |
表中差异不是全部都要消除。只有临时 source 构建、预发布依赖或第三方元数据兼容适合在上游成熟后
优先退场;已由性能与产品边界证明合理的驱动或实现选择,可以继续由 profile 集中管理。
## 4. 依赖检查与自愈合同
“自愈”包含两个不同的恢复边界,不能与普通依赖校验混为一谈。
### 4.1 启动前恢复
容器启动前先导入一组后端核心依赖。导入成功时不运行 uv 同步;导入失败时才选择可用依赖源,并对
`/app` 执行锁定的项目同步。同步命令必须:
- 使用当前虚拟环境中的解释器推导 runtime profile
- 使用 `--locked`,禁止启动时重新求解未锁定版本;
- 使用 `--inexact`,保留共享环境中的插件额外依赖;
- 只恢复主项目运行依赖,不安装项目本身或开发依赖;
- 恢复后再次执行核心导入探针,仍失败则停止后端启动并保留诊断入口。
源码更新事务中的依赖同步和失败回滚使用同一 profile 选择入口。否则 V3t 在恢复时可能被普通默认组
覆盖,得到“3.14t 解释器 + 标准原生依赖”的无效组合。
### 4.2 插件安装后的宿主恢复
插件与主程序共享虚拟环境。插件依赖安装前后都会采集宿主健康快照,只对安装后新增的异常执行补偿:
1. 优先使用安装前生成的主程序保护约束恢复被修改的包;
2. 约束不可用时,按主项目 `pyproject.toml``uv.lock` 和当前 runtime profile 恢复;
3. 恢复完成后重新执行依赖诊断与核心能力探针;
4. 宿主即使恢复成功,本次插件安装仍返回失败,不能把被回滚的安装报告为成功。
依赖诊断使用 `uv pip check`,并按安装前后的稳定错误集合识别新增问题;这样既不会把
`oss2` 对旧 `crcmod` 的陈旧元数据误归因于本次安装,也不会让既有告警遮蔽其他新增错误。核心能力
探针统一由 `app.doctor.dependencies` 执行。普通启动使用轻量模式验证 Web 栈、中文分词与转换;
镜像构建及插件安装前后使用完整模式,继续验证 ABI 敏感原生扩展、CRC C 实现、PostgreSQL C 实现及
导入后的 GIL 状态。插件允许的非核心依赖升级不要求与宿主锁文件逐版本相同,因此插件健康检查不得
用整份 `uv.lock` 强制回滚共享环境。
自愈是异常补偿,不是每次启动的常态安装流程。任何新增恢复入口都必须复用统一 profile 选择函数,
不得自己拼装默认 uv group。
## 5. GIL 与原生扩展可观测性
构建阶段必须导入并执行已知核心原生依赖,验证 V3t 在导入前、导入后和热点执行后均保持 GIL 关闭。
运行阶段还必须保留动态观测,因为构建探针无法穷举插件延迟导入的第三方扩展:
- 启动收尾日志记录解释器类型和实际 GIL 状态;
- 登录后全局设置与系统环境 API 返回 `PYTHON_FREE_THREADED``PYTHON_GIL_ENABLED`,系统环境 API
同时返回 `RUST_ACCEL_REQUIRED`
- 插件安装或重载导致 GIL 从关闭变为开启时,日志记录对应插件归因;
- 前端在 V3t 实际启用 GIL 时显示兼容告警,正常状态不误报。
不得通过 `PYTHON_GIL=0` 或等效方式强制绕过扩展的 GIL 声明。扩展一旦使进程退化为 GIL 模式,
应保留可观测性并修复或替换依赖;无法及时处理时回退标准 V3。
## 6. 当前 A/B 基线与决策
以下数据来自同一源码 revision、相同资源限制的 arm64 候选镜像,用于确定当前架构和依赖策略。后续
上游重放、依赖更新、构建链变化或采集 schema 扩充都会使其失去“最终发布验收”资格,但不抹去已经
建立的设计结论;新的发布候选必须重跑并在本节追加或替换相应数据。
### 6.1 镜像与解释器基线
| 指标 | 标准 V3 | V3t | 结论 |
| --- | ---: | ---: | --- |
| arm64 镜像体积 | 647.5 MiB | 674.0 MiB | V3t 增加 26.5 MiB,约 4.1%;标准镜像仍处于既有 660+ MB 发布口径。 |
| 启动就绪中位数 | 6.177 s | 5.608 s | V3t 约快 9.2%,未造成启动退化;不据此承诺所有部署都会更快。 |
| 空载 working set | 274.9 MiB | 336.8 MiB | V3t 增加约 22.6%,低于 25% 验收阈值。 |
| 主工作负载 PSS | 296.4 MiB | 356.3 MiB | V3t 增加约 20.2%,属于实验镜像的明确资源成本。 |
| 32 线程纯 Python CPU 探针 | 13,143.7 ops/s | 42,583.5 ops/s | V3t 约为 3.24 倍,证明解释器并行收益;该探针不经过 Rust 开关。 |
| 32 线程直接 Rust ABI 探针 | 40,832.4 ops/s | 61,550.9 ops/s | V3t 约为 1.51 倍;两边都直接调用 Rust,仅验证原生 ABI 与并发。 |
| 普通 API 最大 p95 比例 | 基线 | 1.10x | 四个本地 API 中最大退化来自订阅列表,仍低于 1.25x 门禁。 |
同 revision 的 amd64 最终候选也已完成交叉架构验收。标准 V3 与 V3t 的本地镜像体积分别为
680.6 MiB 和 714.0 MiBV3t 增加 33.4 MiB、约 4.9%。两者均使用 Python 3.14.7,标准 V3
保持 GIL 启用;V3t 使用 free-threaded 解释器,完整原生依赖、`psycopg` C 实现、Rust 文本能力及
`sites.cpython-314t-x86_64-linux-gnu.so` 依次加载后 GIL 始终关闭。两个镜像均以空白配置启动到
`/health/ready` 且 Docker health 为 healthy。该启动运行在 arm64 宿主的 amd64 模拟环境中,只作为
发布候选功能与 ABI 验收,不纳入性能比例。
该候选还通过了四分片全量回归(5,981 passed、3 skipped、14 subtests passed)、PostgreSQL 18.6
迁移/提交/回滚、现代插件清单、历史 `requirements.txt`、插件源码与依赖恢复以及恢复后单实例加载。
这些结果证明双 profile 方案可行;最终发布仍必须使用最新 revision 和正式不可变 digest 重跑。
### 6.2 应用热点三组
真正受产品 Rust 开关影响的应用识别热点必须分成三个对象:
| 运行方式 | 解释器 | 应用实现 | 产品语义 |
| --- | --- | --- | --- |
| V3 + Python | 标准 CPython | Python fallback | 标准镜像关闭 `RUST_ACCEL` 的兼容基线。 |
| V3 + Rust | 标准 CPython | `moviepilot-rust` | 标准镜像启用 `RUST_ACCEL` 的默认性能路径。 |
| V3t + Rust | free-threaded CPython | `moviepilot-rust` | V3t 固定路径,Rust 不允许关闭。 |
当前六个交替样本的中位耗时分别为 `0.068769s``0.035339s``0.035511s`,三组业务结果校验和
一致。标准 V3 启用 Rust 后耗时约为 Python fallback 的 `0.514x`;V3t 固定 Rust 路径约为标准 V3
Rust 路径的 `1.005x`,即慢约 0.5%,仍低于 1.25x 门禁。解释器探针的并发收益不能抵销产品路径
退化,因此两组比例必须继续独立验收。
### 6.3 PostgreSQL 三方案
三组使用同一 PostgreSQL 18.6、相同 SQL 和六轮全排列顺序,以标准 V3 + `psycopg2` 为 1.00 倍基线:
| 方案 | 单连接查询吞吐 | 16 线程查询吞吐 | 批量事务写耗时 |
| --- | ---: | ---: | ---: |
| V3 + `psycopg2` | 1.00x | 1.00x | 1.00x |
| V3 + `psycopg3 binary` | 0.946x | 0.367x | 0.094x |
| V3t + `psycopg3 C` | 0.931x | 1.258x | 0.096x |
因此标准 V3 保留 `psycopg2`V3t 为满足 free-threaded ABI 使用 `psycopg3 C``psycopg3` 的批量
事务写明显更快,但查询吞吐没有形成全面优势;当前不为统一驱动扩大数据库重构。18 个当前镜像样本
均通过驱动实现、GIL、SQL 结果、长事务并行和样本完整性门禁。三种制品内嵌的 libpq 分别为 17.9、
18.0 和 18.6,性能差异不能全部归因于 Python 驱动。未来驱动或应用访问模式变化
时,必须重跑真实短事务、批量写、连接池、长事务和迁移场景,不能只用单项微基准改写结论。
### 6.4 环境检查成本
`uv sync --check --locked --offline --inexact` 适合验证镜像构建、开发环境和锁定项目能否复现,
不用于覆盖插件允许的非核心依赖升级。真正恢复可能访问包索引,耗时仍受缺失制品、缓存命中、网络和
原生构建影响,不能用检查耗时推断用户故障恢复时长。
当前公共 A/B schema 2 已记录完整安装包集合及哈希、全部原生 distribution 和 wheel tag、导入前后
GIL、working set、RSS/PSS/USS、API p50/p95、SQLite 同步/异步结果和 1/8/16/32 线程热点。两套镜像
各安装 174 个包;V3t 的 16 个核心原生模块在同一进程依次导入后,GIL 始终保持关闭。
### 6.5 冷启动、插件恢复与宿主自愈
空白配置卷首次启动需要下载 CloakBrowser 内核。该场景可能接近用户报告的“两分钟启动”,主要瓶颈
是持久浏览器缓存未命中和下载网络,不是插件初始化;缓存命中后的普通重启不重复下载。
现代 `pyproject.toml` 插件(`boltons==25.0.0`)与历史 `requirements.txt` 插件
`humanize==4.15.0`)均已在 V3t 真实容器中验收。首次运行从快照恢复两个源码目录耗时 1.60ms,
联网安装依赖及激活耗时 4.64s;优雅关停后使用同一不可变镜像和持久 `/config` 断网重建,源码恢复
耗时 1.95ms,仅从持久 uv 缓存恢复依赖及激活耗时 1.56s。两轮各插件都只产生一条实例初始化记录,
最终 API 状态均为 `active`、无 pending/failed,且没有启用 GIL。该结果证明插件源码恢复不应被依赖
下载阻塞,依赖未就绪的插件可以在 Web 可用后渐进加载。
删除一个主程序核心依赖后重启,启动前探针能触发锁定自愈,`--inexact` 保留插件额外依赖;恢复后
插件仍为单实例 `active`,V3t GIL 仍关闭。该路径的价值是让受污染共享环境能够恢复启动,不是性能
优化;它只在核心导入失败时执行,正常重启不会承担这段解析成本。
## 7. 上游能力的渐进式接入
第三方上游发布新版本或新 wheel 后,按以下顺序处理,不能看到文件名包含 `cp314t` 就直接删除本地兼容:
1. **确认制品**:稳定版本覆盖 Linux amd64/arm64wheel tag 与解释器 ABI 匹配;源码构建路线还要固定
可审计的源版本和构建工具链。
2. **建立候选锁**:只修改对应 runtime group 或 source,更新单一 `uv.lock`,确认标准与 free-threaded
两套 profile 均可 `uv sync --locked` 重建。
3. **验证原生合同**:导入、核心功能、错误边界、首次初始化和 1/8/16/32 线程并发结果一致;V3t
在每个阶段保持 GIL 关闭。
4. **验证恢复链**:覆盖启动前恢复、源码更新与回滚、现代插件清单、历史 `requirements.txt` 和插件
安装后宿主恢复,确认 profile 不串组且插件额外依赖不被裁剪。
5. **执行同 revision A/B**:先证明标准 V3 修改前后无可重复退化,再比较同一 revision、同一资源限制
和不可变镜像 digest 的 V3/V3t。至少保留镜像体积、启动、RSS/PSS/USS、API p50/p95、SQLite、
PostgreSQL 和 CPU 热点原始样本。
6. **完成双架构验收**amd64、arm64 的构建、漏洞扫描、启动和核心功能均通过后,才发布候选制品。
7. **删除临时处理**:同一变更中移除不再需要的 source、版本分叉或排除规则,并更新本表、锁文件、
构建探针和回归测试;不要永久保留失效的兼容分支。
公共 A/B 基线为 `scripts/perf/free_threaded_ab.py`,使用方法和输出合同见
[`scripts/perf/README.md`](../scripts/perf/README.md)。正式验收原始数据应按发布候选独立留存,PR 只提交
可复用脚本和维护者可判断的汇总结论。
## 8. 插件生态边界
- 插件不得直接依赖 V3/V3t 的内部实现选择,应使用 `app.sdk` 暴露的文本、网络和运行时能力。
- 插件直接导入 `zhconv_rs` 等标准 V3 专属包时,V3t 可以明确判定为不兼容;宿主不伪造第三方模块。
- 插件若自行声明不再维护的 `crcmod`,必须由插件迁移到 `crcmod-plus`;宿主不映射分发包、不增加插件
特判,也不在运行期卸载插件声明的依赖。
- 插件携带原生 wheel 时必须匹配当前解释器和平台 ABI。缺少 `cp314``cp314t` 制品属于插件依赖
兼容问题,不通过宿主插件 ID 特判绕过。
- 插件安装后若使 V3t 重新启用 GIL,功能可能仍可运行,但该进程已经失去 free-threaded 产品语义,
必须告警并引导用户升级插件、移除依赖或切回标准 V3。
V3 `3.0.0` 的依赖集合随镜像交付。相同版本 Tag 的 `release` 自动更新只执行版本比较,不下载源码或
同步依赖;用户必须拉取并重建容器后才会使用新镜像内的锁定环境。`/app``/opt/venv` 不属于标准
持久化卷,非标准挂载和 `dev` 自动更新不属于正式镜像迁移合同。
## 9. 变更检查表
修改 Python patch 版本、runtime group、原生依赖、Rust 制品、资源 ABI 或双镜像构建链时,至少确认:
- [ ] `pyproject.toml``uv.lock` 同步,两个互斥 profile 均能锁定重建。
- [ ] 标准 V3 依赖集合、性能、体积和功能没有因 V3t 改动退化。
- [ ] V3t 核心原生扩展导入及并发执行后 GIL 仍关闭。
- [ ] amd64、arm64 的 Python、Rust 和站点资源 ABI 匹配。
- [ ] SQLite、PostgreSQL、插件安装/恢复、源码升级/回滚和启动自愈通过。
- [ ] 动态 GIL API、启动日志、插件归因和前端告警保持一致。
- [ ] 安全扫描结果已按实际安装制品、利用面和上游修复状态审计。
- [ ] 上游已成熟的临时兼容已删除,仍保留的例外在本文有明确解除条件。
+32 -6
View File
@@ -16,7 +16,6 @@ dependencies = [
"anyio~=4.14.2",
"apscheduler~=3.11.2",
"asyncpg~=0.31.0",
"bcrypt~=4.3.0",
"beautifulsoup4~=4.15.0",
"boto3~=1.42.42",
"cachetools~=7.1.4",
@@ -24,6 +23,7 @@ dependencies = [
"click~=8.4.1",
"cloakbrowser~=0.5.3",
"cn2an~=0.5.24",
"crcmod-plus==2.3.1",
"cryptography~=50.0.0",
"dateparser~=1.4.0",
"ddgs~=9.14.4",
@@ -34,7 +34,6 @@ dependencies = [
"google-genai~=2.8.0",
"httpx[http2,socks]~=0.28.1",
"httpx2[http2,socks]~=2.12.0",
"jieba-next~=1.0.0rc1",
"jinja2~=3.1.6",
"langchain~=1.3.15",
"langchain-anthropic~=1.4.6",
@@ -47,10 +46,10 @@ dependencies = [
"langgraph~=1.2.11",
"langgraph-checkpoint~=4.2.0",
"lark-oapi~=1.6.8",
"lxml~=6.1.2",
"moviepilot-rust~=0.2.8",
"moviepilot-rust~=0.3.0",
"mutagen~=1.47.0",
"openai~=2.41.1",
"orjson==3.12.0",
"oss2~=2.19.1",
"packaging~=26.3",
"parse~=1.22.1",
@@ -59,7 +58,6 @@ dependencies = [
"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",
@@ -101,7 +99,6 @@ dependencies = [
"watchfiles~=1.2.0",
"webauthn~=2.8.0",
"websocket-client~=1.9.0",
"zhconv-rs~=0.4.1",
]
[dependency-groups]
@@ -114,10 +111,33 @@ dev = [
"pytest-cov~=7.1.0",
"pytest-timeout~=2.4.0",
]
runtime-standard = [
"Brotli==1.2.0",
"bcrypt~=4.3.0",
"lxml~=6.1.2",
"psycopg2-binary~=2.9.12",
"zhconv-rs~=0.4.1",
]
runtime-free-threaded = [
"Brotli==1.2.0",
"bcrypt~=5.0.0",
"lxml==7.0.0b1",
"psycopg[c]==3.3.4",
]
[tool.uv]
package = false
required-version = "==0.12.5"
default-groups = ["dev", "runtime-standard"]
conflicts = [
[
{ group = "runtime-standard" },
{ group = "runtime-free-threaded" },
],
]
exclude-dependencies = [
{ package = { name = "oss2" }, dependencies = ["crcmod"] },
]
environments = [
"sys_platform == 'linux' and platform_machine == 'x86_64'",
"sys_platform == 'linux' and platform_machine == 'aarch64'",
@@ -133,6 +153,12 @@ required-environments = [
"sys_platform == 'win32' and platform_machine == 'AMD64'",
]
[tool.uv.extra-build-variables]
orjson = { ORJSON_BUILD_FREETHREADED = "1" }
[tool.uv.sources]
brotli = { url = "https://github.com/google/brotli/archive/51be27dbd9782f9fe27bc6e06cee6ff18311702d.tar.gz", group = "runtime-free-threaded" }
[tool.setuptools]
py-modules = ["version"]
+86
View File
@@ -0,0 +1,86 @@
"""将 uv 导出的锁定 URL 依赖转换为漏洞审计可识别的精确版本。"""
from __future__ import annotations
import argparse
import re
import tomllib
from pathlib import Path
DIRECT_REFERENCE = re.compile(
r"^(?P<indent>\s*)(?P<name>[A-Za-z0-9_.-]+)\s+@\s+"
r"(?P<url>\S+)(?P<suffix>.*)$"
)
def _canonicalize_name(name: str) -> str:
"""按 Python 包索引规则规范化分发包名。"""
return re.sub(r"[-_.]+", "-", name).lower()
def _locked_url_versions(lock_file: Path) -> dict[tuple[str, str], str]:
"""读取锁文件中 URL 来源与已解析版本的唯一映射。"""
with lock_file.open("rb") as file:
document = tomllib.load(file)
versions: dict[tuple[str, str], str] = {}
for package in document.get("package", []):
source = package.get("source") or {}
url = source.get("url")
name = package.get("name")
version = package.get("version")
if not all(isinstance(value, str) and value for value in (url, name, version)):
continue
key = (_canonicalize_name(name), url)
previous = versions.setdefault(key, version)
if previous != version:
raise ValueError(f"锁文件包含冲突的 URL 依赖版本:{name} @ {url}")
return versions
def normalize_requirements(requirements: str, lock_file: Path) -> str:
"""保留 marker 和注释,将直接引用替换为锁文件中的精确版本。"""
versions = _locked_url_versions(lock_file)
normalized_lines = []
for line in requirements.splitlines(keepends=True):
body = line.rstrip("\r\n")
newline = line[len(body):]
match = DIRECT_REFERENCE.match(body)
if not match:
normalized_lines.append(line)
continue
key = (_canonicalize_name(match.group("name")), match.group("url"))
version = versions.get(key)
if not version:
raise ValueError(
"导出的 URL 依赖无法在锁文件中定位精确版本:"
f"{match.group('name')} @ {match.group('url')}"
)
normalized_lines.append(
f"{match.group('indent')}{match.group('name')}=={version}"
f"{match.group('suffix')}{newline}"
)
normalized = "".join(normalized_lines)
if any(DIRECT_REFERENCE.match(line) for line in normalized.splitlines()):
raise ValueError("审计清单仍包含未规范化的 URL 依赖")
return normalized
def main() -> None:
"""读取 uv 导出文件并写入适合 pip-audit 的锁定版本清单。"""
parser = argparse.ArgumentParser()
parser.add_argument("--lock", type=Path, required=True)
parser.add_argument("--input", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
args.output.write_text(
normalize_requirements(args.input.read_text(encoding="utf-8"), args.lock),
encoding="utf-8",
)
if __name__ == "__main__":
main()
+53
View File
@@ -191,3 +191,56 @@ Before-1 → After-1 → After-2 → Before-2 → Before-3 → After-3
```
本地 JSON、Markdown 和日志不会被 `cleanup` 删除。
## Python 3.14 free-threaded 镜像 A/B
`free_threaded_ab.py` 用于正式发布前在同一 Docker daemon、相同 CPU/内存限制下比较
`moviepilot-v3``moviepilot-v3t`。它不构建镜像,只接受两份
`repository@sha256:<digest>` 不可变引用,并要求镜像标签证明两者来自相同源码 revision 和版本。
未使用 `--pull` 时,digest 也可以是本机 Docker image ID,供依赖尚未发布前验收本地候选。
preflight 会验证 Python 3.14、GIL 状态、`thread_inherit_context`、MoviePilot-Rust 0.3 的
`jieba_cut`/中文转换入口,以及标准与 free-threaded 镜像互斥的原生依赖 profile。正式样本使用
固定 seed 和 fixture hash,按 `v3-1 → v3t-1 → v3t-2 → v3-2 → v3-3 → v3t-3`
交替执行真实 readiness 启动,并把应用识别热点明确分成 `V3 + Python``V3 + Rust`
`V3t + Rust` 三组。两种镜像的纯 Python 并发与直接 Rust 并发是解释器/ABI 探针,不代表产品 Rust
开关的第三组结果;PostgreSQL 驱动选择使用不连接数据库的命令单独验证。
```bash
../.venv/bin/python scripts/perf/free_threaded_ab.py \
--campaign v3-ft-001 \
--standard-image 'jxxghp/moviepilot-v3@sha256:<64-hex-digest>' \
--free-threaded-image 'jxxghp/moviepilot-v3t@sha256:<64-hex-digest>' \
--pull
```
结果默认写入系统临时目录的 `moviepilot-free-threaded-ab/<campaign>/`
- `results.json` 使用 `schema_version` 保存镜像身份、preflight、阈值、原始样本与中位数;
- `report.md` 提供维护者可读摘要;
- `samples/` 保存六个交替样本,便于排查离群值。
退出码 `0` 表示合同与性能阈值通过,`1` 表示样本有效但出现性能回退,`2` 表示 digest、ABI、
依赖、语义、驱动、启动或样本完整性不成立。该工具只用于隔离的本地长 A/B,不接真实凭据、用户数据库、
媒体目录或外网,也不加入常规 CI。
### PostgreSQL 同步驱动三方案
`postgresql_driver_ab.py` 在同一 PostgreSQL 容器中比较标准 V3/psycopg2、标准
V3/psycopg3 binary 和 V3t/psycopg3 C。三个输入镜像必须来自相同源码 revision 和产品版本;
标准 V3/psycopg3 镜像是只增加该驱动的本地验证衍生镜像,不是发布制品。
```bash
../.venv/bin/python scripts/perf/postgresql_driver_ab.py \
--campaign v3-ft-pg-001 \
--postgres-container moviepilot-pg-ab \
--dsn 'postgresql://moviepilot:<benchmark-password>@127.0.0.1:5432/moviepilot' \
--standard-image 'moviepilot-v3@sha256:<64-hex-digest>' \
--standard-psycopg3-image 'moviepilot-v3-pg3@sha256:<64-hex-digest>' \
--free-threaded-image 'moviepilot-v3t@sha256:<64-hex-digest>'
```
脚本按三方案的六个全排列执行固定 SQL,默认把每个采样容器限制为 2 CPU/1 GiB,保存单连接查询、
16 线程查询、批量事务、长事务行锁并行、驱动/libpq/SOABI 和 GIL 状态。每个 campaign 使用独立
测试表并在成功或失败后清理;DSN 只传入隔离容器,不写入结果。性能数据用于解释驱动选择,不作为
跨机器发布阈值;驱动实现、GIL、查询结果、长事务并行和样本完整性属于硬门禁。
File diff suppressed because it is too large Load Diff
+802
View File
@@ -0,0 +1,802 @@
"""比较标准与 free-threaded MoviePilot 镜像的 PostgreSQL 同步驱动。"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import statistics
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
try:
import docker
except ImportError: # pragma: no cover - 保留无 Docker SDK 环境下的 --help
docker = None
SCHEMA_VERSION = 1
VARIANTS = (
("v3_psycopg2", "psycopg2"),
("v3_psycopg3_binary", "psycopg3"),
("v3t_psycopg3_c", "psycopg3"),
)
ORDERS = (
(VARIANTS[0], VARIANTS[1], VARIANTS[2]),
(VARIANTS[1], VARIANTS[2], VARIANTS[0]),
(VARIANTS[2], VARIANTS[0], VARIANTS[1]),
(VARIANTS[2], VARIANTS[1], VARIANTS[0]),
(VARIANTS[1], VARIANTS[0], VARIANTS[2]),
(VARIANTS[0], VARIANTS[2], VARIANTS[1]),
)
class HarnessInvalid(RuntimeError):
"""表示制品、数据库或样本不满足可比较合同。"""
def utc_now() -> str:
"""返回 JSON 使用的 UTC 时间。"""
return datetime.now(timezone.utc).isoformat()
def normalize_campaign(value: str) -> str:
"""限制 campaign,使输出目录和 Docker 标签可安全复用。"""
normalized = value.strip().lower()
if not re.fullmatch(r"[a-z0-9][a-z0-9_.-]{0,39}", normalized):
raise argparse.ArgumentTypeError(
"campaign 只能包含小写字母、数字、点、下划线和短横线,最长 40 字符"
)
return normalized
def immutable_image(value: str) -> str:
"""只接受带完整 sha256 digest 的镜像引用。"""
reference = value.strip()
if not re.fullmatch(r"[^\s@]+@sha256:[0-9a-f]{64}", reference):
raise argparse.ArgumentTypeError("镜像必须使用 repository@sha256:<64 hex> 不可变引用")
return reference
def sample_order(round_index: int) -> tuple[tuple[str, str], ...]:
"""返回三方案的平衡全排列,消除固定位置偏置。"""
return ORDERS[round_index % len(ORDERS)]
def atomic_write_json(path: Path, payload: Any) -> None:
"""原子写入 JSON,避免长采样中断留下半个结果。"""
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(f"{path.suffix}.tmp")
temporary.write_text(
json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
temporary.replace(path)
def require_docker_client():
"""连接本机 Docker Engine。"""
if docker is None:
raise HarnessInvalid("缺少 docker Python SDK,请使用 MoviePilot 工作区环境执行")
try:
client = docker.from_env()
client.ping()
return client
except Exception as default_error:
if os.getenv("DOCKER_HOST"):
raise HarnessInvalid(f"无法连接 Docker Engine{default_error}") from default_error
try:
endpoint = subprocess.run(
[
"docker",
"context",
"inspect",
"--format",
'{{ (index .Endpoints "docker").Host }}',
],
check=True,
capture_output=True,
text=True,
).stdout.strip()
if not endpoint:
raise RuntimeError("当前 Docker context 没有 endpoint")
client = docker.DockerClient(base_url=endpoint)
client.ping()
return client
except Exception as context_error:
raise HarnessInvalid(
f"无法连接 Docker Engine{default_error}"
) from context_error
def image_identity(client, reference: str) -> dict[str, Any]:
"""读取不可变镜像身份与 MoviePilot 源码标签。"""
image_id = reference.rsplit("@", 1)[-1]
try:
image = client.images.get(reference)
except Exception:
try:
image = client.images.get(image_id)
except Exception as error:
raise HarnessInvalid(f"本地不存在镜像 {reference}{error}") from error
attrs = image.attrs or {}
labels = (attrs.get("Config") or {}).get("Labels") or {}
revision = labels.get("org.moviepilot.source-revision") or labels.get(
"org.opencontainers.image.revision"
)
version = labels.get("org.opencontainers.image.version")
if not revision or not version:
raise HarnessInvalid(f"镜像 {reference} 缺少源码 revision 或版本标签")
return {
"reference": reference,
"runtime_reference": image.id,
"image_id": image.id,
"size_bytes": int(attrs.get("Size") or 0),
"architecture": attrs.get("Architecture"),
"source_revision": revision,
"version": version,
}
BENCHMARK_SCRIPT = r"""
import concurrent.futures
import hashlib
import importlib.metadata
import json
import os
import platform
import random
import statistics
import sys
import sysconfig
import threading
import time
driver = os.environ["MP_PG_DRIVER"]
dsn = os.environ["MP_PG_DSN"]
serial_queries = int(os.environ["MP_PG_SERIAL_QUERIES"])
workers = int(os.environ["MP_PG_WORKERS"])
queries_per_worker = int(os.environ["MP_PG_QUERIES_PER_WORKER"])
batch_rows = int(os.environ["MP_PG_BATCH_ROWS"])
long_transaction_seconds = float(os.environ["MP_PG_LONG_TRANSACTION_SECONDS"])
sample_key = os.environ["MP_PG_SAMPLE_KEY"]
table_name = os.environ["MP_PG_TABLE"]
if not table_name.isidentifier():
raise RuntimeError("invalid benchmark table name")
select_sql = f"SELECT payload FROM {table_name} WHERE id = %s"
insert_sql = f"INSERT INTO {table_name} (id, payload) VALUES (%s, %s)"
sql_sha256 = hashlib.sha256(
"\n".join((select_sql, insert_sql, "SELECT ... FOR UPDATE", "UPDATE ..."))
.encode()
).hexdigest()
fixture_sha256 = hashlib.sha256(
"\n".join(f"{index}:payload-{index:05d}" for index in range(10000)).encode()
).hexdigest()
gil_before = sys._is_gil_enabled()
if driver == "psycopg2":
import psycopg2 as driver_module
package_versions = {
"psycopg2-binary": importlib.metadata.version("psycopg2-binary"),
"psycopg": None,
"psycopg-binary": None,
"psycopg-c": None,
}
implementation = "psycopg2"
elif driver == "psycopg3":
import psycopg as driver_module
implementation = driver_module.pq.__impl__
def optional_version(name):
try:
return importlib.metadata.version(name)
except importlib.metadata.PackageNotFoundError:
return None
package_versions = {
"psycopg2-binary": None,
"psycopg": importlib.metadata.version("psycopg"),
"psycopg-binary": optional_version("psycopg-binary"),
"psycopg-c": optional_version("psycopg-c"),
}
else:
raise RuntimeError(f"unsupported driver: {driver}")
gil_after_import = sys._is_gil_enabled()
if driver == "psycopg2":
libpq_version = driver_module.__libpq_version__
else:
libpq_version = driver_module.pq.version()
distribution_name = {
"psycopg2": "psycopg2-binary",
"binary": "psycopg-binary",
"c": "psycopg-c",
}[implementation]
distribution = importlib.metadata.distribution(distribution_name)
wheel_tags = sorted(
line.split(":", 1)[1].strip()
for line in (distribution.read_text("WHEEL") or "").splitlines()
if line.startswith("Tag:")
)
def connect():
return driver_module.connect(dsn)
def percentile(values, ratio):
ordered = sorted(values)
return ordered[min(len(ordered) - 1, int((len(ordered) - 1) * ratio))]
def latency_summary(values):
return {
"p50_ms": statistics.median(values),
"p95_ms": percentile(values, 0.95),
"max_ms": max(values),
}
with connect() as connection:
with connection.cursor() as cursor:
cursor.execute(
f"CREATE UNLOGGED TABLE IF NOT EXISTS {table_name} ("
"id INTEGER PRIMARY KEY, payload TEXT NOT NULL)"
)
cursor.execute(f"SELECT count(*) FROM {table_name}")
if cursor.fetchone()[0] < 10000:
cursor.execute(f"TRUNCATE {table_name}")
cursor.executemany(
insert_sql,
[(index, f"payload-{index:05d}") for index in range(10000)],
)
postgresql_settings = {}
for setting_name in (
"server_version",
"max_connections",
"shared_buffers",
"jit",
"synchronous_commit",
):
cursor.execute("SELECT current_setting(%s)", (setting_name,))
postgresql_settings[setting_name] = cursor.fetchone()[0]
with connect() as connection:
with connection.cursor() as cursor:
for row_id in range(100):
cursor.execute(select_sql, (row_id,))
cursor.fetchone()
randomizer = random.Random(314159)
serial_ids = [randomizer.randrange(10000) for _ in range(serial_queries)]
serial_latencies = []
serial_checksum = hashlib.sha256()
with connect() as connection:
with connection.cursor() as cursor:
started = time.perf_counter()
for row_id in serial_ids:
query_started = time.perf_counter()
cursor.execute(select_sql, (row_id,))
value = cursor.fetchone()[0]
serial_checksum.update(value.encode())
serial_latencies.append((time.perf_counter() - query_started) * 1000)
serial_seconds = time.perf_counter() - started
def concurrent_worker(worker_index):
worker_randomizer = random.Random(314159 + worker_index)
checksum = hashlib.sha256()
with connect() as connection:
with connection.cursor() as cursor:
for _ in range(queries_per_worker):
row_id = worker_randomizer.randrange(10000)
cursor.execute(select_sql, (row_id,))
checksum.update(cursor.fetchone()[0].encode())
return checksum.hexdigest()
concurrent_started = time.perf_counter()
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
concurrent_checksums = list(executor.map(concurrent_worker, range(workers)))
concurrent_seconds = time.perf_counter() - concurrent_started
write_prefix = 10000 + (
int(hashlib.sha256(sample_key.encode()).hexdigest()[:8], 16) % 10000
) * 100000
write_rows = [
(write_prefix + index, f"write-{sample_key}-{index:05d}")
for index in range(batch_rows)
]
with connect() as connection:
with connection.cursor() as cursor:
cursor.execute(
f"DELETE FROM {table_name} WHERE id >= %s AND id < %s",
(write_prefix, write_prefix + batch_rows),
)
write_started = time.perf_counter()
cursor.executemany(
insert_sql,
write_rows,
)
connection.commit()
batch_write_seconds = time.perf_counter() - write_started
long_ready = threading.Event()
long_errors = []
def long_transaction():
try:
with connect() as connection:
with connection.cursor() as cursor:
cursor.execute(
f"SELECT payload FROM {table_name} WHERE id = 1 FOR UPDATE"
)
long_ready.set()
cursor.execute("SELECT pg_sleep(%s)", (long_transaction_seconds,))
connection.commit()
except Exception as error:
long_errors.append(f"{type(error).__name__}: {error}")
long_ready.set()
long_thread = threading.Thread(target=long_transaction)
long_started = time.perf_counter()
long_thread.start()
if not long_ready.wait(timeout=5):
raise RuntimeError("long transaction did not start")
with connect() as connection:
with connection.cursor() as cursor:
short_started = time.perf_counter()
cursor.execute(select_sql, (2,))
short_value = cursor.fetchone()[0]
short_query_seconds = time.perf_counter() - short_started
with connect() as connection:
with connection.cursor() as cursor:
contended_started = time.perf_counter()
cursor.execute(
f"UPDATE {table_name} SET payload = payload WHERE id = 1"
)
connection.commit()
contended_update_seconds = time.perf_counter() - contended_started
long_thread.join(timeout=long_transaction_seconds + 5)
if long_thread.is_alive() or long_errors:
raise RuntimeError(f"long transaction failed: {long_errors}")
long_elapsed = time.perf_counter() - long_started
with connect() as connection:
with connection.cursor() as cursor:
cursor.execute(
f"DELETE FROM {table_name} WHERE id >= %s AND id < %s",
(write_prefix, write_prefix + batch_rows),
)
print(json.dumps({
"postgresql_settings": postgresql_settings,
"runtime": {
"python_version": sys.version.split()[0],
"platform_machine": platform.machine(),
"soabi": sysconfig.get_config_var("SOABI"),
"gil_before_import": gil_before,
"gil_after_import": gil_after_import,
"gil_after_benchmark": sys._is_gil_enabled(),
"driver": driver,
"implementation": implementation,
"native_distribution": distribution_name,
"libpq_version": libpq_version,
"wheel_tags": wheel_tags,
"packages": package_versions,
},
"sql_contract": {
"fixture_sha256": fixture_sha256,
"sql_sha256": sql_sha256,
},
"serial_query": {
"operations": serial_queries,
"seconds": serial_seconds,
"throughput_ops_s": serial_queries / serial_seconds,
"latency": latency_summary(serial_latencies),
"checksum": serial_checksum.hexdigest(),
},
"concurrent_query": {
"workers": workers,
"operations": workers * queries_per_worker,
"seconds": concurrent_seconds,
"throughput_ops_s": workers * queries_per_worker / concurrent_seconds,
"checksums": concurrent_checksums,
},
"batch_write": {
"rows": batch_rows,
"seconds": batch_write_seconds,
"throughput_rows_s": batch_rows / batch_write_seconds,
},
"long_transaction": {
"requested_seconds": long_transaction_seconds,
"elapsed_seconds": long_elapsed,
"parallel_short_query_seconds": short_query_seconds,
"parallel_short_query_value": short_value,
"contended_update_seconds": contended_update_seconds,
},
}, sort_keys=True))
"""
def run_sample(
client,
*,
image: dict[str, Any],
postgres_container: str,
variant: str,
driver: str,
round_index: int,
position: int,
table_name: str,
args: argparse.Namespace,
) -> dict[str, Any]:
"""在目标镜像中执行一次独立样本。"""
environment = {
"MP_PG_DRIVER": driver,
"MP_PG_DSN": args.dsn,
"MP_PG_SERIAL_QUERIES": str(args.serial_queries),
"MP_PG_WORKERS": str(args.workers),
"MP_PG_QUERIES_PER_WORKER": str(args.queries_per_worker),
"MP_PG_BATCH_ROWS": str(args.batch_rows),
"MP_PG_LONG_TRANSACTION_SECONDS": str(args.long_transaction_seconds),
"MP_PG_SAMPLE_KEY": f"{args.campaign}-{variant}-{round_index + 1}",
"MP_PG_TABLE": table_name,
}
started = time.perf_counter()
try:
output = client.containers.run(
image["runtime_reference"],
["python", "-c", BENCHMARK_SCRIPT],
entrypoint="",
environment=environment,
network_mode=f"container:{postgres_container}",
remove=True,
nano_cpus=int(args.cpus * 1_000_000_000),
mem_limit=args.memory,
stdout=True,
stderr=True,
)
except Exception as error:
raise HarnessInvalid(f"{variant}{round_index + 1} 轮执行失败:{error}") from error
try:
payload = json.loads(output.decode("utf-8").strip().splitlines()[-1])
except (UnicodeDecodeError, json.JSONDecodeError, IndexError) as error:
raise HarnessInvalid(f"{variant} 输出不是有效 JSON") from error
payload.update(
{
"variant": variant,
"round": round_index + 1,
"position": position,
"wall_seconds": time.perf_counter() - started,
}
)
return payload
CLEANUP_SCRIPT = r"""
import os
import psycopg2
table_name = os.environ["MP_PG_TABLE"]
if not table_name.isidentifier():
raise RuntimeError("invalid benchmark table name")
with psycopg2.connect(os.environ["MP_PG_DSN"]) as connection:
with connection.cursor() as cursor:
cursor.execute(f"DROP TABLE IF EXISTS {table_name}")
"""
def cleanup_table(
client,
*,
image: dict[str, Any],
postgres_container: str,
table_name: str,
args: argparse.Namespace,
) -> None:
"""在成功或失败后删除 campaign 独占的测试表。"""
try:
client.containers.run(
image["runtime_reference"],
["python", "-c", CLEANUP_SCRIPT],
entrypoint="",
environment={"MP_PG_DSN": args.dsn, "MP_PG_TABLE": table_name},
network_mode=f"container:{postgres_container}",
remove=True,
stdout=True,
stderr=True,
)
except Exception as error:
raise HarnessInvalid(f"无法清理 PostgreSQL benchmark 表:{error}") from error
def validate_sample(variant: str, sample: dict[str, Any]) -> None:
"""验证驱动、ABI 和长事务并行合同。"""
runtime = sample["runtime"]
if runtime["python_version"].split(".")[:2] != ["3", "14"]:
raise HarnessInvalid(f"{variant} 不是 Python 3.14")
if variant == "v3_psycopg2":
expected = ("psycopg2", True)
elif variant == "v3_psycopg3_binary":
expected = ("binary", True)
else:
expected = ("c", False)
if runtime["implementation"] != expected[0]:
raise HarnessInvalid(
f"{variant} 驱动实现应为 {expected[0]},实际为 {runtime['implementation']}"
)
for key in ("gil_before_import", "gil_after_import", "gil_after_benchmark"):
if runtime[key] is not expected[1]:
raise HarnessInvalid(f"{variant}{key} 不满足 GIL 合同")
long_transaction = sample["long_transaction"]
if long_transaction["parallel_short_query_value"] != "payload-00002":
raise HarnessInvalid(f"{variant} 长事务并行查询结果错误")
if (
long_transaction["parallel_short_query_seconds"]
>= long_transaction["requested_seconds"]
):
raise HarnessInvalid(f"{variant} 的独立短查询被长事务完整阻塞")
if (
long_transaction["contended_update_seconds"]
< long_transaction["requested_seconds"] * 0.5
):
raise HarnessInvalid(f"{variant} 的冲突写入未等待长事务行锁")
def validate_campaign(samples: list[dict[str, Any]], rounds: int) -> None:
"""验证三方案样本数量和固定查询结果完全一致。"""
for variant, _driver in VARIANTS:
variant_samples = [
sample for sample in samples if sample["variant"] == variant
]
if len(variant_samples) != rounds:
raise HarnessInvalid(f"{variant} 样本数量不完整")
serial_checksums = {
sample["serial_query"]["checksum"] for sample in samples
}
concurrent_checksums = {
tuple(sample["concurrent_query"]["checksums"]) for sample in samples
}
if len(serial_checksums) != 1 or len(concurrent_checksums) != 1:
raise HarnessInvalid("三方案查询结果校验和不一致")
sql_contracts = {
(sample["sql_contract"]["fixture_sha256"], sample["sql_contract"]["sql_sha256"])
for sample in samples
}
if len(sql_contracts) != 1:
raise HarnessInvalid("三方案使用的 SQL 或 fixture 不一致")
postgresql_settings = {
tuple(sorted(sample["postgresql_settings"].items())) for sample in samples
}
if len(postgresql_settings) != 1:
raise HarnessInvalid("采样期间 PostgreSQL 服务设置发生变化")
def median_at(samples: list[dict[str, Any]], *keys: str) -> float:
"""汇总指定路径的样本中位数。"""
values = []
for sample in samples:
value: Any = sample
for key in keys:
value = value[key]
values.append(float(value))
return statistics.median(values)
def summarize(samples: list[dict[str, Any]]) -> dict[str, Any]:
"""生成三方案中位数和相对标准 V3/psycopg2 的比例。"""
grouped = {
variant: [sample for sample in samples if sample["variant"] == variant]
for variant, _driver in VARIANTS
}
metrics = {
variant: {
"serial_query_throughput_ops_s": median_at(
values, "serial_query", "throughput_ops_s"
),
"concurrent_query_throughput_ops_s": median_at(
values, "concurrent_query", "throughput_ops_s"
),
"batch_write_seconds": median_at(values, "batch_write", "seconds"),
"parallel_short_query_seconds": median_at(
values, "long_transaction", "parallel_short_query_seconds"
),
}
for variant, values in grouped.items()
}
baseline = metrics["v3_psycopg2"]
ratios = {
variant: {
"serial_query_throughput_over_v3_psycopg2": (
values["serial_query_throughput_ops_s"]
/ baseline["serial_query_throughput_ops_s"]
),
"concurrent_query_throughput_over_v3_psycopg2": (
values["concurrent_query_throughput_ops_s"]
/ baseline["concurrent_query_throughput_ops_s"]
),
"batch_write_time_over_v3_psycopg2": (
values["batch_write_seconds"] / baseline["batch_write_seconds"]
),
}
for variant, values in metrics.items()
}
return {"metrics": metrics, "ratios": ratios}
def render_report(result: dict[str, Any]) -> str:
"""生成维护者可读的 Markdown 摘要。"""
summary = result["summary"]
lines = [
"# PostgreSQL driver A/B",
"",
f"- Campaign: `{result['campaign']}`",
f"- PostgreSQL: `{result['postgresql']['version']}`",
f"- Samples: `{len(result['samples'])}`",
f"- Verdict: `{result['verdict']}`",
"",
"| Variant | Serial query ops/s | 16-thread query ops/s | Batch write seconds |",
"| --- | ---: | ---: | ---: |",
]
for variant, _driver in VARIANTS:
metrics = summary["metrics"][variant]
lines.append(
f"| `{variant}` | {metrics['serial_query_throughput_ops_s']:.1f} | "
f"{metrics['concurrent_query_throughput_ops_s']:.1f} | "
f"{metrics['batch_write_seconds']:.4f} |"
)
lines.extend(
[
"",
"性能数据用于驱动选择,不是跨机器发布阈值;硬门禁只覆盖驱动实现、GIL、SQL 结果、长事务并行和样本完整性。",
"",
]
)
return "\n".join(lines)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"""解析命令行参数。"""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--campaign", required=True, type=normalize_campaign)
parser.add_argument("--postgres-container", required=True)
parser.add_argument("--dsn", required=True, help="仅传给隔离容器,不写入结果")
parser.add_argument("--standard-image", required=True, type=immutable_image)
parser.add_argument(
"--standard-psycopg3-image", required=True, type=immutable_image
)
parser.add_argument("--free-threaded-image", required=True, type=immutable_image)
parser.add_argument("--rounds", type=int, default=6)
parser.add_argument("--serial-queries", type=int, default=2000)
parser.add_argument("--workers", type=int, default=16)
parser.add_argument("--queries-per-worker", type=int, default=250)
parser.add_argument("--batch-rows", type=int, default=2000)
parser.add_argument("--long-transaction-seconds", type=float, default=0.25)
parser.add_argument("--cpus", type=float, default=2.0)
parser.add_argument("--memory", default="1g")
parser.add_argument("--output-dir", type=Path)
args = parser.parse_args(argv)
for name in ("rounds", "serial_queries", "workers", "queries_per_worker", "batch_rows"):
if getattr(args, name) <= 0:
parser.error(f"--{name.replace('_', '-')} 必须为正整数")
if args.long_transaction_seconds <= 0:
parser.error("--long-transaction-seconds 必须为正数")
if args.rounds % len(ORDERS):
parser.error(f"--rounds 必须是 {len(ORDERS)} 的正整数倍")
if args.cpus <= 0:
parser.error("--cpus 必须为正数")
return args
def main(argv: list[str] | None = None) -> int:
"""执行 A/B 并写入原始 JSON 与报告。"""
args = parse_args(argv)
output_dir = args.output_dir or (
Path.cwd() / ".artifacts" / "postgresql-driver-ab" / args.campaign
)
result: dict[str, Any] = {
"schema_version": SCHEMA_VERSION,
"campaign": args.campaign,
"started_at": utc_now(),
"verdict": "invalid",
"parameters": {
"rounds": args.rounds,
"serial_queries": args.serial_queries,
"workers": args.workers,
"queries_per_worker": args.queries_per_worker,
"batch_rows": args.batch_rows,
"long_transaction_seconds": args.long_transaction_seconds,
"cpus": args.cpus,
"memory": args.memory,
},
"samples": [],
}
client = None
images: dict[str, dict[str, Any]] = {}
table_name = f"mp_ab_{hashlib.sha256(args.campaign.encode()).hexdigest()[:12]}"
try:
client = require_docker_client()
postgres = client.containers.get(args.postgres_container)
postgres.reload()
if postgres.status != "running":
raise HarnessInvalid("PostgreSQL 容器未运行")
version_output = postgres.exec_run(["postgres", "--version"])
if version_output.exit_code:
raise HarnessInvalid("无法读取 PostgreSQL 版本")
result["postgresql"] = {
"container_image": postgres.image.id,
"version": version_output.output.decode().strip(),
}
images = {
"v3_psycopg2": image_identity(client, args.standard_image),
"v3_psycopg3_binary": image_identity(
client, args.standard_psycopg3_image
),
"v3t_psycopg3_c": image_identity(client, args.free_threaded_image),
}
revisions = {identity["source_revision"] for identity in images.values()}
versions = {identity["version"] for identity in images.values()}
architectures = {identity["architecture"] for identity in images.values()}
if len(revisions) != 1 or len(versions) != 1 or len(architectures) != 1:
raise HarnessInvalid("三个镜像必须来自相同源码 revision、产品版本和架构")
result["images"] = images
for round_index in range(args.rounds):
for position, (variant, driver) in enumerate(sample_order(round_index)):
sample = run_sample(
client,
image=images[variant],
postgres_container=postgres.id,
variant=variant,
driver=driver,
round_index=round_index,
position=position,
table_name=table_name,
args=args,
)
validate_sample(variant, sample)
result["samples"].append(sample)
atomic_write_json(output_dir / "results.partial.json", result)
cleanup_table(
client,
image=images["v3_psycopg2"],
postgres_container=postgres.id,
table_name=table_name,
args=args,
)
validate_campaign(result["samples"], args.rounds)
result["postgresql"]["settings"] = result["samples"][0][
"postgresql_settings"
]
result["sql_contract"] = result["samples"][0]["sql_contract"]
result["summary"] = summarize(result["samples"])
result["verdict"] = "pass"
result["finished_at"] = utc_now()
atomic_write_json(output_dir / "results.json", result)
(output_dir / "report.md").write_text(render_report(result), encoding="utf-8")
partial = output_dir / "results.partial.json"
if partial.exists():
partial.unlink()
return 0
except HarnessInvalid as error:
if client is not None and images:
try:
cleanup_table(
client,
image=images["v3_psycopg2"],
postgres_container=args.postgres_container,
table_name=table_name,
args=args,
)
except HarnessInvalid as cleanup_error:
result["cleanup_error"] = str(cleanup_error)
result["error"] = str(error)
result["finished_at"] = utc_now()
atomic_write_json(output_dir / "results.invalid.json", result)
print(f"postgresql_driver_ab invalid: {error}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+20 -3
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [],
"workflow_to_db": []
},
"edge_count": 6564,
"edge_sha256": "333de3c1ef3f7e49be96dda288a0f382bcd19a853817708453d67557d138aa80",
"edge_count": 6579,
"edge_sha256": "87982f9e351a23cb949bcb8a978b9c1eced6260d6c19761fccc9557b79ac90d3",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -62,6 +62,7 @@
"app.adapters.external.market -> app.foundation.version",
"app.adapters.external.market -> app.runtime",
"app.adapters.external.market -> app.runtime.cache",
"app.adapters.external.market -> app.runtime.dependencies",
"app.adapters.external.market -> app.runtime.execution",
"app.adapters.external.market -> app.runtime.log",
"app.adapters.external.market -> app.runtime.observability",
@@ -134,6 +135,8 @@
"app.adapters.system.host -> app.foundation.environment",
"app.adapters.system.host -> app.schemas",
"app.adapters.system.host -> app.schemas.dashboard",
"app.adapters.system.package -> app.runtime",
"app.adapters.system.package -> app.runtime.dependencies",
"app.adapters.system.plugin.dependency -> app.adapters",
"app.adapters.system.plugin.dependency -> app.adapters.external",
"app.adapters.system.plugin.dependency -> app.adapters.external.market",
@@ -163,6 +166,8 @@
"app.adapters.system.resource -> app.runtime.config",
"app.adapters.system.resource -> app.runtime.log",
"app.adapters.system.resource -> app.runtime.settings",
"app.adapters.system.rust -> app.foundation",
"app.adapters.system.rust -> app.foundation.environment",
"app.adapters.system.rust -> app.runtime",
"app.adapters.system.rust -> app.runtime.log",
"app.adapters.system.rust -> app.runtime.settings",
@@ -2292,6 +2297,7 @@
"app.api.endpoints.system -> app.domain.metainfo",
"app.api.endpoints.system -> app.foundation",
"app.api.endpoints.system -> app.foundation.crypto",
"app.api.endpoints.system -> app.foundation.environment",
"app.api.endpoints.system -> app.foundation.url",
"app.api.endpoints.system -> app.runtime",
"app.api.endpoints.system -> app.runtime.config",
@@ -3593,6 +3599,8 @@
"app.db.engine -> app.db",
"app.db.engine -> app.db.diagnostics",
"app.db.engine -> app.db.worker",
"app.db.engine -> app.foundation",
"app.db.engine -> app.foundation.environment",
"app.db.engine -> app.runtime",
"app.db.engine -> app.runtime.config",
"app.db.engine -> app.runtime.log",
@@ -3927,6 +3935,8 @@
"app.factory -> app.schemas.response",
"app.factory -> app.startup",
"app.factory -> app.startup.lifecycle",
"app.foundation.text -> app.foundation",
"app.foundation.text -> app.foundation.environment",
"app.main -> app.adapters",
"app.main -> app.adapters.system",
"app.main -> app.adapters.system.host",
@@ -5597,6 +5607,8 @@
"app.runtime.config -> app.schemas.types",
"app.runtime.debounce -> app.runtime",
"app.runtime.debounce -> app.runtime.log",
"app.runtime.dependencies -> app.foundation",
"app.runtime.dependencies -> app.foundation.environment",
"app.runtime.deprecation.policy -> app.runtime",
"app.runtime.deprecation.policy -> app.runtime.deprecation",
"app.runtime.deprecation.policy -> app.runtime.deprecation.notices",
@@ -5749,6 +5761,7 @@
"app.runtime.extensions.plugin.tools -> app.runtime.extensions.plugin.contracts",
"app.runtime.extensions.plugin_manager -> app.foundation",
"app.runtime.extensions.plugin_manager -> app.foundation.crypto",
"app.runtime.extensions.plugin_manager -> app.foundation.environment",
"app.runtime.extensions.plugin_manager -> app.foundation.singleton",
"app.runtime.extensions.plugin_manager -> app.foundation.version",
"app.runtime.extensions.plugin_manager -> app.runtime",
@@ -6369,6 +6382,8 @@
"app.startup.lifecycle -> app.chain.system",
"app.startup.lifecycle -> app.db",
"app.startup.lifecycle -> app.db.engine",
"app.startup.lifecycle -> app.foundation",
"app.startup.lifecycle -> app.foundation.environment",
"app.startup.lifecycle -> app.runtime",
"app.startup.lifecycle -> app.runtime.config",
"app.startup.lifecycle -> app.runtime.health",
@@ -6581,7 +6596,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
"module_count": 810,
"module_count": 812,
"modules": [
"app",
"app.adapters",
@@ -7019,6 +7034,7 @@
"app.db.worker",
"app.doctor",
"app.doctor.checks",
"app.doctor.dependencies",
"app.doctor.formatters",
"app.doctor.models",
"app.doctor.runner",
@@ -7237,6 +7253,7 @@
"app.runtime.config",
"app.runtime.correlation",
"app.runtime.debounce",
"app.runtime.dependencies",
"app.runtime.deprecation",
"app.runtime.deprecation.notices",
"app.runtime.deprecation.policy",
@@ -8700,6 +8700,11 @@
"name": "TimerUtils",
"target": "app.runtime.scheduling.TimerUtils"
},
{
"kind": "import",
"name": "convert",
"target": "app.foundation.text.convert"
},
{
"kind": "import",
"name": "cut",
+2 -1
View File
@@ -11,10 +11,11 @@ def test_runtime_image_installs_postgresql_18_client_from_pgdg() -> None:
).read_text(encoding="utf-8")
assert re.search(
r"^FROM python:3\.14\.7-slim-trixie AS base$",
r'^ARG MOVIEPILOT_PYTHON_VERSION="3\.14\.7"$',
dockerfile,
re.MULTILINE,
)
assert "FROM python:${MOVIEPILOT_PYTHON_VERSION}-slim-trixie AS base" in dockerfile
assert "https://www.postgresql.org/media/keys/ACCC4CF8.asc" in dockerfile
for curl_option in (
"--connect-timeout 10",
+9 -4
View File
@@ -5,13 +5,18 @@ import subprocess
import sys
import uuid
import psycopg2
from psycopg2 import sql
import pytest
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
try:
import psycopg2 as postgres_driver
from psycopg2 import sql
except ModuleNotFoundError:
import psycopg as postgres_driver
from psycopg import sql
MIGRATION_MODULE = "database.versions.93f8cb6a4d1e_2_2_4"
MEDIA_TABLES = (
@@ -370,7 +375,7 @@ def test_current_schema_reaches_current_alembic_head_on_postgresql(
port = os.getenv(f"{prefix}PORT", "5432")
password = os.getenv(f"{prefix}PASSWORD", "")
schema = f"p1_db1_{uuid.uuid4().hex}"
with psycopg2.connect(
with postgres_driver.connect(
host=host,
port=port,
dbname=database,
@@ -400,7 +405,7 @@ def test_current_schema_reaches_current_alembic_head_on_postgresql(
try:
_run_current_schema_chain(repository, environment)
finally:
with psycopg2.connect(
with postgres_driver.connect(
host=host,
port=port,
dbname=database,
@@ -2,13 +2,20 @@ import importlib
import os
import uuid
import psycopg2
from psycopg2 import sql
import pytest
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
try:
import psycopg2 as postgres_driver
from psycopg2 import sql
POSTGRESQL_SQLALCHEMY_DRIVER = "postgresql+psycopg2"
except ModuleNotFoundError:
import psycopg as postgres_driver
from psycopg import sql
POSTGRESQL_SQLALCHEMY_DRIVER = "postgresql+psycopg"
E6_MIGRATION = "database.versions.e6a1c4b8d2f0_2_2_13"
F7_MIGRATION = "database.versions.f7b2d5c9a301_2_2_14"
@@ -636,7 +643,7 @@ def test_e6_f7_round_trip_on_postgresql(monkeypatch) -> None:
port = os.getenv(f"{prefix}PORT", "5432")
password = os.getenv(f"{prefix}PASSWORD", "")
schema = f"p1_db1_roundtrip_{uuid.uuid4().hex}"
with psycopg2.connect(
with postgres_driver.connect(
host=host,
port=port,
dbname=database,
@@ -654,7 +661,7 @@ def test_e6_f7_round_trip_on_postgresql(monkeypatch) -> None:
try:
engine = sa.create_engine(
sa.URL.create(
"postgresql+psycopg2",
POSTGRESQL_SQLALCHEMY_DRIVER,
username=username,
password=password,
host=host,
@@ -667,7 +674,7 @@ def test_e6_f7_round_trip_on_postgresql(monkeypatch) -> None:
finally:
if engine is not None:
engine.dispose()
with psycopg2.connect(
with postgres_driver.connect(
host=host,
port=port,
dbname=database,
+13
View File
@@ -223,6 +223,19 @@ def test_pg_sync_engine_applies_pool_settings(monkeypatch):
assert captured["url"].startswith("postgresql")
def test_pg_sync_engine_uses_psycopg_on_free_threaded_python(monkeypatch):
"""free-threaded 运行时不能加载会重新启用 GIL 的 psycopg2 扩展。"""
monkeypatch.setattr(engine_module, "is_free_threaded_runtime", lambda: True)
captured = {}
monkeypatch.setattr(engine_module, "create_engine",
lambda **kw: captured.update(kw) or MagicMock())
monkeypatch.setattr(engine_module, "_register_database_error_logging", lambda *_a: None)
engine_module._get_postgresql_engine(is_async=False)
assert captured["url"].startswith("postgresql+psycopg://")
def test_pg_async_engine_pooled_omits_poolclass(monkeypatch):
"""
池化的异步引擎不得指定 poolclassSQLAlchemy 需自行选用异步适配的
+82 -1
View File
@@ -1,6 +1,7 @@
import os
import shlex
import subprocess
import sys
import textwrap
from pathlib import Path
@@ -35,8 +36,10 @@ def test_dockerfile_control_bundle_build_checks_fail_closed() -> None:
assert "COPY --from=uv /uv /usr/local/bin/uv" in dockerfile
assert "COPY pyproject.toml uv.lock ./" in dockerfile
assert "python3 -m venv --without-pip ${VENV_PATH}" in dockerfile
assert 'sysconfig.get_path("purelib")' in dockerfile
assert "UV_PROJECT_ENVIRONMENT=${VENV_PATH} uv sync" in dockerfile
for option in ("--locked", "--no-dev", "--no-install-project"):
assert "PYTHON_THREAD_INHERIT_CONTEXT=0" in dockerfile
for option in ("--locked", "--no-default-groups", "--group", "--no-install-project"):
assert option in dockerfile
assert "uv-pip-compat" not in dockerfile
assert "requirements.in" not in dockerfile
@@ -58,6 +61,81 @@ def test_dockerfile_control_bundle_build_checks_fail_closed() -> None:
)
def test_update_sync_selects_target_interpreter_runtime_group(tmp_path: Path) -> None:
project = tmp_path / "project"
project.mkdir()
(project / "pyproject.toml").write_text(
"""
[project]
name = "moviepilot"
version = "0"
[dependency-groups]
runtime-standard = ["standard"]
runtime-free-threaded = ["free-threaded"]
""",
encoding="utf-8",
)
(project / "uv.lock").write_text("version = 1\n", encoding="utf-8")
runtime_selector = project / "app" / "runtime" / "dependencies.py"
runtime_selector.parent.mkdir(parents=True)
runtime_selector.write_text("print('runtime-free-threaded')\n", encoding="utf-8")
venv_bin = tmp_path / "venv" / "bin"
venv_bin.mkdir(parents=True)
python_bin = venv_bin / "python3"
python_bin.write_text(
"#!/bin/bash\ncat >/dev/null\nprintf '%s\\n' runtime-free-threaded\n",
encoding="utf-8",
)
python_bin.chmod(0o755)
uv_bin = tmp_path / "uv"
uv_log = tmp_path / "uv.log"
uv_bin.write_text(
f"#!/bin/bash\nprintf '%s\\n' \"$*\" > {shlex.quote(str(uv_log))}\n",
encoding="utf-8",
)
uv_bin.chmod(0o755)
script = textwrap.dedent(
f"""\
CONFIG_DIR="$1"
VENV_PATH="$2"
UV_BIN="$3"
source {UPDATER!s}
PACKAGE_ENV=()
UV_OPTIONS=()
sync_project_dependencies_for "$4"
"""
)
subprocess.run(
[
"bash",
"-c",
script,
"runtime-profile-test",
str(tmp_path / "config"),
str(tmp_path / "venv"),
str(uv_bin),
str(project),
],
check=True,
)
command = uv_log.read_text(encoding="utf-8")
assert "--no-default-groups --group runtime-free-threaded" in command
def test_build_profiles_use_full_runtime_capability_probe() -> None:
"""两套依赖 profile 必须经过统一的完整运行能力验证。"""
dockerfile = (ROOT / "docker" / "Dockerfile").read_text(encoding="utf-8")
assert dockerfile.count(
'RUN "${VENV_PATH}/bin/python" /tmp/moviepilot-runtime-dependencies.py --full'
) == 2
assert "moviepilot_rust.jieba_cut" not in dockerfile
assert "moviepilot_rust.zhconv_fast" not in dockerfile
def _run_launcher(
tmp_path: Path,
source: Path,
@@ -915,6 +993,9 @@ def test_failed_dependency_sync_does_not_replace_program_files(tmp_path: Path) -
encoding="utf-8",
)
uv_bin.chmod(0o755)
python_bin = tmp_path / "venv" / "bin" / "python3"
python_bin.parent.mkdir(parents=True)
python_bin.symlink_to(Path(sys.executable))
live_app = tmp_path / "app"
live_public = tmp_path / "public"
(live_app / "app" / "plugins").mkdir(parents=True)
+45 -1
View File
@@ -315,11 +315,15 @@ def test_browser_install_is_centralized_in_startup() -> None:
browser = (ROOT / "docker" / "browser.sh").read_text(encoding="utf-8")
updater = (ROOT / "docker" / "update.sh").read_text(encoding="utf-8")
startup = entrypoint.split("# 使用env配置", 1)[1]
updater_source = 'source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/update.sh"'
assert "-m cloakbrowser install" not in entrypoint
assert browser.count("-m cloakbrowser install") == 2
assert "-m cloakbrowser install" not in updater
assert startup.index('source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/update.sh"') < startup.index(
assert startup.count(updater_source) == 1
assert startup.index(updater_source) < startup.index(
'if [ "${MOVIEPILOT_BOOTSTRAP_UPDATE_DONE:-0}" != "1" ]'
) < startup.index(
'source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/browser.sh"'
) < startup.index("resolve_browser_cache_dir") < startup.index("ensure_browser_kernel")
@@ -589,6 +593,46 @@ def test_backend_ready_timeout_accepts_leading_zero_decimal(tmp_path: Path) -> N
assert "MoviePilot Web 已可访问" in output
def test_backend_dependency_recovery_uses_runtime_profile_sync(tmp_path: Path) -> None:
"""启动自愈必须复用按当前解释器选择依赖组的同步入口。"""
venv_bin = tmp_path / "venv" / "bin"
venv_bin.mkdir(parents=True)
python_bin = venv_bin / "python3"
python_bin.write_text(
"#!/bin/bash\n[ -f \"${RECOVERY_MARKER}\" ]\n",
encoding="utf-8",
)
python_bin.chmod(0o755)
marker = tmp_path / "recovered"
output = _run_entrypoint_case(
tmp_path,
"""
INFO() { printf '[INFO] %s\\n' "$1"; }
WARN() { printf '[WARN] %s\\n' "$1"; }
configure_package_route() {
PACKAGE_LOG="test-route"
printf 'configured\\n'
}
sync_project_dependencies_for() {
printf 'sync:%s\\n' "$1"
touch "${RECOVERY_MARKER}"
}
ensure_backend_runtime_dependencies
printf 'route-ready:%s\\n' "${PACKAGE_ROUTE_READY}"
""",
env={
"VENV_PATH": str(tmp_path / "venv"),
"RECOVERY_MARKER": str(marker),
},
)
assert "configured" in output
assert "sync:/app" in output
assert "route-ready:true" in output
assert marker.exists()
def test_backend_failure_keepalive_contract_is_explicit() -> None:
"""后端异常默认保活诊断,显式关闭后才退出容器。"""
content = (ROOT / "docker" / "entrypoint.sh").read_text(encoding="utf-8")
+2
View File
@@ -21,6 +21,7 @@ def test_build_context_excludes_runtime_state_and_keeps_release_inputs() -> None
for pattern in (
".venv/",
".worktrees/",
".artifacts/",
".build/",
".agent-work/",
".runtime/",
@@ -141,5 +142,6 @@ def test_custom_frontend_directory_is_stable_but_artifacts_remain_untracked() ->
assert (ROOT / "frontend-dist" / ".gitkeep").is_file()
assert "frontend-dist/*" in gitignore
assert "!frontend-dist/.gitkeep" in gitignore
assert ".artifacts/" in gitignore
assert "COPY frontend-dist/ /tmp/frontend-dist/" in dockerfile
assert "! -name '.gitkeep'" in dockerfile
+457
View File
@@ -0,0 +1,457 @@
"""free-threaded 镜像 A/B harness 的无 Docker 合同测试。"""
from __future__ import annotations
import argparse
import importlib.util
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "scripts" / "perf" / "free_threaded_ab.py"
def load_harness(name: str = "free_threaded_ab_test"):
"""从脚本路径加载 harness,避免把 scripts 变成运行时 package。"""
spec = importlib.util.spec_from_file_location(name, SCRIPT)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def digest_image(repository: str, character: str) -> str:
"""构造测试用不可变镜像引用。"""
return f"registry.example/{repository}@sha256:{character * 64}"
def valid_preflight(variant: str) -> dict:
"""构造满足目标镜像合同的 preflight。"""
common = {
"python_version": "3.14.7",
"python_implementation": "CPython",
"moviepilot_rust_version": "0.3.0",
"rust_available": True,
"has_jieba_cut": True,
"packages": {},
"native": {},
"installed_packages": ["moviepilot-rust==0.3.0"],
"installed_packages_sha256": "package-hash",
"native_distributions": [],
"uv_pip_check": {
"returncode": 1,
"stderr": "The package `oss2` requires `crcmod>=1.7`, but it's not installed",
},
"uv_project_sync_check": {"returncode": 0},
}
if variant == "v3":
common.update(
{
"gil_disabled": False,
"gil_enabled": True,
"thread_inherit_context": 0,
"has_zhconv_fast": False,
"gil_enabled_after_imports": True,
"packages": {
"bcrypt": "4.3.0",
"brotli": "1.2.0",
"crcmod": None,
"crcmod-plus": "2.3.1",
"lxml": "6.1.2",
"orjson": "3.12.0",
"psycopg": None,
"psycopg2-binary": "2.9.12",
"zhconv-rs": "0.4.1",
},
"native": {
"crcmod_extension": True,
},
"imports": {
"moviepilot-rust": {"imported": True, "gil_after": True},
"psycopg2-binary": {"imported": True, "gil_after": True},
},
}
)
else:
common.update(
{
"gil_disabled": True,
"gil_enabled": False,
"thread_inherit_context": 0,
"has_zhconv_fast": True,
"gil_enabled_after_imports": False,
"packages": {
"bcrypt": "5.0.0",
"brotli": "1.2.0",
"crcmod": None,
"lxml": "7.0.0b1",
"orjson": "3.12.0",
"crcmod-plus": "2.3.1",
"psycopg": "3.3.4",
"psycopg2-binary": None,
"zhconv-rs": None,
},
"native": {
"crcmod_extension": True,
"psycopg_impl": "c",
},
"imports": {
"moviepilot-rust": {"imported": True, "gil_after": False},
"psycopg": {"imported": True, "gil_after": False},
},
}
)
expected_imports = {
"asyncpg",
"bcrypt",
"brotli",
"crcmod-plus",
"cryptography",
"greenlet",
"lxml",
"moviepilot-rust",
"orjson",
"oss2",
"pillow",
"pillow-avif-plugin",
"pydantic-core",
"site-resource",
"zstandard",
}
expected_imports.update(
{"psycopg2-binary", "zhconv-rs"} if variant == "v3" else {"psycopg"}
)
common["imports"] = {
name: {"imported": True, "gil_after": variant == "v3"}
for name in expected_imports
}
return common
def sample(harness, variant: str, index: int, multiplier: float = 1.0) -> dict:
"""构造可汇总的真实字段形状。"""
checksum = "same-checksum"
application = {
"rust_on": {
"seconds": 0.8 * multiplier,
"checksum": checksum,
}
}
if variant == "v3":
application["rust_off"] = {"seconds": 1.0, "checksum": checksum}
throughput = 100.0 if variant == "v3" else 120.0
return {
"variant": variant,
"sample_index": index,
"startup": {
"ready_seconds": 10.0 * multiplier,
"idle": {
"engine": {"working_set_bytes": 100_000_000},
"processes": {
"totals": {
"rss_kib": 100_000,
"pss_kib": 90_000,
"uss_kib": 80_000,
"threads": 20,
},
"main_python": {"pid": 1},
},
},
"api": {
endpoint: {
"status": 200,
"p50_ms": 1.0,
"p95_ms": 2.0,
"max_ms": 3.0,
**(
{
"runtime": {
"gil_enabled": variant == "v3",
"rust_enabled": True,
"rust_required": variant == "v3t",
}
}
if endpoint == "system_env"
else {}
),
}
for endpoint in (
"health_ready",
"dashboard_statistic",
"subscribe_list",
"system_env",
)
},
},
"hotspots": {
"fixture_sha256": harness.FIXTURE_SHA256,
"jieba_cut": {"available": True},
"gil_enabled_after_hotspots": variant == "v3",
"application": application,
"rust_concurrency": {
"1": {"throughput_ops_s": throughput, "checksum": checksum},
"32": {"throughput_ops_s": throughput, "checksum": checksum},
},
"python_concurrency": {
"1": {"throughput_ops_s": throughput, "checksum": checksum},
"32": {"throughput_ops_s": throughput, "checksum": checksum},
},
},
"postgresql": {
"probe": "app.db.engine._sync_postgresql_driver",
"result": {
"driver": None if variant == "v3" else "psycopg",
"scheme": "postgresql" if variant == "v3" else "postgresql+psycopg",
},
},
"sqlite": {
"sync": {"throughput_ops_s": 1000.0, "checksum": checksum},
"async": {"throughput_ops_s": 900.0, "checksum": checksum},
},
}
def result_for_evaluation(harness, ft_multiplier: float = 1.0) -> dict:
"""按平衡顺序构造三组 A/B。"""
samples = [
sample(harness, variant, index, ft_multiplier if variant == "v3t" else 1.0)
for variant, index in harness.SAMPLE_ORDER
]
return {
"samples": samples,
"workers": [1, 32],
"thresholds": {
"max_startup_ratio": 1.25,
"max_v3_rust_over_python_ratio": 1.10,
"max_v3t_rust_over_v3_rust_ratio": 1.25,
"min_ft_max_worker_throughput_ratio": 1.05,
"max_idle_memory_ratio": 1.25,
"max_api_p95_ratio": 1.25,
},
"preflight": {
variant: {"payload": valid_preflight(variant)}
for variant in ("v3", "v3t")
},
"images": {
"v3": {"size_bytes": 600_000_000},
"v3t": {"size_bytes": 630_000_000},
},
}
def test_digest_inputs_and_public_image_names_are_strict() -> None:
"""拒绝可变 tag、短 digest、旧 v3t 名称及参数传反。"""
harness = load_harness("free_threaded_ab_images")
standard = digest_image("moviepilot-v3", "a")
free_threaded = digest_image("moviepilot-v3t", "b")
assert harness.immutable_image(standard) == standard
harness.assert_expected_repository(standard, "moviepilot-v3")
harness.assert_expected_repository(free_threaded, "moviepilot-v3t")
for invalid in ("moviepilot-v3:latest", "moviepilot-v3@sha256:abc"):
with pytest.raises(argparse.ArgumentTypeError):
harness.immutable_image(invalid)
with pytest.raises(harness.HarnessInvalid, match="moviepilot-v3t"):
harness.assert_expected_repository(
digest_image("moviepilot-v3-ft", "c"), "moviepilot-v3t"
)
def test_local_image_id_is_an_immutable_offline_fallback() -> None:
"""未拉取时允许用本地 image ID 验收尚未发布的候选。"""
harness = load_harness("free_threaded_ab_local_image")
digest = f"sha256:{'a' * 64}"
reference = f"moviepilot-v3@{digest}"
image = SimpleNamespace(
id=digest,
attrs={
"Id": digest,
"RepoDigests": [],
"Config": {
"Labels": {
"org.moviepilot.source-revision": "local-source",
"org.opencontainers.image.version": "3.0.0-local",
}
},
},
)
images = Mock()
images.get.side_effect = [RuntimeError("no manifest digest"), image]
client = SimpleNamespace(images=images)
identity = harness.image_identity(client, reference, pull=False)
assert identity["image_id"] == digest
assert identity["runtime_reference"] == digest
assert identity["source_revision"] == "local-source"
assert identity["version"] == "3.0.0-local"
assert images.get.call_args_list[1].args == (digest,)
def test_docker_client_uses_current_cli_context_when_default_socket_fails(
monkeypatch,
) -> None:
"""Docker Desktop 等非默认 socket 由当前 CLI context 统一定位。"""
harness = load_harness("free_threaded_ab_docker_context")
monkeypatch.delenv("DOCKER_HOST", raising=False)
default_client = Mock()
default_client.ping.side_effect = RuntimeError("default socket missing")
context_client = Mock()
docker_module = SimpleNamespace(
from_env=Mock(return_value=default_client),
DockerClient=Mock(return_value=context_client),
)
monkeypatch.setattr(harness, "docker", docker_module)
run = Mock(return_value=SimpleNamespace(stdout="unix:///current/docker.sock\n"))
monkeypatch.setattr(harness.subprocess, "run", run)
assert harness.require_docker_client() is context_client
docker_module.DockerClient.assert_called_once_with(
base_url="unix:///current/docker.sock"
)
context_client.ping.assert_called_once_with()
def test_fixture_and_sample_order_are_stable() -> None:
"""固定 seed、内容 hash 和交替顺序防止样本漂移。"""
harness = load_harness("free_threaded_ab_fixture")
assert harness.fixture_hash(harness.build_fixture()) == harness.FIXTURE_SHA256
assert harness.FIXTURE_SHA256 == harness.EXPECTED_FIXTURE_SHA256
assert len(harness.FIXTURE) == 64
assert harness.SAMPLE_ORDER == (
("v3", 1),
("v3t", 1),
("v3t", 2),
("v3", 2),
("v3", 3),
("v3t", 3),
)
def test_preflight_enforces_runtime_and_native_profiles() -> None:
"""标准与 FT 镜像必须满足互斥 ABI、GIL、Rust 和原生依赖合同。"""
harness = load_harness("free_threaded_ab_preflight")
assert harness.validate_preflight("v3", valid_preflight("v3")) == []
assert harness.validate_preflight("v3t", valid_preflight("v3t")) == []
leaked = valid_preflight("v3")
leaked["has_zhconv_fast"] = True
assert any("has_zhconv_fast" in item for item in harness.validate_preflight("v3", leaked))
unsafe = valid_preflight("v3t")
unsafe["gil_enabled_after_imports"] = True
unsafe["packages"]["psycopg2-binary"] = "2.9.12"
errors = harness.validate_preflight("v3t", unsafe)
assert any("GIL" in item or "gil_enabled_after_imports" in item for item in errors)
assert any("标准原生依赖" in item for item in errors)
incomplete = valid_preflight("v3t")
incomplete["imports"].pop("lxml")
errors = harness.validate_preflight("v3t", incomplete)
assert any("缺少核心组件导入结果" in item and "lxml" in item for item in errors)
def test_evaluation_distinguishes_invalid_regression_and_pass() -> None:
"""合同错误优先 invalid,有效性能失败才归为 regression。"""
harness = load_harness("free_threaded_ab_evaluation")
summary, invalid, regressions = harness.evaluate_samples(result_for_evaluation(harness))
assert summary["ratios"]["ft_max_worker_throughput_over_v3"] == pytest.approx(1.2)
assert set(summary["application_seconds"]) == {
"v3_python",
"v3_rust",
"v3t_rust",
}
assert summary["ratios"]["v3_rust_over_python"] == pytest.approx(0.8)
assert summary["ratios"]["v3t_rust_over_v3_rust"] == pytest.approx(1.0)
assert summary["installed_packages"]["v3"]["count"] == 1
assert invalid == []
assert regressions == []
regression = result_for_evaluation(harness, ft_multiplier=1.5)
_, invalid, regressions = harness.evaluate_samples(regression)
assert invalid == []
assert any("startup" in item for item in regressions)
assert any("V3t Rust" in item for item in regressions)
v3_rust_regression = result_for_evaluation(harness)
for item in v3_rust_regression["samples"]:
if item["variant"] == "v3":
item["hotspots"]["application"]["rust_on"]["seconds"] = 1.2
_, invalid, regressions = harness.evaluate_samples(v3_rust_regression)
assert invalid == []
assert any("标准镜像启用 Rust" in item for item in regressions)
broken = result_for_evaluation(harness)
broken["samples"][0]["postgresql"]["result"]["scheme"] = "postgresql+psycopg"
summary, invalid, regressions = harness.evaluate_samples(broken)
assert summary == {}
assert any("PostgreSQL" in item for item in invalid)
assert regressions == []
incomplete_api = result_for_evaluation(harness)
incomplete_api["samples"][0]["startup"]["api"].pop("subscribe_list")
summary, invalid, regressions = harness.evaluate_samples(incomplete_api)
assert summary == {}
assert any("缺少 API 样本" in item and "subscribe_list" in item for item in invalid)
assert regressions == []
def test_markdown_and_exit_codes_preserve_machine_verdict(tmp_path: Path, monkeypatch) -> None:
"""JSON verdict、Markdown 摘要与 0/1/2 进程状态保持一致。"""
harness = load_harness("free_threaded_ab_exit")
base = {
"schema_version": harness.SCHEMA_VERSION,
"fixture": {"sha256": harness.FIXTURE_SHA256, "count": 64},
"source_revision": "abc123",
"images": {
"v3": {"reference": digest_image("moviepilot-v3", "a")},
"v3t": {"reference": digest_image("moviepilot-v3t", "b")},
},
"summary": {},
"invalid_reasons": [],
"regressions": [],
}
markdown = harness.build_markdown({**base, "verdict": "pass"})
assert "moviepilot-v3t@sha256" in markdown
assert harness.FIXTURE_SHA256 in markdown
evaluated = result_for_evaluation(harness)
summary, invalid, regressions = harness.evaluate_samples(evaluated)
report = harness.build_markdown(
{
**base,
"verdict": "pass",
"summary": summary,
"invalid_reasons": invalid,
"regressions": regressions,
}
)
assert "V3 + Python | V3 + Rust | V3t + Rust" in report
assert "Pure Python CPU probe" in report
assert "Direct Rust ABI probe" in report
argv = [
"--standard-image",
digest_image("moviepilot-v3", "a"),
"--free-threaded-image",
digest_image("moviepilot-v3t", "b"),
"--campaign",
"fake",
"--output-dir",
str(tmp_path),
]
for verdict, expected in (("pass", 0), ("regression", 1), ("invalid", 2)):
monkeypatch.setattr(
harness,
"execute_campaign",
lambda _args, value=verdict: {"verdict": value},
)
assert harness.main(argv) == expected
+14 -2
View File
@@ -1,9 +1,21 @@
from app.foundation.text import cut
def test_cut_accepts_legacy_hmm_argument():
"""验证分词封装支持旧 jieba.cut 的 HMM 参数名。"""
def test_cut_accepts_hmm_argument():
"""验证分词入口支持公开的 HMM 参数名。"""
words = cut("台湾后台测试", HMM=False)
assert "".join(words) == "台湾后台测试"
assert "后台" in words
def test_cut_preserves_full_mode_contract():
assert cut("南京市长江大桥", cut_all=True) == [
"南京",
"南京市",
"京市",
"市长",
"长江",
"长江大桥",
"大桥",
]
+13
View File
@@ -199,6 +199,19 @@ def test_lifespan_propagates_logger_nonconvergence(monkeypatch):
)
def test_runtime_gil_status_warns_when_free_threaded_runtime_enables_gil(monkeypatch):
"""free-threaded 运行时退化为 GIL 模式时必须留下明确诊断。"""
monkeypatch.setattr(lifecycle, "is_free_threaded_runtime", lambda: True)
monkeypatch.setattr(lifecycle, "is_gil_enabled", lambda: True)
warning = MagicMock()
monkeypatch.setattr(lifecycle.logger, "warning", warning)
lifecycle._log_runtime_gil_status()
warning.assert_called_once()
assert "已启用 GIL" in warning.call_args.args[0]
def test_lifespan_validation_failure_does_not_clear_outer_loop_owner(monkeypatch):
"""当前生命周期尚未取得 owner 时,启动失败不得清理外层登记。"""
_patch_lifespan(monkeypatch)
+26
View File
@@ -7,6 +7,7 @@ from app.adapters.system.package import (
PackageInstallRequest,
build_package_install_env,
build_package_install_strategies,
build_project_sync_strategies,
redact_url,
)
@@ -129,6 +130,31 @@ def test_build_strategies_passes_all_manifests_to_one_uv_process(tmp_path):
assert command[second_requirement + 1] == str(legacy)
def test_project_sync_selects_current_runtime_group(tmp_path, monkeypatch):
"""主项目恢复必须显式选择当前解释器对应的互斥运行依赖组。"""
project = tmp_path / "project"
project.mkdir()
pyproject = project / "pyproject.toml"
pyproject.write_text("[project]\nname='moviepilot'\nversion='0'\n", encoding="utf-8")
uv_bin = tmp_path / "venv" / "bin" / "uv"
uv_bin.parent.mkdir(parents=True)
uv_bin.write_text("", encoding="utf-8")
request = PackageInstallRequest(
dependency_files=(pyproject,),
python_bin=tmp_path / "venv" / "bin" / "python",
)
monkeypatch.setattr(
"app.adapters.system.package.runtime_sync_arguments",
lambda: ("--no-default-groups", "--group", "runtime-free-threaded"),
)
command = build_project_sync_strategies(request)[0].command
assert command.count("--group") == 1
assert command[command.index("--group") + 1] == "runtime-free-threaded"
assert "--no-default-groups" in command
def test_redact_url_removes_userinfo():
assert redact_url("https://user:pass@mirror.example/simple") == "https://mirror.example/simple"
+114 -4
View File
@@ -1302,6 +1302,79 @@ demo = { index = "private" }
assert "主程序核心依赖" in message
assert "fastapi" in message
@pytest.mark.parametrize(
("runtime_group", "installed_package", "installed_version", "requirement"),
[
("runtime-standard", "lxml", "6.1.2", "lxml>=7.1"),
("runtime-free-threaded", "psycopg", "3.3.4", "psycopg>=3.4"),
],
)
def test_uv_install_rejects_runtime_profile_root_upgrade(
self,
runtime_group,
installed_package,
installed_version,
requirement,
):
"""插件不得升级当前解释器 profile 中经过 ABI/GIL 验证的根包。"""
from app.adapters.external.market import PluginHelper
with tempfile.TemporaryDirectory() as temp_dir:
requirements_file = Path(temp_dir) / "requirements.txt"
requirements_file.write_text(f"{requirement}\n", encoding="utf-8")
with patch(
"app.runtime.dependencies.runtime_dependency_group",
return_value=runtime_group,
):
success, message = PluginHelper._PluginHelper__validate_runtime_dependency_conflicts(
requirements_file,
{installed_package: Version(installed_version)},
)
assert not success
assert "主程序核心依赖" in message
assert installed_package in message
def test_runtime_healthcheck_preserves_plugin_upgrade_semantics(self, tmp_path):
"""运行环境诊断不应把允许的插件依赖升级强制还原到宿主锁版本。"""
from app.adapters.external.market import PluginHelper
uv_bin = tmp_path / "uv"
with patch("app.adapters.external.market.find_uv", return_value=uv_bin):
command = PluginHelper._PluginHelper__build_runtime_uv_check_command()
assert command == [
str(uv_bin),
"pip",
"check",
"--python",
sys.executable,
]
def test_plugin_runtime_healthcheck_uses_full_capability_probe(self, tmp_path):
"""共享 venv 发生变更时必须验证 ABI 敏感原生能力。"""
from app.adapters.external.market import PluginHelper
uv_bin = tmp_path / "uv"
commands = []
def execute(command):
commands.append(command)
return True, "ok"
with patch("app.adapters.external.market.find_uv", return_value=uv_bin), patch(
"app.adapters.external.market.SystemUtils.execute_with_subprocess",
side_effect=execute,
):
PluginHelper._PluginHelper__run_runtime_healthcheck()
assert [
sys.executable,
"-m",
"app.doctor.dependencies",
"--full",
] in commands
def test_uv_install_allows_changing_non_runtime_dependency(self):
"""
验证非主程序依赖即便已安装插件后续仍可调整其版本约束
@@ -1415,7 +1488,7 @@ demo = { index = "private" }
if uv_check_count == 2:
return False, "broken"
return True, "healthy"
if len(cmd) >= 3 and cmd[1] == "-c":
if len(cmd) >= 3 and cmd[1:3] == ["-m", "app.doctor.dependencies"]:
return True, "probe ok"
raise AssertionError(f"unexpected command: {cmd}")
@@ -1425,7 +1498,9 @@ demo = { index = "private" }
return_value={"fastapi": Version("0.115.14")}
):
with patch("app.adapters.external.market.SystemUtils.execute_with_subprocess", side_effect=fake_execute):
with patch("app.adapters.system.package.find_uv", return_value=uv_bin):
with patch("app.adapters.external.market.find_uv", return_value=uv_bin), patch(
"app.adapters.system.package.find_uv", return_value=uv_bin
):
success, message = PluginHelper.install_packages_with_fallback(requirements_file)
assert not success
@@ -1444,11 +1519,17 @@ demo = { index = "private" }
health_snapshots = [
{
"uv check": (False, "existing issue before install"),
"uv check": (
False,
"before summary\nThe package `oss2` requires `crcmod>=1.7`, but it's not installed",
),
"核心依赖导入检查": (True, "ok"),
},
{
"uv check": (False, "same issue with different command summary"),
"uv check": (
False,
"after summary\nThe package `oss2` requires `crcmod>=1.7`, but it's not installed",
),
"核心依赖导入检查": (True, "ok"),
},
]
@@ -1527,6 +1608,35 @@ demo = { index = "private" }
assert "核心依赖导入检查失败" in message
repair_mock.assert_called_once()
def test_preexisting_uv_diagnostic_does_not_hide_new_package_error(self):
"""既有第三方元数据告警不能遮蔽插件安装新增的依赖错误。"""
from app.adapters.external.market import PluginHelper
existing_error = "The package `oss2` requires `crcmod>=1.7`, but it's not installed"
added_error = "The package `demo` requires `missing>=1`, but it's not installed"
message = PluginHelper._PluginHelper__runtime_health_regression_message(
{"uv check": (False, existing_error)},
{"uv check": (False, f"{existing_error}\n{added_error}")},
)
assert added_error in message
assert existing_error not in message
def test_uv_diagnostic_parser_handles_executor_prefix(self):
"""执行器把首条错误拼在命令摘要后时仍应识别完整诊断项。"""
from app.adapters.external.market import PluginHelper
package_error = "The package `demo` requires `missing>=1`, but it's not installed"
message = f"命令:uv pip check,执行失败,返回码:1,错误输出:{package_error}"
issues = PluginHelper._PluginHelper__runtime_health_error_lines(
"uv check",
message,
)
assert issues == {package_error}
def test_failed_install_repairs_runtime_before_returning_error(self):
"""
安装策略失败后如果主运行环境异常应先恢复主程序依赖再返回失败
+29
View File
@@ -189,6 +189,35 @@ async def test_quiesce_timeout_retains_admitted_owner_and_nested_reload(
assert manager.finalize_plugins() is True
def test_reload_attributes_gil_transition_to_plugin(
plugin_manager: PluginManager,
monkeypatch,
) -> None:
"""运行期插件加载导致 GIL 退化时应记录插件归因。"""
states = iter((False, True))
plugin_manager._plugin_lifecycle.reload = MagicMock(
return_value=PluginRuntimeStatus.ACTIVE
)
warning = MagicMock()
monkeypatch.setattr(
"app.runtime.extensions.plugin_manager.is_free_threaded_runtime",
lambda: True,
)
monkeypatch.setattr(
"app.runtime.extensions.plugin_manager.is_gil_enabled",
lambda: next(states),
)
monkeypatch.setattr(
"app.runtime.extensions.plugin_manager.logger.warning",
warning,
)
assert plugin_manager.reload_plugin("DemoPlugin") is PluginRuntimeStatus.ACTIVE
warning.assert_called_once()
assert warning.call_args.args[1] == "DemoPlugin"
@pytest.mark.asyncio
async def test_quiesce_inside_mutation_fails_without_sealing(
plugin_manager: PluginManager,
+3 -1
View File
@@ -13,7 +13,7 @@ from app.sdk.network import RequestUtils, RssHelper, SitesHelper
from app.sdk.plugins import ModuleManager, PluginManager
from app.sdk.services import NotificationHelper
from app.sdk.utilities import StringUtils as UtilityStringUtils
from app.sdk.utilities import decrypt, encrypt
from app.sdk.utilities import convert, decrypt, encrypt
PROJECT_ROOT = Path(__file__).parents[1]
@@ -30,6 +30,7 @@ def test_sdk_exports_canonical_plugin_interfaces():
"app.domain.string"
).StringUtils
from app.foundation.crypto import CryptoJsUtils
from app.foundation.text import convert as canonical_convert
from app.runtime.extensions.module_manager import ModuleManager as CanonicalModuleManager
from app.runtime.extensions.plugin_manager import PluginManager as CanonicalPluginManager
from app.adapters.network.http import RequestUtils as CanonicalRequestUtils
@@ -61,6 +62,7 @@ def test_sdk_exports_canonical_plugin_interfaces():
assert UtilityStringUtils is LegacyDomainStringUtils
assert decrypt is CryptoJsUtils.decrypt
assert encrypt is CryptoJsUtils.encrypt
assert convert is canonical_convert
assert ModuleManager is CanonicalModuleManager
assert PluginManager is CanonicalPluginManager
assert CanonicalPluginManager.__module__ == "app.runtime.extensions.plugin_manager"
+120
View File
@@ -0,0 +1,120 @@
"""PostgreSQL 驱动 A/B harness 的无 Docker 合同测试。"""
from __future__ import annotations
import argparse
import importlib.util
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "scripts" / "perf" / "postgresql_driver_ab.py"
def load_harness():
"""从脚本路径加载 harness,避免把 scripts 变成运行时 package。"""
spec = importlib.util.spec_from_file_location("postgresql_driver_ab_test", SCRIPT)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def sample(variant: str, value: float) -> dict:
"""构造满足汇总合同的单个样本。"""
return {
"variant": variant,
"postgresql_settings": {"server_version": "18.4"},
"sql_contract": {"fixture_sha256": "fixture", "sql_sha256": "sql"},
"serial_query": {"throughput_ops_s": value},
"concurrent_query": {"throughput_ops_s": value * 2},
"batch_write": {"seconds": 10 / value},
"long_transaction": {"parallel_short_query_seconds": 1 / value},
}
def test_campaign_and_image_arguments_are_strict() -> None:
"""公开参数不得生成不安全名称或接受可变镜像标签。"""
harness = load_harness()
assert harness.normalize_campaign("PG-AB.1") == "pg-ab.1"
with pytest.raises(argparse.ArgumentTypeError):
harness.normalize_campaign("../escape")
with pytest.raises(argparse.ArgumentTypeError):
harness.immutable_image("moviepilot-v3:latest")
assert harness.immutable_image(f"moviepilot-v3@sha256:{'a' * 64}")
def test_sample_order_covers_all_balanced_permutations() -> None:
"""六轮中每个方案必须各占两次首、中、末位置。"""
harness = load_harness()
orders = [harness.sample_order(index) for index in range(6)]
assert len(set(orders)) == 6
for variant in harness.VARIANTS:
assert [order[0] for order in orders].count(variant) == 2
assert [order[1] for order in orders].count(variant) == 2
assert [order[2] for order in orders].count(variant) == 2
def test_validate_sample_enforces_driver_and_gil_contracts() -> None:
"""V3t 必须使用 psycopg C 且在基准结束后仍关闭 GIL。"""
harness = load_harness()
valid = {
"runtime": {
"python_version": "3.14.7",
"implementation": "c",
"gil_before_import": False,
"gil_after_import": False,
"gil_after_benchmark": False,
},
"long_transaction": {
"parallel_short_query_value": "payload-00002",
"parallel_short_query_seconds": 0.01,
"contended_update_seconds": 0.2,
"requested_seconds": 0.25,
},
}
harness.validate_sample("v3t_psycopg3_c", valid)
valid["runtime"]["gil_after_benchmark"] = True
with pytest.raises(harness.HarnessInvalid, match="GIL"):
harness.validate_sample("v3t_psycopg3_c", valid)
def test_summary_keeps_three_variants_separate() -> None:
"""汇总必须保留三方案,不能把两个标准 V3 驱动合并。"""
harness = load_harness()
samples = []
for variant, _driver in harness.VARIANTS:
samples.extend((sample(variant, 10.0), sample(variant, 20.0), sample(variant, 30.0)))
summary = harness.summarize(samples)
assert set(summary["metrics"]) == {variant for variant, _ in harness.VARIANTS}
assert summary["metrics"]["v3_psycopg2"]["serial_query_throughput_ops_s"] == 20
assert (
summary["ratios"]["v3t_psycopg3_c"]
["serial_query_throughput_over_v3_psycopg2"]
== 1
)
def test_campaign_requires_complete_matching_results() -> None:
"""三方案缺样本或业务结果不同都不能生成性能结论。"""
harness = load_harness()
samples = []
for variant, _driver in harness.VARIANTS:
for _ in range(3):
item = sample(variant, 10.0)
item["serial_query"]["checksum"] = "same"
item["concurrent_query"]["checksums"] = ["same"]
samples.append(item)
harness.validate_campaign(samples, rounds=3)
samples[-1]["serial_query"]["checksum"] = "different"
with pytest.raises(harness.HarnessInvalid, match="校验和"):
harness.validate_campaign(samples, rounds=3)
+171 -20
View File
@@ -3,19 +3,23 @@
from datetime import date
from pathlib import Path
import pytest
from ruamel.yaml import YAML
from scripts.normalize_audit_requirements import normalize_requirements
ROOT = Path(__file__).resolve().parents[1]
DOCKERFILE = ROOT / "docker" / "Dockerfile"
RELEASE_WORKFLOW = ROOT / ".github" / "workflows" / "build-v3.yml"
BETA_WORKFLOW = ROOT / ".github" / "workflows" / "beta.yml"
TRIVY_IGNORE = ROOT / ".trivyignore.yaml"
def _load_workflow() -> dict:
"""以 YAML 1.2 解析正式发布工作流。"""
def _load_workflow(path: Path = RELEASE_WORKFLOW) -> dict:
"""以 YAML 1.2 解析镜像发布工作流。"""
yaml = YAML(typ="safe")
return yaml.load(RELEASE_WORKFLOW.read_text(encoding="utf-8"))
return yaml.load(path.read_text(encoding="utf-8"))
def _steps_by_name(workflow: dict) -> dict[str, dict]:
@@ -31,29 +35,77 @@ def test_base_image_uses_refreshable_tag_and_apt_does_not_upgrade_in_place() ->
"""基础镜像允许获得上游更新,构建阶段不得无边界升级整套 Debian。"""
dockerfile = DOCKERFILE.read_text(encoding="utf-8")
assert "FROM python:3.14.7-slim-trixie AS base" in dockerfile
assert "python:3.14.7-slim-trixie@sha256:" not in dockerfile
assert 'ARG MOVIEPILOT_PYTHON_VERSION="3.14.7"' in dockerfile
assert "FROM python:${MOVIEPILOT_PYTHON_VERSION}-slim-trixie AS base" in dockerfile
assert "python:${MOVIEPILOT_PYTHON_VERSION}-slim-trixie@sha256:" not in dockerfile
free_threaded_stage = dockerfile.split(
"FROM prepare_venv_common AS prepare_venv_free-threaded",
maxsplit=1,
)[1]
assert "ARG MOVIEPILOT_PYTHON_VERSION" in free_threaded_stage
assert 'uv python install --no-bin "${MOVIEPILOT_PYTHON_VERSION}t"' in free_threaded_stage
assert "apt-get upgrade" not in dockerfile
assert "\n util-linux \\\n" in dockerfile
def test_release_audits_locked_runtime_dependencies_before_building() -> None:
"""发布构建前必须审计带哈希的锁定运行依赖。"""
workflow = _load_workflow()
steps = workflow["jobs"]["Docker-build"]["steps"]
names = [step.get("name") for step in steps]
audit = _steps_by_name(workflow)["Audit locked Python dependencies"]["run"]
"""正式版和 Beta 构建前必须分别审计两套锁定运行依赖。"""
for workflow_path in (RELEASE_WORKFLOW, BETA_WORKFLOW):
workflow = _load_workflow(workflow_path)
steps = workflow["jobs"]["Docker-build"]["steps"]
names = [step.get("name") for step in steps]
audit = _steps_by_name(workflow)["Audit locked Python dependencies"]["run"]
assert names.index("Audit locked Python dependencies") < names.index("Build amd64 candidate")
assert "uv export --quiet --locked --no-dev --no-emit-project" in audit
assert "pip-audit==2.10.1" in audit
for option in ("--require-hashes", "--disable-pip", "--strict"):
assert option in audit
first_candidate = next(name for name in names if name and name.startswith("Build "))
assert names.index("Audit locked Python dependencies") < names.index(first_candidate)
assert "--group runtime-standard" in audit
assert "--group runtime-free-threaded" in audit
assert "scripts/normalize_audit_requirements.py" in audit
assert "pip-audit==2.10.1" in audit
for option in ("--require-hashes", "--no-deps", "--disable-pip", "--strict"):
assert option in audit
def test_direct_url_audit_requirement_uses_version_from_matching_lock_source(tmp_path: Path) -> None:
"""URL 依赖的漏洞审计版本必须来自同名且同来源的锁文件条目。"""
lock_file = tmp_path / "uv.lock"
lock_file.write_text(
"""
version = 1
[[package]]
name = "Brotli"
version = "1.2.0"
source = { url = "https://example.com/brotli.tar.gz" }
""",
encoding="utf-8",
)
exported = (
"brotli @ https://example.com/brotli.tar.gz ; python_version >= '3.14' \\\n"
" # via httpx\n"
)
normalized = normalize_requirements(exported, lock_file)
assert "brotli==1.2.0 ; python_version >= '3.14' \\" in normalized
assert "@ https://example.com/brotli.tar.gz" not in normalized
def test_direct_url_audit_requirement_rejects_unlocked_source(tmp_path: Path) -> None:
"""不能把未匹配锁文件来源的 URL 依赖伪装为已审计版本。"""
lock_file = tmp_path / "uv.lock"
lock_file.write_text("version = 1\npackage = []\n", encoding="utf-8")
with pytest.raises(ValueError, match="无法在锁文件中定位精确版本"):
normalize_requirements("demo @ https://example.com/demo.tar.gz\n", lock_file)
def test_release_scans_both_architectures_before_registry_login_and_publish() -> None:
"""任一架构的最终漏洞扫描失败时都不得登录仓库发布镜像"""
"""两个 Python 变体的各架构扫描都必须在登录仓库发布前完成"""
workflow = _load_workflow()
trivy_env = workflow["jobs"]["Docker-build"]["env"]
assert trivy_env["TRIVY_SKIP_DIRS"] == "/usr/share/java"
assert trivy_env["TRIVY_SKIP_JAVA_DB_UPDATE"] == "true"
steps = workflow["jobs"]["Docker-build"]["steps"]
names = [step.get("name") for step in steps]
indexed = _steps_by_name(workflow)
@@ -61,6 +113,14 @@ def test_release_scans_both_architectures_before_registry_login_and_publish() ->
expected_candidates = {
"Build amd64 candidate": ("linux/amd64", "moviepilot-v3-candidate:linux-amd64"),
"Build arm64 candidate": ("linux/arm64/v8", "moviepilot-v3-candidate:linux-arm64"),
"Build free-threaded amd64 candidate": (
"linux/amd64",
"moviepilot-v3t-candidate:linux-amd64",
),
"Build free-threaded arm64 candidate": (
"linux/arm64/v8",
"moviepilot-v3t-candidate:linux-arm64",
),
}
for name, (platform, tag) in expected_candidates.items():
build = indexed[name]["with"]
@@ -70,10 +130,14 @@ def test_release_scans_both_architectures_before_registry_login_and_publish() ->
assert build["tags"] == tag
assert build["pull"] is True
assert "no-cache-filters" not in build
expected_variant = "free-threaded" if "free-threaded" in name else "standard"
assert f"MOVIEPILOT_PYTHON_VARIANT={expected_variant}" in build["build-args"]
for name in (
"Scan amd64 candidate vulnerabilities",
"Scan arm64 candidate vulnerabilities",
"Scan free-threaded amd64 candidate vulnerabilities",
"Scan free-threaded arm64 candidate vulnerabilities",
):
scan = indexed[name]
assert scan["with"]["cache-dir"] == "${{ runner.temp }}/trivy"
@@ -91,11 +155,19 @@ def test_release_scans_both_architectures_before_registry_login_and_publish() ->
"exit-code": 1,
}.items()
last_scan = names.index("Scan arm64 candidate vulnerabilities")
last_scan = max(
names.index(name)
for name in (
"Scan amd64 candidate vulnerabilities",
"Scan arm64 candidate vulnerabilities",
"Scan free-threaded amd64 candidate vulnerabilities",
"Scan free-threaded arm64 candidate vulnerabilities",
)
)
assert last_scan < names.index("Login DockerHub")
assert last_scan < names.index("Login GitHub Container Registry")
assert last_scan < names.index("Publish multi-architecture image")
assert last_scan < names.index("Publish free-threaded multi-architecture image")
def test_vulnerability_ignores_are_scoped_justified_and_time_bounded() -> None:
"""漏洞豁免必须限定制品范围,并保留复查期限和接受理由。"""
@@ -118,5 +190,84 @@ def test_publish_reuses_scanned_architecture_caches_without_refreshing_base() ->
assert publish["platforms"] == "linux/amd64\nlinux/arm64/v8\n"
assert publish["push"] is True
assert publish["pull"] is False
assert "scope=moviepilot-v3-docker-amd64" in publish["cache-from"]
assert "scope=moviepilot-v3-docker-arm64" in publish["cache-from"]
assert "scope=moviepilot-v3-standard-docker-amd64" in publish["cache-from"]
assert "scope=moviepilot-v3-standard-docker-arm64" in publish["cache-from"]
def test_release_publishes_free_threaded_image_with_separate_metadata_and_cache() -> None:
"""free-threaded 发布必须使用 v3t 命名、参数和独立缓存。"""
workflow = _load_workflow()
indexed = _steps_by_name(workflow)
metadata = indexed["Docker Meta free-threaded"]
publish = indexed["Publish free-threaded multi-architecture image"]
assert "moviepilot-v3t" in metadata["with"]["images"]
assert "MOVIEPILOT_PYTHON_VARIANT=free-threaded" in publish["with"]["build-args"]
assert "scope=moviepilot-v3t-docker-amd64" in publish["with"]["cache-from"]
assert "scope=moviepilot-v3t-docker-arm64" in publish["with"]["cache-from"]
def test_release_promotes_latest_only_after_both_versioned_images() -> None:
"""只有两个版本制品都发布成功后才可移动 latest 标签。"""
workflow = _load_workflow()
steps = workflow["jobs"]["Docker-build"]["steps"]
names = [step.get("name") for step in steps]
indexed = _steps_by_name(workflow)
assert "value=latest" not in indexed["Docker Meta"]["with"]["tags"]
assert "value=latest" not in indexed["Docker Meta free-threaded"]["with"]["tags"]
assert names.index("Publish multi-architecture image") < names.index("Promote latest image pair")
assert names.index("Publish free-threaded multi-architecture image") < names.index(
"Promote latest image pair"
)
promote = indexed["Promote latest image pair"]["run"]
assert "moviepilot-v3:latest" not in promote
assert '"${image}:latest"' in promote
assert '"${image}:${app_version}"' in promote
def test_beta_applies_the_same_variant_scan_and_publish_contract() -> None:
"""Beta 也必须在发布两个变体前完成各架构漏洞扫描。"""
workflow = _load_workflow(BETA_WORKFLOW)
trivy_env = workflow["jobs"]["Docker-build"]["env"]
assert trivy_env["TRIVY_SKIP_DIRS"] == "/usr/share/java"
assert trivy_env["TRIVY_SKIP_JAVA_DB_UPDATE"] == "true"
steps = workflow["jobs"]["Docker-build"]["steps"]
names = [step.get("name") for step in steps]
indexed = _steps_by_name(workflow)
assert workflow["on"]["workflow_dispatch"] is None
for name in (
"Build standard amd64 candidate",
"Build standard arm64 candidate",
"Build free-threaded amd64 candidate",
"Build free-threaded arm64 candidate",
):
assert indexed[name]["with"]["load"] is True
assert indexed[name]["with"]["push"] is False
scan_names = (
"Scan standard amd64 candidate vulnerabilities",
"Scan standard arm64 candidate vulnerabilities",
"Scan free-threaded amd64 candidate vulnerabilities",
"Scan free-threaded arm64 candidate vulnerabilities",
)
publish_names = (
"Publish standard multi-architecture image",
"Publish free-threaded multi-architecture image",
)
last_scan = max(names.index(name) for name in scan_names)
assert all(last_scan < names.index(name) for name in publish_names)
assert "MOVIEPILOT_PYTHON_VARIANT=standard" in indexed[publish_names[0]]["with"]["build-args"]
assert "MOVIEPILOT_PYTHON_VARIANT=free-threaded" in indexed[publish_names[1]]["with"]["build-args"]
assert "scope=moviepilot-v3-standard-docker-amd64" in indexed[publish_names[0]]["with"]["cache-from"]
assert "scope=moviepilot-v3t-docker-amd64" in indexed[publish_names[1]]["with"]["cache-from"]
assert "value=beta-${{ github.run_id }}-${{ github.run_attempt }}" in indexed["Docker Meta"]["with"]["tags"]
assert "value=beta-${{ github.run_id }}-${{ github.run_attempt }}" in indexed[
"Docker Meta free-threaded"
]["with"]["tags"]
assert all(names.index(name) < names.index("Promote beta image pair") for name in publish_names)
promote = indexed["Promote beta image pair"]["run"]
assert '"${image}:beta"' in promote
assert '"${image}:${candidate}"' in promote
+29
View File
@@ -1,5 +1,7 @@
from pathlib import Path
import pytest
from app.runtime.config import settings
from app.adapters.system.resource import (
ResourceHelper,
@@ -21,6 +23,33 @@ def test_resource_helper_uses_v3_only():
assert ResourceHelper._resource_target == Path("app/application/site")
@pytest.mark.parametrize(
("system", "machine", "gil_disabled", "expected"),
[
("Linux", "x86_64", 0, "sites.cpython-314-x86_64-linux-gnu.so"),
("Linux", "aarch64", 1, "sites.cpython-314t-aarch64-linux-gnu.so"),
("Darwin", "arm64", 1, "sites.cpython-314t-darwin.so"),
("Windows", "AMD64", 1, "sites.cp314t-win_amd64.pyd"),
],
)
def test_resource_helper_selects_runtime_abi(
monkeypatch,
system,
machine,
gil_disabled,
expected,
):
"""资源下载文件名必须区分普通解释器与 free-threaded ABI。"""
monkeypatch.setattr("app.adapters.system.resource.platform.system", lambda: system)
monkeypatch.setattr("app.adapters.system.resource.platform.machine", lambda: machine)
monkeypatch.setattr(
"app.adapters.system.resource.sysconfig.get_config_var",
lambda name: gil_disabled if name == "Py_GIL_DISABLED" else None,
)
assert ResourceHelper._get_needed_files()[-1] == expected
def test_resource_helper_preserves_no_argument_check_contract(monkeypatch):
"""旧插件无参数调用 check 时应使用启动层注入版本,不反向导入站点应用。"""
provider_calls = []
+106
View File
@@ -0,0 +1,106 @@
import tomllib
from pathlib import Path
from types import SimpleNamespace
import pytest
from app.doctor import dependencies as dependency_doctor
from app.foundation import environment
from app.runtime import dependencies
def test_free_threaded_runtime_tracks_interpreter_build(monkeypatch):
monkeypatch.setattr(
environment.sysconfig,
"get_config_var",
lambda name: 1 if name == "Py_GIL_DISABLED" else None,
)
assert environment.is_free_threaded_runtime() is True
def test_gil_status_tracks_current_interpreter_state(monkeypatch):
monkeypatch.setattr(environment.sys, "_is_gil_enabled", lambda: False)
assert environment.is_gil_enabled() is False
def test_runtime_dependency_group_tracks_interpreter_abi(monkeypatch):
monkeypatch.setattr(dependencies, "is_free_threaded_runtime", lambda: False)
assert dependencies.runtime_dependency_group() == "runtime-standard"
monkeypatch.setattr(dependencies, "is_free_threaded_runtime", lambda: True)
assert dependencies.runtime_dependency_group() == "runtime-free-threaded"
def test_runtime_requirements_include_project_and_active_group(tmp_path: Path, monkeypatch):
project_file = tmp_path / "pyproject.toml"
project_file.write_text(
"""
[project]
dependencies = ["shared==1"]
[dependency-groups]
runtime-standard = ["standard==2"]
runtime-free-threaded = ["free-threaded==3"]
""",
encoding="utf-8",
)
monkeypatch.setattr(
dependencies,
"runtime_dependency_group",
lambda: "runtime-free-threaded",
)
assert list(dependencies.iter_runtime_requirement_strings(project_file)) == [
"shared==1",
"free-threaded==3",
]
assert list(dependencies.iter_runtime_profile_requirement_strings(project_file)) == [
"free-threaded==3",
]
def test_runtime_profiles_share_gil_safe_crcmod_distribution():
project_file = Path(__file__).resolve().parents[1] / "pyproject.toml"
with project_file.open("rb") as file:
document = tomllib.load(file)
assert "crcmod-plus==2.3.1" in document["project"]["dependencies"]
groups = document["dependency-groups"]
assert all(
not requirement.lower().startswith("crcmod")
for group in ("runtime-standard", "runtime-free-threaded")
for requirement in groups[group]
)
assert {
"package": {"name": "oss2"},
"dependencies": ["crcmod"],
} in document["tool"]["uv"]["exclude-dependencies"]
def test_full_dependency_probe_rejects_psycopg_python_fallback(monkeypatch):
"""V3t 构建不得把 psycopg 纯 Python 实现误认为可发布能力。"""
modules = {
"moviepilot_rust": SimpleNamespace(
is_available=lambda: True,
jieba_cut=lambda _value: ["中文", "分词"],
zhconv_fast=lambda value, _target: value,
),
"crcmod.crcmod": SimpleNamespace(_usingExtension=True),
"psycopg": SimpleNamespace(pq=SimpleNamespace(__impl__="python")),
}
monkeypatch.setattr(
dependency_doctor,
"import_module",
lambda name: modules.get(name, SimpleNamespace()),
)
monkeypatch.setattr(
dependency_doctor.sysconfig,
"get_config_var",
lambda name: 1 if name == "Py_GIL_DISABLED" else None,
)
monkeypatch.setattr(dependency_doctor.sys, "_is_gil_enabled", lambda: False)
with pytest.raises(RuntimeError, match="psycopg C 实现不可用"):
dependency_doctor.main(full=True)
+13
View File
@@ -27,6 +27,7 @@ def test_rust_accel_runtime_switch_disables_fast_paths(monkeypatch):
RUST_ACCEL 关闭时即便扩展可用也应回退到 Python 路径
"""
monkeypatch.setattr(settings, "RUST_ACCEL", False)
monkeypatch.setattr(rust_accel, "is_required", lambda: False)
monkeypatch.setattr(rust_accel, "_moviepilot_rust", _DummyRustExtension())
assert rust_accel.is_available()
@@ -34,11 +35,23 @@ def test_rust_accel_runtime_switch_disables_fast_paths(monkeypatch):
assert rust_accel.parse_filter_rule("HDR") is None
def test_free_threaded_runtime_requires_rust_acceleration(monkeypatch):
"""free-threaded 运行时不能通过配置关闭 Rust 快路径。"""
monkeypatch.setattr(settings, "RUST_ACCEL", False)
monkeypatch.setattr(rust_accel, "is_required", lambda: True)
monkeypatch.setattr(rust_accel, "_moviepilot_rust", _DummyRustExtension())
assert rust_accel.is_config_enabled()
assert rust_accel.is_enabled()
assert rust_accel.status()["required"] is True
def test_rust_accel_status_reports_enabled_state(monkeypatch):
"""
状态接口应同时体现扩展可用性和配置开关后的实际启用状态
"""
monkeypatch.setattr(settings, "RUST_ACCEL", True)
monkeypatch.setattr(rust_accel, "is_required", lambda: False)
monkeypatch.setattr(rust_accel, "_moviepilot_rust", _DummyRustExtension())
assert rust_accel.status()["available"] is True
+38 -17
View File
@@ -7,26 +7,47 @@ def test_app_installs_known_oss2_invalid_escape_warning_filter():
"""
app 初始化过滤器应覆盖 oss2 的无效转义警告
"""
app._filter_third_party_startup_warnings()
action, message, category, module, lineno = warnings.filters[0]
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
app._filter_third_party_startup_warnings()
warnings.warn_explicit(
f'"{chr(92)}&" is an invalid escape sequence.',
SyntaxWarning,
filename="oss2/api.py",
lineno=703,
module="oss2.api",
)
assert action == "ignore"
assert message.match("invalid escape sequence '\\&'")
assert category is SyntaxWarning
assert module is None
assert lineno == 0
assert caught == []
def test_app_does_not_hide_other_invalid_escape_warnings():
"""其他无效转义仍应暴露,避免过滤器遮蔽新问题。"""
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
app._filter_third_party_startup_warnings()
warnings.warn_explicit(
f'"{chr(92)}q" is an invalid escape sequence.',
SyntaxWarning,
filename="app/example.py",
lineno=1,
module="app.example",
)
assert len(caught) == 1
def test_app_installs_google_genai_python314_warning_filter():
"""app 初始化过滤器应覆盖 Google GenAI SDK 的 Python 3.14 弃用警告。"""
app._filter_third_party_startup_warnings()
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
app._filter_third_party_startup_warnings()
warnings.warn_explicit(
"'_UnionGenericAlias' is deprecated and slated for removal in Python 3.17",
DeprecationWarning,
filename="google/genai/types.py",
lineno=1,
module="google.genai.types",
)
assert any(
action == "ignore"
and message.match("'_UnionGenericAlias' is deprecated and slated for removal in Python 3.17")
and category is DeprecationWarning
and module is not None
and module.match("google.genai.types")
and lineno == 0
for action, message, category, module, lineno in warnings.filters
)
assert caught == []
+46 -3
View File
@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, Mock, patch
from app.runtime.config import settings as runtime_settings
from app.testing import stub_modules
from app.testing.stub import restore_modules, snapshot_modules
def _stub(name: str, **attrs) -> tuple:
@@ -30,7 +31,7 @@ class _DummyError(Exception):
self.duration_ms = duration_ms
# import 期用占位模块替换重依赖/外部模块,import 完由 stub_modules 精确还原,避免污染其它用例
# 被测模块会绑定 import 期的桩对象,退出后需同时还原这期间加载的 app 模块图。
_STUB_MODULES = dict([
_stub("pillow_avif"),
_stub("aiofiles"),
@@ -64,8 +65,12 @@ _STUB_MODULES = dict([
_stub("version", APP_VERSION="test", FRONTEND_VERSION="frontend-test"),
])
with stub_modules(_STUB_MODULES):
from app.api.endpoints import system as system_endpoint
_APP_MODULES = snapshot_modules("app")
try:
with stub_modules(_STUB_MODULES):
from app.api.endpoints import system as system_endpoint
finally:
restore_modules(_APP_MODULES, "app")
class NettestSecurityTest(unittest.TestCase):
@@ -75,12 +80,50 @@ class NettestSecurityTest(unittest.TestCase):
"""
with patch.object(system_endpoint.rust_accel, "is_available", return_value=True), patch.object(
system_endpoint.rust_accel, "is_enabled", return_value=False
), patch.object(
system_endpoint.rust_accel, "is_required", return_value=True
), patch.object(
system_endpoint, "is_free_threaded_runtime", return_value=True
), patch.object(
system_endpoint, "is_gil_enabled", return_value=False
):
resp = asyncio.run(system_endpoint.get_env_setting(_="token"))
self.assertTrue(resp.success)
self.assertTrue(resp.data["RUST_ACCEL_AVAILABLE"])
self.assertFalse(resp.data["RUST_ACCEL_ENABLED"])
self.assertTrue(resp.data["RUST_ACCEL_REQUIRED"])
self.assertTrue(resp.data["PYTHON_FREE_THREADED"])
self.assertFalse(resp.data["PYTHON_GIL_ENABLED"])
def test_get_user_global_setting_reports_runtime_variant(self):
"""登录后的全局设置应提供导航所需的解释器类型。"""
runtime_config = SimpleNamespace(
snapshot=Mock(return_value={}),
get=Mock(return_value=False),
)
with patch.object(
system_endpoint, "get_runtime_settings", return_value=runtime_config
), patch.object(
system_endpoint.MoviePilotServerHelper,
"async_is_admin_user",
new=AsyncMock(return_value=False),
create=True,
), patch.object(
system_endpoint.MoviePilotServerHelper,
"get_user_uuid",
return_value="user-id",
create=True,
), patch.object(
system_endpoint, "is_free_threaded_runtime", return_value=True
), patch.object(
system_endpoint, "is_gil_enabled", return_value=False
):
resp = asyncio.run(system_endpoint.get_user_global_setting(_="token"))
self.assertTrue(resp.success)
self.assertTrue(resp.data["PYTHON_FREE_THREADED"])
self.assertFalse(resp.data["PYTHON_GIL_ENABLED"])
def test_fetch_image_allows_signed_private_url(self):
"""
Generated
+262 -61
View File
@@ -22,6 +22,13 @@ required-markers = [
"platform_machine == 'arm64' and sys_platform == 'darwin'",
"platform_machine == 'AMD64' and sys_platform == 'win32'",
]
conflicts = [[
{ package = "moviepilot", group = "runtime-free-threaded" },
{ package = "moviepilot", group = "runtime-standard" },
]]
[manifest]
excludes = [{ package = { name = "oss2" }, dependencies = ["crcmod"] }]
[[package]]
name = "aiofiles"
@@ -280,6 +287,13 @@ wheels = [
name = "bcrypt"
version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"platform_machine == 'x86_64' and sys_platform == 'linux'",
"platform_machine == 'aarch64' and sys_platform == 'linux'",
"platform_machine == 'x86_64' and sys_platform == 'darwin'",
"platform_machine == 'arm64' and sys_platform == 'darwin'",
"platform_machine == 'AMD64' and sys_platform == 'win32'",
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/5d/6d7433e0f3cd46ce0b43cd65e1db465ea024dbb8216fb2404e919c2ad77b/bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18", size = 25697, upload-time = "2025-02-28T01:24:09.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/11/22/5ada0b9af72b60cbc4c9a399fdde4af0feaa609d27eb0adc61607997a3fa/bcrypt-4.3.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d", size = 498019, upload-time = "2025-02-28T01:23:05.838Z" },
@@ -308,6 +322,55 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799, upload-time = "2025-02-28T01:23:53.139Z" },
]
[[package]]
name = "bcrypt"
version = "5.0.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"platform_machine == 'x86_64' and sys_platform == 'linux'",
"platform_machine == 'aarch64' and sys_platform == 'linux'",
"platform_machine == 'x86_64' and sys_platform == 'darwin'",
"platform_machine == 'arm64' and sys_platform == 'darwin'",
"platform_machine == 'AMD64' and sys_platform == 'win32'",
]
sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" },
{ url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" },
{ url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" },
{ url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" },
{ url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" },
{ url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" },
{ url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" },
{ url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" },
{ url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" },
{ url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" },
{ url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" },
{ url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" },
{ url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" },
{ url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" },
{ url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" },
{ url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" },
{ url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" },
{ url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" },
{ url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" },
{ url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" },
{ url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" },
{ url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" },
{ url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" },
{ url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" },
{ url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" },
{ url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" },
{ url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" },
{ url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" },
{ url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" },
{ url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" },
{ url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" },
{ url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" },
{ url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" },
]
[[package]]
name = "beautifulsoup4"
version = "4.15.0"
@@ -353,6 +416,13 @@ wheels = [
name = "brotli"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"platform_machine == 'x86_64' and sys_platform == 'linux'",
"platform_machine == 'aarch64' and sys_platform == 'linux'",
"platform_machine == 'x86_64' and sys_platform == 'darwin'",
"platform_machine == 'arm64' and sys_platform == 'darwin'",
"platform_machine == 'AMD64' and sys_platform == 'win32'",
]
sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" },
@@ -364,6 +434,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" },
]
[[package]]
name = "brotli"
version = "1.2.0"
source = { url = "https://github.com/google/brotli/archive/51be27dbd9782f9fe27bc6e06cee6ff18311702d.tar.gz" }
resolution-markers = [
"platform_machine == 'x86_64' and sys_platform == 'linux'",
"platform_machine == 'aarch64' and sys_platform == 'linux'",
"platform_machine == 'x86_64' and sys_platform == 'darwin'",
"platform_machine == 'arm64' and sys_platform == 'darwin'",
"platform_machine == 'AMD64' and sys_platform == 'win32'",
]
sdist = { hash = "sha256:81964822d063c789a2c14271fac52ceedcb523285a5b69db82c2181c786485e3" }
[[package]]
name = "brotlicffi"
version = "1.2.0.2"
@@ -421,7 +504,7 @@ name = "cffi"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
{ name = "pycparser", marker = "implementation_name != 'PyPy' or (extra == 'group-10-moviepilot-runtime-free-threaded' and extra == 'group-10-moviepilot-runtime-standard')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
wheels = [
@@ -524,7 +607,7 @@ name = "click"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "colorama", marker = "sys_platform == 'win32' or (extra == 'group-10-moviepilot-runtime-free-threaded' and extra == 'group-10-moviepilot-runtime-standard')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
wheels = [
@@ -604,17 +687,33 @@ wheels = [
]
[[package]]
name = "crcmod"
version = "1.7"
name = "crcmod-plus"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6b/b0/e595ce2a2527e169c3bcd6c33d2473c1918e0b7f6826a043ca1245dd4e5b/crcmod-1.7.tar.gz", hash = "sha256:dc7051a0db5f2bd48665a990d3ec1cc305a466a77358ca4492826f41f283601e", size = 89670, upload-time = "2010-06-27T14:35:29.538Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0b/0c/71733bbaf38e9f1eaecfdf7f8e350993f3dcac208a5297c41503ae66e513/crcmod_plus-2.3.1.tar.gz", hash = "sha256:732ffe3c3ce3ef9b272e1827d8fb894590c4d6ff553f2a2b41ae30f4f94b0f5d", size = 22319, upload-time = "2025-10-10T22:14:21.691Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/e0/2dad2e6f0cd4914b4144496d9785780ec820e200816c080df785cfa34da6/crcmod_plus-2.3.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:b7e35e0f7d93d7571c2c9c3d6760e456999ea4c1eae5ead6acac247b5a79e469", size = 23279, upload-time = "2025-10-10T22:13:47.281Z" },
{ url = "https://files.pythonhosted.org/packages/66/76/53c0b65b9679b903f98fc54efa32b0e5a19634712a45200c7a80674aa6f5/crcmod_plus-2.3.1-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:6853243120db84677b94b625112116f0ef69cd581741d20de58dce4c34242654", size = 20185, upload-time = "2025-10-10T22:13:48.06Z" },
{ url = "https://files.pythonhosted.org/packages/98/79/2b4dc9bb26394873d7699737124408b5106264ae33053fdec600e9a9fa65/crcmod_plus-2.3.1-cp311-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:17735bc4e944d552ea18c8609fc6d08a5e64ee9b29cc216ba4d623754029cc3a", size = 26999, upload-time = "2025-10-10T22:13:48.854Z" },
{ url = "https://files.pythonhosted.org/packages/bb/e8/f5d66778b5a1bff915807016561a02b5cebf6b3840fb8a2be40bbb0c8575/crcmod_plus-2.3.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ac755040a2a35f43ab331978c48a9acb4ff64b425f282a296be467a410f00c3", size = 27536, upload-time = "2025-10-10T22:13:49.956Z" },
{ url = "https://files.pythonhosted.org/packages/f3/2c/0113ad30cadad40c22eef08c0f2618f2446dd282f02268fecbcfc9fda3c1/crcmod_plus-2.3.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bdcfb838ca093ca673a3bbb37f62d1e5ec7182e00cc5ee2d00759f9f9f8ab11", size = 27385, upload-time = "2025-10-10T22:13:50.765Z" },
{ url = "https://files.pythonhosted.org/packages/8e/ba/501ef1b02119402cf1a31c01eb2cb8399660bca863c2f4dd3dc060220284/crcmod_plus-2.3.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9166bc3c9b5e7b07b4e6854cac392b4a451b31d58d3950e48c140ab7b5d05394", size = 27135, upload-time = "2025-10-10T22:13:51.889Z" },
{ url = "https://files.pythonhosted.org/packages/4d/7e/57bb97a8c7b4e19900744f58b67dc83bc9c83aaac670deeede9fb3bfab6a/crcmod_plus-2.3.1-cp311-abi3-win_amd64.whl", hash = "sha256:82b0f7e968c430c5a80fe0fc59e75cb54f2e84df2ed0cee5a3ff9cadfbf8a220", size = 22912, upload-time = "2025-10-10T22:13:53.849Z" },
{ url = "https://files.pythonhosted.org/packages/44/e9/1dde51efbf57ab73c18e11b3260e980d50c8be75ec41e696c7b66e1d738e/crcmod_plus-2.3.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:52abc724f5232eddbe565c258878123337339bf9cfe9ac9c154e38557b8affc5", size = 23293, upload-time = "2025-10-10T22:14:03.173Z" },
{ url = "https://files.pythonhosted.org/packages/52/52/9cff0c25b255b91cc2175df3b7d45e43a697fd1326746ccf964f79f8647a/crcmod_plus-2.3.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b0e644395d68bbfb576ee28becb69d962b173fa648ce269aec260f538841fa9", size = 20184, upload-time = "2025-10-10T22:14:03.974Z" },
{ url = "https://files.pythonhosted.org/packages/47/86/dff07e7d97b2514d24ad9e053909d3d6c74fc8d62ffec399588b3d4389e7/crcmod_plus-2.3.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:07962695c53eedf3c9f0bacb2d7d6c00064394d4c88c0eb7d5b082808812fe82", size = 30349, upload-time = "2025-10-10T22:14:04.775Z" },
{ url = "https://files.pythonhosted.org/packages/ee/58/0d2e1efc4fc9a269b7a03aa753c0fa5bae40c40aa2b6663dd34edacb7be3/crcmod_plus-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:43acb79630192f91e60ec5b979a0e1fc2a4734182ce8b37d657f11fcd27c1f86", size = 30912, upload-time = "2025-10-10T22:14:05.958Z" },
{ url = "https://files.pythonhosted.org/packages/79/c4/f9b627db3277afa2f4b6adf7371db25cac9dcef8cd1e28b29892693f2eca/crcmod_plus-2.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:52aacdfc0f04510c9c0e6ecf7c09528543cb00f4d4edd0871be8c9b8e03f2c08", size = 30601, upload-time = "2025-10-10T22:14:06.832Z" },
{ url = "https://files.pythonhosted.org/packages/ac/e6/e3c6310fd97d1e7df357433f99a35a61a655649695ad05774823c3747119/crcmod_plus-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ac4ce5a423f3ccf143a42ce6af4661e2f806f09a6124c24996689b3457f1afcb", size = 30382, upload-time = "2025-10-10T22:14:07.651Z" },
{ url = "https://files.pythonhosted.org/packages/a7/27/3b367ff19d68458634afd361b412f40f354a375938cec235f0caffac9db3/crcmod_plus-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ba925ca53a1e00233a1b93380a46c0e821f6b797a19fc401aec85219cd85fd6f", size = 23347, upload-time = "2025-10-10T22:14:09.227Z" },
]
[[package]]
name = "cryptography"
version = "50.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
{ name = "cffi", marker = "platform_python_implementation != 'PyPy' or (extra == 'group-10-moviepilot-runtime-free-threaded' and extra == 'group-10-moviepilot-runtime-standard')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" }
wheels = [
@@ -698,7 +797,8 @@ dependencies = [
{ name = "click" },
{ name = "fake-useragent" },
{ name = "httpx", extra = ["brotli", "http2", "socks"] },
{ name = "lxml" },
{ name = "lxml", version = "6.1.2", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-10-moviepilot-runtime-standard'" },
{ name = "lxml", version = "7.0.0b1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-10-moviepilot-runtime-free-threaded' or extra != 'group-10-moviepilot-runtime-standard'" },
{ name = "primp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/77/24/9d29eeb7dd4852c27c3673adcaf30c4dc55ced76b303c1fbb792ce7cae52/ddgs-9.14.4.tar.gz", hash = "sha256:f7b118a2b709a9e9c04a1dca6e96b98c25d4dfaca1a4b0a244d74454fcca48ef", size = 59742, upload-time = "2026-05-15T06:53:45.946Z" }
@@ -742,7 +842,7 @@ name = "docker"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pywin32", marker = "sys_platform == 'win32'" },
{ name = "pywin32", marker = "sys_platform == 'win32' or (extra == 'group-10-moviepilot-runtime-free-threaded' and extra == 'group-10-moviepilot-runtime-standard')" },
{ name = "requests" },
{ name = "urllib3" },
]
@@ -997,8 +1097,9 @@ wheels = [
[package.optional-dependencies]
brotli = [
{ name = "brotli", marker = "platform_python_implementation == 'CPython'" },
{ name = "brotlicffi", marker = "platform_python_implementation != 'CPython'" },
{ name = "brotli", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "(platform_python_implementation == 'CPython' and extra != 'group-10-moviepilot-runtime-free-threaded') or (extra == 'group-10-moviepilot-runtime-free-threaded' and extra == 'group-10-moviepilot-runtime-standard')" },
{ name = "brotli", version = "1.2.0", source = { url = "https://github.com/google/brotli/archive/51be27dbd9782f9fe27bc6e06cee6ff18311702d.tar.gz" }, marker = "(platform_python_implementation == 'CPython' and extra == 'group-10-moviepilot-runtime-free-threaded') or (extra == 'group-10-moviepilot-runtime-free-threaded' and extra == 'group-10-moviepilot-runtime-standard')" },
{ name = "brotlicffi", marker = "platform_python_implementation != 'CPython' or (extra == 'group-10-moviepilot-runtime-free-threaded' and extra == 'group-10-moviepilot-runtime-standard')" },
]
http2 = [
{ name = "h2" },
@@ -1075,21 +1176,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" },
]
[[package]]
name = "jieba-next"
version = "1.0.0rc1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ab/c7/82c88518395aa78116d3502362aa2a36399b9a5578583912ec1e17a72822/jieba_next-1.0.0rc1.tar.gz", hash = "sha256:71d33d4f84f7fa826da3806501bf19ac3e496432d71f75fd4a9c5f1c656c67b3", size = 5249019, upload-time = "2026-04-20T10:16:27.929Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/62/14d4de2a8e1c80f700f1e4bae8a908e4aeaca37e0c1585a1d2a6e57073b3/jieba_next-1.0.0rc1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c1349360f4a18c4a63578db8b8f531b90ed169e11309554b87696a41b42e96d0", size = 5530400, upload-time = "2026-04-20T10:15:54.446Z" },
{ url = "https://files.pythonhosted.org/packages/4a/1d/a60bc999423c2334dd199d6add0ae7f37a59ef81def6879886934de5bddf/jieba_next-1.0.0rc1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6813bfb8b34cc6867980d508a4ffab9203104f56e82b426be1eb9666a9b82e5f", size = 5519287, upload-time = "2026-04-20T10:15:56.541Z" },
{ url = "https://files.pythonhosted.org/packages/0d/57/f3d81885da74448590cbdd0085ecbfab0ade9ef7f43a589a78d429c5145a/jieba_next-1.0.0rc1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:3f058836f5f5fefd4e9d3efeb832b25a986a65eae4e625cde3247d0a829ea00c", size = 5541088, upload-time = "2026-04-20T10:15:58.503Z" },
{ url = "https://files.pythonhosted.org/packages/b9/5d/d45fdaf3637ad1af0b952fffcb80530892eae17d56abeeab6648af91875a/jieba_next-1.0.0rc1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:db6ff42dcd0f047d7f0559d44c58700e4ffa28d2ba065dcd8ab5e47a3188ebfc", size = 5543217, upload-time = "2026-04-20T10:16:00.808Z" },
{ url = "https://files.pythonhosted.org/packages/c3/0f/6ca56202662aafbf3acb7ddce82268c0d160b20d28067d46b06f24c292de/jieba_next-1.0.0rc1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3839ec61ad0f07a7642fd7b3a9d94f7fb41e9c992e0dc481960dba954941191f", size = 5605669, upload-time = "2026-04-20T10:16:02.779Z" },
{ url = "https://files.pythonhosted.org/packages/1a/34/08cc521fc7e1f821e6da327ae8ea054f95fc2e0d03c36932443a64e45a33/jieba_next-1.0.0rc1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0eee6de104a57e3a6fa0ea561e4f78417a4a69b642e87efb60fdc5af280a3eed", size = 5623440, upload-time = "2026-04-20T10:16:05.06Z" },
{ url = "https://files.pythonhosted.org/packages/fb/d7/375588648fb96ff422328ed88cfedb5b0a596091af706c0f718a70e3ec15/jieba_next-1.0.0rc1-cp314-cp314-win_amd64.whl", hash = "sha256:c68d4f88d0f80fa9e308b4622bb0709bf13f72b682bfb5cfef22d686fcd3eb43", size = 5599136, upload-time = "2026-04-20T10:16:09.458Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
@@ -1391,7 +1477,7 @@ dependencies = [
{ name = "anyio" },
{ name = "distro" },
{ name = "httpx" },
{ name = "orjson", marker = "platform_python_implementation != 'PyPy'" },
{ name = "orjson", marker = "platform_python_implementation != 'PyPy' or (extra == 'group-10-moviepilot-runtime-free-threaded' and extra == 'group-10-moviepilot-runtime-standard')" },
{ name = "packaging" },
{ name = "pydantic" },
{ name = "requests" },
@@ -1427,6 +1513,13 @@ wheels = [
name = "lxml"
version = "6.1.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"platform_machine == 'x86_64' and sys_platform == 'linux'",
"platform_machine == 'aarch64' and sys_platform == 'linux'",
"platform_machine == 'x86_64' and sys_platform == 'darwin'",
"platform_machine == 'arm64' and sys_platform == 'darwin'",
"platform_machine == 'AMD64' and sys_platform == 'win32'",
]
sdist = { url = "https://files.pythonhosted.org/packages/ad/a9/970b8fa0ecc4fbf1dfaed0d89bbc1fc1421b25ec26a2038c91e872dc6c8e/lxml-6.1.2.tar.gz", hash = "sha256:1055241852f2b02068af4a625a5d32c087db193c12251928af2562ecd2239f18", size = 4210626, upload-time = "2026-08-19T04:58:15.341Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/b5/728b0578791b397ace8d1b101c8b3fe10f36043542f7bb85f82d8bdc3f50/lxml-6.1.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:d50a44113fe6800dcc8a859332b823a4735b1e6ae1b0063882e4cca569ec3e29", size = 8609651, upload-time = "2026-08-19T04:58:42.42Z" },
@@ -1463,6 +1556,49 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/b6/07530896ca062bc3d2f09d5cb8a48e799c05b12c496205db03159ba13b6c/lxml-6.1.2-cp315-cp315t-win_amd64.whl", hash = "sha256:4e220a9c297e5d36895d489a08c9a3f1f6193b6414e702c5fb751e4a3767f8d0", size = 4395355, upload-time = "2026-08-19T05:05:01.651Z" },
]
[[package]]
name = "lxml"
version = "7.0.0b1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"platform_machine == 'x86_64' and sys_platform == 'linux'",
"platform_machine == 'aarch64' and sys_platform == 'linux'",
"platform_machine == 'x86_64' and sys_platform == 'darwin'",
"platform_machine == 'arm64' and sys_platform == 'darwin'",
"platform_machine == 'AMD64' and sys_platform == 'win32'",
]
sdist = { url = "https://files.pythonhosted.org/packages/42/d4/977a27c34c40f5b25f526476167b1df43fc750942d59d849a1ad1ccb1c37/lxml-7.0.0b1.tar.gz", hash = "sha256:c28385c1834aae143367c1251fbdc17fdf6aefbd5d33521778d31dca06286ebc", size = 5290555, upload-time = "2026-08-22T18:04:53.063Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b6/12/dc9d37010e7141ce929f1faf0d1cbebed4c0cb3e616fbed3af3f8e4e1391/lxml-7.0.0b1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b92ee515c1af2bdd151187d2a93e128f4d388227e0a1e3d403b7e5d575095cd5", size = 9021398, upload-time = "2026-08-22T18:04:47.597Z" },
{ url = "https://files.pythonhosted.org/packages/ea/a6/597c0c0e949000c4e107640b015458f2896903e0b42b00aa0f27a97f479b/lxml-7.0.0b1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4534471a974cdabfbc5b0ac8d7794b0dd8c543e80b694c749cf453dfc3c9f24d", size = 4850686, upload-time = "2026-08-22T18:04:52.299Z" },
{ url = "https://files.pythonhosted.org/packages/d8/b2/a28fc35e0790db0b7f84a5c667f1f5cf7bd4fa1c6a6c92238f1bbf24a86a/lxml-7.0.0b1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:538419dd75ce74cf93dbbb6b197173e7c2a30a38650667dab82f46e4f895a9ed", size = 5106879, upload-time = "2026-08-22T18:04:56.189Z" },
{ url = "https://files.pythonhosted.org/packages/83/76/68cf7ca0985fa41231c17f5515df4e16204649d9916c126f08c84dd64802/lxml-7.0.0b1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74dad5165eff9f727571d75961f73a17112d389738d542c3f15b6473eed34852", size = 5310277, upload-time = "2026-08-22T18:05:03.342Z" },
{ url = "https://files.pythonhosted.org/packages/89/86/2ed29b143092f107d23c84951a48e844c8d21254e5bec26649d81531a6cf/lxml-7.0.0b1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ef6d75c90d978a73ec72e95a9c178bd90b1be8ebba65d72a5ca87260d7ca77a6", size = 5167801, upload-time = "2026-08-22T18:05:15.545Z" },
{ url = "https://files.pythonhosted.org/packages/6a/31/dd59ddc64152193284727e9c9a7ec9537d6025ac65f3c8b957b205fe59c8/lxml-7.0.0b1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81cb6d7fe5eee26241cd47af18e4135eb192bfc3c0da2535ffa533c93f674135", size = 5338999, upload-time = "2026-08-22T18:05:28.978Z" },
{ url = "https://files.pythonhosted.org/packages/e7/50/bb5cde8460d5ac1650fac61d3b58e59546e712e5c5683cf6859a823b0534/lxml-7.0.0b1-cp314-cp314-win_amd64.whl", hash = "sha256:6da412397f8e3d1dad44bedfc4ce0f034574b1db7939572f1c8704b9ffea89cb", size = 4233727, upload-time = "2026-08-22T18:06:23.994Z" },
{ url = "https://files.pythonhosted.org/packages/ac/83/bde43b77394e8e12f3dc6a1cd2a5ca90184662874e2a40261d96f6609742/lxml-7.0.0b1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:191b28e872c5559b78b1b4e7690fe45c931cdb312724878e31f7fb27dcae3087", size = 9241163, upload-time = "2026-08-22T18:05:32.478Z" },
{ url = "https://files.pythonhosted.org/packages/d1/78/fcb3fc33af5f1a91f54cf0ddb5697ebb8980d929fa43b0673533172089ff/lxml-7.0.0b1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:e4fcbfa2ef34bef098bc8665cd253dfbec931c5193d433d78ed644ddbe780e15", size = 4951490, upload-time = "2026-08-22T18:05:35.53Z" },
{ url = "https://files.pythonhosted.org/packages/d4/fd/f5dbcfe73da3ad5ca6eed782ee2eed16c919a77f30fa380df707acfcc795/lxml-7.0.0b1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e067ce26374a672348aa7bd74acef9a7bdc9a22723d26356b13fe93a35930a4f", size = 5085503, upload-time = "2026-08-22T18:05:39.152Z" },
{ url = "https://files.pythonhosted.org/packages/b4/b5/a4b47167e686cfffaf320f8147ddd8d6eab2fd7fb478c2818d6f944e9415/lxml-7.0.0b1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ef19463b195127fafb5cf8430f0c34cf1496415a98dfa7272070a9746f070f0", size = 5283009, upload-time = "2026-08-22T18:05:45.466Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/1783c7aa5670992a253c44efcd9b94889daecae1f8b4a02319fdaf5b5600/lxml-7.0.0b1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fc00b4272d4d77e3d567444155a087c9b5bc49476c067c608cdd1383fab7117f", size = 5147306, upload-time = "2026-08-22T18:05:58.852Z" },
{ url = "https://files.pythonhosted.org/packages/32/f4/34670a40c3a820c4858e963e08831e707b50e9872da4749f4f00992f14e3/lxml-7.0.0b1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2e2bc6151f1cc3d9174d909bf875d1524bdef462aba286cfa316b5b538ca7ce6", size = 5307828, upload-time = "2026-08-22T18:06:10.391Z" },
{ url = "https://files.pythonhosted.org/packages/7a/19/94d2644dd1727bd5c6d69f314878712efd406a654b143d2e3888ca9e85a0/lxml-7.0.0b1-cp314-cp314t-win_amd64.whl", hash = "sha256:39b8f8017a8a94c5b986126677c66682b4c2f0b14b903537332282cf51f1f88c", size = 4607884, upload-time = "2026-08-22T18:06:15.782Z" },
{ url = "https://files.pythonhosted.org/packages/6b/1b/3a79761094d643c5dc6cf747731051e3ef0e4d011135ebf9d07513e479d1/lxml-7.0.0b1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:24c2931913d8772578f15f5245a2bf1570c1516dcc9f309019996f7549207198", size = 9008462, upload-time = "2026-08-22T18:06:29.982Z" },
{ url = "https://files.pythonhosted.org/packages/5b/4a/9ffdb6b4cd8f6025bcdc8a9bf730fba02140ac8816ab51dd92a7a8286c94/lxml-7.0.0b1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:d9277ba097b3217f9d45f40145a31689bf076c70b2effd3b6a536282b8a74f06", size = 4848649, upload-time = "2026-08-22T18:06:33.487Z" },
{ url = "https://files.pythonhosted.org/packages/89/42/b84e8ae79201f4d09893dcd7935526f151b9155e4e298e4afe2ac1b8da68/lxml-7.0.0b1-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef90c620dd7ac7dd95b348bec2592fe7e8d1d9ee719c7c785ed46ea9e0413709", size = 5104485, upload-time = "2026-08-22T18:06:36.251Z" },
{ url = "https://files.pythonhosted.org/packages/19/e8/e3c30369cb5550e27b90fe0674a34d5ce10d63b2c1e3a6e16f31ed0a24f3/lxml-7.0.0b1-cp315-cp315-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f2032b81a5a128d12ac4bbe0d639fb6b1ec2e95e4defe6cfb5f1d775bba89ca", size = 5307165, upload-time = "2026-08-22T18:06:42.098Z" },
{ url = "https://files.pythonhosted.org/packages/ac/e5/c935caf73e776fea777c657c51175cfd5233252040fe0f7c8875c0476765/lxml-7.0.0b1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:44468bf343a1fc5536bf182a11928b88a55ca879de2773f895346ab4247b492a", size = 5165191, upload-time = "2026-08-22T18:06:50.92Z" },
{ url = "https://files.pythonhosted.org/packages/e8/db/fc61d8265a219ae58fab678dd662e95efdcbd02c80990bc9648fc8dbbb15/lxml-7.0.0b1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:5594fcc20931f5f2129ba95c525e7cc11c29ee7a1b6493cc356db203a32e22b9", size = 5335633, upload-time = "2026-08-22T18:07:04.034Z" },
{ url = "https://files.pythonhosted.org/packages/49/96/d3fc3abd9fad029175494df4ee92659894e314a5cebde80011e448f35a8c/lxml-7.0.0b1-cp315-cp315-win_amd64.whl", hash = "sha256:a89ee5317cb3faec458b2494a4c44aa4b01b6648d5cdd1d9aced8c40a39ce97a", size = 4233276, upload-time = "2026-08-22T18:07:57.305Z" },
{ url = "https://files.pythonhosted.org/packages/09/66/e3cca5f8b56c32086fa511a2b964c8f2ff2618a6340352c33b2595004527/lxml-7.0.0b1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:3026e71f95b5a1b0795d803cef9f53b724d09603c5de9d51bc28327f3d198749", size = 9230470, upload-time = "2026-08-22T18:07:07.207Z" },
{ url = "https://files.pythonhosted.org/packages/50/70/c2e5bb71fa1b24b1a12c94e48448719e76508a506985021c6f7495a2620f/lxml-7.0.0b1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:306642f9ddf0c17fbd36be3e2889a39a7e010b1e70e7689fad9e5bde6e70360e", size = 4947183, upload-time = "2026-08-22T18:07:10.695Z" },
{ url = "https://files.pythonhosted.org/packages/a1/21/2df8fcc601b1109f539f23f97e2e1282c4c6f935935a0b849ca7a1de9f83/lxml-7.0.0b1-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dff26fea272bdfe3f9dab30f6c653e0b7a835533998c8f6cfd4ea67217783c04", size = 5080066, upload-time = "2026-08-22T18:07:13.642Z" },
{ url = "https://files.pythonhosted.org/packages/bb/df/d29fbce2753ec50d8a9f30b2493e6b842802fadde9694201722e522457f7/lxml-7.0.0b1-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d8f405e85dee68dfe65e829613792a5eadfaaf0d9fb0db14af07b7c904b7135", size = 5283985, upload-time = "2026-08-22T18:07:19.601Z" },
{ url = "https://files.pythonhosted.org/packages/e2/b9/552e5f36cbee68158d52e4fb3ece7b4eff3d3747b31c89310fba583eaca5/lxml-7.0.0b1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9a704cdbcd250da4cdf37082d015ed8b6001811f209047cc14607ce600739370", size = 5139658, upload-time = "2026-08-22T18:07:29.364Z" },
{ url = "https://files.pythonhosted.org/packages/b0/90/1742a00a22d20742eee1ec734adbe8646f7387541c98047b5c8d3ccc3621/lxml-7.0.0b1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c4095950f68f171efb0308cd2c1cf953887e3d8446bfaac81afd0aaf264dfa3f", size = 5309406, upload-time = "2026-08-22T18:07:42.303Z" },
{ url = "https://files.pythonhosted.org/packages/eb/7c/a678990905f8fb51987f63fcdcbc9b909c1713b605ac7120ed0e8a22a929/lxml-7.0.0b1-cp315-cp315t-win_amd64.whl", hash = "sha256:9fd6cabb63e9cbd5105a4aa2e62569c7ea4819c35ffef3ad105e5a9adc32a55a", size = 4597140, upload-time = "2026-08-22T18:07:48.363Z" },
]
[[package]]
name = "mako"
version = "1.4.1"
@@ -1519,7 +1655,6 @@ dependencies = [
{ name = "anyio" },
{ name = "apscheduler" },
{ name = "asyncpg" },
{ name = "bcrypt" },
{ name = "beautifulsoup4" },
{ name = "boto3" },
{ name = "cachetools" },
@@ -1527,6 +1662,7 @@ dependencies = [
{ name = "click" },
{ name = "cloakbrowser" },
{ name = "cn2an" },
{ name = "crcmod-plus" },
{ name = "cryptography" },
{ name = "dateparser" },
{ name = "ddgs" },
@@ -1537,7 +1673,6 @@ dependencies = [
{ name = "google-genai" },
{ name = "httpx", extra = ["http2", "socks"] },
{ name = "httpx2", extra = ["http2", "socks"] },
{ name = "jieba-next" },
{ name = "jinja2" },
{ name = "langchain" },
{ name = "langchain-anthropic" },
@@ -1550,10 +1685,10 @@ dependencies = [
{ name = "langgraph" },
{ name = "langgraph-checkpoint" },
{ name = "lark-oapi" },
{ name = "lxml" },
{ name = "moviepilot-rust" },
{ name = "mutagen" },
{ name = "openai" },
{ name = "orjson" },
{ name = "oss2" },
{ name = "packaging" },
{ name = "parse" },
@@ -1562,7 +1697,6 @@ dependencies = [
{ name = "pinyin2hanzi" },
{ name = "plexapi" },
{ name = "psutil" },
{ name = "psycopg2-binary" },
{ name = "pycryptodome" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
@@ -1579,7 +1713,7 @@ dependencies = [
{ name = "pytz" },
{ name = "pyvirtualdisplay" },
{ name = "pywebpush" },
{ name = "pywin32", marker = "sys_platform == 'win32'" },
{ name = "pywin32", marker = "sys_platform == 'win32' or (extra == 'group-10-moviepilot-runtime-free-threaded' and extra == 'group-10-moviepilot-runtime-standard')" },
{ name = "pyyaml" },
{ name = "qbittorrent-api" },
{ name = "redis" },
@@ -1604,7 +1738,6 @@ dependencies = [
{ name = "watchfiles" },
{ name = "webauthn" },
{ name = "websocket-client" },
{ name = "zhconv-rs" },
]
[package.dev-dependencies]
@@ -1617,6 +1750,19 @@ dev = [
{ name = "pytest-cov" },
{ name = "pytest-timeout" },
]
runtime-free-threaded = [
{ name = "bcrypt", version = "5.0.0", source = { registry = "https://pypi.org/simple" } },
{ name = "brotli", version = "1.2.0", source = { url = "https://github.com/google/brotli/archive/51be27dbd9782f9fe27bc6e06cee6ff18311702d.tar.gz" } },
{ name = "lxml", version = "7.0.0b1", source = { registry = "https://pypi.org/simple" } },
{ name = "psycopg", extra = ["c"], marker = "extra == 'group-10-moviepilot-runtime-free-threaded'" },
]
runtime-standard = [
{ name = "bcrypt", version = "4.3.0", source = { registry = "https://pypi.org/simple" } },
{ name = "brotli", version = "1.2.0", source = { registry = "https://pypi.org/simple" } },
{ name = "lxml", version = "6.1.2", source = { registry = "https://pypi.org/simple" } },
{ name = "psycopg2-binary" },
{ name = "zhconv-rs" },
]
[package.metadata]
requires-dist = [
@@ -1629,7 +1775,6 @@ requires-dist = [
{ name = "anyio", specifier = "~=4.14.2" },
{ name = "apscheduler", specifier = "~=3.11.2" },
{ name = "asyncpg", specifier = "~=0.31.0" },
{ name = "bcrypt", specifier = "~=4.3.0" },
{ name = "beautifulsoup4", specifier = "~=4.15.0" },
{ name = "boto3", specifier = "~=1.42.42" },
{ name = "cachetools", specifier = "~=7.1.4" },
@@ -1637,6 +1782,7 @@ requires-dist = [
{ name = "click", specifier = "~=8.4.1" },
{ name = "cloakbrowser", specifier = "~=0.5.3" },
{ name = "cn2an", specifier = "~=0.5.24" },
{ name = "crcmod-plus", specifier = "==2.3.1" },
{ name = "cryptography", specifier = "~=50.0.0" },
{ name = "dateparser", specifier = "~=1.4.0" },
{ name = "ddgs", specifier = "~=9.14.4" },
@@ -1647,7 +1793,6 @@ requires-dist = [
{ name = "google-genai", specifier = "~=2.8.0" },
{ name = "httpx", extras = ["http2", "socks"], specifier = "~=0.28.1" },
{ name = "httpx2", extras = ["http2", "socks"], specifier = "~=2.12.0" },
{ name = "jieba-next", specifier = "~=1.0.0rc1" },
{ name = "jinja2", specifier = "~=3.1.6" },
{ name = "langchain", specifier = "~=1.3.15" },
{ name = "langchain-anthropic", specifier = "~=1.4.6" },
@@ -1660,10 +1805,10 @@ requires-dist = [
{ name = "langgraph", specifier = "~=1.2.11" },
{ name = "langgraph-checkpoint", specifier = "~=4.2.0" },
{ name = "lark-oapi", specifier = "~=1.6.8" },
{ name = "lxml", specifier = "~=6.1.2" },
{ name = "moviepilot-rust", specifier = "~=0.2.8" },
{ name = "moviepilot-rust", specifier = "~=0.3.0" },
{ name = "mutagen", specifier = "~=1.47.0" },
{ name = "openai", specifier = "~=2.41.1" },
{ name = "orjson", specifier = "==3.12.0" },
{ name = "oss2", specifier = "~=2.19.1" },
{ name = "packaging", specifier = "~=26.3" },
{ name = "parse", specifier = "~=1.22.1" },
@@ -1672,7 +1817,6 @@ requires-dist = [
{ name = "pinyin2hanzi", specifier = "~=0.1.1" },
{ name = "plexapi", specifier = "~=4.18.1" },
{ name = "psutil", specifier = "~=7.2.2" },
{ name = "psycopg2-binary", specifier = "~=2.9.12" },
{ name = "pycryptodome", specifier = "~=3.23.0" },
{ name = "pydantic", specifier = ">=2.13.4,<3.0.0" },
{ name = "pydantic-settings", specifier = ">=2.14.2,<3.0.0" },
@@ -1714,7 +1858,6 @@ requires-dist = [
{ name = "watchfiles", specifier = "~=1.2.0" },
{ name = "webauthn", specifier = "~=2.8.0" },
{ name = "websocket-client", specifier = "~=1.9.0" },
{ name = "zhconv-rs", specifier = "~=0.4.1" },
]
[package.metadata.requires-dev]
@@ -1727,19 +1870,46 @@ dev = [
{ name = "pytest-cov", specifier = "~=7.1.0" },
{ name = "pytest-timeout", specifier = "~=2.4.0" },
]
runtime-free-threaded = [
{ name = "bcrypt", specifier = "~=5.0.0" },
{ name = "brotli", url = "https://github.com/google/brotli/archive/51be27dbd9782f9fe27bc6e06cee6ff18311702d.tar.gz" },
{ name = "lxml", specifier = "==7.0.0b1" },
{ name = "psycopg", extras = ["c"], specifier = "==3.3.4" },
]
runtime-standard = [
{ name = "bcrypt", specifier = "~=4.3.0" },
{ name = "brotli", specifier = "==1.2.0" },
{ name = "lxml", specifier = "~=6.1.2" },
{ name = "psycopg2-binary", specifier = "~=2.9.12" },
{ name = "zhconv-rs", specifier = "~=0.4.1" },
]
[[package]]
name = "moviepilot-rust"
version = "0.2.8"
version = "0.3.0"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/61/7bf4da55e27a584fc58e9df58ebd63bb40a4d9244e4417cd6822817a8e35/moviepilot_rust-0.2.8-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2c7f2a27f2d44e3e8d96ed1de725b777b88363299995d3743675e09090a97dba", size = 2419422, upload-time = "2026-08-12T20:57:15.166Z" },
{ url = "https://files.pythonhosted.org/packages/06/4e/8f3915d68f7c1cf4ca95d7f0067150528fbe3fec12f7cd7d08a2ca950a19/moviepilot_rust-0.2.8-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:4471ecbf6b5bd4297d2fa984543cec1ea3803996f5afe8be541439b14fb0ad8f", size = 2331159, upload-time = "2026-08-12T20:57:17.209Z" },
{ url = "https://files.pythonhosted.org/packages/15/08/1c4ba8eba94661ddfbadf7e7c1fdcdfbac3abe09e5c3f2adc383c1e468e2/moviepilot_rust-0.2.8-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47df4af6c690028789f90d0a1c7c0f28b8a4437548e32766bfde7b080ba94d93", size = 2471257, upload-time = "2026-08-12T20:57:18.907Z" },
{ url = "https://files.pythonhosted.org/packages/27/b8/faddc8ba84138a3b50895d756cf9714eff0ce8a2133c7ea482136cf3d75b/moviepilot_rust-0.2.8-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3d2ecf3f4d81c9c3640112cbb282e45fad6baab7d70d623c35e0b0ae28addf8", size = 2581673, upload-time = "2026-08-12T20:57:20.809Z" },
{ url = "https://files.pythonhosted.org/packages/51/80/63f09bf12fc480163fd0f3bdc97f37ef3b376c351a0c342065b593c43460/moviepilot_rust-0.2.8-cp311-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a90a73cd9398e08d31b3e272e4920fd38ae21e8456581624b899752386fa9401", size = 2650958, upload-time = "2026-08-12T20:57:22.669Z" },
{ url = "https://files.pythonhosted.org/packages/e2/10/175ed483eb16fba563a8b67990af1266809a9d66912af29309731dee2fee/moviepilot_rust-0.2.8-cp311-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:e1c2fbf52a5731a58d4246972dcb944edcfccd959213d231065659c84f53502a", size = 2826651, upload-time = "2026-08-12T20:57:24.36Z" },
{ url = "https://files.pythonhosted.org/packages/f4/a7/80ec00a5df84e5357041a917e08becab2cb0440634a8b269810aa5ed38ed/moviepilot_rust-0.2.8-cp311-abi3-win_amd64.whl", hash = "sha256:544d4d9d356cbd3ea65795a7be269a2749362bd9bb9e86b398a05ca1b87bf2cc", size = 2433021, upload-time = "2026-08-12T20:57:25.91Z" },
{ url = "https://files.pythonhosted.org/packages/bc/22/6890260f09db7c86bc4803c2e7b7bcdaf0232bb24bfbb564c464a39e2c91/moviepilot_rust-0.3.0-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:1bbbafd4261205c820c5448ccc888da688ebe01b11753cd0343f6ecd92aa0d31", size = 4922468, upload-time = "2026-08-24T02:54:21.942Z" },
{ url = "https://files.pythonhosted.org/packages/11/31/27d6a32c38b3d1fb6e49d0d6272c02bccceb3df678d4d74e50b36bd47566/moviepilot_rust-0.3.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:e4072ab20bd7aedeee2c47c9a2a84cf609786060ca74c9348cee3c8f7f050e46", size = 4804494, upload-time = "2026-08-24T02:54:24.171Z" },
{ url = "https://files.pythonhosted.org/packages/cd/42/a25793e28bacaf19a7a198a871005f6819bfd7ffef02a003afb71314baef/moviepilot_rust-0.3.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:467eb5d8dd106b867ebbf5795771131e487b12db244b630852a854200ef754ed", size = 4931770, upload-time = "2026-08-24T02:54:26.776Z" },
{ url = "https://files.pythonhosted.org/packages/dd/22/7421fd4b4622972c35d44c023549049fc13c3de9872aa9b17d44bd88ae9f/moviepilot_rust-0.3.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40eda95c03c028e21747d736aade2b7f046eff2ab0222212bc354344efaed332", size = 5114152, upload-time = "2026-08-24T02:54:28.855Z" },
{ url = "https://files.pythonhosted.org/packages/d5/e9/7d57ef2dcfd29df68283b02076d0bca1f7b699dedd2723fadc5ffa6b8a68/moviepilot_rust-0.3.0-cp311-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7ff19669a5f755d9b60b498218af1275342e28f008106c4014c4e942d15b557c", size = 5140455, upload-time = "2026-08-24T02:54:30.945Z" },
{ url = "https://files.pythonhosted.org/packages/41/0f/cce8612e72358252d267abce3a24b83892ee7307400a98dd1f091106f8f2/moviepilot_rust-0.3.0-cp311-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:bdfca1606499caeec85e8c16642076b448faa7d280483b8ade94b9d1d0709a64", size = 5356911, upload-time = "2026-08-24T02:54:33.003Z" },
{ url = "https://files.pythonhosted.org/packages/a2/d9/096217bfcf8771ea496af1c4b8a1ab14cff0a4b2d88c791c2b81d72283f3/moviepilot_rust-0.3.0-cp311-abi3-win_amd64.whl", hash = "sha256:fb35a868be701f37b7e01d0d2e72c87a3b268f55c9198dee4c7ea3f152eac400", size = 4929164, upload-time = "2026-08-24T02:54:35.057Z" },
{ url = "https://files.pythonhosted.org/packages/4b/ad/ef291f4cd7b931a411b2620b1f163bdb2d2b64620bd0baf5bda8a8a01271/moviepilot_rust-0.3.0-cp314-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c5fbbedb091646ade10e6a69b355606922a0749897bc710df5a4a6743f81afe7", size = 4922096, upload-time = "2026-08-24T02:54:37.4Z" },
{ url = "https://files.pythonhosted.org/packages/2b/d0/a18d22f0546286965e6ac66b4fe9488eda859e76d43f3d2df0e51acd6197/moviepilot_rust-0.3.0-cp314-abi3-macosx_11_0_arm64.whl", hash = "sha256:19df1f9c3a8eaa150bdb89b31df101b5cd3841adf129245e12fc6876e7e57514", size = 4798803, upload-time = "2026-08-24T02:54:39.84Z" },
{ url = "https://files.pythonhosted.org/packages/13/60/1626b207fde99a891c336dca1bd18d13fa14ecb211e9e6eabfedfd4e79a2/moviepilot_rust-0.3.0-cp314-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9914c612cadd093bb06d9a26af8e3d7b9dbc967163c80431a43b37381eb2f63b", size = 4929634, upload-time = "2026-08-24T02:54:41.824Z" },
{ url = "https://files.pythonhosted.org/packages/1f/87/a8345de6260bcd7890c6d9d961fa96d88c67b06594178a1737e9de955f56/moviepilot_rust-0.3.0-cp314-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a54f25b3030ec585a142648bd30a2fd2e8183e14b84289b98970d4994e2ba1c9", size = 5111349, upload-time = "2026-08-24T02:54:43.709Z" },
{ url = "https://files.pythonhosted.org/packages/ae/13/7fc40b89c7d5f639b3775ecbefa1fafd3566b19a562f34cd0ebcf3e05f32/moviepilot_rust-0.3.0-cp314-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f1ffc45795d2c215769b97f434f4826cea2118336e51b8ee6c8bebe1eed389d5", size = 5136599, upload-time = "2026-08-24T02:54:45.865Z" },
{ url = "https://files.pythonhosted.org/packages/0a/be/92bc21133ad72e78a964e6376f43b5a27b5fe7ea4831212f04df1c36db31/moviepilot_rust-0.3.0-cp314-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:9090c5df956dbad200a6a288b6da6dff738a5bfbbe326f3dbf277c391c743448", size = 5350039, upload-time = "2026-08-24T02:54:47.985Z" },
{ url = "https://files.pythonhosted.org/packages/40/8b/8f474a4a36ffc0983fa9e84c03facc5abf7c446849607504c14eaa68214b/moviepilot_rust-0.3.0-cp314-abi3-win_amd64.whl", hash = "sha256:13e07135e00d3dce35bedfd021deb886be61149c9dc6dd7474c945b6e6d6e1cb", size = 4923570, upload-time = "2026-08-24T02:54:50.332Z" },
{ url = "https://files.pythonhosted.org/packages/ae/22/2d15f02630ddf8f7899f2c13bfdd8c75bfdda31243d391beea2a9d13aded/moviepilot_rust-0.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f96ee23e7a712db574c3a68306d9e470c54dc064cefd4dbb5e2dcd91a46faa16", size = 5475033, upload-time = "2026-08-24T02:54:52.745Z" },
{ url = "https://files.pythonhosted.org/packages/37/bb/7af710947bcf73d33cbfc140e896cc47afc3b1bf35c97f0720ca566b8cef/moviepilot_rust-0.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:df2464752528446ec0546cdbf2a280593066bb08dc87fe3856564d2fb4664c69", size = 5364308, upload-time = "2026-08-24T02:54:55.094Z" },
{ url = "https://files.pythonhosted.org/packages/27/0e/499e40b2051b1e22da014e4e06d55beaaad41477cc44a50a6a4241199853/moviepilot_rust-0.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ba6e9ec28f5508c52d44d98975b7ae00148b08513f9c4c233e3c58c08315c02", size = 5496250, upload-time = "2026-08-24T02:54:57.197Z" },
{ url = "https://files.pythonhosted.org/packages/4a/5f/5c2c462f6ba3eea3ea58e9d0f68e89f56f29be8082375f7f46e494ce0ee9/moviepilot_rust-0.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a7aa8ebed85a315e30ae2fe9e31bd95cd0529f3ae40c839d0ac3e1caa8808a1", size = 5696220, upload-time = "2026-08-24T02:54:59.358Z" },
{ url = "https://files.pythonhosted.org/packages/06/17/a96cd61491ae9be5409bcf683cd9608cece016216b5ec4675600c7078094/moviepilot_rust-0.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:61e0d7e2ef5fc7b9d7921a10e0ae6c2f7736b1744db8df80f9fab7016baa357a", size = 5707762, upload-time = "2026-08-24T02:55:01.441Z" },
{ url = "https://files.pythonhosted.org/packages/8c/f2/8094ebbf7c0ba4f26114dc0156330c292de3a85da8d07130fe0c29240d45/moviepilot_rust-0.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5702cbb2b216e98784b46fbb33e476539a989d0a4f402be6a3a1848bd3128483", size = 5930378, upload-time = "2026-08-24T02:55:03.547Z" },
{ url = "https://files.pythonhosted.org/packages/49/86/4eb560ab3ea5e912875c2aff472a1889019a8c77b4adbfb3dd87de76225f/moviepilot_rust-0.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1738fbcae4a72d9661d3cf79b4f726742a67c58900718c37bfd75f20333620ff", size = 5490741, upload-time = "2026-08-24T02:55:05.93Z" },
]
[[package]]
@@ -1914,7 +2084,6 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aliyun-python-sdk-core" },
{ name = "aliyun-python-sdk-kms" },
{ name = "crcmod" },
{ name = "pycryptodome" },
{ name = "requests" },
{ name = "six" },
@@ -2145,6 +2314,29 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
]
[[package]]
name = "psycopg"
version = "3.3.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" },
]
[package.optional-dependencies]
c = [
{ name = "psycopg-c", marker = "implementation_name != 'pypy'" },
]
[[package]]
name = "psycopg-c"
version = "3.3.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/7c/c08364f2eab2913e4068b3b955d963e7a3491986a85429990969525def30/psycopg_c-3.3.4.tar.gz", hash = "sha256:ed8106128b2d04359c185fc9641b4409abfce4d0b6fb1d1ff6800646e27f1a22", size = 647111, upload-time = "2026-05-01T23:31:58.032Z" }
[[package]]
name = "psycopg2-binary"
version = "2.9.12"
@@ -2307,7 +2499,7 @@ version = "4.0.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "astroid" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "colorama", marker = "sys_platform == 'win32' or (extra == 'group-10-moviepilot-runtime-free-threaded' and extra == 'group-10-moviepilot-runtime-standard')" },
{ name = "dill" },
{ name = "isort" },
{ name = "mccabe" },
@@ -2324,7 +2516,7 @@ name = "pympler"
version = "1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pywin32", marker = "sys_platform == 'win32'" },
{ name = "pywin32", marker = "sys_platform == 'win32' or (extra == 'group-10-moviepilot-runtime-free-threaded' and extra == 'group-10-moviepilot-runtime-standard')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dd/37/c384631908029676d8e7213dd956bb686af303a80db7afbc9be36bc49495/pympler-1.1.tar.gz", hash = "sha256:1eaa867cb8992c218430f1708fdaccda53df064144d1c5656b1e6f1ee6000424", size = 179954, upload-time = "2024-06-28T19:56:06.563Z" }
wheels = [
@@ -2348,7 +2540,7 @@ name = "pyobjc-framework-cocoa"
version = "12.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyobjc-core" },
{ name = "pyobjc-core", marker = "(sys_platform != 'linux' and sys_platform != 'win32') or (sys_platform == 'linux' and extra == 'group-10-moviepilot-runtime-free-threaded' and extra == 'group-10-moviepilot-runtime-standard') or (sys_platform == 'win32' and extra == 'group-10-moviepilot-runtime-free-threaded' and extra == 'group-10-moviepilot-runtime-standard')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/75/76/49c6da2c6a831020b4854ba20079d5a1030474bffc776b7b73c2eeff8c15/pyobjc_framework_cocoa-12.2.2.tar.gz", hash = "sha256:c96c0ef69a71afbbb0e6a7d594b455c5fe47d62e0db376ee7a2b4b828c16ace9", size = 3132831, upload-time = "2026-08-11T19:44:02.288Z" }
wheels = [
@@ -2363,8 +2555,8 @@ name = "pyobjc-framework-quartz"
version = "12.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyobjc-core" },
{ name = "pyobjc-framework-cocoa" },
{ name = "pyobjc-core", marker = "(sys_platform != 'linux' and sys_platform != 'win32') or (sys_platform == 'linux' and extra == 'group-10-moviepilot-runtime-free-threaded' and extra == 'group-10-moviepilot-runtime-standard') or (sys_platform == 'win32' and extra == 'group-10-moviepilot-runtime-free-threaded' and extra == 'group-10-moviepilot-runtime-standard')" },
{ name = "pyobjc-framework-cocoa", marker = "(sys_platform != 'linux' and sys_platform != 'win32') or (sys_platform == 'linux' and extra == 'group-10-moviepilot-runtime-free-threaded' and extra == 'group-10-moviepilot-runtime-standard') or (sys_platform == 'win32' and extra == 'group-10-moviepilot-runtime-free-threaded' and extra == 'group-10-moviepilot-runtime-standard')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/35/b1/426a37c7ae37280b3ffca2571fb48f211946aee2f4ca31a603ed1943c4a7/pyobjc_framework_quartz-12.2.2.tar.gz", hash = "sha256:810f97b210cfd93704d240860286dfd6df09f9f1c52525fc5c2166723aea3f9e", size = 3218295, upload-time = "2026-08-11T19:45:15.189Z" }
wheels = [
@@ -2410,7 +2602,8 @@ version = "2.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cssselect" },
{ name = "lxml" },
{ name = "lxml", version = "6.1.2", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-10-moviepilot-runtime-standard'" },
{ name = "lxml", version = "7.0.0b1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-10-moviepilot-runtime-free-threaded' or extra != 'group-10-moviepilot-runtime-standard'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1a/48/500aa06c23443919c76b6ac4e5e3c3e57271b99a026beac00fd676e7208c/pyquery-2.0.2.tar.gz", hash = "sha256:a17b5e0b22810fccf264795c4671fd67dfb27a697a4ecf627cdbbddbcf93a7fb", size = 46043, upload-time = "2026-07-27T12:54:57.527Z" }
wheels = [
@@ -2461,7 +2654,7 @@ version = "0.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "sspilib", marker = "sys_platform == 'win32'" },
{ name = "sspilib", marker = "sys_platform == 'win32' or (extra == 'group-10-moviepilot-runtime-free-threaded' and extra == 'group-10-moviepilot-runtime-standard')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/84/58577bd1b14293650879de0579ec263a1d8350f1d6d227226cf776b5a6a6/pyspnego-0.12.1.tar.gz", hash = "sha256:ff4fb6df38202a012ea2a0f43091ae9680878443f0ea61c9ea0e2e8152a4b810", size = 226027, upload-time = "2026-03-02T20:16:09.74Z" }
wheels = [
@@ -2474,8 +2667,8 @@ version = "0.19.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pillow" },
{ name = "pyobjc-framework-quartz", marker = "sys_platform != 'linux' and sys_platform != 'win32'" },
{ name = "python-xlib", marker = "sys_platform == 'linux'" },
{ name = "pyobjc-framework-quartz", marker = "(sys_platform != 'linux' and sys_platform != 'win32') or (sys_platform == 'linux' and extra == 'group-10-moviepilot-runtime-free-threaded' and extra == 'group-10-moviepilot-runtime-standard') or (sys_platform == 'win32' and extra == 'group-10-moviepilot-runtime-free-threaded' and extra == 'group-10-moviepilot-runtime-standard')" },
{ name = "python-xlib", marker = "sys_platform == 'linux' or (extra == 'group-10-moviepilot-runtime-free-threaded' and extra == 'group-10-moviepilot-runtime-standard')" },
{ name = "six" },
]
wheels = [
@@ -2500,7 +2693,7 @@ name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "colorama", marker = "sys_platform == 'win32' or (extra == 'group-10-moviepilot-runtime-free-threaded' and extra == 'group-10-moviepilot-runtime-standard')" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
@@ -2584,7 +2777,7 @@ name = "python-xlib"
version = "0.33"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
{ name = "six", marker = "sys_platform == 'linux' or (extra == 'group-10-moviepilot-runtime-free-threaded' and extra == 'group-10-moviepilot-runtime-standard')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068, upload-time = "2022-12-25T18:53:00.824Z" }
wheels = [
@@ -2874,7 +3067,7 @@ name = "sqlalchemy"
version = "2.0.52"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'x86_64' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "greenlet", marker = "platform_machine == 'x86_64' or (platform_machine != 'x86_64' and extra == 'group-10-moviepilot-runtime-free-threaded' and extra == 'group-10-moviepilot-runtime-standard') or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3b/21/77b4c147963073040dc3c3a5cb7a8c3001a1893c0209432cb77f9df836aa/sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97", size = 9945637, upload-time = "2026-08-11T19:07:09.829Z" }
@@ -2895,7 +3088,15 @@ version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a7/e6/d0d74b18bed8c16949fddc0401005c072947ae7bf1bab982ed28f9ebc2d8/sspilib-0.5.0.tar.gz", hash = "sha256:b62f7f2602aa1add0505eee2417e2df24421224cb411e53bf3ae42a71b62fe98", size = 59920, upload-time = "2025-12-03T00:31:05.564Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/a9/4fa74151ebfb5f7c872a8d0c21efe142af2a879a5536065ffa8cdabbbb4c/sspilib-0.5.0-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:eaba0331997368ffbdedff5e95f4fec18b19c809637e8848a4c673130fb4dd9e", size = 6780966, upload-time = "2025-12-03T00:30:27.931Z" },
{ url = "https://files.pythonhosted.org/packages/31/96/f9e2245f0b11915a350fe5fb8aac022824210bc38624f3a7d324350aa402/sspilib-0.5.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:3dbb80bfe0a17f272c68e9d86a0375d11f98157abe998272a540e635694c1540", size = 6304059, upload-time = "2025-12-03T00:30:29.554Z" },
{ url = "https://files.pythonhosted.org/packages/a0/34/0d651c27d7b839a14547f67b02337052db78695cc1dd3e70a7fc0fe2dd98/sspilib-0.5.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4266eda17b81f50e71a3f75cdddf424d76046de68d8014d289a895ec008df4e4", size = 10422422, upload-time = "2025-12-03T00:30:31.81Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e1/15718341947168258e7e6c6abe7941ebfc16121f44ba8ed7e54d2dbcda53/sspilib-0.5.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:57ea0ce644339bb71ee3eb226b22a121d011b0aea5e2f32d078d88f4a269359b", size = 10514133, upload-time = "2025-12-03T00:30:34.425Z" },
{ url = "https://files.pythonhosted.org/packages/17/6a/a11abf90172ff580ac2f9ade3496d868e05e851c4ecf487dd5baeb966b1d/sspilib-0.5.0-cp311-abi3-win_amd64.whl", hash = "sha256:ca2a21a4e90db563c2cec639c66b3a29ea53129a0c55ff1e4154a02937f6bd45", size = 540777, upload-time = "2025-12-03T00:30:38.44Z" },
{ url = "https://files.pythonhosted.org/packages/ab/c8/09d8a7cf8ba10e060c62ff398ff48c733380e74f17c2a89ea4d2f5517673/sspilib-0.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10018d475022643d11b1aeef08e674d8a3f8b03a597ad31fa8c8c302a58ee960", size = 6902087, upload-time = "2025-12-03T00:30:41.49Z" },
{ url = "https://files.pythonhosted.org/packages/74/b6/44b395902d96379fdd7e133d3b38c530108fde6ce0c27250d119da85270a/sspilib-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2124dece22406b71294311bcef7e0e3fae88e85fee77039a96771f6972c98377", size = 6407913, upload-time = "2025-12-03T00:30:43.008Z" },
{ url = "https://files.pythonhosted.org/packages/14/1e/8e4f198071491c0757111771c11e991e18ea5d7c6a51e3f19b00739e8936/sspilib-0.5.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:963ca7d7158b19b46fcfd8c3bb5f94696ff6e9cf7b01911586717105f11336b0", size = 10975238, upload-time = "2025-12-03T00:30:45.001Z" },
{ url = "https://files.pythonhosted.org/packages/a8/af/bb7cb1f4df7ea77ab384bdc113dfd6087421ea441a5a014d5bc832b889c4/sspilib-0.5.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:13d9fbe9a2e0df6405cac39a2a5a93f0f04c67b8e8d5e4c6cd27f8f76a16ce9c", size = 10976954, upload-time = "2025-12-03T00:30:46.91Z" },
{ url = "https://files.pythonhosted.org/packages/c4/d8/8c4ba75f925fd9651cb855c47e0e67931a175d6fd41e569193a8d58133ac/sspilib-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7d7724d5dbb31f68e62465863dfb862fe2793281ce40d0c8f2dc60c8f07998f2", size = 690291, upload-time = "2025-12-03T00:30:49.929Z" },
]
@@ -2995,7 +3196,7 @@ name = "tqdm"
version = "4.68.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "colorama", marker = "sys_platform == 'win32' or (extra == 'group-10-moviepilot-runtime-free-threaded' and extra == 'group-10-moviepilot-runtime-standard')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/5f/57ff8b434839e70dab45601284ea413e947a63799891b7553e5960a793a8/tqdm-4.68.4.tar.gz", hash = "sha256:19829c9673638f2a0b8617da4cdcb927e831cd88bcfcb6e78d42a4d1af131520", size = 792418, upload-time = "2026-07-07T09:58:18.369Z" }
wheels = [
@@ -3059,7 +3260,7 @@ name = "tzlocal"
version = "5.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" },
{ name = "tzdata", marker = "sys_platform == 'win32' or (extra == 'group-10-moviepilot-runtime-free-threaded' and extra == 'group-10-moviepilot-runtime-standard')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/5b/879b2f932adfa7a053c360d50bc896c977fa6426109185f7c12ebdd0cb9d/tzlocal-5.4.4.tar.gz", hash = "sha256:8dbb8660838688a7b6ba4fed31d18dedf842afb4d47ca050d6d891c2c15f3be4", size = 31170, upload-time = "2026-06-29T08:03:40.026Z" }
wheels = [