diff --git a/.dockerignore b/.dockerignore index a42a29787..b6633368e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -92,6 +92,7 @@ test_* *_test.py # Build artifacts +.artifacts/ build/ .build/ dist/ diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 7757c0e4b..8231e9a47 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -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 diff --git a/.github/workflows/build-v3.yml b/.github/workflows/build-v3.yml index 273c82b4c..ce8d61b00 100644 --- a/.github/workflows/build-v3.yml +++ b/.github/workflows/build-v3.yml @@ -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 diff --git a/.github/workflows/dependency-compat.yml b/.github/workflows/dependency-compat.yml index dd906e5da..e043863dc 100644 --- a/.github/workflows/dependency-compat.yml +++ b/.github/workflows/dependency-compat.yml @@ -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: diff --git a/.gitignore b/.gitignore index c9fac1576..377067eb3 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ config/.cache/ # 运行期设置持久化目录(settings 写回 app.env 的落点)与本地验证产物 app/config/ .verify_tmp/ +.artifacts/ .runtime/ public/ .moviepilot.env diff --git a/app/__init__.py b/app/__init__.py index 457dd43ef..70298c772 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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, ) diff --git a/app/adapters/external/market.py b/app/adapters/external/market.py index 0159858a6..5498e3b8d 100644 --- a/app/adapters/external/market.py +++ b/app/adapters/external/market.py @@ -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] = ( diff --git a/app/adapters/system/package.py b/app/adapters/system/package.py index e57f81c42..dd16c9037 100644 --- a/app/adapters/system/package.py +++ b/app/adapters/system/package.py @@ -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]) diff --git a/app/adapters/system/resource.py b/app/adapters/system/resource.py index 37c4e0272..c6c06a15b 100644 --- a/app/adapters/system/resource.py +++ b/app/adapters/system/resource.py @@ -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: diff --git a/app/adapters/system/rust.py b/app/adapters/system/rust.py index cdb8a17f3..3f37a64c0 100644 --- a/app/adapters/system/rust.py +++ b/app/adapters/system/rust.py @@ -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 "", } diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index 3ec1ef365..91f35ee39 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -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) diff --git a/app/db/engine.py b/app/db/engine.py index 0967c453a..23da8416a 100644 --- a/app/db/engine.py +++ b/app/db/engine.py @@ -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 diff --git a/app/doctor/dependencies.py b/app/doctor/dependencies.py new file mode 100644 index 000000000..a1b5f9211 --- /dev/null +++ b/app/doctor/dependencies.py @@ -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) diff --git a/app/foundation/environment.py b/app/foundation/environment.py index eeb72e3f5..b92a58bf6 100644 --- a/app/foundation/environment.py +++ b/app/foundation/environment.py @@ -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" diff --git a/app/foundation/text.py b/app/foundation/text.py index f438914ef..6c9675c88 100644 --- a/app/foundation/text.py +++ b/app/foundation/text.py @@ -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) diff --git a/app/runtime/config.py b/app/runtime/config.py index 6dde889a3..c764a2d7f 100644 --- a/app/runtime/config.py +++ b/app/runtime/config.py @@ -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) diff --git a/app/runtime/dependencies.py b/app/runtime/dependencies.py new file mode 100644 index 000000000..726333887 --- /dev/null +++ b/app/runtime/dependencies.py @@ -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()) diff --git a/app/runtime/extensions/plugin_manager.py b/app/runtime/extensions/plugin_manager.py index 2aa44c2d9..2726171e4 100644 --- a/app/runtime/extensions/plugin_manager.py +++ b/app/runtime/extensions/plugin_manager.py @@ -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 diff --git a/app/sdk/utilities.py b/app/sdk/utilities.py index 81d9eb385..a3a9572c7 100644 --- a/app/sdk/utilities.py +++ b/app/sdk/utilities.py @@ -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", diff --git a/app/startup/lifecycle/__init__.py b/app/startup/lifecycle/__init__.py index 9dadf6f22..a3ad0897e 100644 --- a/app/startup/lifecycle/__init__.py +++ b/app/startup/lifecycle/__init__.py @@ -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运行时:%s,GIL=%s", + "free-threaded" if free_threaded else "standard", + "enabled" if gil_enabled else "disabled", + ) + + async def run_shutdown_step( name: str, callback: Callable[[], object], diff --git a/docker/Dockerfile b/docker/Dockerfile index db4246e6c..0d703c7e4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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 \ diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 3e2ca14ec..120513fb9 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -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 diff --git a/docker/update.sh b/docker/update.sh index 44c20534a..be483747a 100644 --- a/docker/update.sh +++ b/docker/update.sh @@ -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" diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 61b66fa0e..661a6d814 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -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 后端后的二阶段任务、验收与回滚方案 | diff --git a/docs/cli.md b/docs/cli.md index 14e534d66..d6e829692 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 加速;如果后端未加载扩展,该开关会保持关闭且不可操作 diff --git a/docs/development-setup.md b/docs/development-setup.md index 665e02299..f65689438 100644 --- a/docs/development-setup.md +++ b/docs/development-setup.md @@ -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 环境验证。 diff --git a/docs/refactor/backend-architecture-governance.md b/docs/refactor/backend-architecture-governance.md index f0b259f9e..d79d87095 100644 --- a/docs/refactor/backend-architecture-governance.md +++ b/docs/refactor/backend-architecture-governance.md @@ -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 | 插件存储、服务配置均改为启动注入 | diff --git a/docs/rules/02-tech-stack.md b/docs/rules/02-tech-stack.md index d98bd30c4..3363219c4 100644 --- a/docs/rules/02-tech-stack.md +++ b/docs/rules/02-tech-stack.md @@ -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` | --- diff --git a/docs/rules/03-commands.md b/docs/rules/03-commands.md index c2623fe4f..1a6694ecf 100644 --- a/docs/rules/03-commands.md +++ b/docs/rules/03-commands.md @@ -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+. --- diff --git a/docs/rules/06-code-styles.md b/docs/rules/06-code-styles.md index 92d382de6..459d6e84f 100644 --- a/docs/rules/06-code-styles.md +++ b/docs/rules/06-code-styles.md @@ -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. diff --git a/docs/rules/11-quality-and-security.md b/docs/rules/11-quality-and-security.md index 1fd6fa273..3c833ad1c 100644 --- a/docs/rules/11-quality-and-security.md +++ b/docs/rules/11-quality-and-security.md @@ -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/` diff --git a/docs/rules/12-collaboration-and-distribution.md b/docs/rules/12-collaboration-and-distribution.md index 645556731..bd3bab760 100644 --- a/docs/rules/12-collaboration-and-distribution.md +++ b/docs/rules/12-collaboration-and-distribution.md @@ -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`. --- diff --git a/docs/site-adapter-collector-release.md b/docs/site-adapter-collector-release.md index ba07d3ab6..dbc3f3319 100644 --- a/docs/site-adapter-collector-release.md +++ b/docs/site-adapter-collector-release.md @@ -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+ 及完整后端依赖,不适合作为普通用户的首选路径。 ## 维护者发布流程 diff --git a/docs/v3t-runtime-governance.md b/docs/v3t-runtime-governance.md new file mode 100644 index 000000000..db3d92a67 --- /dev/null +++ b/docs/v3t-runtime-governance.md @@ -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 MiB,V3t 增加 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/arm64,wheel 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、启动日志、插件归因和前端告警保持一致。 +- [ ] 安全扫描结果已按实际安装制品、利用面和上游修复状态审计。 +- [ ] 上游已成熟的临时兼容已删除,仍保留的例外在本文有明确解除条件。 diff --git a/pyproject.toml b/pyproject.toml index 9ff528e6a..4ec37fdd8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/scripts/normalize_audit_requirements.py b/scripts/normalize_audit_requirements.py new file mode 100644 index 000000000..08ae20158 --- /dev/null +++ b/scripts/normalize_audit_requirements.py @@ -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\s*)(?P[A-Za-z0-9_.-]+)\s+@\s+" + r"(?P\S+)(?P.*)$" +) + + +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() diff --git a/scripts/perf/README.md b/scripts/perf/README.md index 11d29fd26..6c23c5d02 100644 --- a/scripts/perf/README.md +++ b/scripts/perf/README.md @@ -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:` 不可变引用,并要求镜像标签证明两者来自相同源码 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//`: + +- `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:@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、查询结果、长事务并行和样本完整性属于硬门禁。 diff --git a/scripts/perf/free_threaded_ab.py b/scripts/perf/free_threaded_ab.py new file mode 100644 index 000000000..a9fcf4296 --- /dev/null +++ b/scripts/perf/free_threaded_ab.py @@ -0,0 +1,1571 @@ +"""对同一版本的标准与 free-threaded MoviePilot 镜像执行本地 A/B 验收。""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import random +import re +import statistics +import subprocess +import sys +import tempfile +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +try: + import docker +except ImportError: # pragma: no cover - 保留无 Docker SDK 环境下的 --help + docker = None + + +SCHEMA_VERSION = 2 +EXIT_PASS = 0 +EXIT_REGRESSION = 1 +EXIT_INVALID = 2 +FIXTURE_SEED = 314159 +EXPECTED_FIXTURE_SHA256 = "2560135ffe9501b4d1230c973da237143c94c32d623153c2b4dabcefd1d055a1" +SAMPLE_ORDER = ( + ("v3", 1), + ("v3t", 1), + ("v3t", 2), + ("v3", 2), + ("v3", 3), + ("v3t", 3), +) +COMMON_PREFLIGHT_IMPORTS = frozenset({ + "asyncpg", + "bcrypt", + "brotli", + "crcmod-plus", + "cryptography", + "greenlet", + "lxml", + "moviepilot-rust", + "orjson", + "oss2", + "pillow", + "pillow-avif-plugin", + "pydantic-core", + "site-resource", + "zstandard", +}) +PROFILE_PREFLIGHT_IMPORTS = { + "v3": frozenset({"psycopg2-binary", "zhconv-rs"}), + "v3t": frozenset({"psycopg"}), +} +REQUIRED_API_ENDPOINTS = ( + "health_ready", + "dashboard_statistic", + "subscribe_list", + "system_env", +) +LAB_ENVIRONMENT = { + "TZ": "Asia/Shanghai", + "PUID": "0", + "PGID": "0", + "UMASK": "000", + "PORT": "3001", + "NGINX_PORT": "3000", + "API_WORKERS": "1", + "DB_TYPE": "sqlite", + "CACHE_BACKEND_TYPE": "cachetools", + "AI_AGENT_ENABLE": "false", + "DEV": "false", + "DEBUG": "false", + "MOVIEPILOT_SAFE_MODE": "false", + "MOVIEPILOT_AUTO_UPDATE": "false", + "MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE": "false", + "AUTO_UPDATE_RESOURCE": "false", + "PLUGIN_MARKET": "", + "PLUGIN_AUTO_RELOAD": "false", + "PLUGIN_LOCAL_REPO_PATHS": "", + "PLUGIN_STATISTIC_SHARE": "false", + "SUBSCRIBE_STATISTIC_SHARE": "false", + "USAGE_STATISTIC_SHARE": "false", + "WORKFLOW_STATISTIC_SHARE": "false", + "MEDIA_RECOGNIZE_SHARE": "false", + "MP_SERVER_HOST": "", + "GITHUB_TOKEN": "", + "REPO_GITHUB_TOKEN": "", + "AUTH_SITE": "", + "SKILL_MARKET": "", + "FANART_ENABLE": "false", + "API_TOKEN": "moviepilot-ft-perf-token-00000001", + "SUPERUSER": "admin", + "SUPERUSER_PASSWORD": "MoviePilot-FT-Perf-Only-00000001!", + "SECRET_KEY": "moviepilot-ft-perf-secret-key-00000001", + "RESOURCE_SECRET_KEY": "moviepilot-ft-perf-resource-key-00000001", + "LOG_LEVEL": "INFO", +} + + +class HarnessInvalid(RuntimeError): + """表示输入、制品或采样合同不成立,结果不能用于性能判断。""" + + +def utc_now() -> str: + """返回 JSON 使用的 UTC 时间。""" + return datetime.now(timezone.utc).isoformat() + + +def build_fixture(seed: int = FIXTURE_SEED) -> list[str]: + """生成稳定且不含用户数据的媒体标题 fixture。""" + randomizer = random.Random(seed) + names = ("流浪地球", "繁花", "三体", "庆余年", "长安十二时辰", "琅琊榜") + sources = ("WEB-DL", "BluRay", "HDTV") + codecs = ("H265", "AV1", "H264") + pixels = ("1080p", "2160p") + fixture = [] + for index in range(64): + fixture.append( + ".".join( + ( + randomizer.choice(names), + f"S{index % 6 + 1:02d}E{index % 24 + 1:02d}", + randomizer.choice(pixels), + randomizer.choice(sources), + randomizer.choice(codecs), + f"GROUP{index % 5}", + ) + ) + ) + return fixture + + +def canonical_json(value: Any) -> str: + """返回跨进程稳定的紧凑 JSON。""" + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def fixture_hash(fixture: list[str]) -> str: + """计算 fixture 内容身份。""" + return hashlib.sha256(canonical_json(fixture).encode("utf-8")).hexdigest() + + +FIXTURE = build_fixture() +FIXTURE_SHA256 = fixture_hash(FIXTURE) +if FIXTURE_SHA256 != EXPECTED_FIXTURE_SHA256: # pragma: no cover - import 期固定合同 + raise RuntimeError("free-threaded A/B fixture 内容发生未审计变化") + + +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 assert_expected_repository(reference: str, expected: str) -> None: + """防止标准与 free-threaded 镜像参数传反或使用旧命名。""" + repository = reference.split("@", 1)[0].rsplit("/", 1)[-1].split(":", 1)[0].lower() + if repository != expected: + raise HarnessInvalid(f"{reference} 的仓库名必须为 {expected}") + + +def parse_workers(value: str) -> tuple[int, ...]: + """解析并发线程档位。""" + try: + workers = tuple(dict.fromkeys(int(item.strip()) for item in value.split(","))) + except ValueError as error: + raise argparse.ArgumentTypeError("--workers 必须是逗号分隔的正整数") from error + if not workers or any(worker <= 0 for worker in workers): + raise argparse.ArgumentTypeError("--workers 必须是逗号分隔的正整数") + return workers + + +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, pull: bool) -> dict[str, Any]: + """解析镜像本地身份与发布标签,不把可变 tag 当成事实源。""" + runtime_reference = reference + try: + if pull: + client.images.pull(reference) + image = client.images.get(reference) + except Exception as error: + if pull: + raise HarnessInvalid(f"无法读取镜像 {reference}:{error}") from error + image_id = reference.rsplit("@", 1)[-1] + try: + image = client.images.get(image_id) + except Exception as image_id_error: + raise HarnessInvalid(f"无法读取镜像 {reference}:{error}") from image_id_error + if image.id != image_id: + raise HarnessInvalid(f"本地镜像 ID 与引用不一致:{reference}") + runtime_reference = image.id + attrs = image.attrs or {} + labels = (attrs.get("Config") or {}).get("Labels") or {} + source_revision = labels.get("org.moviepilot.source-revision") or labels.get( + "org.opencontainers.image.revision" + ) + version = labels.get("org.opencontainers.image.version") + if not source_revision: + raise HarnessInvalid(f"镜像 {reference} 缺少源码 revision 标签") + if not version: + raise HarnessInvalid(f"镜像 {reference} 缺少版本标签") + return { + "reference": reference, + "runtime_reference": runtime_reference, + "image_id": attrs.get("Id") or getattr(image, "id", ""), + "size_bytes": int(attrs.get("Size") or 0), + "rootfs_layers": list((attrs.get("RootFS") or {}).get("Layers") or []), + "repo_digests": sorted(attrs.get("RepoDigests") or []), + "source_revision": source_revision, + "version": version, + } + + +PREFLIGHT_SCRIPT = r""" +import hashlib +import importlib +import importlib.metadata +import json +import os +import platform +import subprocess +import sys +import sysconfig + +def version(name): + try: + return importlib.metadata.version(name) + except importlib.metadata.PackageNotFoundError: + return None + +def canonical_name(distribution): + return (distribution.metadata.get("Name") or "").strip().lower().replace("_", "-") + +packages = sorted( + f"{canonical_name(distribution)}=={distribution.version}" + for distribution in importlib.metadata.distributions() +) +native_distributions = [] +for distribution in importlib.metadata.distributions(): + native_files = sorted( + str(path) + for path in (distribution.files or ()) + if str(path).lower().endswith((".so", ".pyd", ".dylib")) + ) + if not native_files: + continue + wheel = distribution.read_text("WHEEL") or "" + native_distributions.append({ + "name": canonical_name(distribution), + "version": distribution.version, + "files": native_files, + "wheel_tags": sorted( + line.split(":", 1)[1].strip() + for line in wheel.splitlines() + if line.startswith("Tag:") + ), + }) +native_distributions.sort(key=lambda item: (item["name"], item["version"])) + +imports = {} +def probe(name, module_name): + before = sys._is_gil_enabled() + try: + module = importlib.import_module(module_name) + except Exception as error: + imports[name] = { + "module": module_name, + "imported": False, + "error": f"{type(error).__name__}: {error}", + "gil_before": before, + "gil_after": sys._is_gil_enabled(), + } + return None + imports[name] = { + "module": module_name, + "imported": True, + "gil_before": before, + "gil_after": sys._is_gil_enabled(), + } + return module + +moviepilot_rust = probe("moviepilot-rust", "moviepilot_rust") +probe("site-resource", "app.application.site.sites") +probe("lxml", "lxml.etree") +probe("orjson", "orjson") +probe("zstandard", "zstandard") +probe("bcrypt", "bcrypt._bcrypt") +probe("pydantic-core", "pydantic_core._pydantic_core") +probe("pillow", "PIL._imaging") +probe("pillow-avif-plugin", "pillow_avif") +probe("cryptography", "cryptography.hazmat.bindings._rust") +probe("greenlet", "greenlet._greenlet") +probe("asyncpg", "asyncpg") +probe("brotli", "brotli") +probe("oss2", "oss2") + +crcmod = probe("crcmod-plus", "crcmod.crcmod") +native = { + "crcmod_extension": bool(crcmod and crcmod._usingExtension), +} +if sysconfig.get_config_var("Py_GIL_DISABLED") == 1: + psycopg = probe("psycopg", "psycopg") + native.update({ + "psycopg_impl": psycopg.pq.__impl__ if psycopg else None, + }) +else: + probe("psycopg2-binary", "psycopg2") + probe("zhconv-rs", "zhconv_rs") + +uv_check = subprocess.run( + ["/usr/local/bin/uv", "pip", "check", "--python", sys.executable], + text=True, + capture_output=True, + check=False, +) +runtime_group = ( + "runtime-free-threaded" + if sysconfig.get_config_var("Py_GIL_DISABLED") == 1 + else "runtime-standard" +) +sync_environment = os.environ.copy() +sync_environment["UV_PROJECT_ENVIRONMENT"] = sys.prefix +project_sync_check = subprocess.run( + [ + "/usr/local/bin/uv", "sync", "--project", "/app", "--locked", "--offline", + "--inexact", "--no-dev", "--no-install-project", "--check", + "--python", sys.executable, "--no-default-groups", "--group", runtime_group, + ], + text=True, + capture_output=True, + check=False, + env=sync_environment, +) + +print(json.dumps({ + "python_version": sys.version.split()[0], + "python_implementation": platform.python_implementation(), + "machine": platform.machine(), + "libc": platform.libc_ver(), + "soabi": sysconfig.get_config_var("SOABI"), + "multiarch": sysconfig.get_config_var("MULTIARCH"), + "gil_disabled": sysconfig.get_config_var("Py_GIL_DISABLED") == 1, + "gil_enabled": sys._is_gil_enabled(), + "thread_inherit_context": sys.flags.thread_inherit_context, + "moviepilot_rust_version": version("moviepilot-rust"), + "rust_available": bool(moviepilot_rust and moviepilot_rust.is_available()), + "has_jieba_cut": callable(getattr(moviepilot_rust, "jieba_cut", None)), + "has_zhconv_fast": callable(getattr(moviepilot_rust, "zhconv_fast", None)), + "packages": { + "bcrypt": version("bcrypt"), + "brotli": version("brotli"), + "lxml": version("lxml"), + "orjson": version("orjson"), + "zstandard": version("zstandard"), + "crcmod": version("crcmod"), + "crcmod-plus": version("crcmod-plus"), + "psycopg": version("psycopg"), + "psycopg2-binary": version("psycopg2-binary"), + "zhconv-rs": version("zhconv-rs"), + }, + "installed_packages": packages, + "installed_packages_sha256": hashlib.sha256("\n".join(packages).encode()).hexdigest(), + "native_distributions": native_distributions, + "imports": imports, + "native": native, + "uv_pip_check": { + "returncode": uv_check.returncode, + "stdout": uv_check.stdout.strip(), + "stderr": uv_check.stderr.strip(), + }, + "uv_project_sync_check": { + "runtime_group": runtime_group, + "returncode": project_sync_check.returncode, + "stdout": project_sync_check.stdout.strip(), + "stderr": project_sync_check.stderr.strip(), + }, + "gil_enabled_after_imports": sys._is_gil_enabled(), +}, sort_keys=True)) +""" + + +HOTSPOT_SCRIPT = r""" +import concurrent.futures +import hashlib +import json +import os +import sys +import time + +fixture = json.loads(os.environ["MP_FT_FIXTURE"]) +expected_hash = os.environ["MP_FT_FIXTURE_SHA256"] +actual_hash = hashlib.sha256( + json.dumps(fixture, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") +).hexdigest() +assert actual_hash == expected_hash +iterations = int(os.environ["MP_FT_ITERATIONS"]) +workers = tuple(int(item) for item in os.environ["MP_FT_WORKERS"].split(",")) +variant = os.environ["MP_FT_VARIANT"] +sample_index = int(os.environ["MP_FT_SAMPLE_INDEX"]) + +import moviepilot_rust +from app.adapters.system import rust as rust_accel +from app.domain.meta.runtime import configure_recognition_runtime +from app.domain.metainfo import MetaInfo +from app.runtime.config import settings + +def digest_rows(rows): + return hashlib.sha256( + json.dumps(rows, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + +def run_application(accelerator): + configure_recognition_runtime( + media_extensions_provider=lambda: (), + audio_extensions_provider=lambda: (), + accelerator=accelerator, + ) + started = time.perf_counter() + rows = [] + for index in range(iterations): + meta = MetaInfo(fixture[index % len(fixture)]) + rows.append((meta.name, meta.begin_season, meta.begin_episode, meta.resource_pix)) + elapsed = time.perf_counter() - started + return { + "operations": iterations, + "seconds": elapsed, + "throughput_ops_s": iterations / elapsed, + "checksum": digest_rows(rows), + } + +settings.RUST_ACCEL = True +application = {} +mode_order = ["rust_on"] +if variant == "v3": + mode_order = ["rust_on", "rust_off"] if sample_index % 2 == 0 else ["rust_off", "rust_on"] +for mode in mode_order: + application[mode] = run_application(rust_accel if mode == "rust_on" else None) + +sample_words = moviepilot_rust.jieba_cut("MoviePilot中文分词性能验证", hmm=False, cut_all=False) +assert sample_words + +def parse(index): + result = moviepilot_rust.parse_metainfo_fast(fixture[index % len(fixture)], "", {}) + return (result.get("name"), result.get("begin_season"), result.get("begin_episode"), result.get("resource_pix")) + +rust_concurrency = {} +for worker in workers: + started = time.perf_counter() + with concurrent.futures.ThreadPoolExecutor(max_workers=worker) as executor: + rows = list(executor.map(parse, range(iterations))) + elapsed = time.perf_counter() - started + rust_concurrency[str(worker)] = { + "operations": iterations, + "seconds": elapsed, + "throughput_ops_s": iterations / elapsed, + "checksum": digest_rows(rows), + } + +from app.foundation.text import contains_chinese, remove_punctuation + +def python_cpu(index): + value = fixture[index % len(fixture)] + cleaned = remove_punctuation(value, replacement=" ", allow_space=True) + score = 0 + for repeat in range(32): + score += sum((position + repeat + 1) * ord(character) for position, character in enumerate(cleaned)) + return (cleaned, contains_chinese(cleaned), score) + +python_concurrency = {} +for worker in workers: + started = time.perf_counter() + with concurrent.futures.ThreadPoolExecutor(max_workers=worker) as executor: + rows = list(executor.map(python_cpu, range(iterations))) + elapsed = time.perf_counter() - started + python_concurrency[str(worker)] = { + "operations": iterations, + "seconds": elapsed, + "throughput_ops_s": iterations / elapsed, + "checksum": digest_rows(rows), + } + +print(json.dumps({ + "fixture_sha256": actual_hash, + "application": application, + "mode_order": mode_order, + "rust_concurrency": rust_concurrency, + "python_concurrency": python_concurrency, + "jieba_cut": {"words": sample_words, "available": True}, + "gil_enabled_after_hotspots": sys._is_gil_enabled(), +}, ensure_ascii=False, sort_keys=True)) +""" + + +POSTGRESQL_SCRIPT = r""" +import json +from app.db.engine import _sync_postgresql_driver +from app.runtime.config import settings + +driver = _sync_postgresql_driver() +scheme = settings.DB_POSTGRESQL_URL(driver).split(":", 1)[0] +print(json.dumps({"driver": driver, "scheme": scheme}, sort_keys=True)) +""" + + +SQLITE_SCRIPT = r""" +import asyncio +import hashlib +import json +import os +import sqlite3 +import tempfile +import time + +import aiosqlite + +iterations = int(os.environ["MP_FT_ITERATIONS"]) +handle, path = tempfile.mkstemp(prefix="moviepilot-ft-sqlite-", suffix=".db") +os.close(handle) + +connection = sqlite3.connect(path) +connection.execute("PRAGMA journal_mode=WAL") +connection.execute("CREATE TABLE sample (id INTEGER PRIMARY KEY, title TEXT NOT NULL, score INTEGER NOT NULL)") +rows = [(index, f"title-{index:04d}", index % 97) for index in range(512)] +started = time.perf_counter() +with connection: + connection.executemany("INSERT INTO sample VALUES (?, ?, ?)", rows) +insert_seconds = time.perf_counter() - started + +started = time.perf_counter() +sync_values = [] +for index in range(iterations): + sync_values.append( + connection.execute( + "SELECT title, score FROM sample WHERE id = ?", (index % len(rows),) + ).fetchone() + ) +sync_seconds = time.perf_counter() - started +connection.close() + +async def run_async(): + values = [] + async with aiosqlite.connect(path) as database: + started_at = time.perf_counter() + for index in range(iterations): + async with database.execute( + "SELECT title, score FROM sample WHERE id = ?", (index % len(rows),) + ) as cursor: + values.append(await cursor.fetchone()) + return values, time.perf_counter() - started_at + +async_values, async_seconds = asyncio.run(run_async()) +os.unlink(path) + +def checksum(values): + return hashlib.sha256(json.dumps(values, sort_keys=True).encode()).hexdigest() + +print(json.dumps({ + "rows": len(rows), + "iterations": iterations, + "insert_seconds": insert_seconds, + "sync": { + "seconds": sync_seconds, + "throughput_ops_s": iterations / sync_seconds, + "checksum": checksum(sync_values), + }, + "async": { + "seconds": async_seconds, + "throughput_ops_s": iterations / async_seconds, + "checksum": checksum(async_values), + }, +}, sort_keys=True)) +""" + + +API_SCRIPT = r""" +import hashlib +import json +import os +import statistics +import time +import urllib.request + +iterations = int(os.environ["MP_FT_API_ITERATIONS"]) +token = os.environ["MP_FT_API_TOKEN"] +endpoints = { + "health_ready": "/health/ready", + "dashboard_statistic": f"/api/v1/dashboard/statistic2?token={token}", + "subscribe_list": f"/api/v1/subscribe/list?token={token}", + "system_env": f"/api/v1/system/env?token={token}", +} + +def percentile(values, fraction): + ordered = sorted(values) + index = max(0, min(len(ordered) - 1, int(len(ordered) * fraction + 0.999999) - 1)) + return ordered[index] + +results = {} +for name, path in endpoints.items(): + latencies = [] + bodies = [] + status = None + for _ in range(iterations): + started = time.perf_counter() + with urllib.request.urlopen(f"http://127.0.0.1:3001{path}", timeout=10) as response: + status = response.status + bodies.append(response.read()) + latencies.append((time.perf_counter() - started) * 1000) + results[name] = { + "status": status, + "iterations": iterations, + "p50_ms": statistics.median(latencies), + "p95_ms": percentile(latencies, 0.95), + "max_ms": max(latencies), + "last_body_sha256": hashlib.sha256(bodies[-1]).hexdigest(), + } + if name == "system_env": + payload = json.loads(bodies[-1]) + data = payload.get("data") or {} + results[name]["runtime"] = { + "rust_required": data.get("RUST_ACCEL_REQUIRED"), + "rust_enabled": data.get("RUST_ACCEL_ENABLED"), + "gil_enabled": data.get("PYTHON_GIL_ENABLED"), + } + +print(json.dumps(results, sort_keys=True)) +""" + + +def run_json_command( + client, + image: str, + script: str, + *, + environment: Optional[dict[str, str]] = None, +) -> dict[str, Any]: + """在无网络临时容器中执行 JSON 探针。""" + try: + output = client.containers.run( + image, + ["-c", script], + entrypoint="/opt/venv/bin/python", + environment=environment or {}, + network_disabled=True, + remove=True, + stdout=True, + stderr=True, + ) + text = output.decode("utf-8") if isinstance(output, bytes) else str(output) + return json.loads(text.strip().splitlines()[-1]) + except Exception as error: + raise HarnessInvalid(f"镜像探针执行失败:{error}") from error + + +def exec_json_in_container( + container, + script: str, + *, + environment: Optional[dict[str, str]] = None, +) -> dict[str, Any]: + """在已经启动的候选容器中执行 JSON 探针。""" + command = ["/usr/bin/env"] + command.extend(f"{key}={value}" for key, value in (environment or {}).items()) + command.extend(["/opt/venv/bin/python", "-c", script]) + result = container.exec_run(command) + output = result.output.decode("utf-8", errors="replace") + if result.exit_code != 0: + raise HarnessInvalid(f"运行态探针失败(exit={result.exit_code}):{output[-2000:]}") + return json.loads(output.strip().splitlines()[-1]) + + +def capture_engine_stats(container) -> dict[str, int]: + """读取容器 cgroup 内存与网络累计值。""" + try: + stats = container.stats(stream=False, one_shot=True) + except TypeError: # pragma: no cover - 旧 Docker SDK 兼容 + stats = container.stats(stream=False) + memory = stats.get("memory_stats") or {} + detail = memory.get("stats") or {} + usage = int(memory.get("usage") or 0) + inactive = int(detail.get("inactive_file") or detail.get("total_inactive_file") or 0) + networks = stats.get("networks") or {} + return { + "memory_current_bytes": usage, + "inactive_file_bytes": inactive, + "working_set_bytes": max(0, usage - inactive), + "network_rx_bytes": sum(int(item.get("rx_bytes") or 0) for item in networks.values()), + "network_tx_bytes": sum(int(item.get("tx_bytes") or 0) for item in networks.values()), + } + + +def capture_processes(container) -> dict[str, Any]: + """读取容器进程的 RSS、PSS、USS 与线程数。""" + result = container.exec_run(["/bin/bash", "/app/scripts/perf/instrument/collect_proc.sh"]) + output = result.output.decode("utf-8", errors="replace") + if result.exit_code != 0: + raise HarnessInvalid(f"进程采样失败:{output[-2000:]}") + processes = [] + for line in output.splitlines()[1:]: + fields = line.split("\t", 8) + if len(fields) != 9: + continue + pid, ppid, threads, rss, pss, uss, comm, executable, command_line = fields + processes.append({ + "pid": int(pid), + "ppid": int(ppid), + "threads": int(threads), + "rss_kib": int(rss), + "pss_kib": int(pss), + "uss_kib": int(uss), + "comm": comm, + "executable": executable, + "cmdline": command_line, + }) + if not processes: + raise HarnessInvalid("进程采样结果为空") + python_processes = [ + process + for process in processes + if "python" in Path(process["executable"]).name.lower() + ] + return { + "items": sorted(processes, key=lambda item: item["pid"]), + "totals": { + "rss_kib": sum(item["rss_kib"] for item in processes), + "pss_kib": sum(item["pss_kib"] for item in processes), + "uss_kib": sum(item["uss_kib"] for item in processes), + "threads": sum(item["threads"] for item in processes), + }, + "main_python": max( + python_processes, key=lambda item: item["pss_kib"], default=None + ), + } + + +def capture_runtime_measurement(container) -> dict[str, Any]: + """采集同一时点的 cgroup 与进程内存。""" + return { + "captured_at": utc_now(), + "engine": capture_engine_stats(container), + "processes": capture_processes(container), + } + + +def validate_preflight(variant: str, payload: dict[str, Any]) -> list[str]: + """验证解释器、GIL、Rust 与互斥原生依赖合同。""" + errors = [] + packages = payload.get("packages") or {} + native = payload.get("native") or {} + imports = payload.get("imports") or {} + required_imports = COMMON_PREFLIGHT_IMPORTS | PROFILE_PREFLIGHT_IMPORTS[variant] + missing_imports = sorted(required_imports - imports.keys()) + failed_imports = sorted( + name for name in required_imports if not (imports.get(name) or {}).get("imported") + ) + if not str(payload.get("python_version") or "").startswith("3.14."): + errors.append("Python 必须为 3.14.x") + if payload.get("python_implementation") != "CPython": + errors.append("运行时必须为 CPython") + if not str(payload.get("moviepilot_rust_version") or "").startswith("0.3."): + errors.append("moviepilot-rust 必须为 0.3.x") + if not payload.get("rust_available") or not payload.get("has_jieba_cut"): + errors.append("moviepilot-rust 必须可用并提供 jieba_cut") + if (payload.get("uv_project_sync_check") or {}).get("returncode") != 0: + errors.append("uv 项目 profile 一致性检查未通过") + pip_check = payload.get("uv_pip_check") or {} + pip_check_errors = [ + line.strip() + for line in str(pip_check.get("stderr") or "").splitlines() + if line.strip().startswith("The package `") + ] + allowed_pip_check_errors = [ + "The package `oss2` requires `crcmod>=1.7`, but it's not installed" + ] + if pip_check_errors and pip_check_errors != allowed_pip_check_errors: + errors.append("uv pip check 出现未声明的包元数据不兼容") + if not pip_check_errors and pip_check.get("returncode") != 0: + errors.append("uv pip check 未通过且未返回可识别的不兼容项") + if missing_imports: + errors.append(f"缺少核心组件导入结果:{', '.join(missing_imports)}") + elif failed_imports: + errors.append(f"核心组件导入失败:{', '.join(failed_imports)}") + for package, version in { + "brotli": "1.2.0", + "orjson": "3.12.0", + }.items(): + if packages.get(package) != version: + errors.append(f"{package} 必须为 {version}") + if variant == "v3": + expected = { + "gil_disabled": False, + "gil_enabled": True, + "thread_inherit_context": 0, + "has_zhconv_fast": False, + "gil_enabled_after_imports": True, + } + for key, value in expected.items(): + if payload.get(key) != value: + errors.append(f"标准镜像 {key} 应为 {value!r}") + if packages.get("lxml") != "6.1.2": + errors.append("标准镜像必须使用 lxml 6.1.2") + if packages.get("bcrypt") != "4.3.0": + errors.append("标准镜像必须使用 bcrypt 4.3.0") + if packages.get("crcmod-plus") != "2.3.1" or packages.get("crcmod") is not None: + errors.append("标准镜像必须使用 crcmod-plus 2.3.1") + if packages.get("zhconv-rs") is None or packages.get("psycopg2-binary") is None: + errors.append("标准镜像必须保留 zhconv-rs 与 psycopg2-binary") + if packages.get("psycopg") is not None: + errors.append("标准镜像不得混入 psycopg profile") + if not native.get("crcmod_extension"): + errors.append("标准镜像必须使用 crcmod-plus 原生实现") + else: + expected = { + "gil_disabled": True, + "gil_enabled": False, + "thread_inherit_context": 0, + "has_zhconv_fast": True, + "gil_enabled_after_imports": False, + } + for key, value in expected.items(): + if payload.get(key) != value: + errors.append(f"free-threaded 镜像 {key} 应为 {value!r}") + required_versions = { + "bcrypt": "5.0.0", + "lxml": "7.0.0b1", + "orjson": "3.12.0", + "crcmod-plus": "2.3.1", + "psycopg": "3.3.4", + } + for package, version in required_versions.items(): + if packages.get(package) != version: + errors.append(f"free-threaded 镜像 {package} 必须为 {version}") + if ( + packages.get("zhconv-rs") is not None + or packages.get("psycopg2-binary") is not None + or packages.get("crcmod") is not None + ): + errors.append("free-threaded 镜像不得混入标准原生依赖 profile") + if native.get("psycopg_impl") != "c" or not native.get("crcmod_extension"): + errors.append("free-threaded 镜像必须使用 psycopg C 与 crcmod 原生实现") + gil_enabling_imports = sorted( + name + for name, result in imports.items() + if result.get("imported") and result.get("gil_after") is not False + ) + if gil_enabling_imports: + errors.append( + "free-threaded 核心组件导入后启用了 GIL:" + + ", ".join(gil_enabling_imports) + ) + return errors + + +def startup_sample(client, args: argparse.Namespace, image: str, variant: str, index: int) -> dict[str, Any]: + """用空白配置卷完成 readiness、API 与资源采样。""" + name = f"mpftab-{args.campaign}-{variant}-{index}" + volume_name = f"{name}-config" + labels = {"org.moviepilot.perf.campaign": args.campaign, "org.moviepilot.perf.role": "ft-ab"} + volume = None + container = None + try: + volume = client.volumes.create(name=volume_name, labels=labels) + environment = dict(LAB_ENVIRONMENT) + environment["MOVIEPILOT_BACKEND_READY_TIMEOUT"] = str(args.ready_timeout) + container = client.containers.create( + image, + name=name, + detach=True, + environment=environment, + volumes={volume.name: {"bind": "/config", "mode": "rw"}}, + network_disabled=True, + nano_cpus=int(args.cpus * 1_000_000_000), + mem_limit=args.memory, + memswap_limit=args.memory, + labels=labels, + ) + started = time.monotonic() + container.start() + deadline = started + args.ready_timeout + while time.monotonic() < deadline: + container.reload() + if container.status != "running": + raise HarnessInvalid(f"{name} 在 readiness 前退出") + probe = container.exec_run( + ["curl", "-fsS", "--max-time", "2", "http://127.0.0.1:3001/health/ready"] + ) + if probe.exit_code == 0: + break + time.sleep(0.5) + else: + raise HarnessInvalid(f"{name} readiness 超时") + ready_seconds = time.monotonic() - started + time.sleep(args.settle_seconds) + idle = capture_runtime_measurement(container) + api = exec_json_in_container( + container, + API_SCRIPT, + environment={ + "MP_FT_API_ITERATIONS": str(args.api_iterations), + "MP_FT_API_TOKEN": LAB_ENVIRONMENT["API_TOKEN"], + }, + ) + post_api = capture_runtime_measurement(container) + logs = container.logs(stdout=True, stderr=True).decode("utf-8", errors="replace") + runtime_log_lines = [ + line + for line in logs.splitlines() + if "GIL" in line or "free-threaded" in line or "Rust" in line + ] + return { + "ready_seconds": ready_seconds, + "settle_seconds": args.settle_seconds, + "idle": idle, + "api": api, + "post_api": post_api, + "runtime_log_lines": runtime_log_lines, + } + except HarnessInvalid: + raise + except Exception as error: + raise HarnessInvalid(f"启动样本 {name} 执行失败:{error}") from error + finally: + try: + if container is not None: + container.remove(force=True, v=False) + except Exception: + pass + try: + if volume is not None: + volume.remove(force=True) + except Exception: + pass + + +def execute_sample(client, args: argparse.Namespace, variant: str, index: int, image: str) -> dict[str, Any]: + """执行单个启动、热点与 PostgreSQL 驱动样本。""" + hotspot_environment = { + "MP_FT_FIXTURE": canonical_json(FIXTURE), + "MP_FT_FIXTURE_SHA256": FIXTURE_SHA256, + "MP_FT_ITERATIONS": str(args.iterations), + "MP_FT_WORKERS": ",".join(str(worker) for worker in args.workers), + "MP_FT_VARIANT": variant, + "MP_FT_SAMPLE_INDEX": str(index), + "CONFIG_DIR": "/tmp/moviepilot-ft-ab", + } + result = { + "variant": variant, + "sample_index": index, + "image": image, + "started_at": utc_now(), + "startup": startup_sample(client, args, image, variant, index), + "hotspots": run_json_command( + client, image, HOTSPOT_SCRIPT, environment=hotspot_environment + ), + "sqlite": run_json_command( + client, + image, + SQLITE_SCRIPT, + environment={"MP_FT_ITERATIONS": str(args.iterations)}, + ), + "postgresql": { + "probe": "app.db.engine._sync_postgresql_driver", + "result": run_json_command( + client, + image, + POSTGRESQL_SCRIPT, + environment={"CONFIG_DIR": "/tmp/moviepilot-ft-ab"}, + ), + }, + } + result["completed_at"] = utc_now() + return result + + +def median_metric(samples: list[dict[str, Any]], variant: str, path: tuple[str, ...]) -> float: + """读取某一变体的数值路径并返回中位数。""" + values = [] + for sample in samples: + if sample["variant"] != variant: + continue + value: Any = sample + for key in path: + value = value[key] + values.append(float(value)) + if len(values) != 3: + raise HarnessInvalid(f"{variant} 的 {'.'.join(path)} 必须有三个有效样本") + return statistics.median(values) + + +def evaluate_samples(result: dict[str, Any]) -> tuple[dict[str, Any], list[str], list[str]]: + """验证样本语义并根据显式阈值区分 pass 与 regression。""" + samples = result["samples"] + invalid = [] + regressions = [] + for sample in samples: + variant = sample["variant"] + hotspots = sample["hotspots"] + startup = sample["startup"] + expected_gil = variant == "v3" + if hotspots.get("fixture_sha256") != FIXTURE_SHA256: + invalid.append(f"{variant}-{sample['sample_index']} fixture hash 不一致") + if not hotspots.get("jieba_cut", {}).get("available"): + invalid.append(f"{variant}-{sample['sample_index']} jieba_cut 未执行") + if variant == "v3t" and hotspots.get("gil_enabled_after_hotspots") is not False: + invalid.append(f"{variant}-{sample['sample_index']} 热点后 GIL 被重新启用") + application = hotspots.get("application") or {} + if variant == "v3": + if "rust_off" not in application: + invalid.append(f"{variant}-{sample['sample_index']} 缺少 Rust 关闭样本") + elif application["rust_off"]["checksum"] != application["rust_on"]["checksum"]: + invalid.append(f"{variant}-{sample['sample_index']} Rust 开关改变识别语义") + expected_driver = None if variant == "v3" else "psycopg" + expected_scheme = "postgresql" if variant == "v3" else "postgresql+psycopg" + if sample["postgresql"].get("result") != { + "driver": expected_driver, + "scheme": expected_scheme, + }: + invalid.append(f"{variant}-{sample['sample_index']} PostgreSQL 驱动合同错误") + sqlite = sample.get("sqlite") or {} + if (sqlite.get("sync") or {}).get("checksum") != (sqlite.get("async") or {}).get( + "checksum" + ): + invalid.append(f"{variant}-{sample['sample_index']} SQLite 同步/异步结果不一致") + if not (startup.get("idle") or {}).get("processes", {}).get("main_python"): + invalid.append(f"{variant}-{sample['sample_index']} 未采集到主 Python 进程") + api_results = startup.get("api") or {} + missing_endpoints = sorted(set(REQUIRED_API_ENDPOINTS) - api_results.keys()) + if missing_endpoints: + invalid.append( + f"{variant}-{sample['sample_index']} 缺少 API 样本:{', '.join(missing_endpoints)}" + ) + for endpoint in REQUIRED_API_ENDPOINTS: + api_result = api_results.get(endpoint) or {} + if api_result.get("status") != 200: + invalid.append( + f"{variant}-{sample['sample_index']} API {endpoint} 状态不是 200" + ) + runtime = api_results.get("system_env", {}).get("runtime") or {} + if runtime.get("gil_enabled") is not expected_gil: + invalid.append(f"{variant}-{sample['sample_index']} API GIL 状态错误") + if runtime.get("rust_enabled") is not True: + invalid.append(f"{variant}-{sample['sample_index']} API Rust 状态未启用") + if runtime.get("rust_required") is not (variant == "v3t"): + invalid.append(f"{variant}-{sample['sample_index']} API Rust required 状态错误") + for hotspot_name in ("rust_concurrency", "python_concurrency"): + checksums = { + item["checksum"] + for item in (hotspots.get(hotspot_name) or {}).values() + } + if len(checksums) != 1: + invalid.append( + f"{variant}-{sample['sample_index']} {hotspot_name} 结果不稳定" + ) + + application_checksums = { + sample["hotspots"]["application"]["rust_on"]["checksum"] + for sample in samples + } + if len(application_checksums) != 1: + invalid.append("两个镜像或重复样本的 Rust-on 识别语义不一致") + for hotspot_name in ("rust_concurrency", "python_concurrency"): + cross_sample_checksums = { + next(iter(sample["hotspots"][hotspot_name].values()))["checksum"] + for sample in samples + } + if len(cross_sample_checksums) != 1: + invalid.append(f"两个镜像或重复样本的 {hotspot_name} 语义不一致") + sqlite_checksums = { + sample["sqlite"]["sync"]["checksum"] + for sample in samples + } + if len(sqlite_checksums) != 1: + invalid.append("两个镜像或重复样本的 SQLite 结果不一致") + + if invalid: + return {}, invalid, regressions + + workers = result["workers"] + max_worker = str(max(workers)) + endpoints = REQUIRED_API_ENDPOINTS + preflight = result["preflight"] + package_sets = { + variant: set(preflight[variant]["payload"]["installed_packages"]) + for variant in ("v3", "v3t") + } + summary = { + "image_size_bytes": { + variant: result["images"][variant]["size_bytes"] + for variant in ("v3", "v3t") + }, + "installed_packages": { + variant: { + "count": len(package_sets[variant]), + "sha256": preflight[variant]["payload"]["installed_packages_sha256"], + } + for variant in ("v3", "v3t") + }, + "package_profile_diff": { + "v3_only": sorted(package_sets["v3"] - package_sets["v3t"]), + "v3t_only": sorted(package_sets["v3t"] - package_sets["v3"]), + }, + "native_distribution_count": { + variant: len(preflight[variant]["payload"]["native_distributions"]) + for variant in ("v3", "v3t") + }, + "startup_ready_seconds": { + variant: median_metric(samples, variant, ("startup", "ready_seconds")) + for variant in ("v3", "v3t") + }, + "idle_working_set_bytes": { + variant: median_metric( + samples, variant, ("startup", "idle", "engine", "working_set_bytes") + ) + for variant in ("v3", "v3t") + }, + "idle_process_totals": { + metric: { + variant: median_metric( + samples, + variant, + ("startup", "idle", "processes", "totals", metric), + ) + for variant in ("v3", "v3t") + } + for metric in ("rss_kib", "pss_kib", "uss_kib", "threads") + }, + "api": { + endpoint: { + metric: { + variant: median_metric( + samples, + variant, + ("startup", "api", endpoint, metric), + ) + for variant in ("v3", "v3t") + } + for metric in ("p50_ms", "p95_ms", "max_ms") + } + for endpoint in endpoints + }, + "sqlite_throughput_ops_s": { + mode: { + variant: median_metric( + samples, variant, ("sqlite", mode, "throughput_ops_s") + ) + for variant in ("v3", "v3t") + } + for mode in ("sync", "async") + }, + "application_seconds": { + "v3_python": median_metric( + samples, "v3", ("hotspots", "application", "rust_off", "seconds") + ), + "v3_rust": median_metric( + samples, "v3", ("hotspots", "application", "rust_on", "seconds") + ), + "v3t_rust": median_metric( + samples, "v3t", ("hotspots", "application", "rust_on", "seconds") + ), + }, + "max_worker": int(max_worker), + "python_throughput_ops_s": { + str(worker): { + variant: median_metric( + samples, + variant, + ("hotspots", "python_concurrency", str(worker), "throughput_ops_s"), + ) + for variant in ("v3", "v3t") + } + for worker in workers + }, + "rust_throughput_ops_s": { + str(worker): { + variant: median_metric( + samples, + variant, + ("hotspots", "rust_concurrency", str(worker), "throughput_ops_s"), + ) + for variant in ("v3", "v3t") + } + for worker in workers + }, + } + thresholds = result["thresholds"] + startup_ratio = ( + summary["startup_ready_seconds"]["v3t"] + / summary["startup_ready_seconds"]["v3"] + ) + v3_rust_ratio = ( + summary["application_seconds"]["v3_rust"] + / summary["application_seconds"]["v3_python"] + ) + v3t_rust_ratio = ( + summary["application_seconds"]["v3t_rust"] + / summary["application_seconds"]["v3_rust"] + ) + ft_throughput_ratio = ( + summary["python_throughput_ops_s"][max_worker]["v3t"] + / summary["python_throughput_ops_s"][max_worker]["v3"] + ) + memory_ratio = summary["idle_working_set_bytes"]["v3t"] / summary[ + "idle_working_set_bytes" + ]["v3"] + api_p95_ratios = { + endpoint: values["p95_ms"]["v3t"] / values["p95_ms"]["v3"] + for endpoint, values in summary["api"].items() + } + summary["ratios"] = { + "startup_ft_over_v3": startup_ratio, + "v3_rust_over_python": v3_rust_ratio, + "v3t_rust_over_v3_rust": v3t_rust_ratio, + "ft_max_worker_throughput_over_v3": ft_throughput_ratio, + "idle_working_set_ft_over_v3": memory_ratio, + "api_p95_ft_over_v3": api_p95_ratios, + } + if startup_ratio > thresholds["max_startup_ratio"]: + regressions.append("free-threaded startup 超出允许比例") + if v3_rust_ratio > thresholds["max_v3_rust_over_python_ratio"]: + regressions.append("标准镜像启用 Rust 后热点变慢") + if v3t_rust_ratio > thresholds["max_v3t_rust_over_v3_rust_ratio"]: + regressions.append("V3t Rust 相对 V3 Rust 应用热点退化") + if ft_throughput_ratio < thresholds["min_ft_max_worker_throughput_ratio"]: + regressions.append("free-threaded 高并发热点未达到最低吞吐收益") + if memory_ratio > thresholds["max_idle_memory_ratio"]: + regressions.append("free-threaded 空载 working set 超出允许比例") + for endpoint, ratio in api_p95_ratios.items(): + if ratio > thresholds["max_api_p95_ratio"]: + regressions.append(f"free-threaded API {endpoint} p95 超出允许比例") + return summary, invalid, regressions + + +def build_markdown(result: dict[str, Any]) -> str: + """生成不包含本机路径、凭据或用户数据的审核报告。""" + lines = [ + "# MoviePilot free-threaded A/B", + "", + f"- Verdict: **{result['verdict']}**", + f"- Schema: `{result['schema_version']}`", + f"- Fixture: `{result['fixture']['sha256']}` ({result['fixture']['count']} titles)", + f"- Source revision: `{result.get('source_revision') or 'unverified'}`", + f"- Standard: `{result['images']['v3']['reference']}`", + f"- Free-threaded: `{result['images']['v3t']['reference']}`", + "", + ] + if result.get("summary"): + summary = result["summary"] + lines.extend( + [ + "| Metric | v3 | v3t |", + "| --- | ---: | ---: |", + ( + "| Startup ready median (s) | " + f"{summary['startup_ready_seconds']['v3']:.3f} | " + f"{summary['startup_ready_seconds']['v3t']:.3f} |" + ), + ( + "| Image size (MiB) | " + f"{summary['image_size_bytes']['v3'] / 1024 / 1024:.1f} | " + f"{summary['image_size_bytes']['v3t'] / 1024 / 1024:.1f} |" + ), + ( + "| Idle working set (MiB) | " + f"{summary['idle_working_set_bytes']['v3'] / 1024 / 1024:.1f} | " + f"{summary['idle_working_set_bytes']['v3t'] / 1024 / 1024:.1f} |" + ), + ( + "| Main workload PSS (MiB) | " + f"{summary['idle_process_totals']['pss_kib']['v3'] / 1024:.1f} | " + f"{summary['idle_process_totals']['pss_kib']['v3t'] / 1024:.1f} |" + ), + ( + f"| {summary['max_worker']}-thread pure Python probe (ops/s) | " + f"{summary['python_throughput_ops_s'][str(summary['max_worker'])]['v3']:.1f} | " + f"{summary['python_throughput_ops_s'][str(summary['max_worker'])]['v3t']:.1f} |" + ), + ( + "| SQLite sync throughput (ops/s) | " + f"{summary['sqlite_throughput_ops_s']['sync']['v3']:.1f} | " + f"{summary['sqlite_throughput_ops_s']['sync']['v3t']:.1f} |" + ), + "", + ] + ) + lines.extend( + [ + "| Application fixture median (s) | V3 + Python | V3 + Rust | V3t + Rust |", + "| --- | ---: | ---: | ---: |", + ( + "| Media recognition | " + f"{summary['application_seconds']['v3_python']:.6f} | " + f"{summary['application_seconds']['v3_rust']:.6f} | " + f"{summary['application_seconds']['v3t_rust']:.6f} |" + ), + "", + "| API p95 (ms) | v3 | v3t |", + "| --- | ---: | ---: |", + *( + f"| {endpoint} | {values['p95_ms']['v3']:.3f} | " + f"{values['p95_ms']['v3t']:.3f} |" + for endpoint, values in summary["api"].items() + ), + "", + "| Pure Python CPU probe (ops/s) | v3 | v3t |", + "| --- | ---: | ---: |", + *( + f"| {workers} threads | {values['v3']:.1f} | {values['v3t']:.1f} |" + for workers, values in summary["python_throughput_ops_s"].items() + ), + "", + "| Direct Rust ABI probe (ops/s) | v3 | v3t |", + "| --- | ---: | ---: |", + *( + f"| {workers} threads | {values['v3']:.1f} | {values['v3t']:.1f} |" + for workers, values in summary["rust_throughput_ops_s"].items() + ), + "", + ] + ) + for title, key in (("Invalid reasons", "invalid_reasons"), ("Regressions", "regressions")): + if result.get(key): + lines.append(f"## {title}") + lines.append("") + lines.extend(f"- {item}" for item in result[key]) + lines.append("") + lines.extend( + [ + "完整 preflight、交替样本、热点校验和与阈值见 `results.json`。", + "该本地 harness 使用空白配置卷和无外网容器,不读取真实 app.env、数据库或凭据。", + "", + ] + ) + return "\n".join(lines) + + +def campaign_directory(args: argparse.Namespace) -> Path: + """返回 campaign 输出目录。""" + return args.output_dir.expanduser().resolve() / args.campaign + + +def write_results(args: argparse.Namespace, result: dict[str, Any]) -> None: + """写入聚合 JSON、Markdown 与单样本 JSON。""" + output = campaign_directory(args) + for sample in result.get("samples") or []: + atomic_write_json( + output / "samples" / f"{sample['variant']}-{sample['sample_index']}.json", + sample, + ) + atomic_write_json(output / "results.json", result) + report = output / "report.md" + report.parent.mkdir(parents=True, exist_ok=True) + report.write_text(build_markdown(result), encoding="utf-8") + + +def execute_campaign(args: argparse.Namespace) -> dict[str, Any]: + """执行完整 preflight 和三组平衡 A/B,并始终生成可判定结果。""" + result: dict[str, Any] = { + "schema_version": SCHEMA_VERSION, + "campaign": args.campaign, + "created_at": utc_now(), + "fixture": {"seed": FIXTURE_SEED, "sha256": FIXTURE_SHA256, "count": len(FIXTURE)}, + "images": { + "v3": {"reference": args.standard_image}, + "v3t": {"reference": args.free_threaded_image}, + }, + "workers": list(args.workers), + "iterations": args.iterations, + "api_iterations": args.api_iterations, + "settle_seconds": args.settle_seconds, + "sample_order": [f"{variant}-{index}" for variant, index in SAMPLE_ORDER], + "thresholds": { + "max_startup_ratio": args.max_startup_ratio, + "max_v3_rust_over_python_ratio": args.max_v3_rust_over_python_ratio, + "max_v3t_rust_over_v3_rust_ratio": args.max_v3t_rust_over_v3_rust_ratio, + "min_ft_max_worker_throughput_ratio": args.min_ft_max_worker_throughput_ratio, + "max_idle_memory_ratio": args.max_idle_memory_ratio, + "max_api_p95_ratio": args.max_api_p95_ratio, + }, + "preflight": {}, + "samples": [], + "summary": {}, + "invalid_reasons": [], + "regressions": [], + "verdict": "invalid", + } + try: + assert_expected_repository(args.standard_image, "moviepilot-v3") + assert_expected_repository(args.free_threaded_image, "moviepilot-v3t") + client = require_docker_client() + for variant, reference in ( + ("v3", args.standard_image), + ("v3t", args.free_threaded_image), + ): + result["images"][variant] = image_identity(client, reference, args.pull) + identities = result["images"] + if identities["v3"]["source_revision"] != identities["v3t"]["source_revision"]: + raise HarnessInvalid("两个镜像不是同一源码 revision") + if identities["v3"]["version"] != identities["v3t"]["version"]: + raise HarnessInvalid("两个镜像不是同一版本") + result["source_revision"] = identities["v3"]["source_revision"] + for variant in ("v3", "v3t"): + payload = run_json_command( + client, identities[variant]["runtime_reference"], PREFLIGHT_SCRIPT + ) + errors = validate_preflight(variant, payload) + result["preflight"][variant] = {"payload": payload, "errors": errors} + result["invalid_reasons"].extend(f"{variant}: {error}" for error in errors) + if result["invalid_reasons"]: + raise HarnessInvalid("preflight 未通过") + for variant, index in SAMPLE_ORDER: + sample = execute_sample( + client, args, variant, index, identities[variant]["runtime_reference"] + ) + result["samples"].append(sample) + atomic_write_json( + campaign_directory(args) / "samples" / f"{variant}-{index}.json", sample + ) + summary, invalid, regressions = evaluate_samples(result) + result["summary"] = summary + result["invalid_reasons"].extend(invalid) + result["regressions"].extend(regressions) + if result["invalid_reasons"]: + result["verdict"] = "invalid" + elif result["regressions"]: + result["verdict"] = "regression" + else: + result["verdict"] = "pass" + except HarnessInvalid as error: + if str(error) not in result["invalid_reasons"]: + result["invalid_reasons"].append(str(error)) + result["verdict"] = "invalid" + except Exception as error: # pragma: no cover - Docker 实机异常兜底 + result["invalid_reasons"].append( + f"未预期的 harness 失败:{type(error).__name__}: {error}" + ) + result["verdict"] = "invalid" + result["completed_at"] = utc_now() + write_results(args, result) + return result + + +def build_parser() -> argparse.ArgumentParser: + """构建本地 A/B 命令行。""" + parser = argparse.ArgumentParser( + description="MoviePilot moviepilot-v3 vs moviepilot-v3t local A/B harness" + ) + parser.add_argument("--standard-image", type=immutable_image, required=True) + parser.add_argument("--free-threaded-image", type=immutable_image, required=True) + parser.add_argument("--campaign", type=normalize_campaign, required=True) + parser.add_argument( + "--output-dir", + type=Path, + default=Path(tempfile.gettempdir()) / "moviepilot-free-threaded-ab", + ) + parser.add_argument("--pull", action="store_true", help="按 digest 拉取两个镜像") + parser.add_argument("--iterations", type=int, default=1024) + parser.add_argument("--api-iterations", type=int, default=100) + parser.add_argument("--settle-seconds", type=float, default=3.0) + parser.add_argument("--workers", type=parse_workers, default=parse_workers("1,8,16,32")) + parser.add_argument("--cpus", type=float, default=4.0) + parser.add_argument("--memory", default="2g") + parser.add_argument("--ready-timeout", type=int, default=300) + parser.add_argument("--max-startup-ratio", type=float, default=1.25) + parser.add_argument("--max-v3-rust-over-python-ratio", type=float, default=1.10) + parser.add_argument( + "--max-v3t-rust-over-v3-rust-ratio", type=float, default=1.25 + ) + parser.add_argument( + "--min-ft-max-worker-throughput-ratio", type=float, default=1.05 + ) + parser.add_argument("--max-idle-memory-ratio", type=float, default=1.25) + parser.add_argument("--max-api-p95-ratio", type=float, default=1.25) + return parser + + +def main(argv: Optional[list[str]] = None) -> int: + """执行 harness,并以 0/1/2 区分 pass/regression/invalid。""" + parser = build_parser() + args = parser.parse_args(argv) + if ( + args.iterations <= 0 + or args.api_iterations <= 0 + or args.settle_seconds < 0 + or args.cpus <= 0 + or args.ready_timeout <= 0 + ): + parser.error("iterations、api-iterations、cpus 与 ready-timeout 必须大于 0") + thresholds = ( + args.max_startup_ratio, + args.max_v3_rust_over_python_ratio, + args.max_v3t_rust_over_v3_rust_ratio, + args.min_ft_max_worker_throughput_ratio, + args.max_idle_memory_ratio, + args.max_api_p95_ratio, + ) + if any(value <= 0 for value in thresholds): + parser.error("所有性能比例阈值必须大于 0") + result = execute_campaign(args) + print(f"Verdict: {result['verdict']}") + print(f"Results: {campaign_directory(args) / 'results.json'}") + print(f"Report: {campaign_directory(args) / 'report.md'}") + return { + "pass": EXIT_PASS, + "regression": EXIT_REGRESSION, + "invalid": EXIT_INVALID, + }[result["verdict"]] + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/perf/postgresql_driver_ab.py b/scripts/perf/postgresql_driver_ab.py new file mode 100644 index 000000000..90d0d9461 --- /dev/null +++ b/scripts/perf/postgresql_driver_ab.py @@ -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()) diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 1b5c7b0e7..9204ee08d 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -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", diff --git a/tests/fixtures/architecture/runtime-contract-baseline.json b/tests/fixtures/architecture/runtime-contract-baseline.json index 8a2f72ab8..3ed1356e7 100644 --- a/tests/fixtures/architecture/runtime-contract-baseline.json +++ b/tests/fixtures/architecture/runtime-contract-baseline.json @@ -8700,6 +8700,11 @@ "name": "TimerUtils", "target": "app.runtime.scheduling.TimerUtils" }, + { + "kind": "import", + "name": "convert", + "target": "app.foundation.text.convert" + }, { "kind": "import", "name": "cut", diff --git a/tests/test_database_backup_docker.py b/tests/test_database_backup_docker.py index c08ab2cf5..a59dde85d 100644 --- a/tests/test_database_backup_docker.py +++ b/tests/test_database_backup_docker.py @@ -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", diff --git a/tests/test_database_index_migration.py b/tests/test_database_index_migration.py index cf94593e3..d12c51c0b 100644 --- a/tests/test_database_index_migration.py +++ b/tests/test_database_index_migration.py @@ -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, diff --git a/tests/test_database_media_identity_backfill_migrations.py b/tests/test_database_media_identity_backfill_migrations.py index d92432192..bfd040c3c 100644 --- a/tests/test_database_media_identity_backfill_migrations.py +++ b/tests/test_database_media_identity_backfill_migrations.py @@ -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, diff --git a/tests/test_db_engine_postgresql.py b/tests/test_db_engine_postgresql.py index e9b4734df..fdd32384d 100644 --- a/tests/test_db_engine_postgresql.py +++ b/tests/test_db_engine_postgresql.py @@ -223,6 +223,19 @@ def test_pg_sync_engine_applies_pool_settings(monkeypatch): assert captured["url"].startswith("postgresql") +def test_pg_sync_engine_uses_psycopg_on_free_threaded_python(monkeypatch): + """free-threaded 运行时不能加载会重新启用 GIL 的 psycopg2 扩展。""" + monkeypatch.setattr(engine_module, "is_free_threaded_runtime", lambda: True) + captured = {} + monkeypatch.setattr(engine_module, "create_engine", + lambda **kw: captured.update(kw) or MagicMock()) + monkeypatch.setattr(engine_module, "_register_database_error_logging", lambda *_a: None) + + engine_module._get_postgresql_engine(is_async=False) + + assert captured["url"].startswith("postgresql+psycopg://") + + def test_pg_async_engine_pooled_omits_poolclass(monkeypatch): """ 池化的异步引擎不得指定 poolclass:SQLAlchemy 需自行选用异步适配的 diff --git a/tests/test_docker_bootstrap.py b/tests/test_docker_bootstrap.py index 7b789b866..284cd98c6 100644 --- a/tests/test_docker_bootstrap.py +++ b/tests/test_docker_bootstrap.py @@ -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) diff --git a/tests/test_docker_entrypoint_permissions.py b/tests/test_docker_entrypoint_permissions.py index 4b981427b..2a846f155 100644 --- a/tests/test_docker_entrypoint_permissions.py +++ b/tests/test_docker_entrypoint_permissions.py @@ -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") diff --git a/tests/test_docker_payload_contract.py b/tests/test_docker_payload_contract.py index c0c7b1285..722567589 100644 --- a/tests/test_docker_payload_contract.py +++ b/tests/test_docker_payload_contract.py @@ -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 diff --git a/tests/test_free_threaded_ab.py b/tests/test_free_threaded_ab.py new file mode 100644 index 000000000..3cf366ebb --- /dev/null +++ b/tests/test_free_threaded_ab.py @@ -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 diff --git a/tests/test_jieba_utils.py b/tests/test_jieba_utils.py index 1f984bd5a..eec449a05 100644 --- a/tests/test_jieba_utils.py +++ b/tests/test_jieba_utils.py @@ -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) == [ + "南京", + "南京市", + "京市", + "市长", + "长江", + "长江大桥", + "大桥", + ] diff --git a/tests/test_lifecycle_shutdown.py b/tests/test_lifecycle_shutdown.py index 3f0977804..68242ca7d 100644 --- a/tests/test_lifecycle_shutdown.py +++ b/tests/test_lifecycle_shutdown.py @@ -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) diff --git a/tests/test_package_installer.py b/tests/test_package_installer.py index 4a8aef426..ec6c8c3ec 100644 --- a/tests/test_package_installer.py +++ b/tests/test_package_installer.py @@ -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" diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index 947765e64..ea4bc0573 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -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): """ 安装策略失败后如果主运行环境异常,应先恢复主程序依赖再返回失败。 diff --git a/tests/test_plugin_mutation_admission.py b/tests/test_plugin_mutation_admission.py index be72d87c9..f1e06eb63 100644 --- a/tests/test_plugin_mutation_admission.py +++ b/tests/test_plugin_mutation_admission.py @@ -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, diff --git a/tests/test_plugin_sdk.py b/tests/test_plugin_sdk.py index d2b36aab3..8bb91571f 100644 --- a/tests/test_plugin_sdk.py +++ b/tests/test_plugin_sdk.py @@ -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" diff --git a/tests/test_postgresql_driver_ab.py b/tests/test_postgresql_driver_ab.py new file mode 100644 index 000000000..28122083f --- /dev/null +++ b/tests/test_postgresql_driver_ab.py @@ -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) diff --git a/tests/test_release_supply_chain.py b/tests/test_release_supply_chain.py index e4c4e2e23..9390051d1 100644 --- a/tests/test_release_supply_chain.py +++ b/tests/test_release_supply_chain.py @@ -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 diff --git a/tests/test_resource_v3.py b/tests/test_resource_v3.py index f80b5dd41..1463bc00f 100644 --- a/tests/test_resource_v3.py +++ b/tests/test_resource_v3.py @@ -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 = [] diff --git a/tests/test_runtime_dependencies.py b/tests/test_runtime_dependencies.py new file mode 100644 index 000000000..c70e78087 --- /dev/null +++ b/tests/test_runtime_dependencies.py @@ -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) diff --git a/tests/test_rust_accel_toggle.py b/tests/test_rust_accel_toggle.py index 8af851c41..c6ecfd1b5 100644 --- a/tests/test_rust_accel_toggle.py +++ b/tests/test_rust_accel_toggle.py @@ -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 diff --git a/tests/test_startup_warnings.py b/tests/test_startup_warnings.py index 0167754d6..25f1b12c8 100644 --- a/tests/test_startup_warnings.py +++ b/tests/test_startup_warnings.py @@ -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 == [] diff --git a/tests/test_system_nettest.py b/tests/test_system_nettest.py index 89300e5e4..280c88a3e 100644 --- a/tests/test_system_nettest.py +++ b/tests/test_system_nettest.py @@ -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): """ diff --git a/uv.lock b/uv.lock index e5c860ae0..bc822c54f 100644 --- a/uv.lock +++ b/uv.lock @@ -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 = [