mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
feat: 新增 Python 3.14t 自由线程镜像 (#6434)
* fix(resource): select free-threaded extension ABI * chore(deps): require moviepilot-rust 0.2.9 * perf: add free-threaded runtime comparison * feat: add free-threaded runtime profile * chore(deps): require moviepilot-rust 0.3.0 * test: isolate system endpoint import graph * fix(docker): preserve runtime profile during recovery * feat(runtime): expose active GIL state * feat(plugin): log GIL fallback attribution * test: refresh runtime observability dependency baseline * fix(plugin): validate the active uv runtime profile * test(runtime): expand free-threaded benchmark evidence * docs(runtime): define v3t governance gates * test(runtime): separate rust benchmark modes * docs: sync free-threaded architecture baseline * perf: add PostgreSQL driver comparison * docs(runtime): record final free-threaded evidence * fix(runtime): converge dual-profile dependency verification * fix(runtime): scope Python 3.14 warning filter * fix(runtime): match actual oss2 syntax warning * docs(runtime): refresh free-threaded benchmark evidence * test(architecture): refresh runtime dependency baseline * ci: skip unused Trivy Java database * build: exclude local verification artifacts * docs(runtime): refresh PostgreSQL driver benchmarks * docs(runtime): record plugin restore acceptance * docs(runtime): record amd64 candidate acceptance * ci: pin beta image publisher action * test(architecture): merge runtime dependency baseline * feat(runtime): expose Python GIL status * docs(runtime): document Python runtime status fields
This commit is contained in:
@@ -92,6 +92,7 @@ test_*
|
||||
*_test.py
|
||||
|
||||
# Build artifacts
|
||||
.artifacts/
|
||||
build/
|
||||
.build/
|
||||
dist/
|
||||
|
||||
+213
-5
@@ -10,6 +10,9 @@ jobs:
|
||||
Docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build Docker Image
|
||||
env:
|
||||
TRIVY_SKIP_DIRS: /usr/share/java
|
||||
TRIVY_SKIP_JAVA_DB_UPDATE: "true"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -22,6 +25,25 @@ jobs:
|
||||
- name: Verify dependency lock
|
||||
run: uv lock --check
|
||||
|
||||
- name: Audit locked Python dependencies
|
||||
run: |
|
||||
uv export --quiet --locked --no-default-groups --group runtime-standard \
|
||||
--no-emit-project --output-file /tmp/moviepilot-audit-standard.txt
|
||||
uvx --from pip-audit==2.10.1 pip-audit \
|
||||
--require-hashes --disable-pip --strict --progress-spinner off \
|
||||
--requirement /tmp/moviepilot-audit-standard.txt
|
||||
|
||||
uv export --quiet --locked --no-default-groups --group runtime-free-threaded \
|
||||
--no-emit-project --no-hashes \
|
||||
--output-file /tmp/moviepilot-audit-free-threaded.txt
|
||||
python3 scripts/normalize_audit_requirements.py \
|
||||
--lock uv.lock \
|
||||
--input /tmp/moviepilot-audit-free-threaded.txt \
|
||||
--output /tmp/moviepilot-audit-free-threaded-normalized.txt
|
||||
uvx --from pip-audit==2.10.1 pip-audit \
|
||||
--no-deps --disable-pip --strict --progress-spinner off \
|
||||
--requirement /tmp/moviepilot-audit-free-threaded-normalized.txt
|
||||
|
||||
- name: Release version
|
||||
id: release_version
|
||||
run: |
|
||||
@@ -97,7 +119,17 @@ jobs:
|
||||
${{ secrets.DOCKER_USERNAME }}/moviepilot-v3
|
||||
ghcr.io/${{ github.repository }}-v3
|
||||
tags: |
|
||||
type=raw,value=beta
|
||||
type=raw,value=beta-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- name: Docker Meta free-threaded
|
||||
id: meta_ft
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_USERNAME }}/moviepilot-v3t
|
||||
ghcr.io/${{ github.repository }}-v3t
|
||||
tags: |
|
||||
type=raw,value=beta-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- name: Set Up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -105,6 +137,134 @@ jobs:
|
||||
- name: Set Up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build standard amd64 candidate
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: linux/amd64
|
||||
load: true
|
||||
push: false
|
||||
pull: true
|
||||
tags: moviepilot-v3-candidate:linux-amd64
|
||||
build-args: |
|
||||
MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }}
|
||||
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
|
||||
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
|
||||
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
|
||||
MOVIEPILOT_PYTHON_VARIANT=standard
|
||||
cache-from: type=gha,scope=moviepilot-v3-standard-docker-amd64,version=2
|
||||
cache-to: type=gha,scope=moviepilot-v3-standard-docker-amd64,mode=max,version=2
|
||||
|
||||
- name: Scan standard amd64 candidate vulnerabilities
|
||||
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
|
||||
with:
|
||||
image-ref: moviepilot-v3-candidate:linux-amd64
|
||||
version: v0.70.0
|
||||
cache-dir: ${{ runner.temp }}/trivy
|
||||
scanners: vuln
|
||||
vuln-type: os,library
|
||||
severity: HIGH,CRITICAL
|
||||
ignore-unfixed: true
|
||||
trivyignores: .trivyignore.yaml
|
||||
exit-code: 1
|
||||
|
||||
- name: Build standard arm64 candidate
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: linux/arm64/v8
|
||||
load: true
|
||||
push: false
|
||||
pull: true
|
||||
tags: moviepilot-v3-candidate:linux-arm64
|
||||
build-args: |
|
||||
MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }}
|
||||
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
|
||||
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
|
||||
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
|
||||
MOVIEPILOT_PYTHON_VARIANT=standard
|
||||
cache-from: type=gha,scope=moviepilot-v3-standard-docker-arm64,version=2
|
||||
cache-to: type=gha,scope=moviepilot-v3-standard-docker-arm64,mode=max,version=2
|
||||
|
||||
- name: Scan standard arm64 candidate vulnerabilities
|
||||
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
|
||||
with:
|
||||
image-ref: moviepilot-v3-candidate:linux-arm64
|
||||
version: v0.70.0
|
||||
cache-dir: ${{ runner.temp }}/trivy
|
||||
scanners: vuln
|
||||
vuln-type: os,library
|
||||
severity: HIGH,CRITICAL
|
||||
ignore-unfixed: true
|
||||
trivyignores: .trivyignore.yaml
|
||||
exit-code: 1
|
||||
|
||||
- name: Build free-threaded amd64 candidate
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: linux/amd64
|
||||
load: true
|
||||
push: false
|
||||
pull: true
|
||||
tags: moviepilot-v3t-candidate:linux-amd64
|
||||
build-args: |
|
||||
MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }}
|
||||
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
|
||||
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
|
||||
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
|
||||
MOVIEPILOT_PYTHON_VARIANT=free-threaded
|
||||
cache-from: type=gha,scope=moviepilot-v3t-docker-amd64,version=2
|
||||
cache-to: type=gha,scope=moviepilot-v3t-docker-amd64,mode=max,version=2
|
||||
|
||||
- name: Scan free-threaded amd64 candidate vulnerabilities
|
||||
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
|
||||
with:
|
||||
image-ref: moviepilot-v3t-candidate:linux-amd64
|
||||
version: v0.70.0
|
||||
cache-dir: ${{ runner.temp }}/trivy
|
||||
scanners: vuln
|
||||
vuln-type: os,library
|
||||
severity: HIGH,CRITICAL
|
||||
ignore-unfixed: true
|
||||
trivyignores: .trivyignore.yaml
|
||||
exit-code: 1
|
||||
|
||||
- name: Build free-threaded arm64 candidate
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: linux/arm64/v8
|
||||
load: true
|
||||
push: false
|
||||
pull: true
|
||||
tags: moviepilot-v3t-candidate:linux-arm64
|
||||
build-args: |
|
||||
MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }}
|
||||
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
|
||||
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
|
||||
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
|
||||
MOVIEPILOT_PYTHON_VARIANT=free-threaded
|
||||
cache-from: type=gha,scope=moviepilot-v3t-docker-arm64,version=2
|
||||
cache-to: type=gha,scope=moviepilot-v3t-docker-arm64,mode=max,version=2
|
||||
|
||||
- name: Scan free-threaded arm64 candidate vulnerabilities
|
||||
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
|
||||
with:
|
||||
image-ref: moviepilot-v3t-candidate:linux-arm64
|
||||
version: v0.70.0
|
||||
cache-dir: ${{ runner.temp }}/trivy
|
||||
scanners: vuln
|
||||
vuln-type: os,library
|
||||
severity: HIGH,CRITICAL
|
||||
ignore-unfixed: true
|
||||
trivyignores: .trivyignore.yaml
|
||||
exit-code: 1
|
||||
|
||||
- name: Login DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -118,8 +278,8 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Image
|
||||
uses: docker/build-push-action@v7
|
||||
- name: Publish standard multi-architecture image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
@@ -133,6 +293,7 @@ jobs:
|
||||
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
|
||||
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
|
||||
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
|
||||
MOVIEPILOT_PYTHON_VARIANT=standard
|
||||
labels: |
|
||||
${{ steps.meta.outputs.labels }}
|
||||
org.opencontainers.image.revision=${{ env.SOURCE_COMMIT }}
|
||||
@@ -143,5 +304,52 @@ jobs:
|
||||
org.moviepilot.resources-revision=${{ steps.payloads.outputs.resources_revision }}
|
||||
org.moviepilot.plugin-market-wiki-revision=${{ steps.plugin_market.outputs.wiki_commit }}
|
||||
org.moviepilot.models-catalog-digest=${{ steps.models_catalog.outputs.digest }}
|
||||
cache-from: type=gha,scope=moviepilot-v3-docker,version=2
|
||||
cache-to: type=gha,scope=moviepilot-v3-docker,mode=max,version=2
|
||||
cache-from: |
|
||||
type=gha,scope=moviepilot-v3-standard-docker-amd64,version=2
|
||||
type=gha,scope=moviepilot-v3-standard-docker-arm64,version=2
|
||||
|
||||
- name: Publish free-threaded multi-architecture image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64/v8
|
||||
push: true
|
||||
pull: false
|
||||
tags: ${{ steps.meta_ft.outputs.tags }}
|
||||
build-args: |
|
||||
MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }}
|
||||
MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }}
|
||||
MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}
|
||||
MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}
|
||||
MOVIEPILOT_PYTHON_VARIANT=free-threaded
|
||||
labels: |
|
||||
${{ steps.meta_ft.outputs.labels }}
|
||||
org.opencontainers.image.revision=${{ env.SOURCE_COMMIT }}
|
||||
org.moviepilot.source-revision=${{ env.SOURCE_COMMIT }}
|
||||
org.moviepilot.frontend-version=${{ steps.release_version.outputs.frontend_version }}
|
||||
org.moviepilot.frontend-digest=${{ steps.payloads.outputs.frontend_digest }}
|
||||
org.moviepilot.plugins-revision=${{ steps.payloads.outputs.plugins_revision }}
|
||||
org.moviepilot.resources-revision=${{ steps.payloads.outputs.resources_revision }}
|
||||
org.moviepilot.plugin-market-wiki-revision=${{ steps.plugin_market.outputs.wiki_commit }}
|
||||
org.moviepilot.models-catalog-digest=${{ steps.models_catalog.outputs.digest }}
|
||||
cache-from: |
|
||||
type=gha,scope=moviepilot-v3t-docker-amd64,version=2
|
||||
type=gha,scope=moviepilot-v3t-docker-arm64,version=2
|
||||
|
||||
- name: Promote beta image pair
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
run: |
|
||||
candidate="beta-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
for image in \
|
||||
"${DOCKER_USERNAME}/moviepilot-v3" \
|
||||
"ghcr.io/${GITHUB_REPOSITORY}-v3" \
|
||||
"${DOCKER_USERNAME}/moviepilot-v3t" \
|
||||
"ghcr.io/${GITHUB_REPOSITORY}-v3t"; do
|
||||
docker buildx imagetools create \
|
||||
--tag "${image}:beta" \
|
||||
"${image}:${candidate}"
|
||||
done
|
||||
|
||||
+152
-14
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -29,6 +29,7 @@ config/.cache/
|
||||
# 运行期设置持久化目录(settings 写回 app.env 的落点)与本地验证产物
|
||||
app/config/
|
||||
.verify_tmp/
|
||||
.artifacts/
|
||||
.runtime/
|
||||
public/
|
||||
.moviepilot.env
|
||||
|
||||
+1
-1
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
Vendored
+66
-19
@@ -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] = (
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 "",
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ from app.schemas.event import ConfigChangeEventData
|
||||
from app.schemas.exception import PluginMutationRejectedError
|
||||
from app.schemas.types import SystemConfigKey, EventType
|
||||
from app.foundation.crypto import HashUtils
|
||||
from app.foundation.environment import is_free_threaded_runtime, is_gil_enabled
|
||||
from app.adapters.network.http import RequestUtils, AsyncRequestUtils
|
||||
from app.adapters.system import rust as rust_accel
|
||||
from app.application.security.url import SecurityUtils
|
||||
@@ -802,6 +803,8 @@ async def get_user_global_setting(_: ApiPrincipal = Depends(get_current_active_u
|
||||
"USER_UNIQUE_ID": MoviePilotServerHelper.get_user_uuid(),
|
||||
"SUBSCRIBE_SHARE_MANAGE": share_admin,
|
||||
"WORKFLOW_SHARE_MANAGE": share_admin,
|
||||
"PYTHON_FREE_THREADED": is_free_threaded_runtime(),
|
||||
"PYTHON_GIL_ENABLED": is_gil_enabled(),
|
||||
}
|
||||
)
|
||||
return _SchemaResponse(success=True, data=info)
|
||||
@@ -827,8 +830,12 @@ async def get_env_setting(
|
||||
"AUTH_VERSION": SitesHelper().auth_version,
|
||||
"INDEXER_VERSION": SitesHelper().indexer_version,
|
||||
"FRONTEND_VERSION": SystemChain().get_frontend_version(),
|
||||
"RUST_ACCEL": rust_accel.is_config_enabled(),
|
||||
"RUST_ACCEL_AVAILABLE": rust_accel.is_available(),
|
||||
"RUST_ACCEL_ENABLED": rust_accel.is_enabled(),
|
||||
"RUST_ACCEL_REQUIRED": rust_accel.is_required(),
|
||||
"PYTHON_FREE_THREADED": is_free_threaded_runtime(),
|
||||
"PYTHON_GIL_ENABLED": is_gil_enabled(),
|
||||
}
|
||||
)
|
||||
return _SchemaResponse(success=True, data=info)
|
||||
|
||||
+7
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -3,6 +3,7 @@
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
import sysconfig
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -17,6 +18,16 @@ def is_frozen() -> bool:
|
||||
return bool(getattr(sys, "frozen", False))
|
||||
|
||||
|
||||
def is_free_threaded_runtime() -> bool:
|
||||
"""判断当前解释器是否为 CPython free-threaded 构建。"""
|
||||
return sysconfig.get_config_var("Py_GIL_DISABLED") == 1
|
||||
|
||||
|
||||
def is_gil_enabled() -> bool:
|
||||
"""返回当前 CPython 进程是否实际启用了 GIL。"""
|
||||
return sys._is_gil_enabled()
|
||||
|
||||
|
||||
def is_windows() -> bool:
|
||||
"""判断当前操作系统是否为 Windows。"""
|
||||
return os.name == "nt"
|
||||
|
||||
+13
-7
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
@@ -23,6 +23,7 @@ from app.schemas.plugin import Plugin as _SchemaPlugin
|
||||
from app.schemas.plugin import PluginDashboard as _SchemaPluginDashboard
|
||||
from app.schemas.plugin import PluginInstance, PluginRuntimeStatus
|
||||
from app.foundation.crypto import RSAUtils
|
||||
from app.foundation.environment import is_free_threaded_runtime, is_gil_enabled
|
||||
from app.foundation.singleton import Singleton
|
||||
from app.foundation.version import compare_version
|
||||
from app.runtime.execution import run_in_threadpool_to_completion
|
||||
@@ -90,6 +91,24 @@ def _unavailable_plugin_catalog_factory(_manager: "PluginManager") -> Any:
|
||||
raise RuntimeError("插件目录应用服务尚未由启动组合根装配")
|
||||
|
||||
|
||||
def _warn_if_plugin_enabled_gil(
|
||||
*,
|
||||
gil_enabled_before: bool,
|
||||
plugin_id: Optional[str],
|
||||
) -> None:
|
||||
"""记录插件加载使 free-threaded 进程重新启用 GIL 的真实转换。"""
|
||||
if (
|
||||
not is_free_threaded_runtime()
|
||||
or gil_enabled_before
|
||||
or not is_gil_enabled()
|
||||
):
|
||||
return
|
||||
logger.warning(
|
||||
"加载插件%s后 free-threaded 运行时已启用 GIL,请检查原生扩展兼容性",
|
||||
plugin_id or "集合",
|
||||
)
|
||||
|
||||
|
||||
_legacy_diagnostics_configurator: LegacyDiagnosticsConfigurator = (
|
||||
_ignore_legacy_diagnostics
|
||||
)
|
||||
@@ -363,7 +382,14 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
enabled=settings.DEBUG,
|
||||
emitter=logger.warning,
|
||||
)
|
||||
return self._plugin_lifecycle.start(pid)
|
||||
gil_enabled_before = is_gil_enabled()
|
||||
try:
|
||||
return self._plugin_lifecycle.start(pid)
|
||||
finally:
|
||||
_warn_if_plugin_enabled_gil(
|
||||
gil_enabled_before=gil_enabled_before,
|
||||
plugin_id=pid,
|
||||
)
|
||||
except PluginMutationRejectedError as error:
|
||||
logger.warning(str(error))
|
||||
if pid:
|
||||
@@ -719,10 +745,17 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
try:
|
||||
with self.mutation("重新加载插件"):
|
||||
with self._plugin_quiesce_lock:
|
||||
return self._plugin_lifecycle.reload(
|
||||
plugin_id,
|
||||
EventType.PluginReload,
|
||||
)
|
||||
gil_enabled_before = is_gil_enabled()
|
||||
try:
|
||||
return self._plugin_lifecycle.reload(
|
||||
plugin_id,
|
||||
EventType.PluginReload,
|
||||
)
|
||||
finally:
|
||||
_warn_if_plugin_enabled_gil(
|
||||
gil_enabled_before=gil_enabled_before,
|
||||
plugin_id=plugin_id,
|
||||
)
|
||||
except PluginMutationRejectedError as error:
|
||||
logger.warning(str(error))
|
||||
return PluginRuntimeStatus.LOAD_FAILED
|
||||
|
||||
@@ -2,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",
|
||||
|
||||
@@ -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],
|
||||
|
||||
+74
-11
@@ -1,5 +1,8 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
ARG MOVIEPILOT_PYTHON_VARIANT="standard"
|
||||
ARG MOVIEPILOT_PYTHON_VERSION="3.14.7"
|
||||
|
||||
FROM ghcr.io/astral-sh/uv:0.12.5@sha256:e85be844203885286c60ffad8a858d48afb6c5a5c237ca0e67f12e74b8f174b1 AS uv
|
||||
|
||||
|
||||
@@ -9,7 +12,10 @@ FROM rclone/rclone:1.75.0@sha256:b06aed988cf5967de7c25be5925240983981c757f4ed1ac
|
||||
FROM mwader/static-ffmpeg:8.1.1@sha256:735f84b905e00d5c618b667f0b053f83b1096f5fc404c607e6134bf2275a0e0a AS ffmpeg
|
||||
|
||||
|
||||
FROM python:3.14.7-slim-trixie AS base
|
||||
FROM python:${MOVIEPILOT_PYTHON_VERSION}-slim-trixie AS base
|
||||
|
||||
|
||||
FROM rust:slim-trixie AS rust_toolchain
|
||||
|
||||
|
||||
# 准备外部制品所需的最小工具集
|
||||
@@ -33,6 +39,7 @@ ENV LANG="C.UTF-8" \
|
||||
PUID=0 \
|
||||
PGID=0 \
|
||||
UMASK=000 \
|
||||
PYTHON_THREAD_INHERIT_CONTEXT=0 \
|
||||
VENV_PATH="/opt/venv"
|
||||
|
||||
ENV PATH="${VENV_PATH}/bin:${PATH}"
|
||||
@@ -94,8 +101,8 @@ RUN apt-get update \
|
||||
/var/tmp/*
|
||||
|
||||
|
||||
# 准备 python 环境
|
||||
FROM base AS prepare_venv
|
||||
# 准备 Python 环境的公共输入
|
||||
FROM base AS prepare_venv_common
|
||||
|
||||
# 设置环境变量
|
||||
ENV LANG="C.UTF-8" \
|
||||
@@ -112,15 +119,65 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
jq \
|
||||
wget
|
||||
|
||||
# 按锁文件创建主程序虚拟环境
|
||||
WORKDIR /app
|
||||
COPY --from=uv /uv /usr/local/bin/uv
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN python3 -m venv --without-pip ${VENV_PATH} \
|
||||
|
||||
|
||||
# 准备标准 Python 运行环境
|
||||
FROM prepare_venv_common AS prepare_venv_standard
|
||||
|
||||
RUN mkdir -p /opt/python \
|
||||
&& python3 -m venv --without-pip ${VENV_PATH} \
|
||||
&& UV_PROJECT_ENVIRONMENT=${VENV_PATH} uv sync \
|
||||
--locked \
|
||||
--no-dev \
|
||||
--no-install-project
|
||||
--locked \
|
||||
--no-default-groups \
|
||||
--group runtime-standard \
|
||||
--no-install-project
|
||||
|
||||
|
||||
# free-threaded 原生依赖需要 Rust 和 C 构建工具链。
|
||||
FROM prepare_venv_common AS prepare_venv_free-threaded
|
||||
|
||||
ARG MOVIEPILOT_PYTHON_VERSION
|
||||
|
||||
ENV CARGO_HOME="/usr/local/cargo" \
|
||||
RUSTUP_HOME="/usr/local/rustup" \
|
||||
UV_PYTHON_INSTALL_DIR="/opt/python"
|
||||
|
||||
ENV PATH="${CARGO_HOME}/bin:${VENV_PATH}/bin:${PATH}"
|
||||
|
||||
COPY --from=rust_toolchain /usr/local/cargo /usr/local/cargo
|
||||
COPY --from=rust_toolchain /usr/local/rustup /usr/local/rustup
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& uv python install --no-bin "${MOVIEPILOT_PYTHON_VERSION}t" \
|
||||
&& python_bin="$(uv python find --managed-python "${MOVIEPILOT_PYTHON_VERSION}t")" \
|
||||
&& "${python_bin}" -m venv --without-pip ${VENV_PATH} \
|
||||
&& UV_PROJECT_ENVIRONMENT=${VENV_PATH} uv sync \
|
||||
--locked \
|
||||
--no-default-groups \
|
||||
--group runtime-free-threaded \
|
||||
--no-install-project \
|
||||
--python "${python_bin}"
|
||||
|
||||
|
||||
FROM prepare_venv_standard AS verify_venv_standard
|
||||
|
||||
COPY app/doctor/dependencies.py /tmp/moviepilot-runtime-dependencies.py
|
||||
RUN "${VENV_PATH}/bin/python" /tmp/moviepilot-runtime-dependencies.py --full
|
||||
|
||||
|
||||
FROM prepare_venv_free-threaded AS verify_venv_free-threaded
|
||||
|
||||
COPY app/doctor/dependencies.py /tmp/moviepilot-runtime-dependencies.py
|
||||
RUN "${VENV_PATH}/bin/python" /tmp/moviepilot-runtime-dependencies.py --full
|
||||
|
||||
|
||||
ARG MOVIEPILOT_PYTHON_VARIANT
|
||||
FROM verify_venv_${MOVIEPILOT_PYTHON_VARIANT} AS prepare_venv
|
||||
|
||||
# 准备后端源码
|
||||
FROM base AS prepare_backend
|
||||
@@ -184,10 +241,15 @@ FROM prepare_payload AS prepare_resources
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG MOVIEPILOT_RESOURCES_REF="main"
|
||||
ARG MOVIEPILOT_PYTHON_VARIANT="standard"
|
||||
|
||||
RUN set -eu; \
|
||||
test -n "${MOVIEPILOT_RESOURCES_REF}"; \
|
||||
python_ver="$(python3 -c 'import sys; print(f"cpython-{sys.version_info.major}{sys.version_info.minor}")')"; \
|
||||
case "${MOVIEPILOT_PYTHON_VARIANT}" in \
|
||||
standard) python_ver="$(python3 -c 'import sys; print(f"cpython-{sys.version_info.major}{sys.version_info.minor}")')" ;; \
|
||||
free-threaded) python_ver="cpython-314t" ;; \
|
||||
*) printf 'Unsupported Python variant: %s\n' "${MOVIEPILOT_PYTHON_VARIANT}" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
target_arch="${TARGETARCH:-$(uname -m)}"; \
|
||||
case "${target_arch}" in \
|
||||
arm64|aarch64) suffix="aarch64-linux-gnu" ;; \
|
||||
@@ -230,6 +292,7 @@ COPY --from=rclone /usr/local/bin/rclone /usr/bin/rclone
|
||||
|
||||
# python 环境
|
||||
COPY --from=prepare_venv --chmod=777 ${VENV_PATH} ${VENV_PATH}
|
||||
COPY --from=prepare_venv /opt/python /opt/python
|
||||
COPY --from=uv /uv /usr/local/bin/uv
|
||||
|
||||
# 浏览器运行依赖
|
||||
@@ -252,8 +315,8 @@ COPY --from=prepare_control /bundle/bin/moviepilot /usr/local/bin/moviepilot
|
||||
RUN mkdir -p ${HOME} \
|
||||
&& groupadd -r moviepilot -g 918 \
|
||||
&& useradd -r moviepilot -g moviepilot -d ${HOME} -s /bin/bash -u 918 \
|
||||
&& python_ver=$(python3 -V | awk '{print $2}') \
|
||||
&& echo "/app/" > ${VENV_PATH}/lib/python${python_ver%.*}/site-packages/app.pth \
|
||||
&& site_packages="$(${VENV_PATH}/bin/python -c 'import sysconfig; print(sysconfig.get_path("purelib"))')" \
|
||||
&& echo "/app/" > "${site_packages}/app.pth" \
|
||||
&& echo 'fs.inotify.max_user_watches=5242880' >> /etc/sysctl.conf \
|
||||
&& echo 'fs.inotify.max_user_instances=5242880' >> /etc/sysctl.conf \
|
||||
&& echo "zh_CN.UTF-8 UTF-8" >> /etc/locale.gen \
|
||||
|
||||
+10
-17
@@ -337,34 +337,27 @@ function diagnostic_keepalive() {
|
||||
# 插件依赖和主程序共用同一套 venv 时,历史安装记录可能已经污染环境,
|
||||
# 这里优先在真正拉起后端前做一次自愈,避免容器反复起不来。
|
||||
function ensure_backend_runtime_dependencies() {
|
||||
local probe_code="import alembic, cloakbrowser, fastapi, pydantic, pydantic_core, pydantic_settings, sqlalchemy, starlette, uvicorn; from pydantic import BaseModel, Field"
|
||||
local probe_module="app.doctor.dependencies"
|
||||
|
||||
INFO "→ 启动前检查后端核心依赖..."
|
||||
if "${VENV_PATH}/bin/python3" -c "${probe_code}" >/dev/null 2>&1; then
|
||||
if "${VENV_PATH}/bin/python3" -m "${probe_module}" >/dev/null 2>&1; then
|
||||
INFO "→ 后端核心依赖检查通过。"
|
||||
return 0
|
||||
fi
|
||||
|
||||
WARN "→ 检测到后端核心依赖异常,开始尝试恢复主程序依赖..."
|
||||
local -a uv_cmd=(
|
||||
"${UV_BIN}" sync
|
||||
--project /app
|
||||
--locked
|
||||
--no-dev
|
||||
--no-install-project
|
||||
--inexact
|
||||
)
|
||||
if [ -n "${PIP_PROXY}" ]; then
|
||||
uv_cmd+=(--default-index "${PIP_PROXY}")
|
||||
if ! configure_package_route; then
|
||||
ERROR "→ 无法选择可用的主程序依赖源,后端无法启动。"
|
||||
diagnostic_keepalive 1
|
||||
fi
|
||||
|
||||
if ! run_package_command env "UV_PROJECT_ENVIRONMENT=${VENV_PATH}" \
|
||||
"${uv_cmd[@]}" > /dev/stdout 2> /dev/stderr; then
|
||||
PACKAGE_ROUTE_READY="true"
|
||||
INFO "依赖源:${PACKAGE_LOG}"
|
||||
if ! sync_project_dependencies_for "/app" > /dev/stdout 2> /dev/stderr; then
|
||||
ERROR "→ 自动恢复主程序依赖失败,后端无法启动。"
|
||||
diagnostic_keepalive 1
|
||||
fi
|
||||
|
||||
if ! "${VENV_PATH}/bin/python3" -c "${probe_code}" >/dev/null 2>&1; then
|
||||
if ! "${VENV_PATH}/bin/python3" -m "${probe_module}" >/dev/null 2>&1; then
|
||||
ERROR "→ 主程序依赖恢复后仍然异常,后端无法启动。"
|
||||
diagnostic_keepalive 1
|
||||
fi
|
||||
@@ -575,8 +568,8 @@ render_nginx_config
|
||||
|
||||
# 自动更新,控制脚本由 launcher 固化到同一代运行目录,源码替换不会改变本轮执行内容。
|
||||
cd /
|
||||
source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/update.sh"
|
||||
if [ "${MOVIEPILOT_BOOTSTRAP_UPDATE_DONE:-0}" != "1" ]; then
|
||||
source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/update.sh"
|
||||
if ! recover_pending_update; then
|
||||
ERROR "→ 上一次容器更新未能恢复,容器将保持运行以便执行 moviepilot doctor。"
|
||||
diagnostic_keepalive 1
|
||||
|
||||
+13
-1
@@ -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"
|
||||
|
||||
@@ -653,8 +653,8 @@ flowchart LR
|
||||
|
||||
| 指标 | 当前值 |
|
||||
|---|---:|
|
||||
| Python 模块 | 810 |
|
||||
| 内部导入边 | 6,560 |
|
||||
| Python 模块 | 811 |
|
||||
| 内部导入边 | 6,572 |
|
||||
| 非平凡 SCC | 1(仅隔离的 TMDB 移植包) |
|
||||
| Module Contract V2 spec | 212(其中 211 个进入 `run_module` 观察面) |
|
||||
| Event Contract | 53 |
|
||||
@@ -705,6 +705,7 @@ flowchart LR
|
||||
| [`docs/rules/10-data-and-persistent.md`](rules/10-data-and-persistent.md) | 数据模型、迁移与缓存规范 |
|
||||
| [`docs/subscribe-lifecycle.md`](subscribe-lifecycle.md) | 订阅生命周期详解 |
|
||||
| [`docs/mcp-api.md`](mcp-api.md) | MCP 工具端点说明 |
|
||||
| [`docs/v3t-runtime-governance.md`](v3t-runtime-governance.md) | V3/V3t 运行依赖、故障恢复、GIL 可观测性与兼容退场门禁 |
|
||||
| [`docs/refactor/backend-architecture-governance.md`](refactor/backend-architecture-governance.md) | 分阶段架构治理、边界门禁与迁移验收 |
|
||||
| [`docs/refactor/backend-module-refactor-compatibility.md`](refactor/backend-module-refactor-compatibility.md) | 模块迁移与插件兼容层实施矩阵 |
|
||||
| [`docs/refactor/backend-architecture-next-stage.md`](refactor/backend-architecture-next-stage.md) | 对标优秀 Python 后端后的二阶段任务、验收与回滚方案 |
|
||||
|
||||
+5
-5
@@ -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 加速;如果后端未加载扩展,该开关会保持关闭且不可操作
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
在开始之前,请确保您的系统已安装以下软件:
|
||||
|
||||
- **Python 3.12+**
|
||||
- **Python 3.14+**
|
||||
- **uv 0.12.5**(Python 版本、虚拟环境和依赖锁定工具)
|
||||
- **Git** (用于版本控制)
|
||||
- **RAR 解压工具**:本地开发如需测试或使用 `.rar` 字幕包解压,请安装 `unar`、`unrar`、`7z` 或 `bsdtar` 之一;Docker 镜像会内置 `unar`。
|
||||
@@ -35,9 +35,10 @@ uv sync --locked --no-dev --no-install-project
|
||||
|
||||
| 位置 | 用途 | 维护方式 |
|
||||
| --- | --- | --- |
|
||||
| `pyproject.toml` 的 `[project].dependencies` | 主程序生产运行依赖。 | 开发者按直接依赖的兼容范围维护。 |
|
||||
| `pyproject.toml` 的 `[project].dependencies` | 两套 Python 运行时共享的主程序生产依赖。 | 开发者按直接依赖的兼容范围维护。 |
|
||||
| `pyproject.toml` 的 `[dependency-groups].dev` | pytest、覆盖率、Pylint 和源码构建等开发工具。 | 不进入 Docker 生产运行环境。 |
|
||||
| `uv.lock` | Python 3.12+ 和受支持平台共享的完整解析结果。 | 修改 `pyproject.toml` 后由 `uv lock` 更新并提交。 |
|
||||
| `pyproject.toml` 的 `[dependency-groups].runtime-*` | 标准与 free-threaded 解释器互斥的 ABI 敏感运行依赖。 | 只放两套运行时确实不同的直接依赖。 |
|
||||
| `uv.lock` | Python 3.14+、两套运行时 profile 和受支持平台共享的完整解析结果。 | 修改 `pyproject.toml` 后由 `uv lock` 更新并提交。 |
|
||||
|
||||
主程序不再维护 `requirements.in`、`requirements-dev.in` 或 `requirements.txt`,也不生成
|
||||
平台专属的 requirements 锁文件。Docker、CLI 和 CI 都以提交的 `uv.lock` 为安装输入。
|
||||
@@ -86,10 +87,11 @@ chmod +x scripts/start-local.sh
|
||||
|
||||
新增或升级依赖时,先确认依赖属于哪个层级:
|
||||
|
||||
1. **运行时依赖**:被 `app/` 生产代码直接导入,或是生产功能、后台任务、插件框架启动必需,写入 `[project].dependencies`。
|
||||
2. **开发 / 测试 / 静态检查 / 构建依赖**:只用于单测、覆盖率、lint 辅助、源码构建等,写入 `[dependency-groups].dev`。
|
||||
3. **工具依赖**:仓库要求使用 `uv 0.12.5`;不应为了安装工具而把它加入主程序运行依赖。
|
||||
4. **插件依赖**:由插件清单声明并在插件安装阶段处理,不直接并入主程序依赖。
|
||||
1. **共享运行时依赖**:被 `app/` 生产代码直接导入,或是生产功能、后台任务、插件框架启动必需,写入 `[project].dependencies`。
|
||||
2. **ABI 敏感运行依赖**:标准与 free-threaded 解释器必须选择不同制品或版本时,分别写入 `runtime-standard` 和 `runtime-free-threaded`;两组保持互斥并由运行时统一选择。
|
||||
3. **开发 / 测试 / 静态检查 / 构建依赖**:只用于单测、覆盖率、lint 辅助、源码构建等,写入 `[dependency-groups].dev`。
|
||||
4. **工具依赖**:仓库要求使用 `uv 0.12.5`;不应为了安装工具而把它加入主程序运行依赖。
|
||||
5. **插件依赖**:由插件清单声明并在插件安装阶段处理,不直接并入主程序依赖。
|
||||
|
||||
修改后更新并校验锁文件:
|
||||
|
||||
@@ -97,9 +99,13 @@ chmod +x scripts/start-local.sh
|
||||
uv lock
|
||||
uv lock --check
|
||||
uv sync --locked
|
||||
uv pip check
|
||||
uv sync --locked --offline --inexact --no-dev --check
|
||||
```
|
||||
|
||||
`uv pip check` 可用于查看第三方包元数据诊断,但不作为项目依赖合同:`oss2` 已停止维护,其元数据仍
|
||||
声明旧 `crcmod`,而主程序统一使用保持相同导入接口的 `crcmod-plus`。项目一致性以锁文件和上述
|
||||
`uv sync --check` 结果为准。
|
||||
|
||||
`uv.lock` 同时覆盖 Linux x86_64/arm64、macOS x86_64/arm64 和 Windows x64。统一锁文件只
|
||||
固定解析结果,不能替代这些平台的真实安装门禁;平台条件依赖变更必须通过对应 CI 环境验证。
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
3. `PluginManager` 的加载、生命周期、注册表、投影、存储、目录、路径、同步、依赖、克隆和文件监控分别由 `app/runtime/extensions/plugin/` 下的单职责组件承担;旧管理器只保留 V3 ABI 门面和兼容调用顺序。
|
||||
4. 动态插件 API 使用专用 raw 路由;主程序统一响应信封不进入插件 `get_api()`。前端 `pluginApi` 对非 `Response` envelope 的 payload 原样交付调用方。
|
||||
5. 旧插件导入仅由 `app/runtime/compat/manifest.py` 精确映射;canonical 模块不复制旧 Manager/Helper/Oper 导出。`app/plugins/` 仍是运行时副本,继续排除在宿主架构扫描之外。
|
||||
6. 2026-08-24 当前机器基线为 810 个宿主 Python 模块、6,560 条内部导入边;数据库边界、Adapter→DB、Runtime→DB、Application→DB 及新增 API/Agent/Chain 目标边均为 0。架构门禁、插件兼容快照和基线脚本均已重新生成。
|
||||
6. 2026-08-24 当前机器基线为 811 个宿主 Python 模块、6,572 条内部导入边;数据库边界、Adapter→DB、Runtime→DB、Application→DB 及新增 API/Agent/Chain 目标边均为 0。架构门禁、插件兼容快照和基线脚本均已重新生成。
|
||||
7. 订阅写入统一归入 `app/application/subscription/write.py`;插件动态路由和文件夹操作统一归入 `app/application/plugin/routes.py`、`folders.py`。重构期间新增且未形成插件 ABI 的 `app/application/subscribe.py`、`app/application/plugins.py` 已直接删除,不进入 compat manifest。
|
||||
8. 2026-08-24 完成 Module Contract V2 宿主观察面收口:212 个 spec 均使用可执行的显式 aggregation,
|
||||
`legacy` 只保留为未知第三方自定义方法的开放 fallback;插件方法名、kwargs、优先级和异常隔离 ABI 不变。
|
||||
@@ -106,7 +106,7 @@ MoviePilot V3 已经完成一轮重要基础工作:原 `app/core`、`app/helpe
|
||||
|
||||
### 4.3 模块规模
|
||||
|
||||
排除 `app/plugins/` 后,2026-08-24 当前静态扫描得到 810 个 Python 模块、6,560 条内部导入边。下表保留 2026-08-18 收口时的一级目录规模快照(代码行数包含注释和空行,用于趋势比较而非质量评分):
|
||||
排除 `app/plugins/` 后,2026-08-24 当前静态扫描得到 811 个 Python 模块、6,572 条内部导入边。下表保留 2026-08-18 收口时的一级目录规模快照(代码行数包含注释和空行,用于趋势比较而非质量评分):
|
||||
|
||||
| 一级目录 | 约代码行数 | Python 文件数 | 判断 |
|
||||
| --- | ---: | ---: | --- |
|
||||
@@ -154,8 +154,8 @@ MoviePilot V3 已经完成一轮重要基础工作:原 `app/core`、`app/helpe
|
||||
|
||||
| 指标 | 初始审计 | 当前基线 | 说明 |
|
||||
| --- | ---: | ---: | --- |
|
||||
| Python 模块数 | 约 654 | 810 | 增量来自单一职责的 Application、Runtime、Adapter、插件组件和维护用例模块 |
|
||||
| 内部导入边 | 约 5,623 | 6,560 | 显式端口增加模块数但移除了反向边;边数不作为单独质量目标 |
|
||||
| Python 模块数 | 约 654 | 811 | 增量来自单一职责的 Application、Runtime、Adapter、插件组件和维护用例模块 |
|
||||
| 内部导入边 | 约 5,623 | 6,572 | 显式端口增加模块数但移除了反向边;边数不作为单独质量目标 |
|
||||
| SCC 数 | 14 | 1 | 自有代码 SCC 已归零,仅保留 TMDB 移植包内部隔离例外 |
|
||||
| `adapters -> db` | 存在 | 0 | `PluginHelper`、`MoviePilotServerHelper` 的本地数据读取已移到组合根/Application |
|
||||
| `runtime -> db` | 存在 | 0 | 插件存储、服务配置均改为启动注入 |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
---
|
||||
|
||||
@@ -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+.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -136,7 +136,7 @@ Before marking any task as complete:
|
||||
|
||||
- [ ] Related pytest tests pass
|
||||
- [ ] No new pylint error-level issues in `pylint app/`
|
||||
- [ ] If dependencies changed: the package is in the correct `pyproject.toml` group, `uv.lock` is current, locked sync and `uv pip check` pass, and the locked runtime dependency audit passes
|
||||
- [ ] If dependencies changed: the package is in the correct `pyproject.toml` group, `uv.lock` is current, the locked project consistency check and runtime dependency audit pass
|
||||
- [ ] If CLI behavior changed: `docs/cli.md` and related tests are updated
|
||||
- [ ] If MCP/API behavior changed: `docs/mcp-api.md` and related skill files are updated
|
||||
- [ ] If database schema changed: a new Alembic migration exists under `database/versions/`
|
||||
|
||||
@@ -103,7 +103,7 @@ When updating a dependency:
|
||||
|
||||
1. Decide the dependency layer: runtime packages go to `[project].dependencies`; test, coverage, lint, and explicit build tooling go to `[dependency-groups].dev`.
|
||||
2. Run `uv lock`, commit the updated `uv.lock`, and verify it with `uv lock --check`.
|
||||
3. Run `uv sync --locked`, `uv pip check`, and the locked runtime dependency audit documented in `03-commands.md`.
|
||||
3. Run `uv sync --locked`, the locked project consistency check, and the runtime dependency audit documented in `03-commands.md`.
|
||||
4. Run the full test suite: `uv run --locked --no-sync pytest`.
|
||||
|
||||
---
|
||||
|
||||
@@ -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+ 及完整后端依赖,不适合作为普通用户的首选路径。
|
||||
|
||||
## 维护者发布流程
|
||||
|
||||
|
||||
@@ -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、启动日志、插件归因和前端告警保持一致。
|
||||
- [ ] 安全扫描结果已按实际安装制品、利用面和上游修复状态审计。
|
||||
- [ ] 上游已成熟的临时兼容已删除,仍保留的例外在本文有明确解除条件。
|
||||
+32
-6
@@ -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"]
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""将 uv 导出的锁定 URL 依赖转换为漏洞审计可识别的精确版本。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DIRECT_REFERENCE = re.compile(
|
||||
r"^(?P<indent>\s*)(?P<name>[A-Za-z0-9_.-]+)\s+@\s+"
|
||||
r"(?P<url>\S+)(?P<suffix>.*)$"
|
||||
)
|
||||
|
||||
|
||||
def _canonicalize_name(name: str) -> str:
|
||||
"""按 Python 包索引规则规范化分发包名。"""
|
||||
return re.sub(r"[-_.]+", "-", name).lower()
|
||||
|
||||
|
||||
def _locked_url_versions(lock_file: Path) -> dict[tuple[str, str], str]:
|
||||
"""读取锁文件中 URL 来源与已解析版本的唯一映射。"""
|
||||
with lock_file.open("rb") as file:
|
||||
document = tomllib.load(file)
|
||||
|
||||
versions: dict[tuple[str, str], str] = {}
|
||||
for package in document.get("package", []):
|
||||
source = package.get("source") or {}
|
||||
url = source.get("url")
|
||||
name = package.get("name")
|
||||
version = package.get("version")
|
||||
if not all(isinstance(value, str) and value for value in (url, name, version)):
|
||||
continue
|
||||
key = (_canonicalize_name(name), url)
|
||||
previous = versions.setdefault(key, version)
|
||||
if previous != version:
|
||||
raise ValueError(f"锁文件包含冲突的 URL 依赖版本:{name} @ {url}")
|
||||
return versions
|
||||
|
||||
|
||||
def normalize_requirements(requirements: str, lock_file: Path) -> str:
|
||||
"""保留 marker 和注释,将直接引用替换为锁文件中的精确版本。"""
|
||||
versions = _locked_url_versions(lock_file)
|
||||
normalized_lines = []
|
||||
for line in requirements.splitlines(keepends=True):
|
||||
body = line.rstrip("\r\n")
|
||||
newline = line[len(body):]
|
||||
match = DIRECT_REFERENCE.match(body)
|
||||
if not match:
|
||||
normalized_lines.append(line)
|
||||
continue
|
||||
|
||||
key = (_canonicalize_name(match.group("name")), match.group("url"))
|
||||
version = versions.get(key)
|
||||
if not version:
|
||||
raise ValueError(
|
||||
"导出的 URL 依赖无法在锁文件中定位精确版本:"
|
||||
f"{match.group('name')} @ {match.group('url')}"
|
||||
)
|
||||
normalized_lines.append(
|
||||
f"{match.group('indent')}{match.group('name')}=={version}"
|
||||
f"{match.group('suffix')}{newline}"
|
||||
)
|
||||
|
||||
normalized = "".join(normalized_lines)
|
||||
if any(DIRECT_REFERENCE.match(line) for line in normalized.splitlines()):
|
||||
raise ValueError("审计清单仍包含未规范化的 URL 依赖")
|
||||
return normalized
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""读取 uv 导出文件并写入适合 pip-audit 的锁定版本清单。"""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--lock", type=Path, required=True)
|
||||
parser.add_argument("--input", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
args.output.write_text(
|
||||
normalize_requirements(args.input.read_text(encoding="utf-8"), args.lock),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -191,3 +191,56 @@ Before-1 → After-1 → After-2 → Before-2 → Before-3 → After-3
|
||||
```
|
||||
|
||||
本地 JSON、Markdown 和日志不会被 `cleanup` 删除。
|
||||
|
||||
## Python 3.14 free-threaded 镜像 A/B
|
||||
|
||||
`free_threaded_ab.py` 用于正式发布前在同一 Docker daemon、相同 CPU/内存限制下比较
|
||||
`moviepilot-v3` 与 `moviepilot-v3t`。它不构建镜像,只接受两份
|
||||
`repository@sha256:<digest>` 不可变引用,并要求镜像标签证明两者来自相同源码 revision 和版本。
|
||||
未使用 `--pull` 时,digest 也可以是本机 Docker image ID,供依赖尚未发布前验收本地候选。
|
||||
|
||||
preflight 会验证 Python 3.14、GIL 状态、`thread_inherit_context`、MoviePilot-Rust 0.3 的
|
||||
`jieba_cut`/中文转换入口,以及标准与 free-threaded 镜像互斥的原生依赖 profile。正式样本使用
|
||||
固定 seed 和 fixture hash,按 `v3-1 → v3t-1 → v3t-2 → v3-2 → v3-3 → v3t-3`
|
||||
交替执行真实 readiness 启动,并把应用识别热点明确分成 `V3 + Python`、`V3 + Rust`、
|
||||
`V3t + Rust` 三组。两种镜像的纯 Python 并发与直接 Rust 并发是解释器/ABI 探针,不代表产品 Rust
|
||||
开关的第三组结果;PostgreSQL 驱动选择使用不连接数据库的命令单独验证。
|
||||
|
||||
```bash
|
||||
../.venv/bin/python scripts/perf/free_threaded_ab.py \
|
||||
--campaign v3-ft-001 \
|
||||
--standard-image 'jxxghp/moviepilot-v3@sha256:<64-hex-digest>' \
|
||||
--free-threaded-image 'jxxghp/moviepilot-v3t@sha256:<64-hex-digest>' \
|
||||
--pull
|
||||
```
|
||||
|
||||
结果默认写入系统临时目录的 `moviepilot-free-threaded-ab/<campaign>/`:
|
||||
|
||||
- `results.json` 使用 `schema_version` 保存镜像身份、preflight、阈值、原始样本与中位数;
|
||||
- `report.md` 提供维护者可读摘要;
|
||||
- `samples/` 保存六个交替样本,便于排查离群值。
|
||||
|
||||
退出码 `0` 表示合同与性能阈值通过,`1` 表示样本有效但出现性能回退,`2` 表示 digest、ABI、
|
||||
依赖、语义、驱动、启动或样本完整性不成立。该工具只用于隔离的本地长 A/B,不接真实凭据、用户数据库、
|
||||
媒体目录或外网,也不加入常规 CI。
|
||||
|
||||
### PostgreSQL 同步驱动三方案
|
||||
|
||||
`postgresql_driver_ab.py` 在同一 PostgreSQL 容器中比较标准 V3/psycopg2、标准
|
||||
V3/psycopg3 binary 和 V3t/psycopg3 C。三个输入镜像必须来自相同源码 revision 和产品版本;
|
||||
标准 V3/psycopg3 镜像是只增加该驱动的本地验证衍生镜像,不是发布制品。
|
||||
|
||||
```bash
|
||||
../.venv/bin/python scripts/perf/postgresql_driver_ab.py \
|
||||
--campaign v3-ft-pg-001 \
|
||||
--postgres-container moviepilot-pg-ab \
|
||||
--dsn 'postgresql://moviepilot:<benchmark-password>@127.0.0.1:5432/moviepilot' \
|
||||
--standard-image 'moviepilot-v3@sha256:<64-hex-digest>' \
|
||||
--standard-psycopg3-image 'moviepilot-v3-pg3@sha256:<64-hex-digest>' \
|
||||
--free-threaded-image 'moviepilot-v3t@sha256:<64-hex-digest>'
|
||||
```
|
||||
|
||||
脚本按三方案的六个全排列执行固定 SQL,默认把每个采样容器限制为 2 CPU/1 GiB,保存单连接查询、
|
||||
16 线程查询、批量事务、长事务行锁并行、驱动/libpq/SOABI 和 GIL 状态。每个 campaign 使用独立
|
||||
测试表并在成功或失败后清理;DSN 只传入隔离容器,不写入结果。性能数据用于解释驱动选择,不作为
|
||||
跨机器发布阈值;驱动实现、GIL、查询结果、长事务并行和样本完整性属于硬门禁。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,802 @@
|
||||
"""比较标准与 free-threaded MoviePilot 镜像的 PostgreSQL 同步驱动。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import docker
|
||||
except ImportError: # pragma: no cover - 保留无 Docker SDK 环境下的 --help
|
||||
docker = None
|
||||
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
VARIANTS = (
|
||||
("v3_psycopg2", "psycopg2"),
|
||||
("v3_psycopg3_binary", "psycopg3"),
|
||||
("v3t_psycopg3_c", "psycopg3"),
|
||||
)
|
||||
ORDERS = (
|
||||
(VARIANTS[0], VARIANTS[1], VARIANTS[2]),
|
||||
(VARIANTS[1], VARIANTS[2], VARIANTS[0]),
|
||||
(VARIANTS[2], VARIANTS[0], VARIANTS[1]),
|
||||
(VARIANTS[2], VARIANTS[1], VARIANTS[0]),
|
||||
(VARIANTS[1], VARIANTS[0], VARIANTS[2]),
|
||||
(VARIANTS[0], VARIANTS[2], VARIANTS[1]),
|
||||
)
|
||||
|
||||
|
||||
class HarnessInvalid(RuntimeError):
|
||||
"""表示制品、数据库或样本不满足可比较合同。"""
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
"""返回 JSON 使用的 UTC 时间。"""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def normalize_campaign(value: str) -> str:
|
||||
"""限制 campaign,使输出目录和 Docker 标签可安全复用。"""
|
||||
normalized = value.strip().lower()
|
||||
if not re.fullmatch(r"[a-z0-9][a-z0-9_.-]{0,39}", normalized):
|
||||
raise argparse.ArgumentTypeError(
|
||||
"campaign 只能包含小写字母、数字、点、下划线和短横线,最长 40 字符"
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def immutable_image(value: str) -> str:
|
||||
"""只接受带完整 sha256 digest 的镜像引用。"""
|
||||
reference = value.strip()
|
||||
if not re.fullmatch(r"[^\s@]+@sha256:[0-9a-f]{64}", reference):
|
||||
raise argparse.ArgumentTypeError("镜像必须使用 repository@sha256:<64 hex> 不可变引用")
|
||||
return reference
|
||||
|
||||
|
||||
def sample_order(round_index: int) -> tuple[tuple[str, str], ...]:
|
||||
"""返回三方案的平衡全排列,消除固定位置偏置。"""
|
||||
return ORDERS[round_index % len(ORDERS)]
|
||||
|
||||
|
||||
def atomic_write_json(path: Path, payload: Any) -> None:
|
||||
"""原子写入 JSON,避免长采样中断留下半个结果。"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(f"{path.suffix}.tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def require_docker_client():
|
||||
"""连接本机 Docker Engine。"""
|
||||
if docker is None:
|
||||
raise HarnessInvalid("缺少 docker Python SDK,请使用 MoviePilot 工作区环境执行")
|
||||
try:
|
||||
client = docker.from_env()
|
||||
client.ping()
|
||||
return client
|
||||
except Exception as default_error:
|
||||
if os.getenv("DOCKER_HOST"):
|
||||
raise HarnessInvalid(f"无法连接 Docker Engine:{default_error}") from default_error
|
||||
try:
|
||||
endpoint = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"context",
|
||||
"inspect",
|
||||
"--format",
|
||||
'{{ (index .Endpoints "docker").Host }}',
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
if not endpoint:
|
||||
raise RuntimeError("当前 Docker context 没有 endpoint")
|
||||
client = docker.DockerClient(base_url=endpoint)
|
||||
client.ping()
|
||||
return client
|
||||
except Exception as context_error:
|
||||
raise HarnessInvalid(
|
||||
f"无法连接 Docker Engine:{default_error}"
|
||||
) from context_error
|
||||
|
||||
|
||||
def image_identity(client, reference: str) -> dict[str, Any]:
|
||||
"""读取不可变镜像身份与 MoviePilot 源码标签。"""
|
||||
image_id = reference.rsplit("@", 1)[-1]
|
||||
try:
|
||||
image = client.images.get(reference)
|
||||
except Exception:
|
||||
try:
|
||||
image = client.images.get(image_id)
|
||||
except Exception as error:
|
||||
raise HarnessInvalid(f"本地不存在镜像 {reference}:{error}") from error
|
||||
attrs = image.attrs or {}
|
||||
labels = (attrs.get("Config") or {}).get("Labels") or {}
|
||||
revision = labels.get("org.moviepilot.source-revision") or labels.get(
|
||||
"org.opencontainers.image.revision"
|
||||
)
|
||||
version = labels.get("org.opencontainers.image.version")
|
||||
if not revision or not version:
|
||||
raise HarnessInvalid(f"镜像 {reference} 缺少源码 revision 或版本标签")
|
||||
return {
|
||||
"reference": reference,
|
||||
"runtime_reference": image.id,
|
||||
"image_id": image.id,
|
||||
"size_bytes": int(attrs.get("Size") or 0),
|
||||
"architecture": attrs.get("Architecture"),
|
||||
"source_revision": revision,
|
||||
"version": version,
|
||||
}
|
||||
|
||||
|
||||
BENCHMARK_SCRIPT = r"""
|
||||
import concurrent.futures
|
||||
import hashlib
|
||||
import importlib.metadata
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import random
|
||||
import statistics
|
||||
import sys
|
||||
import sysconfig
|
||||
import threading
|
||||
import time
|
||||
|
||||
driver = os.environ["MP_PG_DRIVER"]
|
||||
dsn = os.environ["MP_PG_DSN"]
|
||||
serial_queries = int(os.environ["MP_PG_SERIAL_QUERIES"])
|
||||
workers = int(os.environ["MP_PG_WORKERS"])
|
||||
queries_per_worker = int(os.environ["MP_PG_QUERIES_PER_WORKER"])
|
||||
batch_rows = int(os.environ["MP_PG_BATCH_ROWS"])
|
||||
long_transaction_seconds = float(os.environ["MP_PG_LONG_TRANSACTION_SECONDS"])
|
||||
sample_key = os.environ["MP_PG_SAMPLE_KEY"]
|
||||
table_name = os.environ["MP_PG_TABLE"]
|
||||
if not table_name.isidentifier():
|
||||
raise RuntimeError("invalid benchmark table name")
|
||||
|
||||
select_sql = f"SELECT payload FROM {table_name} WHERE id = %s"
|
||||
insert_sql = f"INSERT INTO {table_name} (id, payload) VALUES (%s, %s)"
|
||||
sql_sha256 = hashlib.sha256(
|
||||
"\n".join((select_sql, insert_sql, "SELECT ... FOR UPDATE", "UPDATE ..."))
|
||||
.encode()
|
||||
).hexdigest()
|
||||
fixture_sha256 = hashlib.sha256(
|
||||
"\n".join(f"{index}:payload-{index:05d}" for index in range(10000)).encode()
|
||||
).hexdigest()
|
||||
|
||||
gil_before = sys._is_gil_enabled()
|
||||
if driver == "psycopg2":
|
||||
import psycopg2 as driver_module
|
||||
package_versions = {
|
||||
"psycopg2-binary": importlib.metadata.version("psycopg2-binary"),
|
||||
"psycopg": None,
|
||||
"psycopg-binary": None,
|
||||
"psycopg-c": None,
|
||||
}
|
||||
implementation = "psycopg2"
|
||||
elif driver == "psycopg3":
|
||||
import psycopg as driver_module
|
||||
implementation = driver_module.pq.__impl__
|
||||
|
||||
def optional_version(name):
|
||||
try:
|
||||
return importlib.metadata.version(name)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
return None
|
||||
|
||||
package_versions = {
|
||||
"psycopg2-binary": None,
|
||||
"psycopg": importlib.metadata.version("psycopg"),
|
||||
"psycopg-binary": optional_version("psycopg-binary"),
|
||||
"psycopg-c": optional_version("psycopg-c"),
|
||||
}
|
||||
else:
|
||||
raise RuntimeError(f"unsupported driver: {driver}")
|
||||
gil_after_import = sys._is_gil_enabled()
|
||||
if driver == "psycopg2":
|
||||
libpq_version = driver_module.__libpq_version__
|
||||
else:
|
||||
libpq_version = driver_module.pq.version()
|
||||
|
||||
distribution_name = {
|
||||
"psycopg2": "psycopg2-binary",
|
||||
"binary": "psycopg-binary",
|
||||
"c": "psycopg-c",
|
||||
}[implementation]
|
||||
distribution = importlib.metadata.distribution(distribution_name)
|
||||
wheel_tags = sorted(
|
||||
line.split(":", 1)[1].strip()
|
||||
for line in (distribution.read_text("WHEEL") or "").splitlines()
|
||||
if line.startswith("Tag:")
|
||||
)
|
||||
|
||||
def connect():
|
||||
return driver_module.connect(dsn)
|
||||
|
||||
def percentile(values, ratio):
|
||||
ordered = sorted(values)
|
||||
return ordered[min(len(ordered) - 1, int((len(ordered) - 1) * ratio))]
|
||||
|
||||
def latency_summary(values):
|
||||
return {
|
||||
"p50_ms": statistics.median(values),
|
||||
"p95_ms": percentile(values, 0.95),
|
||||
"max_ms": max(values),
|
||||
}
|
||||
|
||||
with connect() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
f"CREATE UNLOGGED TABLE IF NOT EXISTS {table_name} ("
|
||||
"id INTEGER PRIMARY KEY, payload TEXT NOT NULL)"
|
||||
)
|
||||
cursor.execute(f"SELECT count(*) FROM {table_name}")
|
||||
if cursor.fetchone()[0] < 10000:
|
||||
cursor.execute(f"TRUNCATE {table_name}")
|
||||
cursor.executemany(
|
||||
insert_sql,
|
||||
[(index, f"payload-{index:05d}") for index in range(10000)],
|
||||
)
|
||||
postgresql_settings = {}
|
||||
for setting_name in (
|
||||
"server_version",
|
||||
"max_connections",
|
||||
"shared_buffers",
|
||||
"jit",
|
||||
"synchronous_commit",
|
||||
):
|
||||
cursor.execute("SELECT current_setting(%s)", (setting_name,))
|
||||
postgresql_settings[setting_name] = cursor.fetchone()[0]
|
||||
|
||||
with connect() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
for row_id in range(100):
|
||||
cursor.execute(select_sql, (row_id,))
|
||||
cursor.fetchone()
|
||||
|
||||
randomizer = random.Random(314159)
|
||||
serial_ids = [randomizer.randrange(10000) for _ in range(serial_queries)]
|
||||
serial_latencies = []
|
||||
serial_checksum = hashlib.sha256()
|
||||
with connect() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
started = time.perf_counter()
|
||||
for row_id in serial_ids:
|
||||
query_started = time.perf_counter()
|
||||
cursor.execute(select_sql, (row_id,))
|
||||
value = cursor.fetchone()[0]
|
||||
serial_checksum.update(value.encode())
|
||||
serial_latencies.append((time.perf_counter() - query_started) * 1000)
|
||||
serial_seconds = time.perf_counter() - started
|
||||
|
||||
def concurrent_worker(worker_index):
|
||||
worker_randomizer = random.Random(314159 + worker_index)
|
||||
checksum = hashlib.sha256()
|
||||
with connect() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
for _ in range(queries_per_worker):
|
||||
row_id = worker_randomizer.randrange(10000)
|
||||
cursor.execute(select_sql, (row_id,))
|
||||
checksum.update(cursor.fetchone()[0].encode())
|
||||
return checksum.hexdigest()
|
||||
|
||||
concurrent_started = time.perf_counter()
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
concurrent_checksums = list(executor.map(concurrent_worker, range(workers)))
|
||||
concurrent_seconds = time.perf_counter() - concurrent_started
|
||||
|
||||
write_prefix = 10000 + (
|
||||
int(hashlib.sha256(sample_key.encode()).hexdigest()[:8], 16) % 10000
|
||||
) * 100000
|
||||
write_rows = [
|
||||
(write_prefix + index, f"write-{sample_key}-{index:05d}")
|
||||
for index in range(batch_rows)
|
||||
]
|
||||
with connect() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
f"DELETE FROM {table_name} WHERE id >= %s AND id < %s",
|
||||
(write_prefix, write_prefix + batch_rows),
|
||||
)
|
||||
write_started = time.perf_counter()
|
||||
cursor.executemany(
|
||||
insert_sql,
|
||||
write_rows,
|
||||
)
|
||||
connection.commit()
|
||||
batch_write_seconds = time.perf_counter() - write_started
|
||||
|
||||
long_ready = threading.Event()
|
||||
long_errors = []
|
||||
|
||||
def long_transaction():
|
||||
try:
|
||||
with connect() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
f"SELECT payload FROM {table_name} WHERE id = 1 FOR UPDATE"
|
||||
)
|
||||
long_ready.set()
|
||||
cursor.execute("SELECT pg_sleep(%s)", (long_transaction_seconds,))
|
||||
connection.commit()
|
||||
except Exception as error:
|
||||
long_errors.append(f"{type(error).__name__}: {error}")
|
||||
long_ready.set()
|
||||
|
||||
long_thread = threading.Thread(target=long_transaction)
|
||||
long_started = time.perf_counter()
|
||||
long_thread.start()
|
||||
if not long_ready.wait(timeout=5):
|
||||
raise RuntimeError("long transaction did not start")
|
||||
with connect() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
short_started = time.perf_counter()
|
||||
cursor.execute(select_sql, (2,))
|
||||
short_value = cursor.fetchone()[0]
|
||||
short_query_seconds = time.perf_counter() - short_started
|
||||
with connect() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
contended_started = time.perf_counter()
|
||||
cursor.execute(
|
||||
f"UPDATE {table_name} SET payload = payload WHERE id = 1"
|
||||
)
|
||||
connection.commit()
|
||||
contended_update_seconds = time.perf_counter() - contended_started
|
||||
long_thread.join(timeout=long_transaction_seconds + 5)
|
||||
if long_thread.is_alive() or long_errors:
|
||||
raise RuntimeError(f"long transaction failed: {long_errors}")
|
||||
long_elapsed = time.perf_counter() - long_started
|
||||
|
||||
with connect() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
f"DELETE FROM {table_name} WHERE id >= %s AND id < %s",
|
||||
(write_prefix, write_prefix + batch_rows),
|
||||
)
|
||||
|
||||
print(json.dumps({
|
||||
"postgresql_settings": postgresql_settings,
|
||||
"runtime": {
|
||||
"python_version": sys.version.split()[0],
|
||||
"platform_machine": platform.machine(),
|
||||
"soabi": sysconfig.get_config_var("SOABI"),
|
||||
"gil_before_import": gil_before,
|
||||
"gil_after_import": gil_after_import,
|
||||
"gil_after_benchmark": sys._is_gil_enabled(),
|
||||
"driver": driver,
|
||||
"implementation": implementation,
|
||||
"native_distribution": distribution_name,
|
||||
"libpq_version": libpq_version,
|
||||
"wheel_tags": wheel_tags,
|
||||
"packages": package_versions,
|
||||
},
|
||||
"sql_contract": {
|
||||
"fixture_sha256": fixture_sha256,
|
||||
"sql_sha256": sql_sha256,
|
||||
},
|
||||
"serial_query": {
|
||||
"operations": serial_queries,
|
||||
"seconds": serial_seconds,
|
||||
"throughput_ops_s": serial_queries / serial_seconds,
|
||||
"latency": latency_summary(serial_latencies),
|
||||
"checksum": serial_checksum.hexdigest(),
|
||||
},
|
||||
"concurrent_query": {
|
||||
"workers": workers,
|
||||
"operations": workers * queries_per_worker,
|
||||
"seconds": concurrent_seconds,
|
||||
"throughput_ops_s": workers * queries_per_worker / concurrent_seconds,
|
||||
"checksums": concurrent_checksums,
|
||||
},
|
||||
"batch_write": {
|
||||
"rows": batch_rows,
|
||||
"seconds": batch_write_seconds,
|
||||
"throughput_rows_s": batch_rows / batch_write_seconds,
|
||||
},
|
||||
"long_transaction": {
|
||||
"requested_seconds": long_transaction_seconds,
|
||||
"elapsed_seconds": long_elapsed,
|
||||
"parallel_short_query_seconds": short_query_seconds,
|
||||
"parallel_short_query_value": short_value,
|
||||
"contended_update_seconds": contended_update_seconds,
|
||||
},
|
||||
}, sort_keys=True))
|
||||
"""
|
||||
|
||||
|
||||
def run_sample(
|
||||
client,
|
||||
*,
|
||||
image: dict[str, Any],
|
||||
postgres_container: str,
|
||||
variant: str,
|
||||
driver: str,
|
||||
round_index: int,
|
||||
position: int,
|
||||
table_name: str,
|
||||
args: argparse.Namespace,
|
||||
) -> dict[str, Any]:
|
||||
"""在目标镜像中执行一次独立样本。"""
|
||||
environment = {
|
||||
"MP_PG_DRIVER": driver,
|
||||
"MP_PG_DSN": args.dsn,
|
||||
"MP_PG_SERIAL_QUERIES": str(args.serial_queries),
|
||||
"MP_PG_WORKERS": str(args.workers),
|
||||
"MP_PG_QUERIES_PER_WORKER": str(args.queries_per_worker),
|
||||
"MP_PG_BATCH_ROWS": str(args.batch_rows),
|
||||
"MP_PG_LONG_TRANSACTION_SECONDS": str(args.long_transaction_seconds),
|
||||
"MP_PG_SAMPLE_KEY": f"{args.campaign}-{variant}-{round_index + 1}",
|
||||
"MP_PG_TABLE": table_name,
|
||||
}
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
output = client.containers.run(
|
||||
image["runtime_reference"],
|
||||
["python", "-c", BENCHMARK_SCRIPT],
|
||||
entrypoint="",
|
||||
environment=environment,
|
||||
network_mode=f"container:{postgres_container}",
|
||||
remove=True,
|
||||
nano_cpus=int(args.cpus * 1_000_000_000),
|
||||
mem_limit=args.memory,
|
||||
stdout=True,
|
||||
stderr=True,
|
||||
)
|
||||
except Exception as error:
|
||||
raise HarnessInvalid(f"{variant} 第 {round_index + 1} 轮执行失败:{error}") from error
|
||||
try:
|
||||
payload = json.loads(output.decode("utf-8").strip().splitlines()[-1])
|
||||
except (UnicodeDecodeError, json.JSONDecodeError, IndexError) as error:
|
||||
raise HarnessInvalid(f"{variant} 输出不是有效 JSON") from error
|
||||
payload.update(
|
||||
{
|
||||
"variant": variant,
|
||||
"round": round_index + 1,
|
||||
"position": position,
|
||||
"wall_seconds": time.perf_counter() - started,
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
CLEANUP_SCRIPT = r"""
|
||||
import os
|
||||
import psycopg2
|
||||
|
||||
table_name = os.environ["MP_PG_TABLE"]
|
||||
if not table_name.isidentifier():
|
||||
raise RuntimeError("invalid benchmark table name")
|
||||
with psycopg2.connect(os.environ["MP_PG_DSN"]) as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(f"DROP TABLE IF EXISTS {table_name}")
|
||||
"""
|
||||
|
||||
|
||||
def cleanup_table(
|
||||
client,
|
||||
*,
|
||||
image: dict[str, Any],
|
||||
postgres_container: str,
|
||||
table_name: str,
|
||||
args: argparse.Namespace,
|
||||
) -> None:
|
||||
"""在成功或失败后删除 campaign 独占的测试表。"""
|
||||
try:
|
||||
client.containers.run(
|
||||
image["runtime_reference"],
|
||||
["python", "-c", CLEANUP_SCRIPT],
|
||||
entrypoint="",
|
||||
environment={"MP_PG_DSN": args.dsn, "MP_PG_TABLE": table_name},
|
||||
network_mode=f"container:{postgres_container}",
|
||||
remove=True,
|
||||
stdout=True,
|
||||
stderr=True,
|
||||
)
|
||||
except Exception as error:
|
||||
raise HarnessInvalid(f"无法清理 PostgreSQL benchmark 表:{error}") from error
|
||||
|
||||
|
||||
def validate_sample(variant: str, sample: dict[str, Any]) -> None:
|
||||
"""验证驱动、ABI 和长事务并行合同。"""
|
||||
runtime = sample["runtime"]
|
||||
if runtime["python_version"].split(".")[:2] != ["3", "14"]:
|
||||
raise HarnessInvalid(f"{variant} 不是 Python 3.14")
|
||||
if variant == "v3_psycopg2":
|
||||
expected = ("psycopg2", True)
|
||||
elif variant == "v3_psycopg3_binary":
|
||||
expected = ("binary", True)
|
||||
else:
|
||||
expected = ("c", False)
|
||||
if runtime["implementation"] != expected[0]:
|
||||
raise HarnessInvalid(
|
||||
f"{variant} 驱动实现应为 {expected[0]},实际为 {runtime['implementation']}"
|
||||
)
|
||||
for key in ("gil_before_import", "gil_after_import", "gil_after_benchmark"):
|
||||
if runtime[key] is not expected[1]:
|
||||
raise HarnessInvalid(f"{variant} 的 {key} 不满足 GIL 合同")
|
||||
long_transaction = sample["long_transaction"]
|
||||
if long_transaction["parallel_short_query_value"] != "payload-00002":
|
||||
raise HarnessInvalid(f"{variant} 长事务并行查询结果错误")
|
||||
if (
|
||||
long_transaction["parallel_short_query_seconds"]
|
||||
>= long_transaction["requested_seconds"]
|
||||
):
|
||||
raise HarnessInvalid(f"{variant} 的独立短查询被长事务完整阻塞")
|
||||
if (
|
||||
long_transaction["contended_update_seconds"]
|
||||
< long_transaction["requested_seconds"] * 0.5
|
||||
):
|
||||
raise HarnessInvalid(f"{variant} 的冲突写入未等待长事务行锁")
|
||||
|
||||
|
||||
def validate_campaign(samples: list[dict[str, Any]], rounds: int) -> None:
|
||||
"""验证三方案样本数量和固定查询结果完全一致。"""
|
||||
for variant, _driver in VARIANTS:
|
||||
variant_samples = [
|
||||
sample for sample in samples if sample["variant"] == variant
|
||||
]
|
||||
if len(variant_samples) != rounds:
|
||||
raise HarnessInvalid(f"{variant} 样本数量不完整")
|
||||
serial_checksums = {
|
||||
sample["serial_query"]["checksum"] for sample in samples
|
||||
}
|
||||
concurrent_checksums = {
|
||||
tuple(sample["concurrent_query"]["checksums"]) for sample in samples
|
||||
}
|
||||
if len(serial_checksums) != 1 or len(concurrent_checksums) != 1:
|
||||
raise HarnessInvalid("三方案查询结果校验和不一致")
|
||||
sql_contracts = {
|
||||
(sample["sql_contract"]["fixture_sha256"], sample["sql_contract"]["sql_sha256"])
|
||||
for sample in samples
|
||||
}
|
||||
if len(sql_contracts) != 1:
|
||||
raise HarnessInvalid("三方案使用的 SQL 或 fixture 不一致")
|
||||
postgresql_settings = {
|
||||
tuple(sorted(sample["postgresql_settings"].items())) for sample in samples
|
||||
}
|
||||
if len(postgresql_settings) != 1:
|
||||
raise HarnessInvalid("采样期间 PostgreSQL 服务设置发生变化")
|
||||
|
||||
|
||||
def median_at(samples: list[dict[str, Any]], *keys: str) -> float:
|
||||
"""汇总指定路径的样本中位数。"""
|
||||
values = []
|
||||
for sample in samples:
|
||||
value: Any = sample
|
||||
for key in keys:
|
||||
value = value[key]
|
||||
values.append(float(value))
|
||||
return statistics.median(values)
|
||||
|
||||
|
||||
def summarize(samples: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""生成三方案中位数和相对标准 V3/psycopg2 的比例。"""
|
||||
grouped = {
|
||||
variant: [sample for sample in samples if sample["variant"] == variant]
|
||||
for variant, _driver in VARIANTS
|
||||
}
|
||||
metrics = {
|
||||
variant: {
|
||||
"serial_query_throughput_ops_s": median_at(
|
||||
values, "serial_query", "throughput_ops_s"
|
||||
),
|
||||
"concurrent_query_throughput_ops_s": median_at(
|
||||
values, "concurrent_query", "throughput_ops_s"
|
||||
),
|
||||
"batch_write_seconds": median_at(values, "batch_write", "seconds"),
|
||||
"parallel_short_query_seconds": median_at(
|
||||
values, "long_transaction", "parallel_short_query_seconds"
|
||||
),
|
||||
}
|
||||
for variant, values in grouped.items()
|
||||
}
|
||||
baseline = metrics["v3_psycopg2"]
|
||||
ratios = {
|
||||
variant: {
|
||||
"serial_query_throughput_over_v3_psycopg2": (
|
||||
values["serial_query_throughput_ops_s"]
|
||||
/ baseline["serial_query_throughput_ops_s"]
|
||||
),
|
||||
"concurrent_query_throughput_over_v3_psycopg2": (
|
||||
values["concurrent_query_throughput_ops_s"]
|
||||
/ baseline["concurrent_query_throughput_ops_s"]
|
||||
),
|
||||
"batch_write_time_over_v3_psycopg2": (
|
||||
values["batch_write_seconds"] / baseline["batch_write_seconds"]
|
||||
),
|
||||
}
|
||||
for variant, values in metrics.items()
|
||||
}
|
||||
return {"metrics": metrics, "ratios": ratios}
|
||||
|
||||
|
||||
def render_report(result: dict[str, Any]) -> str:
|
||||
"""生成维护者可读的 Markdown 摘要。"""
|
||||
summary = result["summary"]
|
||||
lines = [
|
||||
"# PostgreSQL driver A/B",
|
||||
"",
|
||||
f"- Campaign: `{result['campaign']}`",
|
||||
f"- PostgreSQL: `{result['postgresql']['version']}`",
|
||||
f"- Samples: `{len(result['samples'])}`",
|
||||
f"- Verdict: `{result['verdict']}`",
|
||||
"",
|
||||
"| Variant | Serial query ops/s | 16-thread query ops/s | Batch write seconds |",
|
||||
"| --- | ---: | ---: | ---: |",
|
||||
]
|
||||
for variant, _driver in VARIANTS:
|
||||
metrics = summary["metrics"][variant]
|
||||
lines.append(
|
||||
f"| `{variant}` | {metrics['serial_query_throughput_ops_s']:.1f} | "
|
||||
f"{metrics['concurrent_query_throughput_ops_s']:.1f} | "
|
||||
f"{metrics['batch_write_seconds']:.4f} |"
|
||||
)
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"性能数据用于驱动选择,不是跨机器发布阈值;硬门禁只覆盖驱动实现、GIL、SQL 结果、长事务并行和样本完整性。",
|
||||
"",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
"""解析命令行参数。"""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--campaign", required=True, type=normalize_campaign)
|
||||
parser.add_argument("--postgres-container", required=True)
|
||||
parser.add_argument("--dsn", required=True, help="仅传给隔离容器,不写入结果")
|
||||
parser.add_argument("--standard-image", required=True, type=immutable_image)
|
||||
parser.add_argument(
|
||||
"--standard-psycopg3-image", required=True, type=immutable_image
|
||||
)
|
||||
parser.add_argument("--free-threaded-image", required=True, type=immutable_image)
|
||||
parser.add_argument("--rounds", type=int, default=6)
|
||||
parser.add_argument("--serial-queries", type=int, default=2000)
|
||||
parser.add_argument("--workers", type=int, default=16)
|
||||
parser.add_argument("--queries-per-worker", type=int, default=250)
|
||||
parser.add_argument("--batch-rows", type=int, default=2000)
|
||||
parser.add_argument("--long-transaction-seconds", type=float, default=0.25)
|
||||
parser.add_argument("--cpus", type=float, default=2.0)
|
||||
parser.add_argument("--memory", default="1g")
|
||||
parser.add_argument("--output-dir", type=Path)
|
||||
args = parser.parse_args(argv)
|
||||
for name in ("rounds", "serial_queries", "workers", "queries_per_worker", "batch_rows"):
|
||||
if getattr(args, name) <= 0:
|
||||
parser.error(f"--{name.replace('_', '-')} 必须为正整数")
|
||||
if args.long_transaction_seconds <= 0:
|
||||
parser.error("--long-transaction-seconds 必须为正数")
|
||||
if args.rounds % len(ORDERS):
|
||||
parser.error(f"--rounds 必须是 {len(ORDERS)} 的正整数倍")
|
||||
if args.cpus <= 0:
|
||||
parser.error("--cpus 必须为正数")
|
||||
return args
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""执行 A/B 并写入原始 JSON 与报告。"""
|
||||
args = parse_args(argv)
|
||||
output_dir = args.output_dir or (
|
||||
Path.cwd() / ".artifacts" / "postgresql-driver-ab" / args.campaign
|
||||
)
|
||||
result: dict[str, Any] = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"campaign": args.campaign,
|
||||
"started_at": utc_now(),
|
||||
"verdict": "invalid",
|
||||
"parameters": {
|
||||
"rounds": args.rounds,
|
||||
"serial_queries": args.serial_queries,
|
||||
"workers": args.workers,
|
||||
"queries_per_worker": args.queries_per_worker,
|
||||
"batch_rows": args.batch_rows,
|
||||
"long_transaction_seconds": args.long_transaction_seconds,
|
||||
"cpus": args.cpus,
|
||||
"memory": args.memory,
|
||||
},
|
||||
"samples": [],
|
||||
}
|
||||
client = None
|
||||
images: dict[str, dict[str, Any]] = {}
|
||||
table_name = f"mp_ab_{hashlib.sha256(args.campaign.encode()).hexdigest()[:12]}"
|
||||
try:
|
||||
client = require_docker_client()
|
||||
postgres = client.containers.get(args.postgres_container)
|
||||
postgres.reload()
|
||||
if postgres.status != "running":
|
||||
raise HarnessInvalid("PostgreSQL 容器未运行")
|
||||
version_output = postgres.exec_run(["postgres", "--version"])
|
||||
if version_output.exit_code:
|
||||
raise HarnessInvalid("无法读取 PostgreSQL 版本")
|
||||
result["postgresql"] = {
|
||||
"container_image": postgres.image.id,
|
||||
"version": version_output.output.decode().strip(),
|
||||
}
|
||||
images = {
|
||||
"v3_psycopg2": image_identity(client, args.standard_image),
|
||||
"v3_psycopg3_binary": image_identity(
|
||||
client, args.standard_psycopg3_image
|
||||
),
|
||||
"v3t_psycopg3_c": image_identity(client, args.free_threaded_image),
|
||||
}
|
||||
revisions = {identity["source_revision"] for identity in images.values()}
|
||||
versions = {identity["version"] for identity in images.values()}
|
||||
architectures = {identity["architecture"] for identity in images.values()}
|
||||
if len(revisions) != 1 or len(versions) != 1 or len(architectures) != 1:
|
||||
raise HarnessInvalid("三个镜像必须来自相同源码 revision、产品版本和架构")
|
||||
result["images"] = images
|
||||
for round_index in range(args.rounds):
|
||||
for position, (variant, driver) in enumerate(sample_order(round_index)):
|
||||
sample = run_sample(
|
||||
client,
|
||||
image=images[variant],
|
||||
postgres_container=postgres.id,
|
||||
variant=variant,
|
||||
driver=driver,
|
||||
round_index=round_index,
|
||||
position=position,
|
||||
table_name=table_name,
|
||||
args=args,
|
||||
)
|
||||
validate_sample(variant, sample)
|
||||
result["samples"].append(sample)
|
||||
atomic_write_json(output_dir / "results.partial.json", result)
|
||||
cleanup_table(
|
||||
client,
|
||||
image=images["v3_psycopg2"],
|
||||
postgres_container=postgres.id,
|
||||
table_name=table_name,
|
||||
args=args,
|
||||
)
|
||||
validate_campaign(result["samples"], args.rounds)
|
||||
result["postgresql"]["settings"] = result["samples"][0][
|
||||
"postgresql_settings"
|
||||
]
|
||||
result["sql_contract"] = result["samples"][0]["sql_contract"]
|
||||
result["summary"] = summarize(result["samples"])
|
||||
result["verdict"] = "pass"
|
||||
result["finished_at"] = utc_now()
|
||||
atomic_write_json(output_dir / "results.json", result)
|
||||
(output_dir / "report.md").write_text(render_report(result), encoding="utf-8")
|
||||
partial = output_dir / "results.partial.json"
|
||||
if partial.exists():
|
||||
partial.unlink()
|
||||
return 0
|
||||
except HarnessInvalid as error:
|
||||
if client is not None and images:
|
||||
try:
|
||||
cleanup_table(
|
||||
client,
|
||||
image=images["v3_psycopg2"],
|
||||
postgres_container=args.postgres_container,
|
||||
table_name=table_name,
|
||||
args=args,
|
||||
)
|
||||
except HarnessInvalid as cleanup_error:
|
||||
result["cleanup_error"] = str(cleanup_error)
|
||||
result["error"] = str(error)
|
||||
result["finished_at"] = utc_now()
|
||||
atomic_write_json(output_dir / "results.invalid.json", result)
|
||||
print(f"postgresql_driver_ab invalid: {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+20
-3
@@ -13,8 +13,8 @@
|
||||
"runtime_to_db": [],
|
||||
"workflow_to_db": []
|
||||
},
|
||||
"edge_count": 6564,
|
||||
"edge_sha256": "333de3c1ef3f7e49be96dda288a0f382bcd19a853817708453d67557d138aa80",
|
||||
"edge_count": 6579,
|
||||
"edge_sha256": "87982f9e351a23cb949bcb8a978b9c1eced6260d6c19761fccc9557b79ac90d3",
|
||||
"edges": [
|
||||
"app -> app.runtime",
|
||||
"app -> app.runtime.compat",
|
||||
@@ -62,6 +62,7 @@
|
||||
"app.adapters.external.market -> app.foundation.version",
|
||||
"app.adapters.external.market -> app.runtime",
|
||||
"app.adapters.external.market -> app.runtime.cache",
|
||||
"app.adapters.external.market -> app.runtime.dependencies",
|
||||
"app.adapters.external.market -> app.runtime.execution",
|
||||
"app.adapters.external.market -> app.runtime.log",
|
||||
"app.adapters.external.market -> app.runtime.observability",
|
||||
@@ -134,6 +135,8 @@
|
||||
"app.adapters.system.host -> app.foundation.environment",
|
||||
"app.adapters.system.host -> app.schemas",
|
||||
"app.adapters.system.host -> app.schemas.dashboard",
|
||||
"app.adapters.system.package -> app.runtime",
|
||||
"app.adapters.system.package -> app.runtime.dependencies",
|
||||
"app.adapters.system.plugin.dependency -> app.adapters",
|
||||
"app.adapters.system.plugin.dependency -> app.adapters.external",
|
||||
"app.adapters.system.plugin.dependency -> app.adapters.external.market",
|
||||
@@ -163,6 +166,8 @@
|
||||
"app.adapters.system.resource -> app.runtime.config",
|
||||
"app.adapters.system.resource -> app.runtime.log",
|
||||
"app.adapters.system.resource -> app.runtime.settings",
|
||||
"app.adapters.system.rust -> app.foundation",
|
||||
"app.adapters.system.rust -> app.foundation.environment",
|
||||
"app.adapters.system.rust -> app.runtime",
|
||||
"app.adapters.system.rust -> app.runtime.log",
|
||||
"app.adapters.system.rust -> app.runtime.settings",
|
||||
@@ -2292,6 +2297,7 @@
|
||||
"app.api.endpoints.system -> app.domain.metainfo",
|
||||
"app.api.endpoints.system -> app.foundation",
|
||||
"app.api.endpoints.system -> app.foundation.crypto",
|
||||
"app.api.endpoints.system -> app.foundation.environment",
|
||||
"app.api.endpoints.system -> app.foundation.url",
|
||||
"app.api.endpoints.system -> app.runtime",
|
||||
"app.api.endpoints.system -> app.runtime.config",
|
||||
@@ -3593,6 +3599,8 @@
|
||||
"app.db.engine -> app.db",
|
||||
"app.db.engine -> app.db.diagnostics",
|
||||
"app.db.engine -> app.db.worker",
|
||||
"app.db.engine -> app.foundation",
|
||||
"app.db.engine -> app.foundation.environment",
|
||||
"app.db.engine -> app.runtime",
|
||||
"app.db.engine -> app.runtime.config",
|
||||
"app.db.engine -> app.runtime.log",
|
||||
@@ -3927,6 +3935,8 @@
|
||||
"app.factory -> app.schemas.response",
|
||||
"app.factory -> app.startup",
|
||||
"app.factory -> app.startup.lifecycle",
|
||||
"app.foundation.text -> app.foundation",
|
||||
"app.foundation.text -> app.foundation.environment",
|
||||
"app.main -> app.adapters",
|
||||
"app.main -> app.adapters.system",
|
||||
"app.main -> app.adapters.system.host",
|
||||
@@ -5597,6 +5607,8 @@
|
||||
"app.runtime.config -> app.schemas.types",
|
||||
"app.runtime.debounce -> app.runtime",
|
||||
"app.runtime.debounce -> app.runtime.log",
|
||||
"app.runtime.dependencies -> app.foundation",
|
||||
"app.runtime.dependencies -> app.foundation.environment",
|
||||
"app.runtime.deprecation.policy -> app.runtime",
|
||||
"app.runtime.deprecation.policy -> app.runtime.deprecation",
|
||||
"app.runtime.deprecation.policy -> app.runtime.deprecation.notices",
|
||||
@@ -5749,6 +5761,7 @@
|
||||
"app.runtime.extensions.plugin.tools -> app.runtime.extensions.plugin.contracts",
|
||||
"app.runtime.extensions.plugin_manager -> app.foundation",
|
||||
"app.runtime.extensions.plugin_manager -> app.foundation.crypto",
|
||||
"app.runtime.extensions.plugin_manager -> app.foundation.environment",
|
||||
"app.runtime.extensions.plugin_manager -> app.foundation.singleton",
|
||||
"app.runtime.extensions.plugin_manager -> app.foundation.version",
|
||||
"app.runtime.extensions.plugin_manager -> app.runtime",
|
||||
@@ -6369,6 +6382,8 @@
|
||||
"app.startup.lifecycle -> app.chain.system",
|
||||
"app.startup.lifecycle -> app.db",
|
||||
"app.startup.lifecycle -> app.db.engine",
|
||||
"app.startup.lifecycle -> app.foundation",
|
||||
"app.startup.lifecycle -> app.foundation.environment",
|
||||
"app.startup.lifecycle -> app.runtime",
|
||||
"app.startup.lifecycle -> app.runtime.config",
|
||||
"app.startup.lifecycle -> app.runtime.health",
|
||||
@@ -6581,7 +6596,7 @@
|
||||
"app.workflow.actions.transfer_file -> app.workflow",
|
||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||
],
|
||||
"module_count": 810,
|
||||
"module_count": 812,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -7019,6 +7034,7 @@
|
||||
"app.db.worker",
|
||||
"app.doctor",
|
||||
"app.doctor.checks",
|
||||
"app.doctor.dependencies",
|
||||
"app.doctor.formatters",
|
||||
"app.doctor.models",
|
||||
"app.doctor.runner",
|
||||
@@ -7237,6 +7253,7 @@
|
||||
"app.runtime.config",
|
||||
"app.runtime.correlation",
|
||||
"app.runtime.debounce",
|
||||
"app.runtime.dependencies",
|
||||
"app.runtime.deprecation",
|
||||
"app.runtime.deprecation.notices",
|
||||
"app.runtime.deprecation.policy",
|
||||
|
||||
@@ -8700,6 +8700,11 @@
|
||||
"name": "TimerUtils",
|
||||
"target": "app.runtime.scheduling.TimerUtils"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "convert",
|
||||
"target": "app.foundation.text.convert"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "cut",
|
||||
|
||||
@@ -11,10 +11,11 @@ def test_runtime_image_installs_postgresql_18_client_from_pgdg() -> None:
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert re.search(
|
||||
r"^FROM python:3\.14\.7-slim-trixie AS base$",
|
||||
r'^ARG MOVIEPILOT_PYTHON_VERSION="3\.14\.7"$',
|
||||
dockerfile,
|
||||
re.MULTILINE,
|
||||
)
|
||||
assert "FROM python:${MOVIEPILOT_PYTHON_VERSION}-slim-trixie AS base" in dockerfile
|
||||
assert "https://www.postgresql.org/media/keys/ACCC4CF8.asc" in dockerfile
|
||||
for curl_option in (
|
||||
"--connect-timeout 10",
|
||||
|
||||
@@ -5,13 +5,18 @@ import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
import psycopg2
|
||||
from psycopg2 import sql
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
|
||||
try:
|
||||
import psycopg2 as postgres_driver
|
||||
from psycopg2 import sql
|
||||
except ModuleNotFoundError:
|
||||
import psycopg as postgres_driver
|
||||
from psycopg import sql
|
||||
|
||||
|
||||
MIGRATION_MODULE = "database.versions.93f8cb6a4d1e_2_2_4"
|
||||
MEDIA_TABLES = (
|
||||
@@ -370,7 +375,7 @@ def test_current_schema_reaches_current_alembic_head_on_postgresql(
|
||||
port = os.getenv(f"{prefix}PORT", "5432")
|
||||
password = os.getenv(f"{prefix}PASSWORD", "")
|
||||
schema = f"p1_db1_{uuid.uuid4().hex}"
|
||||
with psycopg2.connect(
|
||||
with postgres_driver.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
dbname=database,
|
||||
@@ -400,7 +405,7 @@ def test_current_schema_reaches_current_alembic_head_on_postgresql(
|
||||
try:
|
||||
_run_current_schema_chain(repository, environment)
|
||||
finally:
|
||||
with psycopg2.connect(
|
||||
with postgres_driver.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
dbname=database,
|
||||
|
||||
@@ -2,13 +2,20 @@ import importlib
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import psycopg2
|
||||
from psycopg2 import sql
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
|
||||
try:
|
||||
import psycopg2 as postgres_driver
|
||||
from psycopg2 import sql
|
||||
POSTGRESQL_SQLALCHEMY_DRIVER = "postgresql+psycopg2"
|
||||
except ModuleNotFoundError:
|
||||
import psycopg as postgres_driver
|
||||
from psycopg import sql
|
||||
POSTGRESQL_SQLALCHEMY_DRIVER = "postgresql+psycopg"
|
||||
|
||||
|
||||
E6_MIGRATION = "database.versions.e6a1c4b8d2f0_2_2_13"
|
||||
F7_MIGRATION = "database.versions.f7b2d5c9a301_2_2_14"
|
||||
@@ -636,7 +643,7 @@ def test_e6_f7_round_trip_on_postgresql(monkeypatch) -> None:
|
||||
port = os.getenv(f"{prefix}PORT", "5432")
|
||||
password = os.getenv(f"{prefix}PASSWORD", "")
|
||||
schema = f"p1_db1_roundtrip_{uuid.uuid4().hex}"
|
||||
with psycopg2.connect(
|
||||
with postgres_driver.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
dbname=database,
|
||||
@@ -654,7 +661,7 @@ def test_e6_f7_round_trip_on_postgresql(monkeypatch) -> None:
|
||||
try:
|
||||
engine = sa.create_engine(
|
||||
sa.URL.create(
|
||||
"postgresql+psycopg2",
|
||||
POSTGRESQL_SQLALCHEMY_DRIVER,
|
||||
username=username,
|
||||
password=password,
|
||||
host=host,
|
||||
@@ -667,7 +674,7 @@ def test_e6_f7_round_trip_on_postgresql(monkeypatch) -> None:
|
||||
finally:
|
||||
if engine is not None:
|
||||
engine.dispose()
|
||||
with psycopg2.connect(
|
||||
with postgres_driver.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
dbname=database,
|
||||
|
||||
@@ -223,6 +223,19 @@ def test_pg_sync_engine_applies_pool_settings(monkeypatch):
|
||||
assert captured["url"].startswith("postgresql")
|
||||
|
||||
|
||||
def test_pg_sync_engine_uses_psycopg_on_free_threaded_python(monkeypatch):
|
||||
"""free-threaded 运行时不能加载会重新启用 GIL 的 psycopg2 扩展。"""
|
||||
monkeypatch.setattr(engine_module, "is_free_threaded_runtime", lambda: True)
|
||||
captured = {}
|
||||
monkeypatch.setattr(engine_module, "create_engine",
|
||||
lambda **kw: captured.update(kw) or MagicMock())
|
||||
monkeypatch.setattr(engine_module, "_register_database_error_logging", lambda *_a: None)
|
||||
|
||||
engine_module._get_postgresql_engine(is_async=False)
|
||||
|
||||
assert captured["url"].startswith("postgresql+psycopg://")
|
||||
|
||||
|
||||
def test_pg_async_engine_pooled_omits_poolclass(monkeypatch):
|
||||
"""
|
||||
池化的异步引擎不得指定 poolclass:SQLAlchemy 需自行选用异步适配的
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
@@ -35,8 +36,10 @@ def test_dockerfile_control_bundle_build_checks_fail_closed() -> None:
|
||||
assert "COPY --from=uv /uv /usr/local/bin/uv" in dockerfile
|
||||
assert "COPY pyproject.toml uv.lock ./" in dockerfile
|
||||
assert "python3 -m venv --without-pip ${VENV_PATH}" in dockerfile
|
||||
assert 'sysconfig.get_path("purelib")' in dockerfile
|
||||
assert "UV_PROJECT_ENVIRONMENT=${VENV_PATH} uv sync" in dockerfile
|
||||
for option in ("--locked", "--no-dev", "--no-install-project"):
|
||||
assert "PYTHON_THREAD_INHERIT_CONTEXT=0" in dockerfile
|
||||
for option in ("--locked", "--no-default-groups", "--group", "--no-install-project"):
|
||||
assert option in dockerfile
|
||||
assert "uv-pip-compat" not in dockerfile
|
||||
assert "requirements.in" not in dockerfile
|
||||
@@ -58,6 +61,81 @@ def test_dockerfile_control_bundle_build_checks_fail_closed() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_update_sync_selects_target_interpreter_runtime_group(tmp_path: Path) -> None:
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
(project / "pyproject.toml").write_text(
|
||||
"""
|
||||
[project]
|
||||
name = "moviepilot"
|
||||
version = "0"
|
||||
|
||||
[dependency-groups]
|
||||
runtime-standard = ["standard"]
|
||||
runtime-free-threaded = ["free-threaded"]
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(project / "uv.lock").write_text("version = 1\n", encoding="utf-8")
|
||||
runtime_selector = project / "app" / "runtime" / "dependencies.py"
|
||||
runtime_selector.parent.mkdir(parents=True)
|
||||
runtime_selector.write_text("print('runtime-free-threaded')\n", encoding="utf-8")
|
||||
venv_bin = tmp_path / "venv" / "bin"
|
||||
venv_bin.mkdir(parents=True)
|
||||
python_bin = venv_bin / "python3"
|
||||
python_bin.write_text(
|
||||
"#!/bin/bash\ncat >/dev/null\nprintf '%s\\n' runtime-free-threaded\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
python_bin.chmod(0o755)
|
||||
uv_bin = tmp_path / "uv"
|
||||
uv_log = tmp_path / "uv.log"
|
||||
uv_bin.write_text(
|
||||
f"#!/bin/bash\nprintf '%s\\n' \"$*\" > {shlex.quote(str(uv_log))}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
uv_bin.chmod(0o755)
|
||||
script = textwrap.dedent(
|
||||
f"""\
|
||||
CONFIG_DIR="$1"
|
||||
VENV_PATH="$2"
|
||||
UV_BIN="$3"
|
||||
source {UPDATER!s}
|
||||
PACKAGE_ENV=()
|
||||
UV_OPTIONS=()
|
||||
sync_project_dependencies_for "$4"
|
||||
"""
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"bash",
|
||||
"-c",
|
||||
script,
|
||||
"runtime-profile-test",
|
||||
str(tmp_path / "config"),
|
||||
str(tmp_path / "venv"),
|
||||
str(uv_bin),
|
||||
str(project),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
command = uv_log.read_text(encoding="utf-8")
|
||||
assert "--no-default-groups --group runtime-free-threaded" in command
|
||||
|
||||
|
||||
def test_build_profiles_use_full_runtime_capability_probe() -> None:
|
||||
"""两套依赖 profile 必须经过统一的完整运行能力验证。"""
|
||||
dockerfile = (ROOT / "docker" / "Dockerfile").read_text(encoding="utf-8")
|
||||
|
||||
assert dockerfile.count(
|
||||
'RUN "${VENV_PATH}/bin/python" /tmp/moviepilot-runtime-dependencies.py --full'
|
||||
) == 2
|
||||
assert "moviepilot_rust.jieba_cut" not in dockerfile
|
||||
assert "moviepilot_rust.zhconv_fast" not in dockerfile
|
||||
|
||||
|
||||
def _run_launcher(
|
||||
tmp_path: Path,
|
||||
source: Path,
|
||||
@@ -915,6 +993,9 @@ def test_failed_dependency_sync_does_not_replace_program_files(tmp_path: Path) -
|
||||
encoding="utf-8",
|
||||
)
|
||||
uv_bin.chmod(0o755)
|
||||
python_bin = tmp_path / "venv" / "bin" / "python3"
|
||||
python_bin.parent.mkdir(parents=True)
|
||||
python_bin.symlink_to(Path(sys.executable))
|
||||
live_app = tmp_path / "app"
|
||||
live_public = tmp_path / "public"
|
||||
(live_app / "app" / "plugins").mkdir(parents=True)
|
||||
|
||||
@@ -315,11 +315,15 @@ def test_browser_install_is_centralized_in_startup() -> None:
|
||||
browser = (ROOT / "docker" / "browser.sh").read_text(encoding="utf-8")
|
||||
updater = (ROOT / "docker" / "update.sh").read_text(encoding="utf-8")
|
||||
startup = entrypoint.split("# 使用env配置", 1)[1]
|
||||
updater_source = 'source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/update.sh"'
|
||||
|
||||
assert "-m cloakbrowser install" not in entrypoint
|
||||
assert browser.count("-m cloakbrowser install") == 2
|
||||
assert "-m cloakbrowser install" not in updater
|
||||
assert startup.index('source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/update.sh"') < startup.index(
|
||||
assert startup.count(updater_source) == 1
|
||||
assert startup.index(updater_source) < startup.index(
|
||||
'if [ "${MOVIEPILOT_BOOTSTRAP_UPDATE_DONE:-0}" != "1" ]'
|
||||
) < startup.index(
|
||||
'source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/browser.sh"'
|
||||
) < startup.index("resolve_browser_cache_dir") < startup.index("ensure_browser_kernel")
|
||||
|
||||
@@ -589,6 +593,46 @@ def test_backend_ready_timeout_accepts_leading_zero_decimal(tmp_path: Path) -> N
|
||||
assert "MoviePilot Web 已可访问" in output
|
||||
|
||||
|
||||
def test_backend_dependency_recovery_uses_runtime_profile_sync(tmp_path: Path) -> None:
|
||||
"""启动自愈必须复用按当前解释器选择依赖组的同步入口。"""
|
||||
venv_bin = tmp_path / "venv" / "bin"
|
||||
venv_bin.mkdir(parents=True)
|
||||
python_bin = venv_bin / "python3"
|
||||
python_bin.write_text(
|
||||
"#!/bin/bash\n[ -f \"${RECOVERY_MARKER}\" ]\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
python_bin.chmod(0o755)
|
||||
marker = tmp_path / "recovered"
|
||||
|
||||
output = _run_entrypoint_case(
|
||||
tmp_path,
|
||||
"""
|
||||
INFO() { printf '[INFO] %s\\n' "$1"; }
|
||||
WARN() { printf '[WARN] %s\\n' "$1"; }
|
||||
configure_package_route() {
|
||||
PACKAGE_LOG="test-route"
|
||||
printf 'configured\\n'
|
||||
}
|
||||
sync_project_dependencies_for() {
|
||||
printf 'sync:%s\\n' "$1"
|
||||
touch "${RECOVERY_MARKER}"
|
||||
}
|
||||
ensure_backend_runtime_dependencies
|
||||
printf 'route-ready:%s\\n' "${PACKAGE_ROUTE_READY}"
|
||||
""",
|
||||
env={
|
||||
"VENV_PATH": str(tmp_path / "venv"),
|
||||
"RECOVERY_MARKER": str(marker),
|
||||
},
|
||||
)
|
||||
|
||||
assert "configured" in output
|
||||
assert "sync:/app" in output
|
||||
assert "route-ready:true" in output
|
||||
assert marker.exists()
|
||||
|
||||
|
||||
def test_backend_failure_keepalive_contract_is_explicit() -> None:
|
||||
"""后端异常默认保活诊断,显式关闭后才退出容器。"""
|
||||
content = (ROOT / "docker" / "entrypoint.sh").read_text(encoding="utf-8")
|
||||
|
||||
@@ -21,6 +21,7 @@ def test_build_context_excludes_runtime_state_and_keeps_release_inputs() -> None
|
||||
for pattern in (
|
||||
".venv/",
|
||||
".worktrees/",
|
||||
".artifacts/",
|
||||
".build/",
|
||||
".agent-work/",
|
||||
".runtime/",
|
||||
@@ -141,5 +142,6 @@ def test_custom_frontend_directory_is_stable_but_artifacts_remain_untracked() ->
|
||||
assert (ROOT / "frontend-dist" / ".gitkeep").is_file()
|
||||
assert "frontend-dist/*" in gitignore
|
||||
assert "!frontend-dist/.gitkeep" in gitignore
|
||||
assert ".artifacts/" in gitignore
|
||||
assert "COPY frontend-dist/ /tmp/frontend-dist/" in dockerfile
|
||||
assert "! -name '.gitkeep'" in dockerfile
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
"""free-threaded 镜像 A/B harness 的无 Docker 合同测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "perf" / "free_threaded_ab.py"
|
||||
|
||||
|
||||
def load_harness(name: str = "free_threaded_ab_test"):
|
||||
"""从脚本路径加载 harness,避免把 scripts 变成运行时 package。"""
|
||||
spec = importlib.util.spec_from_file_location(name, SCRIPT)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def digest_image(repository: str, character: str) -> str:
|
||||
"""构造测试用不可变镜像引用。"""
|
||||
return f"registry.example/{repository}@sha256:{character * 64}"
|
||||
|
||||
|
||||
def valid_preflight(variant: str) -> dict:
|
||||
"""构造满足目标镜像合同的 preflight。"""
|
||||
common = {
|
||||
"python_version": "3.14.7",
|
||||
"python_implementation": "CPython",
|
||||
"moviepilot_rust_version": "0.3.0",
|
||||
"rust_available": True,
|
||||
"has_jieba_cut": True,
|
||||
"packages": {},
|
||||
"native": {},
|
||||
"installed_packages": ["moviepilot-rust==0.3.0"],
|
||||
"installed_packages_sha256": "package-hash",
|
||||
"native_distributions": [],
|
||||
"uv_pip_check": {
|
||||
"returncode": 1,
|
||||
"stderr": "The package `oss2` requires `crcmod>=1.7`, but it's not installed",
|
||||
},
|
||||
"uv_project_sync_check": {"returncode": 0},
|
||||
}
|
||||
if variant == "v3":
|
||||
common.update(
|
||||
{
|
||||
"gil_disabled": False,
|
||||
"gil_enabled": True,
|
||||
"thread_inherit_context": 0,
|
||||
"has_zhconv_fast": False,
|
||||
"gil_enabled_after_imports": True,
|
||||
"packages": {
|
||||
"bcrypt": "4.3.0",
|
||||
"brotli": "1.2.0",
|
||||
"crcmod": None,
|
||||
"crcmod-plus": "2.3.1",
|
||||
"lxml": "6.1.2",
|
||||
"orjson": "3.12.0",
|
||||
"psycopg": None,
|
||||
"psycopg2-binary": "2.9.12",
|
||||
"zhconv-rs": "0.4.1",
|
||||
},
|
||||
"native": {
|
||||
"crcmod_extension": True,
|
||||
},
|
||||
"imports": {
|
||||
"moviepilot-rust": {"imported": True, "gil_after": True},
|
||||
"psycopg2-binary": {"imported": True, "gil_after": True},
|
||||
},
|
||||
}
|
||||
)
|
||||
else:
|
||||
common.update(
|
||||
{
|
||||
"gil_disabled": True,
|
||||
"gil_enabled": False,
|
||||
"thread_inherit_context": 0,
|
||||
"has_zhconv_fast": True,
|
||||
"gil_enabled_after_imports": False,
|
||||
"packages": {
|
||||
"bcrypt": "5.0.0",
|
||||
"brotli": "1.2.0",
|
||||
"crcmod": None,
|
||||
"lxml": "7.0.0b1",
|
||||
"orjson": "3.12.0",
|
||||
"crcmod-plus": "2.3.1",
|
||||
"psycopg": "3.3.4",
|
||||
"psycopg2-binary": None,
|
||||
"zhconv-rs": None,
|
||||
},
|
||||
"native": {
|
||||
"crcmod_extension": True,
|
||||
"psycopg_impl": "c",
|
||||
},
|
||||
"imports": {
|
||||
"moviepilot-rust": {"imported": True, "gil_after": False},
|
||||
"psycopg": {"imported": True, "gil_after": False},
|
||||
},
|
||||
}
|
||||
)
|
||||
expected_imports = {
|
||||
"asyncpg",
|
||||
"bcrypt",
|
||||
"brotli",
|
||||
"crcmod-plus",
|
||||
"cryptography",
|
||||
"greenlet",
|
||||
"lxml",
|
||||
"moviepilot-rust",
|
||||
"orjson",
|
||||
"oss2",
|
||||
"pillow",
|
||||
"pillow-avif-plugin",
|
||||
"pydantic-core",
|
||||
"site-resource",
|
||||
"zstandard",
|
||||
}
|
||||
expected_imports.update(
|
||||
{"psycopg2-binary", "zhconv-rs"} if variant == "v3" else {"psycopg"}
|
||||
)
|
||||
common["imports"] = {
|
||||
name: {"imported": True, "gil_after": variant == "v3"}
|
||||
for name in expected_imports
|
||||
}
|
||||
return common
|
||||
|
||||
|
||||
def sample(harness, variant: str, index: int, multiplier: float = 1.0) -> dict:
|
||||
"""构造可汇总的真实字段形状。"""
|
||||
checksum = "same-checksum"
|
||||
application = {
|
||||
"rust_on": {
|
||||
"seconds": 0.8 * multiplier,
|
||||
"checksum": checksum,
|
||||
}
|
||||
}
|
||||
if variant == "v3":
|
||||
application["rust_off"] = {"seconds": 1.0, "checksum": checksum}
|
||||
throughput = 100.0 if variant == "v3" else 120.0
|
||||
return {
|
||||
"variant": variant,
|
||||
"sample_index": index,
|
||||
"startup": {
|
||||
"ready_seconds": 10.0 * multiplier,
|
||||
"idle": {
|
||||
"engine": {"working_set_bytes": 100_000_000},
|
||||
"processes": {
|
||||
"totals": {
|
||||
"rss_kib": 100_000,
|
||||
"pss_kib": 90_000,
|
||||
"uss_kib": 80_000,
|
||||
"threads": 20,
|
||||
},
|
||||
"main_python": {"pid": 1},
|
||||
},
|
||||
},
|
||||
"api": {
|
||||
endpoint: {
|
||||
"status": 200,
|
||||
"p50_ms": 1.0,
|
||||
"p95_ms": 2.0,
|
||||
"max_ms": 3.0,
|
||||
**(
|
||||
{
|
||||
"runtime": {
|
||||
"gil_enabled": variant == "v3",
|
||||
"rust_enabled": True,
|
||||
"rust_required": variant == "v3t",
|
||||
}
|
||||
}
|
||||
if endpoint == "system_env"
|
||||
else {}
|
||||
),
|
||||
}
|
||||
for endpoint in (
|
||||
"health_ready",
|
||||
"dashboard_statistic",
|
||||
"subscribe_list",
|
||||
"system_env",
|
||||
)
|
||||
},
|
||||
},
|
||||
"hotspots": {
|
||||
"fixture_sha256": harness.FIXTURE_SHA256,
|
||||
"jieba_cut": {"available": True},
|
||||
"gil_enabled_after_hotspots": variant == "v3",
|
||||
"application": application,
|
||||
"rust_concurrency": {
|
||||
"1": {"throughput_ops_s": throughput, "checksum": checksum},
|
||||
"32": {"throughput_ops_s": throughput, "checksum": checksum},
|
||||
},
|
||||
"python_concurrency": {
|
||||
"1": {"throughput_ops_s": throughput, "checksum": checksum},
|
||||
"32": {"throughput_ops_s": throughput, "checksum": checksum},
|
||||
},
|
||||
},
|
||||
"postgresql": {
|
||||
"probe": "app.db.engine._sync_postgresql_driver",
|
||||
"result": {
|
||||
"driver": None if variant == "v3" else "psycopg",
|
||||
"scheme": "postgresql" if variant == "v3" else "postgresql+psycopg",
|
||||
},
|
||||
},
|
||||
"sqlite": {
|
||||
"sync": {"throughput_ops_s": 1000.0, "checksum": checksum},
|
||||
"async": {"throughput_ops_s": 900.0, "checksum": checksum},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def result_for_evaluation(harness, ft_multiplier: float = 1.0) -> dict:
|
||||
"""按平衡顺序构造三组 A/B。"""
|
||||
samples = [
|
||||
sample(harness, variant, index, ft_multiplier if variant == "v3t" else 1.0)
|
||||
for variant, index in harness.SAMPLE_ORDER
|
||||
]
|
||||
return {
|
||||
"samples": samples,
|
||||
"workers": [1, 32],
|
||||
"thresholds": {
|
||||
"max_startup_ratio": 1.25,
|
||||
"max_v3_rust_over_python_ratio": 1.10,
|
||||
"max_v3t_rust_over_v3_rust_ratio": 1.25,
|
||||
"min_ft_max_worker_throughput_ratio": 1.05,
|
||||
"max_idle_memory_ratio": 1.25,
|
||||
"max_api_p95_ratio": 1.25,
|
||||
},
|
||||
"preflight": {
|
||||
variant: {"payload": valid_preflight(variant)}
|
||||
for variant in ("v3", "v3t")
|
||||
},
|
||||
"images": {
|
||||
"v3": {"size_bytes": 600_000_000},
|
||||
"v3t": {"size_bytes": 630_000_000},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_digest_inputs_and_public_image_names_are_strict() -> None:
|
||||
"""拒绝可变 tag、短 digest、旧 v3t 名称及参数传反。"""
|
||||
harness = load_harness("free_threaded_ab_images")
|
||||
standard = digest_image("moviepilot-v3", "a")
|
||||
free_threaded = digest_image("moviepilot-v3t", "b")
|
||||
|
||||
assert harness.immutable_image(standard) == standard
|
||||
harness.assert_expected_repository(standard, "moviepilot-v3")
|
||||
harness.assert_expected_repository(free_threaded, "moviepilot-v3t")
|
||||
|
||||
for invalid in ("moviepilot-v3:latest", "moviepilot-v3@sha256:abc"):
|
||||
with pytest.raises(argparse.ArgumentTypeError):
|
||||
harness.immutable_image(invalid)
|
||||
with pytest.raises(harness.HarnessInvalid, match="moviepilot-v3t"):
|
||||
harness.assert_expected_repository(
|
||||
digest_image("moviepilot-v3-ft", "c"), "moviepilot-v3t"
|
||||
)
|
||||
|
||||
|
||||
def test_local_image_id_is_an_immutable_offline_fallback() -> None:
|
||||
"""未拉取时允许用本地 image ID 验收尚未发布的候选。"""
|
||||
harness = load_harness("free_threaded_ab_local_image")
|
||||
digest = f"sha256:{'a' * 64}"
|
||||
reference = f"moviepilot-v3@{digest}"
|
||||
image = SimpleNamespace(
|
||||
id=digest,
|
||||
attrs={
|
||||
"Id": digest,
|
||||
"RepoDigests": [],
|
||||
"Config": {
|
||||
"Labels": {
|
||||
"org.moviepilot.source-revision": "local-source",
|
||||
"org.opencontainers.image.version": "3.0.0-local",
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
images = Mock()
|
||||
images.get.side_effect = [RuntimeError("no manifest digest"), image]
|
||||
client = SimpleNamespace(images=images)
|
||||
|
||||
identity = harness.image_identity(client, reference, pull=False)
|
||||
|
||||
assert identity["image_id"] == digest
|
||||
assert identity["runtime_reference"] == digest
|
||||
assert identity["source_revision"] == "local-source"
|
||||
assert identity["version"] == "3.0.0-local"
|
||||
assert images.get.call_args_list[1].args == (digest,)
|
||||
|
||||
|
||||
def test_docker_client_uses_current_cli_context_when_default_socket_fails(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""Docker Desktop 等非默认 socket 由当前 CLI context 统一定位。"""
|
||||
harness = load_harness("free_threaded_ab_docker_context")
|
||||
monkeypatch.delenv("DOCKER_HOST", raising=False)
|
||||
default_client = Mock()
|
||||
default_client.ping.side_effect = RuntimeError("default socket missing")
|
||||
context_client = Mock()
|
||||
docker_module = SimpleNamespace(
|
||||
from_env=Mock(return_value=default_client),
|
||||
DockerClient=Mock(return_value=context_client),
|
||||
)
|
||||
monkeypatch.setattr(harness, "docker", docker_module)
|
||||
run = Mock(return_value=SimpleNamespace(stdout="unix:///current/docker.sock\n"))
|
||||
monkeypatch.setattr(harness.subprocess, "run", run)
|
||||
|
||||
assert harness.require_docker_client() is context_client
|
||||
docker_module.DockerClient.assert_called_once_with(
|
||||
base_url="unix:///current/docker.sock"
|
||||
)
|
||||
context_client.ping.assert_called_once_with()
|
||||
|
||||
|
||||
def test_fixture_and_sample_order_are_stable() -> None:
|
||||
"""固定 seed、内容 hash 和交替顺序防止样本漂移。"""
|
||||
harness = load_harness("free_threaded_ab_fixture")
|
||||
|
||||
assert harness.fixture_hash(harness.build_fixture()) == harness.FIXTURE_SHA256
|
||||
assert harness.FIXTURE_SHA256 == harness.EXPECTED_FIXTURE_SHA256
|
||||
assert len(harness.FIXTURE) == 64
|
||||
assert harness.SAMPLE_ORDER == (
|
||||
("v3", 1),
|
||||
("v3t", 1),
|
||||
("v3t", 2),
|
||||
("v3", 2),
|
||||
("v3", 3),
|
||||
("v3t", 3),
|
||||
)
|
||||
|
||||
|
||||
def test_preflight_enforces_runtime_and_native_profiles() -> None:
|
||||
"""标准与 FT 镜像必须满足互斥 ABI、GIL、Rust 和原生依赖合同。"""
|
||||
harness = load_harness("free_threaded_ab_preflight")
|
||||
|
||||
assert harness.validate_preflight("v3", valid_preflight("v3")) == []
|
||||
assert harness.validate_preflight("v3t", valid_preflight("v3t")) == []
|
||||
|
||||
leaked = valid_preflight("v3")
|
||||
leaked["has_zhconv_fast"] = True
|
||||
assert any("has_zhconv_fast" in item for item in harness.validate_preflight("v3", leaked))
|
||||
|
||||
unsafe = valid_preflight("v3t")
|
||||
unsafe["gil_enabled_after_imports"] = True
|
||||
unsafe["packages"]["psycopg2-binary"] = "2.9.12"
|
||||
errors = harness.validate_preflight("v3t", unsafe)
|
||||
assert any("GIL" in item or "gil_enabled_after_imports" in item for item in errors)
|
||||
assert any("标准原生依赖" in item for item in errors)
|
||||
|
||||
incomplete = valid_preflight("v3t")
|
||||
incomplete["imports"].pop("lxml")
|
||||
errors = harness.validate_preflight("v3t", incomplete)
|
||||
assert any("缺少核心组件导入结果" in item and "lxml" in item for item in errors)
|
||||
|
||||
|
||||
def test_evaluation_distinguishes_invalid_regression_and_pass() -> None:
|
||||
"""合同错误优先 invalid,有效性能失败才归为 regression。"""
|
||||
harness = load_harness("free_threaded_ab_evaluation")
|
||||
|
||||
summary, invalid, regressions = harness.evaluate_samples(result_for_evaluation(harness))
|
||||
assert summary["ratios"]["ft_max_worker_throughput_over_v3"] == pytest.approx(1.2)
|
||||
assert set(summary["application_seconds"]) == {
|
||||
"v3_python",
|
||||
"v3_rust",
|
||||
"v3t_rust",
|
||||
}
|
||||
assert summary["ratios"]["v3_rust_over_python"] == pytest.approx(0.8)
|
||||
assert summary["ratios"]["v3t_rust_over_v3_rust"] == pytest.approx(1.0)
|
||||
assert summary["installed_packages"]["v3"]["count"] == 1
|
||||
assert invalid == []
|
||||
assert regressions == []
|
||||
|
||||
regression = result_for_evaluation(harness, ft_multiplier=1.5)
|
||||
_, invalid, regressions = harness.evaluate_samples(regression)
|
||||
assert invalid == []
|
||||
assert any("startup" in item for item in regressions)
|
||||
assert any("V3t Rust" in item for item in regressions)
|
||||
|
||||
v3_rust_regression = result_for_evaluation(harness)
|
||||
for item in v3_rust_regression["samples"]:
|
||||
if item["variant"] == "v3":
|
||||
item["hotspots"]["application"]["rust_on"]["seconds"] = 1.2
|
||||
_, invalid, regressions = harness.evaluate_samples(v3_rust_regression)
|
||||
assert invalid == []
|
||||
assert any("标准镜像启用 Rust" in item for item in regressions)
|
||||
|
||||
broken = result_for_evaluation(harness)
|
||||
broken["samples"][0]["postgresql"]["result"]["scheme"] = "postgresql+psycopg"
|
||||
summary, invalid, regressions = harness.evaluate_samples(broken)
|
||||
assert summary == {}
|
||||
assert any("PostgreSQL" in item for item in invalid)
|
||||
assert regressions == []
|
||||
|
||||
incomplete_api = result_for_evaluation(harness)
|
||||
incomplete_api["samples"][0]["startup"]["api"].pop("subscribe_list")
|
||||
summary, invalid, regressions = harness.evaluate_samples(incomplete_api)
|
||||
assert summary == {}
|
||||
assert any("缺少 API 样本" in item and "subscribe_list" in item for item in invalid)
|
||||
assert regressions == []
|
||||
|
||||
|
||||
def test_markdown_and_exit_codes_preserve_machine_verdict(tmp_path: Path, monkeypatch) -> None:
|
||||
"""JSON verdict、Markdown 摘要与 0/1/2 进程状态保持一致。"""
|
||||
harness = load_harness("free_threaded_ab_exit")
|
||||
base = {
|
||||
"schema_version": harness.SCHEMA_VERSION,
|
||||
"fixture": {"sha256": harness.FIXTURE_SHA256, "count": 64},
|
||||
"source_revision": "abc123",
|
||||
"images": {
|
||||
"v3": {"reference": digest_image("moviepilot-v3", "a")},
|
||||
"v3t": {"reference": digest_image("moviepilot-v3t", "b")},
|
||||
},
|
||||
"summary": {},
|
||||
"invalid_reasons": [],
|
||||
"regressions": [],
|
||||
}
|
||||
markdown = harness.build_markdown({**base, "verdict": "pass"})
|
||||
assert "moviepilot-v3t@sha256" in markdown
|
||||
assert harness.FIXTURE_SHA256 in markdown
|
||||
|
||||
evaluated = result_for_evaluation(harness)
|
||||
summary, invalid, regressions = harness.evaluate_samples(evaluated)
|
||||
report = harness.build_markdown(
|
||||
{
|
||||
**base,
|
||||
"verdict": "pass",
|
||||
"summary": summary,
|
||||
"invalid_reasons": invalid,
|
||||
"regressions": regressions,
|
||||
}
|
||||
)
|
||||
assert "V3 + Python | V3 + Rust | V3t + Rust" in report
|
||||
assert "Pure Python CPU probe" in report
|
||||
assert "Direct Rust ABI probe" in report
|
||||
|
||||
argv = [
|
||||
"--standard-image",
|
||||
digest_image("moviepilot-v3", "a"),
|
||||
"--free-threaded-image",
|
||||
digest_image("moviepilot-v3t", "b"),
|
||||
"--campaign",
|
||||
"fake",
|
||||
"--output-dir",
|
||||
str(tmp_path),
|
||||
]
|
||||
for verdict, expected in (("pass", 0), ("regression", 1), ("invalid", 2)):
|
||||
monkeypatch.setattr(
|
||||
harness,
|
||||
"execute_campaign",
|
||||
lambda _args, value=verdict: {"verdict": value},
|
||||
)
|
||||
assert harness.main(argv) == expected
|
||||
@@ -1,9 +1,21 @@
|
||||
from app.foundation.text import cut
|
||||
|
||||
|
||||
def test_cut_accepts_legacy_hmm_argument():
|
||||
"""验证分词封装支持旧 jieba.cut 的 HMM 参数名。"""
|
||||
def test_cut_accepts_hmm_argument():
|
||||
"""验证分词入口支持公开的 HMM 参数名。"""
|
||||
words = cut("台湾后台测试", HMM=False)
|
||||
|
||||
assert "".join(words) == "台湾后台测试"
|
||||
assert "后台" in words
|
||||
|
||||
|
||||
def test_cut_preserves_full_mode_contract():
|
||||
assert cut("南京市长江大桥", cut_all=True) == [
|
||||
"南京",
|
||||
"南京市",
|
||||
"京市",
|
||||
"市长",
|
||||
"长江",
|
||||
"长江大桥",
|
||||
"大桥",
|
||||
]
|
||||
|
||||
@@ -199,6 +199,19 @@ def test_lifespan_propagates_logger_nonconvergence(monkeypatch):
|
||||
)
|
||||
|
||||
|
||||
def test_runtime_gil_status_warns_when_free_threaded_runtime_enables_gil(monkeypatch):
|
||||
"""free-threaded 运行时退化为 GIL 模式时必须留下明确诊断。"""
|
||||
monkeypatch.setattr(lifecycle, "is_free_threaded_runtime", lambda: True)
|
||||
monkeypatch.setattr(lifecycle, "is_gil_enabled", lambda: True)
|
||||
warning = MagicMock()
|
||||
monkeypatch.setattr(lifecycle.logger, "warning", warning)
|
||||
|
||||
lifecycle._log_runtime_gil_status()
|
||||
|
||||
warning.assert_called_once()
|
||||
assert "已启用 GIL" in warning.call_args.args[0]
|
||||
|
||||
|
||||
def test_lifespan_validation_failure_does_not_clear_outer_loop_owner(monkeypatch):
|
||||
"""当前生命周期尚未取得 owner 时,启动失败不得清理外层登记。"""
|
||||
_patch_lifespan(monkeypatch)
|
||||
|
||||
@@ -7,6 +7,7 @@ from app.adapters.system.package import (
|
||||
PackageInstallRequest,
|
||||
build_package_install_env,
|
||||
build_package_install_strategies,
|
||||
build_project_sync_strategies,
|
||||
redact_url,
|
||||
)
|
||||
|
||||
@@ -129,6 +130,31 @@ def test_build_strategies_passes_all_manifests_to_one_uv_process(tmp_path):
|
||||
assert command[second_requirement + 1] == str(legacy)
|
||||
|
||||
|
||||
def test_project_sync_selects_current_runtime_group(tmp_path, monkeypatch):
|
||||
"""主项目恢复必须显式选择当前解释器对应的互斥运行依赖组。"""
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
pyproject = project / "pyproject.toml"
|
||||
pyproject.write_text("[project]\nname='moviepilot'\nversion='0'\n", encoding="utf-8")
|
||||
uv_bin = tmp_path / "venv" / "bin" / "uv"
|
||||
uv_bin.parent.mkdir(parents=True)
|
||||
uv_bin.write_text("", encoding="utf-8")
|
||||
request = PackageInstallRequest(
|
||||
dependency_files=(pyproject,),
|
||||
python_bin=tmp_path / "venv" / "bin" / "python",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.adapters.system.package.runtime_sync_arguments",
|
||||
lambda: ("--no-default-groups", "--group", "runtime-free-threaded"),
|
||||
)
|
||||
|
||||
command = build_project_sync_strategies(request)[0].command
|
||||
|
||||
assert command.count("--group") == 1
|
||||
assert command[command.index("--group") + 1] == "runtime-free-threaded"
|
||||
assert "--no-default-groups" in command
|
||||
|
||||
|
||||
def test_redact_url_removes_userinfo():
|
||||
assert redact_url("https://user:pass@mirror.example/simple") == "https://mirror.example/simple"
|
||||
|
||||
|
||||
+114
-4
@@ -1302,6 +1302,79 @@ demo = { index = "private" }
|
||||
assert "主程序核心依赖" in message
|
||||
assert "fastapi" in message
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("runtime_group", "installed_package", "installed_version", "requirement"),
|
||||
[
|
||||
("runtime-standard", "lxml", "6.1.2", "lxml>=7.1"),
|
||||
("runtime-free-threaded", "psycopg", "3.3.4", "psycopg>=3.4"),
|
||||
],
|
||||
)
|
||||
def test_uv_install_rejects_runtime_profile_root_upgrade(
|
||||
self,
|
||||
runtime_group,
|
||||
installed_package,
|
||||
installed_version,
|
||||
requirement,
|
||||
):
|
||||
"""插件不得升级当前解释器 profile 中经过 ABI/GIL 验证的根包。"""
|
||||
from app.adapters.external.market import PluginHelper
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
requirements_file = Path(temp_dir) / "requirements.txt"
|
||||
requirements_file.write_text(f"{requirement}\n", encoding="utf-8")
|
||||
with patch(
|
||||
"app.runtime.dependencies.runtime_dependency_group",
|
||||
return_value=runtime_group,
|
||||
):
|
||||
success, message = PluginHelper._PluginHelper__validate_runtime_dependency_conflicts(
|
||||
requirements_file,
|
||||
{installed_package: Version(installed_version)},
|
||||
)
|
||||
|
||||
assert not success
|
||||
assert "主程序核心依赖" in message
|
||||
assert installed_package in message
|
||||
|
||||
def test_runtime_healthcheck_preserves_plugin_upgrade_semantics(self, tmp_path):
|
||||
"""运行环境诊断不应把允许的插件依赖升级强制还原到宿主锁版本。"""
|
||||
from app.adapters.external.market import PluginHelper
|
||||
|
||||
uv_bin = tmp_path / "uv"
|
||||
with patch("app.adapters.external.market.find_uv", return_value=uv_bin):
|
||||
command = PluginHelper._PluginHelper__build_runtime_uv_check_command()
|
||||
|
||||
assert command == [
|
||||
str(uv_bin),
|
||||
"pip",
|
||||
"check",
|
||||
"--python",
|
||||
sys.executable,
|
||||
]
|
||||
|
||||
def test_plugin_runtime_healthcheck_uses_full_capability_probe(self, tmp_path):
|
||||
"""共享 venv 发生变更时必须验证 ABI 敏感原生能力。"""
|
||||
from app.adapters.external.market import PluginHelper
|
||||
|
||||
uv_bin = tmp_path / "uv"
|
||||
commands = []
|
||||
|
||||
def execute(command):
|
||||
commands.append(command)
|
||||
return True, "ok"
|
||||
|
||||
with patch("app.adapters.external.market.find_uv", return_value=uv_bin), patch(
|
||||
"app.adapters.external.market.SystemUtils.execute_with_subprocess",
|
||||
side_effect=execute,
|
||||
):
|
||||
PluginHelper._PluginHelper__run_runtime_healthcheck()
|
||||
|
||||
assert [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"app.doctor.dependencies",
|
||||
"--full",
|
||||
] in commands
|
||||
|
||||
def test_uv_install_allows_changing_non_runtime_dependency(self):
|
||||
"""
|
||||
验证非主程序依赖即便已安装,插件后续仍可调整其版本约束。
|
||||
@@ -1415,7 +1488,7 @@ demo = { index = "private" }
|
||||
if uv_check_count == 2:
|
||||
return False, "broken"
|
||||
return True, "healthy"
|
||||
if len(cmd) >= 3 and cmd[1] == "-c":
|
||||
if len(cmd) >= 3 and cmd[1:3] == ["-m", "app.doctor.dependencies"]:
|
||||
return True, "probe ok"
|
||||
raise AssertionError(f"unexpected command: {cmd}")
|
||||
|
||||
@@ -1425,7 +1498,9 @@ demo = { index = "private" }
|
||||
return_value={"fastapi": Version("0.115.14")}
|
||||
):
|
||||
with patch("app.adapters.external.market.SystemUtils.execute_with_subprocess", side_effect=fake_execute):
|
||||
with patch("app.adapters.system.package.find_uv", return_value=uv_bin):
|
||||
with patch("app.adapters.external.market.find_uv", return_value=uv_bin), patch(
|
||||
"app.adapters.system.package.find_uv", return_value=uv_bin
|
||||
):
|
||||
success, message = PluginHelper.install_packages_with_fallback(requirements_file)
|
||||
|
||||
assert not success
|
||||
@@ -1444,11 +1519,17 @@ demo = { index = "private" }
|
||||
|
||||
health_snapshots = [
|
||||
{
|
||||
"uv check": (False, "existing issue before install"),
|
||||
"uv check": (
|
||||
False,
|
||||
"before summary\nThe package `oss2` requires `crcmod>=1.7`, but it's not installed",
|
||||
),
|
||||
"核心依赖导入检查": (True, "ok"),
|
||||
},
|
||||
{
|
||||
"uv check": (False, "same issue with different command summary"),
|
||||
"uv check": (
|
||||
False,
|
||||
"after summary\nThe package `oss2` requires `crcmod>=1.7`, but it's not installed",
|
||||
),
|
||||
"核心依赖导入检查": (True, "ok"),
|
||||
},
|
||||
]
|
||||
@@ -1527,6 +1608,35 @@ demo = { index = "private" }
|
||||
assert "核心依赖导入检查失败" in message
|
||||
repair_mock.assert_called_once()
|
||||
|
||||
def test_preexisting_uv_diagnostic_does_not_hide_new_package_error(self):
|
||||
"""既有第三方元数据告警不能遮蔽插件安装新增的依赖错误。"""
|
||||
from app.adapters.external.market import PluginHelper
|
||||
|
||||
existing_error = "The package `oss2` requires `crcmod>=1.7`, but it's not installed"
|
||||
added_error = "The package `demo` requires `missing>=1`, but it's not installed"
|
||||
|
||||
message = PluginHelper._PluginHelper__runtime_health_regression_message(
|
||||
{"uv check": (False, existing_error)},
|
||||
{"uv check": (False, f"{existing_error}\n{added_error}")},
|
||||
)
|
||||
|
||||
assert added_error in message
|
||||
assert existing_error not in message
|
||||
|
||||
def test_uv_diagnostic_parser_handles_executor_prefix(self):
|
||||
"""执行器把首条错误拼在命令摘要后时仍应识别完整诊断项。"""
|
||||
from app.adapters.external.market import PluginHelper
|
||||
|
||||
package_error = "The package `demo` requires `missing>=1`, but it's not installed"
|
||||
message = f"命令:uv pip check,执行失败,返回码:1,错误输出:{package_error}"
|
||||
|
||||
issues = PluginHelper._PluginHelper__runtime_health_error_lines(
|
||||
"uv check",
|
||||
message,
|
||||
)
|
||||
|
||||
assert issues == {package_error}
|
||||
|
||||
def test_failed_install_repairs_runtime_before_returning_error(self):
|
||||
"""
|
||||
安装策略失败后如果主运行环境异常,应先恢复主程序依赖再返回失败。
|
||||
|
||||
@@ -189,6 +189,35 @@ async def test_quiesce_timeout_retains_admitted_owner_and_nested_reload(
|
||||
assert manager.finalize_plugins() is True
|
||||
|
||||
|
||||
def test_reload_attributes_gil_transition_to_plugin(
|
||||
plugin_manager: PluginManager,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""运行期插件加载导致 GIL 退化时应记录插件归因。"""
|
||||
states = iter((False, True))
|
||||
plugin_manager._plugin_lifecycle.reload = MagicMock(
|
||||
return_value=PluginRuntimeStatus.ACTIVE
|
||||
)
|
||||
warning = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"app.runtime.extensions.plugin_manager.is_free_threaded_runtime",
|
||||
lambda: True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.runtime.extensions.plugin_manager.is_gil_enabled",
|
||||
lambda: next(states),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.runtime.extensions.plugin_manager.logger.warning",
|
||||
warning,
|
||||
)
|
||||
|
||||
assert plugin_manager.reload_plugin("DemoPlugin") is PluginRuntimeStatus.ACTIVE
|
||||
|
||||
warning.assert_called_once()
|
||||
assert warning.call_args.args[1] == "DemoPlugin"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quiesce_inside_mutation_fails_without_sealing(
|
||||
plugin_manager: PluginManager,
|
||||
|
||||
@@ -13,7 +13,7 @@ from app.sdk.network import RequestUtils, RssHelper, SitesHelper
|
||||
from app.sdk.plugins import ModuleManager, PluginManager
|
||||
from app.sdk.services import NotificationHelper
|
||||
from app.sdk.utilities import StringUtils as UtilityStringUtils
|
||||
from app.sdk.utilities import decrypt, encrypt
|
||||
from app.sdk.utilities import convert, decrypt, encrypt
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parents[1]
|
||||
@@ -30,6 +30,7 @@ def test_sdk_exports_canonical_plugin_interfaces():
|
||||
"app.domain.string"
|
||||
).StringUtils
|
||||
from app.foundation.crypto import CryptoJsUtils
|
||||
from app.foundation.text import convert as canonical_convert
|
||||
from app.runtime.extensions.module_manager import ModuleManager as CanonicalModuleManager
|
||||
from app.runtime.extensions.plugin_manager import PluginManager as CanonicalPluginManager
|
||||
from app.adapters.network.http import RequestUtils as CanonicalRequestUtils
|
||||
@@ -61,6 +62,7 @@ def test_sdk_exports_canonical_plugin_interfaces():
|
||||
assert UtilityStringUtils is LegacyDomainStringUtils
|
||||
assert decrypt is CryptoJsUtils.decrypt
|
||||
assert encrypt is CryptoJsUtils.encrypt
|
||||
assert convert is canonical_convert
|
||||
assert ModuleManager is CanonicalModuleManager
|
||||
assert PluginManager is CanonicalPluginManager
|
||||
assert CanonicalPluginManager.__module__ == "app.runtime.extensions.plugin_manager"
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""PostgreSQL 驱动 A/B harness 的无 Docker 合同测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "perf" / "postgresql_driver_ab.py"
|
||||
|
||||
|
||||
def load_harness():
|
||||
"""从脚本路径加载 harness,避免把 scripts 变成运行时 package。"""
|
||||
spec = importlib.util.spec_from_file_location("postgresql_driver_ab_test", SCRIPT)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def sample(variant: str, value: float) -> dict:
|
||||
"""构造满足汇总合同的单个样本。"""
|
||||
return {
|
||||
"variant": variant,
|
||||
"postgresql_settings": {"server_version": "18.4"},
|
||||
"sql_contract": {"fixture_sha256": "fixture", "sql_sha256": "sql"},
|
||||
"serial_query": {"throughput_ops_s": value},
|
||||
"concurrent_query": {"throughput_ops_s": value * 2},
|
||||
"batch_write": {"seconds": 10 / value},
|
||||
"long_transaction": {"parallel_short_query_seconds": 1 / value},
|
||||
}
|
||||
|
||||
|
||||
def test_campaign_and_image_arguments_are_strict() -> None:
|
||||
"""公开参数不得生成不安全名称或接受可变镜像标签。"""
|
||||
harness = load_harness()
|
||||
|
||||
assert harness.normalize_campaign("PG-AB.1") == "pg-ab.1"
|
||||
with pytest.raises(argparse.ArgumentTypeError):
|
||||
harness.normalize_campaign("../escape")
|
||||
with pytest.raises(argparse.ArgumentTypeError):
|
||||
harness.immutable_image("moviepilot-v3:latest")
|
||||
assert harness.immutable_image(f"moviepilot-v3@sha256:{'a' * 64}")
|
||||
|
||||
|
||||
def test_sample_order_covers_all_balanced_permutations() -> None:
|
||||
"""六轮中每个方案必须各占两次首、中、末位置。"""
|
||||
harness = load_harness()
|
||||
|
||||
orders = [harness.sample_order(index) for index in range(6)]
|
||||
|
||||
assert len(set(orders)) == 6
|
||||
for variant in harness.VARIANTS:
|
||||
assert [order[0] for order in orders].count(variant) == 2
|
||||
assert [order[1] for order in orders].count(variant) == 2
|
||||
assert [order[2] for order in orders].count(variant) == 2
|
||||
|
||||
|
||||
def test_validate_sample_enforces_driver_and_gil_contracts() -> None:
|
||||
"""V3t 必须使用 psycopg C 且在基准结束后仍关闭 GIL。"""
|
||||
harness = load_harness()
|
||||
valid = {
|
||||
"runtime": {
|
||||
"python_version": "3.14.7",
|
||||
"implementation": "c",
|
||||
"gil_before_import": False,
|
||||
"gil_after_import": False,
|
||||
"gil_after_benchmark": False,
|
||||
},
|
||||
"long_transaction": {
|
||||
"parallel_short_query_value": "payload-00002",
|
||||
"parallel_short_query_seconds": 0.01,
|
||||
"contended_update_seconds": 0.2,
|
||||
"requested_seconds": 0.25,
|
||||
},
|
||||
}
|
||||
|
||||
harness.validate_sample("v3t_psycopg3_c", valid)
|
||||
valid["runtime"]["gil_after_benchmark"] = True
|
||||
with pytest.raises(harness.HarnessInvalid, match="GIL"):
|
||||
harness.validate_sample("v3t_psycopg3_c", valid)
|
||||
|
||||
|
||||
def test_summary_keeps_three_variants_separate() -> None:
|
||||
"""汇总必须保留三方案,不能把两个标准 V3 驱动合并。"""
|
||||
harness = load_harness()
|
||||
samples = []
|
||||
for variant, _driver in harness.VARIANTS:
|
||||
samples.extend((sample(variant, 10.0), sample(variant, 20.0), sample(variant, 30.0)))
|
||||
|
||||
summary = harness.summarize(samples)
|
||||
|
||||
assert set(summary["metrics"]) == {variant for variant, _ in harness.VARIANTS}
|
||||
assert summary["metrics"]["v3_psycopg2"]["serial_query_throughput_ops_s"] == 20
|
||||
assert (
|
||||
summary["ratios"]["v3t_psycopg3_c"]
|
||||
["serial_query_throughput_over_v3_psycopg2"]
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
def test_campaign_requires_complete_matching_results() -> None:
|
||||
"""三方案缺样本或业务结果不同都不能生成性能结论。"""
|
||||
harness = load_harness()
|
||||
samples = []
|
||||
for variant, _driver in harness.VARIANTS:
|
||||
for _ in range(3):
|
||||
item = sample(variant, 10.0)
|
||||
item["serial_query"]["checksum"] = "same"
|
||||
item["concurrent_query"]["checksums"] = ["same"]
|
||||
samples.append(item)
|
||||
|
||||
harness.validate_campaign(samples, rounds=3)
|
||||
samples[-1]["serial_query"]["checksum"] = "different"
|
||||
with pytest.raises(harness.HarnessInvalid, match="校验和"):
|
||||
harness.validate_campaign(samples, rounds=3)
|
||||
@@ -3,19 +3,23 @@
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from scripts.normalize_audit_requirements import normalize_requirements
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOCKERFILE = ROOT / "docker" / "Dockerfile"
|
||||
RELEASE_WORKFLOW = ROOT / ".github" / "workflows" / "build-v3.yml"
|
||||
BETA_WORKFLOW = ROOT / ".github" / "workflows" / "beta.yml"
|
||||
TRIVY_IGNORE = ROOT / ".trivyignore.yaml"
|
||||
|
||||
|
||||
def _load_workflow() -> dict:
|
||||
"""以 YAML 1.2 解析正式发布工作流。"""
|
||||
def _load_workflow(path: Path = RELEASE_WORKFLOW) -> dict:
|
||||
"""以 YAML 1.2 解析镜像发布工作流。"""
|
||||
yaml = YAML(typ="safe")
|
||||
return yaml.load(RELEASE_WORKFLOW.read_text(encoding="utf-8"))
|
||||
return yaml.load(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _steps_by_name(workflow: dict) -> dict[str, dict]:
|
||||
@@ -31,29 +35,77 @@ def test_base_image_uses_refreshable_tag_and_apt_does_not_upgrade_in_place() ->
|
||||
"""基础镜像允许获得上游更新,构建阶段不得无边界升级整套 Debian。"""
|
||||
dockerfile = DOCKERFILE.read_text(encoding="utf-8")
|
||||
|
||||
assert "FROM python:3.14.7-slim-trixie AS base" in dockerfile
|
||||
assert "python:3.14.7-slim-trixie@sha256:" not in dockerfile
|
||||
assert 'ARG MOVIEPILOT_PYTHON_VERSION="3.14.7"' in dockerfile
|
||||
assert "FROM python:${MOVIEPILOT_PYTHON_VERSION}-slim-trixie AS base" in dockerfile
|
||||
assert "python:${MOVIEPILOT_PYTHON_VERSION}-slim-trixie@sha256:" not in dockerfile
|
||||
free_threaded_stage = dockerfile.split(
|
||||
"FROM prepare_venv_common AS prepare_venv_free-threaded",
|
||||
maxsplit=1,
|
||||
)[1]
|
||||
assert "ARG MOVIEPILOT_PYTHON_VERSION" in free_threaded_stage
|
||||
assert 'uv python install --no-bin "${MOVIEPILOT_PYTHON_VERSION}t"' in free_threaded_stage
|
||||
assert "apt-get upgrade" not in dockerfile
|
||||
assert "\n util-linux \\\n" in dockerfile
|
||||
|
||||
|
||||
def test_release_audits_locked_runtime_dependencies_before_building() -> None:
|
||||
"""发布构建前必须审计带哈希的锁定运行时依赖。"""
|
||||
workflow = _load_workflow()
|
||||
steps = workflow["jobs"]["Docker-build"]["steps"]
|
||||
names = [step.get("name") for step in steps]
|
||||
audit = _steps_by_name(workflow)["Audit locked Python dependencies"]["run"]
|
||||
"""正式版和 Beta 构建前必须分别审计两套锁定运行依赖。"""
|
||||
for workflow_path in (RELEASE_WORKFLOW, BETA_WORKFLOW):
|
||||
workflow = _load_workflow(workflow_path)
|
||||
steps = workflow["jobs"]["Docker-build"]["steps"]
|
||||
names = [step.get("name") for step in steps]
|
||||
audit = _steps_by_name(workflow)["Audit locked Python dependencies"]["run"]
|
||||
|
||||
assert names.index("Audit locked Python dependencies") < names.index("Build amd64 candidate")
|
||||
assert "uv export --quiet --locked --no-dev --no-emit-project" in audit
|
||||
assert "pip-audit==2.10.1" in audit
|
||||
for option in ("--require-hashes", "--disable-pip", "--strict"):
|
||||
assert option in audit
|
||||
first_candidate = next(name for name in names if name and name.startswith("Build "))
|
||||
assert names.index("Audit locked Python dependencies") < names.index(first_candidate)
|
||||
assert "--group runtime-standard" in audit
|
||||
assert "--group runtime-free-threaded" in audit
|
||||
assert "scripts/normalize_audit_requirements.py" in audit
|
||||
assert "pip-audit==2.10.1" in audit
|
||||
for option in ("--require-hashes", "--no-deps", "--disable-pip", "--strict"):
|
||||
assert option in audit
|
||||
|
||||
|
||||
def test_direct_url_audit_requirement_uses_version_from_matching_lock_source(tmp_path: Path) -> None:
|
||||
"""URL 依赖的漏洞审计版本必须来自同名且同来源的锁文件条目。"""
|
||||
lock_file = tmp_path / "uv.lock"
|
||||
lock_file.write_text(
|
||||
"""
|
||||
version = 1
|
||||
|
||||
[[package]]
|
||||
name = "Brotli"
|
||||
version = "1.2.0"
|
||||
source = { url = "https://example.com/brotli.tar.gz" }
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
exported = (
|
||||
"brotli @ https://example.com/brotli.tar.gz ; python_version >= '3.14' \\\n"
|
||||
" # via httpx\n"
|
||||
)
|
||||
|
||||
normalized = normalize_requirements(exported, lock_file)
|
||||
|
||||
assert "brotli==1.2.0 ; python_version >= '3.14' \\" in normalized
|
||||
assert "@ https://example.com/brotli.tar.gz" not in normalized
|
||||
|
||||
|
||||
def test_direct_url_audit_requirement_rejects_unlocked_source(tmp_path: Path) -> None:
|
||||
"""不能把未匹配锁文件来源的 URL 依赖伪装为已审计版本。"""
|
||||
lock_file = tmp_path / "uv.lock"
|
||||
lock_file.write_text("version = 1\npackage = []\n", encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="无法在锁文件中定位精确版本"):
|
||||
normalize_requirements("demo @ https://example.com/demo.tar.gz\n", lock_file)
|
||||
|
||||
|
||||
def test_release_scans_both_architectures_before_registry_login_and_publish() -> None:
|
||||
"""任一架构的最终漏洞扫描失败时都不得登录仓库或发布镜像。"""
|
||||
"""两个 Python 变体的各架构扫描都必须在登录仓库和发布前完成。"""
|
||||
workflow = _load_workflow()
|
||||
trivy_env = workflow["jobs"]["Docker-build"]["env"]
|
||||
assert trivy_env["TRIVY_SKIP_DIRS"] == "/usr/share/java"
|
||||
assert trivy_env["TRIVY_SKIP_JAVA_DB_UPDATE"] == "true"
|
||||
steps = workflow["jobs"]["Docker-build"]["steps"]
|
||||
names = [step.get("name") for step in steps]
|
||||
indexed = _steps_by_name(workflow)
|
||||
@@ -61,6 +113,14 @@ def test_release_scans_both_architectures_before_registry_login_and_publish() ->
|
||||
expected_candidates = {
|
||||
"Build amd64 candidate": ("linux/amd64", "moviepilot-v3-candidate:linux-amd64"),
|
||||
"Build arm64 candidate": ("linux/arm64/v8", "moviepilot-v3-candidate:linux-arm64"),
|
||||
"Build free-threaded amd64 candidate": (
|
||||
"linux/amd64",
|
||||
"moviepilot-v3t-candidate:linux-amd64",
|
||||
),
|
||||
"Build free-threaded arm64 candidate": (
|
||||
"linux/arm64/v8",
|
||||
"moviepilot-v3t-candidate:linux-arm64",
|
||||
),
|
||||
}
|
||||
for name, (platform, tag) in expected_candidates.items():
|
||||
build = indexed[name]["with"]
|
||||
@@ -70,10 +130,14 @@ def test_release_scans_both_architectures_before_registry_login_and_publish() ->
|
||||
assert build["tags"] == tag
|
||||
assert build["pull"] is True
|
||||
assert "no-cache-filters" not in build
|
||||
expected_variant = "free-threaded" if "free-threaded" in name else "standard"
|
||||
assert f"MOVIEPILOT_PYTHON_VARIANT={expected_variant}" in build["build-args"]
|
||||
|
||||
for name in (
|
||||
"Scan amd64 candidate vulnerabilities",
|
||||
"Scan arm64 candidate vulnerabilities",
|
||||
"Scan free-threaded amd64 candidate vulnerabilities",
|
||||
"Scan free-threaded arm64 candidate vulnerabilities",
|
||||
):
|
||||
scan = indexed[name]
|
||||
assert scan["with"]["cache-dir"] == "${{ runner.temp }}/trivy"
|
||||
@@ -91,11 +155,19 @@ def test_release_scans_both_architectures_before_registry_login_and_publish() ->
|
||||
"exit-code": 1,
|
||||
}.items()
|
||||
|
||||
last_scan = names.index("Scan arm64 candidate vulnerabilities")
|
||||
last_scan = max(
|
||||
names.index(name)
|
||||
for name in (
|
||||
"Scan amd64 candidate vulnerabilities",
|
||||
"Scan arm64 candidate vulnerabilities",
|
||||
"Scan free-threaded amd64 candidate vulnerabilities",
|
||||
"Scan free-threaded arm64 candidate vulnerabilities",
|
||||
)
|
||||
)
|
||||
assert last_scan < names.index("Login DockerHub")
|
||||
assert last_scan < names.index("Login GitHub Container Registry")
|
||||
assert last_scan < names.index("Publish multi-architecture image")
|
||||
|
||||
assert last_scan < names.index("Publish free-threaded multi-architecture image")
|
||||
|
||||
def test_vulnerability_ignores_are_scoped_justified_and_time_bounded() -> None:
|
||||
"""漏洞豁免必须限定制品范围,并保留复查期限和接受理由。"""
|
||||
@@ -118,5 +190,84 @@ def test_publish_reuses_scanned_architecture_caches_without_refreshing_base() ->
|
||||
assert publish["platforms"] == "linux/amd64\nlinux/arm64/v8\n"
|
||||
assert publish["push"] is True
|
||||
assert publish["pull"] is False
|
||||
assert "scope=moviepilot-v3-docker-amd64" in publish["cache-from"]
|
||||
assert "scope=moviepilot-v3-docker-arm64" in publish["cache-from"]
|
||||
assert "scope=moviepilot-v3-standard-docker-amd64" in publish["cache-from"]
|
||||
assert "scope=moviepilot-v3-standard-docker-arm64" in publish["cache-from"]
|
||||
|
||||
|
||||
def test_release_publishes_free_threaded_image_with_separate_metadata_and_cache() -> None:
|
||||
"""free-threaded 发布必须使用 v3t 命名、参数和独立缓存。"""
|
||||
workflow = _load_workflow()
|
||||
indexed = _steps_by_name(workflow)
|
||||
|
||||
metadata = indexed["Docker Meta free-threaded"]
|
||||
publish = indexed["Publish free-threaded multi-architecture image"]
|
||||
|
||||
assert "moviepilot-v3t" in metadata["with"]["images"]
|
||||
assert "MOVIEPILOT_PYTHON_VARIANT=free-threaded" in publish["with"]["build-args"]
|
||||
assert "scope=moviepilot-v3t-docker-amd64" in publish["with"]["cache-from"]
|
||||
assert "scope=moviepilot-v3t-docker-arm64" in publish["with"]["cache-from"]
|
||||
|
||||
|
||||
def test_release_promotes_latest_only_after_both_versioned_images() -> None:
|
||||
"""只有两个版本制品都发布成功后才可移动 latest 标签。"""
|
||||
workflow = _load_workflow()
|
||||
steps = workflow["jobs"]["Docker-build"]["steps"]
|
||||
names = [step.get("name") for step in steps]
|
||||
indexed = _steps_by_name(workflow)
|
||||
|
||||
assert "value=latest" not in indexed["Docker Meta"]["with"]["tags"]
|
||||
assert "value=latest" not in indexed["Docker Meta free-threaded"]["with"]["tags"]
|
||||
assert names.index("Publish multi-architecture image") < names.index("Promote latest image pair")
|
||||
assert names.index("Publish free-threaded multi-architecture image") < names.index(
|
||||
"Promote latest image pair"
|
||||
)
|
||||
promote = indexed["Promote latest image pair"]["run"]
|
||||
assert "moviepilot-v3:latest" not in promote
|
||||
assert '"${image}:latest"' in promote
|
||||
assert '"${image}:${app_version}"' in promote
|
||||
|
||||
|
||||
def test_beta_applies_the_same_variant_scan_and_publish_contract() -> None:
|
||||
"""Beta 也必须在发布两个变体前完成各架构漏洞扫描。"""
|
||||
workflow = _load_workflow(BETA_WORKFLOW)
|
||||
trivy_env = workflow["jobs"]["Docker-build"]["env"]
|
||||
assert trivy_env["TRIVY_SKIP_DIRS"] == "/usr/share/java"
|
||||
assert trivy_env["TRIVY_SKIP_JAVA_DB_UPDATE"] == "true"
|
||||
steps = workflow["jobs"]["Docker-build"]["steps"]
|
||||
names = [step.get("name") for step in steps]
|
||||
indexed = _steps_by_name(workflow)
|
||||
|
||||
assert workflow["on"]["workflow_dispatch"] is None
|
||||
for name in (
|
||||
"Build standard amd64 candidate",
|
||||
"Build standard arm64 candidate",
|
||||
"Build free-threaded amd64 candidate",
|
||||
"Build free-threaded arm64 candidate",
|
||||
):
|
||||
assert indexed[name]["with"]["load"] is True
|
||||
assert indexed[name]["with"]["push"] is False
|
||||
|
||||
scan_names = (
|
||||
"Scan standard amd64 candidate vulnerabilities",
|
||||
"Scan standard arm64 candidate vulnerabilities",
|
||||
"Scan free-threaded amd64 candidate vulnerabilities",
|
||||
"Scan free-threaded arm64 candidate vulnerabilities",
|
||||
)
|
||||
publish_names = (
|
||||
"Publish standard multi-architecture image",
|
||||
"Publish free-threaded multi-architecture image",
|
||||
)
|
||||
last_scan = max(names.index(name) for name in scan_names)
|
||||
assert all(last_scan < names.index(name) for name in publish_names)
|
||||
assert "MOVIEPILOT_PYTHON_VARIANT=standard" in indexed[publish_names[0]]["with"]["build-args"]
|
||||
assert "MOVIEPILOT_PYTHON_VARIANT=free-threaded" in indexed[publish_names[1]]["with"]["build-args"]
|
||||
assert "scope=moviepilot-v3-standard-docker-amd64" in indexed[publish_names[0]]["with"]["cache-from"]
|
||||
assert "scope=moviepilot-v3t-docker-amd64" in indexed[publish_names[1]]["with"]["cache-from"]
|
||||
assert "value=beta-${{ github.run_id }}-${{ github.run_attempt }}" in indexed["Docker Meta"]["with"]["tags"]
|
||||
assert "value=beta-${{ github.run_id }}-${{ github.run_attempt }}" in indexed[
|
||||
"Docker Meta free-threaded"
|
||||
]["with"]["tags"]
|
||||
assert all(names.index(name) < names.index("Promote beta image pair") for name in publish_names)
|
||||
promote = indexed["Promote beta image pair"]["run"]
|
||||
assert '"${image}:beta"' in promote
|
||||
assert '"${image}:${candidate}"' in promote
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.runtime.config import settings
|
||||
from app.adapters.system.resource import (
|
||||
ResourceHelper,
|
||||
@@ -21,6 +23,33 @@ def test_resource_helper_uses_v3_only():
|
||||
assert ResourceHelper._resource_target == Path("app/application/site")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("system", "machine", "gil_disabled", "expected"),
|
||||
[
|
||||
("Linux", "x86_64", 0, "sites.cpython-314-x86_64-linux-gnu.so"),
|
||||
("Linux", "aarch64", 1, "sites.cpython-314t-aarch64-linux-gnu.so"),
|
||||
("Darwin", "arm64", 1, "sites.cpython-314t-darwin.so"),
|
||||
("Windows", "AMD64", 1, "sites.cp314t-win_amd64.pyd"),
|
||||
],
|
||||
)
|
||||
def test_resource_helper_selects_runtime_abi(
|
||||
monkeypatch,
|
||||
system,
|
||||
machine,
|
||||
gil_disabled,
|
||||
expected,
|
||||
):
|
||||
"""资源下载文件名必须区分普通解释器与 free-threaded ABI。"""
|
||||
monkeypatch.setattr("app.adapters.system.resource.platform.system", lambda: system)
|
||||
monkeypatch.setattr("app.adapters.system.resource.platform.machine", lambda: machine)
|
||||
monkeypatch.setattr(
|
||||
"app.adapters.system.resource.sysconfig.get_config_var",
|
||||
lambda name: gil_disabled if name == "Py_GIL_DISABLED" else None,
|
||||
)
|
||||
|
||||
assert ResourceHelper._get_needed_files()[-1] == expected
|
||||
|
||||
|
||||
def test_resource_helper_preserves_no_argument_check_contract(monkeypatch):
|
||||
"""旧插件无参数调用 check 时应使用启动层注入版本,不反向导入站点应用。"""
|
||||
provider_calls = []
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.doctor import dependencies as dependency_doctor
|
||||
from app.foundation import environment
|
||||
from app.runtime import dependencies
|
||||
|
||||
|
||||
def test_free_threaded_runtime_tracks_interpreter_build(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
environment.sysconfig,
|
||||
"get_config_var",
|
||||
lambda name: 1 if name == "Py_GIL_DISABLED" else None,
|
||||
)
|
||||
|
||||
assert environment.is_free_threaded_runtime() is True
|
||||
|
||||
|
||||
def test_gil_status_tracks_current_interpreter_state(monkeypatch):
|
||||
monkeypatch.setattr(environment.sys, "_is_gil_enabled", lambda: False)
|
||||
|
||||
assert environment.is_gil_enabled() is False
|
||||
|
||||
|
||||
def test_runtime_dependency_group_tracks_interpreter_abi(monkeypatch):
|
||||
monkeypatch.setattr(dependencies, "is_free_threaded_runtime", lambda: False)
|
||||
assert dependencies.runtime_dependency_group() == "runtime-standard"
|
||||
|
||||
monkeypatch.setattr(dependencies, "is_free_threaded_runtime", lambda: True)
|
||||
assert dependencies.runtime_dependency_group() == "runtime-free-threaded"
|
||||
|
||||
|
||||
def test_runtime_requirements_include_project_and_active_group(tmp_path: Path, monkeypatch):
|
||||
project_file = tmp_path / "pyproject.toml"
|
||||
project_file.write_text(
|
||||
"""
|
||||
[project]
|
||||
dependencies = ["shared==1"]
|
||||
|
||||
[dependency-groups]
|
||||
runtime-standard = ["standard==2"]
|
||||
runtime-free-threaded = ["free-threaded==3"]
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
dependencies,
|
||||
"runtime_dependency_group",
|
||||
lambda: "runtime-free-threaded",
|
||||
)
|
||||
|
||||
assert list(dependencies.iter_runtime_requirement_strings(project_file)) == [
|
||||
"shared==1",
|
||||
"free-threaded==3",
|
||||
]
|
||||
assert list(dependencies.iter_runtime_profile_requirement_strings(project_file)) == [
|
||||
"free-threaded==3",
|
||||
]
|
||||
|
||||
|
||||
def test_runtime_profiles_share_gil_safe_crcmod_distribution():
|
||||
project_file = Path(__file__).resolve().parents[1] / "pyproject.toml"
|
||||
with project_file.open("rb") as file:
|
||||
document = tomllib.load(file)
|
||||
|
||||
assert "crcmod-plus==2.3.1" in document["project"]["dependencies"]
|
||||
groups = document["dependency-groups"]
|
||||
assert all(
|
||||
not requirement.lower().startswith("crcmod")
|
||||
for group in ("runtime-standard", "runtime-free-threaded")
|
||||
for requirement in groups[group]
|
||||
)
|
||||
assert {
|
||||
"package": {"name": "oss2"},
|
||||
"dependencies": ["crcmod"],
|
||||
} in document["tool"]["uv"]["exclude-dependencies"]
|
||||
|
||||
|
||||
def test_full_dependency_probe_rejects_psycopg_python_fallback(monkeypatch):
|
||||
"""V3t 构建不得把 psycopg 纯 Python 实现误认为可发布能力。"""
|
||||
modules = {
|
||||
"moviepilot_rust": SimpleNamespace(
|
||||
is_available=lambda: True,
|
||||
jieba_cut=lambda _value: ["中文", "分词"],
|
||||
zhconv_fast=lambda value, _target: value,
|
||||
),
|
||||
"crcmod.crcmod": SimpleNamespace(_usingExtension=True),
|
||||
"psycopg": SimpleNamespace(pq=SimpleNamespace(__impl__="python")),
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
dependency_doctor,
|
||||
"import_module",
|
||||
lambda name: modules.get(name, SimpleNamespace()),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
dependency_doctor.sysconfig,
|
||||
"get_config_var",
|
||||
lambda name: 1 if name == "Py_GIL_DISABLED" else None,
|
||||
)
|
||||
monkeypatch.setattr(dependency_doctor.sys, "_is_gil_enabled", lambda: False)
|
||||
|
||||
with pytest.raises(RuntimeError, match="psycopg C 实现不可用"):
|
||||
dependency_doctor.main(full=True)
|
||||
@@ -27,6 +27,7 @@ def test_rust_accel_runtime_switch_disables_fast_paths(monkeypatch):
|
||||
RUST_ACCEL 关闭时,即便扩展可用也应回退到 Python 路径。
|
||||
"""
|
||||
monkeypatch.setattr(settings, "RUST_ACCEL", False)
|
||||
monkeypatch.setattr(rust_accel, "is_required", lambda: False)
|
||||
monkeypatch.setattr(rust_accel, "_moviepilot_rust", _DummyRustExtension())
|
||||
|
||||
assert rust_accel.is_available()
|
||||
@@ -34,11 +35,23 @@ def test_rust_accel_runtime_switch_disables_fast_paths(monkeypatch):
|
||||
assert rust_accel.parse_filter_rule("HDR") is None
|
||||
|
||||
|
||||
def test_free_threaded_runtime_requires_rust_acceleration(monkeypatch):
|
||||
"""free-threaded 运行时不能通过配置关闭 Rust 快路径。"""
|
||||
monkeypatch.setattr(settings, "RUST_ACCEL", False)
|
||||
monkeypatch.setattr(rust_accel, "is_required", lambda: True)
|
||||
monkeypatch.setattr(rust_accel, "_moviepilot_rust", _DummyRustExtension())
|
||||
|
||||
assert rust_accel.is_config_enabled()
|
||||
assert rust_accel.is_enabled()
|
||||
assert rust_accel.status()["required"] is True
|
||||
|
||||
|
||||
def test_rust_accel_status_reports_enabled_state(monkeypatch):
|
||||
"""
|
||||
状态接口应同时体现扩展可用性和配置开关后的实际启用状态。
|
||||
"""
|
||||
monkeypatch.setattr(settings, "RUST_ACCEL", True)
|
||||
monkeypatch.setattr(rust_accel, "is_required", lambda: False)
|
||||
monkeypatch.setattr(rust_accel, "_moviepilot_rust", _DummyRustExtension())
|
||||
|
||||
assert rust_accel.status()["available"] is True
|
||||
|
||||
@@ -7,26 +7,47 @@ def test_app_installs_known_oss2_invalid_escape_warning_filter():
|
||||
"""
|
||||
app 初始化过滤器应覆盖 oss2 的无效转义警告。
|
||||
"""
|
||||
app._filter_third_party_startup_warnings()
|
||||
action, message, category, module, lineno = warnings.filters[0]
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
app._filter_third_party_startup_warnings()
|
||||
warnings.warn_explicit(
|
||||
f'"{chr(92)}&" is an invalid escape sequence.',
|
||||
SyntaxWarning,
|
||||
filename="oss2/api.py",
|
||||
lineno=703,
|
||||
module="oss2.api",
|
||||
)
|
||||
|
||||
assert action == "ignore"
|
||||
assert message.match("invalid escape sequence '\\&'")
|
||||
assert category is SyntaxWarning
|
||||
assert module is None
|
||||
assert lineno == 0
|
||||
assert caught == []
|
||||
|
||||
|
||||
def test_app_does_not_hide_other_invalid_escape_warnings():
|
||||
"""其他无效转义仍应暴露,避免过滤器遮蔽新问题。"""
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
app._filter_third_party_startup_warnings()
|
||||
warnings.warn_explicit(
|
||||
f'"{chr(92)}q" is an invalid escape sequence.',
|
||||
SyntaxWarning,
|
||||
filename="app/example.py",
|
||||
lineno=1,
|
||||
module="app.example",
|
||||
)
|
||||
|
||||
assert len(caught) == 1
|
||||
|
||||
|
||||
def test_app_installs_google_genai_python314_warning_filter():
|
||||
"""app 初始化过滤器应覆盖 Google GenAI SDK 的 Python 3.14 弃用警告。"""
|
||||
app._filter_third_party_startup_warnings()
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
app._filter_third_party_startup_warnings()
|
||||
warnings.warn_explicit(
|
||||
"'_UnionGenericAlias' is deprecated and slated for removal in Python 3.17",
|
||||
DeprecationWarning,
|
||||
filename="google/genai/types.py",
|
||||
lineno=1,
|
||||
module="google.genai.types",
|
||||
)
|
||||
|
||||
assert any(
|
||||
action == "ignore"
|
||||
and message.match("'_UnionGenericAlias' is deprecated and slated for removal in Python 3.17")
|
||||
and category is DeprecationWarning
|
||||
and module is not None
|
||||
and module.match("google.genai.types")
|
||||
and lineno == 0
|
||||
for action, message, category, module, lineno in warnings.filters
|
||||
)
|
||||
assert caught == []
|
||||
|
||||
@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from app.runtime.config import settings as runtime_settings
|
||||
from app.testing import stub_modules
|
||||
from app.testing.stub import restore_modules, snapshot_modules
|
||||
|
||||
|
||||
def _stub(name: str, **attrs) -> tuple:
|
||||
@@ -30,7 +31,7 @@ class _DummyError(Exception):
|
||||
self.duration_ms = duration_ms
|
||||
|
||||
|
||||
# 在 import 期用占位模块替换重依赖/外部模块,import 完由 stub_modules 精确还原,避免污染其它用例
|
||||
# 被测模块会绑定 import 期的桩对象,退出后需同时还原这期间加载的 app 模块图。
|
||||
_STUB_MODULES = dict([
|
||||
_stub("pillow_avif"),
|
||||
_stub("aiofiles"),
|
||||
@@ -64,8 +65,12 @@ _STUB_MODULES = dict([
|
||||
_stub("version", APP_VERSION="test", FRONTEND_VERSION="frontend-test"),
|
||||
])
|
||||
|
||||
with stub_modules(_STUB_MODULES):
|
||||
from app.api.endpoints import system as system_endpoint
|
||||
_APP_MODULES = snapshot_modules("app")
|
||||
try:
|
||||
with stub_modules(_STUB_MODULES):
|
||||
from app.api.endpoints import system as system_endpoint
|
||||
finally:
|
||||
restore_modules(_APP_MODULES, "app")
|
||||
|
||||
|
||||
class NettestSecurityTest(unittest.TestCase):
|
||||
@@ -75,12 +80,50 @@ class NettestSecurityTest(unittest.TestCase):
|
||||
"""
|
||||
with patch.object(system_endpoint.rust_accel, "is_available", return_value=True), patch.object(
|
||||
system_endpoint.rust_accel, "is_enabled", return_value=False
|
||||
), patch.object(
|
||||
system_endpoint.rust_accel, "is_required", return_value=True
|
||||
), patch.object(
|
||||
system_endpoint, "is_free_threaded_runtime", return_value=True
|
||||
), patch.object(
|
||||
system_endpoint, "is_gil_enabled", return_value=False
|
||||
):
|
||||
resp = asyncio.run(system_endpoint.get_env_setting(_="token"))
|
||||
|
||||
self.assertTrue(resp.success)
|
||||
self.assertTrue(resp.data["RUST_ACCEL_AVAILABLE"])
|
||||
self.assertFalse(resp.data["RUST_ACCEL_ENABLED"])
|
||||
self.assertTrue(resp.data["RUST_ACCEL_REQUIRED"])
|
||||
self.assertTrue(resp.data["PYTHON_FREE_THREADED"])
|
||||
self.assertFalse(resp.data["PYTHON_GIL_ENABLED"])
|
||||
|
||||
def test_get_user_global_setting_reports_runtime_variant(self):
|
||||
"""登录后的全局设置应提供导航所需的解释器类型。"""
|
||||
runtime_config = SimpleNamespace(
|
||||
snapshot=Mock(return_value={}),
|
||||
get=Mock(return_value=False),
|
||||
)
|
||||
with patch.object(
|
||||
system_endpoint, "get_runtime_settings", return_value=runtime_config
|
||||
), patch.object(
|
||||
system_endpoint.MoviePilotServerHelper,
|
||||
"async_is_admin_user",
|
||||
new=AsyncMock(return_value=False),
|
||||
create=True,
|
||||
), patch.object(
|
||||
system_endpoint.MoviePilotServerHelper,
|
||||
"get_user_uuid",
|
||||
return_value="user-id",
|
||||
create=True,
|
||||
), patch.object(
|
||||
system_endpoint, "is_free_threaded_runtime", return_value=True
|
||||
), patch.object(
|
||||
system_endpoint, "is_gil_enabled", return_value=False
|
||||
):
|
||||
resp = asyncio.run(system_endpoint.get_user_global_setting(_="token"))
|
||||
|
||||
self.assertTrue(resp.success)
|
||||
self.assertTrue(resp.data["PYTHON_FREE_THREADED"])
|
||||
self.assertFalse(resp.data["PYTHON_GIL_ENABLED"])
|
||||
|
||||
def test_fetch_image_allows_signed_private_url(self):
|
||||
"""
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user