diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..64067ec --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +desktop/runtime-constraints.txt text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13f799d..bf7f460 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,22 @@ on: - opened - synchronize - reopened + workflow_call: + inputs: + checkout_ref: + description: Exact commit to validate. + required: false + type: string + full: + description: Run every validation surface without PR path scoping. + required: false + default: false + type: boolean + skip_containers: + description: Skip container validation when a downstream release job builds and smokes the same images. + required: false + default: false + type: boolean concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -18,26 +34,47 @@ permissions: jobs: validate: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ${{ fromJSON(inputs.full && '["3.11","3.12","3.13","3.14"]' || '["3.14"]') }} steps: - uses: actions/checkout@v7 with: fetch-depth: 0 + ref: ${{ inputs.checkout_ref || github.sha }} - name: Determine validation scope id: scope shell: bash env: BASE_SHA: ${{ github.event.pull_request.base.sha }} + FORCE_FULL: ${{ inputs.full || false }} + SKIP_CONTAINERS: ${{ inputs.skip_containers || false }} GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number }} run: | + if [[ "$FORCE_FULL" == "true" ]]; then + { + echo "run_suite=true" + if [[ "$SKIP_CONTAINERS" == "true" ]]; then + echo "run_container=false" + else + echo "run_container=true" + fi + echo "require_changelog=false" + echo "needs_python=true" + } >> "$GITHUB_OUTPUT" + exit 0 + fi + changed_files="$(git diff --name-only "$BASE_SHA" HEAD)" - if grep -Eq '^(\.github/workflows/ci\.yml$|src/|tests/|utils/|LICENSE$|MANIFEST\.in$|pyproject\.toml$)' <<< "$changed_files"; then + if grep -Eq '^(\.github/workflows/ci\.yml$|src/|tests/|utils/|LICENSE$|MANIFEST\.in$|pyproject\.toml$|uv\.lock$)' <<< "$changed_files"; then run_suite=true else run_suite=false fi - if grep -Eq '^(\.dockerignore$|\.github/workflows/ci\.yml$|Dockerfile$|compose\.yaml$|pyproject\.toml$|src/vidxp/(requirements/.*\.txt|capabilities/[^/]+/requirements\.txt)$)' <<< "$changed_files"; then + if grep -Eq '^(\.dockerignore$|\.github/workflows/ci\.yml$|Dockerfile$|compose\.yaml$|pyproject\.toml$|uv\.lock$|src/vidxp/(requirements/.*\.txt|capabilities/[^/]+/requirements\.txt)$)' <<< "$changed_files"; then run_container=true else run_container=false @@ -67,7 +104,7 @@ jobs: - uses: actions/setup-python@v7 if: steps.scope.outputs.needs_python == 'true' with: - python-version: "3.11" + python-version: ${{ matrix.python-version }} cache: pip - name: Install repository tooling @@ -77,29 +114,39 @@ jobs: python -m pip install -r utils/build-requirements.txt - name: Require a changelog fragment - if: steps.scope.outputs.require_changelog == 'true' + if: steps.scope.outputs.require_changelog == 'true' && matrix.python-version == '3.14' run: towncrier check --compare-with "${{ github.event.pull_request.base.sha }}" - name: Install test surface if: steps.scope.outputs.run_suite == 'true' - run: python -m pip install ".[scene,frontend,benchmarks]" + run: | + python -m pip install "uv==0.12.0" + uv sync \ + --frozen \ + --extra all \ + --extra frontend \ + --extra benchmarks \ + --extra server \ + --extra slm \ + --extra test \ + --no-dev - name: Lint - if: steps.scope.outputs.run_suite == 'true' + if: steps.scope.outputs.run_suite == 'true' && matrix.python-version == '3.14' run: ruff check src tests - name: Test if: steps.scope.outputs.run_suite == 'true' - run: python -m unittest discover -s tests -q + run: uv run --no-sync python -m unittest discover -s tests -q env: PYTHONPATH: src - name: Build wheel and source distribution - if: steps.scope.outputs.run_suite == 'true' + if: steps.scope.outputs.run_suite == 'true' && matrix.python-version == '3.14' run: python -m build - name: Verify minimal wheel - if: steps.scope.outputs.run_suite == 'true' + if: steps.scope.outputs.run_suite == 'true' && matrix.python-version == '3.14' shell: bash run: | python -m venv .wheel-smoke @@ -111,38 +158,43 @@ jobs: from subprocess import check_output import vidxp - from vidxp.capabilities.registry import capability_names + from vidxp.capabilities.registry import create_capability_registry - assert capability_names() == ("dialogue", "scene", "actor") + assert create_capability_registry().names() == ( + "dialogue", "scene", "actor" + ) help_text = check_output( [".wheel-smoke/bin/vidxp", "--help"], text=True, ) - assert "benchmark" not in help_text + assert "benchmark" in help_text + benchmark_help = check_output( + [".wheel-smoke/bin/vidxp", "benchmark", "--help"], + text=True, + ) + assert "official benchmark adapters" in benchmark_help for module in ( "chromadb", - "clip", "cv2", - "face_recognition", + "faster_whisper", "sentence_transformers", - "srt", "streamlit", "torch", - "whisperx", + "transformers", ): assert find_spec(module) is None, module PY - name: Validate Compose configuration - if: steps.scope.outputs.run_container == 'true' + if: steps.scope.outputs.run_container == 'true' && matrix.python-version == '3.14' run: docker compose config --quiet - name: Build container - if: steps.scope.outputs.run_container == 'true' + if: steps.scope.outputs.run_container == 'true' && matrix.python-version == '3.14' run: docker build --tag vidxp:ci . - name: Smoke container - if: steps.scope.outputs.run_container == 'true' + if: steps.scope.outputs.run_container == 'true' && matrix.python-version == '3.14' shell: bash run: | docker run --detach --name vidxp-ci \ @@ -150,8 +202,11 @@ jobs: trap 'docker rm --force vidxp-ci >/dev/null 2>&1 || true' EXIT docker exec vidxp-ci vidxp --version + docker exec vidxp-ci vidxp init --json docker exec vidxp-ci python -c \ - 'import clip, face_recognition, whisperx' + 'import chromadb, cv2, faster_whisper, huggingface_hub, pooch, psutil, sentence_transformers, torch, transformers; assert psutil.virtual_memory().available > 0' + docker exec vidxp-ci python -c \ + 'import importlib.metadata as m; assert not any(d.metadata["Name"].lower().startswith("nvidia-") for d in m.distributions())' docker exec vidxp-ci sh -c \ 'test ! -d "$HOME/.cache/huggingface" && test ! -d "$HOME/.cache/clip"' @@ -167,3 +222,89 @@ jobs: docker logs vidxp-ci exit 1 + + provider-platform-smoke: + name: CPU providers (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: ${{ fromJSON(inputs.full && '["ubuntu-24.04","windows-2025","macos-15"]' || '["windows-2025"]') }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.checkout_ref || github.sha }} + + - uses: actions/setup-python@v7 + with: + python-version: "3.14" + + - name: Install locked local-worker environment + run: | + python -m pip install "uv==0.12.0" + uv sync --frozen --extra local-worker --no-dev + + - name: Verify provider contracts without model downloads + run: | + uv run --no-sync python -m unittest discover -s tests -p "test_models.py" -q + uv run --no-sync python -m unittest discover -s tests -p "test_indexing.py" -q + env: + PYTHONPATH: src + VIDXP_ALLOW_MODEL_DOWNLOADS: "false" + + - name: Verify platform and CPU runtime + shell: bash + run: | + uv run --no-sync python - <<'PY' + from importlib import metadata + import platform + import chromadb + import cv2 + import faster_whisper + import huggingface_hub + import pooch + import psutil + import sentence_transformers + import torch + import transformers + + from vidxp.runtime import resolve_backends + + assert hasattr(cv2, "FaceDetectorYN") + assert hasattr(cv2, "FaceRecognizerSF") + assert hasattr(faster_whisper, "WhisperModel") + assert hasattr(faster_whisper, "BatchedInferencePipeline") + assert hasattr( + sentence_transformers.SentenceTransformer, + "encode_query", + ) + assert hasattr( + sentence_transformers.SentenceTransformer, + "encode_document", + ) + assert hasattr(transformers, "AutoModel") + assert hasattr(transformers, "AutoProcessor") + assert hasattr(chromadb, "PersistentClient") + assert hasattr(huggingface_hub, "snapshot_download") + assert hasattr(pooch, "retrieve") + assert psutil.virtual_memory().available > 0 + assert torch.version.cuda is None, torch.version.cuda + installed = { + distribution.metadata["Name"].lower().replace("_", "-") + for distribution in metadata.distributions() + if distribution.metadata.get("Name") + } + leaks = sorted( + name + for name in installed + if name in {"cuda-toolkit", "cuda-bindings", "triton"} + or name.startswith("nvidia-") + or name.startswith("pytorch-triton") + ) + assert not leaks, leaks + + profile = resolve_backends("cpu") + assert profile.torch_device == "cpu" + if platform.system() == "Darwin": + assert platform.machine() == "arm64" + PY diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml new file mode 100644 index 0000000..94c0b6f --- /dev/null +++ b/.github/workflows/desktop.yml @@ -0,0 +1,149 @@ +name: Desktop + +on: + workflow_call: + inputs: + checkout_ref: + description: Commit to build + required: true + type: string + workflow_dispatch: + inputs: + checkout_ref: + description: Commit, branch, or tag to build + required: false + default: "" + type: string + pull_request: + paths: + - "desktop/**" + - "src/vidxp/requirements/**" + - "src/vidxp/capabilities/*/requirements.txt" + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/desktop.yml" + +permissions: + contents: read + +concurrency: + group: desktop-${{ github.event.pull_request.number || inputs.checkout_ref || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + build: + name: ${{ matrix.target == 'windows' && 'Windows x86-64 NSIS' || matrix.target == 'macos' && 'macOS Apple Silicon DMG' || 'Linux x86-64 AppImage' }} + runs-on: ${{ matrix.target == 'windows' && 'windows-2025' || matrix.target == 'macos' && 'macos-15' || 'ubuntu-24.04' }} + strategy: + fail-fast: false + matrix: + target: ${{ fromJSON(github.event_name == 'pull_request' && '["windows"]' || '["windows","macos","linux"]') }} + + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.checkout_ref || github.event.inputs.checkout_ref || github.sha }} + + - name: Install Linux desktop build dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential \ + file \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + libssl-dev \ + libwebkit2gtk-4.1-dev \ + libxdo-dev + + - uses: actions/setup-node@v7 + with: + node-version: "22" + cache: npm + cache-dependency-path: desktop/package-lock.json + + - uses: astral-sh/setup-uv@v7 + with: + version: "0.12.0" + enable-cache: false + + - uses: dtolnay/rust-toolchain@1.85.0 + + - name: Restore Rust build cache + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + desktop/src-tauri/target + key: desktop-rust-${{ runner.os }}-${{ runner.arch }}-1.85.0-${{ hashFiles('desktop/src-tauri/Cargo.lock') }} + restore-keys: | + desktop-rust-${{ runner.os }}-${{ runner.arch }}-1.85.0- + + - name: Install locked desktop tooling + run: npm ci + working-directory: desktop + + - name: Verify the desktop runtime constraints + shell: bash + run: | + generated="$RUNNER_TEMP/runtime-constraints.txt" + uv export \ + --frozen \ + --extra local-worker \ + --extra frontend \ + --no-dev \ + --no-emit-project \ + --no-hashes \ + --format requirements-txt \ + --output-file "$generated" + git diff --no-index --ignore-matching-lines='^#' -- \ + desktop/runtime-constraints.txt "$generated" + + - name: Restore the pinned uv sidecar + id: uv-sidecar-cache + uses: actions/cache@v5 + with: + path: desktop/src-tauri/binaries + key: desktop-uv-sidecar-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('desktop/sidecars.json') }} + + - name: Fetch the pinned uv sidecar + if: steps.uv-sidecar-cache.outputs.cache-hit != 'true' + run: npm run ${{ matrix.target == 'windows' && 'sidecar:windows' || 'sidecar:unix' }} + working-directory: desktop + + - name: Test the locked desktop crate + run: cargo test --release --locked --manifest-path desktop/src-tauri/Cargo.toml + + - name: Build the unsigned desktop installer + run: npm run desktop:build + working-directory: desktop + + - name: Verify the Windows app uses the GUI subsystem + if: runner.os == 'Windows' + shell: pwsh + run: | + $path = "desktop/src-tauri/target/release/vidxp-desktop.exe" + $stream = [System.IO.File]::OpenRead($path) + try { + $reader = [System.IO.BinaryReader]::new($stream) + $stream.Position = 0x3c + $peOffset = $reader.ReadInt32() + $stream.Position = $peOffset + 4 + 20 + 68 + $subsystem = $reader.ReadUInt16() + if ($subsystem -ne 2) { + throw "Expected Windows GUI subsystem 2, found $subsystem." + } + } finally { + $stream.Dispose() + } + + - name: Preserve the desktop installer + uses: actions/upload-artifact@v7 + with: + name: ${{ matrix.target == 'windows' && 'vidxp-desktop-windows-x86_64' || matrix.target == 'macos' && 'vidxp-desktop-macos-aarch64' || 'vidxp-desktop-linux-x86_64' }} + path: ${{ matrix.target == 'windows' && 'desktop/src-tauri/target/release/bundle/nsis/*-setup.exe' || matrix.target == 'macos' && 'desktop/src-tauri/target/release/bundle/dmg/*.dmg' || 'desktop/src-tauri/target/release/bundle/appimage/*.AppImage' }} + if-no-files-found: error + compression-level: 0 + retention-days: 14 diff --git a/.github/workflows/release-to-pypi.yml b/.github/workflows/release-to-pypi.yml index 8eef5de..ff53181 100644 --- a/.github/workflows/release-to-pypi.yml +++ b/.github/workflows/release-to-pypi.yml @@ -30,7 +30,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v7 with: - python-version: "3.11" + python-version: "3.14" - name: Install dependencies run: | @@ -53,15 +53,6 @@ jobs: .release-smoke/bin/python -m pip check .release-smoke/bin/vidxp --version - - name: Publish GitHub release - if: steps.release.outputs.released == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - semantic-release publish --tag "${{ steps.release.outputs.tag }}" - gh release edit "${{ steps.release.outputs.tag }}" \ - --notes-file .release-notes.md - - name: Preserve distribution if: steps.release.outputs.released == 'true' uses: actions/upload-artifact@v7 @@ -71,9 +62,29 @@ jobs: if-no-files-found: error retention-days: 7 - publish-pypi: + - name: Preserve release notes + if: steps.release.outputs.released == 'true' + uses: actions/upload-artifact@v7 + with: + name: github-release-notes + path: .release-notes.md + if-no-files-found: error + retention-days: 7 + + validate-release: if: needs.release.outputs.released == 'true' needs: release + uses: ./.github/workflows/ci.yml + with: + checkout_ref: ${{ needs.release.outputs.commit_sha }} + full: true + skip_containers: true + + publish-pypi: + if: needs.release.outputs.released == 'true' + needs: + - release + - validate-release runs-on: ubuntu-latest environment: pypi permissions: @@ -94,7 +105,9 @@ jobs: if: >- github.ref == 'refs/heads/main' && needs.release.outputs.released == 'true' - needs: release + needs: + - release + - validate-release runs-on: ubuntu-latest permissions: contents: read @@ -134,6 +147,28 @@ jobs: tags: ${{ steps.metadata.outputs.tags }} labels: ${{ steps.metadata.outputs.labels }} + - name: Build stable control container + uses: docker/build-push-action@v7 + with: + context: . + load: true + platforms: linux/amd64 + push: false + target: control + tags: ghcr.io/${{ github.repository }}:${{ needs.release.outputs.version }}-control + labels: ${{ steps.metadata.outputs.labels }} + + - name: Build stable worker container + uses: docker/build-push-action@v7 + with: + context: . + load: true + platforms: linux/amd64 + push: false + target: worker + tags: ghcr.io/${{ github.repository }}:${{ needs.release.outputs.version }}-worker + labels: ${{ steps.metadata.outputs.labels }} + - name: Smoke stable container shell: bash run: | @@ -143,8 +178,11 @@ jobs: trap 'docker rm --force vidxp-release >/dev/null 2>&1 || true' EXIT docker exec vidxp-release vidxp --version + docker exec vidxp-release vidxp init --json docker exec vidxp-release python -c \ - 'import clip, face_recognition, whisperx' + 'import chromadb, cv2, faster_whisper, huggingface_hub, pooch, psutil, sentence_transformers, torch, transformers; assert psutil.virtual_memory().available > 0' + docker exec vidxp-release python -c \ + 'import importlib.metadata as m; assert not any(d.metadata["Name"].lower().startswith("nvidia-") for d in m.distributions())' docker exec vidxp-release sh -c \ 'test ! -d "$HOME/.cache/huggingface" && test ! -d "$HOME/.cache/clip"' @@ -161,6 +199,21 @@ jobs: docker logs vidxp-release exit 1 + - name: Smoke stable server containers + shell: bash + run: | + for target in control worker; do + image="ghcr.io/${{ github.repository }}:${{ needs.release.outputs.version }}-${target}" + docker run --rm "$image" vidxp --version + done + control="ghcr.io/${{ github.repository }}:${{ needs.release.outputs.version }}-control" + worker="ghcr.io/${{ github.repository }}:${{ needs.release.outputs.version }}-worker" + docker run --rm "$control" vidxp-api --help + docker run --rm "$control" vidxp-mcp --help + docker run --rm "$worker" vidxp init --json + docker run --rm "$worker" python -c \ + 'import chromadb, cv2, faster_whisper, huggingface_hub, pooch, psutil, sentence_transformers, torch, transformers; assert psutil.virtual_memory().available > 0' + - name: Log in to GitHub Container Registry uses: docker/login-action@v4 with: @@ -175,8 +228,69 @@ jobs: shell: bash run: | docker logout ghcr.io - image="ghcr.io/${{ github.repository }}:${{ needs.release.outputs.version }}" - if ! docker buildx imagetools inspect "$image"; then - echo "::error::Make the GHCR package public, then rerun this failed job." - exit 1 - fi + for suffix in "" "-control" "-worker"; do + image="ghcr.io/${{ github.repository }}:${{ needs.release.outputs.version }}${suffix}" + if ! docker buildx imagetools inspect "$image"; then + echo "::error::Make the GHCR package public, then rerun this failed job." + exit 1 + fi + done + + build-desktop: + if: needs.release.outputs.released == 'true' + needs: + - release + - validate-release + uses: ./.github/workflows/desktop.yml + with: + checkout_ref: ${{ needs.release.outputs.commit_sha }} + + publish-github: + if: needs.release.outputs.released == 'true' + needs: + - release + - validate-release + - publish-pypi + - publish-container + - build-desktop + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: ${{ needs.release.outputs.commit_sha }} + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.14" + + - name: Install release tooling + run: | + python -m pip install --upgrade pip + python -m pip install -r utils/build-requirements.txt + + - uses: actions/download-artifact@v8 + with: + name: github-release-notes + path: . + + - uses: actions/download-artifact@v8 + with: + pattern: vidxp-desktop-* + path: desktop-dist/ + merge-multiple: true + + - name: Publish GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + semantic-release publish --tag "${{ needs.release.outputs.tag }}" + gh release edit "${{ needs.release.outputs.tag }}" \ + --notes-file .release-notes.md + gh release upload "${{ needs.release.outputs.tag }}" \ + desktop-dist/* \ + --clobber diff --git a/.github/workflows/release-to-test-pypi.yml b/.github/workflows/release-to-test-pypi.yml index f9c55e6..803d784 100644 --- a/.github/workflows/release-to-test-pypi.yml +++ b/.github/workflows/release-to-test-pypi.yml @@ -13,6 +13,9 @@ on: - "LICENSE" - "MANIFEST.in" - "utils/**" + - "desktop/**" + - "uv.lock" + - ".github/workflows/desktop.yml" - ".github/workflows/release-to-test-pypi.yml" permissions: @@ -23,12 +26,21 @@ concurrency: cancel-in-progress: false jobs: + validate: + uses: ./.github/workflows/ci.yml + with: + checkout_ref: ${{ github.sha }} + full: true + prerelease: + needs: validate runs-on: ubuntu-latest permissions: contents: write outputs: + commit_sha: ${{ steps.release.outputs.commit_sha }} released: ${{ steps.release.outputs.released }} + tag: ${{ steps.release.outputs.tag }} steps: - uses: actions/checkout@v7 @@ -38,7 +50,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v7 with: - python-version: "3.11" + python-version: "3.14" - name: Install dependencies run: | @@ -98,3 +110,36 @@ jobs: with: repository-url: https://test.pypi.org/legacy/ packages-dir: dist/ + + build-desktop: + if: needs.prerelease.outputs.released == 'true' + needs: + - prerelease + - publish-testpypi + uses: ./.github/workflows/desktop.yml + with: + checkout_ref: ${{ needs.prerelease.outputs.commit_sha }} + + publish-desktop: + if: needs.prerelease.outputs.released == 'true' + needs: + - prerelease + - build-desktop + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/download-artifact@v8 + with: + pattern: vidxp-desktop-* + path: desktop-dist/ + merge-multiple: true + + - name: Attach desktop installers to the prerelease + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${{ needs.prerelease.outputs.tag }}" \ + desktop-dist/* \ + --clobber diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 268772c..2b2fa6e 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -24,3 +24,6 @@ jobs: - uses: actions/dependency-review-action@v5 with: fail-on-severity: high + # No patched Chroma release exists yet. Chroma is kept on the + # deployment's private network and is never published directly. + allow-ghsas: GHSA-f4j7-r4q5-qw2c diff --git a/.gitignore b/.gitignore index ffcf0a1..55a6fdd 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,16 @@ pnpm-debug.log* # compiled output /target +/desktop/node_modules/ +/desktop/src-tauri/target/ +/desktop/src-tauri/gen/ +/desktop/src-tauri/binaries/uv-* +/desktop/web/icon.png +/desktop/src-tauri/icons/android/ +/desktop/src-tauri/icons/ios/ +/desktop/src-tauri/icons/64x64.png +/desktop/src-tauri/icons/Square*.png +/desktop/src-tauri/icons/StoreLogo.png # dependencies /node_modules diff --git a/Dockerfile b/Dockerfile index e74c2f4..75c0d46 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,93 +1,83 @@ # syntax=docker/dockerfile:1 -FROM python:3.11-slim-bookworm AS builder +FROM python:3.14-slim-trixie AS build-base WORKDIR /app -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential \ - cmake \ - git \ - pkg-config \ - libopenblas-dev \ - liblapack-dev \ - libjpeg62-turbo-dev \ - libpng-dev \ - && rm -rf /var/lib/apt/lists/* - -RUN python -m venv /opt/vidxp +RUN python -m pip install --no-cache-dir "uv==0.12.0" -ENV PATH="/opt/vidxp/bin:${PATH}" \ - PIP_DISABLE_PIP_VERSION_CHECK=1 - -COPY pyproject.toml README.md LICENSE MANIFEST.in ./ -COPY src/vidxp/requirements ./src/vidxp/requirements -COPY src/vidxp/capabilities/dialogue/requirements.txt ./src/vidxp/capabilities/dialogue/requirements.txt -COPY src/vidxp/capabilities/scene/requirements.txt ./src/vidxp/capabilities/scene/requirements.txt -COPY src/vidxp/capabilities/actor/requirements.txt ./src/vidxp/capabilities/actor/requirements.txt - -ARG PYTORCH_INDEX_URL="https://download.pytorch.org/whl/cpu" - -RUN --mount=type=cache,target=/root/.cache/pip \ - python -m pip install --upgrade pip setuptools wheel \ - && python -m pip install \ - --extra-index-url "${PYTORCH_INDEX_URL}" \ - -r src/vidxp/requirements/storage.txt \ - -r src/vidxp/requirements/frontend.txt \ - -r src/vidxp/capabilities/dialogue/requirements.txt \ - -r src/vidxp/capabilities/scene/requirements.txt \ - -r src/vidxp/capabilities/actor/requirements.txt +ENV UV_LINK_MODE=copy \ + UV_COMPILE_BYTECODE=1 +COPY pyproject.toml uv.lock README.md LICENSE MANIFEST.in ./ COPY src ./src -RUN --mount=type=cache,target=/root/.cache/pip \ - python -m pip install . \ - && python -m pip check +FROM build-base AS local-builder +ENV UV_PROJECT_ENVIRONMENT="/opt/vidxp" +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --extra local-worker --extra frontend \ + --no-dev --no-editable \ + && uv pip check --python /opt/vidxp/bin/python + +FROM build-base AS control-builder +ENV UV_PROJECT_ENVIRONMENT="/opt/vidxp" +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --extra server --no-dev --no-editable \ + && uv pip check --python /opt/vidxp/bin/python + +FROM build-base AS worker-builder +ENV UV_PROJECT_ENVIRONMENT="/opt/vidxp" +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --extra server-worker \ + --no-dev --no-editable \ + && uv pip check --python /opt/vidxp/bin/python -FROM python:3.11-slim-bookworm AS runtime +FROM python:3.14-slim-trixie AS runtime-base LABEL org.opencontainers.image.title="VidXP" \ - org.opencontainers.image.description="Local-first video indexing and search" \ + org.opencontainers.image.description="Video indexing and search" \ org.opencontainers.image.source="https://github.com/grayhatdevelopers/vidxp" \ org.opencontainers.image.licenses="MIT" RUN apt-get update && apt-get install -y --no-install-recommends \ ffmpeg \ libglib2.0-0 \ - libgl1 \ libgomp1 \ - libjpeg62-turbo \ - liblapack3 \ - libopenblas0 \ - libpng16-16 \ - libsm6 \ - libxext6 \ - libxrender1 \ && rm -rf /var/lib/apt/lists/* \ - && groupadd --system vidxp \ - && useradd --system --gid vidxp --home-dir /var/lib/vidxp vidxp \ + && groupadd --system --gid 1000 vidxp \ + && useradd --system --uid 1000 --gid vidxp \ + --home-dir /var/lib/vidxp vidxp \ && mkdir -p /var/lib/vidxp \ && chown vidxp:vidxp /var/lib/vidxp -COPY --from=builder /opt/vidxp /opt/vidxp - ENV PATH="/opt/vidxp/bin:${PATH}" \ - HOME="/var/lib/vidxp" \ PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - STREAMLIT_BROWSER_GATHER_USAGE_STATS=false \ - STREAMLIT_SERVER_HEADLESS=true \ - VIDXP_CONFIG_FILE="/var/lib/vidxp/config/repositories.json" \ - VIDXP_INDEX_DIR="/var/lib/vidxp/index" + PYTHONUNBUFFERED=1 USER vidxp WORKDIR /var/lib/vidxp +FROM runtime-base AS control +COPY --from=control-builder /opt/vidxp /opt/vidxp +EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=3)"] +CMD ["vidxp-api"] + +FROM runtime-base AS worker +COPY --from=worker-builder /opt/vidxp /opt/vidxp +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD ["python", "-m", "vidxp.health_cli", "worker"] +CMD ["vidxp-worker", "--role", "cpu"] + +FROM runtime-base AS local +COPY --from=local-builder /opt/vidxp /opt/vidxp +ENV STREAMLIT_BROWSER_GATHER_USAGE_STATS=false \ + STREAMLIT_SERVER_HEADLESS=true \ + VIDXP_CONFIG_FILE="/var/lib/vidxp/config/repositories.json" \ + VIDXP_INDEX_DIR="/var/lib/vidxp/index" VOLUME ["/var/lib/vidxp"] - EXPOSE 8501 - HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8501/_stcore/health', timeout=3)"] - CMD ["vidxp", "ui", "--host", "0.0.0.0", "--port", "8501"] diff --git a/INSTALLATION_GUIDE.md b/INSTALLATION_GUIDE.md index fc5c747..38bbde2 100644 --- a/INSTALLATION_GUIDE.md +++ b/INSTALLATION_GUIDE.md @@ -1,218 +1,498 @@ # Installation guide -The main [README](README.md) introduces the product and contains the shortest -install-and-run path. This guide covers platform prerequisites, source -installation, model preparation, and common first-run issues. +Use this guide to choose one supported VidXP shape and install only what that +shape needs. -## Prerequisites +## Choose an installation -- Python 3.10 through 3.13 -- FFmpeg available on `PATH` when installing `dialogue` -- CMake and a C/C++ build toolchain when installing `actor` +| Goal | Recommended installation | What runs | +|---|---|---| +| Try the browser UI locally | `compose.yaml` | One CPU worker/UI container | +| Native CLI indexing | `vidxp[local-worker]` | CLI plus a supervised local worker | +| Native browser UI | `vidxp[local-worker,frontend]` | CLI, local worker, Streamlit | +| Local agent integration | `vidxp[local-worker,mcp]` | Local worker and stdio MCP | +| Local application server | `vidxp[local-worker,server]` | Loopback HTTP API, remote MCP, local worker | +| Desktop app | Install the native package | Guided app-owned Python and worker runtime with an optional browser interface | +| Public/self-hosted service | `compose.coolify.yaml` | API/MCP control plane, CPU worker, PostgreSQL, Chroma, tusd | +| Embed one capability | `dialogue`, `scene`, or `actor` extra | Python indexing/retrieval code | -The official `dlib` package is distributed through PyPI as source. Installing -VidXP therefore compiles it locally unless a compatible build is already cached -or provided by the environment. Changing from Python 3.13 to 3.11 does not -remove that requirement. +Do not install the bare package and expect it to index video. Base `vidxp` +provides the lightweight command shell, configuration, and typed contracts. +Local model work requires a capability or worker extra. -On Windows, install CMake and the Microsoft C++ Build Tools. Do not add -platform-specific `dlib` wheels to this repository. +## Requirements -## Create an environment +- Python 3.11 through 3.14. +- Windows x86-64, Linux x86-64, or Apple Silicon macOS 14 and newer. +- FFmpeg, ffprobe, `libx264`, and `aac` for media operations. +- CPU execution. **GPU remains explicitly deferred and is not a supported + installation profile.** -Using a virtual environment keeps VidXP and its Python dependencies separate -from the system Python: +Python wheels never install operating-system packages. `vidxp init` is the +explicit, guided FFmpeg setup command; `vidxp doctor` is always read-only. +Native installation uses +[uv 0.12 or newer](https://docs.astral.sh/uv/getting-started/installation/). +Docker users do not need a host Python or uv installation. + +## Fastest local setup: Docker + +The local image contains the CPU worker, browser UI, and FFmpeg. Model weights +remain an explicit first-run download. ```bash -python -m venv venv +git clone https://github.com/grayhatdevelopers/vidxp.git +cd vidxp +docker compose pull +docker compose run --rm vidxp vidxp prepare +docker compose up ``` -```bash -# Windows -venv\Scripts\activate +Open `http://localhost:8501`. + +| Setting | Default | Purpose | +|---|---|---| +| `VIDXP_IMAGE` | `ghcr.io/grayhatdevelopers/vidxp:latest` | Local image tag or digest | +| `VIDXP_PORT` | `8501` | Host port for the browser UI | +| `VIDXP_DEVICE` | `cpu` | Runtime device; CPU is the supported value | +| `vidxp-data` volume | Managed by Compose | Media, indexes, jobs, artifacts, and models | -# macOS/Linux -source venv/bin/activate +Stop the app with `Ctrl+C` or: + +```bash +docker compose down ``` -Upgrade pip before installing: +`docker compose down` keeps the named volume. Add `--volumes` only when you +intentionally want to remove all persisted VidXP data. + +## Native local setup + +uv installs VidXP as an isolated command-line tool, selects Python, and +resolves CPU PyTorch. Users do not need to create or activate a virtual +environment. + +### 1. Install the profile you need ```bash -python -m pip install --upgrade pip +uv --version +uv tool install --python 3.14 --torch-backend cpu "vidxp[local-worker,frontend]" ``` -## Install from PyPI +The command above installs the complete local browser app. Replace its bracketed +extras when you need another surface: + +| Goal | Install this package profile | +|---|---| +| CLI indexing | `vidxp[local-worker]` | +| Browser UI | `vidxp[local-worker,frontend]` | +| Local stdio MCP | `vidxp[local-worker,mcp]` | +| Local API + remote MCP | `vidxp[local-worker,server]` | +| UI + API + both MCP transports | `vidxp[local-worker,frontend,server]` | -Install the lightweight command and application layer: +For example, install the local MCP profile with: ```bash -python -m pip install vidxp +uv tool install --python 3.14 --torch-backend cpu "vidxp[local-worker,mcp]" ``` -Add only the indexing capabilities you need: +`--torch-backend cpu` prevents an unintended CUDA dependency set on Linux and +keeps the supported runtime consistent across platforms. If `vidxp` is not +found after installation, run `uv tool update-shell` and reopen the terminal. + +### 2. Initialize FFmpeg ```bash -python -m pip install "vidxp[dialogue]" -python -m pip install "vidxp[scene,actor]" -python -m pip install "vidxp[all]" +vidxp init ``` -`all` contains all runtime capabilities. It does not include the `frontend` or -`benchmarks` extras: +`init` performs these steps: + +1. resolves FFmpeg and ffprobe; +2. executes version checks; +3. verifies the `libx264` and `aac` encoders; +4. shows the exact supported package-manager command if anything is missing; +5. asks before running WinGet or Homebrew; and +6. stores the verified absolute paths in VidXP’s per-user configuration. + +Linux prints an applicable APT, DNF, or pacman command for the user to run in a +system terminal. VidXP does not automate `sudo`. + +For scripts and CI: ```bash -python -m pip install "vidxp[all,frontend]" +vidxp init --json \ + --ffmpeg /absolute/path/to/ffmpeg \ + --ffprobe /absolute/path/to/ffprobe ``` -The `benchmarks` extra contains evaluation tooling only. Combine it with the -capability being evaluated, for example `vidxp[scene,benchmarks]`. +Redirected/noninteractive input never prompts or installs. `--yes` is an +explicit authorization to run the displayed supported package-manager command. -## Install from source +The lightweight one-off form is: -From the repository root: +```bash +uvx vidxp init +``` + +Because the paths are stored in per-user configuration, a later permanent +installation can reuse initialization performed by `uvx`. + +### 3. Prepare models ```bash -python -m pip install . +vidxp prepare ``` -Include all capabilities and the Streamlit interface: +The command displays the models that are missing, their pinned download sizes, +the cache location, and the maximum additional disk use before asking for +confirmation. + +| Capability | Models | Approximate download | +|---|---|---:| +| Dialogue | Qwen3 Embedding 0.6B + faster-whisper large-v3-turbo | 2.64 GiB | +| Scene | SigLIP2 base patch16-224 | 1.43 GiB | +| Actor | OpenCV Zoo YuNet + SFace | 37 MiB | + +Prepare only what you plan to index: ```bash -python -m pip install ".[all,frontend]" +vidxp prepare --modalities scene +vidxp prepare --modalities dialogue,actor ``` -Use an editable installation while developing: +Noninteractive preparation requires explicit confirmation: ```bash -python -m pip install -e ".[all,frontend,benchmarks]" +vidxp prepare --modalities scene --yes ``` -## Run the container +Indexing, API jobs, and MCP tools never turn the first request into a hidden +model download. -Stable container images include every runtime capability and the browser -interface, but not downloaded model weights. Docker Compose keeps indexes, -repository configuration, and model caches in the `vidxp-data` volume: +#### Upgrading from the previous model stack + +This release replaces: + +- WhisperX `large-v2` and `all-MiniLM-L6-v2` with faster-whisper + `large-v3-turbo` and Qwen3 Embedding 0.6B; +- OpenAI CLIP `ViT-B/32` with SigLIP2 base patch16-224; and +- `face_recognition`/dlib with OpenCV Zoo YuNet and SFace. + +The resulting embeddings, actor thresholds, and provider manifests are not +compatible with indexes built by the earlier stack. Prepare the new models, +then re-index the registered videos you want in the active snapshot. Keep the +old repository until the replacement index has been validated. + +### 4. Verify ```bash -docker compose pull -docker compose run --rm vidxp vidxp prepare -docker compose up +vidxp doctor ``` -The interface is available at `http://localhost:8501`. Set `VIDXP_PORT` in a -local `.env` file to publish a different host port. Set `VIDXP_IMAGE` to select -an immutable release instead of `latest`: +`doctor` checks installed distributions, provider imports, FFmpeg, codecs, and +the selected pinned model artifacts. It never installs packages, changes the +operating system, constructs models, or downloads weights. + +Limit the check when you prepared a subset: -```dotenv -VIDXP_IMAGE=ghcr.io/grayhatdevelopers/vidxp:0.2.0 -VIDXP_PORT=8502 -VIDXP_DEVICE=cpu +```bash +vidxp doctor --modalities scene +vidxp doctor --modalities dialogue,actor ``` -Run the image without Compose when persistent storage is not required: +### 5. Start the selected surface + +- CLI: `vidxp --help` +- Browser UI: `vidxp ui` +- Local HTTP API and remote MCP: `vidxp-api` +- Local stdio MCP: `vidxp-mcp` + +## First CLI index ```bash -docker run --rm -p 8501:8501 ghcr.io/grayhatdevelopers/vidxp:latest +vidxp media import samplevideo.mp4 --json +vidxp index create +vidxp search scene "a yellow taxi on a city street" ``` -## Verify the installation +- `media import` copies and validates the video in managed storage. +- `media list` rediscovers registered filenames and IDs. +- `index list` shows active searchable membership. +- Search/query without `--media-id` ranks across every indexed video. +- `--media-id ` restricts results to one video. -Display the installed package version: +Index one capability or change scene cadence: ```bash -vidxp --version +vidxp index create \ + --modality scene \ + --scene-sample-fps 1 ``` -Check the Python and system dependencies needed by all indexing capabilities: +## Optional dependency extras + +Extras are composable: + +| Extra | Includes | Does not include | +|---|---|---| +| `storage` | Embedded Chroma and host monitoring | Model providers or UI | +| `dialogue` | Storage, transcription, dialogue embeddings | Scene/actor providers | +| `scene` | Storage, PyTorch, Transformers, OpenCV, Pillow | Dialogue/actor providers | +| `actor` | Storage, OpenCV, YuNet/SFace support | Dialogue/scene providers | +| `all` | Dialogue, scene, and actor | Grounded-query model client and UI | +| `local-worker` | `all` plus grounded-query client | Browser UI, MCP SDK, HTTP server | +| `frontend` | Streamlit | Worker providers | +| `mcp` | MCP SDK | Worker providers | +| `slm` | OpenAI-compatible local query-model client | A language model or model weights | +| `server` | FastAPI, remote MCP, PostgreSQL/control dependencies | Local providers | +| `server-worker` | Server storage client and every CPU provider | Public API process | +| `benchmarks` | Benchmark adapter dependencies | Local worker providers | +| `test` | Pytest and HTTP test client | Product runtime features | + +The `server` extra is intentionally model-free. Use it with `local-worker` for +a loopback all-in-one API, or use the separate control and worker images for a +deployed server. + +## Local MCP + +After installing `local-worker,mcp`: ```bash -vidxp doctor +vidxp mcp-config ``` -`vidxp doctor` imports the selected dependencies but does not download model -weights. A base-only install therefore reports which capability extras are -missing. Restrict the check when diagnosing one capability: +The command prints a complete, import-ready `mcpServers` JSON object with the +resolved absolute `vidxp-mcp` executable and default repository argument. ```bash -vidxp doctor --modalities scene +vidxp-mcp --check --repository default ``` -If the frontend extra was installed, start the interface with `vidxp ui`. It -uses the active repository configured by `vidxp repositories use` and runs -until stopped with `Ctrl+C`. +The self-check performs a real stdio handshake, discovers tools, calls the +read-only index-status tool, prints resolved data/index paths, and exits. + +Useful alternatives: + +| Command | Result | +|---|---| +| `vidxp mcp-config --repository ` | Client JSON for a named repository | +| `vidxp-mcp --print-config` | JSON without other output | +| `vidxp-mcp --help` | Options plus a copy/paste example | + +## Local HTTP API + +Install `local-worker,server`, prepare models, then run: -## Prepare models +```bash +vidxp-api +``` -Model weights are not part of the Python package: +The unauthenticated local default is deliberately loopback-only: -| Capability | Model | +| Endpoint | Purpose | |---|---| -| Dialogue embeddings | `sentence-transformers/all-MiniLM-L6-v2` | -| Transcription | WhisperX `large-v2` | -| Scene search | CLIP `ViT-B/32` | -| Word alignment | WhisperX model selected for the detected language | +| `http://127.0.0.1:8000/docs` | Interactive OpenAPI | +| `http://127.0.0.1:8000/openapi.json` | Machine-readable contract | +| `http://127.0.0.1:8000/health` | Process liveness | +| `http://127.0.0.1:8000/ready` | Aggregate runtime readiness | +| `http://127.0.0.1:8000/mcp` | Streamable HTTP MCP | + +Do not bind an unauthenticated API to a non-loopback address. Public +deployments require static bearer or OIDC authentication and should use the +supported server Compose topology. + +## Published container images + +Every stable release publishes three Linux/amd64 images from the same validated +release commit: + +| Image | Published tag | Purpose | +|---|---|---| +| Local | ``, `.`, `latest` | CPU worker + browser UI | +| Control | `-control` | API, remote MCP, migrations, private upload hooks | +| Worker | `-worker` | CPU model providers and server storage client | + +Repository: + +```text +ghcr.io/grayhatdevelopers/vidxp +``` + +The local image is used by `compose.yaml`. The control and worker images are +used by `compose.coolify.yaml`. + +## Coolify and server Compose + +The supported server topology contains: + +| Control plane | Execution and storage | Ingestion | +|---|---|---| +| FastAPI + Streamable HTTP MCP | One CPU worker, PostgreSQL, Chroma | tusd + private hook service | -Download and cache the fixed dialogue, transcription, and scene models before -indexing: +It is a single-node, single-repository deployment. It does not claim +multi-replica failover, hosted database substitution, or GPU execution. + +Follow [Coolify deployment](docs/deployment/coolify.md) for: + +- exact control/worker image variables; +- required secrets and public hostnames; +- proxy rules for MCP and resumable uploads; +- explicit worker model preparation through the authenticated API or MCP; +- readiness/migration gates; +- persistent volumes and backups; and +- the optional, explicitly prepared Ollama profile. + +## Desktop application + +The operating-system package installs the VidXP application itself. On first +launch, the application configures local processing in this order: + +```text +FFmpeg preflight +→ explicit package-manager consent when missing +→ app-owned Python and VidXP runtime provisioning +→ optional model preparation +→ full doctor +→ atomic runtime activation +``` + +Capability code is selected independently from the optional browser interface. +Model downloads can be deferred, and the model cache can use a directory chosen +through the operating system's folder picker. Later launches open the browser +interface directly when it was selected. After configuration the Tauri +supervisor stays in the system tray instead of keeping a window open. Closing +the window hides it; **Quit VidXP** from the tray performs the complete +interface and worker shutdown. + +The NSIS/DMG/AppImage packages do not install FFmpeg themselves. The application +uses a native confirmation dialog before running a supported package manager. +Windows SmartScreen and macOS Gatekeeper may require explicit confirmation +until signing is added. Build instructions are in +[Desktop application](docs/desktop.md). + +## Install from source + +Source installation is for contributors and reproducible development, not the +normal public quick start: ```bash -vidxp prepare +git clone https://github.com/grayhatdevelopers/vidxp.git +cd vidxp +uv sync --frozen \ + --extra local-worker \ + --extra frontend \ + --extra mcp \ + --extra server +uv run --no-sync vidxp init +uv run --no-sync vidxp prepare +uv run --no-sync vidxp doctor +uv run --no-sync vidxp ui ``` -WhisperX selects its alignment model after detecting the video's language. Cache -a known language explicitly when required: +The lock routes PyTorch through the official CPU index on Linux and Windows. + +## Data and configuration locations + +Local commands do not treat the current directory as their storage root. + +| Platform | Data root | Configuration root | +|---|---|---| +| Windows | `%LOCALAPPDATA%\VidXP` | `%APPDATA%\VidXP` | +| macOS | `~/Library/Application Support/VidXP` | `~/Library/Application Support/VidXP` | +| Linux | `${XDG_DATA_HOME:-~/.local/share}/VidXP` | `${XDG_CONFIG_HOME:-~/.config}/VidXP` | + +The data root contains: + +```text +VidXP/ + repositories/ + default/ + models/ +``` + +The desktop application uses this same root for repositories and its default +model cache. Its managed Python environments and active-runtime pointer remain +in the identifier-scoped private application-data directory. On Windows, +per-user program files are installed separately under +`%LOCALAPPDATA%\Programs\VidXP`. + +Use an alternate root for one CLI invocation: ```bash -vidxp prepare --option dialogue.alignment_language=en +vidxp --data-dir /path/to/vidxp-data ui ``` -SentenceTransformer and WhisperX use the Hugging Face cache; CLIP uses its own -user cache. These caches normally live outside the virtual environment, so -removing the environment does not necessarily remove downloaded model weights. +Set `VIDXP_DATA_DIR` when the CLI, UI, local MCP, and local API should all use +the same alternate root. `VIDXP_INDEX_DIR` and `VIDXP_MODEL_CACHE` are advanced +single-location overrides. Docker uses its declared volumes instead. -## First indexing run +## Troubleshooting -A successful installation means the commands and Python dependencies are -available. It does not mean the model weights have been downloaded. +### `vidxp` is not found -If `vidxp prepare` was not run first, the initial indexing command downloads any -missing models before processing the video: +- Run `uv tool update-shell`, restart the shell, and try again. +- Confirm the installation with `uv tool list`. + +### FFmpeg or a codec is missing ```bash -vidxp index create samplevideo.mp4 +vidxp init ``` -Keep the terminal and internet connection open until VidXP reports that the -index is ready. Machine-learning dependencies and model caches can consume -several gigabytes, and the first run takes longer while models are downloaded. -Exact sizes depend on the platform and selected package builds. +Review the displayed package-manager command before approving it. Rerun +`vidxp doctor` afterward. + +### Linux or Windows starts resolving NVIDIA packages -VidXP saves local progress and the final state in -`chroma_data/index_status.json`. Search is unavailable until indexing completes. -If the process stops early, run indexing again; VidXP clears the incomplete -single-video index before rebuilding it. +Reinstall the managed tool with the explicit CPU option: -## Common problems +```bash +uv tool install --force --python 3.14 --torch-backend cpu \ + "vidxp[local-worker,frontend]" +``` -### `dlib` fails to install +Do not use plain `pip install "vidxp[local-worker]"` on Linux unless you have +already installed and constrained the intended CPU PyTorch build. -Confirm that CMake and a working C/C++ compiler are installed and visible from -the active terminal. On Windows, use the Microsoft C++ Build Tools. +### uv cannot hardlink from its cache -### FFmpeg is not found +This warning means the cache and environment are on filesystems that cannot +hardlink to each other. Installation falls back to copying and remains valid. +Suppress the warning when that layout is intentional: -Run `ffmpeg -version` in the same terminal. Install FFmpeg or add its executable -directory to `PATH`, then rerun `vidxp doctor`. +```bash +uv tool install --link-mode copy --python 3.14 --torch-backend cpu \ + "vidxp[local-worker,frontend]" +``` -### A search says the index is not ready +or set `UV_LINK_MODE=copy`. -Wait for the active indexing command to finish. If the previous process ended -or failed, run `vidxp index create` again to rebuild the incomplete local -index. +### A model is missing or the worker is offline -### The first indexing run appears slow +```bash +vidxp prepare +vidxp doctor +``` + +For offline operation, prepare while network access is allowed, then set +`VIDXP_ALLOW_MODEL_DOWNLOADS=false`. + +### Search says the index is not ready + +- Check `vidxp index status`. +- Check `vidxp jobs list` and inspect the failed job. +- Retry indexing after fixing the reported dependency/model error. +- A failed or cancelled generation does not replace the previous active + snapshot. + +### Resetting local data + +VidXP intentionally does not provide an implicit destructive reset. Inspect the +resolved paths first: + +```bash +vidxp repositories show +``` -Check the terminal for model-download or indexing progress. Model preparation, -transcription, scene analysis, and actor detection are separate stages. Use -`vidxp prepare` before indexing or select fewer capabilities with -one or more `--modality` options. +Back up or remove only the specific per-user repository/model directories you +intend to discard. diff --git a/MANIFEST.in b/MANIFEST.in index 993b28c..383bfce 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,6 @@ include README.md include LICENSE +include docs/images/logo.png global-exclude *.whl global-exclude *.pyc global-exclude __pycache__ diff --git a/README.md b/README.md index d151fda..02116d8 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

- - logo - + + VidXP logo +

VidXP

@@ -10,258 +10,221 @@ Search video by what was said, what appeared on screen, and recurring faces.

-
-

- VidXP is a local-first video indexing and search engine distributed as a Python - package. -

- -
-
-
You can use it: -
    -
  • From the command line
  • -
  • Through its browser interface
  • -
  • As a desktop app
  • -
  • As an API
  • -
  • As an MCP, with your agents (coming soon ⚡️) -
  • As an indexing and retrieval layer inside another application
  • -
+

+ A local-first video search engine for people, applications, and AI agents. +

- Dialogue search · Scene search · Actor grouping · CLI · Browser UI · Python API + Dialogue search · Scene search · Actor grouping

- - PyPI version - - - Supported Python versions - - - CI status - - - MIT license - - - Discord - + PyPI version + GHCR container + CI status + MIT license + Discord

-## Why VidXP +## Find the moment, not the timestamp -Finding one moment in a video should not require scrubbing through the entire -timeline. VidXP builds a searchable index from three kinds of evidence: +VidXP makes one video—or an entire collection—searchable by meaning: -- **Dialogue:** semantic search over timestamped transcripts. -- **Scenes:** text-to-frame search. -- **Actors:** groups similar detected faces and exports a highlighted video for a selected cluster. +- **Dialogue search:** type what you remember someone saying and jump to the + matching moments. +- **Scene search:** describe what appeared on screen and find the closest + visual matches. +- **Actor matching:** find recurring faces within a video and export a + highlighted video for a selected group. -After the required model weights are available, video processing and search run completely locally, for your privacy and security. +Use it to search years of family videos, add video search to an editing +workflow, or let an AI agent answer questions using evidence from your own +video library. Your videos can stay on your machine. -Some ideas on how to use VidXP: -- Use it as way to find your favorite relatives in a huge folder of wedding videos (been there, done that) -- Use it in your application, allow users to search videos (an idea: use it alongside a video-editing application) -- Use it as an "understanding" layer so your LLM / agent can understand videos +[![VidXP browser interface](./docs/images/video-screenshot.jpeg)](https://www.linkedin.com/feed/update/urn:li:activity:7343569473720725505/) -[![Video Screenshot](./docs/images/video-screenshot.jpeg)](https://www.linkedin.com/feed/update/urn:li:activity:7343569473720725505/) +## Start here +Choose the setup that fits how you want to use VidXP. -## Current capabilities +### 1. CLI and MCP -| Capability | Available now | Result | -|---|---|---| -| Dialogue search | Transcription, word alignment, semantic phrase indexing | Matching video time | -| Scene search | Text search over sampled video frames | Matching frame and time | -| Actor grouping | Within-video face detection and clustering | Clustered detections and highlighted output video | -| Interfaces | Typer CLI, Streamlit browser interface, Python API | Interactive or programmatic use | -| Index management | Saved progress, ready/failed state, cancellation, isolated programmatic runs | Traceable and reusable indexes | +For direct use, scripts, and local AI agents, install +[uv](https://docs.astral.sh/uv/getting-started/installation/), then run: -## Quick start +```bash +# Install the CPU edition +uv tool install --python 3.14 --torch-backend cpu "vidxp[local-worker,mcp]" -VidXP supports Python 3.10 through 3.13 and requires FFmpeg. See the -[installation guide](INSTALLATION_GUIDE.md) for the `dlib` compiler -requirements, source installation, model preparation, and troubleshooting. +# Set up FFmpeg +vidxp init -Install the command line and browser interface with -[pipx](https://packaging.python.org/en/latest/guides/installing-stand-alone-command-line-tools/). -The command is available on your `PATH` while VidXP and its dependencies remain -isolated: +# Download search models +vidxp prepare -```bash -pipx install "vidxp[all,frontend]" +# Check everything +vidxp doctor + +# Connect an MCP client +vidxp mcp-config ``` -Install only the capabilities you need with a smaller selection such as -`pipx install "vidxp[scene,frontend]"` or -`pipx install "vidxp[dialogue,scene]"`. To import VidXP from another Python -project, install it into that project's environment instead; the -[installation guide](INSTALLATION_GUIDE.md) covers that path. +`uv` installs and manages Python for VidXP. `vidxp init` checks FFmpeg and +shows an operating-system install command if it is missing. -Confirm the installed package and its runtime dependencies: +The CLI works without MCP. Add the browser app with: ```bash -vidxp --version -vidxp doctor +uv tool install --python 3.14 --torch-backend cpu \ + "vidxp[local-worker,mcp,frontend]" +vidxp ui ``` -The first use of each capability downloads its model weights. Download the fixed -dialogue, transcription, and scene models in advance with: +If the `vidxp` command is not found, run `uv tool update-shell` once and reopen +the terminal. -```bash -vidxp prepare -``` +### 2. Desktop app -## Index and search +Download the installer for Windows, Apple Silicon macOS, or Linux from +[GitHub Releases](https://github.com/grayhatdevelopers/vidxp/releases). -Build an index containing dialogue, scene, and actor information: +The desktop installer manages Python, VidXP, and the local worker for you. On +first launch, choose the search capabilities you want, where model files should +live, and whether to download them immediately. Python and uv do not need to be +installed separately. -```bash -vidxp index create samplevideo.mp4 -``` +After setup, VidXP can stay available from the system tray. The browser +interface is optional and can be left out of the installed VidXP runtime. + +### 3. Docker for a server -Search the completed index: +Run the published all-in-one image on a home server or another single machine: ```bash -vidxp search dialogue "the bread just came out of the oven" -vidxp search scene "a yellow taxi on a city street" --top-k 5 -vidxp actors list -vidxp actors render 1 samplevideo.mp4 +docker run --rm --init \ + -p 8501:8501 \ + -v vidxp-data:/var/lib/vidxp \ + ghcr.io/grayhatdevelopers/vidxp:latest ``` -Index only selected capabilities or sample fewer visual frames: +For a long-lived server, pin a published version instead of `latest`. For a +Coolify deployment, use the published `-control` and `-worker` images with +[`compose.coolify.yaml`](compose.coolify.yaml)—no repository build is required. +See the [Coolify guide](docs/deployment/coolify.md) for the complete setup. -```bash -vidxp index create samplevideo.mp4 --modality scene --frame-stride 5 -``` +## What you can do today -Repeat `--modality` to combine `dialogue`, `scene`, and `actor`. -Run `vidxp --help` or any command followed by `--help` for the complete command -reference. +- Build a reusable search library from one video or a whole collection. +- Search dialogue by meaning, even when you do not remember the exact words. +- Find visual moments by describing the scene you are looking for. +- Group recurring faces in a video and render a highlighted actor overlay. +- Search one selected video or every video in the active library. +- Open matching timestamps and export downloadable clips and overlays. +- Keep personal, client, or project libraries separate. +- Follow long indexing jobs, cancel them, and keep the last working index if a + later run fails. +- Use the browser app, automate the CLI, connect an MCP agent, or integrate + VidXP into another application. -Use named repositories to keep index locations and devices centrally -configured: +## A first search + +The browser app guides you through importing and indexing. The same flow from +the command line is: ```bash -vidxp repositories add team --index-dir ./indexes/team --device cuda --use -vidxp repositories list -``` +# Add a video +vidxp media import samplevideo.mp4 --json -## Browser interface +# Index the returned media ID +vidxp index create -Install the `frontend` extra and start: +# Find a visual moment +vidxp search scene "a yellow taxi on a city street" -```bash -vidxp ui +# Find something that was said +vidxp search dialogue "the bread just came out of the oven" ``` -The command uses the active named repository, starts a local Streamlit server, -and remains active until stopped. -The interface can upload a video, start or cancel indexing, restore saved -progress after a page reload, and search the capabilities available in the -completed index. +Results include the source video, timestamps, match score, and the evidence +used to find the moment. Add `--media-id ` to search only one video. -## Container +Run `vidxp --help` or `vidxp --help` for the full command reference. -Stable releases are available from GitHub Container Registry. Start the local -interface with persistent index and model storage by running: +## For applications and AI agents -```bash -docker compose up -``` +Use the Python package to add selected VidXP capabilities directly to an +application, or use the HTTP API when VidXP runs as a service. -See the [installation guide](INSTALLATION_GUIDE.md#run-the-container) for model -preparation, configuration, and direct `docker run` usage. - -## Use VidXP as a Python package - -The programmatic API supports isolated multi-video runs, supplied timestamped -transcripts, resumable per-video checkpoints, and metadata-rich top-k results. - -```python -from vidxp.core import IndexConfig, VideoSource -from vidxp.core.runner import run_index -from vidxp.capabilities.scene.operations import search_scene - -config = IndexConfig( - dataset="my-library", - split="local", - run_id="demo", - enabled_modalities=("scene",), - frame_stride=5, -) - -run_index( - [ - VideoSource(video_id="video-1", path="videos/first.mp4"), - VideoSource(video_id="video-2", path="videos/second.mp4"), - ], - config, -) - -results = search_scene("a person enters a taxi", config=config, top_k=5) -for hit in results.hits: - print(hit.video_id, hit.start, hit.end, hit.score) -``` +MCP clients can add and discover videos, start indexing, search dialogue and +scenes, ask questions about a library, and create clips or actor overlays. +Local agents can connect over stdio; remote agents can connect to a +self-hosted VidXP server. -The [Python indexing and retrieval contract](docs/benchmarking/core_contract.md) -documents configuration, stored metadata, result fields, and run layout. +- [Python, HTTP, and MCP installation](INSTALLATION_GUIDE.md) +- [Optional capability packages](INSTALLATION_GUIDE.md#optional-dependency-extras) +- [Coolify server setup](docs/deployment/coolify.md) -## Recommended specs +## Downloads and storage -> Coming soon +First setup downloads only the models needed for the capabilities you select. +VidXP shows the download size and destination before it starts. ---- +| Capability | Approximate model download | +|---|---:| +| Dialogue search | 2.64 GiB | +| Scene search | 1.43 GiB | +| Actor matching | 37 MiB | -## Roadmap +Leave additional space for the VidXP runtime, indexes, source videos, and +exported results. -VidXP is an evolving beta. We'd love to hear your feedback and where you'd like to see the project go. +By default, the CLI and desktop app share the same VidXP data directory: -| Area | Current foundation | Direction | -|---|---|---| -| Search results | Top result in the CLI; structured top-k Python results | Rich ranked results, metadata, previews, and filtering across interfaces | -| Temporal search | Frame and transcript-phrase timestamps | Better time ranges, scene boundaries, aggregation, and ranking | -| Video collections | One local CLI/UI index; isolated multi-video Python runs | User-facing persistent multi-video libraries and index management | -| Actor workflows | Face clustering and highlighted video export | Cluster browsing, labeling, actor search, and stronger tracking | -| Speaker context | Timestamped dialogue search | Active-speaker detection and links between speech and visible people | -| Product experience | CLI and browser indexing/search | Clearer progress, result navigation, recovery, and long-running job controls | -| Evaluation | DiDeMo and HiREST baselines | Combined and component benchmarks, beginning with a LongVALE pilot | +| Platform | Default location | +|---|---| +| Windows | `%LOCALAPPDATA%\VidXP` | +| macOS | `~/Library/Application Support/VidXP` | +| Linux | `${XDG_DATA_HOME:-~/.local/share}/VidXP` | -## Models and local data +Docker keeps the same data in the `vidxp-data` volume shown above. -| Capability | Model | -|---|---| -| Dialogue embeddings | `sentence-transformers/all-MiniLM-L6-v2` | -| Transcription | WhisperX `large-v2` | -| Scene search | CLIP `ViT-B/32` | -| Word alignment | WhisperX model selected for the detected language | +## Product roadmap + +The next product improvements are focused on: -VidXP maintains the standard local CLI/UI index in `chroma_data/`. Starting a -new local indexing run replaces the previous or incomplete local index. Model -caches normally live outside this directory and outside the virtual environment. +- labeling actor groups and matching the same person across different videos; +- more reliable face tracking across angle, lighting, motion, and occlusion; +- connecting visible people with the dialogue they are speaking; +- better search ranking, time ranges, and natural-language questions across a + whole library; +- richer previews, timelines, filters, saved searches, and result playback; +- easier organization for large personal and project video collections; +- faster indexing and supported GPU acceleration; and +- smoother desktop updates, repair, and model management. -## Documentation and project links +VidXP is in beta. Feedback about search quality, actor workflows, and real +video-library use cases is especially useful. + +## Help and project links - [Installation and troubleshooting](INSTALLATION_GUIDE.md) -- [Benchmarking status and results](docs/benchmarking/README.md) -- [Adding a capability](docs/adding-a-capability.md) +- [Desktop application](docs/desktop.md) +- [Coolify deployment](docs/deployment/coolify.md) - [Changelog](CHANGELOG.md) - [Issue tracker](https://github.com/grayhatdevelopers/vidxp/issues) - [MIT license](LICENSE) - ## Contributing -See [CONTRIBUTING.md](./docs/CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs. AI/vibe-coded PRs welcome! +Contributions are welcome. Read the +[contribution guide](docs/CONTRIBUTING.md) before opening a pull request. ## Credits -Built by Grayhat Developers PVT Ltd. 2026. Maintained by the community. +Built by Grayhat Developers PVT Ltd. and maintained by the community. Email: info@grayhat.studio - + VidXP contributors diff --git a/changes/+artifacts.feature.md b/changes/+artifacts.feature.md new file mode 100644 index 0000000..a781a4f --- /dev/null +++ b/changes/+artifacts.feature.md @@ -0,0 +1,8 @@ +Add managed video artifacts: + +- create source-preserving or broadly compatible MP4 clips from a media ID while rejecting ranges outside the source or configured duration limit +- reuse a ready clip for the same source checksum, interval, and output profile +- render actor overlays from stable composite cluster IDs bound to an immutable snapshot +- validate generated video and the requested output profile before atomic publication +- durably synchronize managed publication and removal on platforms that support directory `fsync` +- expose opaque artifact metadata and authorized downloads through CLI, HTTP, and MCP without leaking repository paths diff --git a/changes/+benchmarking.feature.md b/changes/+benchmarking.feature.md new file mode 100644 index 0000000..d05175f --- /dev/null +++ b/changes/+benchmarking.feature.md @@ -0,0 +1 @@ +Add optional DiDeMo and HiREST benchmark commands with guided, resumable preparation of pinned inputs; media and checksum validation; explicit documented substitutions; generation-aware indexes; opaque internal media IDs; scene-cadence controls; and invocation-time checks that name the exact missing benchmark extra instead of breaking unrelated commands. Retain the legacy full-corpus baselines and record bounded real SigLIP2/Qwen3 provider smokes separately. diff --git a/changes/+durable-workflows.feature.md b/changes/+durable-workflows.feature.md new file mode 100644 index 0000000..7bb07b7 --- /dev/null +++ b/changes/+durable-workflows.feature.md @@ -0,0 +1,9 @@ +Run long operations through durable, inspectable jobs: + +- execute indexing, fused search, grounded questions, model preparation, snippets, and actor overlays through versioned DBOS workflows +- use SQLite-backed local jobs and PostgreSQL-backed server jobs with the same typed progress, cancellation, retry, error, and result contracts +- pin search and actor-overlay work to the exact immutable snapshot selected at submission +- freeze descending job pages so concurrent submissions do not shift later cursors +- materialize actor-cluster summaries and advance actor-detection cursors without rescanning all retained detections for every page +- bind workflow recovery to the package version and implementation digest while keeping legacy atomic-search records readable and recoverable +- supervise detached local workers without creating a second job-state store diff --git a/changes/+model-providers.breaking.md b/changes/+model-providers.breaking.md new file mode 100644 index 0000000..897ec46 --- /dev/null +++ b/changes/+model-providers.breaking.md @@ -0,0 +1,8 @@ +Re-index videos created with the previous provider stack: + +- replace WhisperX `large-v2` and `all-MiniLM-L6-v2` with pinned faster-whisper `large-v3-turbo` and Qwen3 Embedding 0.6B for dialogue +- replace OpenAI CLIP `ViT-B/32` with pinned SigLIP2 base patch16-224 for scene retrieval +- replace `face_recognition`/dlib with pinned OpenCV Zoo YuNet and SFace for face detection and within-video grouping +- record model revisions, weight checksums, licenses, precision, runtime identity, and expected download sizes in the index/model contracts + +The new embeddings, face thresholds, and provider manifests are intentionally incompatible with old indexes. diff --git a/changes/+product-interfaces.feature.md b/changes/+product-interfaces.feature.md new file mode 100644 index 0000000..eed9c25 --- /dev/null +++ b/changes/+product-interfaces.feature.md @@ -0,0 +1,9 @@ +Expose the shared VidXP application through each supported product surface: + +- provide CLI commands for media, index membership, cross-video search, grounded questions, jobs, repositories, actors, and artifacts +- provide a browser UI that reloads cataloged media in fresh sessions, derives search/query controls from capability metadata, confirms model downloads, shows live job progress, submits search with Enter, stops polling terminal jobs, and describes visual-similarity results accurately +- provide a Python indexing and retrieval layer for applications +- provide a versioned HTTP API with OpenAPI, media transfer, readiness, durable jobs, and artifact delivery +- provide local stdio and remote Streamable HTTP MCP with import-ready client configuration, a real protocol self-check, media/index discovery, job polling/retry, and clip download links +- provide a Tauri desktop application that preflights FFmpeg with native consent, provisions selected capabilities and optional browser dependencies, supports a custom model location and deferred downloads, opens configured browser profiles directly, owns its runtime from a native system tray with full shutdown on Quit, builds as a Windows GUI executable, and hides supervised child consoles +- provide an all-in-one local container using the same repository and worker behavior diff --git a/changes/+public-transports.security.md b/changes/+public-transports.security.md new file mode 100644 index 0000000..43a1520 --- /dev/null +++ b/changes/+public-transports.security.md @@ -0,0 +1,9 @@ +Protect public HTTP and MCP transports: + +- support constant-time static bearer authentication or cached asymmetric OIDC/JWKS validation with exact issuer, audience, algorithm, HTTPS, and unsafe-URL-character checks +- enforce repository read, write, and admin scopes after authentication +- declare bearer authentication in protected OpenAPI and keep authenticated schemas private +- enforce independent trusted Host, Origin/CORS, request-size, query-length, actor-identifier, and streamed-upload bounds +- return typed public errors with correlation IDs while retaining internal workflow tracebacks +- derive opaque repository-, subject-, and operation-scoped idempotency keys for retries +- deliver authorized media and artifacts through confined handles with checksum ETags and range responses, without exposing storage keys or filesystem paths diff --git a/changes/+release-publication.feature.md b/changes/+release-publication.feature.md new file mode 100644 index 0000000..34b5dac --- /dev/null +++ b/changes/+release-publication.feature.md @@ -0,0 +1,7 @@ +Align public release artifacts: + +- stamp the Python package, Tauri package, desktop runtime manifest, and native app versions through semantic release +- build PyPI distributions and local/control/worker container images from the exact validated release commit +- build unsigned NSIS, DMG, and AppImage desktop installers from locked target-specific profiles and attach them to the matching GitHub release +- verify the minimal wheel separately from optional capability, UI, MCP, server, benchmark, and test extras +- keep the repository README's relative assets while rewriting them only in the temporary PyPI build diff --git a/changes/+runtime-setup.feature.md b/changes/+runtime-setup.feature.md new file mode 100644 index 0000000..72fd855 --- /dev/null +++ b/changes/+runtime-setup.feature.md @@ -0,0 +1,12 @@ +Make local setup and execution explicit and observable: + +- add `vidxp init` to verify FFmpeg, ffprobe, `libx264`, and `aac`, and request consent before a supported package-manager command +- make `vidxp prepare` disclose every missing pinned model, its expected bytes, the cache location, total additional space, and live byte progress before downloading +- use bounded HTTP model downloads instead of Xet transfers that can remain parked at zero bytes +- keep `vidxp doctor` read-only and report Python, provider, codec, worker, storage, and prepared-model readiness separately +- prevent indexing, API, or MCP first requests from silently downloading models +- place repositories and model caches in the operating system's per-user VidXP data root instead of the shell's current directory +- sample scenes by time and source frame rate with independent actor cadence and CLI, UI, API, and MCP controls +- skip dialogue cleanly for videos without audio instead of failing the entire index +- avoid an arbitrary free-memory refusal, allow realistic worker startup time, hide the Windows worker console, prevent duplicate indexing previews, and stop owned workers when the local UI, API, or MCP process exits +- pass local-worker settings through a one-use owner-readable bootstrap without HTTP credentials, with startup backoff, bounded log rotation, stale-process cleanup, and executable/provider identity checks diff --git a/changes/+server-deployment.feature.md b/changes/+server-deployment.feature.md new file mode 100644 index 0000000..4a8a44b --- /dev/null +++ b/changes/+server-deployment.feature.md @@ -0,0 +1,9 @@ +Add a production-oriented single-node Compose/Coolify deployment: + +- publish separate model-free control and CPU-provider worker images from the same validated release commit +- persist catalogs and immutable snapshot metadata in PostgreSQL, DBOS state in its own schema, vectors in Chroma, and media/models in named volumes +- accept streamed multipart uploads up to 256 MiB and large authenticated resumable tus uploads with private hooks, recovery, retention, atomic reservation, and per-principal quota enforcement +- gate startup on migrations, Chroma connectivity, API/worker health, and explicit model preparation +- keep PostgreSQL, Chroma, hooks, Ollama, and upload internals off the public network +- document the supported one-node, one-worker, one-repository-per-stack boundary and graceful worker/tusd shutdown behavior +- support optional grounded-query generation only after the operator explicitly configures and prepares an evaluated model diff --git a/changes/+video-library.feature.md b/changes/+video-library.feature.md new file mode 100644 index 0000000..b17ae48 --- /dev/null +++ b/changes/+video-library.feature.md @@ -0,0 +1,9 @@ +Build a persistent multi-video library: + +- stream, hash, and ffprobe local imports before publishing them under opaque media IDs +- store each indexed video as an immutable generation and atomically update the active multi-video snapshot only after validation +- preserve the previous searchable snapshot when indexing fails or is cancelled +- re-index, remove, or clear individual media without rebuilding unrelated videos +- rank top-k results across the active collection or restrict search and grounded questions with a `media_id` +- list registered and actively indexed videos with bounded, stable cursor pages that reject malformed, noncanonical, or overflowing cursors +- import large local files without routing their bytes through the browser upload control diff --git a/changes/19.bugfix.md b/changes/19.bugfix.md deleted file mode 100644 index 2c79f5a..0000000 --- a/changes/19.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Prevent dependency updates and label changes from spawning redundant CI runs. diff --git a/changes/README.md b/changes/README.md index c264b27..92d7d79 100644 --- a/changes/README.md +++ b/changes/README.md @@ -1,15 +1,17 @@ # Changelog fragments -Every pull request with a user-visible change must add one short fragment here. -Use the pull request number and the most specific type: +Every pull request with a user-visible change normally adds one short fragment +here. Describe the difference from the latest public release—not every +intermediate implementation step. Use the pull request number and the most +specific type: ```text changes/..md ``` Supported types are `breaking`, `feature`, `bugfix`, `deprecation`, `docs`, and -`security`. Write one sentence for users in the imperative voice and do not add -a heading or the version number. +`security`. Write for users in the imperative voice and do not add a heading or +the version number. Example: @@ -21,9 +23,16 @@ changes/123.feature.md Add named repositories for selecting shared index locations and devices. ``` +Use `bugfix` only for behavior that was wrong in the latest public release. If +an unreleased feature changes while it is being built, update or consolidate +its pending feature note instead of stacking fictional public fixes. Do not +create one fragment per internal commit. + Dependency updates marked `dependencies` do not require a fragment. Other internal changes may omit one when a maintainer applies `skip-changelog` and -the pull request explains why. +the pull request explains why. Maintainers may use an issue-less +`changes/+..md` fragment when consolidating a release; normal pull +requests should use their numeric identifier. Towncrier renders the pending fragments for prerelease notes, then collects and removes them when a stable release is created. Do not edit `CHANGELOG.md` diff --git a/compose.coolify.yaml b/compose.coolify.yaml new file mode 100644 index 0000000..cd6d601 --- /dev/null +++ b/compose.coolify.yaml @@ -0,0 +1,214 @@ +x-vidxp-environment: &vidxp-environment + VIDXP_MODE: server + VIDXP_RUNTIME_BACKEND: cpu + VIDXP_REPOSITORY_ROOT: /var/lib/vidxp/data + VIDXP_MODEL_CACHE: /var/lib/vidxp/models + PGPASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD} + VIDXP_HTTP_BIND_HOST: 0.0.0.0 + VIDXP_HTTP_AUTH_MODE: static + VIDXP_HTTP_STATIC_BEARER_TOKEN: ${VIDXP_HTTP_STATIC_BEARER_TOKEN:?Set a bearer token of at least 32 characters} + VIDXP_HTTP_TRUSTED_HOSTS: '["127.0.0.1","localhost","${VIDXP_PUBLIC_API_HOST:?Set the public API host}"]' + VIDXP_MCP_ALLOWED_HOSTS: '["127.0.0.1:*","localhost:*","${VIDXP_PUBLIC_API_HOST:?Set the public API host}"]' + VIDXP_UPLOAD_PUBLIC_ENDPOINT: ${VIDXP_UPLOAD_PUBLIC_ENDPOINT:?Set the public tusd endpoint ending in /uploads/} + VIDXP_UPLOAD_INTERNAL_ENDPOINT: http://tusd:8080/uploads/ + VIDXP_UPLOAD_CLEANUP_TOKEN: ${VIDXP_UPLOAD_CLEANUP_TOKEN:?Set a cleanup token of at least 32 characters} + VIDXP_UPLOAD_QUARANTINE_ROOT: /var/lib/vidxp/uploads + VIDXP_UPLOAD_MAX_BYTES: ${VIDXP_UPLOAD_MAX_BYTES:-53687091200} + VIDXP_UPLOAD_QUOTA_BYTES: ${VIDXP_UPLOAD_QUOTA_BYTES:-107374182400} + +services: + postgres: + image: postgres:18.3-trixie@sha256:7e32e9833a6fb1c92c32552794cb6ed569d51b445a54907d35fc112ef39684db + environment: + POSTGRES_DB: vidxp + POSTGRES_USER: vidxp + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD} + volumes: + - postgres-data:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U vidxp -d vidxp"] + interval: 5s + timeout: 5s + retries: 20 + restart: unless-stopped + + chroma: + image: chromadb/chroma:1.5.9@sha256:1e0b73a187a28757c572acba508c46f48c9e8b0acaf5c20e6d95cdedce1acdf6 + volumes: + - chroma-data:/data + restart: unless-stopped + + storage-init: + image: ${VIDXP_CONTROL_IMAGE:?Set the immutable VidXP control image} + user: "0:0" + command: + - chown + - -R + - vidxp:vidxp + - /var/lib/vidxp/data + - /var/lib/vidxp/uploads + - /var/lib/vidxp/models + volumes: + - content-data:/var/lib/vidxp/data + - upload-quarantine:/var/lib/vidxp/uploads + - model-cache:/var/lib/vidxp/models + healthcheck: + disable: true + restart: "no" + + chroma-ready: + image: ${VIDXP_CONTROL_IMAGE:?Set the immutable VidXP control image} + command: ["python", "-m", "vidxp.health_cli", "chroma"] + depends_on: + chroma: + condition: service_started + healthcheck: + disable: true + restart: "no" + + migrate: + image: ${VIDXP_CONTROL_IMAGE:?Set the immutable VidXP control image} + environment: + <<: *vidxp-environment + command: + - sh + - -c + - >- + vidxp-database && + dbos migrate + --sys-db-url postgresql://vidxp@postgres:5432/vidxp + --schema dbos + depends_on: + postgres: + condition: service_healthy + storage-init: + condition: service_completed_successfully + healthcheck: + disable: true + restart: "no" + + api: + image: ${VIDXP_CONTROL_IMAGE:?Set the immutable VidXP control image} + environment: + <<: *vidxp-environment + command: ["vidxp-api"] + volumes: + - content-data:/var/lib/vidxp/data + depends_on: + migrate: + condition: service_completed_successfully + healthcheck: + test: + - CMD + - python + - -c + - "import urllib.request; urllib.request.urlopen('http://localhost:8000/ready', timeout=5)" + interval: 15s + timeout: 10s + retries: 20 + restart: unless-stopped + + hooks: + image: ${VIDXP_CONTROL_IMAGE:?Set the immutable VidXP control image} + environment: + <<: *vidxp-environment + command: ["vidxp-hooks"] + volumes: + - upload-quarantine:/var/lib/vidxp/uploads:ro + depends_on: + migrate: + condition: service_completed_successfully + healthcheck: + test: + - CMD + - python + - -c + - "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3)" + interval: 15s + timeout: 5s + retries: 20 + restart: unless-stopped + + worker: + image: ${VIDXP_WORKER_IMAGE:?Set the immutable VidXP worker image} + environment: + <<: *vidxp-environment + VIDXP_ALLOW_MODEL_DOWNLOADS: ${VIDXP_ALLOW_MODEL_DOWNLOADS:-true} + VIDXP_SLM_BASE_URL: ${VIDXP_SLM_BASE_URL:-} + VIDXP_SLM_MODEL: ${VIDXP_SLM_MODEL:-} + command: ["vidxp-worker", "--role", "cpu"] + volumes: + - content-data:/var/lib/vidxp/data + - upload-quarantine:/var/lib/vidxp/uploads:ro + - model-cache:/var/lib/vidxp/models + depends_on: + migrate: + condition: service_completed_successfully + chroma-ready: + condition: service_completed_successfully + stop_grace_period: 40s + restart: unless-stopped + + ollama: + image: ollama/ollama:0.32.5@sha256:4dea9fb511947e24a84237bb636b0203abcb2ff0d3fbc7b4ff865deb91362131 + profiles: ["slm"] + volumes: + - ollama-models:/root/.ollama + healthcheck: + test: ["CMD", "ollama", "list"] + interval: 10s + timeout: 5s + retries: 20 + restart: unless-stopped + + tusd: + image: tusproject/tusd:v2.10.0@sha256:9610f0f8edf4cceeb26f1b3ac9fa4bb4803d61a878e222c1573cda63c9e23374 + command: + - -host=0.0.0.0 + - -port=8080 + - -upload-dir=/srv/tusd-data + - -base-path=/uploads/ + - -behind-proxy + - -max-size=${VIDXP_UPLOAD_MAX_BYTES:-53687091200} + - -disable-download + - -disable-concatenation + - -hooks-http=http://hooks:8000/hooks + - -hooks-enabled-events=pre-create,post-finish,pre-terminate,post-terminate + - -hooks-http-timeout=5s + - -hooks-http-size-limit=5120 + - -hooks-http-retry=3 + - -hooks-http-backoff=1s + - -cors-allow-origin=${VIDXP_UPLOAD_CORS_ORIGIN_REGEX:?Set an anchored browser-origin regex} + - -cors-allow-headers=Authorization + - -cors-max-age=600 + - -network-timeout=60s + - -shutdown-timeout=30s + - -verbose=false + - -show-startup-logs=false + - -log-format=json + volumes: + - upload-quarantine:/srv/tusd-data + depends_on: + hooks: + condition: service_healthy + stop_grace_period: 35s + healthcheck: + test: + - CMD + - wget + - -q + - -O + - /dev/null + - http://127.0.0.1:8080/metrics + interval: 10s + timeout: 5s + retries: 20 + restart: unless-stopped + +volumes: + postgres-data: + chroma-data: + content-data: + upload-quarantine: + model-cache: + ollama-models: diff --git a/desktop/package-lock.json b/desktop/package-lock.json new file mode 100644 index 0000000..b6972e3 --- /dev/null +++ b/desktop/package-lock.json @@ -0,0 +1,232 @@ +{ + "name": "vidxp-desktop", + "version": "0.2.1-b.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vidxp-desktop", + "version": "0.2.1-b.1", + "devDependencies": { + "@tauri-apps/cli": "2.11.4" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz", + "integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.4", + "@tauri-apps/cli-darwin-x64": "2.11.4", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", + "@tauri-apps/cli-linux-arm64-musl": "2.11.4", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-musl": "2.11.4", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", + "@tauri-apps/cli-win32-x64-msvc": "2.11.4" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", + "integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", + "integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", + "integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", + "integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", + "integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", + "integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz", + "integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz", + "integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", + "integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", + "integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", + "integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + } + } +} diff --git a/desktop/package.json b/desktop/package.json new file mode 100644 index 0000000..e83472d --- /dev/null +++ b/desktop/package.json @@ -0,0 +1,20 @@ +{ + "name": "vidxp-desktop", + "private": true, + "version": "0.2.1-b.1", + "type": "module", + "scripts": { + "tauri": "tauri", + "sync:branding": "node scripts/sync-branding.mjs", + "icons": "npm run sync:branding && tauri icon ../docs/images/logo.png --output src-tauri/icons", + "predesktop:dev": "npm run sync:branding", + "desktop:dev": "tauri dev", + "predesktop:build": "npm run icons", + "desktop:build": "node scripts/build-desktop.mjs", + "sidecar:windows": "powershell -ExecutionPolicy Bypass -File scripts/fetch-uv.ps1", + "sidecar:unix": "bash scripts/fetch-uv.sh" + }, + "devDependencies": { + "@tauri-apps/cli": "2.11.4" + } +} diff --git a/desktop/runtime-constraints.txt b/desktop/runtime-constraints.txt new file mode 100644 index 0000000..d564a7c --- /dev/null +++ b/desktop/runtime-constraints.txt @@ -0,0 +1,499 @@ +# This file was autogenerated by uv via the following command: +# uv export --frozen --extra local-worker --extra frontend --no-dev --no-emit-project --no-hashes --format requirements-txt --output-file desktop/runtime-constraints.txt +aiohappyeyeballs==2.7.1 + # via aiohttp +aiohttp==3.14.3 + # via kubernetes +aiosignal==1.4.0 + # via aiohttp +altair==6.2.2 + # via streamlit +annotated-doc==0.0.5 + # via typer +annotated-types==0.8.0 + # via pydantic +anyio==4.14.2 + # via + # httpx + # httpx2 + # openai + # starlette + # streamlit + # watchfiles +attrs==26.1.0 + # via + # aiohttp + # jsonschema + # referencing +av==18.0.0 + # via faster-whisper +bcrypt==5.0.0 + # via chromadb +blinker==1.9.0 + # via streamlit +build==1.5.0 + # via chromadb +certifi==2026.7.22 + # via + # httpcore + # httpx + # kubernetes + # requests +charset-normalizer==3.4.9 + # via requests +chromadb==1.5.9 + # via vidxp +click==8.4.2 + # via + # huggingface-hub + # streamlit + # uvicorn +colorama==0.4.6 ; os_name == 'nt' or sys_platform == 'win32' + # via + # build + # click + # tqdm + # typer +ctranslate2==4.8.1 + # via faster-whisper +dbos==2.28.0 + # via vidxp +distro==1.9.0 + # via openai +durationpy==0.10 + # via kubernetes +faster-whisper==1.2.1 + # via vidxp +filelock==3.32.0 + # via + # huggingface-hub + # torch + # vidxp +flatbuffers==25.12.19 + # via onnxruntime +frozenlist==1.8.0 + # via + # aiohttp + # aiosignal +fsspec==2026.7.0 + # via + # huggingface-hub + # torch +genai-prices==0.0.72 + # via pydantic-ai-slim +gitdb==4.0.12 + # via gitpython +gitpython==3.1.57 + # via streamlit +googleapis-common-protos==1.75.0 + # via opentelemetry-exporter-otlp-proto-grpc +greenlet==3.5.4 + # via sqlalchemy +griffelib==2.1.0 + # via pydantic-ai-slim +grpcio==1.83.0 + # via + # chromadb + # opentelemetry-exporter-otlp-proto-grpc +h11==0.16.0 + # via + # httpcore + # httpcore2 + # uvicorn +hf-xet==1.5.2 ; platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64' + # via huggingface-hub +httpcore==1.0.9 + # via httpx +httpcore2==2.9.1 + # via httpx2 +httptools==0.8.0 + # via + # streamlit + # uvicorn +httpx==0.28.1 + # via + # chromadb + # huggingface-hub + # openai + # pydantic-ai-slim + # pydantic-graph +httpx2==2.9.1 + # via genai-prices +huggingface-hub==1.25.1 + # via + # faster-whisper + # sentence-transformers + # tokenizers + # transformers + # vidxp +idna==3.18 + # via + # anyio + # httpx + # httpx2 + # requests + # yarl +importlib-resources==7.1.0 + # via chromadb +itsdangerous==2.2.0 + # via streamlit +jinja2==3.1.6 + # via + # altair + # pydeck + # torch +jiter==0.16.0 + # via openai +joblib==1.5.3 + # via scikit-learn +jsonschema==4.26.0 + # via + # altair + # chromadb +jsonschema-specifications==2025.9.1 + # via jsonschema +kubernetes==36.0.3 + # via chromadb +logfire-api==4.39.0 + # via pydantic-graph +markdown-it-py==4.2.0 + # via rich +markupsafe==3.0.3 + # via jinja2 +mdurl==0.1.2 + # via markdown-it-py +mmh3==5.2.1 + # via chromadb +mpmath==1.3.0 + # via sympy +multidict==6.7.1 + # via + # aiohttp + # yarl +narwhals==2.24.0 + # via + # altair + # scikit-learn +networkx==3.6.1 + # via torch +numpy==2.4.6 ; python_full_version < '3.12' + # via + # chromadb + # ctranslate2 + # onnxruntime + # opencv-python-headless + # pandas + # pydeck + # scikit-learn + # scipy + # sentence-transformers + # streamlit + # transformers + # vidxp +numpy==2.5.1 ; python_full_version >= '3.12' + # via + # chromadb + # ctranslate2 + # onnxruntime + # opencv-python-headless + # pandas + # pydeck + # scikit-learn + # scipy + # sentence-transformers + # streamlit + # transformers + # vidxp +oauthlib==3.3.1 + # via requests-oauthlib +onnxruntime==1.28.0 + # via + # chromadb + # faster-whisper +openai==2.50.0 + # via pydantic-ai-slim +opencv-python-headless==5.0.0.93 + # via vidxp +opentelemetry-api==1.44.0 + # via + # chromadb + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-sdk + # opentelemetry-semantic-conventions + # pydantic-ai-slim +opentelemetry-exporter-otlp-proto-common==1.44.0 + # via opentelemetry-exporter-otlp-proto-grpc +opentelemetry-exporter-otlp-proto-grpc==1.44.0 + # via chromadb +opentelemetry-proto==1.44.0 + # via + # opentelemetry-exporter-otlp-proto-common + # opentelemetry-exporter-otlp-proto-grpc +opentelemetry-sdk==1.44.0 + # via + # chromadb + # opentelemetry-exporter-otlp-proto-grpc +opentelemetry-semantic-conventions==0.65b0 + # via opentelemetry-sdk +orjson==3.11.9 + # via chromadb +overrides==7.7.0 + # via chromadb +packaging==26.2 + # via + # altair + # build + # huggingface-hub + # onnxruntime + # pooch + # streamlit + # transformers + # vidxp +pandas==3.0.5 + # via streamlit +pillow==12.3.0 + # via + # streamlit + # vidxp +platformdirs==4.11.0 + # via + # pooch + # vidxp +pooch==1.9.0 + # via vidxp +propcache==0.5.2 + # via + # aiohttp + # yarl +protobuf==7.35.1 + # via + # googleapis-common-protos + # onnxruntime + # opentelemetry-proto + # streamlit +psutil==7.2.2 + # via vidxp +psycopg==3.3.4 + # via dbos +psycopg-binary==3.3.4 ; implementation_name != 'pypy' + # via psycopg +pyarrow==24.0.0 + # via streamlit +pybase64==1.4.3 + # via chromadb +pydantic==2.13.4 + # via + # chromadb + # genai-prices + # openai + # pydantic-ai-slim + # pydantic-graph + # pydantic-settings + # vidxp +pydantic-ai-slim==2.20.0 + # via vidxp +pydantic-core==2.46.4 + # via pydantic +pydantic-graph==2.20.0 + # via pydantic-ai-slim +pydantic-settings==2.14.2 + # via + # chromadb + # vidxp +pydeck==0.9.3 + # via streamlit +pygments==2.20.0 + # via rich +pypika==0.51.1 + # via chromadb +pyproject-hooks==1.2.0 + # via build +python-dateutil==2.9.0.post0 + # via + # dbos + # kubernetes + # pandas +python-dotenv==1.2.2 + # via + # pydantic-settings + # uvicorn +python-multipart==0.0.32 + # via streamlit +pyyaml==6.0.3 + # via + # chromadb + # ctranslate2 + # dbos + # huggingface-hub + # kubernetes + # transformers + # uvicorn +referencing==0.37.0 + # via + # jsonschema + # jsonschema-specifications +regex==2026.7.19 + # via + # tiktoken + # transformers +requests==2.34.2 + # via + # kubernetes + # pooch + # requests-oauthlib + # streamlit + # tiktoken +requests-oauthlib==2.0.0 + # via kubernetes +rich==15.0.0 + # via + # chromadb + # typer + # vidxp +rpds-py==2026.6.3 + # via + # jsonschema + # referencing +safetensors==0.8.0 + # via transformers +scikit-learn==1.9.0 + # via sentence-transformers +scipy==1.17.1 ; python_full_version < '3.12' + # via + # scikit-learn + # sentence-transformers +scipy==1.18.0 ; python_full_version >= '3.12' + # via + # scikit-learn + # sentence-transformers +sentence-transformers==5.6.1 + # via vidxp +setuptools==83.0.0 + # via + # ctranslate2 + # torch +shellingham==1.5.4 + # via typer +six==1.17.0 + # via + # kubernetes + # python-dateutil +smmap==5.0.3 + # via gitdb +sniffio==1.3.1 + # via openai +sqlalchemy==2.0.51 + # via + # dbos + # vidxp +starlette==1.3.1 + # via streamlit +streamlit==1.60.0 + # via vidxp +sympy==1.14.0 + # via torch +tenacity==9.1.4 + # via + # chromadb + # streamlit +threadpoolctl==3.6.0 + # via scikit-learn +tiktoken==0.13.0 + # via pydantic-ai-slim +tokenizers==0.22.2 + # via + # chromadb + # faster-whisper + # transformers +toml==0.10.2 + # via streamlit +torch==2.13.0 ; sys_platform != 'linux' and sys_platform != 'win32' + # via + # sentence-transformers + # vidxp +torch==2.13.0+cpu ; sys_platform == 'linux' or sys_platform == 'win32' + # via + # sentence-transformers + # vidxp +tqdm==4.70.0 + # via + # chromadb + # faster-whisper + # huggingface-hub + # openai + # sentence-transformers + # transformers +transformers==5.14.1 + # via + # sentence-transformers + # vidxp +truststore==0.10.4 + # via + # httpcore2 + # httpx2 +typer==0.27.0 + # via + # chromadb + # transformers + # typer-slim + # vidxp +typer-slim==0.24.0 + # via dbos +typing-extensions==4.16.0 + # via + # aiohttp + # aiosignal + # altair + # anyio + # chromadb + # grpcio + # httpx2 + # huggingface-hub + # openai + # opentelemetry-api + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-sdk + # opentelemetry-semantic-conventions + # psycopg + # pydantic + # pydantic-core + # referencing + # sentence-transformers + # sqlalchemy + # starlette + # streamlit + # torch + # typing-inspection +typing-inspection==0.4.2 + # via + # pydantic + # pydantic-ai-slim + # pydantic-graph + # pydantic-settings +tzdata==2026.3 ; sys_platform == 'emscripten' or sys_platform == 'win32' + # via + # pandas + # psycopg +urllib3==2.7.0 + # via + # kubernetes + # requests +uvicorn==0.51.0 + # via + # chromadb + # streamlit +uvloop==0.22.1 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32' + # via uvicorn +watchdog==6.0.0 ; sys_platform != 'darwin' + # via streamlit +watchfiles==1.2.0 + # via uvicorn +websocket-client==1.9.0 + # via kubernetes +websockets==16.1.1 + # via + # dbos + # streamlit + # uvicorn +yarl==1.24.5 + # via aiohttp diff --git a/desktop/runtime-manifest.json b/desktop/runtime-manifest.json new file mode 100644 index 0000000..306c32b --- /dev/null +++ b/desktop/runtime-manifest.json @@ -0,0 +1,43 @@ +{ + "schema_version": 1, + "desktop_version": "0.2.1-b.1", + "package_name": "vidxp", + "package_version": "0.2.1-b.1", + "dependency_index": "https://pypi.org/simple", + "dependency_constraints_sha256": "d3ed16906841952017f112903356bfe433a8515a37aa3f56e4af4dfa1f985835", + "python_version": "3.14.6", + "uv_version": "0.12.0", + "surfaces": { + "browser": { + "extra": "frontend", + "label": "Browser interface", + "description": "Installs the local browser interface. Leave this off for a processing-only runtime.", + "default": true + } + }, + "capabilities": { + "dialogue": { + "extra": "dialogue", + "modality": "dialogue", + "label": "Dialogue search" + }, + "scene": { + "extra": "scene", + "modality": "scene", + "label": "Visual scene search" + }, + "actor": { + "extra": "actor", + "modality": "actor", + "label": "Actor recognition" + } + }, + "media_runtime": { + "strategy": "system", + "executables": [ + "ffmpeg", + "ffprobe" + ], + "reason": "Startup checks verify the system FFmpeg runtime and offer a consented package-manager command; bundling remains disabled until target-specific codec licenses and provenance are recorded." + } +} diff --git a/desktop/scripts/build-desktop.mjs b/desktop/scripts/build-desktop.mjs new file mode 100644 index 0000000..4cc137a --- /dev/null +++ b/desktop/scripts/build-desktop.mjs @@ -0,0 +1,32 @@ +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const bundles = { + darwin: "dmg", + linux: "appimage", + win32: "nsis", +}; + +const bundle = bundles[process.platform]; +if (!bundle) { + throw new Error(`Unsupported desktop build platform: ${process.platform}`); +} + +const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const executable = resolve( + desktopRoot, + "node_modules", + ".bin", + process.platform === "win32" ? "tauri.cmd" : "tauri", +); +const result = spawnSync( + executable, + ["build", "--bundles", bundle, "--ci", "--no-sign", "--", "--locked"], + { shell: process.platform === "win32", stdio: "inherit" }, +); + +if (result.error) { + throw result.error; +} +process.exit(result.status ?? 1); diff --git a/desktop/scripts/fetch-uv.ps1 b/desktop/scripts/fetch-uv.ps1 new file mode 100644 index 0000000..1549a05 --- /dev/null +++ b/desktop/scripts/fetch-uv.ps1 @@ -0,0 +1,59 @@ +$ErrorActionPreference = "Stop" + +$scriptRoot = Split-Path -Parent $PSScriptRoot +$lock = Get-Content -Raw (Join-Path $scriptRoot "sidecars.json") | + ConvertFrom-Json +$version = $lock.uv_version +$architecture = $env:PROCESSOR_ARCHITECTURE +if ($architecture -ne "AMD64") { + throw "The first Windows desktop target supports x86-64 only (found $architecture)." +} + +$target = "x86_64-pc-windows-msvc" +$targetLock = $lock.targets.$target +$archiveName = $targetLock.archive +$lockedChecksum = $targetLock.sha256.ToLowerInvariant() +$releaseRoot = "https://github.com/astral-sh/uv/releases/download/$version" +$binaryDirectory = Join-Path $scriptRoot "src-tauri\binaries" +$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("vidxp-uv-" + [guid]::NewGuid()) +$resolvedTempBase = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()) +$resolvedTemporaryRoot = [System.IO.Path]::GetFullPath($temporaryRoot) +if (-not $resolvedTemporaryRoot.StartsWith($resolvedTempBase, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to use a temporary directory outside the system temporary root." +} + +New-Item -ItemType Directory -Path $temporaryRoot | Out-Null +New-Item -ItemType Directory -Force -Path $binaryDirectory | Out-Null +try { + $archive = Join-Path $temporaryRoot $archiveName + Invoke-WebRequest -Uri "$releaseRoot/$archiveName" -OutFile $archive + $stream = [System.IO.File]::OpenRead($archive) + try { + $hasher = [System.Security.Cryptography.SHA256]::Create() + $actual = [System.BitConverter]::ToString( + $hasher.ComputeHash($stream) + ).Replace("-", "").ToLowerInvariant() + } + finally { + $stream.Dispose() + } + if ($actual -ne $lockedChecksum) { + throw "The uv archive checksum did not match desktop/sidecars.json." + } + + $expanded = Join-Path $temporaryRoot "expanded" + Expand-Archive -LiteralPath $archive -DestinationPath $expanded + $source = Get-ChildItem -LiteralPath $expanded -Recurse -File -Filter "uv.exe" | + Select-Object -First 1 + if ($null -eq $source) { + throw "The uv release archive did not contain uv.exe." + } + Copy-Item -LiteralPath $source.FullName -Destination ( + Join-Path $binaryDirectory "uv-$target.exe" + ) +} +finally { + if (Test-Path -LiteralPath $resolvedTemporaryRoot) { + Remove-Item -LiteralPath $resolvedTemporaryRoot -Recurse -Force + } +} diff --git a/desktop/scripts/fetch-uv.sh b/desktop/scripts/fetch-uv.sh new file mode 100644 index 0000000..e751452 --- /dev/null +++ b/desktop/scripts/fetch-uv.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +case "$(uname -s):$(uname -m)" in + Darwin:arm64) target="aarch64-apple-darwin" ;; + Linux:x86_64) target="x86_64-unknown-linux-gnu" ;; + *) + echo "The first Unix desktop targets are macOS arm64 and Linux x86-64." >&2 + exit 1 + ;; +esac + +script_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +version="$(node -p "require('${script_root}/sidecars.json').uv_version")" +archive_name="$(node -p "require('${script_root}/sidecars.json').targets['${target}'].archive")" +locked_checksum="$(node -p "require('${script_root}/sidecars.json').targets['${target}'].sha256")" +release_root="https://github.com/astral-sh/uv/releases/download/${version}" +binary_directory="${script_root}/src-tauri/binaries" +temporary_root="$(mktemp -d "${TMPDIR:-/tmp}/vidxp-uv.XXXXXXXX")" +trap 'rm -rf -- "$temporary_root"' EXIT + +mkdir -p "$binary_directory" +curl --fail --location --silent --show-error \ + "${release_root}/${archive_name}" \ + --output "${temporary_root}/${archive_name}" +if command -v sha256sum >/dev/null 2>&1; then + actual_checksum="$(sha256sum "${temporary_root}/${archive_name}" | awk '{print $1}')" +else + actual_checksum="$(shasum -a 256 "${temporary_root}/${archive_name}" | awk '{print $1}')" +fi +test "$actual_checksum" = "$locked_checksum" + +tar -xzf "${temporary_root}/${archive_name}" -C "$temporary_root" +uv_path="$(find "$temporary_root" -type f -name uv -print -quit)" +test -n "$uv_path" +install -m 0755 "$uv_path" "${binary_directory}/uv-${target}" diff --git a/desktop/scripts/sync-branding.mjs b/desktop/scripts/sync-branding.mjs new file mode 100644 index 0000000..218f170 --- /dev/null +++ b/desktop/scripts/sync-branding.mjs @@ -0,0 +1,10 @@ +import { copyFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const source = resolve(desktopRoot, "../docs/images/logo.png"); +const favicon = resolve(desktopRoot, "web/icon.png"); + +copyFileSync(source, favicon); +console.log("Synced the VidXP desktop favicon from the shared icon."); diff --git a/desktop/sidecars.json b/desktop/sidecars.json new file mode 100644 index 0000000..a16bb0b --- /dev/null +++ b/desktop/sidecars.json @@ -0,0 +1,17 @@ +{ + "uv_version": "0.12.0", + "targets": { + "x86_64-pc-windows-msvc": { + "archive": "uv-x86_64-pc-windows-msvc.zip", + "sha256": "68200e25de594df92387186bbfb9d9df606ec1d87efaa0ae0c7f690970e53db6" + }, + "aarch64-apple-darwin": { + "archive": "uv-aarch64-apple-darwin.tar.gz", + "sha256": "2b9e582af54f84fa50c115427451a6c13e80f43b52f8282b8af5791077317bbf" + }, + "x86_64-unknown-linux-gnu": { + "archive": "uv-x86_64-unknown-linux-gnu.tar.gz", + "sha256": "eaf842262aa1c418d8ecc5605f02ee1ebfd369124fa48548e85f9481a47831a9" + } + } +} diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock new file mode 100644 index 0000000..306e984 --- /dev/null +++ b/desktop/src-tauri/Cargo.lock @@ -0,0 +1,5344 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" +dependencies = [ + "android_log-sys", + "env_filter", + "log", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atomic-write-file" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84790c55b5704b0d35130bf16a4ce22a8e70eb0ea773522557524d9a4852663d" +dependencies = [ + "nix", + "rand", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.19", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.4+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fern" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29" +dependencies = [ + "log", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.19", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.19", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "shared_child" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" +dependencies = [ + "libc", + "sigchld", + "windows-sys 0.60.2", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "sigchld" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" +dependencies = [ + "libc", + "os_pipe", + "signal-hook", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.1", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.19", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2 0.10.9", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.19", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.19", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.19", + "toml 1.1.4+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-plugin-log" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6792296e6f389268016c77db21ebae1fc0568f2fccf88b1ec7e2ea71330afb4c" +dependencies = [ + "android_logger", + "fern", + "log", + "objc2", + "objc2-foundation", + "serde", + "serde_json", + "serde_repr", + "swift-rs", + "tauri", + "tauri-plugin", + "thiserror 2.0.19", + "time", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.19", + "url", + "windows", + "zbus", +] + +[[package]] +name = "tauri-plugin-shell" +version = "2.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b" +dependencies = [ + "encoding_rs", + "log", + "open", + "os_pipe", + "regex", + "schemars 0.8.22", + "serde", + "serde_json", + "shared_child", + "tauri", + "tauri-plugin", + "thiserror 2.0.19", + "tokio", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.19", + "tokio", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.19", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.19", + "toml 1.1.4+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.4+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +dependencies = [ + "deranged", + "itoa", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" + +[[package]] +name = "time-macros" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.19", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vidxp-desktop" +version = "0.2.1-b.1" +dependencies = [ + "atomic-write-file", + "hex", + "log", + "serde", + "serde_json", + "sha2 0.11.0", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-log", + "tauri-plugin-opener", + "tauri-plugin-shell", + "tauri-plugin-single-instance", + "wait-timeout", +] + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.19", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2 0.10.9", + "soup3", + "tao-macros", + "thiserror 2.0.19", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.15", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow 0.7.15", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 0.7.15", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.119", + "winnow 0.7.15", +] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml new file mode 100644 index 0000000..12c9696 --- /dev/null +++ b/desktop/src-tauri/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "vidxp-desktop" +version = "0.2.1-b.1" +description = "The VidXP desktop launcher and local runtime supervisor" +edition = "2024" +rust-version = "1.85" +license = "MIT" + +[lib] +name = "vidxp_desktop_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +serde_json = "1.0.151" +tauri-build = { version = "2.6.3", features = [] } + +[dependencies] +atomic-write-file = "0.3.0" +hex = "0.4.3" +log = "0.4.29" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +sha2 = "0.11.0" +tauri = { version = "2.11.5", features = ["tray-icon"] } +tauri-plugin-log = "2.9.0" +tauri-plugin-dialog = "2.6.0" +tauri-plugin-opener = "2.5.0" +tauri-plugin-shell = "2.3.5" +tauri-plugin-single-instance = "2.4.3" +wait-timeout = "0.2.1" diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs new file mode 100644 index 0000000..ae6d059 --- /dev/null +++ b/desktop/src-tauri/build.rs @@ -0,0 +1,33 @@ +fn main() { + let manifest: serde_json::Value = + serde_json::from_slice(include_bytes!("../runtime-manifest.json")) + .expect("desktop/runtime-manifest.json must be valid JSON"); + let expected = manifest["uv_version"] + .as_str() + .expect("runtime manifest must contain uv_version"); + let target = std::env::var("TARGET").expect("Cargo must provide TARGET"); + let suffix = if target.contains("windows") { + ".exe" + } else { + "" + }; + let sidecar = std::path::PathBuf::from("binaries").join(format!("uv-{target}{suffix}")); + let output = std::process::Command::new(&sidecar) + .arg("--version") + .output() + .unwrap_or_else(|error| { + panic!( + "{} is missing or unusable; run the target sidecar fetch script: {error}", + sidecar.display() + ) + }); + let actual = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success() && actual.starts_with(&format!("uv {expected}")), + "{} reports {:?}; expected uv {}", + sidecar.display(), + actual.trim(), + expected + ); + tauri_build::build() +} diff --git a/desktop/src-tauri/capabilities/main.json b/desktop/src-tauri/capabilities/main.json new file mode 100644 index 0000000..f3f4ca4 --- /dev/null +++ b/desktop/src-tauri/capabilities/main.json @@ -0,0 +1,9 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "main-window", + "description": "The bundled setup interface; no shell or filesystem API is exposed.", + "windows": [ + "main" + ], + "permissions": [] +} diff --git a/desktop/src-tauri/icons/128x128.png b/desktop/src-tauri/icons/128x128.png new file mode 100644 index 0000000..07e4996 Binary files /dev/null and b/desktop/src-tauri/icons/128x128.png differ diff --git a/desktop/src-tauri/icons/128x128@2x.png b/desktop/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..ebab038 Binary files /dev/null and b/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/desktop/src-tauri/icons/32x32.png b/desktop/src-tauri/icons/32x32.png new file mode 100644 index 0000000..28037b4 Binary files /dev/null and b/desktop/src-tauri/icons/32x32.png differ diff --git a/desktop/src-tauri/icons/icon.icns b/desktop/src-tauri/icons/icon.icns new file mode 100644 index 0000000..ddcc711 Binary files /dev/null and b/desktop/src-tauri/icons/icon.icns differ diff --git a/desktop/src-tauri/icons/icon.ico b/desktop/src-tauri/icons/icon.ico new file mode 100644 index 0000000..e78d13a Binary files /dev/null and b/desktop/src-tauri/icons/icon.ico differ diff --git a/desktop/src-tauri/icons/icon.png b/desktop/src-tauri/icons/icon.png new file mode 100644 index 0000000..aae1a36 Binary files /dev/null and b/desktop/src-tauri/icons/icon.png differ diff --git a/desktop/src-tauri/nsis-hooks.nsh b/desktop/src-tauri/nsis-hooks.nsh new file mode 100644 index 0000000..e0bf9c3 --- /dev/null +++ b/desktop/src-tauri/nsis-hooks.nsh @@ -0,0 +1,5 @@ +!macro NSIS_HOOK_PREINSTALL + ; Keep per-user program files separate from VidXP's shared models and indexes. + StrCpy $INSTDIR "$LOCALAPPDATA\Programs\${PRODUCTNAME}" + SetOutPath "$INSTDIR" +!macroend diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs new file mode 100644 index 0000000..c831e8d --- /dev/null +++ b/desktop/src-tauri/src/lib.rs @@ -0,0 +1,1700 @@ +use std::{ + borrow::Cow, + collections::{BTreeMap, BTreeSet}, + env, fs, + io::{self, Write}, + net::{SocketAddr, TcpListener, TcpStream}, + path::{Path, PathBuf}, + process::{Child, Command, Output, Stdio}, + sync::{ + Mutex, + atomic::{AtomicBool, Ordering}, + }, + thread, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +use atomic_write_file::AtomicWriteFile; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tauri::{ + AppHandle, Manager, RunEvent, WindowEvent, + menu::{Menu, MenuItem}, + tray::TrayIconBuilder, +}; +use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; +use tauri_plugin_opener::OpenerExt; +use tauri_plugin_shell::{ + ShellExt, + process::{Command as ShellCommand, CommandChild, CommandEvent}, +}; +use wait_timeout::ChildExt; + +const RUNTIME_MANIFEST_BYTES: &[u8] = include_bytes!("../../runtime-manifest.json"); +const RUNTIME_CONSTRAINTS_BYTES: &[u8] = include_bytes!("../../runtime-constraints.txt"); +const PRODUCT_DATA_DIRECTORY_NAME: &str = "VidXP"; + +#[derive(Clone, Deserialize, Serialize)] +struct CapabilitySpec { + extra: String, + modality: String, + label: String, +} + +#[derive(Clone, Deserialize, Serialize)] +struct SurfaceSpec { + extra: String, + label: String, + description: String, + default: bool, +} + +#[derive(Clone, Deserialize, Serialize)] +struct MediaRuntimeSpec { + strategy: String, + executables: Vec, + reason: String, +} + +#[derive(Clone, Deserialize, Serialize)] +struct RuntimeManifest { + schema_version: u32, + desktop_version: String, + package_name: String, + package_version: String, + dependency_index: String, + dependency_constraints_sha256: String, + python_version: String, + uv_version: String, + surfaces: BTreeMap, + capabilities: BTreeMap, + media_runtime: MediaRuntimeSpec, +} + +#[derive(Deserialize)] +struct InstallRequest { + capabilities: Vec, + surfaces: Vec, + prepare_models: bool, + model_directory: Option, +} + +#[derive(Serialize)] +struct InstallResult { + package_version: String, + capabilities: Vec, + surfaces: Vec, + model_directory: String, + prepared: bool, +} + +#[derive(Serialize)] +struct RuntimeStatus { + ready: bool, + package_version: String, + capabilities: Vec, + surfaces: Vec, + model_directory: String, + detail: String, +} + +#[derive(Clone, Serialize)] +struct MediaRuntimeStatus { + ready: bool, + ffmpeg_executable: Option, + ffprobe_executable: Option, + required_encoders: Vec, + errors: Vec, + package_manager: Option, + install_command: Option, + automatic_install: bool, +} + +struct VerifiedMediaRuntime { + ffmpeg: PathBuf, + ffprobe: PathBuf, +} + +struct SystemInstallPlan { + manager: String, + command: Vec, + automatic: bool, +} + +#[derive(Clone, Deserialize, Serialize)] +struct ActiveRuntime { + schema_version: u32, + manifest_sha256: String, + profile: String, + package_version: String, + capabilities: Vec, + #[serde(default)] + surfaces: Vec, + #[serde(default)] + model_directory: PathBuf, +} + +struct DesktopPaths { + private_data: PathBuf, + data: PathBuf, + cache: PathBuf, + repository: PathBuf, + runtimes: PathBuf, + python: PathBuf, + models: PathBuf, + active_runtime: PathBuf, +} + +struct ManagedUi { + process: Child, + url: String, +} + +struct DesktopState { + ui_process: Mutex>, + operation_process: Mutex>, + operation_worker_runtime: Mutex>, + operation_active: AtomicBool, + shutdown_started: AtomicBool, +} + +impl Default for DesktopState { + fn default() -> Self { + Self { + ui_process: Mutex::new(None), + operation_process: Mutex::new(None), + operation_worker_runtime: Mutex::new(None), + operation_active: AtomicBool::new(false), + shutdown_started: AtomicBool::new(false), + } + } +} + +struct OperationGuard<'a> { + active: &'a AtomicBool, +} + +impl Drop for OperationGuard<'_> { + fn drop(&mut self) { + self.active.store(false, Ordering::Release); + } +} + +fn manifest() -> Result { + let manifest: RuntimeManifest = serde_json::from_slice(RUNTIME_MANIFEST_BYTES) + .map_err(|error| format!("The embedded runtime manifest is invalid: {error}"))?; + let actual = hex::encode(Sha256::digest(normalized_runtime_constraints().as_ref())); + if actual != manifest.dependency_constraints_sha256 { + return Err(format!( + "The embedded runtime constraints have digest {actual}; expected {}.", + manifest.dependency_constraints_sha256 + )); + } + Ok(manifest) +} + +fn normalize_line_endings(bytes: &[u8]) -> Cow<'_, [u8]> { + if !bytes.windows(2).any(|pair| pair == b"\r\n") { + return Cow::Borrowed(bytes); + } + + let mut normalized = Vec::with_capacity(bytes.len()); + let mut offset = 0; + while offset < bytes.len() { + if bytes[offset..].starts_with(b"\r\n") { + normalized.push(b'\n'); + offset += 2; + } else { + normalized.push(bytes[offset]); + offset += 1; + } + } + Cow::Owned(normalized) +} + +fn normalized_runtime_constraints() -> Cow<'static, [u8]> { + normalize_line_endings(RUNTIME_CONSTRAINTS_BYTES) +} + +fn manifest_digest() -> String { + hex::encode(Sha256::digest(RUNTIME_MANIFEST_BYTES)) +} + +fn desktop_paths(app: &AppHandle) -> Result { + let private_data = app.path().app_local_data_dir().map_err(|error| { + format!("Could not resolve the private application data directory: {error}") + })?; + let local_data = app.path().local_data_dir().map_err(|error| { + format!("Could not resolve the operating-system data directory: {error}") + })?; + let cache = app + .path() + .app_cache_dir() + .map_err(|error| format!("Could not resolve the application cache directory: {error}"))?; + Ok(desktop_paths_from_roots(&private_data, &cache, &local_data)) +} + +fn desktop_paths_from_roots(private_data: &Path, cache: &Path, local_data: &Path) -> DesktopPaths { + let data = local_data.join(PRODUCT_DATA_DIRECTORY_NAME); + DesktopPaths { + repository: data.join("repositories").join("default"), + runtimes: private_data.join("runtimes"), + python: private_data.join("python"), + active_runtime: private_data.join("active-runtime.json"), + models: data.join("models"), + private_data: private_data.to_path_buf(), + data, + cache: cache.to_path_buf(), + } +} + +fn move_legacy_shared_directory(source: &Path, destination: &Path) -> Result { + if !source.exists() { + return Ok(false); + } + if destination.exists() { + log::warn!( + "Leaving legacy VidXP data at {} because {} already exists", + source.display(), + destination.display() + ); + return Ok(false); + } + let parent = destination + .parent() + .ok_or_else(|| format!("{} has no parent directory", destination.display()))?; + fs::create_dir_all(parent) + .map_err(|error| format!("Could not create {}: {error}", parent.display()))?; + fs::rename(source, destination).map_err(|error| { + format!( + "Could not move legacy VidXP data from {} to {}: {error}", + source.display(), + destination.display() + ) + })?; + Ok(true) +} + +fn migrate_legacy_shared_data(app: &AppHandle) -> Result<(), String> { + let paths = desktop_paths(app)?; + let legacy_models = paths.private_data.join("models"); + let legacy_repositories = paths.private_data.join("repositories"); + let shared_repositories = paths + .repository + .parent() + .expect("the default repository always has a parent"); + let moved_models = move_legacy_shared_directory(&legacy_models, &paths.models)?; + move_legacy_shared_directory(&legacy_repositories, shared_repositories)?; + + if moved_models && paths.active_runtime.exists() { + let contents = fs::read(&paths.active_runtime).map_err(|error| { + format!("Could not read the active runtime during migration: {error}") + })?; + let mut active: ActiveRuntime = serde_json::from_slice(&contents) + .map_err(|error| format!("The active runtime pointer is invalid: {error}"))?; + if active.model_directory == legacy_models { + active.model_directory = paths.models.clone(); + write_active_runtime(&paths, &active)?; + } + } + Ok(()) +} + +fn model_directory(paths: &DesktopPaths, requested: Option<&str>) -> Result { + let Some(requested) = requested.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(paths.models.clone()); + }; + let directory = PathBuf::from(requested); + if !directory.is_absolute() { + return Err("The model directory must be an absolute path.".into()); + } + if directory.is_file() { + return Err("The selected model location is a file, not a directory.".into()); + } + Ok(directory) +} + +fn selected_capabilities( + manifest: &RuntimeManifest, + requested: &[String], +) -> Result, String> { + let selected: BTreeSet<_> = requested.iter().cloned().collect(); + if selected.is_empty() { + return Err("Select at least one capability.".into()); + } + if let Some(unknown) = selected + .iter() + .find(|name| !manifest.capabilities.contains_key(*name)) + { + return Err(format!("Unknown desktop capability: {unknown}")); + } + Ok(selected.into_iter().collect()) +} + +fn selected_surfaces( + manifest: &RuntimeManifest, + requested: &[String], +) -> Result, String> { + let selected: BTreeSet<_> = requested.iter().cloned().collect(); + if let Some(unknown) = selected + .iter() + .find(|name| !manifest.surfaces.contains_key(*name)) + { + return Err(format!("Unknown desktop surface: {unknown}")); + } + Ok(selected.into_iter().collect()) +} + +fn package_specification( + manifest: &RuntimeManifest, + capabilities: &[String], + surfaces: &[String], +) -> String { + let extras: BTreeSet<_> = manifest + .surfaces + .iter() + .filter(|(name, _)| surfaces.contains(name)) + .map(|(_, surface)| surface.extra.clone()) + .chain( + capabilities + .iter() + .map(|name| manifest.capabilities[name].extra.clone()), + ) + .collect(); + format!( + "{}[{}]=={}", + manifest.package_name, + extras.into_iter().collect::>().join(","), + manifest.package_version + ) +} + +fn base_package_specification(manifest: &RuntimeManifest) -> String { + format!("{}=={}", manifest.package_name, manifest.package_version) +} + +fn package_index(package_version: &str) -> &'static str { + if package_version.split_once('-').is_some() { + "https://test.pypi.org/simple" + } else { + "https://pypi.org/simple" + } +} + +fn package_acquisition_arguments(manifest: &RuntimeManifest, python: &Path) -> Vec { + vec![ + "pip".into(), + "install".into(), + "--python".into(), + python.to_string_lossy().into_owned(), + "--no-config".into(), + "--no-deps".into(), + "--default-index".into(), + package_index(&manifest.package_version).into(), + "--index-strategy".into(), + "first-index".into(), + base_package_specification(manifest), + ] +} + +fn dependency_installation_arguments( + manifest: &RuntimeManifest, + capabilities: &[String], + surfaces: &[String], + python: &Path, + constraints: &Path, + cpu_torch: bool, +) -> Vec { + let mut arguments = vec![ + "pip".into(), + "install".into(), + "--python".into(), + python.to_string_lossy().into_owned(), + "--no-config".into(), + "--default-index".into(), + manifest.dependency_index.clone(), + "--index-strategy".into(), + "first-index".into(), + "--constraints".into(), + constraints.to_string_lossy().into_owned(), + ]; + if cpu_torch { + arguments.extend(["--torch-backend".into(), "cpu".into()]); + } + arguments.push(package_specification(manifest, capabilities, surfaces)); + arguments +} + +fn capability_command_arguments( + manifest: &RuntimeManifest, + operation: &str, + capabilities: &[String], +) -> Vec { + let modalities = capabilities + .iter() + .map(|name| manifest.capabilities[name].modality.as_str()) + .collect::>() + .join(","); + vec![ + operation.into(), + "--json".into(), + "--modalities".into(), + modalities, + ] +} + +fn executable(runtime: &Path, name: &str) -> PathBuf { + if cfg!(windows) { + runtime.join("Scripts").join(format!("{name}.exe")) + } else { + runtime.join("bin").join(name) + } +} + +fn executable_candidates(name: &str) -> Vec { + let requested = PathBuf::from(name); + if requested.is_absolute() || requested.components().count() > 1 { + return vec![requested]; + } + let mut directories = env::var_os("PATH") + .map(|value| env::split_paths(&value).collect::>()) + .unwrap_or_default(); + if cfg!(windows) { + if let Some(local) = env::var_os("LOCALAPPDATA") { + directories.push( + PathBuf::from(local) + .join("Microsoft") + .join("WinGet") + .join("Links"), + ); + } + } + if cfg!(target_os = "macos") { + directories.extend([ + PathBuf::from("/opt/homebrew/bin"), + PathBuf::from("/usr/local/bin"), + ]); + } else if !cfg!(windows) { + directories.extend([ + PathBuf::from("/usr/local/bin"), + PathBuf::from("/usr/bin"), + PathBuf::from("/snap/bin"), + ]); + } + directories + .into_iter() + .flat_map(|directory| { + let plain = directory.join(name); + if cfg!(windows) && plain.extension().is_none() { + vec![plain.with_extension("exe"), plain] + } else { + vec![plain] + } + }) + .collect() +} + +fn resolve_system_executable(name: &str) -> Option { + executable_candidates(name) + .into_iter() + .find(|candidate| candidate.is_file()) + .and_then(|candidate| fs::canonicalize(&candidate).ok().or(Some(candidate))) +} + +fn combined_output(output: &Output) -> String { + format!( + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) +} + +fn required_encoder_missing(output: &str, encoder: &str) -> bool { + !output + .lines() + .flat_map(|line| line.split_whitespace()) + .any(|token| token == encoder) +} + +fn system_install_plan() -> Option { + if cfg!(windows) { + resolve_system_executable("winget")?; + return Some(SystemInstallPlan { + manager: "Windows Package Manager".into(), + command: vec![ + "winget".into(), + "install".into(), + "--id".into(), + "Gyan.FFmpeg".into(), + "--exact".into(), + "--source".into(), + "winget".into(), + "--accept-package-agreements".into(), + "--accept-source-agreements".into(), + ], + automatic: true, + }); + } + if cfg!(target_os = "macos") { + let brew = resolve_system_executable("brew")?; + return Some(SystemInstallPlan { + manager: "Homebrew".into(), + command: vec![ + brew.to_string_lossy().into_owned(), + "install".into(), + "ffmpeg".into(), + ], + automatic: true, + }); + } + if resolve_system_executable("apt-get").is_some() { + return Some(SystemInstallPlan { + manager: "APT".into(), + command: vec![ + "sudo".into(), + "apt-get".into(), + "install".into(), + "ffmpeg".into(), + ], + automatic: false, + }); + } + if resolve_system_executable("dnf").is_some() { + return Some(SystemInstallPlan { + manager: "DNF".into(), + command: vec![ + "sudo".into(), + "dnf".into(), + "install".into(), + "ffmpeg".into(), + ], + automatic: false, + }); + } + None +} + +fn display_command(arguments: &[String]) -> String { + arguments + .iter() + .map(|argument| { + if argument.contains(char::is_whitespace) { + format!("\"{}\"", argument.replace('"', "\\\"")) + } else { + argument.clone() + } + }) + .collect::>() + .join(" ") +} + +fn inspect_media_runtime() -> MediaRuntimeStatus { + let ffmpeg = resolve_system_executable("ffmpeg"); + let ffprobe = resolve_system_executable("ffprobe"); + let mut errors = Vec::new(); + if let Some(path) = &ffmpeg { + let mut version_command = Command::new(path); + version_command.arg("-version"); + let version = checked_output(version_command, "FFmpeg version check"); + if let Err(error) = version { + errors.push(error); + } else { + let mut encoder_command = Command::new(path); + encoder_command.args(["-hide_banner", "-encoders"]); + match checked_output(encoder_command, "FFmpeg encoder check") { + Ok(output) => { + let encoders = combined_output(&output); + for required in ["libx264", "aac"] { + if required_encoder_missing(&encoders, required) { + errors.push(format!( + "FFmpeg is missing the required {required} encoder." + )); + } + } + } + Err(error) => errors.push(error), + } + } + } else { + errors.push("FFmpeg was not found.".into()); + } + if let Some(path) = &ffprobe { + let mut probe_command = Command::new(path); + probe_command.arg("-version"); + if let Err(error) = checked_output(probe_command, "ffprobe version check") { + errors.push(error); + } + } else { + errors.push("ffprobe was not found.".into()); + } + let plan = if errors.is_empty() { + None + } else { + system_install_plan() + }; + MediaRuntimeStatus { + ready: errors.is_empty(), + ffmpeg_executable: ffmpeg.map(|path| path.to_string_lossy().into_owned()), + ffprobe_executable: ffprobe.map(|path| path.to_string_lossy().into_owned()), + required_encoders: vec!["libx264".into(), "aac".into()], + errors, + package_manager: plan.as_ref().map(|plan| plan.manager.clone()), + install_command: plan.as_ref().map(|plan| display_command(&plan.command)), + automatic_install: plan.is_some_and(|plan| plan.automatic), + } +} + +fn verified_media_runtime() -> Result { + let status = inspect_media_runtime(); + if !status.ready { + return Err(format!( + "{} Run the guided FFmpeg setup, then retry.", + status.errors.join(" ") + )); + } + Ok(VerifiedMediaRuntime { + ffmpeg: PathBuf::from( + status + .ffmpeg_executable + .ok_or("FFmpeg did not resolve to an absolute path.")?, + ), + ffprobe: PathBuf::from( + status + .ffprobe_executable + .ok_or("ffprobe did not resolve to an absolute path.")?, + ), + }) +} + +fn clean_environment(paths: &DesktopPaths) -> Vec<(String, String)> { + let mut environment: Vec<_> = std::env::vars() + .filter(|(key, _)| { + let upper = key.to_ascii_uppercase(); + !upper.starts_with("VIDXP_") + && !upper.starts_with("DBOS_") + && !upper.starts_with("UV_") + && !upper.starts_with("STREAMLIT_") + && !upper.starts_with("PYTHON") + && !upper.starts_with("PYENV_") + && !upper.starts_with("PIP_") + && !upper.starts_with("CONDA") + && upper != "VIRTUAL_ENV" + && upper != "VIRTUAL_ENV_PROMPT" + && upper != "_OLD_VIRTUAL_PATH" + }) + .collect(); + environment.extend([ + ( + "VIDXP_DATA_DIR".into(), + paths.data.to_string_lossy().into_owned(), + ), + ( + "VIDXP_MODEL_CACHE".into(), + paths.models.to_string_lossy().into_owned(), + ), + ("VIDXP_ALLOW_MODEL_DOWNLOADS".into(), "true".into()), + ("STREAMLIT_SERVER_HEADLESS".into(), "true".into()), + ( + "STREAMLIT_BROWSER_GATHER_USAGE_STATS".into(), + "false".into(), + ), + ]); + environment +} + +fn configured_command(executable_path: &Path, paths: &DesktopPaths) -> Command { + let mut command = Command::new(executable_path); + hide_child_console(&mut command); + command.env_clear(); + command.envs(clean_environment(paths)); + command +} + +#[cfg(windows)] +fn hide_child_console(command: &mut Command) { + use std::os::windows::process::CommandExt; + + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); +} + +#[cfg(not(windows))] +fn hide_child_console(_command: &mut Command) {} + +fn checked_output(mut command: Command, operation: &str) -> Result { + hide_child_console(&mut command); + let output = command + .output() + .map_err(|error| format!("{operation} could not start: {error}"))?; + if output.status.success() { + return Ok(output); + } + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + let detail = if stderr.is_empty() { stdout } else { stderr }; + Err(format!("{operation} failed: {detail}")) +} + +fn active_runtime(paths: &DesktopPaths) -> Result { + let contents = fs::read(&paths.active_runtime) + .map_err(|_| "Local video processing has not been configured yet.".to_string())?; + let active: ActiveRuntime = serde_json::from_slice(&contents) + .map_err(|error| format!("The active runtime pointer is invalid: {error}"))?; + if active.schema_version != 2 || active.manifest_sha256 != manifest_digest() { + return Err("The desktop runtime needs to be installed for this app version.".into()); + } + if !active + .profile + .chars() + .all(|character| character.is_ascii_hexdigit() || character == '-') + { + return Err("The active runtime profile identity is invalid.".into()); + } + Ok(active) +} + +fn runtime_directory(paths: &DesktopPaths, active: &ActiveRuntime) -> PathBuf { + paths.runtimes.join(&active.profile) +} + +fn write_active_runtime(paths: &DesktopPaths, active: &ActiveRuntime) -> Result<(), String> { + let mut destination = AtomicWriteFile::options() + .open(&paths.active_runtime) + .map_err(|error| format!("Could not stage the active runtime pointer: {error}"))?; + serde_json::to_writer(&mut destination, active) + .map_err(|error| format!("Could not serialize the active runtime pointer: {error}"))?; + destination + .flush() + .and_then(|_| destination.commit()) + .map_err(|error| format!("Could not activate the validated runtime: {error}")) +} + +async fn supervised_output( + state: &DesktopState, + command: ShellCommand, + operation: &str, +) -> Result<(Vec, Vec), String> { + if state.shutdown_started.load(Ordering::Acquire) { + return Err(format!( + "{operation} was cancelled because VidXP is closing." + )); + } + let (mut events, child) = command + .spawn() + .map_err(|error| format!("{operation} could not start: {error}"))?; + { + let mut active_child = state + .operation_process + .lock() + .map_err(|_| "The setup process supervisor is unavailable.".to_string())?; + if active_child.is_some() { + drop(active_child); + let _ = child.kill(); + return Err("Another desktop setup process is already running.".into()); + } + *active_child = Some(child); + } + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let mut exit_code = None; + while let Some(event) = events.recv().await { + match event { + CommandEvent::Stdout(bytes) => stdout.extend(bytes), + CommandEvent::Stderr(bytes) => stderr.extend(bytes), + CommandEvent::Error(error) => stderr.extend(error.as_bytes()), + CommandEvent::Terminated(status) => { + exit_code = status.code; + break; + } + _ => {} + } + } + if let Ok(mut active_child) = state.operation_process.lock() { + active_child.take(); + } + if exit_code == Some(0) { + return Ok((stdout, stderr)); + } + let detail = String::from_utf8_lossy(&stderr).trim().to_owned(); + Err(format!( + "{operation} failed{}: {detail}", + exit_code.map_or_else(String::new, |code| format!(" with exit code {code}")) + )) +} + +async fn uv_output( + app: &AppHandle, + state: &DesktopState, + paths: &DesktopPaths, + arguments: Vec, + operation: &str, +) -> Result<(), String> { + let mut command = app + .shell() + .sidecar("uv") + .map_err(|error| format!("The bundled uv sidecar is unavailable: {error}"))? + .args(arguments) + .env_clear(); + for (key, value) in clean_environment(paths) { + command = command.env(key, value); + } + command = command + .env("UV_CACHE_DIR", paths.cache.join("uv")) + .env("UV_PYTHON_INSTALL_DIR", &paths.python) + .env("UV_NO_CONFIG", "1") + .env("UV_MANAGED_PYTHON", "1"); + supervised_output(state, command, operation).await?; + Ok(()) +} + +fn run_vidxp( + runtime: &Path, + paths: &DesktopPaths, + arguments: &[String], + operation: &str, +) -> Result { + let mut command = configured_command(&executable(runtime, "vidxp"), paths); + command + .arg("--index-dir") + .arg(&paths.repository) + .args(arguments); + checked_output(command, operation) +} + +async fn run_vidxp_supervised( + app: &AppHandle, + state: &DesktopState, + runtime: &Path, + paths: &DesktopPaths, + arguments: &[String], + operation: &str, +) -> Result<(), String> { + let mut command = app + .shell() + .command(executable(runtime, "vidxp")) + .env_clear(); + for (key, value) in clean_environment(paths) { + command = command.env(key, value); + } + command = command + .arg("--index-dir") + .arg(&paths.repository) + .args(arguments); + supervised_output(state, command, operation).await?; + Ok(()) +} + +#[tauri::command] +fn runtime_manifest() -> Result { + manifest() +} + +#[tauri::command] +fn media_runtime_status() -> MediaRuntimeStatus { + inspect_media_runtime() +} + +#[tauri::command] +fn choose_model_directory(app: AppHandle) -> Result, String> { + let selection = app + .dialog() + .file() + .set_title("Choose where VidXP stores model files") + .blocking_pick_folder(); + selection + .map(|path| { + path.into_path() + .map(|path| path.to_string_lossy().into_owned()) + .map_err(|error| format!("The selected model directory is invalid: {error}")) + }) + .transpose() +} + +#[tauri::command] +async fn install_media_runtime( + app: AppHandle, + state: tauri::State<'_, DesktopState>, +) -> Result { + let current = inspect_media_runtime(); + if current.ready { + return Ok(current); + } + if state + .operation_active + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Err("Another install or model-preparation operation is active.".into()); + } + let _operation_guard = OperationGuard { + active: &state.operation_active, + }; + let plan = system_install_plan().ok_or("No supported system package manager was found.")?; + if !plan.automatic { + let instruction = format!( + "VidXP needs FFmpeg and ffprobe.\n\nRun this command in a terminal, then return to VidXP:\n\n{}", + display_command(&plan.command) + ); + app.dialog() + .message(&instruction) + .title("FFmpeg is required") + .kind(MessageDialogKind::Warning) + .blocking_show(); + return Err(instruction); + } + let approved = app + .dialog() + .message(format!( + "VidXP needs FFmpeg and ffprobe for video processing.\n\nInstall them with {}?\n\n{}", + plan.manager, + display_command(&plan.command) + )) + .title("Install FFmpeg") + .kind(MessageDialogKind::Info) + .buttons(MessageDialogButtons::OkCancelCustom( + "Install".into(), + "Not now".into(), + )) + .blocking_show(); + if !approved { + return Err("FFmpeg setup was deferred.".into()); + } + let command = app + .shell() + .command(plan.command[0].clone()) + .args(&plan.command[1..]); + supervised_output( + &state, + command, + &format!("{} FFmpeg installation", plan.manager), + ) + .await?; + let status = inspect_media_runtime(); + if !status.ready { + return Err(format!( + "FFmpeg installation finished but verification failed: {}", + status.errors.join(" ") + )); + } + Ok(status) +} + +#[tauri::command] +fn runtime_status(app: AppHandle) -> Result { + let manifest = manifest()?; + let mut paths = desktop_paths(&app)?; + let default_model_directory = paths.models.to_string_lossy().into_owned(); + if let Err(detail) = verified_media_runtime() { + return Ok(RuntimeStatus { + ready: false, + package_version: manifest.package_version, + capabilities: Vec::new(), + surfaces: Vec::new(), + model_directory: default_model_directory, + detail, + }); + } + let active = match active_runtime(&paths) { + Ok(active) => active, + Err(detail) => { + return Ok(RuntimeStatus { + ready: false, + package_version: manifest.package_version, + capabilities: Vec::new(), + surfaces: Vec::new(), + model_directory: default_model_directory, + detail, + }); + } + }; + paths.models = active.model_directory.clone(); + let runtime = runtime_directory(&paths, &active); + let version = run_vidxp( + &runtime, + &paths, + &["--version".into()], + "VidXP runtime validation", + ) + .and_then(|output| { + let actual = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + let expected = format!("VidXP {}", manifest.package_version); + if actual == expected { + Ok(output) + } else { + Err(format!( + "The active runtime reported {actual:?}; expected {expected:?}." + )) + } + }); + Ok(RuntimeStatus { + ready: version.is_ok(), + package_version: active.package_version, + capabilities: active.capabilities, + surfaces: active.surfaces, + model_directory: active.model_directory.to_string_lossy().into_owned(), + detail: version + .err() + .unwrap_or_else(|| "Local video processing is ready.".into()), + }) +} + +#[tauri::command] +async fn install_runtime( + app: AppHandle, + state: tauri::State<'_, DesktopState>, + request: InstallRequest, +) -> Result { + if state + .operation_active + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Err("Another install or model-preparation operation is active.".into()); + } + let _operation_guard = OperationGuard { + active: &state.operation_active, + }; + let manifest = manifest()?; + let capabilities = selected_capabilities(&manifest, &request.capabilities)?; + let surfaces = selected_surfaces(&manifest, &request.surfaces)?; + let media_runtime = verified_media_runtime()?; + let mut paths = desktop_paths(&app)?; + paths.models = model_directory(&paths, request.model_directory.as_deref())?; + for directory in [ + &paths.data, + &paths.cache, + &paths.repository, + &paths.runtimes, + &paths.python, + &paths.models, + ] { + fs::create_dir_all(directory) + .map_err(|error| format!("Could not create {}: {error}", directory.display()))?; + } + + let profile_seed = format!( + "{}:{}:{}:{}:{}", + manifest_digest(), + std::env::consts::OS, + std::env::consts::ARCH, + capabilities.join(","), + surfaces.join(",") + ); + let profile_hash = hex::encode(Sha256::digest(profile_seed.as_bytes())); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| format!("The system clock is invalid: {error}"))? + .as_nanos(); + let staging_name = format!(".staging-{profile_hash}-{timestamp}-{}", std::process::id()); + let staging = paths.runtimes.join(&staging_name); + let constraints = staging.join("runtime-constraints.txt"); + + let install_result = async { + uv_output( + &app, + &state, + &paths, + vec![ + "venv".into(), + staging.to_string_lossy().into_owned(), + "--python".into(), + manifest.python_version.clone(), + "--managed-python".into(), + "--no-config".into(), + ], + "Managed Python setup", + ) + .await?; + + fs::write(&constraints, normalized_runtime_constraints().as_ref()) + .map_err(|error| format!("Could not write runtime constraints: {error}"))?; + + uv_output( + &app, + &state, + &paths, + package_acquisition_arguments(&manifest, &executable(&staging, "python")), + "VidXP package acquisition", + ) + .await?; + + uv_output( + &app, + &state, + &paths, + dependency_installation_arguments( + &manifest, + &capabilities, + &surfaces, + &executable(&staging, "python"), + &constraints, + !cfg!(target_os = "macos"), + ), + "VidXP package installation", + ) + .await?; + + run_vidxp_supervised( + &app, + &state, + &staging, + &paths, + &[ + "init".into(), + "--json".into(), + "--ffmpeg".into(), + media_runtime.ffmpeg.to_string_lossy().into_owned(), + "--ffprobe".into(), + media_runtime.ffprobe.to_string_lossy().into_owned(), + ], + "FFmpeg configuration", + ) + .await?; + + let doctor_arguments = capability_command_arguments(&manifest, "doctor", &capabilities); + run_vidxp_supervised( + &app, + &state, + &staging, + &paths, + &doctor_arguments, + "VidXP dependency validation", + ) + .await?; + + if request.prepare_models { + let prepare_arguments = + capability_command_arguments(&manifest, "prepare", &capabilities); + *state + .operation_worker_runtime + .lock() + .map_err(|_| "The preparation worker supervisor is unavailable.")? = + Some(staging.clone()); + let preparation = run_vidxp_supervised( + &app, + &state, + &staging, + &paths, + &prepare_arguments, + "VidXP model preparation", + ) + .await; + let _ = run_vidxp( + &staging, + &paths, + &["jobs".into(), "stop-worker".into()], + "VidXP preparation worker shutdown", + ); + if let Ok(mut worker_runtime) = state.operation_worker_runtime.lock() { + worker_runtime.take(); + } + preparation?; + } + + Ok::<(), String>(()) + } + .await; + if let Err(error) = install_result { + let cleanup_error = if staging.exists() { + fs::remove_dir_all(&staging).err() + } else { + None + }; + return Err(match cleanup_error { + Some(cleanup_error) => format!( + "{error}. The previous active runtime was not changed. VidXP could not remove the failed staged runtime at {}: {cleanup_error}", + staging.display() + ), + None => format!( + "{error}. The previous active runtime was not changed, and the failed staged runtime was removed." + ), + }); + } + + let profile = format!("{profile_hash}-{timestamp}"); + let runtime = paths.runtimes.join(&profile); + fs::rename(&staging, &runtime) + .map_err(|error| format!("Could not finalize the validated runtime: {error}"))?; + let active = ActiveRuntime { + schema_version: 2, + manifest_sha256: manifest_digest(), + profile, + package_version: manifest.package_version.clone(), + capabilities: capabilities.clone(), + surfaces: surfaces.clone(), + model_directory: paths.models.clone(), + }; + write_active_runtime(&paths, &active)?; + + Ok(InstallResult { + package_version: manifest.package_version, + capabilities, + surfaces, + model_directory: paths.models.to_string_lossy().into_owned(), + prepared: request.prepare_models, + }) +} + +fn start_ui(app: &AppHandle, state: &DesktopState) -> Result { + let mut paths = desktop_paths(&app)?; + let active = active_runtime(&paths)?; + if !active.surfaces.iter().any(|surface| surface == "browser") { + return Err( + "The browser interface is not installed. Reconfigure VidXP and select Browser interface." + .into(), + ); + } + paths.models = active.model_directory.clone(); + let runtime = runtime_directory(&paths, &active); + let mut active_process = state + .ui_process + .lock() + .map_err(|_| "The desktop process supervisor is unavailable.".to_string())?; + if let Some(ui) = active_process.as_mut() { + if ui + .process + .try_wait() + .map_err(|error| format!("Could not inspect the interface process: {error}"))? + .is_none() + { + return Ok(ui.url.clone()); + } + *active_process = None; + } + + let listener = TcpListener::bind(("127.0.0.1", 0)) + .map_err(|error| format!("Could not reserve a local interface port: {error}"))?; + let port = listener + .local_addr() + .map_err(|error| format!("Could not identify the local interface port: {error}"))? + .port(); + drop(listener); + + let mut command = configured_command(&executable(&runtime, "vidxp"), &paths); + command + .arg("--index-dir") + .arg(&paths.repository) + .args(["ui", "--host", "127.0.0.1", "--port", &port.to_string()]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let mut process = command + .spawn() + .map_err(|error| format!("Could not start the VidXP interface: {error}"))?; + + let address = SocketAddr::from(([127, 0, 0, 1], port)); + let deadline = Instant::now() + Duration::from_secs(30); + while Instant::now() < deadline { + if let Some(status) = process + .try_wait() + .map_err(|error| format!("Could not inspect the interface process: {error}"))? + { + return Err(format!( + "The VidXP interface exited during startup ({status})." + )); + } + if TcpStream::connect_timeout(&address, Duration::from_millis(100)).is_ok() { + let url = format!("http://127.0.0.1:{port}"); + *active_process = Some(ManagedUi { + process, + url: url.clone(), + }); + return Ok(url); + } + thread::sleep(Duration::from_millis(100)); + } + let _ = process.kill(); + let _ = process.wait(); + Err("The VidXP interface did not become ready in 30 seconds.".into()) +} + +fn hide_main_window(app: &AppHandle) -> Result<(), String> { + let window = app + .get_webview_window("main") + .ok_or("The VidXP desktop window is unavailable.")?; + window + .hide() + .map_err(|error| format!("Could not hide VidXP to the system tray: {error}")) +} + +fn show_main_window(app: &AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } +} + +fn configured_runtime(app: &AppHandle) -> bool { + desktop_paths(app) + .and_then(|paths| active_runtime(&paths)) + .is_ok() +} + +fn browser_surface_configured(app: &AppHandle) -> bool { + desktop_paths(app) + .and_then(|paths| active_runtime(&paths)) + .is_ok_and(|active| active.surfaces.iter().any(|surface| surface == "browser")) +} + +fn open_ui_in_browser(app: &AppHandle, state: &DesktopState) -> Result<(), String> { + let url = start_ui(app, state)?; + app.opener() + .open_url(&url, None::<&str>) + .map_err(|error| format!("Could not open VidXP in the default browser: {error}"))?; + hide_main_window(app) +} + +fn open_or_show(app: &AppHandle) { + if !browser_surface_configured(app) { + show_main_window(app); + return; + } + let app = app.clone(); + thread::spawn(move || { + let state = app.state::(); + if let Err(error) = open_ui_in_browser(&app, &state) { + show_main_window(&app); + app.dialog() + .message(error) + .title("VidXP could not open") + .kind(MessageDialogKind::Error) + .blocking_show(); + } + }); +} + +fn begin_shutdown(app: &AppHandle) { + if app + .state::() + .shutdown_started + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return; + } + log::info!("VidXP supervised shutdown requested"); + shutdown(app); + log::info!("VidXP supervised shutdown completed"); + std::process::exit(0); +} + +#[tauri::command] +fn launch_ui(app: AppHandle, state: tauri::State<'_, DesktopState>) -> Result<(), String> { + open_ui_in_browser(&app, &state) +} + +#[tauri::command] +fn hide_to_tray(app: AppHandle) -> Result<(), String> { + if !configured_runtime(&app) { + return Err("Local video processing has not been configured yet.".into()); + } + hide_main_window(&app) +} + +fn create_tray(app: &tauri::App) -> tauri::Result<()> { + let open = MenuItem::with_id(app, "open", "Open VidXP", true, None::<&str>)?; + let quit = MenuItem::with_id(app, "quit", "Quit VidXP", true, None::<&str>)?; + let menu = Menu::with_items(app, &[&open, &quit])?; + let mut tray = TrayIconBuilder::with_id("vidxp") + .tooltip("VidXP") + .menu(&menu) + .show_menu_on_left_click(true) + .on_menu_event(|app, event| match event.id().as_ref() { + "open" => open_or_show(app), + "quit" => begin_shutdown(app), + _ => {} + }); + if let Some(icon) = app.default_window_icon() { + tray = tray.icon(icon.clone()); + } + tray.build(app)?; + Ok(()) +} + +fn stop_worker(runtime: &Path, paths: &DesktopPaths) { + let mut command = configured_command(&executable(runtime, "vidxp"), paths); + command + .arg("--index-dir") + .arg(&paths.repository) + .args(["jobs", "stop-worker"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let Ok(mut process) = command.spawn() else { + return; + }; + match process.wait_timeout(Duration::from_secs(5)) { + Ok(Some(_)) => {} + _ => { + let _ = process.kill(); + let _ = process.wait_timeout(Duration::from_secs(1)); + } + } +} + +fn shutdown(app: &AppHandle) { + log::info!("Stopping active VidXP processes"); + let state = app.state::(); + if let Ok(mut active_operation) = state.operation_process.lock() { + if let Some(process) = active_operation.take() { + let _ = process.kill(); + } + } + if let Ok(mut active_process) = state.ui_process.lock() { + if let Some(mut ui) = active_process.take() { + let _ = ui.process.kill(); + match ui.process.wait_timeout(Duration::from_secs(5)) { + Ok(Some(_)) => {} + _ => { + let _ = ui.process.kill(); + let _ = ui.process.wait_timeout(Duration::from_secs(1)); + } + } + } + } + let Ok(mut paths) = desktop_paths(app) else { + log::warn!("Could not resolve desktop paths during shutdown"); + return; + }; + if let Ok(mut operation_worker) = state.operation_worker_runtime.lock() { + if let Some(runtime) = operation_worker.take() { + stop_worker(&runtime, &paths); + } + } + let Ok(active) = active_runtime(&paths) else { + log::info!("No active VidXP runtime needs worker shutdown"); + return; + }; + paths.models = active.model_directory.clone(); + let runtime = runtime_directory(&paths, &active); + stop_worker(&runtime, &paths); + log::info!("Active VidXP worker shutdown finished"); +} + +pub fn run() { + let builder = tauri::Builder::default() + .plugin(tauri_plugin_single_instance::init(|app, _, _| { + open_or_show(app); + })) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_log::Builder::new().build()) + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_shell::init()) + .manage(DesktopState::default()) + .setup(|app| { + migrate_legacy_shared_data(app.handle()).map_err(io::Error::other)?; + create_tray(app)?; + if !configured_runtime(app.handle()) { + show_main_window(app.handle()); + } + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + runtime_manifest, + media_runtime_status, + choose_model_directory, + install_media_runtime, + runtime_status, + install_runtime, + launch_ui, + hide_to_tray + ]); + let app = builder + .build(tauri::generate_context!()) + .expect("could not initialize VidXP desktop"); + app.run(|app_handle, event| match event { + RunEvent::WindowEvent { + label, + event: WindowEvent::CloseRequested { api, .. }, + .. + } if label == "main" => { + if app_handle + .state::() + .shutdown_started + .load(Ordering::Acquire) + { + return; + } + api.prevent_close(); + if configured_runtime(app_handle) { + let _ = hide_main_window(app_handle); + } else { + begin_shutdown(app_handle); + } + } + RunEvent::ExitRequested { api, .. } + if !app_handle + .state::() + .shutdown_started + .load(Ordering::Acquire) => + { + api.prevent_exit(); + begin_shutdown(app_handle); + } + _ => {} + }); +} + +#[cfg(test)] +mod tests { + use super::{ + base_package_specification, capability_command_arguments, + dependency_installation_arguments, desktop_paths_from_roots, display_command, manifest, + normalize_line_endings, normalized_runtime_constraints, package_acquisition_arguments, + package_index, package_specification, required_encoder_missing, selected_capabilities, + selected_surfaces, + }; + use std::path::{Path, PathBuf}; + + #[test] + fn desktop_runtime_is_private_while_product_data_is_shared() { + let paths = + desktop_paths_from_roots(Path::new("private"), Path::new("cache"), Path::new("local")); + + assert_eq!(paths.data, PathBuf::from("local").join("VidXP")); + assert_eq!( + paths.repository, + PathBuf::from("local") + .join("VidXP") + .join("repositories") + .join("default") + ); + assert_eq!( + paths.models, + PathBuf::from("local").join("VidXP").join("models") + ); + assert_eq!(paths.runtimes, PathBuf::from("private").join("runtimes")); + assert_eq!(paths.python, PathBuf::from("private").join("python")); + assert_eq!( + paths.active_runtime, + PathBuf::from("private").join("active-runtime.json") + ); + } + + #[test] + fn capability_selection_is_sorted_and_rejects_unknown_values() { + let manifest = manifest().expect("manifest"); + let selected = selected_capabilities( + &manifest, + &["scene".into(), "dialogue".into(), "scene".into()], + ) + .expect("selection"); + + assert_eq!(selected, ["dialogue", "scene"]); + assert!(selected_capabilities(&manifest, &["other".into()]).is_err()); + } + + #[test] + fn runtime_constraints_digest_is_independent_of_checkout_line_endings() { + let canonical = normalized_runtime_constraints(); + let windows = canonical + .split(|byte| *byte == b'\n') + .collect::>() + .join(&b"\r\n"[..]); + + assert_eq!( + normalize_line_endings(&windows).as_ref(), + canonical.as_ref() + ); + } + + #[test] + fn package_specification_has_one_sorted_extra_set() { + let manifest = manifest().expect("manifest"); + + assert_eq!(base_package_specification(&manifest), "vidxp==0.2.1-b.1"); + assert_eq!( + package_specification( + &manifest, + &["scene".into(), "dialogue".into()], + &["browser".into()], + ), + "vidxp[dialogue,frontend,scene]==0.2.1-b.1" + ); + assert_eq!( + package_specification(&manifest, &["scene".into()], &[]), + "vidxp[scene]==0.2.1-b.1" + ); + assert_eq!( + selected_surfaces(&manifest, &["browser".into(), "browser".into()]) + .expect("surface selection"), + ["browser"] + ); + assert!(selected_surfaces(&manifest, &["unknown".into()]).is_err()); + } + + #[test] + fn prerelease_package_and_dependencies_use_separate_indexes() { + let manifest = manifest().expect("manifest"); + let python = Path::new("managed-python"); + let constraints = Path::new("runtime-constraints.txt"); + let acquisition = package_acquisition_arguments(&manifest, python); + let dependencies = dependency_installation_arguments( + &manifest, + &["scene".into()], + &[], + python, + constraints, + true, + ); + + assert_eq!( + package_index(&manifest.package_version), + "https://test.pypi.org/simple" + ); + assert_eq!(package_index("0.3.0"), "https://pypi.org/simple"); + assert_eq!(manifest.dependency_index, "https://pypi.org/simple"); + assert!(acquisition.iter().any(|item| item == "--no-deps")); + assert!( + acquisition + .iter() + .any(|item| item == "https://test.pypi.org/simple") + ); + assert!(!dependencies.iter().any(|item| item == "--no-deps")); + assert!( + dependencies + .iter() + .any(|item| item == &manifest.dependency_index) + ); + assert!(dependencies.iter().any(|item| item == "--torch-backend")); + assert!( + dependencies + .windows(2) + .any(|items| items == ["--constraints", "runtime-constraints.txt"]) + ); + } + + #[test] + fn capability_commands_pass_one_comma_separated_option() { + let manifest = manifest().expect("manifest"); + + assert_eq!( + capability_command_arguments(&manifest, "doctor", &["dialogue".into(), "scene".into()]), + ["doctor", "--json", "--modalities", "dialogue,scene"] + ); + } + + #[test] + fn ffmpeg_encoder_check_matches_complete_encoder_names() { + let encoders = " V....D libx264 H.264\n A....D aac AAC"; + + assert!(!required_encoder_missing(encoders, "libx264")); + assert!(!required_encoder_missing(encoders, "aac")); + assert!(required_encoder_missing(encoders, "libx265")); + } + + #[test] + fn package_manager_command_is_presented_as_copyable_text() { + assert_eq!( + display_command(&[ + "winget".into(), + "install".into(), + "--id".into(), + "Gyan.FFmpeg".into(), + ]), + "winget install --id Gyan.FFmpeg" + ); + } +} diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs new file mode 100644 index 0000000..03c28bd --- /dev/null +++ b/desktop/src-tauri/src/main.rs @@ -0,0 +1,5 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + vidxp_desktop_lib::run(); +} diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json new file mode 100644 index 0000000..208ff38 --- /dev/null +++ b/desktop/src-tauri/tauri.conf.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "VidXP", + "version": "0.2.1-b.1", + "identifier": "dev.grayhat.vidxp", + "build": { + "frontendDist": "../web" + }, + "app": { + "withGlobalTauri": true, + "security": { + "csp": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src ipc: http://ipc.localhost" + }, + "windows": [ + { + "label": "main", + "title": "VidXP", + "width": 920, + "height": 760, + "minWidth": 680, + "minHeight": 600, + "visible": false, + "resizable": true + } + ] + }, + "bundle": { + "active": true, + "targets": "all", + "externalBin": [ + "binaries/uv" + ], + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "windows": { + "nsis": { + "installMode": "currentUser", + "installerHooks": "nsis-hooks.nsh" + } + }, + "macOS": { + "minimumSystemVersion": "13.0" + } + } +} diff --git a/desktop/web/app.js b/desktop/web/app.js new file mode 100644 index 0000000..7987bb0 --- /dev/null +++ b/desktop/web/app.js @@ -0,0 +1,188 @@ +const invoke = window.__TAURI__.core.invoke; + +const surfacesNode = document.querySelector("#surfaces"); +const capabilitiesNode = document.querySelector("#capabilities"); +const installButton = document.querySelector("#install"); +const launchButton = document.querySelector("#launch"); +const prepareModels = document.querySelector("#prepare-models"); +const modelDirectoryNode = document.querySelector("#model-directory"); +const chooseModelDirectoryButton = document.querySelector( + "#choose-model-directory", +); +const statusNode = document.querySelector("#status"); + +let manifest; +let selectedModelDirectory; + +function setBusy(busy) { + installButton.disabled = busy; + chooseModelDirectoryButton.disabled = busy; + launchButton.disabled = busy || launchButton.dataset.ready !== "true"; +} + +function selectedCapabilities() { + return [...document.querySelectorAll("[data-capability]:checked")].map( + (node) => node.dataset.capability, + ); +} + +function selectedSurfaces() { + return [...document.querySelectorAll("[data-surface]:checked")].map( + (node) => node.dataset.surface, + ); +} + +function renderOptions(entries, container, dataAttribute, className) { + container.replaceChildren(); + Object.entries(entries).forEach(([id, option]) => { + const label = document.createElement("label"); + label.className = className; + + const input = document.createElement("input"); + input.type = "checkbox"; + input.checked = option.default ?? true; + input.dataset[dataAttribute] = id; + + const copy = document.createElement("span"); + const name = document.createElement("strong"); + name.textContent = option.label; + const detail = document.createElement("small"); + detail.textContent = + option.description ?? `Installs the ${option.extra} capability`; + copy.append(name, detail); + label.append(input, copy); + container.append(label); + }); +} + +function renderManifest() { + renderOptions(manifest.surfaces, surfacesNode, "surface", "surface"); + renderOptions( + manifest.capabilities, + capabilitiesNode, + "capability", + "capability", + ); +} + +function applySelection(attribute, selected) { + document.querySelectorAll(`[data-${attribute}]`).forEach((node) => { + node.checked = selected.includes(node.dataset[attribute]); + }); +} + +async function refreshStatus() { + const status = await invoke("runtime_status"); + selectedModelDirectory ??= status.model_directory; + modelDirectoryNode.textContent = selectedModelDirectory; + + if (status.ready) { + applySelection("capability", status.capabilities); + applySelection("surface", status.surfaces); + } + + const browserReady = status.ready && status.surfaces.includes("browser"); + launchButton.dataset.ready = String(browserReady); + launchButton.disabled = !browserReady; + statusNode.textContent = status.ready + ? `Ready · VidXP ${status.package_version} · ${status.capabilities.join(", ")}` + : status.detail; + return status; +} + +async function ensureMediaRuntime() { + const status = await invoke("media_runtime_status"); + if (status.ready) { + return status; + } + if (!status.install_command) { + throw new Error( + `${status.errors.join(" ")} Install FFmpeg and ffprobe, then retry.`, + ); + } + statusNode.textContent = "Waiting for FFmpeg setup confirmation…"; + return invoke("install_media_runtime"); +} + +async function launchBrowser() { + setBusy(true); + statusNode.textContent = "Starting VidXP…"; + try { + await invoke("launch_ui"); + statusNode.textContent = "VidXP is running in the system tray."; + } catch (error) { + statusNode.textContent = `Launch failed: ${error}`; + setBusy(false); + } +} + +chooseModelDirectoryButton.addEventListener("click", async () => { + try { + const selection = await invoke("choose_model_directory"); + if (selection) { + selectedModelDirectory = selection; + modelDirectoryNode.textContent = selection; + } + } catch (error) { + statusNode.textContent = `Could not select the model folder: ${error}`; + } +}); + +installButton.addEventListener("click", async () => { + const capabilities = selectedCapabilities(); + if (capabilities.length === 0) { + statusNode.textContent = "Select at least one capability."; + return; + } + + setBusy(true); + try { + statusNode.textContent = "Checking FFmpeg and required codecs…"; + await ensureMediaRuntime(); + statusNode.textContent = prepareModels.checked + ? "Configuring local processing and downloading selected models…" + : "Configuring local processing…"; + const result = await invoke("install_runtime", { + request: { + capabilities, + surfaces: selectedSurfaces(), + prepare_models: prepareModels.checked, + model_directory: selectedModelDirectory, + }, + }); + selectedModelDirectory = result.model_directory; + statusNode.textContent = result.prepared + ? "Local processing and models are ready." + : "Local processing is ready. Model downloads were deferred."; + await refreshStatus(); + if (result.surfaces.includes("browser")) { + await launchBrowser(); + } else { + await invoke("hide_to_tray"); + } + } catch (error) { + statusNode.textContent = `Configuration failed: ${error}`; + } finally { + setBusy(false); + } +}); + +launchButton.addEventListener("click", launchBrowser); + +async function start() { + try { + manifest = await invoke("runtime_manifest"); + renderManifest(); + const status = await refreshStatus(); + if (status.ready && status.surfaces.includes("browser")) { + await launchBrowser(); + } else if (status.ready) { + await invoke("hide_to_tray"); + } + } catch (error) { + statusNode.textContent = `Desktop initialization failed: ${error}`; + setBusy(true); + } +} + +start(); diff --git a/desktop/web/index.html b/desktop/web/index.html new file mode 100644 index 0000000..ecc5848 --- /dev/null +++ b/desktop/web/index.html @@ -0,0 +1,73 @@ + + + + + + + VidXP + + + + +
+
+

VIDXP DESKTOP

+

Configure local video processing.

+

+ VidXP is installed. Choose what local processing it should prepare on + this machine. +

+
+ +
+

Interface

+
+
+ +
+

Capabilities

+
+
+ +
+

Models

+
+
+ Model storage + Using the default location +
+ +
+ +
+ +
+
+

STATUS

+

Checking the local runtime…

+
+
+ + +
+
+ +

+ Configuration uses an app-owned Python runtime. VidXP checks FFmpeg, + ffprobe, and required codecs first and uses operating-system prompts + before running a supported package manager. +

+
+ + + diff --git a/desktop/web/styles.css b/desktop/web/styles.css new file mode 100644 index 0000000..61ad67e --- /dev/null +++ b/desktop/web/styles.css @@ -0,0 +1,211 @@ +:root { + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; + color: #eceef3; + background: #0d1016; + font-synthesis: none; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at 80% 0%, rgba(86, 100, 255, 0.2), transparent 36rem), + #0d1016; +} + +main { + width: min(48rem, calc(100% - 3rem)); + margin: 0 auto; + padding: 4rem 0; +} + +.hero { + margin-bottom: 2rem; +} + +.eyebrow { + margin: 0 0 0.55rem; + color: #9da8ff; + font-size: 0.72rem; + font-weight: 750; + letter-spacing: 0.14em; +} + +h1, +h2, +p { + margin-top: 0; +} + +h1 { + max-width: 13ch; + margin-bottom: 1rem; + font-size: clamp(2.4rem, 7vw, 4.5rem); + line-height: 0.98; + letter-spacing: -0.055em; +} + +h2 { + font-size: 1rem; +} + +.lede, +.footnote, +small { + color: #abb1be; +} + +.lede { + max-width: 39rem; + font-size: 1.05rem; + line-height: 1.65; +} + +.panel { + margin-top: 1rem; + padding: 1.35rem; + border: 1px solid #282e3b; + border-radius: 1rem; + background: rgba(21, 25, 34, 0.9); +} + +.capabilities, +.options { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.75rem; +} + +.capability, +.surface, +.prepare-option { + display: flex; + gap: 0.7rem; + align-items: flex-start; + cursor: pointer; +} + +.capability { + min-height: 5rem; + padding: 0.9rem; + border: 1px solid #303746; + border-radius: 0.75rem; + background: #171c26; +} + +.surface { + padding: 0.9rem; + border: 1px solid #303746; + border-radius: 0.75rem; + background: #171c26; +} + +.capability:has(input:checked), +.surface:has(input:checked) { + border-color: #7d88ff; + background: #1d2340; +} + +input { + margin-top: 0.2rem; + accent-color: #7d88ff; +} + +.capability span, +.surface span, +.prepare-option span { + display: grid; + gap: 0.3rem; +} + +.path-option { + display: flex; + gap: 1rem; + align-items: center; + justify-content: space-between; +} + +.path-option div { + display: grid; + min-width: 0; + gap: 0.35rem; +} + +.path-option small { + overflow-wrap: anywhere; +} + +.prepare-option { + margin-top: 1.1rem; + padding-top: 1.1rem; + border-top: 1px solid #282e3b; +} + +.status-panel, +.actions { + display: flex; + gap: 1rem; + align-items: center; + justify-content: space-between; +} + +.status-panel p:last-child { + margin-bottom: 0; +} + +.actions { + flex-shrink: 0; +} + +button { + min-height: 2.7rem; + padding: 0 1rem; + border: 0; + border-radius: 0.65rem; + color: #0d1016; + background: #a9b0ff; + font: inherit; + font-weight: 720; + cursor: pointer; +} + +button.secondary { + color: #eceef3; + background: #303746; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.footnote { + margin: 1rem 0 0; + font-size: 0.78rem; + line-height: 1.5; +} + +@media (max-width: 42rem) { + main { + width: min(100% - 2rem, 48rem); + padding: 2rem 0; + } + + .capabilities, + .options { + grid-template-columns: 1fr; + } + + .status-panel, + .path-option, + .actions { + align-items: stretch; + flex-direction: column; + } +} diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 6847f70..795bb6a 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,125 +1,259 @@ -# Contribution Guidelines +# Contributing to VidXP -Thanks for contributing to VidXP (Video eXPlain). +Thanks for helping improve VidXP. Contributions may target the local product, +public interfaces, model capabilities, deployment, documentation, or +benchmarks. -## Project layout +## Before you start -| File / path | Role | -|-------------|------| -| `src/vidxp/cli.py` | Typer commands and installed `vidxp` entry point | -| `src/vidxp/application.py` | Reusable application boundary for CLI and future adapters | -| `src/vidxp/capabilities/` | Capability registry, schemas, operations, dependencies, and optional CLI modules | -| `src/vidxp/repositories.py` | Persistent named local-index configuration | -| `src/vidxp/frontend.py` | Streamlit interface launched by `vidxp ui` | -| `src/vidxp/core/` | Capability-neutral storage, media, run state, and execution contracts | -| `src/vidxp/benchmarks/` | Benchmark-specific loaders, prediction adapters, and evaluator calls | -| `pyproject.toml` | Package metadata and Python dependencies | -| `docs/` | Installation-linked guidance, benchmark research, and contribution notes | -| `chroma_data/` | Local ChromaDB index and `index_status.json` readiness record (generated; do not commit) | -| `benchmark_runs/` | Isolated programmatic and benchmark runs (generated; do not commit) | -| Model caches | Managed by WhisperX, SentenceTransformer, and CLIP outside the repository | +- Open an issue before a large cross-capability or architecture change. +- Keep pull requests focused on one user-visible outcome. +- Preserve the shared application contracts: CLI, UI, HTTP, and MCP should not + grow separate implementations of the same operation. +- State whether a storage/model change requires existing repositories to be + rebuilt. +- GPU support is deferred. Do not silently make CUDA the default or add a new + GPU installation path without the documented validation gates. -The full local CLI/UI index uses up to three collections: -`dialogue`, `scene`, and `actor`. Runs containing -selected capabilities create only the collections they need. +## Development setup -## Setup +### 1. Clone and install the complete contributor environment -Follow the [installation guide](../INSTALLATION_GUIDE.md) or install from the -package metadata directly. +```bash +git clone https://github.com/grayhatdevelopers/vidxp.git +cd vidxp +uv sync --frozen \ + --extra local-worker \ + --extra frontend \ + --extra mcp \ + --extra server \ + --extra test \ + --extra benchmarks +``` + +The repository lock selects CPU PyTorch on Linux and Windows. + +### 2. Initialize the media runtime + +```bash +uv run --no-sync vidxp init +``` + +This verifies FFmpeg, ffprobe, `libx264`, and `aac`. It may offer an explicit, +confirmed system package-manager command; Python dependency installation never +changes system packages. + +### 3. Verify the environment + +```bash +uv run --no-sync vidxp --version +uv run --no-sync vidxp doctor +uv run --no-sync pytest -q +``` + +`doctor` may report that model artifacts have not been prepared; that is +expected in a fresh contributor environment. Dependency, import, FFmpeg, or +codec failures are not expected. Prepare only the modality needed for manual +testing: + +```bash +uv run --no-sync vidxp prepare --modalities scene +uv run --no-sync vidxp doctor --modalities scene +``` + +Models and normal local repositories use the operating system’s per-user VidXP +data directory. They are not written into the checkout unless you explicitly +override `--data-dir`. + +## Project map + +- Product operations and contracts live in `application.py`, + `control_plane.py`, `application_models.py`, and the media, artifact, and + query services. +- Capability-specific indexing and retrieval live in `capabilities/`. +- CLI, Streamlit, HTTP, and MCP adapters live in `cli.py`, `cli_commands/`, + `frontend.py`, `api.py`, `api_routes/`, and `mcp.py`. +- Durable execution and storage live in `job_service.py`, `workflow_*`, + `execution.py`, `core/`, `infrastructure/`, and `ports.py`. +- Deployment and desktop packaging live in `Dockerfile`, `compose*.yaml`, and + `desktop/`. +- Tests are under `tests/`; benchmark adapters and documentation are under + `src/vidxp/benchmarks/` and `docs/benchmarking/`. +- Optional dependency groups are under `src/vidxp/requirements/`; pending + public release notes are under `changes/`. + +Generated environments, model weights, media, indexes, artifacts, +`benchmark_runs/`, build outputs, and local data do not belong in commits. + +## Architecture rules + +### Put logic at the correct boundary + +- Capability-specific models, indexing, retrieval, schemas, and dependencies: + `src/vidxp/capabilities//`. +- Transport-neutral operations and result models: the application/control + plane. +- CLI, Streamlit, HTTP, and MCP: thin input/output adapters. +- Filesystem, Chroma, PostgreSQL, DBOS, FFmpeg, and process supervision: + infrastructure/ports. +- Benchmark-specific data formats and evaluator behavior: benchmarks only. + +### Preserve public contracts + +- Public commands/results use stable IDs and metadata, not storage keys or + local filesystem paths. +- Local and server adapters call the same typed application operations. +- Long-running work crosses the durable job boundary. +- Model downloads happen only through explicit preparation. +- Media and artifact publication remains atomic. +- A failed or cancelled generation must not replace the previous active + snapshot. +- Server control processes remain model-free; provider work belongs in workers. +- Local defaults remain under the per-user VidXP data root, not the current + working directory. + +### Adding or changing a capability + +Follow [Adding a capability](adding-a-capability.md). At minimum: + +- declare the definition and typed operation schemas; +- keep provider dependencies in that capability’s `requirements.txt`; +- pin model identity, revision, checksum, license metadata, and disclosed size; +- wire the package extra in `pyproject.toml`; +- test provider readiness without downloading models; and +- document whether old indexes must be rebuilt. + +## Validation + +Run the smallest relevant checks while iterating, then the complete applicable +gate before opening a pull request. + +| Change | Minimum validation | +|---|---| +| Python logic or contracts | Targeted pytest file + full `pytest -q` | +| CLI | Command help, success path, failure path, JSON output | +| Streamlit | Full-page render, form submit, job progress/terminal state, reload | +| HTTP API | Shared application test + route/auth/error contract | +| MCP | Tool contract + `vidxp-mcp --check` | +| Media/artifacts | Real ffprobe/FFmpeg smoke + path/ID confinement | +| Models/providers | Dependency check, prepared/unprepared states, short real-media smoke | +| Desktop | Rust tests, JavaScript syntax, setup/close lifecycle | +| Docker/Compose | Dockerfile targets, `docker compose config`, health checks | +| Documentation | Commands, relative links, headings, and rendered tables | + +Common commands: ```bash -python -m venv venv -# Windows: venv\Scripts\activate -# macOS/Linux: source venv/bin/activate -python -m pip install -e ".[all,frontend,benchmarks]" +uv run --no-sync ruff check . +uv run --no-sync pytest -q +node --check desktop/web/app.js +docker compose config --quiet ``` -Verify the environment: +Desktop validation requires the pinned uv sidecar before Rust tests: ```bash -vidxp --version -vidxp doctor +npm --prefix desktop install +npm --prefix desktop run sidecar:windows +cargo test --manifest-path desktop/src-tauri/Cargo.toml ``` -CLI: `vidxp --help` -UI: `vidxp ui` - -Models default to CPU and download into their libraries' standard caches on -first use. If a model identifier changes, update the setup documentation and -record the exact identifier in benchmark results. - -## Where to put work - -- Capability-specific indexing, retrieval, models, schemas, and dependencies: - the matching folder under `src/vidxp/capabilities/`. -- Shared storage, media handling, run state, and execution mechanics: - `src/vidxp/core/`. -- Transport-neutral application operations: `src/vidxp/application.py`. -- Command-line behavior: `src/vidxp/cli.py`. -- Upload and search UX: `src/vidxp/frontend.py`; keep product logic in the - shared application and core modules. -- Official benchmark formats and evaluator calls: `src/vidxp/benchmarks/`. -- Capability dependencies: that capability's `requirements.txt`; wire a new - install extra into `pyproject.toml`. -- Product direction: the roadmap in the main [README](../README.md). - -Prefer small, focused pull requests. If you change how embeddings or metadata -are stored, state whether an existing `chroma_data` index must be rebuilt. - -## Before you open a PR - -1. Run the automated test suite relevant to the change. -2. Index a short sample video, then try dialogue, scene, and—if touched—actor - search. -3. If you changed the Streamlit app, smoke-test upload, indexing, cancellation, - reload, and search. -4. Confirm that an incomplete local index is replaced rather than treated as - ready. -5. Do not commit model weights, generated indexes, benchmark runs, or local - sample media. - -Run the complete automated suite with: +Use `npm --prefix desktop run sidecar:unix` on macOS or Linux. Validate +`compose.coolify.yaml` only with a complete deployment test environment; its +required image, secret, hostname, and upload variables intentionally make a +bare `docker compose config` fail. + +Model-backed manual smoke tests should use a short sample and only the +modalities affected by the change: ```bash -python -m unittest discover -s tests +uv run --no-sync vidxp media import samplevideo.mp4 --json +uv run --no-sync vidxp index create --modality scene +uv run --no-sync vidxp search scene "a person enters the room" ``` +Do not describe mocked adapter tests as end-to-end validation. If a test does +not exercise a real process, provider, codec, database, browser, or protocol +boundary, say so. + ## Pull requests -- Clear title and a few bullets on what / why. -- Note any new env vars, model downloads, or breaking index format changes. -- Link a related issue when there is one. +The repository's +[pull request template](../.github/pull_request_template.md) is the required +starting point. GitHub loads it automatically for new pull requests. Complete +all three sections: + +- **Summary:** describe the user-facing outcome and important compatibility or + migration behavior. +- **Validation:** list the exact commands and real boundaries exercised. Do not + replace results with “tests passed.” +- **Changelog:** add the correct fragment, or explain why the change is + dependency-only/internal and should receive `skip-changelog`. + +Keep the description compact, but include: -### Changelog fragments +- what users can do after the change; +- why the change is needed; +- important design or compatibility decisions; +- new dependencies, environment variables, downloads, or migrations; +- validation actually performed; and +- screenshots or sample output for user-interface changes. + +Use Conventional Commit prefixes for commits, for example: + +```text +feat(mcp): add clip artifact discovery +fix(frontend): keep search form submittable +docs: clarify local installation profiles +``` -Add one `changes/..md` file for every user-visible pull -request. Use one of these types: +## Release-note fragments -- `breaking` for incompatible behavior or API changes. -- `feature` for new behavior. -- `bugfix` for corrected behavior. -- `deprecation` for behavior scheduled for removal. -- `docs` for user-facing documentation improvements. +Every user-visible pull request normally adds one +`changes/..md` file. Supported types are: + +- `breaking` for incompatible public behavior or API changes. +- `feature` for new user-visible behavior. +- `bugfix` for behavior that was wrong in the latest public release. +- `deprecation` for public behavior scheduled for removal. +- `docs` for material user-facing documentation changes. - `security` for security fixes or hardening users should know about. -The fragment should be one sentence written for users. Do not include a heading, -version number, commit message, or implementation details. See -[`changes/README.md`](../changes/README.md) for an example. +Write for users, not for the implementation history. + +- Describe the delta from the latest public release. +- Do not label a correction to an unreleased feature as a separate public bug + fix. Update/consolidate the feature’s pending note instead. +- Do not create one fragment per internal commit. +- Keep implementation details, test repairs, and intermediate refactors out of + release notes unless they change a public contract. +- Use concise bullets when one release-level feature contains several related + capabilities. + +Example: + +```markdown +Add local and remote MCP support with media discovery, cross-video search, +durable job control, and downloadable clip resources. +``` -Dependency updates carrying GitHub's `dependencies` label do not require a -fragment. For other internal-only maintenance, explain the reason in the pull -request and ask a maintainer to apply `skip-changelog`. CI requires a fragment -unless one of those labels is present. +Dependency-only and internal maintenance changes may omit a fragment when the +pull request is labeled appropriately. Towncrier assembles pending fragments +into the stable changelog; do not edit released sections of `CHANGELOG.md`. -Towncrier renders pending fragments as prerelease notes, then collects them into -`CHANGELOG.md` and removes them when a stable release is made. Do not edit the -changelog or package version in a feature pull request. +## Documentation style -## Questions +- Lead with the outcome, then the command or decision. +- Prefer short sections, bullets, and comparison tables over dense paragraphs. +- Keep the README product-focused and the installation guide procedural. +- Put deployment internals in `docs/deployment/`. +- Mark previews, deferred work, and unsupported topologies explicitly. +- Never publish a command sequence that skips a required install, + initialization, preparation, or activation step. -Follow [Adding a capability](adding-a-capability.md) for the complete extension -contract. Open an issue before large cross-capability refactors so scope stays -aligned with the roadmap. +## Getting help -Maintainers should follow the [release process](releasing.md). +- Open an [issue](https://github.com/grayhatdevelopers/vidxp/issues) for a bug + or scoped proposal. +- Use [Discord](https://grayhat.studio/discord) for contributor discussion. +- Maintainers should follow the [release process](releasing.md). diff --git a/docs/architecture/platform.md b/docs/architecture/platform.md new file mode 100644 index 0000000..41e5c09 --- /dev/null +++ b/docs/architecture/platform.md @@ -0,0 +1,1505 @@ +# VidXP Platform Architecture + +Status: accepted +Last validated: 2026-07-28 +Scope: CLI, API, MCP, Streamlit, desktop, media ingestion, indexing, search, +artifacts, natural-language query, CPU/GPU execution, and deployment + +## 1. Purpose + +This document defines the target architecture and delivery gates for VidXP's +application platform. Changes to its ownership boundaries or accepted decisions +require a reviewed architecture update or ADR with supporting evidence. + +## 2. Product outcomes + +VidXP must support: + +1. A local CLI operating directly on local media and repositories. +2. A local interactive UI, currently Streamlit, using the same application layer. +3. A future packaged desktop UI using the same application layer. +4. A remotely accessible HTTP API. +5. A remotely accessible MCP server over Streamable HTTP. +6. Local MCP over stdio for adjacent clients; stdio is not the media path. +7. Durable indexing and artifact jobs with progress and cancellation. +8. CPU execution first and an explicit, tested GPU runtime afterwards. +9. Natural-language questions over all indexed collectors with timestamped evidence. +10. A Coolify-compatible multi-service deployment. +11. Native local operation on supported Apple Silicon macOS without requiring Docker. +12. A thin-client mode for using a self-hosted VidXP server from another machine. + +## 3. Non-negotiable invariants + +1. CLI, UI, API, MCP, and desktop are adapters. They parse input, authenticate, + invoke application commands, and format output. They do not implement indexing, + media persistence, job state, search policy, or artifact generation. +2. FastAPI and MCP are sibling adapters. MCP never calls VidXP's HTTP API internally. +3. Public transports reuse the same Pydantic command and result models. +4. Video bytes are never passed as MCP tool arguments or over stdio. +5. Remote MCP clients reference previously registered media by `media_id`. +6. A failed or cancelled indexing job cannot damage the last committed index. +7. Search reads an immutable committed index generation. +8. Job state and index state are separate. +9. No remote contract exposes arbitrary server filesystem paths. +10. CPU/GPU selection is a validated runtime profile, not a free-form string. +11. Unexpected internal errors are logged with correlation data and masked publicly. +12. Every unbounded collection exposed by an adapter is paginated or capped centrally. +13. Stable dependencies are kept current; compatibility pins require a recorded + blocker, an owner, and a removal gate. +14. CPU behavior across Linux, Windows, and Apple Silicon is accepted before + platform-specific acceleration work. + +## 4. Dependency direction + +```text +Composition root + ├── Typer CLI adapter + ├── Streamlit adapter + ├── Desktop adapter + ├── FastAPI adapter + └── MCP adapter + │ + ▼ +Application commands, queries, results, and public errors + ├── media use cases + ├── indexing use cases + ├── search use cases + ├── natural-language query use cases + ├── actor use cases + └── artifact use cases + │ + ▼ +Domain contracts and ports + ├── MediaCatalog / MediaStore + ├── IndexCatalog / IndexRepository + ├── JobService + ├── ArtifactStore + ├── CapabilityRegistry + ├── QueryPlanner / AnswerSynthesizer + ├── ModelRuntime + └── ResourceScheduler + │ + ▼ +Infrastructure implementations + ├── managed local filesystem storage + ├── Chroma + ├── DBOS SQLite / Postgres + ├── FFmpeg / ffprobe / OpenCV + ├── local and GPU model providers + └── environment and Docker wiring +``` + +Only the composition root may import both adapters and concrete infrastructure. +Application and domain modules must not import FastAPI, MCP, Typer, Streamlit, +Chroma, DBOS, OpenCV, or environment variables. + +## 5. Composition and settings + +Use one immutable `VidXPSettings` model built with `pydantic-settings`. + +It owns: + +- the per-user application data root +- repository root and active local repository selection +- trusted local import roots +- upload limits and retention +- authentication mode and secrets +- public base URL +- MCP transport configuration +- MCP canonical resource URL, body limit, allowed hosts, and allowed origins +- local/server workflow mode and queue selection +- resolved CPU/GPU runtime request +- model cache path and offline/download policy +- allowed model identifiers +- concurrency and memory-related limits + +Settings are constructed once by a composition root and injected. Adapters must not +mutate process environment variables to communicate configuration. Application +objects must not be created at module import time. + +### 5.1 Version and upgrade policy + +VidXP targets the latest stable, non-pre-release versions available when a delivery +phase begins. As of this validation, the current Python release is 3.14.6. Runtime +images and primary development use the latest 3.14 maintenance release. +The library test matrix covers supported CPython 3.11 through 3.14 while those +versions remain security-supported. + +Direct dependency ranges are bounded at the next incompatible major version. +Release environments and images lock exact direct and transitive artifacts. Update +automation opens dependency changes continuously, but an update merges only after +contract, packaging, platform, benchmark, and license gates pass. + +A dependency may remain below latest stable only when a reproduced incompatibility +or material regression is recorded. The exception must identify: + +- the blocking package and upstream issue +- affected operating systems/runtime profiles +- the newest compatible version +- the replacement or upgrade path +- the test that removes the exception + +An ML package does not hold the control plane, Python runtime, or unrelated +capabilities back. When necessary, the package is replaced or isolated behind its +worker/provider contract. Pre-release packages are excluded from production +constraints. + +Model weights follow a different rule from libraries: the default is the +best-performing current stable model that passes VidXP's quality, latency, memory, +license, redistribution, and cross-platform benchmarks. It is pinned by immutable +revision and checksum. “Newest” alone is not sufficient to change indexed embedding +semantics. + +## 6. Shared public contracts + +The application layer owns the canonical Pydantic models. HTTP and MCP may project +transport-only fields such as links or status codes, but may not redefine business +state. + +Required identifiers: + +- `RepositoryId` +- `MediaId` +- `VideoId` +- `IndexGenerationId` +- `IndexSnapshotId` +- `JobId` +- `ArtifactId` + +For the current single-source media pipeline, `VideoId` is the same opaque value +as `MediaId`. A separate video-track identity is introduced only when one media +asset can produce multiple independently addressable video tracks; adapters must +not invent a second identifier before then. + +Required shared models: + +- `MediaAsset` +- `MediaProbe` +- `IndexPlan` +- `IndexGeneration` +- `IndexSnapshot` +- `IndexGenerationStatus` +- `Job` +- `JobProgress` +- `ProgressEvent` +- `SearchCommand` +- `SearchHit` +- `SearchResult` +- `QueryPlan` +- `QueryAnswer` +- `Evidence` +- `Artifact` +- `Page[T]` +- `RuntimeProfile` + +Public models carry a real schema version where compatibility requires one. Adapters +must not append schema fields manually during serialization. + +## 7. Public error model + +Application errors have: + +- stable machine code +- category +- safe public message +- optional safe details +- retryability +- internal cause retained only for logs + +Initial categories: + +- validation +- authentication +- authorization +- not found +- conflict +- unavailable +- resource limit +- cancelled +- internal + +FastAPI maps categories to HTTP statuses and a consistent error envelope. MCP maps +expected application failures to controlled tool-execution errors. CLI maps them +to exit codes and terminal messages. No adapter infers product meaning from broad +built-in exceptions. + +Every MCP tool is registered through one wrapper that catches expected application +errors and raises the SDK's public `ToolError` with a compact safe JSON payload: + +- an assigned safe protocol integer code +- a safe public message +- the stable VidXP application code, retryability, and correlation ID +- safe validation details or the required repository scope when applicable + +The wrapper catches every other exception, logs the cause with correlation data, and +raises only a generic internal `MCPError`. `MCPServer` re-raises `MCPError` without +converting the underlying exception to text, so the SDK's default +`str(exception)` leakage path is not used. Expected errors become +`CallToolResult(isError=True)` through the SDK's normal execution-error path and +remain outside the Pydantic success schema. + +## 8. Repository layout + +Native local interfaces share an operating-system per-user application data +root rather than deriving storage from the process working directory: + +```text +VidXP/ + repositories/ + default/ + ... + models/ +``` + +The CLI's global `--data-dir` option and `VIDXP_DATA_DIR` replace this root. +`VIDXP_INDEX_DIR` and `VIDXP_MODEL_CACHE` remain narrower advanced overrides. +Named-repository configuration uses the operating system's standard roaming +user-configuration directory. Container deployments do not inherit these host +defaults; Compose supplies explicit repository and model-cache volumes. + +Within a repository, one `RepositoryLayout` defines all persistent paths: + +```text +repository/ + repository.json + catalog.sqlite3 + media/ + objects/ + indexes/ + store/ + generations/ + / + snapshots/ + .json + active-snapshot.json + artifacts/ + objects/ + local-workflows/ +``` + +Model caches do not live inside repositories. For native local operation they +are a sibling under the shared application data root; server deployments mount +their cache explicitly. + +Remote API/MCP responses never expose these internal paths. + +## 9. Media ingestion and identity + +### 9.1 Media asset + +`MediaAsset` records: + +- stable `media_id` +- content checksum +- original filename +- byte size +- declared and detected MIME/container +- duration, streams and codecs from ffprobe +- managed storage key or approved external source reference +- repository and owner/principal where applicable +- ingest state +- associated `video_id` +- creation and retention timestamps + +Checksum is calculated once during ingest and reused for deduplication and indexing. +Untrusted content is not published into the catalog until ffprobe validation succeeds. + +### 9.2 Local CLI and desktop + +Local clients call `ImportMedia` with a path. Policy decides whether VidXP references +the original file or copies it into managed storage. Paths must resolve beneath +configured import roots when a boundary is required. + +Local media does not need to travel through an HTTP upload endpoint. + +### 9.3 Remote upload + +Remote upload is a four-stage protocol: + +1. An authenticated client creates an upload intent. +2. tusd's blocking `pre-create` HTTP hook authenticates the request, validates the + declared size/type and intent, and assigns an opaque upload ID. +3. tusd returns an HTTPS upload URL. That URL is an unscoped bearer credential for + subsequent HEAD/PATCH requests because tusd cannot bind resumptions to the user + who created the upload. +4. The non-blocking `post-finish` hook idempotently upserts completion by upload ID + and enqueues the durable ffprobe/import workflow. + +Application responses carrying upload URLs use `private, no-store` and +`no-referrer`; hook payloads and MCP results do not persist them. The opaque path is +still a bearer credential and may appear in an upstream proxy's access log unless +that deployment disables or redacts logging for `/uploads/`. Hook handlers assume +duplicate and out-of-order delivery and never run ffprobe or encoding inline. A +recovery sweep finds completed uploads whose finish hook was missed. A retention +workflow removes abandoned intents and quarantine objects. + +The hook endpoint is private to the Compose network. Client authorization is read +from the hook request body and redacted; client tokens are never stored in tus +metadata. Only the tus upload route is public. A completed upload is not a +`MediaAsset` until durable probe/import succeeds. + +The supported server topology uses tusd filestore on a named quarantine volume +shared read-only with the hook service and worker. Managed media and artifacts use +the stack's named content volume. The whole deployment is single-node and one +deployed stack is one repository boundary. Clients upload directly to tusd; +FastAPI does not proxy large video bodies. + +S3-compatible storage is deferred and not implemented. If revisited, it would +apply only to upload quarantine, source media, and generated artifacts—never to +embeddings, which remain in Chroma. Although tusd can receive uploads into S3, +VidXP would still require explicit import, processing, publication, recovery, +cleanup, and delivery integration. + +tusd 2.10.0 is pinned to a tested release digest, uses an explicit base path/public HTTPS +URL, a default 50 GiB maximum upload size, a deployment-wide upload quota, +restricted CORS origins, disabled downloads, and disabled concatenation. Deployments +may lower the configured maximum. Termination remains enabled so tusd owns its file +locks and deletion. +The blocking `pre-terminate` hook prevents deletion while import is processing and +admits completed/expired cleanup only with the private cleanup credential. + +FastAPI retains one small multipart compatibility endpoint with a central hard +maximum of 256 MiB. It rejects oversized declared `Content-Length` before reading +and enforces the same maximum while streaming to quarantine. It is never used for +multi-gigabyte media. + +URL ingestion is deferred. If added later, it is an asynchronous downloader with +SSRF controls, redirect and DNS revalidation, size/time limits, quarantine, and +ffprobe validation. + +### 9.4 MCP and media + +Remote MCP workflow: + +```text +HTTP upload/import service -> media_id -> MCP start_indexing(media_id) +``` + +MCP does not carry video bytes, base64 video, or arbitrary server paths. Stdio may +operate on an existing local `media_id`, but is not a video injection protocol. + +## 10. Index generations and repository snapshots + +An `IndexGeneration` is an immutable index of one media asset under one capability +and model/runtime manifest. An `IndexSnapshot` is an immutable repository view that +maps each active `media_id` to the generation containing its current searchable +records. + +This separation supports incremental libraries. Adding or re-indexing one video does +not rebuild every other video and does not mutate the currently searchable version. + +An upsert indexing job: + +1. Resolve and validate the media asset. +2. Acquire a lease for that repository and media asset. +3. Build under `indexes/generations/`. +4. Persist manifests and checkpoints inside the generation. +5. Validate capability completion and storage consistency. +6. Create a new snapshot by replacing only that media asset's generation mapping. +7. Atomically update `active-snapshot.json`. +8. Release the lease. +9. Retain or garbage-collect unreferenced generations according to policy. + +Removing media creates a snapshot without that media mapping; it does not delete +index data before the new snapshot is active. A full rebuild creates generations for +the selected assets and commits one new snapshot after all required generations +succeed. + +Failure or cancellation before step 7 leaves the prior snapshot and prior generation +searchable. + +Search resolves `active-snapshot.json` once and queries only generations referenced +by that immutable snapshot. Indexing and search therefore coexist safely. + +The Chroma adapter stores generation identity with every record and implements +snapshot-scoped search and garbage collection. Chroma remains replaceable behind the +`IndexRepository` port; snapshot semantics do not depend on Chroma collection layout. +For the embedded adapter, `indexes/store/` is the shared physical Chroma database; +generation directories own manifests and checkpoints, while exact generation record +counts in those manifests are revalidated before committed reads. A missing database, +collection, manifest, or referenced record therefore fails closed instead of creating +an empty replacement during a read. + +The manifest is authoritative for completed generation state. Progress files and +workflow events are not used to decide whether a generation or snapshot is valid. + +## 11. Durable jobs and process execution + +The application exposes one `JobService` contract: + +- submit +- get +- list +- cancel +- retry/resume where safe +- publish progress +- retrieve result + +DBOS 2.28 is the durable orchestration, queue, and state backend: + +- SQLite for local CLI/UI/desktop +- one Postgres database for the API catalog, uploads, snapshots, and DBOS's + isolated `dbos` schema in API/Coolify +- the same workflow definitions and job contract in both modes +- separate CPU and GPU worker queues +- concurrency one by default for indexing per constrained runtime + +DBOS owns durable lifecycle, queueing, recovery, status, attempts, and persisted +progress. `set_event` stores the latest `JobProgress`; append-only DBOS streams are +used only when event history is required. Workflow stream writes are exactly once; +step stream writes may repeat after retry. The index manifest remains authoritative +for index state. + +DBOS synchronous workflows and steps run in threads inside the worker process. +DBOS does not provide process isolation or pre-empt blocking CPU/GPU calls. Heavy +work therefore always runs in a dedicated worker process/container, never in the +CLI, desktop, UI, API, or MCP process: + +```text +local: +CLI/UI/desktop -> DBOSClient -> supervised local worker process -> SQLite + +server: +API/MCP -> DBOSClient -> worker process/container -> Postgres +``` + +The local client may detach from the durable worker. SQLite is restricted to one +machine and local storage; it is not used on network filesystems or across hosts. + +The core runner accepts one execution context with progress and cooperative +cancellation. Queued cancellation removes the DBOS workflow. Cancelling an active +synchronous DBOS step only marks it cancelled and is observed at a later step; it +does not stop the running thread. VidXP therefore bridges durable cancellation to +the runner's `CancellationToken` and polls at progress/batch boundaries. Blocking +model and FFmpeg calls that expose no cancellation point finish before cancellation +is observed. Hard interruption is an operator-level termination of the dedicated +concurrency-one worker followed by supervisor restart; it is not promised as a +safe per-call API feature. + +This cooperative/process boundary is execution control, not another job-state +machine. VidXP does not recreate queue/job lifecycle in JSON and locks. + +Retry is enabled only for idempotent steps. Generation-based indexing makes retries +safe because incomplete generations are never active. + +Local workers use a stable executor ID. Server executor IDs are +`--` and remain stable across replacement of the +same release, while blue/green releases receive distinct IDs. The v1 server topology +uses exactly one CPU executor plus exactly one GPU executor when the GPU profile is +enabled. Each calls `DBOS.listen_queues()` before launch. The API uses `DBOSClient` +and never launches or listens to queues. A same-release replacement recovers work +owned by its stable ID; an old-version worker remains available while its workflows +drain. Scaling a release to multiple ordinals requires DBOS Conductor or a separately +reviewed dead-executor recovery coordinator. + +`worker_concurrency=1` limits one executor; repository leases enforce per-repository +exclusion. Queue-global concurrency is configured only where one job across every +worker is intentionally required. + +`application_version` combines the VidXP release with a digest of the installed +workflow implementation. Recovery therefore occurs only on a worker running the +same code, including when a prerelease build changes without a package-version +bump. Deployments drain old-version jobs or run blue/green old-version workers until +they finish; breaking workflow changes require an explicit DBOS patch/migration +strategy. + +## 12. Resource scheduling and model runtime + +`RuntimeProfile` is resolved at startup: + +- operating system and architecture +- requested backend: `auto`, `cpu`, `cuda:`, or `mps` +- resolved backend per capability +- precision/compute type per capability +- model cache +- offline/download policy +- maximum concurrent indexing jobs +- maximum concurrent inference queries +- allowed model IDs and revisions + +Hardware probing occurs once. Invalid CUDA or MPS configuration fails readiness +clearly instead of falling back silently. + +`auto` is allowed only for local CLI/desktop. It resolves once, records the actual +backend used by each capability in the job/index manifest, and never changes during +a job. A capability may resolve differently from another capability on the same +machine; for example, scene embedding can use Apple MPS while transcription uses +CPU. Server/Coolify workers require explicit `cpu` or `cuda:` so scheduling +cannot drift. + +Model construction and caching belong to `ModelRuntime`. Adapters and capability +handlers do not each load models independently. + +Model downloads are explicit preparation jobs. Doctor/readiness checks inspect +the pinned cache without constructing models or downloading, and ordinary +indexing or query work fails fast with `model_unavailable` when required +artifacts are absent. Preparation publishes byte progress and model-loading +stages through the shared durable job contract. Pinned snapshot and artifact +byte sizes are part of the model contract. Interactive preparation displays +the missing-model sizes, maximum additional cache space, and cache path before +requiring confirmation. + +The scheduler bounds concurrent model work. Local execution does not reject work +based on a fixed free-RAM threshold; allocation failures come from the runtime +that attempted the actual operation. API request threadpools are not the model +scheduler. + +### 12.1 Scene sampling delivery boundary + +The current scene-indexing deliverable uses deterministic time-based sampling at +a configured interval. This is the minimal sampling path required to make scene +indexing predictable and runnable through the complete application; it does not +claim content-aware or shot-aware frame selection. + +Content-aware or shot-boundary sampling is deferred. Before it is considered for +the default path, benchmark it against time-based sampling for retrieval quality, +indexing time, materialized frames, and memory use. Add sampling-specific +backpressure only if those measurements show that bounded model/write batches and +the existing scheduler do not adequately control resource use. + +## 13. Cross-platform CPU and acceleration + +CPU completion is the first platform gate. The supported local targets are: + +- Apple Silicon macOS on the current and previous two major macOS releases +- Linux x86-64 +- Windows x86-64 + +Linux ARM64 is accepted only after the same native-wheel and model smoke gates pass. +Intel macOS is not a target for the new local runtime. + +The native macOS path does not require Docker. It uses: + +- the current stable CPython ARM64 installer/runtime +- a supervised local worker and DBOS SQLite +- embedded Chroma +- local FFmpeg/ffprobe +- PyTorch MPS for capabilities that pass parity tests +- CPU inference where a provider has no stable Metal backend +- native Ollama, which manages its own Metal acceleration + +The initial macOS transcription provider is CPU-capable. An MLX provider can replace +or supplement it only after it passes the same transcript/timestamp contracts; the +rest of VidXP does not depend on that optimization. + +CUDA is a later delivery phase, after the CPU application, API/MCP, media, jobs, +index snapshots, and deployment paths are complete. It uses the same application +contracts with a distinct Linux worker image: + +- the latest mutually compatible stable Python, PyTorch, CTranslate2, CUDA, and + cuDNN versions at the start of the GPU phase +- Docker/Compose GPU device reservation +- explicit precision and batch defaults per capability +- worker readiness that verifies CUDA without downloading models + +The GPU phase does not inherit the old PyTorch 2.8/WhisperX matrix. If a package +requires an obsolete Python/PyTorch/CUDA stack, replace or isolate that package +instead of downgrading the platform by default. `torchaudio` and `torchvision` are +installed only when a selected provider directly requires them. + +PyTorch CPU/CUDA variants are selected through separate locked constraints and +explicit package indexes. An `extra-index-url` is not used where it can mix CPU and +CUDA artifacts. + +Capability routing is explicit after acceleration acceptance: + +- scene and text embeddings: CPU everywhere; MPS on supported Macs and CUDA on the + GPU worker after parity tests +- transcription: CPU everywhere; CUDA through the selected CTranslate2 provider + after parity tests; an Apple MLX provider is a separate optimization +- actor: CPU until its ONNX GPU provider passes clustering/search parity +- SLM/Ollama: CPU/Metal locally; CPU by default in the server profile + +API/MCP never receives CUDA access. When the GPU profile is enabled, only +`worker-gpu` receives CUDA access. An optional Ollama CUDA profile can receive a +different explicit `device_id`. Sharing one device between the indexing worker and +Ollama requires a later cross-process VRAM admission design. + +Readiness reports platform, architecture, provider versions, selected backend, +precision, and device details. CUDA readiness reports driver, compiled CUDA, +device index/capability, and `torch.cuda.is_available()`. MPS readiness reports +`torch.backends.mps.is_available()`. Unsupported requested acceleration fails +instead of silently using CPU. + +Model weights are not baked into the default release image. First-use download and +offline/preload behavior are documented and observable. Preloaded model images are +not part of this architecture. + +## 14. Capability system + +Capability definitions contain domain metadata only: + +- name and description +- supported index stages +- operations and their shared input/result models +- execution group +- required media/index data +- model/runtime requirements +- query affordances and expected cost + +They do not contain Typer factories or MCP/FastAPI metadata. + +Built-ins are registered by the composition root. External capabilities use Python +package entry points. Execution grouping is explicit; it is not inferred from Python +callable identity. + +Capabilities depend on storage/model/media ports. Chroma and model packages are +infrastructure implementations. + +External packages register under entry-point group `vidxp.capabilities`. Each entry +point loads a zero-argument factory returning a transport-neutral +`CapabilityPlugin` containing a `CapabilityDefinition`, application executor +factory, and declared VidXP capability-contract version. The definition remains +pure metadata; the executor factory binds operations to handlers that depend only on +application/domain ports. + +External capabilities are trusted in-process code, not sandboxed plugins. They are +disabled by default in remote deployments and enabled by an allowlist of distribution +and entry-point name. Built-in names cannot be overridden. Duplicate names, +unsupported contract versions, import failures, and missing runtime requirements +fail readiness with distribution provenance. Discovery/loading occurs only in the +composition root and is sorted deterministically. + +### 14.1 Collector dependency decisions + +- Scene: remove `clip-anytorch` and its `setuptools<81` compatibility pin. Use + Transformers with `google/siglip2-base-patch16-224` at immutable revision + `75de2d55ec2d0b4efc50b3e9ad70dba96a7b2fa2`. The 2025 SigLIP 2 paper reports + retrieval improvements over SigLIP across model scales, and MIEB independently + places SigLIP-family models among strong multimodal retrieval encoders. That + evidence is recent and matches the image/text retrieval task closely enough that + downloading several large candidates for another selection benchmark is not + justified in this delivery phase. VidXP's existing scene benchmark remains the + regression gate. +- Dialogue: replace WhisperX as the default provider because its stable release + blocks current Python and constrains an older Torch family. The first replacement + is latest stable `faster-whisper`/CTranslate2 using batched transcription, VAD, and + word timestamps. The default is + `dropbox-dash/faster-whisper-large-v3-turbo` at immutable revision + `0a363e9161cbc7ed1431c9597a8ceaf0c4f78fcf`. Its transcript/timestamp output is + tested against the existing + dialogue contract. Forced alignment is an optional provider behind a separate + contract; it cannot hold base transcription or Python back. Sentence embeddings + use Qwen3-Embedding-0.6B at immutable revision + `97b0c614be4d77ee51c0cef4e5f07c00f9eb65b3`; its published multilingual MTEB + retrieval results materially exceed the older multilingual E5 baseline and its + Apache-2.0 license permits the intended deployment. +- Actor: replace `face_recognition`/dlib with OpenCV Zoo YuNet plus SFace through + OpenCV's maintained DNN APIs. Model files are retrieved with `pooch`, pinned to + OpenCV Zoo commit `47534e27c9851bb1128ccc0102f1145e27f23f98`, and verified + against recorded SHA-256 digests before use. + Migration requires clustering/search parity, a new index-schema/model identity, + and CPU performance gates. Actor remains CPU-routed until the ONNX GPU provider + passes the same gates. +- Storage: embedded Chroma `PersistentClient` is local CLI/desktop only. Server + deployments use the latest stable Chroma client/server release behind the + `IndexRepository` port. + +`ModelRuntime` records provider, canonical model ID, immutable revision/digest, +weight checksum, license identifier, precision/quantization, cache state, and offline +policy in every generation manifest. Transitive VAD/alignment/model downloads are +rejected unless allowlisted. + +## 15. Search + +`SearchMoments` is a central application query: + +- query text +- optional modalities/capabilities +- filters from an allowlisted typed grammar +- page size/cursor +- optional reranking profile + +Results are normalized into `SearchHit`: + +- `media_id` +- `video_id` +- source/index generation +- modality +- start/end time +- score and score semantics +- displayable text/metadata +- optional preview reference + +The application owns limits, score normalization, multimodal fusion and deterministic +ordering. UI/API/MCP may request stricter limits but cannot loosen policy. + +Actor cluster and detection queries use the same pagination conventions. + +## 16. Natural-language query layer + +`QueryVideo` is an application use case, not an MCP agent loop and not direct Chroma +access. + +Flow: + +1. Accept question, media/repository scope, and policy. +2. Ask an injected local SLM planner for a strictly typed `QueryPlan`. +3. Validate that the plan uses registered operations and safe parameters only. +4. Execute retrieval through application search/capability operations. +5. Fuse overlapping intervals with deterministic reciprocal-rank fusion. +6. Ask the answer synthesizer for a grounded answer. +7. Return `QueryAnswer` with timestamped citations and supporting hits. + +Pydantic AI is the selected typed-call layer. The first local provider is self-hosted +Ollama 0.32.5, pinned to a tested multi-architecture image digest, through +`pydantic-ai-slim[openai]` and `OllamaModel`. Ollama Cloud is excluded because it +does not enforce the local JSON-Schema path required here. `QueryPlan` and the +model-only `DraftAnswer` use `NativeOutput`, strict Pydantic models, a bounded +request/output retry budget, and explicit time/token limits. VidXP validates the +draft and constructs `QueryAnswer`; the model receives no executable tools. +Pydantic AI does not replace VidXP's capability registry or search storage. + +When the SLM is unavailable or produces an invalid plan, a deterministic fallback +searches applicable modalities and returns evidence. Generated text can never become +a filesystem path, model identifier, arbitrary filter operator, or executable +operation. + +Before returning a generated answer, the application verifies every citation against +the retrieved hit ID, media ID, generation, and time interval. Missing, invented, or +out-of-range citations invalidate the generated answer and trigger the deterministic +evidence fallback. Typed JSON alone is never treated as proof of grounding. + +The result records the configured provider/model identity and plan for +reproducibility. + +The default SLM model ID, immutable revision/digest, and quantization are +intentionally unset until candidate models pass: + +- both output schemas +- adversarial-plan rejection +- grounded-citation verification +- CPU RAM and latency limits +- GPU VRAM limits where applicable +- offline-cache behavior +- license and redistribution review + +Until that gate passes, the provider is fixed to self-hosted Ollama but no arbitrary +caller-provided model string is accepted. Ollama runs as an internal optional `slm` +Compose profile with a persistent model cache; deterministic evidence retrieval +remains available when it is disabled. + +## 17. Artifact and snippet delivery + +`Artifact` records: + +- `artifact_id` +- source `media_id` +- owning job +- type/profile +- MIME and byte size +- checksum/storage key +- state +- creation/expiry + +Media and artifacts are served from the managed local content volume through +protected Starlette `FileResponse`, including byte-range support. + +Every delivery request is handled only after repository authorization. Artifact +delivery resolves an `ArtifactStore` key beneath its configured root, rejects +symlink/path escapes, and only then constructs `FileResponse`. + +Local source playback applies the identical configured-root and symlink/path-escape +checks to the `MediaStore` key before constructing `FileResponse`. + +Search results normally use an authorized range-enabled source URL and client-side +seeking. VidXP does not generate a clip for every search hit. + +`CreateSnippet` is an asynchronous artifact job used for download/share/MCP resource +links. FFmpeg output is cached by source checksum, interval and encoding profile, +written to a temporary key, validated, and atomically published. + +Actor overlays and other rendered media use the same artifact workflow. Public +rendering commands never accept output paths. The separate CLI artifact-download +command may copy an already-authorized managed artifact to an explicit user +destination; API downloads remain protected responses and MCP returns a lazy +resource link instead of embedding video bytes in the tool result. + +## 18. FastAPI adapter + +FastAPI owns: + +- HTTP routing and content negotiation +- authentication dependency and principal projection +- request/correlation IDs +- transport-level body rejection +- status codes and headers +- OpenAPI +- small multipart compatibility +- protected local artifact responses + +FastAPI calls application commands directly. It has no job manager, media store, +index lock, model cache, or MCP client. + +Use shared application models directly where the HTTP shape matches. Add transport +models only for HTTP-specific links or envelopes. + +Provide: + +- `/health` for minimal process liveness +- `/ready` for a minimal aggregate readiness result +- an authenticated runtime-readiness resource for component and device details + +Readiness does not download model weights. + +## 19. Authentication profiles + +### 19.1 Local + +- CLI/UI/desktop in-process: no network authentication. +- Local HTTP bound to loopback: optional static bearer. +- Local stdio MCP: operating-system process boundary. + +### 19.2 Private self-hosted + +A shared static bearer token is permitted as an explicitly documented single-tenant +mode. It uses constant-time verification and produces a typed principal. It is not +described as OAuth and does not imply users, scopes, or revocation. + +For HTTP API and MCP in this mode, one outer ASGI authentication middleware validates +the token before dispatch. The only public-path exceptions are the minimal +`/health` and `/ready` endpoints, which disclose no configuration or component +details. It does not publish fake OAuth issuer/resource metadata. + +The private tusd hook callback is also excluded from public bearer middleware, but +is not a public exception: the reverse proxy does not route it from the public +listener and only the isolated Compose hook network can reach it. The hook handler +validates the original client authorization carried in the `pre-create` hook event +against the upload intent. Later hook events are authorized by the opaque upload ID +and previously authenticated intent, not by assuming that the client bearer will be +present on every resumed request. Hook contents and credentials are redacted. + +### 19.3 Remote/multi-user + +An external OIDC provider issues access tokens. VidXP validates issuer, audience, +expiry and scopes and produces the same typed principal used by API and MCP. + +The MCP server implements OAuth resource-server and protected-resource metadata +required by the protocol. Tokens received by MCP are not forwarded to an internal +HTTP API. + +VidXP's MCP `TokenVerifier`, not the SDK metadata configuration, validates: + +- allowed signature algorithm and signature through cached JWKS +- exact issuer +- `aud` or RFC 8707 resource equal to the canonical public MCP URL +- expiration and not-before +- required scopes + +Only after those checks does it construct the SDK access token and shared VidXP +principal from subject/client ID, scopes, and allowlisted claims. + +Reverse-proxy authentication may be an additional gate but does not replace MCP +resource-server behavior. + +## 20. MCP adapter + +Use the official MCP Python SDK v2 high-level server. + +Primary remote transport: + +- Streamable HTTP +- explicit `stateless_http=True` +- `json_response=False` so disconnect cancellation uses the streaming/SSE path +- application state persisted outside MCP sessions +- explicit lifecycle through the application composition root +- an initial independent MCP request-body limit of 4 MiB + +Local transport: + +- stdio for clients running beside VidXP +- identical tools and shared contracts +- media referenced by `media_id` + +Initial curated tools: + +- `list_capabilities` +- `get_capability` +- `list_media` +- `get_media` +- `get_index_status` +- `start_indexing` +- `search_moments` +- `query_video` +- `list_jobs` +- `get_job` +- `retry_job` +- `cancel_job` + +Media and job discovery let an agent recover registered assets and durable work +without carrying IDs across sessions. Generic job polling, retry, and cancellation +cover indexing, search, and query without duplicating operation contracts. Video +bytes remain on the HTTP/tus ingestion boundary rather than crossing MCP. + +Tool results use real output schemas and structured content. Descriptions remain +short and agent-oriented; full API response schemas are not embedded as prose. + +Every public tool has an explicit Pydantic result annotation and leaves structured +output enabled. Public tools do not return bare primitives/lists because the SDK +wraps them in a synthetic `result` object. Contract tests cover runtime-only Pydantic +validators in addition to emitted JSON Schema. + +Protocol request cancellation is propagated to in-flight search/query work where +safe. It does not silently cancel an already accepted durable indexing job. +Cancellation is cooperative for synchronous CPU/GPU work; blocking functions are +never treated as pre-emptible merely because AnyIO runs them in a thread. + +MCP does not implement its own queue. Persisted VidXP jobs remain the authoritative +long-running operation contract. + +### 20.1 ASGI mounting and lifecycle + +The remote process exposes API and MCP on one public origin while keeping them as +sibling adapters. + +The MCP ASGI app is created with: + +- Streamable HTTP path `/mcp` +- stateless HTTP enabled +- JSON-only responses disabled +- configured MCP body limit +- explicit transport-security settings + +It is mounted at the outer application's root after concrete FastAPI routes. This +keeps both `/mcp` and the RFC 9728 host-root +`/.well-known/oauth-protected-resource/mcp` route reachable. + +The composition-root lifespan enters `mcp.session_manager.run()` exactly once and +orders shared application startup, MCP startup, MCP shutdown, and application +shutdown explicitly. A mounted Starlette child lifespan is not relied on, and MCP +does not start a second copy of shared database/storage/model resources. + +The public/proxy Host and Origin policy is configured explicitly. Binding to +`0.0.0.0` must not disable DNS-rebinding protections. Deployment validation confirms +that the Coolify proxy preserves `Accept`, `MCP-Protocol-Version`, `Mcp-Method`, and +`Mcp-Name`, and does not buffer MCP streaming responses. + +## 21. Streamlit and desktop adapters + +Streamlit retains: + +- widgets and page/session state +- display formatting +- local user interaction + +It loses: + +- fixed global media/artifact paths +- direct file persistence +- index readiness policy +- process and cancellation ownership +- hardcoded capability branching +- model/storage access + +The desktop application uses the same application commands, per-user data-root +layout, and a local DBOS SQLite workflow store, but heavy jobs run in a +supervised separate worker process. It manages client/worker lifecycle, +displays model preparation progress, and detaches from durable work or requests +cooperative cancellation during shutdown. Closing the UI never waits on an +uncancellable model thread inside the UI process. + +The Phase 11 adapter is a small Tauri v2 shell. Its first-run configuration +selects capability extras, optional interfaces, model preparation, and model +storage, while the processing application is the exact published VidXP package +installed into a versioned uv-managed environment. When selected, the Streamlit +adapter is the local human interface on a random loopback port; remote loopback +content receives no Tauri IPC access. Runtime activation is atomic and a failed +configuration retains the prior environment. Tauri owns the Streamlit process +and asks the repository-scoped worker to drain and shut down on exit. + +The first desktop release is an online bootstrap with a native tray supervisor +and no updater. It targets Windows x86-64 NSIS, Apple Silicon DMG, and Linux +x86-64 AppImage. +Target-specific uv binaries are release inputs verified against the pinned +upstream checksum. FFmpeg/ffprobe remain validated system dependencies until +target build provenance, codec selection, and redistribution licenses are +recorded; the application does not silently fetch an unaudited build. + +### 21.1 Operating modes + +The same command and result contracts support three composition profiles: + +1. **Native local:** the CLI, Streamlit, or desktop adapter composes the application + with a local worker, DBOS SQLite, embedded Chroma, and a platform app-data + repository. Media can be imported from allowlisted local paths. This is the + default Apple Silicon experience and does not require Docker. +2. **Remote client (planned):** a thin CLI, UI, or desktop adapter will connect + to a self-hosted VidXP deployment without local models or a vector database. + The current release rejects `VIDXP_MODE=remote` instead of silently composing + local storage. Remote agents are supported now through the Streamable HTTP MCP + endpoint, and other clients can use the authenticated HTTP/tus APIs directly. +3. **Self-hosted server:** API/MCP, workers, workflow state, media ingestion, and + vector search run as the Coolify stack. The same server also supports ordinary + Compose outside Coolify. + +The planned client configuration is limited to server base URL, authentication, +repository, timeouts, and local upload source. Its transport must implement the +same client-facing command interface as the native application facade without +duplicating validation, indexing, search, or artifact policy. + +The distributable Python package has a lightweight client/core installation and +explicit local/server extras. Installing the thin client must not install PyTorch, +model providers, Chroma server components, or CUDA packages. Platform lock files +resolve the local worker dependencies independently from the control plane and thin +client. + +## 22. Deployment topology + +Supported Coolify/Compose server stack: + +```text +api-mcp + ├── FastAPI routes + └── MCP Streamable HTTP mount + +hooks + └── private tusd callback and recovery sweep + +worker-cpu + └── DBOS CPU queue, stable executor ID, concurrency 1 + +postgres + ├── application catalog, uploads, and snapshot metadata + └── DBOS durable workflow state in the `dbos` schema + +chroma + └── private vector-search server with its own volume + +tusd + └── public /uploads/ route and HTTP hook caller + +single-node media/artifact/quarantine volumes +``` + +This is one node, one application stack, and one repository. PostgreSQL, Chroma, +tusd, and the named content volumes are deployment components of that stack, not +provider plug-in points. A separate repository requires a separate stack and +separate databases and volumes. Arbitrary hosted databases, externally shared +Chroma, multiple API/worker replicas, failover, and provider compatibility are +outside the supported topology. + +Server code connects only to the internal Compose service names `postgres` and +`chroma`. Their endpoints are fixed rather than exposed through +`VIDXP_DATABASE_URL`, `VIDXP_CHROMA_SERVER_URL`, or equivalent user settings. + +Optional application services: + +- GPU worker supplementing the CPU worker +- internal CPU-default Ollama `slm` profile +- external OIDC provider configuration + +Postgres, Chroma, and hook endpoints remain private. Only API/MCP and the tus upload +route are published. Long-running services have healthchecks; one-shot storage, +migration, and Chroma-readiness gates must complete before their consumers start. +Required secrets use `${VAR:?required}` rather than insecure defaults. Public +routing cannot reach the tusd callback path; tests exercise that denial +independently of the private-network hook flow. + +Release artifacts are: + +- the native `vidxp` CLI/client package for supported macOS, Linux, and Windows +- an explicit local-worker extra and platform lock for native CPU indexing +- `compose.local.yaml`: an optional containerized Linux evaluation/development + profile; it is not required for native macOS use +- `compose.coolify.yaml`: immutable prebuilt API/MCP, worker, Postgres, Chroma and + tusd images with no build contexts; the same file is valid with ordinary Compose +- `compose.gpu.yaml`: supplemental GPU worker override +- optional `slm` profile + +Use immutable release tags or digests. API/MCP and workers share an application +release but use purpose-specific image targets. API/MCP carries no heavy model or +CUDA dependencies; workers and the optional `slm` service do. + +Deployment v1 is exactly one API/MCP replica and one node. Named +media/artifact/quarantine volumes are part of this topology. The fixed internal +service names and storage paths are not promises of arbitrary external-provider, +multi-replica, or high-availability compatibility. + +The one-click template consumes published tags/digests and must pass a real Coolify +deployment, upgrade, rollback, persistent-data and healthcheck test. + +## 23. Code to retain, move, or remove + +Baseline facts that the rebuild must not mistake for target behavior: + +- project metadata currently requires Python `>=3.10,<3.14`; the target baseline is + the current stable Python release with the supported matrix defined in section 5.1 +- the current Dockerfile/Compose produce one CPU-only Streamlit image/service +- server storage currently uses embedded `chromadb.PersistentClient` +- model loaders use per-module `lru_cache`, not one runtime/resource scheduler +- WhisperX compute type is hardcoded to `float32` +- repository configuration mixes deployment device choice into persistent identity +- the capability registry statically imports every built-in +- `CapabilityDefinition` currently carries CLI and implementation callables +- `clip-anytorch` requires the obsolete `setuptools<81` compatibility pin + +Retain and adapt: + +- capability-specific model/config logic +- frame/audio processing +- FFmpeg/ffprobe helpers +- manifest/checkpoint concepts +- Chroma storage initially +- existing search operations +- CLI rendering and Typer declarations + +Move behind contracts: + +- Chroma access +- repository layout +- media probing and persistence +- model loading/caching +- indexing execution +- actor rendering +- capability registration + +Remove instead of preserving: + +- adapter-owned job state machines +- adapter-owned media stores +- Streamlit process/global persistence policy +- MCP-to-FastAPI internal HTTP calls +- generated OpenAPI-to-MCP mirroring +- environment mutation as application configuration +- module-level application singletons +- progress files as index validity +- destructive in-place active-index replacement +- raw server paths in remote contracts +- caller-selected output paths +- duplicated transport-specific business models +- hardcoded central capability imports as the extension mechanism + +## 24. Verification strategy + +### 24.1 Contract tests + +The same command fixture must produce equivalent CLI JSON, API JSON and MCP +structured results after removing transport-only fields. + +### 24.2 Index durability + +Test: + +- failure before generation commit +- cancellation at each indexing stage +- process death and recovery +- search during replacement +- incremental add/re-index/delete +- atomic active-snapshot switch +- incomplete generation cleanup + +The prior snapshot and affected media generation must remain searchable in every +pre-commit failure. + +### 24.3 Job behavior + +Run the same workflow contract against: + +- SQLite local mode +- Postgres multi-process mode +- queued cancellation +- active cooperative cancellation +- worker restart +- idempotent retry +- CPU and GPU queue routing +- UI/client exit while a local worker continues +- cooperative cancellation latency at every runner checkpoint +- blocking-step cancellation behavior +- stable-executor restart recovery +- release-version drain and recovery + +### 24.4 Media + +Test: + +- resumed upload +- documented bearer-URL behavior and log/referrer redaction +- checksum/deduplication +- invalid extension with real video +- valid extension with invalid content +- interrupted and expired uploads +- duplicate/out-of-order hook delivery +- missed-finish-hook recovery sweep +- abandoned intent/quarantine cleanup +- completion remaining unpublished until durable ffprobe succeeds +- import-root escape +- retained external source disappearance + +### 24.5 MCP + +Validate with the official client over: + +- Streamable HTTP from a separate process +- stdio +- structured output schemas +- union/optional schema fidelity +- auth metadata and audience rejection +- protocol cancellation +- cancellation through the deployed non-buffering proxy +- durable job polling after session/client restart +- bounded actor/search responses +- host/origin rejection and required MCP proxy headers + +### 24.6 Capabilities and artifacts + +Capability discovery tests: + +- no installed plugin +- allowlisted plugin +- disabled plugin is not imported +- duplicate built-in/name +- incompatible contract version +- import failure +- deterministic behavior on supported Python 3.11 through 3.14 + +Managed artifact delivery tests cover authorization, path/symlink containment, +missing or corrupt content, GET/HEAD/range headers, and MIME/disposition metadata. + +### 24.7 Platforms and installation profiles + +Run clean-environment native tests for the latest stable Python on: + +- Apple Silicon macOS +- Linux x86-64 +- Windows x86-64 + +The Apple Silicon gate covers native package installation, FFmpeg/ffprobe, +separate-process DBOS worker recovery, embedded Chroma persistence, scene +CPU/MPS routing, transcription CPU routing, actor ONNX CPU routing, cancellation, +and clean uninstall. It also runs an end-to-end remote-client flow against the +Compose server: resumable media upload, indexing, polling, search, and artifact +retrieval. + +The thin-client environment must import and execute without model, Chroma server, +CUDA, or local-worker dependencies. The local-worker environment must run without +CUDA libraries. Package metadata, platform locks, and release images are tested +from an empty cache so undeclared system or developer-machine dependencies cannot +mask failures. + +### 24.8 CPU/GPU + +Before model downloads: + +- build CPU and GPU targets +- run clean-image `pip check` +- generate SBOM and license/model manifests +- prove the CPU image contains no CUDA runtime +- inspect installed PyTorch/CUDA compatibility +- run CUDA availability/readiness probes +- validate Compose GPU reservation +- validate worker queue routing with a synthetic job +- prove actor work routes to CPU +- validate OOM, cancellation and worker-restart behavior without model downloads + +After CPU functionality is complete: + +- download one pinned model set +- run selected scene and transcription provider smoke tests on a supported GPU +- compare golden outputs against CPU +- record VRAM, precision, throughput and fallback behavior + +## 25. Implementation sequence and gates + +### Phase 1: contracts and composition + +- settings and application factory +- shared identifiers/models/errors +- repository layout +- capability registry cleanup +- lightweight client/core, local-worker, and server dependency groups +- current-Python package metadata and platform lock generation + +Gate: no adapter imports concrete storage/models, cross-adapter schema tests pass, +and the thin-client environment installs and imports without ML/server dependencies. + +### Phase 2: current CPU provider baseline + +- replace obsolete scene, dialogue, and actor provider dependencies +- pin licensed model revisions and record index identities +- model runtime and resource scheduler +- embedded Chroma adapter on the latest stable compatible release +- native CPU packaging for Apple Silicon macOS, Linux x86-64, and Windows x86-64 + +Gate: clean environments on the latest stable Python pass dependency, import, +license, and representative CPU capability smoke tests on all supported platforms. +Apple Silicon also passes any enabled MPS parity test; unsupported acceleration +remains explicit and does not block its CPU path. + +### Phase 3: durable index foundation + +- immutable generations +- authoritative manifests +- immutable multi-media snapshots +- active-snapshot pointer +- repository leases + +Gate: incremental add/re-index/delete works and injected failure/cancellation never +damages the active snapshot. + +### Phase 4: media and artifacts + +- media catalog/store +- local import +- artifact catalog/store +- source playback and snippet contract + +Gate: application operations use IDs, not public filesystem paths. + +### Phase 5: workflow engine + +- DBOS job service +- SQLite local mode +- Postgres worker mode +- typed progress/cancellation + +Gate: restart, cancel and retry tests pass without adapter-owned job state. + +### Phase 6: thin API + +- FastAPI adapter +- auth profiles +- readiness +- small-upload compatibility +- artifact delivery + +Gate: API process performs no indexing/model work and owns no business persistence. + +### Phase 7: remote ingestion and deployment + +- tusd integration +- Coolify Compose +- immutable image targets + +Gate: interrupted multi-part upload resumes and completed media can be indexed by ID. +The gate also covers bearer upload-URL redaction, duplicate/out-of-order hooks, +missed-finish recovery, abandoned-upload retention, and the ffprobe publication +boundary. + +### Phase 8: MCP + +- official SDK v2 server +- Streamable HTTP and stdio +- curated typed tools +- remote auth + +Gate: separate-process remote client completes media-id indexing, polling and search; +schemas and structured results pass conformance checks. + +### Phase 9: multimodal and SLM query + +- fused search +- typed query planning +- evidence-grounded answer synthesis +- deterministic fallback + +Gate: every answer cites retrievable media intervals and invalid plans cannot escape +the operation grammar. + +### Phase 10: GPU + +- GPU worker image/profile +- runtime validation +- pinned model smoke tests +- resource limits + +The 2026-07-29 evaluation selects PyTorch 2.13 `cu126` as the first compatible +NVIDIA baseline. CUDA 12.8 has no Torch 2.13 distribution, while CUDA 13 does +not share current CTranslate2/faster-whisper's CUDA 12 runtime contract. The +full worker set resolves on Python 3.14 to Torch 2.13.0, CTranslate2 4.8.1, +faster-whisper 1.2.1, and cuDNN 9.10.2. Implementation remains deferred until a +CUDA host and prepared model cache are available; no model or image was pulled +during the evaluation. See `docs/deployment/gpu-evaluation.md`. + +Gate: GPU readiness and execution are explicit; no silent CPU fallback. +Clean-image dependency/SBOM checks, CPU-without-CUDA proof, selected scene and +transcription provider smoke tests, CPU actor routing, OOM handling, cancellation, +and worker restart must all pass. + +### Phase 11: desktop + +- desktop adapter +- platform repository lifecycle +- packaging and update strategy + +Gate: desktop uses the same commands/jobs/media/indexes without UI-owned business +logic. + +## 26. Decision validation ledger + +Validated on 2026-07-28: + +- **Official MCP SDK 2.0:** accepted. A separate-process Streamable HTTP probe and a + stdio probe passed tool discovery/calls, Pydantic discriminated unions, optionals, + constraints, `outputSchema`, and `structuredContent`. Required corrections for + mount lifespan, root metadata path, token audience validation, safe errors, + streaming cancellation, host/origin policy, and the MCP-specific body limit are + incorporated above. +- **External FastMCP 3.4.5:** rejected for this phase because its server extra + requires MCP SDK `<2`. The similarly named high-level server in official SDK v2 + is now `MCPServer`; it supplies typed schemas, structured results, Streamable + HTTP, stdio, OAuth resource-server behavior, transport security, and request + limits without generating tools from HTTP routes. +- **fastapi-mcp 0.4:** rejected because it generates an OpenAPI mirror and invokes + the FastAPI application through an internal ASGI HTTP client. That duplicates + transport contracts instead of using VidXP's shared application services. +- **DBOS 2.28:** accepted as durable queue/workflow state, not as process isolation. + A Windows/Python 3.13 probe exercised SQLite queue, progress, stable-executor + crash recovery, and a separate local worker process. Postgres 16 API-client plus + distinct CPU/GPU worker processes and queue routing were exercised. These probes + validate behavior, not the target Python floor or release lock. Active synchronous + cancellation was proven non-preemptive; the separate worker/cooperative semantics + above are therefore mandatory. +- **Current control-plane dependency baseline:** compatible at audit time. + `fastapi==0.140.13`, `mcp==2.0.0`, `dbos==2.28.0`, + `pydantic-settings==2.14.2`, and + `pydantic-ai-slim[openai]==2.19.0` resolved together on Python 3.13. These are + observations, not permanent pins. Implementation re-resolves the latest stable + releases on the latest Python 3.14 maintenance release and records only justified + compatibility exceptions. Release images lock the resulting direct and + transitive artifacts. +- **MCP licensing:** accepted. The audited MCP stack is permissively licensed. +- **DBOS licensing:** accepted with distribution compliance work. DBOS is MIT, but + mandatory psycopg/psycopg-binary dependencies are LGPL-3.0-only. Release artifacts + must carry required notices and satisfy binary redistribution/source obligations. +- **tusd:** accepted with the capability-URL and hook semantics documented above. + HTTP hooks are duplicate/out-of-order capable; the durable importer and sweep own + correctness. +- **Artifacts:** accepted for the managed local content volume. Protected Starlette + range delivery, authorization, storage-key containment, integrity checks, and + response metadata remain mandatory. +- **Pydantic AI/Ollama:** accepted as the local SLM provider boundary. Typed native + output is supported. The exact model remains deliberately blocked by the + no-large-model-download constraint until Phase 9's measured gate. +- **Python and ML compatibility:** the current Python feature release is the + platform baseline. Stable PyTorch, CTranslate2, ONNX Runtime, and OpenCV releases + publish current-Python artifacts for the primary platform set, subject to the + clean-environment runtime gates above. The current stable WhisperX release blocks + that Python baseline and constrains an older Torch family, so it is rejected as + the default transcription provider rather than allowed to hold the platform back. + `faster-whisper`/CTranslate2 is accepted as the CPU transcription provider and + must still pass the Python 3.14, Apple Silicon, transcript, timestamp, and license + release gates. +- **CPU model selection:** accepted without a new multi-model download run. + SigLIP 2 and MIEB provide recent task-matched evidence for the scene encoder; + Qwen3-Embedding publishes stronger multilingual retrieval results than the older + available baselines; faster-whisper publishes CPU/GPU speed and memory comparisons + against Whisper implementations; and OpenCV Zoo publishes the selected detector + and recognizer with evaluation data. VidXP runs regression and compatibility + smoke tests now; a new comparison benchmark is required only when newer evidence + conflicts, deployment measurements regress, or a provider change is proposed. +- **GPU package matrix:** intentionally deferred. The previously proposed Python + 3.11/PyTorch 2.8/WhisperX/CUDA 12.8 matrix is rejected as a target. Phase 10 + resolves the latest mutually compatible stable stack from the then-current CPU + baseline and accepts it only through clean-image, CUDA, model, output-parity, + cancellation, and recovery tests. No model weights or CUDA image were downloaded + during this audit. +- **Chroma deployment:** embedded `PersistentClient` is accepted only locally. + Coolify uses a private Chroma server and client/server adapter. +- **Capability entry points:** accepted as standard trusted-code discovery with the + exact group, allowlist, version and collision rules above. + +Before a release image is published, audit locked direct/transitive packages, +container bases, system libraries, model weights/revisions/quantizations, automatic +alignment/VAD downloads, notices, and redistribution terms. Emit an SBOM and model +manifest for every image. Package metadata alone is not treated as model-license +evidence. diff --git a/docs/benchmarking/README.md b/docs/benchmarking/README.md index bdc0925..cfe692f 100644 --- a/docs/benchmarking/README.md +++ b/docs/benchmarking/README.md @@ -13,8 +13,9 @@ installation and product usage, start with the main | Area | Status | What it means | |---|---|---| | Shared benchmark support | Complete | Stable IDs, time ranges, metadata, top-k retrieval, isolated runs, checkpoints, and prediction files are implemented | -| DiDeMo visual localization | Complete | Full official test run completed over 4,021 queries and 1,037 videos | -| HiREST transcript localization | Validation complete | All 193 official validation pairs were scored; 776 released test predictions are unscored because their public answer bounds are placeholders | +| Guided input preparation | Complete | `vidxp benchmark prepare` estimates and confirms downloads, verifies pinned artifacts, validates DiDeMo media, resumes partial transfers, and prints the runnable benchmark command | +| DiDeMo visual localization | Legacy full result + current smoke | The legacy CLIP stack completed 4,021 official test queries over 1,037 videos; the current SigLIP2 stack passed a one-annotation real execution smoke | +| HiREST transcript localization | Legacy full result + current smoke | The legacy MiniLM stack scored all 193 validation pairs; current Qwen3 passed a two-video real execution smoke; 776 released test predictions remain unscored because their public bounds are placeholders | | LongVALE combined evaluation | Next | Build the visual-plus-speech adapter and validate one evaluation archive before scheduling the full run | | Actor clustering | Data-gated | The preferred BBT/Buffy evaluation still requires lawful access to the source episodes | @@ -44,12 +45,14 @@ they are not the current task list. No single published benchmark covers dialogue retrieval, scene retrieval, and actor clustering together. -The completed DiDeMo and HiREST runs establish separate visual and -transcript-based baselines. LongVALE is the next combined test because it asks a -system to find described events in long videos using visual, speech, and general -audio evidence. VidXP can currently contribute visual and speech evidence; it -does not recognize general sounds such as music, alarms, or barking. Any -LongVALE result must keep that limitation visible. +The retained full DiDeMo and HiREST results establish separate legacy-provider +visual and transcript baselines. Current SigLIP2 and Qwen3 checks establish +adapter/runtime compatibility only; they do not yet provide full-corpus quality +comparisons. LongVALE is the next combined test because it asks a system to find +described events in long videos using visual, speech, and general audio +evidence. VidXP can currently contribute visual and speech evidence; it does +not recognize general sounds such as music, alarms, or barking. Any LongVALE +result must keep that limitation visible. ## Evidence rules diff --git a/docs/benchmarking/adapter_validation.md b/docs/benchmarking/adapter_validation.md index d314e2b..aa92a8c 100644 --- a/docs/benchmarking/adapter_validation.md +++ b/docs/benchmarking/adapter_validation.md @@ -4,23 +4,37 @@ This ledger records the implementation and executable validation of the first two benchmark adapters. Subset runs are smoke tests of the complete data, indexing, serialization, and evaluator path. Their metrics are not paper scores. -For the current scores, metric definitions, and plain-language interpretation, +For the recorded results, metric definitions, and plain-language interpretation, start with [current benchmark results](results.md). This ledger is the technical reproduction record. +The full-corpus scores below are the retained 2026-07-27 legacy-provider +results. The 2026-07-30 SigLIP2/Qwen3 runs are bounded execution smokes, not +replacement quality scores. Both generations used the same physical laptop; +the model and protocol differences are recorded explicitly. + ## Pinned official artifacts | Benchmark | Artifact | Revision | SHA-256 | |---|---|---|---| | DiDeMo | [`data/val_data.json`](https://github.com/LisaAnne/LocalizingMoments/blob/b6a555c8134581305d0ed4716fbc192860e0b88c/data/val_data.json) | `b6a555c8134581305d0ed4716fbc192860e0b88c` | `b0364cc256553332feb19d46bcc4cd2b09774949fe6c0b25e7ed0ff3c6aefebb` | | DiDeMo | [`data/test_data.json`](https://github.com/LisaAnne/LocalizingMoments/blob/b6a555c8134581305d0ed4716fbc192860e0b88c/data/test_data.json) | `b6a555c8134581305d0ed4716fbc192860e0b88c` | `1891c04ec48b3d364c739594b2b6413806b74bd9027c092d896e7ebb930ff1cd` | -| DiDeMo | [`utils/eval.py`](https://github.com/LisaAnne/LocalizingMoments/blob/b6a555c8134581305d0ed4716fbc192860e0b88c/utils/eval.py) | `b6a555c8134581305d0ed4716fbc192860e0b88c` | `4754bb320564e5d2e7c633e0b660e87feca7f00fa73269e50140e81ffb4ca762` | +| DiDeMo | [`utils/eval.py`](https://github.com/LisaAnne/LocalizingMoments/blob/b6a555c8134581305d0ed4716fbc192860e0b88c/utils/eval.py) | `b6a555c8134581305d0ed4716fbc192860e0b88c` | `9ec3e7a171272eb3551b0eaa7bbe9292131ad5cf34fd5c1e02c0fc4a11234df6` | +| DiDeMo | [`data/yfcc100m_hash.txt`](https://github.com/LisaAnne/LocalizingMoments/blob/b6a555c8134581305d0ed4716fbc192860e0b88c/data/yfcc100m_hash.txt) | `b6a555c8134581305d0ed4716fbc192860e0b88c` | `481d9aaf020624d5915200bcf4752fb46d3e1931167e8b46715a5f342577cc4d` | | HiREST | [`data/splits/all_data_val.json`](https://github.com/j-min/HiREST/blob/deffc169b4e8d51c1589d5512ad05da61e81bcee/data/splits/all_data_val.json) | `deffc169b4e8d51c1589d5512ad05da61e81bcee` | `70d32c5fcdffe66cbf3c732dd274f03378da2082f50c9cec7e67705f529ecb4d` | | HiREST | [`data/splits/all_data_test.json`](https://github.com/j-min/HiREST/blob/deffc169b4e8d51c1589d5512ad05da61e81bcee/data/splits/all_data_test.json) | `deffc169b4e8d51c1589d5512ad05da61e81bcee` | `00219050c022ff2fc89c210ca4db605de6aa13c5c6014e4c678345ade3448a62` | | HiREST | [`data/evaluation/categories.json`](https://github.com/j-min/HiREST/blob/deffc169b4e8d51c1589d5512ad05da61e81bcee/data/evaluation/categories.json) | `deffc169b4e8d51c1589d5512ad05da61e81bcee` | `157623d50f7b8482f55fa1c4efc500539784c0399fb2dd60bb687b4006d85ca1` | -| HiREST | [`evaluate.py`](https://github.com/j-min/HiREST/blob/deffc169b4e8d51c1589d5512ad05da61e81bcee/evaluate.py) | `deffc169b4e8d51c1589d5512ad05da61e81bcee` | `c4b8ba9b572ae4088e90ddc3eec2b2cc4f5b4c1a0153ff6e0843817da89a5ca0` | +| HiREST | [`evaluate.py`](https://github.com/j-min/HiREST/blob/deffc169b4e8d51c1589d5512ad05da61e81bcee/evaluate.py) | `deffc169b4e8d51c1589d5512ad05da61e81bcee` | `871b48dc5ce42fbe1a4b672fe4df88a88ce568d57759dfc971e5aacc5f88f119` | | HiREST | [`ASR.zip`](https://huggingface.co/j-min/HiREST-baseline/resolve/54e2f8da7a4384fec8a137011399f5e104069032/ASR.zip) | `54e2f8da7a4384fec8a137011399f5e104069032` | `0b452d38e30064dc7273a58b7b73ec33e307ff83d30048a472777f56e3a29fbc` | +The table records canonical raw upstream bytes. The adapters also accept the +semantically identical CRLF evaluator hashes produced by a Windows Git +checkout: `4754bb320564e5d2e7c633e0b660e87feca7f00fa73269e50140e81ffb4ca762` +for DiDeMo and +`c4b8ba9b572ae4088e90ddc3eec2b2cc4f5b4c1a0153ff6e0843817da89a5ca0` +for HiREST. The observed hash is recorded rather than silently normalizing a +user-supplied checkout. + The adapters verify these hashes before indexing. Each run manifest also records the artifact paths, URLs, revisions, sizes, and observed hashes. VidXP does not replace the official downloaders; the adapters consume prepared media and ASR. @@ -76,8 +90,9 @@ command, working directory, compatibility note, output, and return code. released SRT cues do not contain word timestamps, phrase bounds are interpolated linearly within the real cue bounds and disclosed in the run manifest. -5. Submit the timestamped phrases to the dialogue-only VidXP core. The run uses - MiniLM and does not load WhisperX or decode video. +5. Submit the timestamped phrases to the dialogue-only VidXP core. The legacy + full run used MiniLM; the current smoke used Qwen3 Embedding. Neither path + loads a transcription model or decodes video. 6. Search each prompt only within its known video and retrieve every stored dialogue phrase. Project phrase scores onto one-second bins, assign uncovered seconds an explicit absence penalty, and rank duration-relative windows by @@ -109,9 +124,10 @@ logic remain unchanged, and the shim is disclosed in `evaluator.log`. These choices were made only on official validation data. Test annotations were not used to select either setting. -For DiDeMo, a declared 15-annotation validation subset over four downloadable -official videos was indexed at frame stride `30`. Both alternatives used the -same stored CLIP frame scores and the pinned official evaluator: +For the legacy DiDeMo run, a declared 15-annotation validation subset over four +downloadable official videos was indexed at frame stride `30`. Both +alternatives used the same stored CLIP frame scores and the pinned official +evaluator: | Within-chunk pooling | Rank@1 | Rank@5 | mIoU | |---|---:|---:|---:| @@ -124,11 +140,11 @@ seconds inside the human-selected chunk, but mean pooling diluted it enough to rank chunk 3 first. This is why the change is an aggregation correction rather than a label-specific test patch. -For HiREST, all 193 official validation moment pairs were indexed from released -ASR. The released cues contain no word timestamps, so the repaired core first -created the configured five-word phrases using linear interpolation inside each -real cue. The same stored MiniLM scores were then evaluated over this declared -duration-fraction grid: +For the legacy HiREST run, all 193 official validation moment pairs were +indexed from released ASR. The released cues contain no word timestamps, so the +repaired core first created the configured five-word phrases using linear +interpolation inside each real cue. The same stored MiniLM scores were then +evaluated over this declared duration-fraction grid: | Window fraction | R@0.5 | R@0.7 | |---:|---:|---:| @@ -182,29 +198,97 @@ size match the archive record. The manifest therefore classifies this as an official test result with a documented media substitution, not an untouched official-media run. -## Commands +## 2026-07-30 current-provider regression runs + +These checks used the current CPU providers after the product model swap. They +were intentionally bounded after the integration path was proven; they are not +full benchmark replacements. + +### Hardware and runtime + +| Item | Recorded current setup | +|---|---| +| Laptop | HP ENVY Laptop 16-h0xxx; the same physical hardware used for the legacy runs | +| CPU | Intel Core i7-12700H; 14 cores, 20 logical processors | +| Memory | 15.72 GiB | +| GPU | NVIDIA GeForce RTX 3060 Laptop GPU, 4 GiB; present but unused | +| Runtime | Windows 11; Python 3.14.0; PyTorch 2.13.0+cpu | +| Libraries | Transformers 5.14.1; Sentence Transformers 5.6.1; ChromaDB 1.5.9 | + +HiREST used `Qwen/Qwen3-Embedding-0.6B` at immutable revision +`97b0c614be4d77ee51c0cef4e5f07c00f9eb65b3`. DiDeMo used +`google/siglip2-base-patch16-224` at +`75de2d55ec2d0b4efc50b3e9ad70dba96a7b2fa2`. HiREST consumed the released +SRTs, so the configured faster-whisper model was not loaded or evaluated. + +### Real execution results + +| Run | Declared subset | Result | Runtime evidence | +|---|---|---|---| +| `hirest-qwen3-generation-smoke-20260730` | Two validation prompt/video pairs, including `Make Oatmeal Pancake Mix` / `5V3dI2zp1xA.mp4` | Official evaluator: R@0.5 `50.0`, R@0.7 `50.0`; classification `validation_smoke_test_not_paper_score` | Both media items remained searchable in the run generation; 64.923 recorded stage seconds; 2,593,096-byte store; empty failure log | +| `didemo-siglip2-sample-20260730` | Official test annotation index `0`; one video; 1.0 sample/sec; max chunk pooling | Official evaluator: Rank@1 `0.0`, Rank@5 `1.0`, mIoU `0.0`; classification `smoke_test_not_paper_score` | 29.359 recorded stage seconds; 747,684-byte store; all 21 candidates serialized; empty failure log | + +Prediction SHA-256 values were +`c2cee45eb7802e2c19adb4aec876a8fe2c203c59dde4bb20b41634b83d050afd` +for HiREST and +`4a38ff2c307833393f96fc0af48107a12581ecd9be734d8c0b906b2448a6fd33` +for DiDeMo. The runs were made from commit +`880da785825e95c46bfe050763be42afb02e844f` with the documented uncommitted +benchmark repairs, whose implementation fingerprint was +`afe0ee120f1c7b7daabe6d2c51e320a67a1368158fc5215acc34b7dd3e4e245e`. -Install the optional adapters with the capabilities they evaluate: +### Defects exposed and fixed + +The first current-provider attempt completed all 193 HiREST indexing +checkpoints but failed before predictions because benchmark records lacked the +generation identity now required by the search contract. Adding a shared +generation then exposed that the multi-video runner deleted the entire +generation before each video, leaving only the final video's records. A +two-video real smoke also caught official dataset filenames being passed into +the product's UUID4-only media ID fields. + +The final adapter behavior now: + +- derives stable UUID4-shaped internal media and generation IDs; +- retains official dataset video names at the file/evaluator boundary; +- removes only the current video's records when retrying inside a generation; +- supports documented DiDeMo media substitutions through the public CLI; and +- covers the generation/media-ID boundary in regression tests. + +The temporary 1,037-video corpus, model-run indexes, repository clones, and ASR +copy were removed after recording the evidence above. + +## Prepare and run + +Install the optional adapters and initialize FFmpeg: ```powershell -python -m pip install -e ".[scene,dialogue,benchmarks]" +uv tool install "vidxp[scene,dialogue,benchmarks]" +vidxp init ``` -Run a declared DiDeMo smoke subset by zero-based official annotation indices: +Prepare a declared DiDeMo smoke subset by zero-based official annotation +indices: ```powershell -vidxp benchmark didemo ` - --annotations /data/test_data.json ` - --evaluator /utils/eval.py ` - --media-directory ` - --split test ` - --annotation-indices 0,1,2 ` - --run-id didemo-smoke +vidxp benchmark prepare didemo --split test --annotation-indices 0,1,2 ``` -Omit `--annotation-indices` for the full official test split. +The command reads the pinned metadata needed to inspect the selection, displays +the maximum additional storage, destination free space, and any documented +replacement, then asks before persisting files. It resumes `.part` downloads, +validates each video with +FFprobe and a decoded frame, writes `preparation-manifest.json`, and prints the +complete benchmark command. Omit `--annotation-indices` for the full official +split. + +The known corrupt Multimedia Commons object is never used or replaced +silently. If its annotation is selected, the plan identifies the archived +Wikimedia original, includes its 94,107,862-byte size, and records the SHA-1, +source URL, and generated `media-overrides.json`. `--yes` confirms the displayed +plan for automation; JSON output requires it. -For HiREST, a smoke pair file is a JSON list: +For a HiREST smoke, a pair file is a JSON list: ```json [ @@ -215,23 +299,26 @@ For HiREST, a smoke pair file is a JSON list: ] ``` -Run the declared pair subset: +Prepare the released-ASR archive and a self-contained copy of that selection: ```powershell -vidxp benchmark hirest ` - --ground-truth /data/splits/all_data_test.json ` - --categories /data/evaluation/categories.json ` - --evaluator /evaluate.py ` - --asr-archive /ASR.zip ` - --asr-directory /ASR ` +vidxp benchmark prepare hirest ` --split test ` - --temporal-window-fraction 0.8 ` - --pairs ` - --run-id hirest-smoke + --pairs .\hirest-smoke-pairs.json ``` -Omit `--pairs` to generate all 776 official test predictions. Test output is -explicitly unscored; use `--split validation` with the pinned validation file +Preparation downloads the pinned annotations, categories, evaluator, and +released ASR archive, then extracts only the selected transcripts. No HiREST +video is downloaded by this adapter. Omit `--pairs` to prepare the complete +selected split. + +By default, prepared data is stored below VidXP's platform-native application +data directory under `benchmarks/`. Use `--output-directory` to +choose another volume. The final line is deliberately a copy/paste command, so +the user does not have to assemble artifact paths manually. + +The generated direct command can omit `--pairs` to produce all 776 official +test predictions. Test output is explicitly unscored; use `--split validation` for locally evaluable official metrics. ## Shared run output @@ -252,7 +339,7 @@ They also retain `ground_truth.subset.json`, the core completion marker, and per-video checkpoints. Validation runs add `metrics.json`; held-out HiREST test runs add `submission.summary.json`. Empty failure logs are created deliberately. -## 2026-07-27 executable smoke results +## 2026-07-27 legacy-provider executable smoke results | Adapter | Declared subset | Actual path exercised | Official evaluator result | |---|---|---|---| diff --git a/docs/benchmarking/benchmark_catalog.md b/docs/benchmarking/benchmark_catalog.md index 71aee01..58cd92d 100644 --- a/docs/benchmarking/benchmark_catalog.md +++ b/docs/benchmarking/benchmark_catalog.md @@ -70,18 +70,18 @@ protocol. It does not mean that a similar published score is already comparable. | Category | Benchmark or protocol | Capability actually measured | What a VidXP result would tell us | What it would **not** tell us | Current status | | --- | --- | --- | --- | --- | --- | -| Dialogue | [TVR `sub-only` queries](https://github.com/jayleicn/TVRetrieval) | Subtitle-related paraphrastic query to ranked video intervals, both known-video and corpus-wide | Whether WhisperX/MiniLM transcript evidence can identify and temporally localize relevant TV moments; a subtitle-only run isolates embedding and retrieval | Verbatim quote lookup, scene understanding, actor clustering, generic audio, or end-to-end ASR quality when supplied subtitles are used | Engineering A; raw TV media gated | -| Visual | [TVR `video-only` queries](https://github.com/jayleicn/TVRetrieval) | Visual-language query to ranked video intervals | Whether frame/clip CLIP evidence can retrieve visual moments across a TV corpus | Dialogue retrieval, actor identity, or performance on non-TV domains | Engineering A; raw TV media gated | +| Dialogue | [TVR `sub-only` queries](https://github.com/jayleicn/TVRetrieval) | Subtitle-related paraphrastic query to ranked video intervals, both known-video and corpus-wide | Whether the configured transcript embedding path can identify and temporally localize relevant TV moments; a subtitle-only run isolates embedding and retrieval | Verbatim quote lookup, scene understanding, actor clustering, generic audio, or end-to-end ASR quality when supplied subtitles are used | Engineering A; raw TV media gated | +| Visual | [TVR `video-only` queries](https://github.com/jayleicn/TVRetrieval) | Visual-language query to ranked video intervals | Whether the configured frame/clip embedding path can retrieve visual moments across a TV corpus | Dialogue retrieval, actor identity, or performance on non-TV domains | Engineering A; raw TV media gated | | Combined | [TVR `video+sub` queries](https://github.com/jayleicn/TVRetrieval) | Queries annotators judged to need both visual and subtitle evidence | Whether fixed late fusion improves corpus moment retrieval when both implemented paths contain useful evidence | Generic sound understanding, actor clustering, learned cross-modal reasoning, or intended `video+sub` coverage from a dialogue-only ablation | Engineering A with fixed fusion; raw TV media gated | -| Speech-backed instructional | [HiREST](https://github.com/j-min/HiREST) | Instructional goal to ranked videos, one relevant interval, moment segmentation, and step captioning | Whether VidXP's chunking, MiniLM embeddings, vector index, and interval selection retrieve semantically relevant spoken procedural content | Verbatim dialogue search, entertainment-video generalization, actors, WhisperX accuracy in released-ASR mode, or full video retrieval when negative candidates lack ASR | Known-video validation complete; released test predictions unscored because public bounds are placeholders; full video retrieval gated | +| Speech-backed instructional | [HiREST](https://github.com/j-min/HiREST) | Instructional goal to ranked videos, one relevant interval, moment segmentation, and step captioning | Whether VidXP's chunking, dialogue embeddings, vector index, and interval selection retrieve semantically relevant spoken procedural content | Verbatim dialogue search, entertainment-video generalization, actors, transcription accuracy in released-ASR mode, or full video retrieval when negative candidates lack ASR | Legacy MiniLM validation complete; current Qwen3 two-video smoke complete; released test predictions unscored because public bounds are placeholders; full video retrieval gated | | Narrated retrieval | [QuerYD](https://www.robots.ox.ac.uk/~vgg/data/queryd/) | Paragraph text↔video retrieval and narration text↔localized-clip ranking over supplied ground-truth proposals | Whether VidXP visual representations rank the correct narrated video or oracle segment proposal once source media are processed | In-scene conversational dialogue, overlapping speech, unrestricted boundary prediction, or VidXP quality from the released Collaborative Experts features | Protocol/reference artifacts ready; true VidXP run raw-media gated; narration audio unresolved | | Ranked search | [TVR-Ranking](https://huggingface.co/axgroup/TVR-Ranking) | Graded ranking of multiple relevant corpus moments for imprecise queries | Whether VidXP orders several partially relevant moments usefully, not merely whether its top result overlaps one answer | ASR quality when subtitles are supplied, actors, or generalization outside TVR | Engineering A after TVR; media/license gated | | Speech-backed whole-video | [How2R](https://aclanthology.org/2020.emnlp-main.161/) | Instructional-video retrieval using video plus aligned speech/subtitles, introduced with HERO | Whether VidXP can rank instructional clips from transcript and scene evidence under HERO's retrieval setup | Conversational dialogue, temporal boundary prediction, actors, or immediate executability before artifacts/licenses are rechecked | Relevant benchmark; current artifact/access status unresolved | | Spoken retrieval | [TREC Podcasts](https://trecpodcasts.github.io/) | Semantic query to graded, timestamped long-form spoken segments | Whether VidXP can rank relevant spoken passages across a large audio corpus | Visual retrieval, video timing beyond fixed segments, or actor clustering | Reference-only; corpus access closed | | Spoken occurrence | [NIST OpenKWS](https://www.nist.gov/document/openkws13-evalplan-v4pdf) | Detection of every exact spoken keyword occurrence with calibrated accept/reject decisions | Miss, false-alarm, timing, threshold-calibration, indexing-time, and search-time behavior for exact terms | Semantic/paraphrase dialogue retrieval, scene search, or actor performance | Engineering A; Babel/LDC data gated | | Spoken/visual archive | [SAVA, MediaEval 2015](https://ceur-ws.org/Vol-1436/Paper11.pdf) | Spoken-plus-visual query to unrestricted BBC archive intervals | Whether combined evidence retrieves useful broadcast segments | Actor clustering or reproducibility on presently obtainable public media | Official task-overview paper verified; blocked/reference-only | -| Multilingual temporal | [mTVR](https://aclanthology.org/2021.acl-short.92/) | English/Chinese multilingual TV moment retrieval | Whether a multilingual retrieval stack preserves temporal retrieval across those two languages | Urdu support, current MiniLM multilingual quality, or actor clustering | Reference; not an intentional current claim | -| Visual moment | [DiDeMo](https://github.com/LisaAnne/LocalizingMoments) | Natural-language query to one of 21 fixed moments in a known video | Whether CLIP frame scores and deterministic five-second aggregation rank the human-selected moment | Corpus-wide search, variable boundary quality, dialogue, or actors | Adapter and full official test run complete | +| Multilingual temporal | [mTVR](https://aclanthology.org/2021.acl-short.92/) | English/Chinese multilingual TV moment retrieval | Whether a multilingual retrieval stack preserves temporal retrieval across those two languages | Urdu support, current Qwen3 multilingual quality, or actor clustering | Reference; not an intentional current claim | +| Visual moment | [DiDeMo](https://github.com/LisaAnne/LocalizingMoments) | Natural-language query to one of 21 fixed moments in a known video | Whether configured scene-embedding scores and deterministic five-second aggregation rank the human-selected moment | Corpus-wide search, variable boundary quality, dialogue, or actors | Legacy CLIP full official test complete; current SigLIP2 one-annotation smoke complete | | Visual moment/highlight | [QVHighlights](https://github.com/jayleicn/moment_detr) | Query to ranked variable-duration moments and saliency on an official two-second clip grid | Whether zero-shot visual similarity finds relevant intervals and assigns useful clip-level saliency | Dialogue or actor performance; parity with supervised temporal models; validity of a point prediction under tIoU | Engineering A; storage/compute gated; exact test-label release must be named | | Visual moment | [Charades-STA](https://github.com/jiyanggao/TALL) | Query to ranked intervals in short indoor activity videos | Whether fixed-grid CLIP scoring localizes described actions in short videos | Corpus search, speech, actors, or broad-domain generalization | Engineering A; data agreement gated; original and filtered splits must not be mixed | | Whole-video visual | [MSR-VTT](https://www.microsoft.com/en-us/research/publication/msr-vtt-a-large-video-description-dataset-for-bridging-video-and-language/) | Text-to-whole-video ranking under a declared retrieval split | Whether pooled VidXP scene embeddings retrieve the correct short video from a corpus | Timestamp localization, dialogue retrieval, actors, or comparability across the 9k/1k and 7k/1k/2k conventions | Engineering A; media preparation and exact split required | @@ -113,7 +113,7 @@ protocol. It does not mean that a similar published score is already comparable. | Face verification provenance | [LFW / dlib protocol](https://github.com/ageitgey/face_recognition) | Same/different identity verification on still-image pairs | Only the provenance and generic discrimination of the underlying face embedding | VidXP actor clustering, face detection coverage, temporal continuity, or unknown-K performance | Not an actor benchmark; provenance only | | Combined temporal | [LongVALE](https://github.com/ttgeng233/LongVALE) | Vision, speech, and generic-audio event grounding in long videos, with one interval per event query | Whether a frozen VidXP interval proposal and scene/speech fusion localize multimodal events | Actor clustering or full omni-modal coverage because VidXP lacks generic-audio recognition | Engineering A/medium; artifacts reachable; compliance, interval, and runtime gates remain | | Audiovisual retrieval | [FLARE](https://flarebench.github.io/) | Caption-to-clip/video retrieval plus clip-level model-simulated visual-only, audio-only, and joint query retrieval | Where VidXP's scene, speech, and fixed-fusion clip rankings succeed or fail across evidence types | Actor performance, human-authored-query generalization, or full generic-sound capability | Engineering A/medium; ready/watchlist preprint | -| Large-corpus event | [MultiVENT 2.0](https://huggingface.co/datasets/hltcoe/MultiVENT2.0) | Multilingual event-centric ranked-video retrieval using visual, speech/ASR, embedded-text/OCR, and human-description metadata evidence | Whether existing VidXP visual, speech, and metadata paths scale to a large heterogeneous corpus; unsupported OCR becomes a measurable weakness | Timestamp localization, actor clustering, generic acoustic-event retrieval, or multilingual adequacy of current MiniLM | Engineering A with large operational adapter; approximately 1.93 TB gated | +| Large-corpus event | [MultiVENT 2.0](https://huggingface.co/datasets/hltcoe/MultiVENT2.0) | Multilingual event-centric ranked-video retrieval using visual, speech/ASR, embedded-text/OCR, and human-description metadata evidence | Whether existing VidXP visual, speech, and metadata paths scale to a large heterogeneous corpus; unsupported OCR becomes a measurable weakness | Timestamp localization, actor clustering, generic acoustic-event retrieval, or multilingual adequacy of the current Qwen3 embedding provider | Engineering A with large operational adapter; approximately 1.93 TB gated | | Large-corpus shot | [TRECVID AVS / V3C](https://www-nlpir.nist.gov/projects/tv2025/avs.html) | Natural-language query to up to 1,000 ranked master shots, measured with mean xinfAP | Whether VidXP scene retrieval scales to over a million pooled/judged shots and returns useful corpus rankings | Dialogue or actor performance, or comparable latency across different hardware | Archived 2024/2025 protocol; agreement and approximately 1.6 TB gated | | Multimodal whole-video | [MUVR](https://github.com/debby-0527/MUVR) | Paired query-video plus detailed text to ranked short normalized videos; pure-text and pure-video are ablations | Whether VidXP ranks relevant videos for a declared pure-text ablation or a reusable-CLIP visual slice | Timestamp localization, audio/dialogue understanding, actors, or equivalence to the defining paired-query task | Ablation slice adaptable; lower priority | | Audiovisual whole-video | [VALOR-32K](https://github.com/TXH-mercury/VALOR) | Bidirectional audiovisual-text retrieval and audiovisual captioning over 32,000 ten-second clips with human captions | Whether VidXP's visual/speech or fixed audiovisual representation ranks captioned clips on a standard released split | Temporal localization, actor clustering, long-video search, or full parity without a generic-audio model | Benchmark and official artifacts verified; media links/source rights must be checked | @@ -520,7 +520,8 @@ The evaluator, not VidXP, should compute the published metrics. path, not a standalone reproduction package. That path additionally needs its base model/projector/stage weights, CUDA environment, and evaluator. In all cases, those features bypass VidXP and cannot substitute for running VidXP's - CLIP/WhisperX/MiniLM encoders on the raw evaluation videos. + configured scene/transcription/dialogue providers on the raw evaluation + videos. - **Operations:** raw evaluation media are now packaged directly, so YouTube survival is no longer the access gate. The raw MP4 payload expands to 43,662,117,997 bytes (40.664 GiB); retaining both raw ZIPs and extracted MP4s diff --git a/docs/benchmarking/core_contract.md b/docs/benchmarking/core_contract.md index e7581e8..6a2897e 100644 --- a/docs/benchmarking/core_contract.md +++ b/docs/benchmarking/core_contract.md @@ -33,8 +33,10 @@ does not change merely because the same run is relocated to another output or storage directory. Checkpoint filenames are hashes of video IDs, so official IDs cannot accidentally become platform-specific paths. -The CLI and Streamlit interface use `chroma_data/` as their local run. Indexes -created with an older schema must be rebuilt; VidXP does not invent missing end +The CLI and Streamlit interface use `repositories/default/` beneath the +operating system's per-user VidXP data root. This local application repository +is separate from the explicit `benchmark_runs/` output above. Indexes created +with an older schema must be rebuilt; VidXP does not invent missing end timestamps, video IDs, or source IDs. ## Indexing API @@ -46,27 +48,35 @@ from vidxp.core.runner import run_index config = IndexConfig( dataset="example", split="test", - run_id="clip-stride-5", + run_id="scene-1fps", + generation_id="123456781234423481234567890abcde", enabled_modalities=("scene",), - frame_stride=5, - capability_options={"scene": {"batch_size": 32}}, + capability_options={ + "scene": {"batch_size": 32, "sample_fps": 1.0}, + }, storage_batch_size=256, ) manifest = run_index( [ - VideoSource(video_id="video-001", path="videos/001.mp4"), - VideoSource(video_id="video-002", path="videos/002.mp4"), + VideoSource( + video_id="223456781234423481234567890abcde", + path="videos/001.mp4", + ), + VideoSource( + video_id="323456781234423481234567890abcde", + path="videos/002.mp4", + ), ], config, ) ``` -Released timestamped ASR can be indexed without WhisperX or video decoding: +Released timestamped ASR can be indexed without running a transcription model or decoding video: ```python source = VideoSource( - video_id="video-001", + video_id="223456781234423481234567890abcde", transcript=( {"text": "first timestamped span", "start": 0.0, "end": 2.4}, {"text": "second timestamped span", "start": 2.4, "end": 5.0}, @@ -77,20 +87,22 @@ config = IndexConfig( dataset="example", split="test", run_id="released-asr", + generation_id="423456781234423481234567890abcde", enabled_modalities=("dialogue",), ) run_index([source], config) ``` -Scene-only runs do not load WhisperX or face recognition. Supplied-transcript +Scene-only runs do not load a transcription or actor model. Supplied-transcript dialogue runs load the dialogue encoder but do not decode video. Actor-only runs -do not load CLIP or WhisperX. CLIP inference, dialogue encoding, and Chroma writes -use their configured batch sizes. Cancellation is cooperative and is checked -between batches. The Streamlit process exposes cancellation for indexing workers -it started and reports that the current batch must finish first. The shared file -lock also lets the UI detect an active CLI or separate-process run, although one -process cannot cancel a run owned by another process. +do not load scene or transcription models. Scene inference, dialogue encoding, +and Chroma writes use their configured batch sizes. Cancellation is cooperative +and is checked between batches. The Streamlit process exposes cancellation for +indexing workers it started and reports that the current batch must finish +first. The shared file lock also lets the UI detect an active CLI or +separate-process run, although one process cannot cancel a run owned by another +process. Scene and actor indexing share one video probe and one sampled-frame stream when both are enabled. Each materialized frame is converted to RGB once and then fed @@ -98,23 +110,29 @@ to independently sized scene and actor batches. Actor detections are written incrementally; clusters below `actor_min_detections` are removed after clustering instead of retaining every detection in process memory. -`frame_stride` controls which frames are retrieved into Python and sent through -the models. Skipped frames are advanced with the video backend without being -materialized. Inter-frame codecs may still require backend-internal decoding, so -stride is not claimed to reduce codec work in direct proportion to the stride. -Manifests distinguish `source_frames_advanced`, unique `sampled_frames`, and the -sum of per-modality `frame_operations`. +Scene `sample_fps` is converted from the probed source FPS into a deterministic +frame cadence. A source below the requested rate uses every available frame +without duplication. `frame_stride` independently controls actor and legacy +visual capability sampling. When scene and actor are both enabled, the decoder +materializes the union of their required frames once and routes each capability +only its own cadence. Inter-frame codecs may still require backend-internal +decoding, so sampling is not claimed to reduce codec work in direct proportion +to the cadence. Manifests distinguish `source_frames_advanced`, unique +`sampled_frames`, and the sum of per-modality `frame_operations`. ## Stored identity and metadata -Every record has a deterministic escaped source ID: +Generation-aware records have a deterministic escaped source ID: ```text -run_id:video_id:modality:local_id +generation_id:run_id:video_id:modality:local_id ``` Each component is encoded before joining, so a colon or Unicode character inside -an official ID cannot collide with the separator. +source metadata cannot collide with the separator. Product media and generation +IDs are lowercase UUID4 hex. Dataset adapters retain official video keys at +their input/evaluator boundary and deterministically map them to valid internal +IDs. - Dialogue records store text, start/end, phrase ID, video ID, modality, source ID, dataset, split, and run ID. @@ -146,7 +164,7 @@ for hit in result.hits: ) ``` -Passing `video_id="video-001"` restricts retrieval to one video; omitting it +Passing a media UUID through `video_id` restricts retrieval to one video; omitting it searches the run corpus. Results are deterministically ordered by raw distance and then source ID. Scene vectors and, by default, dialogue vectors are normalized before the explicitly configured Chroma distance (`vector_distance`, default diff --git a/docs/benchmarking/research_papers.md b/docs/benchmarking/research_papers.md index 005c9fd..fdc5d2c 100644 --- a/docs/benchmarking/research_papers.md +++ b/docs/benchmarking/research_papers.md @@ -120,7 +120,7 @@ ranks whole videos rather than locating timestamps. | Paper | Typical benchmarks | Relevance | | --- | --- | --- | -| [CLIP: Learning Transferable Visual Models From Natural Language Supervision](https://arxiv.org/abs/2103.00020) | Image-text transfer only; no selected video temporal benchmark | Foundation of the current scene encoder; provenance, not a video benchmark | +| [CLIP: Learning Transferable Visual Models From Natural Language Supervision](https://arxiv.org/abs/2103.00020) | Image-text transfer only; no selected video temporal benchmark | Foundation for the legacy CLIP scene baseline and a relevant SigLIP2 predecessor; provenance, not a video benchmark | | [CLIP4Clip](https://github.com/ArrowLuo/CLIP4Clip) | MSR-VTT, MSVD, LSMDC, ActivityNet, DiDeMo whole-video/clip retrieval | Established CLIP video-text comparator; its DiDeMo/ActivityNet use is not temporal localization | | [Frozen in Time](https://github.com/m-bain/frozen-in-time) | MSR-VTT, DiDeMo, LSMDC, MSVD whole-video retrieval | Retrieval baseline and practical MSR-VTT preparation route; WebVid/image-caption sets are pretraining | | [X-CLIP](https://github.com/xuguohai/X-CLIP) | MSR-VTT, MSVD, LSMDC, DiDeMo, ActivityNet whole-video retrieval | Stronger supervised CLIP-style comparator, not a timestamp baseline | diff --git a/docs/benchmarking/results.md b/docs/benchmarking/results.md index a2da9a9..ea5e233 100644 --- a/docs/benchmarking/results.md +++ b/docs/benchmarking/results.md @@ -1,4 +1,4 @@ -# Current benchmark results +# Benchmark results This page answers three questions: @@ -9,13 +9,59 @@ This page answers three questions: Detailed artifacts, hashes, commands, and evaluator behavior remain in the [adapter validation ledger](adapter_validation.md). -## Results at a glance +## Evidence at a glance -| Benchmark | Evaluated data | VidXP result | Status | -|---|---|---|---| -| DiDeMo | Official test: 4,021 searches over 1,037 videos | Rank@1 **20.19%**, Rank@5 **55.71%**, average overlap **34.60%** | Valid official test baseline with one documented replacement for a corrupt copy of an official video | -| HiREST | Official validation: 193 known-video searches | R@0.5 **78.24%**, R@0.7 **44.56%** | Validation result; the prediction-window setting was selected on this same validation set | -| HiREST | Released test: 776 known-video searches | Predictions generated, no score | Public test boundaries are placeholders, so local scoring would be meaningless | +| Generation | Benchmark | Evaluated data | Result | What it supports | +|---|---|---|---|---| +| Legacy full | DiDeMo | Official test: 4,021 searches over 1,037 videos | Rank@1 **20.19%**, Rank@5 **55.71%**, mean IoU **34.60%** | Full visual baseline with one documented replacement for a corrupt official media object | +| Legacy full | HiREST | Official validation: 193 known-video searches | R@0.5 **78.24%**, R@0.7 **44.56%** | Validation baseline; the 0.8 prediction-window fraction was selected on this same validation set | +| Legacy full | HiREST | Released test: 776 known-video searches | Predictions generated, not scored | Public test boundaries are placeholders, so local scoring would be meaningless | +| Current smoke | DiDeMo | Official test annotation index `0`; one video | Rank@1 **0**, Rank@5 **1**, mean IoU **0** | Real SigLIP2 execution, serialization, and official-evaluator check only | +| Current smoke | HiREST | Two declared validation pairs over two videos | R@0.5 **50**, R@0.7 **50** | Real Qwen3 execution, multi-video storage, filtered search, serialization, and official-evaluator check only | + +The current-provider rows are deliberately tiny regression runs. Their +percentages are not quality estimates and must not be compared with the full +legacy rows. A current full-corpus score has not been run. + +## Runtime and model generations + +The legacy and current checks used the same physical laptop, as confirmed for +this rerun: + +- HP ENVY Laptop 16-h0xxx; +- Intel Core i7-12700H, 14 cores and 20 logical processors; +- 15.72 GiB system memory; +- NVIDIA GeForce RTX 3060 Laptop GPU with 4 GiB VRAM present but unused; +- CPU-only PyTorch execution. + +| Generation | Dialogue embedding | Scene embedding | Sampling/window | Transcription in these benchmarks | +|---|---|---|---|---| +| Legacy full, 2026-07-27 | `all-MiniLM-L6-v2` | OpenAI CLIP `ViT-B/32` through `clip-anytorch` | HiREST 0.8-duration window; DiDeMo fixed 30-frame stride and max chunk pooling | Released HiREST SRTs; WhisperX `large-v2` was not exercised | +| Current smoke, 2026-07-30 | `Qwen/Qwen3-Embedding-0.6B` at `97b0c614be4d77ee51c0cef4e5f07c00f9eb65b3` | `google/siglip2-base-patch16-224` at `75de2d55ec2d0b4efc50b3e9ad70dba96a7b2fa2` | HiREST same 0.8-duration window; DiDeMo source-aware 1.0 sample/sec and max chunk pooling | Released HiREST SRTs; faster-whisper `large-v3-turbo` was not exercised | + +The surviving legacy run record did not pin immutable model revisions or a +complete package/OS snapshot. The current smoke manifests record Windows 11, +Python 3.14.0, PyTorch 2.13.0+cpu, Transformers 5.14.1, and +Sentence Transformers 5.6.1. This limitation is stated instead of inventing +legacy revision metadata after the fact. + +## Multimodal comparison contract + +Natural-language answer prose is not an official benchmark prediction format. +Benchmark runs continue to preserve atomic scene and dialogue hits, raw +distances, and the existing dataset serializers. When a dataset contains both +eligible modalities, reports must show three fixed rows: + +| Retrieval path | What is compared | +|---|---| +| Scene only | The existing visual retrieval output | +| Dialogue only | The existing transcript retrieval output | +| Fixed RRF fusion | Overlap-connected intervals ranked with `rrf_v1`, `k=60` | + +No fused benchmark score is reported until the same frozen dataset inputs and +evaluator used by the atomic rows have been run. Generated `QueryAnswer` claims +remain a separate grounding evaluation and cannot replace these retrieval +comparisons. ## What the measurements mean @@ -48,13 +94,13 @@ overlap = 8 / 12 = 0.67 ## DiDeMo -### Comparison +### Legacy full-result comparison | Method | Rank@1 | Rank@5 | Mean IoU | |---|---:|---:|---:| | Chance | 3.75% | 22.50% | 22.64% | | Common-position guess | 19.40% | 66.38% | 26.65% | -| **VidXP** | **20.19%** | **55.71%** | **34.60%** | +| **VidXP legacy CLIP baseline** | **20.19%** | **55.71%** | **34.60%** | | Published MCN trained on DiDeMo | 28.10% | 78.21% | 41.08% | The common-position guess ignores the search text and video content. It ranks @@ -66,7 +112,7 @@ DiDeMo-trained MCN system remains better on all three measurements. ### Honest conclusion -VidXP produces meaningful visual localization, but its candidate ranking is not +The legacy VidXP stack produces meaningful visual localization, but its candidate ranking is not yet competitive with the published trained system. Improving how scene evidence is combined across nearby frames is the clearest DiDeMo improvement target. @@ -77,12 +123,12 @@ and its checksum are recorded in the ## HiREST -### Comparison +### Legacy full-result comparison | Method on the same 193 validation pairs | R@0.5 | R@0.7 | |---|---:|---:| | Return almost the entire video without using the query | 68.91% | 23.83% | -| **VidXP transcript search** | **78.24%** | **44.56%** | +| **VidXP legacy MiniLM transcript search** | **78.24%** | **44.56%** | | Improvement | **+9.33 points** | **+20.73 points** | VidXP turns the transcript matches into a score over the video's timeline and @@ -102,7 +148,9 @@ or superiority over published HiREST systems because: - the released test answers contain placeholder time ranges and cannot be scored locally. -The result is a useful validation baseline, not a final held-out paper result. +The result is a useful legacy validation baseline, not a final held-out paper +result. The current two-video Qwen3 smoke establishes compatibility only; it +does not supersede this score. ## Next benchmark: LongVALE diff --git a/docs/benchmarking/runtime_validation.md b/docs/benchmarking/runtime_validation.md index f18b4ae..49ce5c7 100644 --- a/docs/benchmarking/runtime_validation.md +++ b/docs/benchmarking/runtime_validation.md @@ -4,7 +4,47 @@ This ledger records executable checks for the benchmark-ready core. It is separate from unit-test coverage and from benchmark results. A smoke result here must not be reported as a paper score. -## 2026-07-27 Chunk 1 closure +## 2026-07-30 current-provider benchmark closure + +Two real, bounded runs validated the current CPU providers and official +evaluator paths after the model swap: + +| Check | Executed path | Observed result | +|---|---|---| +| HiREST multi-video smoke | Two released-ASR validation pairs; Qwen3 Embedding 0.6B; Chroma; filtered known-video search; 0.8-duration ranking; pinned evaluator | Both media remained indexed and searchable; two predictions serialized; R@0.5/R@0.7 `50/50`; empty failure log | +| DiDeMo sample | Official test annotation index `0`; SigLIP2; source-aware 1.0 sample/sec; max chunk pooling; pinned evaluator | One prediction containing all 21 candidates serialized; Rank@5 `1.0`; empty failure log | +| Targeted regression suite | Runner, benchmark adapters/CLI, search, storage unit and integration tests | 67 tests and 4 subtests passed | + +The percentages are smoke-subset outputs, not current full-corpus quality +scores. The runs used the same HP ENVY/i7-12700H/15.72 GiB laptop as the legacy +results. PyTorch 2.13.0+cpu did not use the installed RTX 3060 Laptop GPU. + +The executable pass found and fixed three integration defects before closure: + +- benchmark records lacked the now-required generation identity; +- multi-video retries deleted an entire shared generation instead of only the + current video's records; and +- official dataset filenames were still being supplied to UUID4-only product + media fields. + +## 2026-07-30 benchmark-preparation closure + +The guided acquisition path was exercised independently of model loading and +indexing: + +| Check | Executed path | Observed result | +|---|---|---| +| DiDeMo preparation | Official test annotation `0`; pinned raw annotations/evaluator/hash map; one Multimedia Commons video | 5.3 MiB maximum-additional-storage plan shown; download completed; FFprobe and frame decode passed; manifest and runnable command emitted | +| DiDeMo resumability | Repeated the same preparation directory | Zero new files and zero additional bytes; existing checksums and media validation passed | +| HiREST preparation | Complete validation moment set; pinned metadata and released ASR | 7.2 MiB downloaded; the plan now reserves up to 17.2 MB for transcript extraction; 193 selected transcripts extracted; manifest and runnable command emitted | + +Both temporary preparation directories were removed after validation. This pass +also found that the earlier evaluator checksums described CRLF Windows checkout +bytes rather than canonical raw GitHub bytes. Preparation now pins canonical +raw hashes while direct adapter invocation accepts and records either canonical +LF or the known CRLF checkout form. + +## 2026-07-27 legacy-provider Chunk 1 closure ### Failure that triggered the pass diff --git a/docs/benchmarking_research.md b/docs/benchmarking_research.md index e6769e5..d960bd2 100644 --- a/docs/benchmarking_research.md +++ b/docs/benchmarking_research.md @@ -63,7 +63,7 @@ The `face_recognition` project states **99.38% accuracy on Labeled Faces in the Use a separate “published context” table, followed by the paper's own “same-harness baselines” table. The latter should contain only methods rerun on the identical frozen VidXP test set, for example: - dialogue: random, exact lexical/BM25, reference-transcript oracle, and full WhisperX pipeline; -- scene: random, CLIP with each pre-registered frame stride, and any temporal aggregation variant; +- scene: random, CLIP with each pre-registered time-based sample rate, and any temporal aggregation variant; - actors: all-singleton, all-in-one, fixed 0.55 threshold, and development-tuned threshold. State underneath the published-context table: **“Values are reproduced from their source papers and are not directly comparable to VidXP because datasets, languages, retrieval units, supervision, relevance definitions, and hardware differ.”** This prevents a literature-review table from being mistaken for an empirical leaderboard. @@ -74,7 +74,7 @@ Use an immutable, versioned corpus and publish a manifest containing video IDs, Use three disjoint splits: -- **Development:** select face threshold, five-word phrase length, frame stride, temporal de-duplication, similarity settings, and any rejection threshold. +- **Development:** select face threshold, five-word phrase length, scene sample rate, temporal de-duplication, similarity settings, and any rejection threshold. - **Test:** run once after the protocol and parameters are frozen. - **Optional training:** only if a component is trained or fine-tuned. VidXP's present models are otherwise evaluated zero-shot. @@ -244,7 +244,7 @@ Record peak: PyTorch's `max_memory_allocated()` returns the peak tensor memory since program start and documents resetting peak statistics between stages ([official API](https://docs.pytorch.org/docs/stable/generated/torch.cuda.memory.max_memory_allocated.html)). Python exposes maximum RSS through `resource.getrusage` ([official documentation](https://docs.python.org/3/library/resource.html)); NVIDIA documents sampled GPU utilization and frame-buffer memory semantics in `nvidia-smi` ([official documentation](https://docs.nvidia.com/deploy/nvidia-smi/index.html)). -For every result table, report CPU model/core count, RAM, storage, GPU and VRAM, OS, accelerator driver/runtime, Python and dependency versions, model identifiers/hashes, precision/compute type, batch size, thread counts, input resolution/fps, frame stride, database settings, and Git commit. Fix seeds where randomness exists, keep the machine otherwise idle, randomize condition order, and publish raw per-run measurements and the benchmark script. +For every result table, report CPU model/core count, RAM, storage, GPU and VRAM, OS, accelerator driver/runtime, Python and dependency versions, model identifiers/hashes, precision/compute type, batch size, thread counts, input resolution/fps, effective scene sample rate, actor frame stride, database settings, and Git commit. Fix seeds where randomness exists, keep the machine otherwise idle, randomize condition order, and publish raw per-run measurements and the benchmark script. ## 6. Aggregation, uncertainty, and comparisons @@ -253,13 +253,13 @@ For every result table, report CPU model/core count, RAM, storage, GPU and VRAM, - Report 95% confidence intervals by resampling the highest independent unit—source title/video, not adjacent frames or queries from the same event. Use the same resampled units for paired system comparisons. - Publish per-query/per-video scores so alternative aggregation is possible. - Compare systems on the identical frozen queries and judgments. -- Present quality and efficiency together: e.g. \(R@1\) versus RTF for Whisper model/compute type, scene frame stride, and face threshold/history choices. +- Present quality and efficiency together: e.g. \(R@1\) versus RTF for Whisper model/compute type, scene sample rate, and face threshold/history choices. ## 7. Minimum paper table set 1. Corpus and annotation statistics by split and query stratum. 2. Dialogue ASR/alignment results, then end-to-end retrieval with reference-transcript oracle and lexical baseline. -3. Scene retrieval by query type, within-video versus corpus-wide, with frame-stride ablation. +3. Scene retrieval by query type, within-video versus corpus-wide, with sample-rate ablation. 4. Actor detection coverage plus B-cubed P/R/F1, pairwise P/R/F1, ARI, and cluster-count diagnostics. 5. Indexing RTF/stage breakdown, query cold/warm p50/p95 and QPS, peak RAM/accelerator memory, and index size. 6. Paired 95% confidence intervals and qualitative failure examples selected by a fixed rule (for example, worst five test queries per module), not hand-picked successes. diff --git a/docs/deployment/coolify.md b/docs/deployment/coolify.md new file mode 100644 index 0000000..1113e8b --- /dev/null +++ b/docs/deployment/coolify.md @@ -0,0 +1,138 @@ +# Coolify deployment + +`compose.coolify.yaml` is a prebuilt-image Compose deployment. It has no build +contexts and does not download models in the API process. + +## Images + +- `VIDXP_CONTROL_IMAGE`: the VidXP `control` target for the API, private tusd + hooks, MCP server, readiness checks, and migrations. It contains no PyTorch or + Chroma server. +- `VIDXP_WORKER_IMAGE`: the VidXP `worker` target with CPU capabilities and + Chroma's HTTP-only client. +- PostgreSQL 18.3, Chroma 1.5.9, tusd 2.10.0, and the optional self-hosted + Ollama 0.32.5 service are pinned by digest in the Compose file. + +Use immutable VidXP release tags or digests for both first-party image variables. + +## Required variables + +```dotenv +VIDXP_CONTROL_IMAGE=ghcr.io/grayhatdevelopers/vidxp:-control +VIDXP_WORKER_IMAGE=ghcr.io/grayhatdevelopers/vidxp:-worker +POSTGRES_PASSWORD= +VIDXP_HTTP_STATIC_BEARER_TOKEN= +VIDXP_UPLOAD_CLEANUP_TOKEN= +VIDXP_PUBLIC_API_HOST=api.example.com +VIDXP_UPLOAD_PUBLIC_ENDPOINT=https://uploads.example.com/uploads/ +VIDXP_UPLOAD_CORS_ORIGIN_REGEX=^https://app\.example\.com$ +``` + +Optional deployment-wide upload limits use `VIDXP_UPLOAD_MAX_BYTES` for one object +and `VIDXP_UPLOAD_QUOTA_BYTES` for all reserved upload bytes in the singleton +repository. There is no per-principal or per-repository quota setting. + +Route the API service's port 8000 to the API hostname. Route only tusd's +`/uploads/` path on port 8080 to the upload hostname. Do not publish PostgreSQL, +Chroma, or the hook service. + +The same API origin exposes Streamable HTTP MCP at `/mcp`. Configure the proxy +to preserve `Authorization`, `Accept`, `Content-Type`, `MCP-Protocol-Version`, +`Mcp-Method`, `Mcp-Name`, and `Mcp-Param-*` headers and disable response buffering +for `/mcp`. Static bearer mode intentionally publishes no OAuth metadata; configure +the bearer header in the remote MCP client. + +The upload path is a capability URL used to resume an upload. Disable or redact +reverse-proxy access logging for `/uploads/`; VidXP cannot control logs written by +an upstream proxy. + +Video bytes do not travel through MCP. Create and resume the upload through the +HTTP/tus endpoints, wait for its `media_id`, and then use that ID with +`start_indexing`. + +## Prepare worker models + +Start the stack, then explicitly prepare the model set before accepting +indexing requests. Submit preparation through the authenticated API so the +durable job executes on the worker and writes to the shared model volume: + +| Selected models | Maximum pinned download | +|---|---:| +| Dialogue + scene + actor | Approximately 4.11 GiB | + +The request below is the operator's explicit authorization for those +downloads. Ensure the `model-cache` volume has enough additional capacity: + +```bash +curl --fail-with-body \ + --request POST \ + "https://${VIDXP_PUBLIC_API_HOST}/api/v1/jobs/model-preparation" \ + --header "Authorization: Bearer ${VIDXP_HTTP_STATIC_BEARER_TOKEN}" \ + --header "Idempotency-Key: initial-cpu-models-v1" \ + --header "Content-Type: application/json" \ + --data '{"modalities":["dialogue","scene","actor"],"capability_options":{}}' +``` + +The `202 Accepted` response contains the durable `job_id` and a `Location` +header. Poll that location until the job succeeds: + +```bash +curl --fail-with-body \ + --header "Authorization: Bearer ${VIDXP_HTTP_STATIC_BEARER_TOKEN}" \ + "https://${VIDXP_PUBLIC_API_HOST}/api/v1/jobs/" +``` + +The Streamable HTTP MCP `prepare_models` and `get_job` tools expose the same +operation for an authenticated agent client. Check +`/api/v1/runtime/readiness` afterward; `/ready` covers control-plane +availability and does not claim that every optional model is prepared. + +## Optional grounded query model + +Grounded retrieval works without a language model and returns timestamped +evidence. To enable generated claims, choose a model only after evaluating its +schema adherence, resource use, license, and grounding behavior. Set both: + +```dotenv +VIDXP_SLM_BASE_URL=http://ollama:11434/v1 +VIDXP_SLM_MODEL= +``` + +Then start the optional service and explicitly prepare the selected model: + +```bash +docker compose -f compose.coolify.yaml --profile slm up -d ollama +docker compose -f compose.coolify.yaml --profile slm exec ollama \ + ollama pull +docker compose -f compose.coolify.yaml up -d worker +``` + +The Compose deployment never pulls an SLM implicitly. Ollama is internal and +should not be published through the proxy. + +## Start and verify + +```bash +docker compose -f compose.coolify.yaml pull +docker compose -f compose.coolify.yaml up -d --wait +docker compose -f compose.coolify.yaml ps +``` + +The migration and readiness containers should exit successfully. PostgreSQL, API, +hooks, worker, and tusd should report healthy; Chroma is checked by the completed +`chroma-ready` gate. + +This is the supported server topology: one node, one API/MCP service, one hook +service, one CPU worker, and the bundled PostgreSQL, Chroma, tusd, and named +volumes. It is not a multi-replica, failover, or provider-portability design. Back +up PostgreSQL and the named data volumes before replacing a release. + +Treat each deployed stack as its singleton repository boundary. The current +PostgreSQL catalog and Chroma collections intentionally contain no repository +namespace. Deploy another complete stack, with separate databases and volumes, for +a separate repository. + +VidXP server mode connects to the internal Compose service names `postgres` and +`chroma`. Those endpoints are fixed by the supported topology: do not set +`VIDXP_DATABASE_URL` or `VIDXP_CHROMA_SERVER_URL`, and do not substitute hosted +PostgreSQL, hosted Chroma, or alternative database providers. diff --git a/docs/deployment/gpu-evaluation.md b/docs/deployment/gpu-evaluation.md new file mode 100644 index 0000000..c0e5f19 --- /dev/null +++ b/docs/deployment/gpu-evaluation.md @@ -0,0 +1,136 @@ +# GPU worker evaluation + +Status: evaluated on 2026-07-29; implementation and model execution are +intentionally deferred. + +No model, CUDA image, or Python wheel was pulled for this evaluation. Dependency +checks used registry metadata only. + +## Decision + +The first NVIDIA worker should use the supported CUDA 12.6 line: + +| Component | Evaluated version or policy | Decision | +| --- | --- | --- | +| Python | 3.14.6 | Keep the project-wide runtime | +| PyTorch | 2.13.0 `cu126` | Accept; latest stable Torch with its supported CUDA 12 fallback | +| CTranslate2 | 4.8.1 | Accept; current wheel supports CUDA 12 | +| faster-whisper | 1.2.1 | Accept with CUDA 12 cuBLAS and cuDNN 9 | +| cuDNN | 9.10.2.21 from the resolved `cu126` stack | Accept | +| Actor models | OpenCV CPU | Keep CPU-only; do not route through CUDA | +| Ollama | Separate optional service | Do not couple to the indexing worker GPU | + +CUDA 12.8 is rejected because the registry has no PyTorch 2.13 `cu128` +distribution. CUDA 12.9 resolved during metadata probing, but PyTorch 2.13's +published support direction retains CUDA 12.6 and moves the default to CUDA +13. CUDA 13 is rejected for the first worker because current CTranslate2 and +faster-whisper require the CUDA 12 family. Using 12.6 is a justified +compatibility exception, not an old default retained without review. + +The metadata-only resolver command was: + +```text +uv pip compile pyproject.toml + --extra server-worker + --no-sources + --python-version 3.14 + --python-platform x86_64-manylinux_2_28 + --torch-backend cu126 + --default-index https://pypi.org/simple + --index-strategy first-index +``` + +The exact implementation lock must be generated from `pyproject.toml` with the +`server-worker` extra and committed separately from the CPU `uv.lock`. +`--no-sources` is mandatory: without it, the current `tool.uv.sources` CPU +override wins and produces `torch+cpu` even when `--torch-backend cu126` is +present. `tool.uv.sources` must remain CPU-only for ordinary development and +publishing; the CUDA index is a release-resolver input, never a direct package +URL. + +## Capability routing + +| Capability operation | Device policy | Precision gate | +| --- | --- | --- | +| Scene SigLIP2 embedding/search | `cuda:` | Start with the current float32 contract; evaluate lower precision separately | +| Dialogue Qwen3 embedding/search | `cuda:` | Use bfloat16 only when `torch.cuda.is_bf16_supported()` passes; otherwise use an explicitly tested fallback | +| Dialogue transcription | CUDA device and index through faster-whisper | float16 | +| Actor detection/recognition and overlays | CPU | float32 | +| Media import and snippet extraction | CPU | Not applicable | +| SLM query planning/synthesis | External Ollama endpoint or deterministic fallback | Owned by the Ollama deployment | + +The existing durable queue split is directionally correct: CUDA repositories +submit model jobs to the GPU queue, while actor overlays, media import, and +snippets remain CPU jobs. An index job can still execute actor work on the GPU +worker process, but `ModelRuntime.device_for("actor")` keeps that operation on +CPU. + +## Required implementation boundary + +The GPU worker should remain a supplemental deployment, not alter the default +CPU/Coolify stack: + +1. Generate a target-specific GPU requirements lock using uv's `cu126` + resolver backend. +2. Add a dedicated worker image target based on the existing Python 3.14 + runtime. The resolved NVIDIA Python libraries supply CUDA 12.6/cuDNN 9; + configure their library directories for CTranslate2 before Python starts. +3. Add `compose.gpu.yaml` with one explicit `device_ids` entry and + `capabilities: [gpu]`. Do not expose all host GPUs implicitly. +4. Run only `vidxp-worker --role gpu` in that service and set + `VIDXP_RUNTIME_BACKEND=cuda:0` inside the container's visible-device + namespace. +5. Keep the CPU worker for CPU-queue jobs. Do not replace it with the GPU + worker. + +The host requires the NVIDIA Container Toolkit and a driver compatible with the +selected CUDA 12 runtime. Release qualification should use the CUDA 12.6 +toolkit driver floor rather than relying only on CUDA 12 minor-version +compatibility. + +## Readiness gaps to close + +Current worker health checks only verify PostgreSQL and Chroma. A GPU worker +must additionally fail readiness unless all of these are true: + +- the requested backend is exactly `cuda:`; +- `torch.backends.cuda.is_built()` and `torch.cuda.is_available()` are true; +- the selected index exists; +- the device capability appears in `torch.cuda.get_arch_list()`; +- the resolved PyTorch build reports CUDA 12.6; +- CTranslate2 reports at least one CUDA device; +- device name, capability, total memory, Torch version, CUDA version, cuDNN + version, and selected precision policy are exposed in structured readiness. + +`auto` must continue to select CPU. There must be no silent CPU fallback after +an explicit CUDA request. + +## Validation required before enablement + +No performance comparison is needed to enable the first functional GPU worker. +The release gate is correctness and failure behavior: + +- build the locked image without CPU Torch or CUDA 13 packages; +- start on a clean NVIDIA host and prove explicit device isolation; +- run dependency/readiness checks without model downloads; +- with already prepared model assets, smoke one scene index/search and one + dialogue transcription/search; +- verify actor processing remains on CPU; +- compare CPU and CUDA result shape, provenance, and retrieval tolerances; +- translate PyTorch OOM into a typed resource-limit failure; +- verify cancellation, worker restart, and DBOS recovery after OOM; +- record peak device memory and wall time without claiming an effectiveness + improvement. + +Blackwell, multi-GPU scheduling, MIG, ROCm, CUDA desktop installers, lower +precision tuning, and Ollama GPU sharing remain separate evaluations. + +## Current blockers + +Implementation must not begin until: + +- the target GPU/driver matrix is selected for CI; +- the separate CUDA lock format and update workflow are agreed; +- a CUDA host is available for readiness and no-model dependency validation; +- model assets are already cached for the two functional smokes; and +- CUDA/cuDNN redistribution notices and the generated image SBOM are reviewed. diff --git a/docs/desktop.md b/docs/desktop.md new file mode 100644 index 0000000..11ca697 --- /dev/null +++ b/docs/desktop.md @@ -0,0 +1,145 @@ +# Desktop application + +The desktop application is a Tauri v2 launcher, first-run configuration +surface, and process supervisor. The operating-system package installs the +application; the application then provisions its local processing runtime. It +does not contain a second VidXP implementation: + +- the selected capability extras are installed from the exact configured + package release; +- the Streamlit browser interface is an optional installation surface; +- repositories and models use the same platform VidXP data directory as the + CLI, while managed Python and package environments use private desktop + application-data directories; +- the existing DBOS worker remains the durable execution boundary; and +- closing the desktop process stops both Streamlit and the repository worker. + +The desktop separates shared product data from its private implementation +state. The shared root is the same operating-system VidXP data directory used +by the CLI: + +```text +VidXP/ + repositories/ + default/ + models/ # default; a user-selected directory is also supported +``` + +The identifier-scoped private desktop directory contains only the managed +runtime and desktop state: + +```text +dev.grayhat.vidxp/ + runtimes/ + python/ + active-runtime.json +``` + +On Windows the per-user NSIS package installs program files under +`%LOCALAPPDATA%\Programs\VidXP`, keeping them separate from both directories. +The desktop's uv download cache uses the operating system-provided app-cache +directory. Docker and Compose storage remains explicitly volume-backed and does +not inherit this desktop layout. + +The application bundles `uv` and performs a system-media preflight before +creating Python or downloading VidXP. If FFmpeg is missing on Windows or +macOS, the application uses a native operating-system dialog to show the exact +approved package-manager command and obtain consent before running it. Linux +shows the applicable terminal command without trying to automate elevation. +The application verifies ffmpeg, ffprobe, +`libx264`, and `aac`, then installs the exact Python and VidXP versions in +`desktop/runtime-manifest.json`, persists the verified absolute executable +paths through `vidxp init`, runs the full `vidxp doctor`, and only then activates +the new runtime. A failed or cancelled setup never replaces the previous active +runtime. + +## Build locally + +From `desktop/`: + +```powershell +npm ci +npm run sidecar:windows +npm run desktop:dev +npm run desktop:build +``` + +On macOS Apple Silicon or Linux x86-64: + +```bash +npm ci +npm run sidecar:unix +npm run desktop:dev +npm run desktop:build +``` + +The sidecar scripts download the pinned official `uv` release, verify its +checked-in SHA-256 archive digest from `desktop/sidecars.json`, and place the +target-suffixed binary where Tauri expects it. The Rust build also executes the +target sidecar and rejects a version mismatch. Generated binaries and build +outputs are ignored by Git. `desktop:build` selects NSIS on Windows, DMG on +macOS, and AppImage on Linux, disables signing, and requires the checked-in +Cargo lock to remain unchanged. + +## First-run configuration + +Users select dialogue, scene, and actor capabilities independently. Interfaces +are selected separately: the browser interface adds the `frontend` extra only +when selected. Model preparation can be deferred, and a native folder picker +can select a model-cache directory before any model is downloaded. +For the current beta, it first acquires only the exact VidXP package from +TestPyPI with dependency resolution disabled. It then resolves that installed +package's selected extras from production PyPI. This prevents TestPyPI from +becoming a competing source for transitive dependencies. The acquisition index +is derived from the stamped package version: prereleases use TestPyPI and stable +versions use production PyPI. +Windows and Linux resolve CPU-only PyTorch wheels using uv's +`--torch-backend cpu`; macOS uses native PyPI wheels. The custom PyTorch index +is therefore a resolver input and is not embedded as a package URL, avoiding +the prior package-publication failure mode. Every selected profile is also +constrained by `desktop/runtime-constraints.txt`, exported from the repository +lock for the complete local-worker and frontend dependency set. Capability +selection controls which packages are installed; the constraints prevent those +packages from drifting independently after the desktop binary is published. + +Optional model preparation invokes the shared `vidxp prepare` command for only +the selected modalities. Setup subprocesses are owned by the Tauri supervisor; +closing the app cancels the active process and stops a preparation worker before +exit. No model is bundled in the installer. + +After a runtime is configured, the Tauri supervisor starts hidden in the system +tray. A browser-enabled profile opens the local interface in the operating +system's default browser. **Open VidXP** reuses the already-running interface; +**Quit VidXP** runs the full supervised shutdown for the interface and +repository worker. Closing the configuration/status window hides it to the tray +instead of terminating a configured runtime. + +The native NSIS, DMG, and AppImage packages themselves never install FFmpeg or +run a package manager. That consented action belongs to first-run configuration, +where errors and retries are visible. `vidxp doctor` remains a read-only validation +gate. Target FFmpeg binaries will not be bundled until their exact build +provenance, enabled codecs, and redistribution licenses are recorded. This is +an explicit packaging gate, not a silent download from an unaudited third +party. + +## Release targets + +The initial target matrix is: + +- Windows x86-64: per-user NSIS installer; +- macOS Apple Silicon: DMG; and +- Linux x86-64: AppImage. + +The release workflow builds all three from the stamped release commit and +attaches them to the matching GitHub release. Initial packages are unsigned; +Windows SmartScreen and macOS Gatekeeper may therefore require explicit user +confirmation. Signing and notarization improve that experience but do not +block publication. + +The Tauri shell itself uses the operating system webview. Selecting no browser +surface omits Streamlit and its Python dependencies, but does not turn the +Tauri executable into a non-webview application. Users who require no webview +at all should install a CLI/MCP package profile instead of the desktop package. + +Updater integration, richer tray controls, in-app playback, CUDA installers, +and true offline installers remain deferred. diff --git a/docs/releasing.md b/docs/releasing.md index eaf514e..37b876d 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -6,9 +6,10 @@ publication branch. ## Prereleases -A release-relevant merge to `main` runs CI, lets Python Semantic Release -calculate the next version, creates a `b` prerelease tag and GitHub prerelease, -builds the distributions, and publishes them to TestPyPI. Commits that do not +A release-relevant merge to `main` first calls the full reusable CI workflow +for that exact commit. Only a successful gate lets Python Semantic Release +calculate the next version, create a `b` prerelease tag and GitHub prerelease, +build the distributions, and publish them to TestPyPI. Commits that do not require a semantic version bump do not publish a package. Towncrier renders the pending fragments into the GitHub prerelease body without @@ -20,15 +21,29 @@ consuming them. The fragments remain in `changes/` for the stable release. 2. Confirm that every user-visible merged pull request has an accurate fragment. 3. Run the **Release (main → PyPI)** workflow from `main`. 4. Approve the `pypi` environment deployment when reviewer protection is enabled. -5. Confirm the new tag, GitHub release, PyPI package, and emptied pending - fragment set. - -The workflow only runs from a `main` dispatch, and every publication job uses -the release commit created from that immutable revision. Python Semantic -Release is the only version authority. Towncrier is the only release-note -renderer. During the stable build it receives the calculated version, renders -the GitHub release body, updates `CHANGELOG.md` with the same section, and -removes the released fragments before the release commit and tag are created. +5. Confirm the new tag, GitHub release, PyPI package, GHCR images, desktop + installers, and emptied pending fragment set. + +The workflow only runs from a `main` dispatch. It creates the release commit, +runs the full reusable CI workflow against that exact commit SHA, and allows no +registry publication unless that gate succeeds. PyPI, the local/control/worker +containers, and the Windows/macOS/Linux desktop installers are built from the +same release commit. The GitHub release is published only after both registry +jobs and all three desktop builds succeed. Python Semantic Release is the only +version authority. Towncrier is the only release-note renderer. During the +stable build it receives the calculated version, renders the GitHub release +body, updates `CHANGELOG.md` with the same section, and removes the released +fragments before the release commit and tag are created. Release and CI tools are declared once in `utils/build-requirements.txt`. Do not -duplicate their versions in workflow files. +duplicate their versions in workflow files. Python Semantic Release stamps the +Python, Cargo, npm, Tauri, and runtime-manifest source versions directly. The +build helper only mirrors that already-stamped value into the generated npm and +Cargo lockfiles, whose repeated version keys cannot be targeted safely by +Semantic Release. + +Desktop builds use `npm ci`, Cargo `--locked`, the checksummed uv sidecar, and +`desktop/runtime-constraints.txt`. The constraints are exported from +`uv.lock` and verified in the desktop workflow before packaging. Prerelease and +stable installers are intentionally unsigned until signing credentials are +configured; they are still published with the corresponding GitHub release. diff --git a/pyproject.toml b/pyproject.toml index cfc7678..89150bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ authors = [ ] description = "VidXP - Video indexing and search by dialogue, scene, and actor" readme = "README.md" -requires-python = ">=3.10,<3.14" +requires-python = ">=3.11,<3.15" license = "MIT" license-files = ["LICENSE"] keywords = ["video", "search", "indexing", "cli", "streamlit"] @@ -26,26 +26,36 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Multimedia :: Video", "Topic :: Software Development :: Libraries :: Python Modules", ] dynamic = ["optional-dependencies"] dependencies = [ - "filelock>=3.13", - "packaging>=24", - "pydantic>=2.8,<3", - "rich", + "dbos>=2.28,<3", + "filelock>=3.32,<4", + "packaging>=26.2,<27", + "platformdirs>=4.11,<5", + "pydantic>=2.13.4,<3", + "pydantic-settings>=2.14.2,<3", + "rich>=15,<16", + "sqlalchemy>=2.0.51,<2.1", "typer>=0.27,<1", ] [project.scripts] -vidxp = "vidxp.cli:main" +vidxp = "vidxp.entrypoint:main" +vidxp-api = "vidxp.api_cli:main" +vidxp-worker = "vidxp.workflow_worker:main" +vidxp-database = "vidxp.database_cli:main" +vidxp-hooks = "vidxp.hook_cli:main" +vidxp-mcp = "vidxp.mcp_cli:main" [project.urls] +Homepage = "https://github.com/grayhatdevelopers/vidxp" Repository = "https://github.com/grayhatdevelopers/vidxp" Issues = "https://github.com/grayhatdevelopers/vidxp/issues" @@ -62,6 +72,8 @@ vidxp = [ "benchmarks/requirements.txt", "capabilities/*/requirements.txt", "requirements/*.txt", + "migrations/*.py", + "migrations/versions/*.py", ] [tool.setuptools.dynamic.optional-dependencies] @@ -84,17 +96,58 @@ all = { file = [ "src/vidxp/capabilities/scene/requirements.txt", "src/vidxp/capabilities/actor/requirements.txt", ] } +local-worker = { file = [ + "src/vidxp/requirements/storage.txt", + "src/vidxp/requirements/slm.txt", + "src/vidxp/capabilities/dialogue/requirements.txt", + "src/vidxp/capabilities/scene/requirements.txt", + "src/vidxp/capabilities/actor/requirements.txt", +] } +mcp = { file = ["src/vidxp/requirements/mcp.txt"] } +slm = { file = ["src/vidxp/requirements/slm.txt"] } +server = { file = [ + "src/vidxp/requirements/server.txt", + "src/vidxp/requirements/mcp.txt", +] } +server-worker = { file = [ + "src/vidxp/requirements/server.txt", + "src/vidxp/requirements/server-storage.txt", + "src/vidxp/requirements/slm.txt", + "src/vidxp/capabilities/dialogue/requirements.txt", + "src/vidxp/capabilities/scene/requirements.txt", + "src/vidxp/capabilities/actor/requirements.txt", +] } +test = { file = ["src/vidxp/requirements/test.txt"] } frontend = { file = ["src/vidxp/requirements/frontend.txt"] } benchmarks = { file = ["src/vidxp/benchmarks/requirements.txt"] } [tool.ruff] -target-version = "py310" +target-version = "py311" [tool.ruff.lint] select = ["E4", "E7", "E9", "F"] +[tool.uv.sources] +torch = [ + { index = "pytorch-cpu", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[[tool.uv.index]] +name = "pytorch-cpu" +url = "https://download.pytorch.org/whl/cpu" +explicit = true + [tool.semantic_release] -version_toml = ["pyproject.toml:project.version"] +version_toml = [ + "pyproject.toml:project.version", + "desktop/src-tauri/Cargo.toml:package.version", +] +version_variables = [ + "desktop/package.json:version", + "desktop/runtime-manifest.json:desktop_version", + "desktop/runtime-manifest.json:package_version", + "desktop/src-tauri/tauri.conf.json:version", +] tag_format = "v{version}" build_command = "bash ./utils/build_package.sh" build_command_env = ["BUILD_CHANGELOG", "BUILD_RELEASE_NOTES"] diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..31da439 --- /dev/null +++ b/setup.py @@ -0,0 +1,17 @@ +from pathlib import Path +from shutil import copyfile + +from setuptools import setup +from setuptools.command.build_py import build_py + + +class BuildPy(build_py): + def run(self): + super().run() + source = Path(__file__).parent / "docs" / "images" / "logo.png" + target = Path(self.build_lib) / "vidxp" / "assets" / "icon.png" + target.parent.mkdir(parents=True, exist_ok=True) + copyfile(source, target) + + +setup(cmdclass={"build_py": BuildPy}) diff --git a/src/vidxp/__main__.py b/src/vidxp/__main__.py index 2f05ddc..d952b81 100644 --- a/src/vidxp/__main__.py +++ b/src/vidxp/__main__.py @@ -1,4 +1,4 @@ -from .cli import main +from .entrypoint import main if __name__ == "__main__": diff --git a/src/vidxp/api.py b/src/vidxp/api.py new file mode 100644 index 0000000..8431d5c --- /dev/null +++ b/src/vidxp/api.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager +from typing import Annotated, AsyncIterator + +from asgi_correlation_id import CorrelationIdMiddleware +from fastapi import Depends, FastAPI, Request, Response, Security +from fastapi.security import HTTPBearer +from vidxp import __version__ +from vidxp.api_errors import install_exception_handlers +from vidxp.api_middleware import ( + ApiCORSMiddleware, + BearerAuthenticationMiddleware, + RequestBodyLimitMiddleware, + RequestBodyTooLarge, + TypedTrustedHostMiddleware, + request_too_large_response, +) +from vidxp.api_models import HealthResponse, ReadinessResponse +from vidxp.api_routes import create_api_router +from vidxp.api_routes.dependencies import context +from vidxp.composition import HttpApplicationContext, create_http_application +from vidxp.mcp import MCPTransportSecurityBoundary, create_remote_mcp +from vidxp.settings import VidXPSettings + + +_BEARER_SECURITY = HTTPBearer( + auto_error=False, + scheme_name="BearerAuth", + description=( + "Bearer access token. Authentication is enforced once by the " + "server middleware." + ), +) + + +def create_app( + settings: VidXPSettings | None = None, + *, + context: HttpApplicationContext | None = None, +) -> FastAPI: + active_context = context or create_http_application(settings) + active_settings = active_context.settings + active_settings.validate_http_server() + owns_context = context is None + try: + remote_mcp = create_remote_mcp(active_context) + except Exception: + if owns_context: + active_context.close() + raise + mcp_paths = ("/mcp", "/mcp/") + delegated_auth_paths = ( + ( + *mcp_paths, + "/.well-known/oauth-protected-resource/mcp", + "/.well-known/oauth-protected-resource/mcp/", + ) + if remote_mcp.owns_authentication + else () + ) + + @asynccontextmanager + async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + try: + active_context.jobs.start() + async with remote_mcp.server.session_manager.run(): + yield + finally: + if owns_context: + active_context.close() + + publish_docs = active_settings.http_auth_mode.value == "none" + app = FastAPI( + title="VidXP API", + version=__version__, + lifespan=lifespan, + docs_url="/docs" if publish_docs else None, + redoc_url="/redoc" if publish_docs else None, + openapi_url="/openapi.json", + ) + app.state.vidxp = active_context + install_exception_handlers(app) + + @app.get( + "/health", + response_model=HealthResponse, + operation_id="health", + summary="Check process liveness", + ) + def health() -> HealthResponse: + return HealthResponse() + + @app.get( + "/ready", + response_model=ReadinessResponse, + responses={503: {"model": ReadinessResponse}}, + operation_id="ready", + summary="Check aggregate readiness", + ) + def ready( + response: Response, + service: Annotated[HttpApplicationContext, Depends(context)], + ) -> ReadinessResponse: + is_ready = service.readiness.ready() + if not is_ready: + response.status_code = 503 + return ReadinessResponse( + ready=is_ready, + status="ready" if is_ready else "not_ready", + ) + + api_dependencies = ( + [Security(_BEARER_SECURITY)] + if active_settings.http_auth_mode.value != "none" + else [] + ) + app.include_router( + create_api_router(), + dependencies=api_dependencies, + ) + app.mount("/", remote_mcp.app) + app.add_exception_handler( + RequestBodyTooLarge, + _request_body_too_large_response, + ) + app.add_middleware( + RequestBodyLimitMiddleware, + json_limit=active_settings.http_max_json_body_bytes, + upload_limit=active_settings.http_max_small_upload_bytes, + delegated_paths=mcp_paths, + ) + app.add_middleware( + BearerAuthenticationMiddleware, + authenticator=active_context.authenticator, + delegated_paths=delegated_auth_paths, + ) + if active_settings.http_allowed_origins: + app.add_middleware( + ApiCORSMiddleware, + allow_origins=active_settings.http_allowed_origins, + ) + app.add_middleware( + TypedTrustedHostMiddleware, + allowed_hosts=active_settings.http_trusted_hosts, + ) + app.add_middleware( + CorrelationIdMiddleware, + header_name="X-Request-ID", + ) + app.add_middleware( + MCPTransportSecurityBoundary, + settings=remote_mcp.transport_security, + ) + return app + + +async def _request_body_too_large_response( + _request: Request, + _exc: RequestBodyTooLarge, +) -> Response: + return request_too_large_response() diff --git a/src/vidxp/api_cli.py b/src/vidxp/api_cli.py new file mode 100644 index 0000000..4b49c32 --- /dev/null +++ b/src/vidxp/api_cli.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import argparse +from collections.abc import Sequence +from pathlib import Path + + +def _arguments(values: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Run the VidXP HTTP API and remote MCP server. " + "Configure the service with VIDXP_* environment variables." + ) + ) + parser.add_argument( + "--data-dir", + type=Path, + help="Store VidXP models and the default repository here.", + ) + return parser.parse_args(values) + + +def main(arguments: Sequence[str] | None = None) -> None: + options = _arguments(arguments) + + import uvicorn + + from vidxp.api import create_app + from vidxp.settings import VidXPSettings + + settings = ( + VidXPSettings(data_dir=options.data_dir) + if options.data_dir is not None + else VidXPSettings() + ) + settings.validate_http_server() + uvicorn.run( + create_app(settings), + host=settings.http_bind_host, + port=settings.http_port, + ) diff --git a/src/vidxp/api_errors.py b/src/vidxp/api_errors.py new file mode 100644 index 0000000..887ff90 --- /dev/null +++ b/src/vidxp/api_errors.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import logging + +from asgi_correlation_id import correlation_id +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException + +from vidxp.api_models import ErrorEnvelope +from vidxp.application_models import ( + ApplicationError, + ErrorCategory, + ErrorDetail, + InvalidRequestError, +) + + +LOGGER = logging.getLogger("vidxp.api") + +_STATUS_BY_CATEGORY = { + ErrorCategory.validation: 422, + ErrorCategory.authentication: 401, + ErrorCategory.authorization: 403, + ErrorCategory.not_found: 404, + ErrorCategory.conflict: 409, + ErrorCategory.unavailable: 503, + ErrorCategory.resource_limit: 429, + ErrorCategory.cancelled: 409, + ErrorCategory.internal: 500, +} + + +def public_error_response( + detail: ErrorDetail, + *, + status_code: int | None = None, + headers: dict[str, str] | None = None, +) -> JSONResponse: + request_id = correlation_id.get() + if detail.correlation_id is None and request_id is not None: + detail = detail.model_copy(update={"correlation_id": request_id}) + active_headers = dict(headers or {}) + if detail.category == ErrorCategory.authentication: + active_headers.setdefault("WWW-Authenticate", "Bearer") + return JSONResponse( + status_code=status_code or _STATUS_BY_CATEGORY[detail.category], + content=ErrorEnvelope(error=detail).model_dump(mode="json"), + headers=active_headers, + ) + + +def _validation_error(request_error: RequestValidationError) -> ErrorDetail: + errors = [ + { + "type": item["type"], + "location": [str(part) for part in item["loc"]], + "message": item["msg"], + } + for item in request_error.errors() + ] + return InvalidRequestError(errors=errors).detail + + +def install_exception_handlers(app: FastAPI) -> None: + @app.exception_handler(ApplicationError) + async def application_error_handler( + _request: Request, + exc: ApplicationError, + ) -> JSONResponse: + status = ( + 413 + if exc.code in {"media_too_large", "request_body_too_large"} + else None + ) + return public_error_response(exc.detail, status_code=status) + + @app.exception_handler(RequestValidationError) + async def request_validation_handler( + _request: Request, + exc: RequestValidationError, + ) -> JSONResponse: + return public_error_response(_validation_error(exc), status_code=422) + + @app.exception_handler(HTTPException) + async def http_error_handler( + _request: Request, + exc: HTTPException, + ) -> JSONResponse: + if exc.status_code == 404: + detail = ErrorDetail( + code="http_not_found", + category=ErrorCategory.not_found, + message="The requested HTTP resource was not found.", + ) + elif exc.status_code == 405: + detail = ErrorDetail( + code="http_method_not_allowed", + category=ErrorCategory.validation, + message="The HTTP method is not allowed for this resource.", + ) + else: + detail = ErrorDetail( + code="http_request_rejected", + category=ErrorCategory.validation, + message="The HTTP request was rejected.", + ) + return public_error_response( + detail, + status_code=exc.status_code, + headers=exc.headers, + ) + + @app.exception_handler(Exception) + async def unexpected_error_handler( + request: Request, + exc: Exception, + ) -> JSONResponse: + LOGGER.exception( + "Unhandled API error for %s %s", + request.method, + request.url.path, + exc_info=exc, + ) + return public_error_response( + ErrorDetail( + code="internal_error", + category=ErrorCategory.internal, + message="The request could not be completed.", + ), + status_code=500, + ) diff --git a/src/vidxp/api_middleware.py b/src/vidxp/api_middleware.py new file mode 100644 index 0000000..c13ae4d --- /dev/null +++ b/src/vidxp/api_middleware.py @@ -0,0 +1,319 @@ +from __future__ import annotations + +from fastapi.security.utils import get_authorization_scheme_param +from starlette.concurrency import run_in_threadpool +from starlette.datastructures import Headers +from starlette.exceptions import HTTPException +from starlette.middleware.cors import CORSMiddleware, SAFELISTED_HEADERS +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from vidxp.api_errors import public_error_response +from vidxp.application_models import ( + ApplicationError, + ErrorCategory, + ErrorDetail, +) +from vidxp.authentication import Authenticator + + +PUBLIC_HTTP_PATHS = frozenset({"/health", "/ready"}) +UPLOAD_PATH = "/api/v1/media" + + +class RequestBodyTooLarge(HTTPException, OSError): + """Abort body parsing while preserving Starlette's file cleanup path.""" + + def __init__(self) -> None: + super().__init__(status_code=413) + + +class ApiCORSMiddleware: + """Apply Starlette CORS policy only to the REST namespace.""" + + def __init__( + self, + app: ASGIApp, + *, + allow_origins: tuple[str, ...], + ) -> None: + self.app = app + self.allow_origins = frozenset(allow_origins) + self.allow_methods = frozenset({"DELETE", "GET", "HEAD", "POST"}) + self.allow_headers = frozenset( + header.lower() + for header in ( + *SAFELISTED_HEADERS, + "Authorization", + "Idempotency-Key", + "Range", + "X-Request-ID", + ) + ) + self.cors = CORSMiddleware( + app, + allow_origins=list(allow_origins), + allow_credentials=False, + allow_methods=sorted(self.allow_methods), + allow_headers=[ + "Authorization", + "Content-Type", + "Idempotency-Key", + "Range", + "X-Request-ID", + ], + expose_headers=[ + "Accept-Ranges", + "Content-Disposition", + "Content-Length", + "Content-Range", + "ETag", + "Location", + "X-Request-ID", + ], + max_age=600, + ) + + async def __call__( + self, + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + if ( + scope["type"] == "http" + and str(scope.get("path", "")).startswith("/api/") + ): + headers = Headers(scope=scope) + origin = headers.get("origin") + requested_method = headers.get("access-control-request-method") + if origin is not None and requested_method is not None: + requested_headers = { + value.strip().lower() + for value in headers.get( + "access-control-request-headers", + "", + ).split(",") + if value.strip() + } + if ( + "*" not in self.allow_origins + and origin not in self.allow_origins + ): + await public_error_response( + ErrorDetail( + code="cors_origin_forbidden", + category=ErrorCategory.authorization, + message="The browser origin is not allowed.", + ), + status_code=403, + )(scope, receive, send) + return + if ( + requested_method not in self.allow_methods + or not requested_headers.issubset(self.allow_headers) + ): + await public_error_response( + ErrorDetail( + code="cors_preflight_invalid", + category=ErrorCategory.validation, + message="The CORS preflight request is invalid.", + ), + status_code=400, + )(scope, receive, send) + return + await self.cors(scope, receive, send) + return + await self.app(scope, receive, send) + + +class TypedTrustedHostMiddleware: + def __init__( + self, + app: ASGIApp, + *, + allowed_hosts: tuple[str, ...], + ) -> None: + self.app = app + self.allowed_hosts = allowed_hosts + self.allow_any = "*" in allowed_hosts + + async def __call__( + self, + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + if self.allow_any or scope["type"] != "http": + await self.app(scope, receive, send) + return + host = _host_name(Headers(scope=scope).get("host", "")) + if any( + host == pattern + or ( + pattern.startswith("*.") + and host.endswith(pattern[1:]) + ) + for pattern in self.allowed_hosts + ): + await self.app(scope, receive, send) + return + await public_error_response( + ErrorDetail( + code="host_not_allowed", + category=ErrorCategory.validation, + message="The HTTP Host header is not allowed.", + ), + status_code=400, + )(scope, receive, send) + + +def _host_name(header: str) -> str: + value = header.lower() + if value.startswith("["): + closing = value.find("]") + if closing < 0: + return "" + remainder = value[closing + 1 :] + if remainder and ( + not remainder.startswith(":") + or not remainder[1:].isdigit() + ): + return "" + return value[1:closing] + if value.count(":") == 1: + candidate, port = value.rsplit(":", 1) + if port.isdigit(): + return candidate + return value + + +class BearerAuthenticationMiddleware: + def __init__( + self, + app: ASGIApp, + *, + authenticator: Authenticator, + delegated_paths: tuple[str, ...] = (), + ) -> None: + self.app = app + self.authenticator = authenticator + self.delegated_paths = frozenset(delegated_paths) + + async def __call__( + self, + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + if ( + scope["type"] != "http" + or str(scope.get("path", "")) in PUBLIC_HTTP_PATHS + or str(scope.get("path", "")) in self.delegated_paths + ): + await self.app(scope, receive, send) + return + authorization = Headers(scope=scope).get("authorization") + scheme, credentials = get_authorization_scheme_param(authorization) + token = credentials if scheme.lower() == "bearer" else None + try: + principal = await run_in_threadpool( + self.authenticator.authenticate, + token, + ) + except ApplicationError as exc: + await public_error_response(exc.detail)( + scope, + receive, + send, + ) + return + scope["vidxp.principal"] = principal + await self.app(scope, receive, send) + + +class RequestBodyLimitMiddleware: + def __init__( + self, + app: ASGIApp, + *, + json_limit: int, + upload_limit: int, + delegated_paths: tuple[str, ...] = (), + ) -> None: + self.app = app + self.json_limit = json_limit + self.upload_limit = upload_limit + self.delegated_paths = frozenset(delegated_paths) + + async def __call__( + self, + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + if str(scope.get("path", "")) in self.delegated_paths: + await self.app(scope, receive, send) + return + limit = ( + self.upload_limit + 1024 * 1024 + if scope.get("path") == UPLOAD_PATH + else self.json_limit + ) + headers = Headers(scope=scope) + content_length = headers.get("content-length") + if content_length is not None: + try: + declared = int(content_length) + except ValueError: + await public_error_response( + ErrorDetail( + code="content_length_invalid", + category=ErrorCategory.validation, + message="The Content-Length header is invalid.", + ), + status_code=400, + )(scope, receive, send) + return + if declared < 0: + await public_error_response( + ErrorDetail( + code="content_length_invalid", + category=ErrorCategory.validation, + message="The Content-Length header is invalid.", + ), + status_code=400, + )(scope, receive, send) + return + if declared > limit: + await request_too_large_response()(scope, receive, send) + return + + received = 0 + + async def limited_receive() -> Message: + nonlocal received + message = await receive() + if message["type"] == "http.request": + received += len(message.get("body", b"")) + if received > limit: + raise RequestBodyTooLarge + return message + + try: + await self.app(scope, limited_receive, send) + except RequestBodyTooLarge: + await request_too_large_response()(scope, receive, send) + + +def request_too_large_response(): + return public_error_response( + ErrorDetail( + code="request_body_too_large", + category=ErrorCategory.resource_limit, + message="The HTTP request body exceeds the configured limit.", + ), + status_code=413, + ) diff --git a/src/vidxp/api_models.py b/src/vidxp/api_models.py new file mode 100644 index 0000000..6f7f269 --- /dev/null +++ b/src/vidxp/api_models.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import Literal + +from vidxp.application_models import ( + ApplicationModel, + ErrorDetail, + UploadIntent, +) + + +class ErrorEnvelope(ApplicationModel): + error: ErrorDetail + + +class HealthResponse(ApplicationModel): + status: Literal["ok"] = "ok" + + +class ReadinessResponse(ApplicationModel): + ready: bool + status: Literal["ready", "not_ready"] + + +class UploadIntentResponse(ApplicationModel): + intent: UploadIntent + creation_url: str + upload_metadata: str + resume_url: str | None = None diff --git a/src/vidxp/api_routes/__init__.py b/src/vidxp/api_routes/__init__.py new file mode 100644 index 0000000..caddbab --- /dev/null +++ b/src/vidxp/api_routes/__init__.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter + +from vidxp.api_models import ErrorEnvelope +from vidxp.api_routes import artifacts, index, jobs, media, platform + + +ERROR_RESPONSES = { + status: {"model": ErrorEnvelope} + for status in (400, 401, 403, 404, 409, 413, 422, 429, 500, 503) +} + + +def create_api_router() -> APIRouter: + router = APIRouter( + prefix="/api/v1", + responses=ERROR_RESPONSES, + ) + router.include_router(platform.router) + router.include_router(media.router) + router.include_router(index.router) + router.include_router(jobs.router) + router.include_router(artifacts.router) + return router diff --git a/src/vidxp/api_routes/artifacts.py b/src/vidxp/api_routes/artifacts.py new file mode 100644 index 0000000..1b2c3d7 --- /dev/null +++ b/src/vidxp/api_routes/artifacts.py @@ -0,0 +1,75 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, Request, Response + +from vidxp.api_routes.dependencies import ( + context, + file_response, + read_principal, +) +from vidxp.application_models import Artifact +from vidxp.composition import HttpApplicationContext +from vidxp.core.identifiers import ArtifactId + + +router = APIRouter( + prefix="/artifacts", + tags=["artifacts"], + dependencies=[Depends(read_principal)], +) + + +@router.get( + "/{artifact_id}", + response_model=Artifact, + operation_id="getArtifact", + summary="Get artifact metadata", +) +def get_artifact( + artifact_id: ArtifactId, + service: Annotated[HttpApplicationContext, Depends(context)], +) -> Artifact: + return service.application.get_artifact(artifact_id) + + +def _content( + artifact_id: ArtifactId, + request: Request, + service: HttpApplicationContext, +) -> Response: + return file_response( + request, + service.application.open_artifact_content(artifact_id), + disposition="attachment", + ) + + +@router.get( + "/{artifact_id}/content", + response_model=None, + operation_id="getArtifactContent", + summary="Download a generated clip or artifact", + description=( + "Stream the generated artifact with attachment, byte-range, and ETag " + "support. Use the artifact_id returned by a completed snippet or " + "actor-overlay job." + ), +) +def get_artifact_content( + artifact_id: ArtifactId, + request: Request, + service: Annotated[HttpApplicationContext, Depends(context)], +) -> Response: + return _content(artifact_id, request, service) + + +@router.head( + "/{artifact_id}/content", + include_in_schema=False, +) +def head_artifact_content( + artifact_id: ArtifactId, + request: Request, + service: Annotated[HttpApplicationContext, Depends(context)], +) -> Response: + return _content(artifact_id, request, service) diff --git a/src/vidxp/api_routes/dependencies.py b/src/vidxp/api_routes/dependencies.py new file mode 100644 index 0000000..15afef6 --- /dev/null +++ b/src/vidxp/api_routes/dependencies.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import os +import tempfile +from pathlib import Path +from typing import Annotated + +from fastapi import Header, Request, Response, UploadFile +from fastapi.responses import FileResponse + +from vidxp.application_models import ( + ApplicationError, + ErrorCategory, + Job, + Principal, +) +from vidxp.composition import HttpApplicationContext +from vidxp.authorization import AuthorizationPolicy, RepositoryPermission +from vidxp.core.media import safe_media_suffix +from vidxp.idempotency import ( + IdempotencyKey, + scoped_job_id as derive_scoped_job_id, + scoped_request_key as derive_scoped_request_key, +) +from vidxp.ports import LocalFileResource + + +HttpIdempotencyKey = Annotated[ + IdempotencyKey, + Header( + alias="Idempotency-Key", + ), +] + + +def context(request: Request) -> HttpApplicationContext: + return request.app.state.vidxp + + +def principal( + request: Request, +) -> Principal: + active = request.scope.get("vidxp.principal") + if not isinstance(active, Principal): + raise ApplicationError( + "authentication_required", + ErrorCategory.authentication, + "Valid bearer authentication is required.", + ) + return active + + +def _authorized( + request: Request, + permission: RepositoryPermission, +) -> Principal: + active = principal(request) + service = context(request) + policy: AuthorizationPolicy = service.authorization + return policy.require(active, permission) + + +def read_principal( + request: Request, +) -> Principal: + return _authorized(request, RepositoryPermission.read) + + +def write_principal( + request: Request, +) -> Principal: + return _authorized(request, RepositoryPermission.write) + + +def accepted(response: Response, job: Job) -> Job: + response.headers["Location"] = f"/api/v1/jobs/{job.job_id}" + return job + + +def scoped_job_id( + service: HttpApplicationContext, + actor: Principal, + *, + operation: str, + idempotency_key: str, +) -> str: + """Derive a non-reversible DBOS workflow ID from an HTTP request key.""" + + return derive_scoped_job_id( + principal=actor, + transport="http", + operation=operation, + idempotency_key=idempotency_key, + ) + + +def scoped_request_key( + service: HttpApplicationContext, + actor: Principal, + *, + operation: str, + idempotency_key: str, +) -> str: + return derive_scoped_request_key( + principal=actor, + transport="http", + operation=operation, + idempotency_key=idempotency_key, + ) + + +def _etag_matches(request: Request, etag: str) -> bool: + supplied = request.headers.get("if-none-match") + if supplied is None: + return False + return any( + candidate == "*" or candidate.removeprefix("W/") == etag + for candidate in ( + item.strip() for item in supplied.split(",") + ) + ) + + +def file_response( + request: Request, + resource: LocalFileResource, + *, + disposition: str, +) -> Response: + etag = f'"{resource.etag}"' + common_headers = { + "ETag": etag, + "Cache-Control": "private, no-store", + "Accept-Ranges": "bytes", + } + if _etag_matches(request, etag): + return Response(status_code=304, headers=common_headers) + return FileResponse( + resource.path, + media_type=resource.mime_type, + filename=resource.filename, + headers=common_headers, + content_disposition_type=disposition, + ) + + +def copy_upload( + upload: UploadFile, + *, + maximum: int, +) -> Path: + suffix = safe_media_suffix(Path(upload.filename or "upload.bin")) + handle = tempfile.NamedTemporaryFile( + mode="w+b", + prefix="vidxp-upload-", + suffix=suffix, + delete=False, + ) + path = Path(handle.name) + total = 0 + try: + with handle: + while chunk := upload.file.read(1024 * 1024): + total += len(chunk) + if total > maximum: + raise ApplicationError( + "media_too_large", + ErrorCategory.resource_limit, + "The uploaded media exceeds the configured limit.", + ) + handle.write(chunk) + handle.flush() + os.fsync(handle.fileno()) + return path + except BaseException: + path.unlink(missing_ok=True) + raise diff --git a/src/vidxp/api_routes/index.py b/src/vidxp/api_routes/index.py new file mode 100644 index 0000000..84ee004 --- /dev/null +++ b/src/vidxp/api_routes/index.py @@ -0,0 +1,26 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends + +from vidxp.api_routes.dependencies import context, read_principal +from vidxp.application_models import IndexStatus +from vidxp.composition import HttpApplicationContext + + +router = APIRouter( + prefix="/index", + tags=["index"], + dependencies=[Depends(read_principal)], +) + + +@router.get( + "/status", + response_model=IndexStatus, + operation_id="getIndexStatus", + summary="Get index status", +) +def get_index_status( + service: Annotated[HttpApplicationContext, Depends(context)], +) -> IndexStatus: + return service.application.index_status() diff --git a/src/vidxp/api_routes/jobs.py b/src/vidxp/api_routes/jobs.py new file mode 100644 index 0000000..72e85c6 --- /dev/null +++ b/src/vidxp/api_routes/jobs.py @@ -0,0 +1,313 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, Query, Response + +from vidxp.api_routes.dependencies import ( + accepted, + context, + HttpIdempotencyKey, + read_principal, + scoped_job_id, + write_principal, +) +from vidxp.application_models import ( + CreateActorOverlayCommand, + CreateIndexCommand, + CreateSnippetCommand, + Job, + JobPage, + JobResult, + ListJobsCommand, + Principal, + PrepareModelsCommand, + QueryVideoCommand, + SearchCommand, +) +from vidxp.composition import HttpApplicationContext +from vidxp.core.identifiers import JobId + + +router = APIRouter(prefix="/jobs", tags=["jobs"]) + + +@router.post( + "/index", + response_model=Job, + status_code=202, + operation_id="startIndexing", + summary="Start indexing", + description=( + "Add or replace one registered media item in the active multi-video " + "index snapshot." + ), + dependencies=[Depends(write_principal)], +) +def submit_index( + command: CreateIndexCommand, + response: Response, + service: Annotated[HttpApplicationContext, Depends(context)], + actor: Annotated[Principal, Depends(write_principal)], + idempotency_key: HttpIdempotencyKey, +) -> Job: + service.application.require_models(command.modalities) + return accepted( + response, + service.jobs.submit_index( + command, + job_id=scoped_job_id( + service, + actor, + operation="index", + idempotency_key=idempotency_key, + ), + ), + ) + + +@router.post( + "/search", + response_model=Job, + status_code=202, + operation_id="searchMoments", + summary="Search indexed moments", + description=( + "Set media_id to search one video, or omit it to rank moments across " + "every media item in the active index snapshot." + ), + dependencies=[Depends(read_principal)], +) +def submit_search( + command: SearchCommand, + response: Response, + service: Annotated[HttpApplicationContext, Depends(context)], + actor: Annotated[Principal, Depends(read_principal)], + idempotency_key: HttpIdempotencyKey, +) -> Job: + return accepted( + response, + service.jobs.submit_search( + command, + job_id=scoped_job_id( + service, + actor, + operation="search", + idempotency_key=idempotency_key, + ), + ), + ) + + +@router.post( + "/query", + response_model=Job, + status_code=202, + operation_id="queryVideo", + summary="Ask a grounded question about indexed media", + description=( + "Set media_id to ground the answer in one video, or omit it to use " + "evidence across every media item in the active index snapshot." + ), + dependencies=[Depends(read_principal)], +) +def submit_query( + command: QueryVideoCommand, + response: Response, + service: Annotated[HttpApplicationContext, Depends(context)], + actor: Annotated[Principal, Depends(read_principal)], + idempotency_key: HttpIdempotencyKey, +) -> Job: + return accepted( + response, + service.jobs.submit_query( + command, + job_id=scoped_job_id( + service, + actor, + operation="query", + idempotency_key=idempotency_key, + ), + ), + ) + + +@router.post( + "/snippet", + response_model=Job, + status_code=202, + operation_id="createSnippet", + summary="Create a downloadable video clip", + description=( + "Create a durable clip-rendering job from a media ID and time range " + "returned by search or query. Poll the job, then download the " + "resulting artifact through GET /api/v1/artifacts/{artifact_id}/content." + ), + dependencies=[Depends(write_principal)], +) +def submit_snippet( + command: CreateSnippetCommand, + response: Response, + service: Annotated[HttpApplicationContext, Depends(context)], + actor: Annotated[Principal, Depends(write_principal)], + idempotency_key: HttpIdempotencyKey, +) -> Job: + return accepted( + response, + service.jobs.submit_snippet( + command, + job_id=scoped_job_id( + service, + actor, + operation="snippet", + idempotency_key=idempotency_key, + ), + ), + ) + + +@router.post( + "/actor-overlay", + response_model=Job, + status_code=202, + operation_id="createActorOverlay", + summary="Create an actor overlay", + dependencies=[Depends(write_principal)], +) +def submit_actor_overlay( + command: CreateActorOverlayCommand, + response: Response, + service: Annotated[HttpApplicationContext, Depends(context)], + actor: Annotated[Principal, Depends(write_principal)], + idempotency_key: HttpIdempotencyKey, +) -> Job: + return accepted( + response, + service.jobs.submit_actor_overlay( + command, + job_id=scoped_job_id( + service, + actor, + operation="actor-overlay", + idempotency_key=idempotency_key, + ), + ), + ) + + +@router.post( + "/model-preparation", + response_model=Job, + status_code=202, + operation_id="prepareModels", + summary="Prepare models", + dependencies=[Depends(write_principal)], +) +def submit_model_preparation( + command: PrepareModelsCommand, + response: Response, + service: Annotated[HttpApplicationContext, Depends(context)], + actor: Annotated[Principal, Depends(write_principal)], + idempotency_key: HttpIdempotencyKey, +) -> Job: + return accepted( + response, + service.jobs.submit_prepare_models( + command, + job_id=scoped_job_id( + service, + actor, + operation="model-preparation", + idempotency_key=idempotency_key, + ), + ), + ) + + +@router.get( + "", + response_model=JobPage, + operation_id="listJobs", + summary="List jobs", + dependencies=[Depends(read_principal)], +) +def list_jobs( + service: Annotated[HttpApplicationContext, Depends(context)], + page_size: Annotated[int, Query(gt=0, le=100)] = 50, + cursor: Annotated[ + str | None, + Query(min_length=1, max_length=512), + ] = None, +) -> JobPage: + return service.jobs.list( + ListJobsCommand(page_size=page_size, cursor=cursor) + ) + + +@router.get( + "/{job_id}", + response_model=Job, + operation_id="getJob", + summary="Get a job", + dependencies=[Depends(read_principal)], +) +def get_job( + job_id: JobId, + service: Annotated[HttpApplicationContext, Depends(context)], +) -> Job: + return service.jobs.get(job_id) + + +@router.get( + "/{job_id}/result", + response_model=JobResult, + operation_id="getJobResult", + summary="Get a job result", + dependencies=[Depends(read_principal)], +) +def get_job_result( + job_id: JobId, + service: Annotated[HttpApplicationContext, Depends(context)], +) -> JobResult: + return service.jobs.result(job_id) + + +@router.post( + "/{job_id}/cancellation", + response_model=Job, + operation_id="cancelJob", + summary="Cancel a job", + dependencies=[Depends(write_principal)], +) +def cancel_job( + job_id: JobId, + service: Annotated[HttpApplicationContext, Depends(context)], +) -> Job: + return service.jobs.cancel(job_id) + + +@router.post( + "/{job_id}/retries", + response_model=Job, + status_code=202, + operation_id="retryJob", + summary="Retry a job", + dependencies=[Depends(write_principal)], +) +def retry_job( + job_id: JobId, + response: Response, + service: Annotated[HttpApplicationContext, Depends(context)], + actor: Annotated[Principal, Depends(write_principal)], + idempotency_key: HttpIdempotencyKey, +) -> Job: + return accepted( + response, + service.jobs.retry( + job_id, + retry_id=scoped_job_id( + service, + actor, + operation=f"retry:{job_id}", + idempotency_key=idempotency_key, + ), + ), + ) diff --git a/src/vidxp/api_routes/media.py b/src/vidxp/api_routes/media.py new file mode 100644 index 0000000..0122bd2 --- /dev/null +++ b/src/vidxp/api_routes/media.py @@ -0,0 +1,240 @@ +from base64 import b64encode +from typing import Annotated + +from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFile + +from vidxp.api_routes.dependencies import ( + context, + copy_upload, + file_response, + HttpIdempotencyKey, + read_principal, + scoped_request_key, + write_principal, +) +from vidxp.application_models import ( + ApplicationError, + CreateUploadIntentCommand, + ErrorCategory, + ListMediaCommand, + MediaAsset, + MediaPage, + Principal, + UploadIntent, + UploadIntentId, +) +from vidxp.api_models import UploadIntentResponse +from vidxp.composition import HttpApplicationContext +from vidxp.core.identifiers import MediaId + + +router = APIRouter(prefix="/media", tags=["media"]) + + +def _upload_response( + service: HttpApplicationContext, + actor: Principal, + intent: UploadIntent, +) -> UploadIntentResponse: + if service.uploads is None: + raise ApplicationError( + "remote_upload_unavailable", + ErrorCategory.unavailable, + "Remote resumable uploads are not configured.", + ) + assert service.settings.upload_public_endpoint is not None + encoded_intent = b64encode(intent.intent_id.encode("ascii")).decode("ascii") + return UploadIntentResponse( + intent=intent, + creation_url=service.settings.upload_public_endpoint, + upload_metadata=f"intent_id {encoded_intent}", + resume_url=service.uploads.upload_url( + intent.intent_id, + principal=actor, + ), + ) + + +def _private_upload_response(response: Response) -> None: + response.headers["Cache-Control"] = "private, no-store" + response.headers["Referrer-Policy"] = "no-referrer" + + +@router.post( + "/uploads", + response_model=UploadIntentResponse, + status_code=201, + operation_id="createUploadIntent", + summary="Create a resumable media upload", + dependencies=[Depends(write_principal)], +) +def create_upload_intent( + command: CreateUploadIntentCommand, + response: Response, + service: Annotated[HttpApplicationContext, Depends(context)], + actor: Annotated[Principal, Depends(write_principal)], + idempotency_key: HttpIdempotencyKey, +) -> UploadIntentResponse: + if service.uploads is None: + raise ApplicationError( + "remote_upload_unavailable", + ErrorCategory.unavailable, + "Remote resumable uploads are not configured.", + ) + _private_upload_response(response) + intent = service.uploads.create_intent( + command, + principal=actor, + request_key=scoped_request_key( + service, + actor, + operation="media-upload-intent", + idempotency_key=idempotency_key, + ), + ) + response.headers["Location"] = f"/api/v1/media/uploads/{intent.intent_id}" + return _upload_response(service, actor, intent) + + +@router.get( + "/uploads/{intent_id}", + response_model=UploadIntentResponse, + operation_id="getUploadIntent", + summary="Get resumable upload state", + dependencies=[Depends(read_principal)], +) +def get_upload_intent( + intent_id: UploadIntentId, + response: Response, + service: Annotated[HttpApplicationContext, Depends(context)], + actor: Annotated[Principal, Depends(read_principal)], +) -> UploadIntentResponse: + if service.uploads is None: + raise ApplicationError( + "remote_upload_unavailable", + ErrorCategory.unavailable, + "Remote resumable uploads are not configured.", + ) + _private_upload_response(response) + return _upload_response( + service, + actor, + service.uploads.get_intent(intent_id, principal=actor), + ) + + +@router.post( + "", + response_model=MediaAsset, + status_code=201, + operation_id="importSmallMedia", + summary="Import a small media file", + dependencies=[Depends(write_principal)], +) +def import_media( + upload: Annotated[UploadFile, File()], + service: Annotated[HttpApplicationContext, Depends(context)], + actor: Annotated[Principal, Depends(write_principal)], + idempotency_key: HttpIdempotencyKey, +) -> MediaAsset: + staged = copy_upload( + upload, + maximum=service.settings.http_max_small_upload_bytes, + ) + try: + return service.application.import_uploaded_media( + staged_path=staged, + original_filename=upload.filename or "", + declared_mime_type=upload.content_type, + request_key=scoped_request_key( + service, + actor, + operation="media-import", + idempotency_key=idempotency_key, + ), + ) + finally: + staged.unlink(missing_ok=True) + + +@router.get( + "", + response_model=MediaPage, + operation_id="listMedia", + summary="List media", + description=( + "List registered filenames, metadata, and stable media IDs. A " + "registered item is searchable only when its ID is also present in " + "the active index snapshot." + ), + dependencies=[Depends(read_principal)], +) +def list_media( + service: Annotated[HttpApplicationContext, Depends(context)], + page_size: Annotated[int, Query(gt=0, le=100)] = 50, + cursor: Annotated[ + str | None, + Query(min_length=1, max_length=512), + ] = None, +) -> MediaPage: + return service.application.list_media( + ListMediaCommand(page_size=page_size, cursor=cursor) + ) + + +@router.get( + "/{media_id}", + response_model=MediaAsset, + operation_id="getMedia", + summary="Get media metadata", + description=( + "Get one registered media item by the stable ID returned from the " + "media list or a completed upload." + ), + dependencies=[Depends(read_principal)], +) +def get_media( + media_id: MediaId, + service: Annotated[HttpApplicationContext, Depends(context)], +) -> MediaAsset: + return service.application.get_media(media_id) + + +def _content( + media_id: MediaId, + request: Request, + service: HttpApplicationContext, +) -> Response: + return file_response( + request, + service.application.open_media_content(media_id), + disposition="inline", + ) + + +@router.get( + "/{media_id}/content", + response_model=None, + operation_id="getMediaContent", + summary="Stream media content", + dependencies=[Depends(read_principal)], +) +def get_media_content( + media_id: MediaId, + request: Request, + service: Annotated[HttpApplicationContext, Depends(context)], +) -> Response: + return _content(media_id, request, service) + + +@router.head( + "/{media_id}/content", + include_in_schema=False, + dependencies=[Depends(read_principal)], +) +def head_media_content( + media_id: MediaId, + request: Request, + service: Annotated[HttpApplicationContext, Depends(context)], +) -> Response: + return _content(media_id, request, service) diff --git a/src/vidxp/api_routes/platform.py b/src/vidxp/api_routes/platform.py new file mode 100644 index 0000000..59fc8fa --- /dev/null +++ b/src/vidxp/api_routes/platform.py @@ -0,0 +1,54 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends + +from vidxp.api_routes.dependencies import context, read_principal +from vidxp.application_models import ( + CapabilityInfo, + CapabilityList, + RuntimeReadiness, +) +from vidxp.composition import HttpApplicationContext + + +router = APIRouter( + tags=["platform"], + dependencies=[Depends(read_principal)], +) + + +@router.get( + "/runtime/readiness", + response_model=RuntimeReadiness, + operation_id="getRuntimeReadiness", + summary="Inspect runtime readiness", +) +def runtime_readiness( + service: Annotated[HttpApplicationContext, Depends(context)], +) -> RuntimeReadiness: + return service.readiness.details() + + +@router.get( + "/capabilities", + response_model=CapabilityList, + operation_id="listCapabilities", + summary="List capabilities", +) +def list_capabilities( + service: Annotated[HttpApplicationContext, Depends(context)], +) -> CapabilityList: + return CapabilityList(items=service.application.list_capabilities()) + + +@router.get( + "/capabilities/{name}", + response_model=CapabilityInfo, + operation_id="getCapability", + summary="Get capability metadata", +) +def get_capability( + name: str, + service: Annotated[HttpApplicationContext, Depends(context)], +) -> CapabilityInfo: + return service.application.get_capability(name) diff --git a/src/vidxp/app_paths.py b/src/vidxp/app_paths.py new file mode 100644 index 0000000..ca12d63 --- /dev/null +++ b/src/vidxp/app_paths.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import os +import shutil +from pathlib import Path + +from platformdirs import user_config_path, user_data_path + + +APP_NAME = "VidXP" + + +def default_data_directory() -> Path: + configured = os.environ.get("VIDXP_DATA_DIR") + if configured: + return Path(configured).expanduser() + return user_data_path(APP_NAME, appauthor=False) + + +def default_repository_directory(data_directory: Path | None = None) -> Path: + root = data_directory or default_data_directory() + return root / "repositories" / "default" + + +def default_model_directory(data_directory: Path | None = None) -> Path: + root = data_directory or default_data_directory() + return root / "models" + + +def default_config_directory() -> Path: + return user_config_path(APP_NAME, appauthor=False, roaming=True) + + +def available_storage_bytes(directory: Path) -> int | None: + """Return free bytes on the volume that will contain ``directory``.""" + + candidate = directory.expanduser().resolve(strict=False) + while not candidate.exists() and candidate != candidate.parent: + candidate = candidate.parent + try: + return shutil.disk_usage(candidate).free + except OSError: + return None diff --git a/src/vidxp/application.py b/src/vidxp/application.py index 57223d0..fcbd1cf 100644 --- a/src/vidxp/application.py +++ b/src/vidxp/application.py @@ -1,358 +1,891 @@ from __future__ import annotations -from dataclasses import replace +from contextlib import contextmanager from pathlib import Path -from typing import Any, Iterable, Mapping, cast +from shutil import which +from time import perf_counter +from typing import Any, Callable, Iterator, Mapping, cast from pydantic import BaseModel -from vidxp.capabilities.contracts import ( - CapabilityContext, - PreparationContext, - capability_install_hint, -) -from vidxp.capabilities.registry import ( - capability_names, - collection_names, - dependency_checks, - get_capability, - index_capability_names, - preparable_capability_names, - validate_capability_options, - validate_capability_names, +from vidxp.application_boundary import application_boundary +from vidxp.application_models import ( + ApplicationError, + Artifact, + CapabilityDependencyCheck, + ComponentReadiness, + CreateIndexCommand, + CreateActorOverlayCommand, + CreateSnippetCommand, + DependencyCheckCommand, + DependencyCheckResult, + DependencyKind, + DependencyUnavailableError, + ErrorCategory, + FusedSearchResult, + IndexResult, + ImportMediaCommand, + IndexSnapshotReference, + MediaAsset, + ModelUnavailableError, + PrepareModelsCommand, + PrepareModelsResult, + RemoveIndexCommand, + ResourceNotFoundError, + RuntimeReadiness, + QueryAnswer, + QueryVideoCommand, + SearchCommand, + SearchMomentsPlanStep, ) from vidxp.capabilities.actor.schemas import ( ActorClusterSummary, - ActorDetection, - ActorRenderResult, + ActorClustersOutput, + ActorDetectionsOutput, +) +from vidxp.capabilities.contracts import ( + CapabilityDependencyError, + CapabilityContext, + CapabilityRequestError, + PreparationContext, ) +from vidxp.capabilities.registry import CapabilityRegistry +from vidxp.capability_service import CapabilityService from vidxp.capabilities.schemas import SearchResult from vidxp.core.contracts import ( - CancellationToken, IndexConfig, - IndexSchemaError, -) -from vidxp.core.manifest import ( - CHECKPOINT_DIRECTORY, - COMPLETION_FILE, - FAILURES_FILE, - MANIFEST_FILE, - TIMINGS_FILE, ) -from vidxp.core.runner import ( - ProgressCallback, - index_video, - indexing_in_progress, - local_config_from_status, +from vidxp.execution import ExecutionContext, execution_context +from vidxp.ports import IndexBackend, ModelRuntimePort, QueryModelPort +from vidxp.query_service import GroundedQueryService +from vidxp.search_fusion import fuse_search_results +from vidxp.model_contracts import ModelArtifactUnavailableError +from vidxp.repository_layout import RepositoryLayout +from vidxp.settings import VidXPSettings +from vidxp.control_plane import ControlPlaneApplication +from vidxp.artifact_service import ( + ArtifactService, ) -from vidxp.core.storage import IndexStorage -from vidxp.index_state import ( - INDEX_STATUS_FILE, - INDEX_STATUS_SCHEMA, - IndexingInProgressError, - read_index_status, - require_ready_index, +from vidxp.media_service import ( + MediaService, ) -class VidXPService: - """Reusable application boundary for CLI, HTTP, and other adapters.""" +class VidXPApplication(ControlPlaneApplication): + """The transport-neutral command and query boundary.""" def __init__( self, - index_directory: str | Path = "chroma_data", *, - device: str | None = None, + settings: VidXPSettings, + layout: RepositoryLayout, + registry: CapabilityRegistry, + runtime: ModelRuntimePort, + index_backend: IndexBackend, + media: MediaService, + artifacts: ArtifactService, + index_status: Callable[[], dict[str, Any] | None], + completed_upload_importer: Callable[[str], MediaAsset] | None = None, + query_model: QueryModelPort | None = None, ) -> None: - self.index_directory = Path(index_directory) - self.device = device - - def index_status(self) -> dict[str, Any]: - status = read_index_status(self.index_directory) - if status is not None: - payload = dict(status) - else: - payload = { - "schema_version": INDEX_STATUS_SCHEMA, - "state": "missing", - "stage": "status", - "message": "No local video index was found.", - } - payload["index_directory"] = str(self.index_directory) - return payload + self.registry = registry + self.runtime = runtime + self.index_backend = index_backend + self.query = GroundedQueryService(query_model) + self._completed_upload_importer = completed_upload_importer + super().__init__( + layout=layout, + capabilities=CapabilityService(registry), + media=media, + artifacts=artifacts, + index_status=index_status, + model_cache=settings.model_cache, + ) + self.settings = settings - def active_config(self) -> tuple[IndexConfig, dict[str, Any]]: - status = require_ready_index(self.index_directory) - config = local_config_from_status( - status, - storage_directory=self.index_directory, + @contextmanager + def _capability_dependencies( + self, + capabilities: tuple[str, ...], + *, + missing_resource: str | None = None, + ) -> Iterator[None]: + try: + yield + except FileNotFoundError as exc: + if missing_resource is not None: + raise ResourceNotFoundError(missing_resource) from exc + raise DependencyUnavailableError( + capabilities, + self.registry.install_hint(capabilities), + ) from exc + except ModelArtifactUnavailableError as exc: + raise ModelUnavailableError(exc.capability) from exc + except (ModuleNotFoundError, CapabilityDependencyError) as exc: + raise DependencyUnavailableError( + capabilities, + self.registry.install_hint(capabilities), + ) from exc + + @property + def index_directory(self) -> Path: + return self.layout.local_index + + @property + def device(self) -> str: + return self.runtime.backends.torch_device + + @application_boundary + def _active_config(self) -> IndexConfig: + return self.index_backend.active_config( + self.index_directory, + device=self.device, + ) + + @application_boundary + def import_media(self, command: ImportMediaCommand) -> MediaAsset: + self.layout.ensure_local_directories() + return self.media.import_local(command) + + @application_boundary + def import_completed_upload(self, upload_id: str) -> MediaAsset: + if self._completed_upload_importer is None: + raise ApplicationError( + "remote_upload_unavailable", + ErrorCategory.unavailable, + "Remote resumable uploads are not configured.", + ) + return self._completed_upload_importer(upload_id) + + @application_boundary + def runtime_readiness(self) -> RuntimeReadiness: + components = ( + *self.control_plane_readiness(), + self._index_storage_readiness(), + ) + dependencies = self.check_dependencies( + DependencyCheckCommand( + modalities=self.registry.names(), + include_models=True, + ) + ) + return RuntimeReadiness( + ready=( + all(component.ready for component in components) + and dependencies.ok + ), + runtime=self.runtime.backends, + components=components, + dependencies=dependencies, ) - if self.device is not None: - config = replace(config, device=self.device) - return config, status + def _index_storage_readiness(self) -> ComponentReadiness: + try: + self.index_backend.status(self.index_directory) + except Exception: + return ComponentReadiness( + name="index_storage", + ready=False, + message="The committed index storage failed integrity checks.", + ) + return ComponentReadiness( + name="index_storage", + ready=True, + message="The committed index storage passed integrity checks.", + ) + + @application_boundary def create_index( self, - video_path: str | Path, + command: CreateIndexCommand, *, - modalities: Iterable[str] | None = None, - frame_stride: int = 1, - capability_options: Mapping[ - str, - Mapping[str, Any], - ] | None = None, - progress_callback: ProgressCallback | None = None, - cancellation: CancellationToken | None = None, - source_name: str | None = None, - ) -> dict[str, Any]: - selected = self._validate_modalities( - index_capability_names() if modalities is None else modalities - ) + execution: ExecutionContext | None = None, + ) -> IndexResult: + active_execution = execution_context(execution) + selected = self.registry.validate_names(command.modalities) non_indexable = [ name for name in selected - if get_capability(name).indexer is None + if self.registry.get(name).collection_name is None ] if non_indexable: - raise ValueError( - "These capabilities do not support indexing: " - + ", ".join(non_indexable) + raise CapabilityRequestError( + "One or more selected capabilities do not support indexing." ) - options: dict[str, Any] = { - "enabled_modalities": selected, - "frame_stride": frame_stride, - "storage_directory": self.index_directory, - "collection_names": collection_names(selected), - "capability_options": validate_capability_options( + media = self.media.require_record(command.media_id) + content = self.media.content(command.media_id) + self.layout.ensure_local_directories() + capability_options = { + name: dict(options) + for name, options in command.capability_options.items() + } + if command.scene_sample_fps is not None: + capability_options.setdefault("scene", {})["sample_fps"] = ( + command.scene_sample_fps + ) + config = IndexConfig.local( + video_id=command.media_id, + enabled_modalities=selected, + frame_stride=command.frame_stride, + storage_directory=self.index_directory, + collection_names=self.registry.collection_names(selected), + capability_options=self.registry.validate_options( selected, capability_options, ), - } - if self.device is not None: - options["device"] = self.device - config = IndexConfig.local(**options) - return index_video( - str(video_path), - progress_callback=progress_callback, - source_name=source_name, - config=config, - cancellation=cancellation, + device=self.device, ) + with self.runtime.scheduler.indexing(): + with self._capability_dependencies(selected): + result = self.index_backend.create( + content.path, + config=config, + progress=active_execution.report, + cancellation=active_execution.cancellation, + operation_id=active_execution.operation_id, + source_name=media.original_filename, + source_checksum=media.sha256, + ) + return IndexResult.model_validate(result) + @application_boundary def indexing_in_progress(self) -> bool: - options: dict[str, Any] = { - "storage_directory": self.index_directory, - } - if self.device is not None: - options["device"] = self.device - return indexing_in_progress(IndexConfig.local(**options)) + return self.index_backend.indexing_in_progress( + self._base_config() + ) + @application_boundary def check_dependencies( self, - modalities: Iterable[str] | None = None, - ) -> dict[str, Any]: - selected = self._validate_modalities( - capability_names() if modalities is None else modalities - ) - checks = list(dependency_checks(selected)) - return { - "ok": all(check["ok"] for check in checks), - "modalities": list(selected), - "checks": checks, - } + command: DependencyCheckCommand, + *, + on_check_start: ( + Callable[[str, DependencyKind, str], None] | None + ) = None, + on_check_complete: ( + Callable[[CapabilityDependencyCheck, float], None] | None + ) = None, + ) -> DependencyCheckResult: + selected = self.registry.validate_names(command.modalities) + checks = ( + *self.registry.dependency_checks( + selected, + include_runtime_checks=command.include_runtime_checks, + on_check_start=on_check_start, + on_check_complete=on_check_complete, + ), + *( + self.registry.model_checks( + selected, + cache=self.settings.model_cache, + on_check_start=on_check_start, + on_check_complete=on_check_complete, + ) + if command.include_models + else () + ), + *( + self._media_runtime_checks( + on_check_start=on_check_start, + on_check_complete=on_check_complete, + ) + if command.include_runtime_checks + else () + ), + ) + return DependencyCheckResult( + ok=all(check.ok for check in checks), + modalities=selected, + checks=checks, + ) + def _media_runtime_checks( + self, + *, + on_check_start: ( + Callable[[str, DependencyKind, str], None] | None + ) = None, + on_check_complete: ( + Callable[[CapabilityDependencyCheck, float], None] | None + ) = None, + ) -> tuple[CapabilityDependencyCheck, ...]: + checks = [] + for name, executable, setting in ( + ( + "ffmpeg", + self.settings.ffmpeg_executable, + "VIDXP_FFMPEG_EXECUTABLE", + ), + ( + "ffprobe", + self.settings.ffprobe_executable, + "VIDXP_FFPROBE_EXECUTABLE", + ), + ): + if on_check_start is not None: + on_check_start("media", DependencyKind.runtime, name) + started = perf_counter() + resolved = which(executable) + check = CapabilityDependencyCheck( + capability="media", + kind=DependencyKind.runtime, + name=name, + ok=resolved is not None, + error=( + None + if resolved is not None + else ( + f"{executable!r} is unavailable. Install FFmpeg " + "with the operating-system package manager or set " + f"{setting}; VidXP does not install OS packages." + ) + ), + ) + checks.append(check) + if on_check_complete is not None: + on_check_complete( + check, + perf_counter() - started, + ) + return tuple(checks) + + @application_boundary def prepare_models( self, - modalities: Iterable[str] | None = None, + command: PrepareModelsCommand, *, - capability_options: Mapping[ - str, - Mapping[str, Any], - ] | None = None, - progress_callback: ProgressCallback | None = None, - ) -> dict[str, Any]: - selected = self._validate_modalities( - preparable_capability_names() - if modalities is None - else modalities - ) - options = validate_capability_options(selected, capability_options) - checks = dependency_checks(selected) - failures = [check for check in checks if not check["ok"]] + execution: ExecutionContext | None = None, + ) -> PrepareModelsResult: + active_execution = execution_context(execution) + selected = self.registry.validate_names(command.modalities) + options = self.registry.validate_options( + selected, + command.capability_options, + ) + checks = self.registry.dependency_checks(selected) + failures = [check for check in checks if not check.ok] if failures: - details = "; ".join( - f"{check['name']}: {check['error']}" - for check in failures - ) - extras = ",".join( - get_capability(name).extra for name in selected - ) - raise RuntimeError( - f"{details}. {capability_install_hint(extras)}" + raise DependencyUnavailableError( + selected, + self.registry.install_hint(selected), ) - prepared = [] - for name in selected: - capability = get_capability(name) - prepare = capability.prepare - if prepare is not None: - prepared.extend( - prepare( - PreparationContext( - device=self.device or "cpu", - settings=capability.config_model.model_validate( - options[name] - ), - ), - progress_callback, - ) - ) - return { - "prepared": prepared, - "modalities": list(selected), - "device": self.device or "cpu", - } + prepared: list[str] = [] + with self.runtime.scheduler.inference(): + with self._capability_dependencies(selected): + for name in selected: + active_execution.checkpoint() + executor = self.registry.executor(name) + if executor.prepare is not None: + prepared.extend( + executor.prepare( + PreparationContext( + runtime=self.runtime, + settings=self.registry.get(name) + .config_model.model_validate(options[name]), + ), + active_execution.report, + ) + ) + active_execution.checkpoint() + return PrepareModelsResult( + prepared=tuple(prepared), + modalities=selected, + runtime=self.runtime.backends, + ) + @application_boundary def execute( self, capability: str, operation: str, payload: BaseModel | Mapping[str, Any], ) -> BaseModel: - """Validate and execute a registered capability operation.""" + definition, contract, handler = self._operation( + capability, + operation, + ) + request = contract.input_model.model_validate(payload) + config = None + if contract.requires_index: + config = self._active_config() + self._require_indexed_capability(capability, config) + + with self._capability_dependencies((capability,)): + if config is None: + with self.runtime.scheduler.inference(): + response = handler( + CapabilityContext( + config=None, + runtime=self.runtime, + ), + request, + ) + return contract.output_model.model_validate(response) + else: + with self.index_backend.open_store(config) as storage: + with self.runtime.scheduler.inference(): + response = handler( + CapabilityContext( + config=config, + runtime=self.runtime, + storage=storage, + ), + request, + ) + return contract.output_model.model_validate(response) - definition = get_capability(capability) + def _operation(self, capability: str, operation: str): + definition = self.registry.get(capability) try: - selected_operation = definition.operations[operation] + contract = definition.operations[operation] + handler = self.registry.executor(capability).operations[operation] except KeyError as exc: available = ", ".join(definition.operations) or "none" - raise ValueError( - f"Capability {capability!r} has no operation {operation!r}. " - f"Available operations: {available}." + raise CapabilityRequestError( + f"Capability {capability!r} has no operation {operation!r}; " + f"available operations: {available}." ) from exc - config = None - if selected_operation.requires_index: - config, _ = self.active_config() - if capability not in config.enabled_modalities: - raise ValueError( - f"The {capability} capability is not present in this index." - ) - try: - return selected_operation.invoke( - CapabilityContext(config=config), - payload, + return definition, contract, handler + + @staticmethod + def _require_indexed_capability( + capability: str, + config: IndexConfig, + ) -> None: + if capability not in config.enabled_modalities: + raise CapabilityRequestError( + f"The {capability} capability is not present in this index." ) - except ModuleNotFoundError as exc: - dependency = exc.name or "optional dependency" - raise RuntimeError( - f"{dependency} is unavailable. " - + capability_install_hint(definition.extra) - ) from exc + def _invoke_operation( + self, + capability: str, + operation: str, + payload: BaseModel | Mapping[str, Any], + *, + context: CapabilityContext, + ) -> BaseModel: + _, contract, handler = self._operation(capability, operation) + if contract.requires_index: + if context.config is None or context.storage is None: + raise RuntimeError( + "Indexed capability execution requires a pinned store." + ) + self._require_indexed_capability(capability, context.config) + request = contract.input_model.model_validate(payload) + response = handler(context, request) + return contract.output_model.model_validate(response) + + def _config_for_snapshot( + self, + reference: IndexSnapshotReference, + ) -> IndexConfig: + return self.index_backend.config_for_snapshot( + self.index_directory, + snapshot_id=reference.snapshot_id, + snapshot_sha256=reference.snapshot_sha256, + device=self.device, + ) + + @application_boundary def search( + self, + command: SearchCommand, + *, + snapshot: IndexSnapshotReference | None = None, + ) -> FusedSearchResult: + config = ( + self._config_for_snapshot(snapshot) + if snapshot is not None + else self._active_config() + ) + selected, _ = self._resolve_query_capabilities( + command.modalities, + config, + include_actor=False, + ) + with self._capability_dependencies(selected): + with self.index_backend.open_store(config) as storage: + context = CapabilityContext( + config=config, + runtime=self.runtime, + storage=storage, + ) + with self.runtime.scheduler.inference(): + results = tuple( + self._search_capability( + modality, + query=command.query, + media_id=command.media_id, + top_k=command.top_k, + context=context, + ) + for modality in selected + ) + return fuse_search_results( + query=command.query, + requested_modalities=selected, + results=results, + media_id=command.media_id, + top_k=command.top_k, + ) + + def _resolve_query_capabilities( + self, + requested: tuple[str, ...], + config: IndexConfig, + *, + include_actor: bool, + ) -> tuple[tuple[str, ...], bool]: + explicit = bool(requested) + candidates = ( + self.registry.validate_names(requested) + if explicit + else tuple(config.enabled_modalities) + ) + unavailable = tuple( + name + for name in candidates + if name not in config.enabled_modalities + ) + if unavailable: + raise CapabilityRequestError( + "Query capabilities are not present in the pinned index: " + + ", ".join(unavailable) + + "." + ) + + searchable = tuple( + name + for name in candidates + if "search" in self.registry.get(name).operations + ) + actor_overview = ( + include_actor + and "actor" in candidates + and "clusters" in self.registry.get("actor").operations + ) + supported = set(searchable) + if actor_overview: + supported.add("actor") + unsupported = tuple( + name for name in candidates if name not in supported + ) + if explicit and unsupported: + operation = "Query" if include_actor else "Search" + raise CapabilityRequestError( + f"{operation} does not support these indexed capabilities: " + + ", ".join(unsupported) + + "." + ) + if not searchable and not actor_overview: + raise CapabilityRequestError( + "The pinned index has no queryable capabilities." + ) + return searchable, actor_overview + + def _search_capability( self, modality: str, - query: str, *, - top_k: int = 10, + query: str, + media_id: str | None, + top_k: int, + context: CapabilityContext, ) -> SearchResult: return cast( SearchResult, - self.execute( + self._invoke_operation( modality, "search", - {"query": query, "top_k": top_k}, + { + "query": query, + "media_id": media_id, + "top_k": top_k, + }, + context=context, ), ) - def actor_clusters(self) -> tuple[ActorClusterSummary, ...]: - result = self.execute("actor", "clusters", {}) - return tuple(result.clusters) + @application_boundary + def query_video( + self, + command: QueryVideoCommand, + *, + snapshot: IndexSnapshotReference, + execution: ExecutionContext | None = None, + ) -> QueryAnswer: + active_execution = execution_context(execution) + config = self._config_for_snapshot(snapshot) + search_modalities, actor_overview = ( + self._resolve_query_capabilities( + command.modalities, + config, + include_actor=True, + ) + ) + if len(search_modalities) + int(actor_overview) > 8: + raise CapabilityRequestError( + "Query supports at most eight capability operations." + ) - def actor_detections( + active_execution.checkpoint() + plan, planning_fallback = self.query.plan( + command, + search_modalities=search_modalities, + actor_overview=actor_overview, + ) + active_execution.checkpoint() + results: list[SearchResult] = [] + actors: tuple[ActorClusterSummary, ...] = () + dependencies = search_modalities + ( + ("actor",) if actor_overview else () + ) + with self._capability_dependencies(dependencies): + with self.index_backend.open_store(config) as storage: + context = CapabilityContext( + config=config, + runtime=self.runtime, + storage=storage, + ) + with self.runtime.scheduler.inference(): + for step in plan.steps: + active_execution.checkpoint() + if isinstance(step, SearchMomentsPlanStep): + results.append( + self._search_capability( + step.modality, + query=step.query, + media_id=command.media_id, + top_k=command.top_k, + context=context, + ) + ) + else: + page = cast( + ActorClustersOutput, + self._invoke_operation( + "actor", + "clusters", + { + "page_size": min(command.top_k, 100), + "media_id": command.media_id, + }, + context=context, + ), + ) + actors = page.clusters + active_execution.checkpoint() + atomic = tuple(results) + fused = fuse_search_results( + query=command.question, + requested_modalities=search_modalities, + results=atomic, + media_id=command.media_id, + top_k=command.top_k, + ) + evidence = self.query.evidence( + snapshot=snapshot, + fused=fused, + actors=actors, + ) + active_execution.checkpoint() + answer = self.query.answer( + command, + plan=plan, + planning_fallback=planning_fallback, + evidence=evidence, + fused=fused, + ) + active_execution.checkpoint() + return answer + + @application_boundary + def actor_clusters( self, - cluster_id: str, - ) -> list[ActorDetection]: - result = self.execute( - "actor", - "detections", - {"cluster_id": cluster_id}, + *, + page_size: int = 50, + cursor: str | None = None, + media_id: str | None = None, + ) -> ActorClustersOutput: + return cast( + ActorClustersOutput, + self.execute( + "actor", + "clusters", + { + "page_size": page_size, + "cursor": cursor, + "media_id": media_id, + }, + ), ) - return list(result.detections) - def render_actor( + @application_boundary + def actor_cluster(self, cluster_id: str) -> ActorClusterSummary: + return cast( + ActorClusterSummary, + self.execute( + "actor", + "cluster", + {"cluster_id": cluster_id}, + ), + ) + + @application_boundary + def actor_detections( self, cluster_id: str, - input_path: str | Path, - output_path: str | Path, - ) -> ActorRenderResult: + *, + page_size: int = 50, + cursor: str | None = None, + ) -> ActorDetectionsOutput: return cast( - ActorRenderResult, + ActorDetectionsOutput, self.execute( "actor", - "render", + "detections", { "cluster_id": cluster_id, - "input_path": input_path, - "output_path": output_path, + "page_size": page_size, + "cursor": cursor, }, ), ) - def clear_index(self) -> bool: - if not self.index_directory.exists(): - return False - base_config = IndexConfig.local( - storage_directory=self.index_directory, - enabled_modalities=index_capability_names(), - collection_names=collection_names(), + @application_boundary + def render_actor( + self, + command: CreateActorOverlayCommand, + *, + snapshot: IndexSnapshotReference | None = None, + media_id: str | None = None, + generation_id: str | None = None, + execution: ExecutionContext | None = None, + ) -> Artifact: + active_execution = execution_context(execution) + if (media_id is None) != (generation_id is None): + raise ValueError( + "Pinned actor media and generation identities must be " + "provided together." + ) + active_execution.report( + { + "stage": "resolving_detections", + "message": "Resolving actor detections.", + } + ) + config = ( + self._config_for_snapshot(snapshot) + if snapshot is not None + else self._active_config() ) - if indexing_in_progress(base_config): - raise IndexingInProgressError( - f"Indexing is active for {self.index_directory}." + self._require_indexed_capability("actor", config) + detections = [] + with self._capability_dependencies(("actor",)): + with self.index_backend.open_store(config) as storage: + context = CapabilityContext( + config=config, + runtime=self.runtime, + storage=storage, + ) + with self.runtime.scheduler.inference(): + cluster = cast( + ActorClusterSummary, + self._invoke_operation( + "actor", + "cluster", + {"cluster_id": command.cluster_id}, + context=context, + ), + ) + cursor = None + while True: + active_execution.checkpoint() + page = cast( + ActorDetectionsOutput, + self._invoke_operation( + "actor", + "detections", + { + "cluster_id": command.cluster_id, + "page_size": 100, + "cursor": cursor, + }, + context=context, + ), + ) + detections.extend(page.detections) + cursor = page.next_cursor + if cursor is None: + break + identities = { + (detection.media_id, detection.generation_id) + for detection in detections + } + expected_identity = (cluster.media_id, cluster.generation_id) + if ( + identities != {expected_identity} + or ( + media_id is not None + and expected_identity != (media_id, generation_id) ) + ): + raise ApplicationError( + "actor_cluster_identity_invalid", + ErrorCategory.conflict, + "The actor cluster does not match the requested index identity.", + ) + return self.artifacts.create_actor_overlay( + media_id=cluster.media_id, + generation_id=cluster.generation_id, + cluster_id=command.cluster_id, + detections=[ + detection.model_dump(mode="python") + for detection in detections + ], + profile=command.profile, + job_id=active_execution.job_id, + execution=active_execution, + ) - status = read_index_status(self.index_directory) - if status is not None and status.get("state") == "ready": - try: - config = local_config_from_status( - status, - storage_directory=self.index_directory, - ) - except (IndexSchemaError, KeyError, TypeError, ValueError): - config = base_config - else: - config = base_config + @application_boundary + def create_snippet( + self, + command: CreateSnippetCommand, + *, + execution: ExecutionContext | None = None, + ) -> Artifact: + active_execution = execution_context(execution) + return self.artifacts.create_snippet( + command, + job_id=active_execution.job_id, + execution=active_execution, + ) + + @application_boundary + def clear_index(self) -> bool: + base_config = self._base_config() try: - with IndexStorage(config) as storage: - storage.clear() + return self.index_backend.clear(base_config) except ModuleNotFoundError as exc: - dependency = exc.name or "optional storage dependency" - raise RuntimeError( - f"{dependency} is unavailable. " - + capability_install_hint("storage") + raise DependencyUnavailableError( + self.registry.index_names(), + self.registry.install_hint(self.registry.index_names()), ) from exc - for name in ( - INDEX_STATUS_FILE, - MANIFEST_FILE, - TIMINGS_FILE, - FAILURES_FILE, - COMPLETION_FILE, - ): - (self.index_directory / name).unlink(missing_ok=True) - checkpoint_directory = self.index_directory / CHECKPOINT_DIRECTORY - if checkpoint_directory.is_dir(): - for checkpoint in checkpoint_directory.glob("*.json"): - checkpoint.unlink() - try: - checkpoint_directory.rmdir() - except OSError: - pass - return True + @application_boundary + def remove_from_index(self, command: RemoveIndexCommand) -> bool: + return self.index_backend.remove( + self._base_config(), + command.media_id, + ) - @staticmethod - def _validate_modalities( - modalities: Iterable[str], - ) -> tuple[str, ...]: - return validate_capability_names(modalities) + def _base_config(self) -> IndexConfig: + return IndexConfig.local( + storage_directory=self.index_directory, + enabled_modalities=self.registry.index_names(), + collection_names=self.registry.collection_names(), + device=self.device, + ) diff --git a/src/vidxp/application_boundary.py b/src/vidxp/application_boundary.py new file mode 100644 index 0000000..33caa4a --- /dev/null +++ b/src/vidxp/application_boundary.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +from functools import wraps +from typing import Any, Callable + +from pydantic import JsonValue, ValidationError + +from vidxp.application_models import ( + ApplicationError, + ErrorCategory, + InvalidRequestError, + ResourceNotFoundError, +) +from vidxp.artifact_service import ( + ArtifactRequestError, + ArtifactUnavailableError, + InvalidArtifactError, +) +from vidxp.capabilities.actor.results import ActorClusterNotFoundError +from vidxp.capabilities.contracts import CapabilityRequestError +from vidxp.core.artifacts import ( + ArtifactIntegrityError, + ArtifactRenderError, + ArtifactRendererUnavailableError, +) +from vidxp.core.contracts import ( + IndexCancelledError, + IndexSchemaError, +) +from vidxp.core.media import ( + InvalidMediaError, + MediaImportLimitError, + MediaProbeUnavailableError, + MediaStoreIntegrityError, + MediaUnavailableError, +) +from vidxp.core.storage import IndexStorageUnavailableError +from vidxp.index_state import ( + IndexingInProgressError, + IndexNotReadyError, +) +from vidxp.media_service import ( + MediaIdempotencyConflictError, + MediaImportNotAllowedError, +) +def _validation_details( + exc: ValidationError, +) -> list[dict[str, JsonValue]]: + return [ + { + "type": item["type"], + "location": [str(part) for part in item["loc"]], + "message": item["msg"], + } + for item in exc.errors( + include_url=False, + include_context=False, + include_input=False, + ) + ] + + +def application_boundary(handler: Callable) -> Callable: + """Translate expected application failures once for every transport.""" + + @wraps(handler) + def wrapped(*args: Any, **kwargs: Any) -> Any: + try: + return handler(*args, **kwargs) + except ApplicationError: + raise + except ActorClusterNotFoundError as exc: + raise ApplicationError( + "actor_cluster_not_found", + ErrorCategory.not_found, + "The requested actor cluster was not found.", + ) from exc + except IndexingInProgressError as exc: + raise ApplicationError( + "indexing_in_progress", + ErrorCategory.conflict, + "An indexing operation is already in progress.", + retryable=True, + ) from exc + except IndexNotReadyError as exc: + raise ApplicationError( + "index_not_ready", + ErrorCategory.conflict, + "The index is not ready.", + retryable=True, + ) from exc + except IndexSchemaError as exc: + raise ApplicationError( + "index_schema_incompatible", + ErrorCategory.conflict, + "The index schema is incompatible with this version.", + ) from exc + except IndexStorageUnavailableError as exc: + raise ApplicationError( + "index_storage_unavailable", + ErrorCategory.unavailable, + "The remote index storage is unavailable.", + retryable=True, + ) from exc + except IndexCancelledError as exc: + raise ApplicationError( + "operation_cancelled", + ErrorCategory.cancelled, + "The operation was cancelled.", + ) from exc + except ValidationError as exc: + raise InvalidRequestError( + errors=_validation_details(exc), + ) from exc + except CapabilityRequestError as exc: + raise InvalidRequestError() from exc + except InvalidMediaError as exc: + raise ApplicationError( + "media_invalid", + ErrorCategory.validation, + "The selected file is not a valid supported video.", + ) from exc + except MediaImportLimitError as exc: + raise ApplicationError( + "media_too_large", + ErrorCategory.resource_limit, + "The selected media exceeds the configured import limit.", + ) from exc + except MediaProbeUnavailableError as exc: + raise ApplicationError( + "media_probe_unavailable", + ErrorCategory.unavailable, + "The media probe dependency is unavailable. " + "For a local installation, run `vidxp init` and retry.", + details={"remediation": "vidxp init"}, + retryable=True, + ) from exc + except MediaImportNotAllowedError as exc: + raise ApplicationError( + "media_import_forbidden", + ErrorCategory.authorization, + "The selected local media cannot be imported.", + ) from exc + except MediaIdempotencyConflictError as exc: + raise ApplicationError( + "idempotency_key_reused", + ErrorCategory.validation, + "The idempotency key was already used for other media.", + ) from exc + except MediaStoreIntegrityError as exc: + raise ApplicationError( + "media_integrity_failed", + ErrorCategory.conflict, + "The media changed or failed an integrity check.", + ) from exc + except MediaUnavailableError as exc: + raise ResourceNotFoundError("media") from exc + except ArtifactUnavailableError as exc: + raise ResourceNotFoundError("artifact") from exc + except ArtifactIntegrityError as exc: + raise ApplicationError( + "artifact_integrity_failed", + ErrorCategory.conflict, + "The artifact failed an integrity check.", + ) from exc + except ArtifactRendererUnavailableError as exc: + raise ApplicationError( + "artifact_renderer_unavailable", + ErrorCategory.unavailable, + "The configured artifact renderer is unavailable. " + "For a local installation, run `vidxp init` and retry.", + details={"remediation": "vidxp init"}, + retryable=True, + ) from exc + except ArtifactRenderError as exc: + raise ApplicationError( + "artifact_render_failed", + ErrorCategory.internal, + "The requested artifact could not be rendered.", + ) from exc + except ArtifactRequestError as exc: + raise InvalidRequestError() from exc + except InvalidArtifactError as exc: + raise ApplicationError( + "artifact_render_invalid", + ErrorCategory.internal, + "The generated artifact failed media validation.", + ) from exc + + return wrapped diff --git a/src/vidxp/application_models.py b/src/vidxp/application_models.py new file mode 100644 index 0000000..9975e3a --- /dev/null +++ b/src/vidxp/application_models.py @@ -0,0 +1,1081 @@ +from __future__ import annotations + +from enum import StrEnum +from pathlib import Path +from typing import Annotated, Any, Generic, Literal, Mapping, TypeAlias, TypeVar + +from pydantic import ( + AwareDatetime, + BaseModel, + ConfigDict, + Field, + JsonValue, + NonNegativeInt, + StringConstraints, + field_validator, + model_validator, +) + +from vidxp.core.identifiers import ( + ActorClusterId as ActorClusterId, + ArtifactId as ArtifactId, + Identifier as Identifier, + IndexGenerationId as IndexGenerationId, + IndexSnapshotId as IndexSnapshotId, + JobId as JobId, + MediaId as MediaId, + MimeType, + Sha256, + UploadIntentId as UploadIntentId, + VideoId as VideoId, +) +from vidxp.core.artifacts import ( + ARTIFACT_SCHEMA_VERSION, + ArtifactKind, + ArtifactState, +) +from vidxp.core.contracts import INDEX_SCHEMA_VERSION +from vidxp.core.media import ( + MEDIA_SCHEMA_VERSION, + MediaState, + MediaStream, + validate_display_filename, +) +from vidxp.core.uploads import UploadState +from vidxp.index_state import INDEX_STATUS_MEDIA_ID_LIMIT + +T = TypeVar("T") +SearchQuery: TypeAlias = Annotated[ + str, + StringConstraints(strip_whitespace=True, min_length=1, max_length=4096), +] + + +class ApplicationModel(BaseModel): + model_config = ConfigDict( + extra="forbid", + frozen=True, + allow_inf_nan=False, + ) + + +class Page(ApplicationModel, Generic[T]): + items: tuple[T, ...] = () + total: int = Field(ge=0) + next_cursor: str | None = None + + +class RuntimeProfile(ApplicationModel): + requested: str + torch_device: str + transcription_device: str + actor_device: str = "cpu" + mps_available: bool = False + cuda_available: bool = False + + +class Principal(ApplicationModel): + subject: str = Field(min_length=1, max_length=255) + client_id: str | None = Field(default=None, min_length=1, max_length=255) + scopes: frozenset[str] = Field(default_factory=frozenset) + + +class ErrorCategory(StrEnum): + validation = "validation" + authentication = "authentication" + authorization = "authorization" + not_found = "not_found" + conflict = "conflict" + unavailable = "unavailable" + resource_limit = "resource_limit" + cancelled = "cancelled" + internal = "internal" + + +class JobKind(StrEnum): + media_import = "media_import" + index = "index" + search = "search" + query = "query" + snippet = "snippet" + actor_overlay = "actor_overlay" + prepare_models = "prepare_models" + + +class JobState(StrEnum): + queued = "queued" + running = "running" + succeeded = "succeeded" + failed = "failed" + cancelled = "cancelled" + recovery_exhausted = "recovery_exhausted" + + +class JobQueue(StrEnum): + cpu = "cpu" + gpu = "gpu" + + +class ErrorDetail(ApplicationModel): + code: str = Field(min_length=1, pattern=r"^[a-z][a-z0-9_]*$") + category: ErrorCategory + message: str = Field(min_length=1) + details: dict[str, JsonValue] = Field(default_factory=dict) + retryable: bool = False + correlation_id: str | None = None + + +class ApplicationError(RuntimeError): + def __init__( + self, + code: str, + category: ErrorCategory, + message: str, + *, + details: Mapping[str, Any] | None = None, + retryable: bool = False, + correlation_id: str | None = None, + ) -> None: + self.detail = ErrorDetail( + code=code, + category=category, + message=message, + details=details or {}, + retryable=retryable, + correlation_id=correlation_id, + ) + super().__init__(message) + + @property + def code(self) -> str: + return self.detail.code + + @property + def category(self) -> ErrorCategory: + return self.detail.category + + @property + def retryable(self) -> bool: + return self.detail.retryable + + def to_dict(self) -> dict[str, Any]: + return self.detail.model_dump(mode="json") + + +class DependencyUnavailableError(ApplicationError): + def __init__( + self, + capabilities: tuple[str, ...], + install_hint: str, + ) -> None: + label = capabilities[0] if len(capabilities) == 1 else "selected" + super().__init__( + "dependency_unavailable", + ErrorCategory.unavailable, + f"Dependencies for the {label} capability are unavailable. " + f"{install_hint}", + details={ + "capabilities": list(capabilities), + "install_hint": install_hint, + }, + ) + + +class ModelUnavailableError(ApplicationError): + def __init__(self, capability: str) -> None: + modality = capability.split(".", 1)[0] + super().__init__( + "model_unavailable", + ErrorCategory.unavailable, + f"Model artifacts for the {capability} capability are not " + "available locally. Run " + f"`vidxp prepare --modalities {modality}` before retrying.", + details={ + "capability": capability, + "remediation": ( + f"vidxp prepare --modalities {modality}" + ), + }, + ) + + +class InvalidRequestError(ApplicationError): + def __init__( + self, + *, + errors: list[dict[str, JsonValue]] | None = None, + ) -> None: + super().__init__( + "invalid_request", + ErrorCategory.validation, + "The request is invalid.", + details={"errors": errors or []}, + ) + + +class ResourceNotFoundError(ApplicationError): + def __init__(self, resource: str) -> None: + super().__init__( + "resource_not_found", + ErrorCategory.not_found, + f"The requested {resource} was not found.", + details={"resource": resource}, + ) + + +class CapabilityProvenance(ApplicationModel): + distribution: str = Field(min_length=1) + entry_point: str = Field(min_length=1) + version: str | None = None + + +class DependencyKind(StrEnum): + distribution = "distribution" + runtime = "runtime" + model = "model" + + +class CapabilityDependencyCheck(ApplicationModel): + capability: str = Field(min_length=1) + provenance: CapabilityProvenance | None = None + kind: DependencyKind + name: str = Field(min_length=1) + requirement: str | None = None + installed_version: str | None = None + download_size_bytes: int | None = Field(default=None, ge=1) + ok: bool + error: str | None = None + + +class CapabilityOperationInfo(ApplicationModel): + name: str = Field(min_length=1) + requires_index: bool + input_schema: dict[str, JsonValue] + output_schema: dict[str, JsonValue] + + +class CapabilitySummary(ApplicationModel): + name: str = Field(min_length=1) + description: str = Field(min_length=1) + install_extra: str = Field(min_length=1) + supports_indexing: bool + prepares_models: bool + provenance: CapabilityProvenance | None = None + + +class CapabilityInfo(CapabilitySummary): + operations: tuple[CapabilityOperationInfo, ...] = () + + +class CapabilityList(ApplicationModel): + items: tuple[CapabilitySummary, ...] = () + + +class ComponentReadiness(ApplicationModel): + name: str = Field(min_length=1) + ready: bool + message: str = Field(min_length=1) + + +class RuntimeReadiness(ApplicationModel): + ready: bool + runtime: RuntimeProfile | None + components: tuple[ComponentReadiness, ...] + dependencies: DependencyCheckResult | None + + +class ImportMediaCommand(ApplicationModel): + """Local-adapter command; remote ingestion uses the upload workflow.""" + + path: Path + original_filename: str | None = Field(default=None, min_length=1) + declared_mime_type: MimeType | None = None + + @field_validator("original_filename") + @classmethod + def _filename_only(cls, value: str | None) -> str | None: + return None if value is None else validate_display_filename(value) + + +class MediaAsset(ApplicationModel): + schema_version: Literal[MEDIA_SCHEMA_VERSION] = MEDIA_SCHEMA_VERSION + media_id: MediaId = Field( + description=( + "Stable registered-media identifier used by indexing and optional " + "single-media search/query filters." + ) + ) + video_id: VideoId + original_filename: str = Field(min_length=1) + sha256: Sha256 + byte_size: int = Field(gt=0) + declared_mime_type: MimeType | None = None + detected_mime_type: MimeType + container: str = Field(min_length=1) + duration_seconds: float = Field(gt=0) + streams: tuple[MediaStream, ...] = Field(min_length=1) + state: MediaState + created_at: AwareDatetime + + +class ListMediaCommand(ApplicationModel): + page_size: int = Field( + default=50, + gt=0, + le=100, + description="Maximum registered media records to return.", + ) + cursor: str | None = Field( + default=None, + min_length=1, + max_length=512, + description="Opaque next_cursor from the previous list_media page.", + ) + + +class MediaPage(Page[MediaAsset]): + pass + + +class CreateUploadIntentCommand(ApplicationModel): + original_filename: str = Field(min_length=1, max_length=255) + byte_size: int = Field(gt=0) + declared_mime_type: MimeType | None = None + + @field_validator("original_filename") + @classmethod + def _filename_only(cls, value: str) -> str: + return validate_display_filename(value) + + +class UploadIntent(ApplicationModel): + intent_id: UploadIntentId + original_filename: str = Field(min_length=1, max_length=255) + byte_size: int = Field(gt=0) + declared_mime_type: MimeType | None = None + state: UploadState + created_at: AwareDatetime + expires_at: AwareDatetime + job_id: JobId | None = None + media_id: MediaId | None = None + + +class CreateIndexCommand(ApplicationModel): + media_id: MediaId = Field( + description=( + "Stable identifier returned by list_media, get_media, or a " + "completed upload." + ) + ) + modalities: tuple[str, ...] + frame_stride: int = Field( + default=1, + gt=0, + description=( + "Materialize every Nth frame for actor and legacy visual indexing." + ), + ) + scene_sample_fps: float | None = Field( + default=None, + gt=0, + description=( + "Target scene samples per second. Sources below this rate use " + "every available frame without duplication." + ), + ) + capability_options: Mapping[str, Mapping[str, JsonValue]] = Field( + default_factory=dict + ) + + @model_validator(mode="before") + @classmethod + def _canonicalize_scene_sampling(cls, value: Any) -> Any: + if not isinstance(value, Mapping): + return value + payload = dict(value) + raw_options = payload.get("capability_options") + if not isinstance(raw_options, Mapping): + return payload + raw_scene = raw_options.get("scene") + if not isinstance(raw_scene, Mapping) or "sample_fps" not in raw_scene: + return payload + + nested_value = raw_scene["sample_fps"] + explicit_value = payload.get("scene_sample_fps") + if explicit_value is not None: + try: + conflicts = float(explicit_value) != float(nested_value) + except (TypeError, ValueError): + conflicts = True + if conflicts: + raise ValueError( + "scene_sample_fps conflicts with " + "capability_options.scene.sample_fps." + ) + else: + payload["scene_sample_fps"] = nested_value + + options = dict(raw_options) + scene = dict(raw_scene) + scene.pop("sample_fps") + if scene: + options["scene"] = scene + else: + options.pop("scene", None) + payload["capability_options"] = options + return payload + + @model_validator(mode="after") + def _scene_sampling_requires_scene(self) -> "CreateIndexCommand": + if ( + self.scene_sample_fps is not None + and "scene" not in self.modalities + ): + raise ValueError( + "scene_sample_fps requires the scene modality." + ) + return self + + +class IndexResult(ApplicationModel): + media_id: MediaId + generation_id: IndexGenerationId + snapshot_id: IndexSnapshotId + active_media_count: int = Field(gt=0) + record_counts: dict[str, NonNegativeInt] = Field(default_factory=dict) + + +class RemoveIndexCommand(ApplicationModel): + media_id: MediaId + + +class Artifact(ApplicationModel): + schema_version: Literal[ARTIFACT_SCHEMA_VERSION] = ( + ARTIFACT_SCHEMA_VERSION + ) + artifact_id: ArtifactId + media_id: MediaId + generation_id: IndexGenerationId | None = None + job_id: JobId | None = None + kind: ArtifactKind + profile: str = Field(min_length=1) + mime_type: MimeType + byte_size: int = Field(gt=0) + sha256: Sha256 + state: ArtifactState + created_at: AwareDatetime + expires_at: AwareDatetime | None = None + + +class ActorOverlayProfile(StrEnum): + default = "default" + + +class CreateActorOverlayCommand(ApplicationModel): + cluster_id: ActorClusterId + profile: ActorOverlayProfile = ActorOverlayProfile.default + + +class SnippetProfile(StrEnum): + source = "source" + compatible_mp4 = "compatible_mp4" + + +class CreateSnippetCommand(ApplicationModel): + media_id: MediaId = Field( + description=( + "Source video ID, normally copied from a search or query result." + ) + ) + start_seconds: float = Field( + ge=0, + description="Inclusive clip start from the selected result, in seconds.", + ) + end_seconds: float = Field( + gt=0, + description="Exclusive clip end from the selected result, in seconds.", + ) + profile: SnippetProfile = Field( + default=SnippetProfile.compatible_mp4, + description=( + "Use compatible_mp4 for a broadly playable download; source " + "preserves source codecs in a Matroska container." + ), + ) + + def model_post_init(self, _context: Any) -> None: + if self.end_seconds <= self.start_seconds: + raise ValueError("end_seconds must be greater than start_seconds") + + +class IndexStatus(ApplicationModel): + schema_version: int = Field(ge=1) + state: str = Field(min_length=1) + stage: str = Field(min_length=1) + message: str = Field(min_length=1) + updated_at: AwareDatetime | None = None + summary: "IndexStatusSummary | None" = None + + +class IndexStatusSummary(ApplicationModel): + index_schema_version: int = Field(ge=1) + snapshot_id: IndexSnapshotId + media_count: int = Field( + ge=0, + description="Number of media items in the active index snapshot.", + ) + media_ids: tuple[MediaId, ...] = Field( + default=(), + max_length=INDEX_STATUS_MEDIA_ID_LIMIT, + description=( + "Stable IDs included in the active snapshot. The list is capped; " + "check media_ids_truncated before treating it as complete." + ), + ) + media_ids_truncated: bool = Field( + default=False, + description="Whether active snapshot media IDs were omitted by the cap.", + ) + modalities: tuple[str, ...] = () + + @model_validator(mode="after") + def _validate_media_id_window(self) -> "IndexStatusSummary": + if self.media_count < len(self.media_ids): + raise ValueError("media_count must cover every returned media ID") + if self.media_ids_truncated != ( + self.media_count > len(self.media_ids) + ): + raise ValueError( + "media_ids_truncated must reflect the returned media-ID window" + ) + return self + + +class FusionProfile(StrEnum): + reciprocal_rank = "rrf_v1" + + +class SearchCommand(ApplicationModel): + query: SearchQuery = Field(description="Text to match against indexed moments.") + modalities: tuple[Identifier, ...] = Field( + default=(), + description=( + "Indexed capabilities to search. Omit to use every searchable " + "capability in the active snapshot." + ), + ) + media_id: MediaId | None = Field( + default=None, + description=( + "Optional single-media filter. Provide a stable ID from list_media " + "to search only that video; omit it to rank results across every " + "media item in the active index snapshot." + ), + ) + top_k: int = Field( + default=10, + gt=0, + le=100, + description="Maximum fused moments to return across the selected scope.", + ) + + @field_validator("modalities") + @classmethod + def _unique_modalities( + cls, + values: tuple[Identifier, ...], + ) -> tuple[Identifier, ...]: + if len(values) != len(set(values)): + raise ValueError("Search modalities must be unique.") + return values + + +class SearchHit(ApplicationModel): + rank: int = Field(gt=0) + media_id: MediaId + video_id: VideoId + generation_id: IndexGenerationId + start: float = Field(ge=0) + end: float = Field(gt=0) + score: float + raw_distance: float + modality: str = Field(min_length=1) + source_id: str = Field(min_length=1) + metadata: dict[str, JsonValue] = Field(default_factory=dict) + + @field_validator("metadata") + @classmethod + def _reject_internal_metadata( + cls, + value: dict[str, JsonValue], + ) -> dict[str, JsonValue]: + forbidden: set[str] = set() + + def inspect(item: JsonValue) -> None: + if isinstance(item, dict): + for key, nested in item.items(): + if ( + key == "path" + or key == "storage_key" + or key.endswith("_path") + or key.endswith("_directory") + ): + forbidden.add(key) + inspect(nested) + elif isinstance(item, list): + for nested in item: + inspect(nested) + + inspect(value) + if forbidden: + raise ValueError( + "Search metadata contains internal location fields." + ) + return value + + @model_validator(mode="after") + def _validate_interval(self) -> "SearchHit": + if self.end <= self.start: + raise ValueError("end must be greater than start") + return self + + def to_dict(self) -> dict[str, Any]: + return self.model_dump(mode="json") + + +class SearchResult(ApplicationModel): + schema_version: int = INDEX_SCHEMA_VERSION + query_id: str = Field(min_length=1) + query: str = Field(min_length=1) + modality: str = Field(min_length=1) + hits: tuple[SearchHit, ...] = () + + def to_dict(self) -> dict[str, Any]: + return self.model_dump(mode="json") + + def to_prediction(self) -> dict[str, list[dict[str, Any]]]: + return { + self.query_id: [ + hit.model_dump(mode="json") for hit in self.hits + ] + } + + +class FusionProvenance(ApplicationModel): + profile: Literal[FusionProfile.reciprocal_rank] = ( + FusionProfile.reciprocal_rank + ) + rank_constant: int = Field(default=60, gt=0) + overlap_rule: Literal["connected_intervals"] = "connected_intervals" + requested_modalities: tuple[Identifier, ...] = () + searched_modalities: tuple[Identifier, ...] = () + + +class FusedMoment(ApplicationModel): + rank: int = Field(gt=0) + score: float = Field(gt=0) + media_id: MediaId + start: float = Field(ge=0) + end: float = Field(gt=0) + modalities: tuple[Identifier, ...] + hits: tuple[SearchHit, ...] = Field(min_length=1) + + @model_validator(mode="after") + def _validate_fused_moment(self) -> "FusedMoment": + if self.end <= self.start: + raise ValueError("end must be greater than start") + if any(hit.media_id != self.media_id for hit in self.hits): + raise ValueError("Fused moment hits must belong to one media item.") + if set(self.modalities) != {hit.modality for hit in self.hits}: + raise ValueError("Fused moment modalities must match its hits.") + return self + + +class FusedSearchResult(ApplicationModel): + schema_version: int = INDEX_SCHEMA_VERSION + query_id: str = Field(min_length=1) + query: SearchQuery + modalities: tuple[Identifier, ...] + moments: tuple[FusedMoment, ...] = () + fusion: FusionProvenance + + +class QueryVideoCommand(ApplicationModel): + question: SearchQuery = Field( + description="Natural-language question grounded in indexed evidence." + ) + media_id: MediaId | None = Field( + default=None, + description=( + "Optional single-media filter. Provide a stable ID from list_media " + "to use evidence only from that video; omit it to use evidence " + "from every media item in the active index snapshot." + ), + ) + modalities: tuple[Identifier, ...] = Field( + default=(), + max_length=8, + description=( + "Indexed capabilities to use as evidence. Omit to use every " + "queryable capability in the active snapshot." + ), + ) + top_k: int = Field( + default=10, + gt=0, + le=50, + description="Maximum ranked evidence moments used for the answer.", + ) + + @field_validator("modalities") + @classmethod + def _unique_modalities( + cls, + values: tuple[Identifier, ...], + ) -> tuple[Identifier, ...]: + if len(values) != len(set(values)): + raise ValueError("Query modalities must be unique.") + return values + + +class SearchMomentsPlanStep(ApplicationModel): + kind: Literal["search_moments"] = "search_moments" + modality: Identifier + query: SearchQuery + + +class ActorOverviewPlanStep(ApplicationModel): + kind: Literal["actor_overview"] = "actor_overview" + + +QueryPlanStep = Annotated[ + SearchMomentsPlanStep | ActorOverviewPlanStep, + Field(discriminator="kind"), +] + + +class QueryPlan(ApplicationModel): + steps: tuple[QueryPlanStep, ...] = Field(min_length=1, max_length=8) + + +class QueryPlanningRequest(ApplicationModel): + question: SearchQuery + allowed_modalities: tuple[Identifier, ...] + actor_overview_allowed: bool = False + + +class QueryModelIdentity(ApplicationModel): + provider: Literal["ollama"] + model: str = Field(min_length=1, max_length=255) + + +class MomentEvidence(ApplicationModel): + kind: Literal["moment"] = "moment" + evidence_id: Sha256 + snapshot_id: IndexSnapshotId + media_id: MediaId + generation_id: IndexGenerationId + modality: Identifier + source_id: str = Field(min_length=1, max_length=512) + start: float = Field(ge=0) + end: float = Field(gt=0) + display_text: str | None = Field(default=None, max_length=4096) + hit: SearchHit + + @model_validator(mode="after") + def _validate_hit_identity(self) -> "MomentEvidence": + if ( + self.media_id != self.hit.media_id + or self.generation_id != self.hit.generation_id + or self.modality != self.hit.modality + or self.source_id != self.hit.source_id + or self.start != self.hit.start + or self.end != self.hit.end + ): + raise ValueError("Evidence identity must match its search hit.") + return self + + +class ActorEvidence(ApplicationModel): + kind: Literal["actor"] = "actor" + evidence_id: Sha256 + snapshot_id: IndexSnapshotId + media_id: MediaId + generation_id: IndexGenerationId + modality: Literal["actor"] = "actor" + cluster_id: ActorClusterId + start: float = Field(ge=0) + end: float = Field(ge=0) + detection_count: int = Field(ge=0) + display_text: str = Field(min_length=1, max_length=4096) + + +Evidence = Annotated[ + MomentEvidence | ActorEvidence, + Field(discriminator="kind"), +] + + +class DraftClaim(ApplicationModel): + text: str = Field(min_length=1, max_length=4096) + evidence_ids: tuple[Sha256, ...] = Field(min_length=1, max_length=10) + + @field_validator("evidence_ids") + @classmethod + def _unique_evidence_ids( + cls, + values: tuple[Sha256, ...], + ) -> tuple[Sha256, ...]: + if len(values) != len(set(values)): + raise ValueError("Draft evidence IDs must be unique.") + return values + + +class DraftAnswer(ApplicationModel): + claims: tuple[DraftClaim, ...] = Field(min_length=1, max_length=20) + + +class QuerySynthesisRequest(ApplicationModel): + question: SearchQuery + evidence: tuple[Evidence, ...] = Field(min_length=1, max_length=200) + + +class GroundedClaim(ApplicationModel): + text: str = Field(min_length=1, max_length=4096) + evidence_ids: tuple[Sha256, ...] = Field(min_length=1, max_length=10) + + @field_validator("evidence_ids") + @classmethod + def _unique_evidence_ids( + cls, + values: tuple[Sha256, ...], + ) -> tuple[Sha256, ...]: + if len(values) != len(set(values)): + raise ValueError("Claim evidence IDs must be unique.") + return values + + +class QueryAnswerMode(StrEnum): + generated = "generated" + evidence_only = "evidence_only" + no_evidence = "no_evidence" + + +class QueryAnswer(ApplicationModel): + schema_version: int = INDEX_SCHEMA_VERSION + question: SearchQuery + mode: QueryAnswerMode + plan: QueryPlan + model: QueryModelIdentity | None = None + claims: tuple[GroundedClaim, ...] = () + evidence: tuple[Evidence, ...] = Field(default=(), max_length=200) + moments: tuple[FusedMoment, ...] = () + fusion: FusionProvenance + fallback_reason: str | None = Field(default=None, max_length=512) + + @model_validator(mode="after") + def _validate_answer_grounding(self) -> "QueryAnswer": + evidence_ids = {item.evidence_id for item in self.evidence} + cited_ids = { + evidence_id + for claim in self.claims + for evidence_id in claim.evidence_ids + } + if not cited_ids.issubset(evidence_ids): + raise ValueError("Every claim citation must resolve to evidence.") + if self.mode == QueryAnswerMode.generated and not self.claims: + raise ValueError("Generated answers require grounded claims.") + if self.mode != QueryAnswerMode.generated and self.claims: + raise ValueError("Fallback answers must not contain generated claims.") + if self.mode == QueryAnswerMode.no_evidence and self.evidence: + raise ValueError("No-evidence answers cannot contain evidence.") + return self + + +class PrepareModelsCommand(ApplicationModel): + modalities: tuple[str, ...] + capability_options: Mapping[str, Mapping[str, JsonValue]] = Field( + default_factory=dict + ) + + +class DependencyCheckCommand(ApplicationModel): + modalities: tuple[str, ...] + include_runtime_checks: bool = True + include_models: bool = False + + +class DependencyCheckResult(ApplicationModel): + ok: bool + modalities: tuple[str, ...] + checks: tuple[CapabilityDependencyCheck, ...] + + +class PrepareModelsResult(ApplicationModel): + prepared: tuple[str, ...] + modalities: tuple[str, ...] + runtime: RuntimeProfile + + +JOB_SCHEMA_VERSION = 2 +JOB_PROGRESS_SCHEMA_VERSION = 1 + + +class IndexSnapshotReference(ApplicationModel): + snapshot_id: IndexSnapshotId + snapshot_sha256: Sha256 + + +class IndexJobRequest(ApplicationModel): + kind: Literal[JobKind.index] = JobKind.index + command: CreateIndexCommand + + +class MediaImportJobRequest(ApplicationModel): + kind: Literal[JobKind.media_import] = JobKind.media_import + upload_id: Identifier + + +class SearchJobRequest(ApplicationModel): + kind: Literal[JobKind.search] = JobKind.search + command: SearchCommand + snapshot: IndexSnapshotReference + + +class QueryJobRequest(ApplicationModel): + kind: Literal[JobKind.query] = JobKind.query + command: QueryVideoCommand + snapshot: IndexSnapshotReference + + +class SnippetJobRequest(ApplicationModel): + kind: Literal[JobKind.snippet] = JobKind.snippet + command: CreateSnippetCommand + + +class ActorOverlayJobRequest(ApplicationModel): + kind: Literal[JobKind.actor_overlay] = JobKind.actor_overlay + command: CreateActorOverlayCommand + snapshot: IndexSnapshotReference + + +class PrepareModelsJobRequest(ApplicationModel): + kind: Literal[JobKind.prepare_models] = JobKind.prepare_models + command: PrepareModelsCommand + + +JobRequest = Annotated[ + MediaImportJobRequest + | IndexJobRequest + | SearchJobRequest + | QueryJobRequest + | SnippetJobRequest + | ActorOverlayJobRequest + | PrepareModelsJobRequest, + Field(discriminator="kind"), +] + + +class IndexJobResult(ApplicationModel): + kind: Literal[JobKind.index] = JobKind.index + result: IndexResult + + +class MediaImportJobResult(ApplicationModel): + kind: Literal[JobKind.media_import] = JobKind.media_import + result: MediaAsset + + +class SearchJobResult(ApplicationModel): + kind: Literal[JobKind.search] = JobKind.search + result: FusedSearchResult + + +class QueryJobResult(ApplicationModel): + kind: Literal[JobKind.query] = JobKind.query + result: QueryAnswer + + +class ArtifactJobResult(ApplicationModel): + kind: Literal[JobKind.snippet, JobKind.actor_overlay] + result: Artifact + + +class PrepareModelsJobResult(ApplicationModel): + kind: Literal[JobKind.prepare_models] = JobKind.prepare_models + result: PrepareModelsResult + + +JobResult = Annotated[ + MediaImportJobResult + | IndexJobResult + | SearchJobResult + | QueryJobResult + | ArtifactJobResult + | PrepareModelsJobResult, + Field(discriminator="kind"), +] + + +class JobProgress(ApplicationModel): + schema_version: Literal[JOB_PROGRESS_SCHEMA_VERSION] = ( + JOB_PROGRESS_SCHEMA_VERSION + ) + stage: str = Field(min_length=1, max_length=128) + message: str = Field(min_length=1, max_length=512) + current: int | None = Field(default=None, ge=0) + total: int | None = Field(default=None, gt=0) + updated_at: AwareDatetime + + @model_validator(mode="after") + def _validate_position(self) -> "JobProgress": + if ( + self.current is not None + and self.total is not None + and self.current > self.total + ): + raise ValueError("current must not exceed total") + return self + + +class Job(ApplicationModel): + schema_version: Literal[JOB_SCHEMA_VERSION] = JOB_SCHEMA_VERSION + job_id: JobId + kind: JobKind + state: JobState + queue: JobQueue + progress: JobProgress | None = None + result: JobResult | None = None + error: ErrorDetail | None = None + recovery_attempts: int = Field(default=0, ge=0) + created_at: AwareDatetime | None = None + updated_at: AwareDatetime | None = None + + @model_validator(mode="after") + def _validate_terminal_payload(self) -> "Job": + if self.state == JobState.succeeded: + if self.result is None or self.error is not None: + raise ValueError( + "succeeded jobs require a result and no error" + ) + if self.result.kind != self.kind: + raise ValueError("job result kind must match job kind") + elif self.result is not None: + raise ValueError("only succeeded jobs may contain a result") + if self.state in {JobState.queued, JobState.running}: + if self.error is not None: + raise ValueError("active jobs may not contain an error") + elif self.state in {JobState.failed, JobState.recovery_exhausted}: + if self.error is None: + raise ValueError("failed jobs require a typed error") + return self + + +class ListJobsCommand(ApplicationModel): + page_size: int = Field(default=50, gt=0, le=100) + cursor: str | None = Field(default=None, min_length=1, max_length=512) + + +class JobPage(ApplicationModel): + items: tuple[Job, ...] = () + next_cursor: str | None = None diff --git a/src/vidxp/artifact_service.py b/src/vidxp/artifact_service.py new file mode 100644 index 0000000..b326a81 --- /dev/null +++ b/src/vidxp/artifact_service.py @@ -0,0 +1,378 @@ +from __future__ import annotations + +import hashlib +import json +from collections.abc import Callable +from pathlib import Path +from uuid import uuid4 + +from vidxp.application_models import ( + ActorOverlayProfile, + Artifact, + CreateSnippetCommand, + SnippetProfile, +) +from vidxp.core.artifacts import ( + ArtifactIntegrityError, + ArtifactKind, + ArtifactRecord, + ArtifactState, +) +from vidxp.core.media import InvalidMediaError, utc_now +from vidxp.execution import ExecutionContext, execution_context +from vidxp.media_service import MediaService +from vidxp.ports import ( + ActorRendererPort, + ArtifactCatalogPort, + ArtifactStorePort, + LocalFileResource, + MediaProbePort, + SnippetRendererPort, +) + + +class ArtifactUnavailableError(FileNotFoundError): + """Raised when an artifact is missing, expired, or fails integrity checks.""" + + +class ArtifactRequestError(ValueError): + """Raised when an artifact request is invalid for its source media.""" + + +class InvalidArtifactError(RuntimeError): + """Raised when rendered output is not valid media.""" + + +ARTIFACT_RENDER_CONTRACT_VERSION = 1 + + +def artifact_result(record: ArtifactRecord) -> Artifact: + return Artifact( + schema_version=record.schema_version, + artifact_id=record.artifact_id, + media_id=record.media_id, + generation_id=record.generation_id, + job_id=record.job_id, + kind=record.kind, + profile=record.profile, + mime_type=record.mime_type, + byte_size=record.byte_size, + sha256=record.sha256, + state=record.state, + created_at=record.created_at, + expires_at=record.expires_at, + ) + + +def _request_key(payload: dict) -> str: + encoded = json.dumps( + payload, + sort_keys=True, + separators=(",", ":"), + ).encode() + return hashlib.sha256(encoded).hexdigest() + + +class ArtifactQueryService: + """Read-only artifact boundary shared by worker and control-plane profiles.""" + + def __init__( + self, + *, + catalog: ArtifactCatalogPort, + store: ArtifactStorePort, + ) -> None: + self.catalog = catalog + self.store = store + + def get(self, artifact_id: str) -> Artifact: + return artifact_result(self.require_record(artifact_id)) + + def require_record(self, artifact_id: str) -> ArtifactRecord: + record = self.catalog.get_artifact(artifact_id) + if record is None: + raise ArtifactUnavailableError("The artifact is unavailable.") + self._require_ready(record) + return record + + @staticmethod + def _require_ready(record: ArtifactRecord) -> None: + if ( + record.state != ArtifactState.ready + or ( + record.expires_at is not None + and record.expires_at <= utc_now() + ) + ): + raise ArtifactUnavailableError("The artifact is unavailable.") + + def content(self, artifact_id: str) -> LocalFileResource: + record = self.require_record(artifact_id) + path = self._verified_content(record) + suffix = ".mp4" if record.mime_type == "video/mp4" else ".mkv" + return LocalFileResource( + path=path, + filename=f"{record.kind.value}-{record.artifact_id}{suffix}", + mime_type=record.mime_type, + byte_size=record.byte_size, + etag=record.sha256, + ) + + def _verified_content(self, record: ArtifactRecord) -> Path: + try: + return self.store.verify( + record.storage_key, + sha256=record.sha256, + byte_size=record.byte_size, + ) + except (FileNotFoundError, PermissionError) as exc: + raise ArtifactUnavailableError( + "The artifact content is unavailable." + ) from exc + except RuntimeError as exc: + raise ArtifactIntegrityError( + "The artifact failed its integrity check." + ) from exc + + +class ArtifactService(ArtifactQueryService): + def __init__( + self, + *, + catalog: ArtifactCatalogPort, + store: ArtifactStorePort, + media: MediaService, + probe: MediaProbePort, + actor_renderer: ActorRendererPort, + snippet_renderer: SnippetRendererPort, + max_snippet_duration_seconds: float, + ) -> None: + super().__init__(catalog=catalog, store=store) + self.media = media + self.probe = probe + self.actor_renderer = actor_renderer + self.snippet_renderer = snippet_renderer + self.max_snippet_duration_seconds = max_snippet_duration_seconds + + def create_actor_overlay( + self, + *, + media_id: str, + generation_id: str, + cluster_id: str, + detections: list[dict], + profile: ActorOverlayProfile, + job_id: str | None = None, + execution: ExecutionContext | None = None, + ) -> Artifact: + active_execution = execution_context(execution) + active_execution.checkpoint() + source = self.media.content(media_id) + request_key = _request_key( + { + "kind": ArtifactKind.actor_overlay, + "render_contract_version": ARTIFACT_RENDER_CONTRACT_VERSION, + "media_sha256": source.etag, + "generation_id": generation_id, + "cluster_id": cluster_id, + "profile": profile, + } + ) + return self._create( + media_id=media_id, + generation_id=generation_id, + kind=ArtifactKind.actor_overlay, + profile=profile.value, + request_key=request_key, + suffix=".mp4", + expected_mime_type="video/mp4", + job_id=job_id, + execution=active_execution, + render=lambda destination: self.actor_renderer.render( + source.path, + destination, + cluster_id, + detections, + cancellation=active_execution.cancellation, + progress=active_execution.progress, + ), + ) + + def create_snippet( + self, + command: CreateSnippetCommand, + *, + job_id: str | None = None, + execution: ExecutionContext | None = None, + ) -> Artifact: + active_execution = execution_context(execution) + active_execution.checkpoint() + media_record = self.media.require_record(command.media_id) + duration = command.end_seconds - command.start_seconds + if command.end_seconds > media_record.duration_seconds: + raise ArtifactRequestError( + "The snippet interval exceeds the media duration." + ) + if duration > self.max_snippet_duration_seconds: + raise ArtifactRequestError( + "The snippet exceeds the configured duration limit." + ) + source = self.media.content(command.media_id) + compatible = command.profile == SnippetProfile.compatible_mp4 + request_key = _request_key( + { + "kind": ArtifactKind.snippet, + "render_contract_version": ARTIFACT_RENDER_CONTRACT_VERSION, + "media_sha256": source.etag, + "start_seconds": command.start_seconds, + "end_seconds": command.end_seconds, + "profile": command.profile, + } + ) + return self._create( + media_id=command.media_id, + generation_id=None, + kind=ArtifactKind.snippet, + profile=command.profile.value, + request_key=request_key, + suffix=".mp4" if compatible else ".mkv", + expected_mime_type=( + "video/mp4" if compatible else "video/x-matroska" + ), + job_id=job_id, + execution=active_execution, + render=lambda destination: self.snippet_renderer.render( + source.path, + destination, + start_seconds=command.start_seconds, + end_seconds=command.end_seconds, + compatible_mp4=compatible, + cancellation=active_execution.cancellation, + progress=active_execution.progress, + ), + ) + + def _create( + self, + *, + media_id: str, + generation_id: str | None, + kind: ArtifactKind, + profile: str, + request_key: str, + suffix: str, + expected_mime_type: str, + job_id: str | None, + execution: ExecutionContext, + render: Callable[[Path], None], + ) -> Artifact: + execution.checkpoint() + cached = self.catalog.get_artifact_by_request(request_key) + if cached is not None: + try: + self._require_ready(cached) + self._verified_content(cached) + except (ArtifactIntegrityError, ArtifactUnavailableError): + self.catalog.invalidate_artifact_request( + request_key, + cached.artifact_id, + ) + else: + execution.report( + { + "stage": "complete", + "message": "Reused the existing ready artifact.", + "current": 1, + "total": 1, + } + ) + return artifact_result(cached) + + artifact_id = execution.operation_id or uuid4().hex + staged = self.store.stage(artifact_id, suffix=suffix) + stored = self.store.recover(artifact_id, suffix=suffix) + try: + if stored is None: + execution.report( + { + "stage": "rendering", + "message": "Rendering the requested video artifact.", + } + ) + render(staged.path) + execution.checkpoint() + artifact_path = staged.path + else: + execution.report( + { + "stage": "recovering", + "message": "Recovering the published video artifact.", + } + ) + artifact_path = stored.local_path + execution.report( + { + "stage": "validating", + "message": "Validating the rendered video artifact.", + } + ) + try: + probed = self.probe.probe(artifact_path) + except InvalidMediaError as exc: + raise InvalidArtifactError( + "The rendered artifact is not valid video." + ) from exc + if probed.detected_mime_type != expected_mime_type: + raise InvalidArtifactError( + "The rendered artifact does not match its output profile." + ) + execution.checkpoint() + if stored is None: + execution.report( + { + "stage": "publishing", + "message": "Publishing the validated video artifact.", + } + ) + stored = self.store.publish(staged) + execution.checkpoint() + record = ArtifactRecord( + artifact_id=artifact_id, + media_id=media_id, + generation_id=generation_id, + request_key=request_key, + kind=kind, + profile=profile, + mime_type=probed.detected_mime_type, + byte_size=stored.byte_size, + sha256=stored.sha256, + storage_key=stored.storage_key, + job_id=job_id, + state=ArtifactState.ready, + created_at=utc_now(), + ) + authoritative = self.catalog.put_artifact(record) + if authoritative.artifact_id != artifact_id: + self._delete_quietly(stored.storage_key) + stored = None + except BaseException: + if stored is not None: + self._delete_quietly(stored.storage_key) + raise + finally: + self.store.discard(staged) + execution.report( + { + "stage": "complete", + "message": "The video artifact is ready.", + "current": 1, + "total": 1, + } + ) + return artifact_result(authoritative) + + def _delete_quietly(self, storage_key: str) -> None: + try: + self.store.delete(storage_key) + except OSError: + pass diff --git a/src/vidxp/authentication.py b/src/vidxp/authentication.py new file mode 100644 index 0000000..8814914 --- /dev/null +++ b/src/vidxp/authentication.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +from dataclasses import dataclass +from hmac import compare_digest +from typing import Any, Protocol + +from vidxp.application_models import ( + ApplicationError, + ComponentReadiness, + ErrorCategory, + Principal, +) +from vidxp.settings import HttpAuthMode, VidXPSettings + + +class Authenticator(Protocol): + def authenticate(self, token: str | None) -> Principal: ... + + def readiness(self) -> ComponentReadiness: ... + + +@dataclass(frozen=True) +class AuthenticatedBearer: + principal: Principal + expires_at: int | None + resource: str | None + claims: dict[str, Any] + + +def _ready() -> ComponentReadiness: + return ComponentReadiness( + name="authentication", + ready=True, + message="The HTTP authentication profile is ready.", + ) + + +def _authentication_error() -> ApplicationError: + return ApplicationError( + "authentication_required", + ErrorCategory.authentication, + "Valid bearer authentication is required.", + ) + + +def _authorization_error() -> ApplicationError: + return ApplicationError( + "insufficient_scope", + ErrorCategory.authorization, + "The authenticated principal lacks a required scope.", + ) + + +class LocalAuthenticator: + def authenticate(self, token: str | None) -> Principal: + del token + return Principal(subject="local", scopes=frozenset({"*"})) + + def readiness(self) -> ComponentReadiness: + return _ready() + + +class StaticBearerAuthenticator: + def __init__(self, expected_token: str) -> None: + self._expected_token = expected_token + + def authenticate(self, token: str | None) -> Principal: + if token is None or not compare_digest(token, self._expected_token): + raise _authentication_error() + return Principal( + subject="static", + client_id="static", + scopes=frozenset({"*"}), + ) + + def readiness(self) -> ComponentReadiness: + return _ready() + + +class OIDCBearerAuthenticator: + def __init__( + self, + *, + issuer: str, + audience: str, + jwks_url: str, + algorithms: tuple[str, ...], + required_scopes: tuple[str, ...], + jwks_client: Any | None = None, + ) -> None: + import jwt + + self._jwt = jwt + self._issuer = issuer + self._audience = audience + self._algorithms = algorithms + self._required_scopes = frozenset(required_scopes) + self._jwks = ( + jwks_client + if jwks_client is not None + else jwt.PyJWKClient( + jwks_url, + cache_keys=True, + cache_jwk_set=True, + lifespan=300, + timeout=5, + ) + ) + self._jwks_url = jwks_url + + def for_audience( + self, + audience: str, + *, + required_scopes: tuple[str, ...] = (), + ) -> "OIDCBearerAuthenticator": + return OIDCBearerAuthenticator( + issuer=self._issuer, + audience=audience, + jwks_url=self._jwks_url, + algorithms=self._algorithms, + required_scopes=required_scopes, + jwks_client=self._jwks, + ) + + def authenticate(self, token: str | None) -> Principal: + return self.authenticate_bearer(token).principal + + def authenticate_bearer( + self, + token: str | None, + ) -> AuthenticatedBearer: + if token is None: + raise _authentication_error() + try: + signing_key = self._jwks.get_signing_key_from_jwt(token) + claims = self._jwt.decode( + token, + signing_key, + algorithms=list(self._algorithms), + audience=self._audience, + issuer=self._issuer, + options={ + "require": ["exp", "iss", "aud", "sub"], + "verify_signature": True, + "verify_exp": True, + "verify_nbf": True, + "verify_iss": True, + "verify_aud": True, + }, + ) + except self._jwt.PyJWTError as exc: + raise _authentication_error() from exc + subject = claims.get("sub") + if not isinstance(subject, str) or not subject: + raise _authentication_error() + scopes = _token_scopes(claims) + if not self._required_scopes.issubset(scopes): + raise _authorization_error() + client_id = claims.get("client_id") + principal = Principal( + subject=subject, + client_id=client_id if isinstance(client_id, str) else None, + scopes=scopes, + ) + expires_at = claims.get("exp") + resource = claims.get("aud") + return AuthenticatedBearer( + principal=principal, + expires_at=( + expires_at if isinstance(expires_at, int) else None + ), + resource=( + resource + if isinstance(resource, str) + else self._audience + ), + claims=dict(claims), + ) + + def readiness(self) -> ComponentReadiness: + try: + key_set = self._jwks.get_jwk_set() + usable = any( + self._usable_signing_key(key) + for key in key_set.keys + ) + if not usable: + raise ValueError( + "The OIDC JWKS contains no usable signing keys." + ) + except Exception: + return ComponentReadiness( + name="authentication", + ready=False, + message="The OIDC signing-key service is unavailable.", + ) + return _ready() + + def _usable_signing_key(self, key) -> bool: + if ( + key.public_key_use not in {None, "sig"} + or key.algorithm_name not in self._algorithms + ): + return False + data = getattr(key, "_jwk_data", {}) + key_operations = ( + data.get("key_ops") if isinstance(data, dict) else None + ) + return ( + key_operations is None + or ( + isinstance(key_operations, list) + and "verify" in key_operations + ) + ) + + +def _token_scopes(claims: dict) -> frozenset[str]: + scope = claims.get("scope") + if isinstance(scope, str): + values = scope.split() + else: + scp = claims.get("scp") + if isinstance(scp, str): + values = scp.split() + elif isinstance(scp, list) and all( + isinstance(value, str) for value in scp + ): + values = scp + else: + values = [] + return frozenset(value for value in values if value) + + +def create_authenticator( + settings: VidXPSettings, + *, + audience: str | None = None, + required_scopes: tuple[str, ...] | None = None, +) -> Authenticator: + if settings.http_auth_mode == HttpAuthMode.none: + return LocalAuthenticator() + if settings.http_auth_mode == HttpAuthMode.static: + assert settings.http_static_bearer_token is not None + return StaticBearerAuthenticator( + settings.http_static_bearer_token.get_secret_value() + ) + assert settings.http_oidc_issuer is not None + assert settings.http_oidc_audience is not None + assert settings.http_oidc_jwks_url is not None + return OIDCBearerAuthenticator( + issuer=settings.http_oidc_issuer, + audience=audience or settings.http_oidc_audience, + jwks_url=settings.http_oidc_jwks_url, + algorithms=settings.http_oidc_algorithms, + required_scopes=( + settings.http_required_scopes + if required_scopes is None + else required_scopes + ), + ) diff --git a/src/vidxp/authorization.py b/src/vidxp/authorization.py new file mode 100644 index 0000000..f597c26 --- /dev/null +++ b/src/vidxp/authorization.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from enum import StrEnum + +from vidxp.application_models import ( + ApplicationError, + ErrorCategory, + Principal, +) + + +class RepositoryPermission(StrEnum): + read = "vidxp.read" + write = "vidxp.write" + admin = "vidxp.admin" + + +class AuthorizationPolicy: + """Repository-wide authorization shared by HTTP and future MCP adapters.""" + + def require( + self, + principal: Principal, + permission: RepositoryPermission, + ) -> Principal: + scopes = principal.scopes + if ( + "*" in scopes + or RepositoryPermission.admin.value in scopes + or permission.value in scopes + ): + return principal + raise ApplicationError( + "insufficient_scope", + ErrorCategory.authorization, + "The authenticated principal lacks the required repository scope.", + details={"required_scope": permission.value}, + ) diff --git a/src/vidxp/benchmarks/cli.py b/src/vidxp/benchmarks/cli.py index fd84ca0..34fa5e2 100644 --- a/src/vidxp/benchmarks/cli.py +++ b/src/vidxp/benchmarks/cli.py @@ -6,21 +6,193 @@ import typer from rich import print as rich_print +from rich.console import Console +from rich.progress import ( + BarColumn, + DownloadColumn, + Progress, + TextColumn, + TimeRemainingColumn, + TransferSpeedColumn, +) from vidxp.benchmarks.didemo import run_didemo from vidxp.benchmarks.hirest import ( HIREST_DEFAULT_WINDOW_FRACTION, run_hirest, ) +from vidxp.benchmarks.prepare import ( + PreparationPlan, + execute_preparation, + plan_didemo, + plan_hirest, +) +from vidxp.app_paths import available_storage_bytes +from vidxp.capabilities.registry import create_capability_registry from vidxp.cli_support import ( OutputFormat, effective_output_format, emit_json, + emit_progress, state_from_context, ) +from vidxp.dependencies import ( + active_requirements, + inspect_requirement, + packaged_requirements, +) -app = typer.Typer(help="Run official benchmark adapters.") +app = typer.Typer(help="Prepare and run official benchmark adapters.") +prepare_app = typer.Typer( + help="Download, verify, and arrange pinned benchmark inputs." +) +app.add_typer(prepare_app, name="prepare") + + +def _format_bytes(value: int) -> str: + size = float(value) + for unit in ("bytes", "KiB", "MiB", "GiB", "TiB"): + if size < 1024 or unit == "TiB": + precision = 0 if unit == "bytes" else 1 + return f"{size:.{precision}f} {unit}" + size /= 1024 + raise AssertionError("unreachable") + + +def _show_preparation_plan(plan: PreparationPlan) -> None: + typer.secho("Benchmark preparation plan", bold=True) + typer.echo(f" Benchmark: {plan.benchmark} {plan.split}") + typer.echo( + f" Selection: {plan.selected_count} item(s), " + f"{len(plan.selected_video_names)} video(s)" + ) + typer.echo(f" Destination: {plan.root}") + typer.echo( + f" New files: {plan.download_count}; " + "maximum additional storage: " + f"{_format_bytes(plan.additional_bytes)}" + ) + free_bytes = available_storage_bytes(plan.root) + if free_bytes is not None: + typer.echo(f" Free space at destination: {_format_bytes(free_bytes)}") + if free_bytes < plan.additional_bytes: + typer.secho( + " Warning: the destination may not have enough free space. " + "Choose another --output-directory or free space first.", + fg=typer.colors.YELLOW, + ) + replacements = [ + resource + for resource in plan.resources + if resource.replacement_for is not None + ] + for resource in replacements: + typer.secho( + " Documented replacement: " + f"{resource.replacement_for} -> {resource.url}", + fg=typer.colors.YELLOW, + ) + + +def _execute_preparation_plan( + plan: PreparationPlan, + *, + state, + yes: bool, + json_output: bool, +) -> None: + output_format = effective_output_format(state, json_output) + if output_format == OutputFormat.rich: + _show_preparation_plan(plan) + if plan.download_count and not yes: + if output_format == OutputFormat.json: + raise typer.BadParameter( + "Benchmark downloads require --yes with JSON output.", + param_hint="--yes", + ) + typer.confirm( + "Download and prepare these benchmark inputs?", + abort=True, + ) + network_bytes = plan.network_bytes + if ( + state.quiet + or output_format == OutputFormat.json + or network_bytes == 0 + ): + result = execute_preparation( + plan, + ffprobe=state.settings.ffprobe_executable, + ffmpeg=state.settings.ffmpeg_executable, + ) + else: + console = Console(stderr=True) + with Progress( + TextColumn("{task.description}"), + BarColumn(), + DownloadColumn(), + TransferSpeedColumn(), + TimeRemainingColumn(), + console=console, + ) as progress: + task = progress.add_task( + "Downloading benchmark inputs", + total=network_bytes or None, + ) + + def advance(name: str, amount: int) -> None: + progress.update( + task, + description=f"Downloading {name}", + advance=amount, + ) + + result = execute_preparation( + plan, + ffprobe=state.settings.ffprobe_executable, + ffmpeg=state.settings.ffmpeg_executable, + progress=advance, + ) + if output_format == OutputFormat.json: + emit_json(result) + return + typer.secho("Benchmark inputs are ready.", fg=typer.colors.GREEN) + typer.echo(f"Manifest: {plan.manifest_path}") + typer.echo("Run:") + typer.secho(plan.command, bold=True) + + +def _require_benchmark_dependencies( + capability: str, + *, + include_benchmark_extra: bool = False, +) -> None: + requirements = list( + create_capability_registry().requirements_for((capability,)) + ) + extras = [capability] + if include_benchmark_extra: + requirements.extend( + active_requirements(packaged_requirements("vidxp.benchmarks")) + ) + extras.append("benchmarks") + failures = [ + check + for requirement in dict.fromkeys(requirements) + if not (check := inspect_requirement(requirement))["ok"] + ] + if not failures: + return + unavailable = ", ".join( + f"{failure['requirement']} ({failure['error']})" + for failure in failures + ) + extra = ",".join(extras) + raise typer.BadParameter( + f"Benchmark dependencies are unavailable: {unavailable}. " + f'Install them with: pip install "vidxp[{extra}]"' + ) def _annotation_indices(value: str | None) -> list[int] | None: @@ -46,7 +218,13 @@ def _annotation_indices(value: str | None) -> list[int] | None: def _pair_file(path: Path | None) -> list[tuple[str, str]] | None: if path is None: return None - payload = json.loads(path.read_text(encoding="utf-8")) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise typer.BadParameter( + "The HiREST pair file must contain valid readable JSON.", + param_hint="--pairs", + ) from exc if not isinstance(payload, list) or not payload: raise typer.BadParameter( "The HiREST pair file must be a non-empty JSON list." @@ -66,6 +244,184 @@ def _pair_file(path: Path | None) -> list[tuple[str, str]] | None: return pairs +def _media_override_file(path: Path | None) -> dict[str, Path] | None: + if path is None: + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise typer.BadParameter( + "The DiDeMo media override file must contain valid readable JSON.", + param_hint="--media-overrides", + ) from exc + if not isinstance(payload, dict) or not payload: + raise typer.BadParameter( + "The DiDeMo media override file must be a non-empty JSON object.", + param_hint="--media-overrides", + ) + overrides: dict[str, Path] = {} + for video_name, replacement in payload.items(): + if ( + not isinstance(video_name, str) + or not video_name.strip() + or not isinstance(replacement, str) + or not replacement.strip() + ): + raise typer.BadParameter( + "Each DiDeMo media override must map a video name to a path.", + param_hint="--media-overrides", + ) + candidate = Path(replacement) + if not candidate.is_absolute(): + candidate = path.parent / candidate + candidate = candidate.resolve() + if not candidate.is_file(): + raise typer.BadParameter( + f"DiDeMo replacement media was not found: {candidate}", + param_hint="--media-overrides", + ) + overrides[video_name] = candidate + return overrides + + +@prepare_app.command("didemo") +def prepare_didemo_command( + ctx: typer.Context, + split: Annotated[ + Literal["validation", "test"], + typer.Option(help="Official split to prepare."), + ] = "test", + annotation_indices: Annotated[ + str | None, + typer.Option( + help=( + "Comma-separated zero-based annotation indices. " + "Omit to prepare the full selected split." + ) + ), + ] = None, + output_directory: Annotated[ + Path | None, + typer.Option( + file_okay=False, + help=( + "Preparation destination. Defaults to the VidXP application " + "data directory." + ), + ), + ] = None, + yes: Annotated[ + bool, + typer.Option( + "--yes", + "-y", + help="Confirm the displayed download and replacement plan.", + ), + ] = False, + json_output: Annotated[ + bool, + typer.Option("--json", help="Emit machine-readable JSON."), + ] = False, +) -> None: + """Prepare verified DiDeMo artifacts and selected official videos.""" + + state = state_from_context(ctx) + root = ( + output_directory + if output_directory is not None + else state.settings.data_dir / "benchmarks" / "didemo" + ) + if ( + not state.quiet + and effective_output_format(state, json_output) == OutputFormat.rich + ): + emit_progress( + "Inspecting pinned DiDeMo metadata and download sizes..." + ) + plan = plan_didemo( + root=root, + split=split, + annotation_indices=_annotation_indices(annotation_indices), + ffprobe=state.settings.ffprobe_executable, + ffmpeg=state.settings.ffmpeg_executable, + ) + _execute_preparation_plan( + plan, + state=state, + yes=yes, + json_output=json_output, + ) + + +@prepare_app.command("hirest") +def prepare_hirest_command( + ctx: typer.Context, + split: Annotated[ + Literal["validation", "test"], + typer.Option(help="Official split to prepare."), + ] = "validation", + pairs: Annotated[ + Path | None, + typer.Option( + exists=True, + dir_okay=False, + help=( + "Optional JSON list of {prompt, video} pairs for a " + "declared smoke subset." + ), + ), + ] = None, + output_directory: Annotated[ + Path | None, + typer.Option( + file_okay=False, + help=( + "Preparation destination. Defaults to the VidXP application " + "data directory." + ), + ), + ] = None, + yes: Annotated[ + bool, + typer.Option( + "--yes", + "-y", + help="Confirm the displayed download plan.", + ), + ] = False, + json_output: Annotated[ + bool, + typer.Option("--json", help="Emit machine-readable JSON."), + ] = False, +) -> None: + """Prepare verified HiREST artifacts and released transcripts.""" + + state = state_from_context(ctx) + root = ( + output_directory + if output_directory is not None + else state.settings.data_dir / "benchmarks" / "hirest" + ) + if ( + not state.quiet + and effective_output_format(state, json_output) == OutputFormat.rich + ): + emit_progress( + "Inspecting pinned HiREST metadata and download sizes..." + ) + plan = plan_hirest( + root=root, + split=split, + pairs=_pair_file(pairs), + ) + _execute_preparation_plan( + plan, + state=state, + yes=yes, + json_output=json_output, + ) + + @app.command("didemo") def didemo_command( ctx: typer.Context, @@ -90,13 +446,31 @@ def didemo_command( ) ), ] = None, + media_overrides: Annotated[ + Path | None, + typer.Option( + exists=True, + dir_okay=False, + help=( + "JSON object mapping an official video name to documented " + "replacement media. Relative paths resolve beside the file." + ), + ), + ] = None, chunk_pooling: Annotated[ Literal["max", "mean"], typer.Option( help="Pool sampled frame scores within each five-second chunk." ), ] = "max", - frame_stride: Annotated[int, typer.Option(min=1)] = 1, + scene_sample_fps: Annotated[ + float, + typer.Option( + "--scene-sample-fps", + min=0.01, + help="Target time-based scene samples per second.", + ), + ] = 1.0, reset: Annotated[bool, typer.Option()] = False, json_output: Annotated[ bool, @@ -105,6 +479,7 @@ def didemo_command( ) -> None: """Run DiDeMo scene retrieval and its official evaluator.""" + _require_benchmark_dependencies("scene") state = state_from_context(ctx) metrics = run_didemo( annotations_path=annotations, @@ -113,11 +488,12 @@ def didemo_command( run_id=run_id, output_root=output_root, annotation_indices=_annotation_indices(annotation_indices), - frame_stride=frame_stride, + media_overrides=_media_override_file(media_overrides), + scene_sample_fps=scene_sample_fps, split=split, chunk_pooling=chunk_pooling, reset=reset, - device=state.service.device or "cpu", + device=state.settings.runtime_backend, ) if effective_output_format(state, json_output) == OutputFormat.json: emit_json(metrics) @@ -169,6 +545,16 @@ def hirest_command( ) -> None: """Run HiREST released-ASR retrieval; score validation predictions.""" + if not 0 < temporal_window_fraction < 1: + raise typer.BadParameter( + "The temporal window fraction must be greater than zero and " + "less than one.", + param_hint="--temporal-window-fraction", + ) + _require_benchmark_dependencies( + "dialogue", + include_benchmark_extra=True, + ) state = state_from_context(ctx) metrics = run_hirest( ground_truth_path=ground_truth, @@ -182,7 +568,7 @@ def hirest_command( split=split, temporal_window_fraction=temporal_window_fraction, reset=reset, - device=state.service.device or "cpu", + device=state.settings.runtime_backend, ) if effective_output_format(state, json_output) == OutputFormat.json: emit_json(metrics) diff --git a/src/vidxp/benchmarks/common.py b/src/vidxp/benchmarks/common.py index 5df276a..f9d2853 100644 --- a/src/vidxp/benchmarks/common.py +++ b/src/vidxp/benchmarks/common.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import json import os import subprocess @@ -9,11 +10,36 @@ from vidxp.core.manifest import sha256_file, utc_now, write_json_atomic +def _benchmark_uuid4(*parts: str) -> str: + digest = bytearray( + hashlib.sha256("\0".join(parts).encode("utf-8")).digest()[:16] + ) + digest[6] = (digest[6] & 0x0F) | 0x40 + digest[8] = (digest[8] & 0x3F) | 0x80 + return digest.hex() + + +def benchmark_generation_id( + benchmark: str, + split: str, + run_id: str, +) -> str: + """Return a stable UUID4-shaped identity for a resumable benchmark run.""" + + return _benchmark_uuid4("generation", benchmark, split, run_id) + + +def benchmark_media_id(benchmark: str, official_video_id: str) -> str: + """Map an official dataset video key to VidXP's opaque media ID shape.""" + + return _benchmark_uuid4("media", benchmark, official_video_id) + + def verify_artifact( path: str | Path, *, name: str, - expected_sha256: str, + expected_sha256: str | Sequence[str], source: str, revision: str, ) -> dict[str, Any]: @@ -21,9 +47,15 @@ def verify_artifact( if not artifact.is_file(): raise FileNotFoundError(f"{name} not found: {artifact}") actual_sha256 = sha256_file(artifact) - if actual_sha256 != expected_sha256: + accepted = ( + (expected_sha256,) + if isinstance(expected_sha256, str) + else tuple(expected_sha256) + ) + if actual_sha256 not in accepted: raise ValueError( - f"{name} checksum mismatch: expected {expected_sha256}, " + f"{name} checksum mismatch: expected one of " + f"{', '.join(accepted)}, " f"received {actual_sha256}." ) return { diff --git a/src/vidxp/benchmarks/didemo.py b/src/vidxp/benchmarks/didemo.py index bdf6752..11f6d89 100644 --- a/src/vidxp/benchmarks/didemo.py +++ b/src/vidxp/benchmarks/didemo.py @@ -11,6 +11,8 @@ from vidxp.benchmarks.common import ( append_failure, + benchmark_generation_id, + benchmark_media_id, ensure_adapter_outputs, record_adapter_manifest, run_logged_evaluator, @@ -18,9 +20,14 @@ ) from vidxp.capabilities.scene.operations import search_scene from vidxp.capabilities.schemas import SearchHit +from vidxp.capabilities.registry import create_capability_registry from vidxp.core.contracts import IndexConfig, VideoSource -from vidxp.core.manifest import write_json_atomic +from vidxp.core.manifest import ManifestStore, write_json_atomic from vidxp.core.runner import run_index +from vidxp.core.storage import IndexStorage +from vidxp.infrastructure.local_index import LOCAL_INDEX_RUNTIME_CHECKS +from vidxp.runtime import ModelRuntime +from vidxp.settings import VidXPSettings DIDEMO_REVISION = "b6a555c8134581305d0ed4716fbc192860e0b88c" @@ -32,6 +39,9 @@ "b0364cc256553332feb19d46bcc4cd2b09774949fe6c0b25e7ed0ff3c6aefebb" ) DIDEMO_EVALUATOR_SHA256 = ( + "9ec3e7a171272eb3551b0eaa7bbe9292131ad5cf34fd5c1e02c0fc4a11234df6" +) +DIDEMO_EVALUATOR_CRLF_SHA256 = ( "4754bb320564e5d2e7c633e0b660e87feca7f00fa73269e50140e81ffb4ca762" ) DIDEMO_MOMENTS = ( @@ -40,8 +50,7 @@ ) -def load_annotations(path: str | Path) -> list[dict[str, Any]]: - payload = json.loads(Path(path).read_text(encoding="utf-8")) +def parse_annotations(payload: Any) -> list[dict[str, Any]]: if not isinstance(payload, list) or not payload: raise ValueError("DiDeMo annotations must be a non-empty JSON list.") required = { @@ -72,7 +81,13 @@ def load_annotations(path: str | Path) -> list[dict[str, Any]]: raise ValueError( f"DiDeMo annotation {index} exceeds num_segments." ) - return payload + return [dict(annotation) for annotation in payload] + + +def load_annotations(path: str | Path) -> list[dict[str, Any]]: + return parse_annotations( + json.loads(Path(path).read_text(encoding="utf-8")) + ) def select_annotations( @@ -235,7 +250,10 @@ def _verified_artifacts( verify_artifact( evaluator_path, name="DiDeMo evaluator", - expected_sha256=DIDEMO_EVALUATOR_SHA256, + expected_sha256=( + DIDEMO_EVALUATOR_SHA256, + DIDEMO_EVALUATOR_CRLF_SHA256, + ), source=( f"{DIDEMO_REPOSITORY}/blob/{DIDEMO_REVISION}" "/utils/eval.py" @@ -253,7 +271,7 @@ def _video_sources( ) -> list[VideoSource]: return [ VideoSource( - video_id=video_name, + video_id=benchmark_media_id("didemo", video_name), path=resolve_media( media_directory, video_name, @@ -271,6 +289,8 @@ def _generate_predictions( config: IndexConfig, manifest: Mapping[str, Any], chunk_pooling: Literal["max", "mean"], + runtime: ModelRuntime, + storage: IndexStorage, ) -> list[list[list[int]]]: scene_counts = { video_id: int(video["summary"]["scene_frames"]) @@ -278,13 +298,18 @@ def _generate_predictions( } predictions = [] for annotation in annotations: - video_id = str(annotation["video"]) + video_id = benchmark_media_id( + "didemo", + str(annotation["video"]), + ) result = search_scene( str(annotation["description"]), config=config, top_k=scene_counts[video_id], video_id=video_id, query_id=str(annotation["annotation_id"]), + runtime=runtime, + storage=storage, ) predictions.append( [ @@ -354,7 +379,7 @@ def run_didemo( output_root: str | Path = "benchmark_runs", annotation_indices: Sequence[int] | None = None, media_overrides: Mapping[str, str | Path] | None = None, - frame_stride: int = 1, + scene_sample_fps: float = 1.0, device: str = "cpu", split: Literal["validation", "test"] = "test", chunk_pooling: Literal["max", "mean"] = "max", @@ -378,11 +403,24 @@ def run_didemo( split=split, run_id=run_id, enabled_modalities=("scene",), - frame_stride=frame_stride, + capability_options={ + "scene": {"sample_fps": scene_sample_fps}, + }, device=device, output_root=output_root, + generation_id=benchmark_generation_id("didemo", split, run_id), ) run_directory = config.run_directory + registry = create_capability_registry( + platform_runtime_checks=LOCAL_INDEX_RUNTIME_CHECKS + ) + runtime = ModelRuntime( + VidXPSettings( + repository_root=run_directory, + runtime_backend=device, + ), + allowed_specs=registry.model_specs(), + ) ensure_adapter_outputs(run_directory) subset = { "label": ( @@ -397,29 +435,40 @@ def run_didemo( "video_count": len({item["video"] for item in annotations}), } try: - manifest = run_index( - _video_sources( + with IndexStorage(config) as storage: + manifest = run_index( + _video_sources( + annotations, + media_directory=media_directory, + media_overrides=media_overrides, + ), + config, + reset=reset, + storage=storage, + manifest_store=ManifestStore( + config, + registry=registry, + runtime=runtime, + ), + registry=registry, + runtime=runtime, + ) + ensure_adapter_outputs(run_directory) + record_adapter_manifest( + run_directory, + benchmark="didemo", + subset=subset, + artifacts=artifacts, + state="predicting", + ) + predictions = _generate_predictions( annotations, - media_directory=media_directory, - media_overrides=media_overrides, - ), - config, - reset=reset, - ) - ensure_adapter_outputs(run_directory) - record_adapter_manifest( - run_directory, - benchmark="didemo", - subset=subset, - artifacts=artifacts, - state="predicting", - ) - predictions = _generate_predictions( - annotations, - config=config, - manifest=manifest, - chunk_pooling=chunk_pooling, - ) + config=config, + manifest=manifest, + chunk_pooling=chunk_pooling, + runtime=runtime, + storage=storage, + ) validate_predictions(predictions, annotations) predictions_path = run_directory / "predictions.json" ground_truth_path = run_directory / "ground_truth.subset.json" @@ -444,6 +493,9 @@ def run_didemo( "chunk_pooling": chunk_pooling, "media_override_count": len(media_overrides or {}), "media_override_video_ids": sorted(media_overrides or {}), + "media_id_mapping": ( + "deterministic_uuid4_from_benchmark_and_official_video_id" + ), "result_classification": _result_classification( split=split, full_split=annotation_indices is None, diff --git a/src/vidxp/benchmarks/hirest.py b/src/vidxp/benchmarks/hirest.py index ce499a3..15ece16 100644 --- a/src/vidxp/benchmarks/hirest.py +++ b/src/vidxp/benchmarks/hirest.py @@ -13,6 +13,8 @@ from vidxp.benchmarks.common import ( append_failure, + benchmark_generation_id, + benchmark_media_id, ensure_adapter_outputs, record_adapter_manifest, run_logged_evaluator, @@ -21,9 +23,14 @@ from vidxp.capabilities.dialogue.config import dialogue_config from vidxp.capabilities.dialogue.operations import search_dialogue from vidxp.capabilities.schemas import SearchHit +from vidxp.capabilities.registry import create_capability_registry from vidxp.core.contracts import IndexConfig, VideoSource -from vidxp.core.manifest import sha256_file, write_json_atomic +from vidxp.core.manifest import ManifestStore, sha256_file, write_json_atomic from vidxp.core.runner import run_index +from vidxp.core.storage import IndexStorage +from vidxp.infrastructure.local_index import LOCAL_INDEX_RUNTIME_CHECKS +from vidxp.runtime import ModelRuntime +from vidxp.settings import VidXPSettings HIREST_REVISION = "deffc169b4e8d51c1589d5512ad05da61e81bcee" @@ -46,6 +53,9 @@ "157623d50f7b8482f55fa1c4efc500539784c0399fb2dd60bb687b4006d85ca1" ) HIREST_EVALUATOR_SHA256 = ( + "871b48dc5ce42fbe1a4b672fe4df88a88ce568d57759dfc971e5aacc5f88f119" +) +HIREST_EVALUATOR_CRLF_SHA256 = ( "c4b8ba9b572ae4088e90ddc3eec2b2cc4f5b4c1a0153ff6e0843817da89a5ca0" ) HIREST_DEFAULT_WINDOW_FRACTION = 0.8 @@ -264,7 +274,10 @@ def _verified_artifacts( verify_artifact( evaluator_path, name="HiREST evaluator", - expected_sha256=HIREST_EVALUATOR_SHA256, + expected_sha256=( + HIREST_EVALUATOR_SHA256, + HIREST_EVALUATOR_CRLF_SHA256, + ), source=f"{revision_root}/evaluate.py", revision=HIREST_REVISION, ), @@ -292,7 +305,7 @@ def _transcript_sources( ) sources.append( VideoSource( - video_id=video, + video_id=benchmark_media_id("hirest", video), source_name=asr_path.name, transcript=parse_srt(asr_path), checksum=sha256_file(asr_path), @@ -312,6 +325,8 @@ def _generate_predictions( config: IndexConfig, manifest: Mapping[str, Any], temporal_window_fraction: float, + runtime: ModelRuntime, + storage: IndexStorage, ) -> dict[str, dict[str, dict[str, list[float]]]]: dialogue_counts = { video_id: int(video["summary"]["dialogue_phrases"]) @@ -319,12 +334,15 @@ def _generate_predictions( } predictions: dict[str, dict[str, dict[str, list[float]]]] = {} for prompt, video in ordered_pairs: + media_id = benchmark_media_id("hirest", video) hits = search_dialogue( prompt, config=config, - top_k=dialogue_counts[video], - video_id=video, + top_k=dialogue_counts[media_id], + video_id=media_id, query_id=f"{prompt}\0{video}", + runtime=runtime, + storage=storage, ).hits if not hits: raise RuntimeError( @@ -430,8 +448,19 @@ def run_hirest( enabled_modalities=("dialogue",), device=device, output_root=output_root, + generation_id=benchmark_generation_id("hirest", split, run_id), ) run_directory = config.run_directory + registry = create_capability_registry( + platform_runtime_checks=LOCAL_INDEX_RUNTIME_CHECKS + ) + runtime = ModelRuntime( + VidXPSettings( + repository_root=run_directory, + runtime_backend=device, + ), + allowed_specs=registry.model_specs(), + ) ensure_adapter_outputs(run_directory) subset = { "label": ( @@ -444,29 +473,40 @@ def run_hirest( "video_count": len({video for _, video in ordered_pairs}), } try: - manifest = run_index( - _transcript_sources( + with IndexStorage(config) as storage: + manifest = run_index( + _transcript_sources( + ordered_pairs, + asr_directory=asr_directory, + ), + config, + reset=reset, + storage=storage, + manifest_store=ManifestStore( + config, + registry=registry, + runtime=runtime, + ), + registry=registry, + runtime=runtime, + ) + ensure_adapter_outputs(run_directory) + record_adapter_manifest( + run_directory, + benchmark="hirest", + subset=subset, + artifacts=artifacts, + state="predicting", + ) + predictions = _generate_predictions( ordered_pairs, - asr_directory=asr_directory, - ), - config, - reset=reset, - ) - ensure_adapter_outputs(run_directory) - record_adapter_manifest( - run_directory, - benchmark="hirest", - subset=subset, - artifacts=artifacts, - state="predicting", - ) - predictions = _generate_predictions( - ordered_pairs, - ground_truth=ground_truth, - config=config, - manifest=manifest, - temporal_window_fraction=temporal_window_fraction, - ) + ground_truth=ground_truth, + config=config, + manifest=manifest, + temporal_window_fraction=temporal_window_fraction, + runtime=runtime, + storage=storage, + ) validate_predictions(predictions, ground_truth) predictions_path = run_directory / "predictions.json" ground_truth_subset_path = ( @@ -489,8 +529,11 @@ def run_hirest( "duration_relative_window_mean_second_score" ), "temporal_window_fraction": temporal_window_fraction, - "whisperx_used": False, + "transcription_provider": "supplied-transcript", "video_decode_used": False, + "media_id_mapping": ( + "deterministic_uuid4_from_benchmark_and_official_video_id" + ), } if split == "test": summary = { diff --git a/src/vidxp/benchmarks/prepare.py b/src/vidxp/benchmarks/prepare.py new file mode 100644 index 0000000..3e861df --- /dev/null +++ b/src/vidxp/benchmarks/prepare.py @@ -0,0 +1,879 @@ +from __future__ import annotations + +import hashlib +import json +import os +import subprocess +import tempfile +import urllib.error +import urllib.request +import zipfile +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Any, Callable, Literal, Mapping, Sequence + +from vidxp.application_models import ApplicationError, ErrorCategory +from vidxp.benchmarks.didemo import ( + DIDEMO_EVALUATOR_SHA256, + DIDEMO_REVISION, + DIDEMO_TEST_SHA256, + DIDEMO_VALIDATION_SHA256, + parse_annotations, + select_annotations, +) +from vidxp.benchmarks.hirest import ( + HIREST_ASR_SHA256, + HIREST_ASR_URL, + HIREST_CATEGORIES_SHA256, + HIREST_EVALUATOR_SHA256, + HIREST_REVISION, + HIREST_TEST_SHA256, + HIREST_VALIDATION_SHA256, + select_ground_truth, +) +from vidxp.core.manifest import sha256_file, utc_now, write_json_atomic +from vidxp.infrastructure.local_media import FFprobeMediaProbe +from vidxp.media_runtime import inspect_media_runtime + + +DIDEMO_RAW_ROOT = ( + "https://raw.githubusercontent.com/LisaAnne/LocalizingMoments/" + f"{DIDEMO_REVISION}" +) +DIDEMO_HASH_MAP_SHA256 = ( + "481d9aaf020624d5915200bcf4752fb46d3e1931167e8b46715a5f342577cc4d" +) +DIDEMO_AWS_TEMPLATE = ( + "https://multimedia-commons.s3-us-west-2.amazonaws.com/" + "data/videos/mp4/{first}/{second}/{digest}.mp4" +) +DIDEMO_KNOWN_REPLACEMENT_VIDEO = ( + "12090392@N02_13482799053_87ef417396.mov" +) +DIDEMO_KNOWN_REPLACEMENT_URL = ( + "https://upload.wikimedia.org/wikipedia/commons/a/af/" + "Common_Starlings_flying_away_from_a_Marsh_Harrier.webm" +) +DIDEMO_KNOWN_REPLACEMENT_SHA1 = ( + "2aefa90d4256e74cf62e492729c0e0f6d6bede72" +) +DIDEMO_KNOWN_REPLACEMENT_SIZE = 94_107_862 + +HIREST_RAW_ROOT = ( + "https://raw.githubusercontent.com/j-min/HiREST/" + f"{HIREST_REVISION}" +) +HIREST_ASR_UNCOMPRESSED_SIZE = 17_236_665 + +ProgressCallback = Callable[[str, int], None] + + +@dataclass(frozen=True) +class PreparationResource: + name: str + url: str + destination: Path + size_bytes: int + kind: Literal["artifact", "media"] + content: bytes | None = None + expected_sha256: str | None = None + expected_sha1: str | None = None + existing: bool = False + replacement_for: str | None = None + + def public_record(self) -> dict[str, Any]: + return { + "name": self.name, + "url": self.url, + "path": str(self.destination), + "size_bytes": self.size_bytes, + "kind": self.kind, + "expected_sha256": self.expected_sha256, + "expected_sha1": self.expected_sha1, + "existing": self.existing, + "replacement_for": self.replacement_for, + } + + +@dataclass(frozen=True) +class PreparationPlan: + benchmark: Literal["didemo", "hirest"] + split: Literal["validation", "test"] + root: Path + resources: tuple[PreparationResource, ...] + selected_count: int + selected_video_names: tuple[str, ...] + command: str + manifest_path: Path + media_directory: Path | None = None + media_overrides_path: Path | None = None + asr_directory: Path | None = None + extraction_reserve_bytes: int = 0 + + @property + def additional_bytes(self) -> int: + return self.extraction_reserve_bytes + sum( + self._remaining_bytes(resource) for resource in self.resources + ) + + @property + def network_bytes(self) -> int: + return sum( + self._remaining_bytes(resource) + for resource in self.resources + if resource.content is None + ) + + @property + def download_count(self) -> int: + return sum(not resource.existing for resource in self.resources) + + @staticmethod + def _remaining_bytes(resource: PreparationResource) -> int: + if resource.existing: + return 0 + if resource.content is not None: + return resource.size_bytes + partial = resource.destination.with_name( + resource.destination.name + ".part" + ) + partial_size = partial.stat().st_size if partial.is_file() else 0 + if partial_size > resource.size_bytes: + return resource.size_bytes + return resource.size_bytes - partial_size + + +def _application_error( + code: str, + message: str, + *, + details: Mapping[str, Any] | None = None, +) -> ApplicationError: + return ApplicationError( + code, + ErrorCategory.unavailable, + message, + details=dict(details or {}), + ) + + +def _request(url: str, *, method: str = "GET", range_start: int = 0): + headers = {"User-Agent": "VidXP benchmark preparation"} + if range_start: + headers["Range"] = f"bytes={range_start}-" + return urllib.request.Request(url, headers=headers, method=method) + + +def _fetch_bytes(url: str) -> bytes: + try: + with urllib.request.urlopen(_request(url), timeout=60) as response: + return response.read() + except (OSError, urllib.error.URLError) as exc: + raise _application_error( + "benchmark_source_unavailable", + f"Could not read pinned benchmark source: {url}", + details={"url": url, "reason": str(exc)}, + ) from exc + + +def _fetch_verified(url: str, expected_sha256: str, name: str) -> bytes: + content = _fetch_bytes(url) + observed = hashlib.sha256(content).hexdigest() + if observed != expected_sha256: + raise _application_error( + "benchmark_source_checksum_mismatch", + f"{name} did not match its pinned SHA-256.", + details={ + "url": url, + "expected_sha256": expected_sha256, + "observed_sha256": observed, + }, + ) + return content + + +def _remote_size(url: str) -> int: + try: + with urllib.request.urlopen( + _request(url, method="HEAD"), + timeout=30, + ) as response: + value = response.headers.get("Content-Length") + if value and int(value) > 0: + return int(value) + except (OSError, ValueError, urllib.error.URLError): + pass + try: + with urllib.request.urlopen( + _request(url, range_start=0), + timeout=30, + ) as response: + content_range = response.headers.get("Content-Range", "") + if "/" in content_range: + value = int(content_range.rsplit("/", 1)[1]) + if value > 0: + return value + value = response.headers.get("Content-Length") + if value and int(value) > 0: + return int(value) + except (OSError, ValueError, urllib.error.URLError) as exc: + raise _application_error( + "benchmark_download_size_unavailable", + f"Could not determine the download size for {url}", + details={"url": url, "reason": str(exc)}, + ) from exc + raise _application_error( + "benchmark_download_size_unavailable", + f"The benchmark source did not report a download size: {url}", + details={"url": url}, + ) + + +def _sha1_file(path: Path) -> str: + digest = hashlib.sha1() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _valid_artifact( + path: Path, + *, + expected_sha256: str | None = None, + expected_sha1: str | None = None, +) -> bool: + if not path.is_file(): + return False + if expected_sha256 and sha256_file(path) != expected_sha256: + return False + return not expected_sha1 or _sha1_file(path) == expected_sha1 + + +def _validate_media(path: Path, *, ffprobe: str, ffmpeg: str) -> None: + media = FFprobeMediaProbe(ffprobe).probe(path) + seek = min(15.0, max(0.0, media.duration_seconds / 2)) + try: + subprocess.run( + [ + ffmpeg, + "-v", + "error", + "-ss", + str(seek), + "-i", + str(path), + "-frames:v", + "1", + "-f", + "null", + "-", + ], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + timeout=60, + ) + except OSError as exc: + raise _application_error( + "benchmark_media_runtime_unavailable", + "FFmpeg could not be executed while validating benchmark media.", + details={"ffmpeg": ffmpeg, "reason": str(exc)}, + ) from exc + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: + raise ValueError( + f"FFmpeg could not decode a frame from {path}." + ) from exc + + +def _valid_media(path: Path, *, ffprobe: str, ffmpeg: str) -> bool: + if not path.is_file(): + return False + try: + _validate_media(path, ffprobe=ffprobe, ffmpeg=ffmpeg) + except (ApplicationError, OSError, ValueError): + return False + return True + + +def _artifact_resource( + *, + name: str, + url: str, + destination: Path, + expected_sha256: str, + content: bytes, +) -> PreparationResource: + return PreparationResource( + name=name, + url=url, + destination=destination, + size_bytes=len(content), + kind="artifact", + content=content, + expected_sha256=expected_sha256, + existing=_valid_artifact( + destination, + expected_sha256=expected_sha256, + ), + ) + + +def _with_remote_sizes( + resources: Sequence[PreparationResource], +) -> tuple[PreparationResource, ...]: + resolved = list(resources) + pending = { + index: resource + for index, resource in enumerate(resources) + if ( + resource.content is None + and not resource.existing + and resource.size_bytes <= 0 + ) + } + with ThreadPoolExecutor(max_workers=8) as pool: + futures = { + pool.submit(_remote_size, resource.url): index + for index, resource in pending.items() + } + for future in as_completed(futures): + index = futures[future] + resolved[index] = replace( + pending[index], + size_bytes=future.result(), + ) + return tuple(resolved) + + +def _parse_didemo_hashes(content: bytes) -> dict[str, str]: + mapping: dict[str, str] = {} + for line in content.decode("utf-8").splitlines(): + fields = line.strip().split("\t") + if len(fields) == 2 and fields[0] and fields[1]: + mapping[fields[0]] = fields[1] + if not mapping: + raise ValueError("The pinned DiDeMo YFCC hash map is empty.") + return mapping + + +def _didemo_aws_url(video_name: str, hashes: Mapping[str, str]) -> str: + fields = video_name.split("_") + if len(fields) < 3 or not fields[1]: + raise ValueError(f"Unsupported DiDeMo video name: {video_name}") + try: + digest = hashes[fields[1]] + except KeyError as exc: + raise ValueError( + f"No YFCC hash exists for DiDeMo video {video_name}." + ) from exc + return DIDEMO_AWS_TEMPLATE.format( + first=digest[:3], + second=digest[3:6], + digest=digest, + ) + + +def _quote_powershell(value: Path | str) -> str: + return "'" + str(value).replace("'", "''") + "'" + + +def plan_didemo( + *, + root: str | Path, + split: Literal["validation", "test"], + annotation_indices: Sequence[int] | None, + ffprobe: str, + ffmpeg: str, +) -> PreparationPlan: + runtime_status = inspect_media_runtime( + ffprobe=ffprobe, + ffmpeg=ffmpeg, + ) + if not runtime_status.ready: + raise _application_error( + "benchmark_media_runtime_unavailable", + "DiDeMo preparation requires FFmpeg and ffprobe to validate " + "downloaded videos. Run `vidxp init`, then retry.", + details={ + "errors": list(runtime_status.errors), + "remediation": "vidxp init", + }, + ) + destination = Path(root).expanduser().resolve() + split_name = "val" if split == "validation" else "test" + annotation_sha = ( + DIDEMO_VALIDATION_SHA256 + if split == "validation" + else DIDEMO_TEST_SHA256 + ) + annotation_url = f"{DIDEMO_RAW_ROOT}/data/{split_name}_data.json" + evaluator_url = f"{DIDEMO_RAW_ROOT}/utils/eval.py" + hash_url = f"{DIDEMO_RAW_ROOT}/data/yfcc100m_hash.txt" + annotation_content = _fetch_verified( + annotation_url, + annotation_sha, + f"DiDeMo {split} annotations", + ) + evaluator_content = _fetch_verified( + evaluator_url, + DIDEMO_EVALUATOR_SHA256, + "DiDeMo evaluator", + ) + hash_content = _fetch_verified( + hash_url, + DIDEMO_HASH_MAP_SHA256, + "DiDeMo YFCC hash map", + ) + annotations = select_annotations( + parse_annotations(json.loads(annotation_content)), + annotation_indices, + ) + video_names = tuple( + sorted({str(annotation["video"]) for annotation in annotations}) + ) + hashes = _parse_didemo_hashes(hash_content) + annotations_path = destination / "annotations" / f"{split_name}_data.json" + evaluator_path = destination / "evaluator" / "eval.py" + hash_path = destination / "metadata" / "yfcc100m_hash.txt" + media_directory = destination / "media" + resources: list[PreparationResource] = [ + _artifact_resource( + name=f"DiDeMo {split} annotations", + url=annotation_url, + destination=annotations_path, + expected_sha256=annotation_sha, + content=annotation_content, + ), + _artifact_resource( + name="DiDeMo evaluator", + url=evaluator_url, + destination=evaluator_path, + expected_sha256=DIDEMO_EVALUATOR_SHA256, + content=evaluator_content, + ), + _artifact_resource( + name="DiDeMo YFCC hash map", + url=hash_url, + destination=hash_path, + expected_sha256=DIDEMO_HASH_MAP_SHA256, + content=hash_content, + ), + ] + overrides: dict[str, str] = {} + for video_name in video_names: + if video_name == DIDEMO_KNOWN_REPLACEMENT_VIDEO: + replacement_path = ( + destination + / "replacements" + / "common-starlings-flickr-13482799053.webm" + ) + overrides[video_name] = str( + replacement_path.relative_to(destination) + ) + resources.append( + PreparationResource( + name=f"documented replacement for {video_name}", + url=DIDEMO_KNOWN_REPLACEMENT_URL, + destination=replacement_path, + size_bytes=DIDEMO_KNOWN_REPLACEMENT_SIZE, + kind="media", + expected_sha1=DIDEMO_KNOWN_REPLACEMENT_SHA1, + existing=( + _valid_artifact( + replacement_path, + expected_sha1=DIDEMO_KNOWN_REPLACEMENT_SHA1, + ) + and _valid_media( + replacement_path, + ffprobe=ffprobe, + ffmpeg=ffmpeg, + ) + ), + replacement_for=video_name, + ) + ) + continue + path = media_directory / video_name + resources.append( + PreparationResource( + name=video_name, + url=_didemo_aws_url(video_name, hashes), + destination=path, + size_bytes=0, + kind="media", + existing=_valid_media( + path, + ffprobe=ffprobe, + ffmpeg=ffmpeg, + ), + ) + ) + overrides_path = ( + destination / "media-overrides.json" if overrides else None + ) + if overrides_path is not None: + override_content = ( + json.dumps(overrides, indent=2, sort_keys=True) + "\n" + ).encode("utf-8") + override_sha = hashlib.sha256(override_content).hexdigest() + resources.append( + _artifact_resource( + name="DiDeMo media overrides", + url="documented-local-manifest", + destination=overrides_path, + expected_sha256=override_sha, + content=override_content, + ) + ) + resources = list(_with_remote_sizes(resources)) + command = ( + "vidxp benchmark didemo " + f"--annotations {_quote_powershell(annotations_path)} " + f"--evaluator {_quote_powershell(evaluator_path)} " + f"--media-directory {_quote_powershell(media_directory)} " + f"--split {split} " + ) + if annotation_indices is not None: + command += ( + "--annotation-indices " + + ",".join(str(index) for index in annotation_indices) + + " " + ) + if overrides_path is not None: + command += ( + f"--media-overrides {_quote_powershell(overrides_path)} " + ) + command += f"--run-id didemo-{split}-run" + return PreparationPlan( + benchmark="didemo", + split=split, + root=destination, + resources=tuple(resources), + selected_count=len(annotations), + selected_video_names=video_names, + command=command, + manifest_path=destination / "preparation-manifest.json", + media_directory=media_directory, + media_overrides_path=overrides_path, + ) + + +def plan_hirest( + *, + root: str | Path, + split: Literal["validation", "test"], + pairs: Sequence[tuple[str, str]] | None, +) -> PreparationPlan: + destination = Path(root).expanduser().resolve() + split_name = "val" if split == "validation" else "test" + ground_truth_sha = ( + HIREST_VALIDATION_SHA256 + if split == "validation" + else HIREST_TEST_SHA256 + ) + ground_truth_url = ( + f"{HIREST_RAW_ROOT}/data/splits/all_data_{split_name}.json" + ) + categories_url = f"{HIREST_RAW_ROOT}/data/evaluation/categories.json" + evaluator_url = f"{HIREST_RAW_ROOT}/evaluate.py" + ground_truth_content = _fetch_verified( + ground_truth_url, + ground_truth_sha, + f"HiREST {split} ground truth", + ) + categories_content = _fetch_verified( + categories_url, + HIREST_CATEGORIES_SHA256, + "HiREST categories", + ) + evaluator_content = _fetch_verified( + evaluator_url, + HIREST_EVALUATOR_SHA256, + "HiREST evaluator", + ) + ground_truth = json.loads(ground_truth_content) + _, ordered_pairs = select_ground_truth(ground_truth, pairs) + video_names = tuple(sorted({video for _, video in ordered_pairs})) + ground_truth_path = ( + destination / "annotations" / f"all_data_{split_name}.json" + ) + categories_path = destination / "evaluator" / "categories.json" + evaluator_path = destination / "evaluator" / "evaluate.py" + asr_archive_path = destination / "ASR.zip" + asr_directory = destination / "asr" + resources = [ + _artifact_resource( + name=f"HiREST {split} ground truth", + url=ground_truth_url, + destination=ground_truth_path, + expected_sha256=ground_truth_sha, + content=ground_truth_content, + ), + _artifact_resource( + name="HiREST categories", + url=categories_url, + destination=categories_path, + expected_sha256=HIREST_CATEGORIES_SHA256, + content=categories_content, + ), + _artifact_resource( + name="HiREST evaluator", + url=evaluator_url, + destination=evaluator_path, + expected_sha256=HIREST_EVALUATOR_SHA256, + content=evaluator_content, + ), + PreparationResource( + name="HiREST released ASR", + url=HIREST_ASR_URL, + destination=asr_archive_path, + size_bytes=0, + kind="artifact", + expected_sha256=HIREST_ASR_SHA256, + existing=_valid_artifact( + asr_archive_path, + expected_sha256=HIREST_ASR_SHA256, + ), + ), + ] + pairs_path = None + if pairs is not None: + pairs_path = destination / "selection" / "pairs.json" + pairs_content = ( + json.dumps( + [ + {"prompt": prompt, "video": video} + for prompt, video in pairs + ], + indent=2, + ensure_ascii=False, + ) + + "\n" + ).encode("utf-8") + pairs_sha = hashlib.sha256(pairs_content).hexdigest() + resources.append( + _artifact_resource( + name="HiREST selected pairs", + url="user-declared-selection", + destination=pairs_path, + expected_sha256=pairs_sha, + content=pairs_content, + ) + ) + resources = list(_with_remote_sizes(resources)) + command = ( + "vidxp benchmark hirest " + f"--ground-truth {_quote_powershell(ground_truth_path)} " + f"--categories {_quote_powershell(categories_path)} " + f"--evaluator {_quote_powershell(evaluator_path)} " + f"--asr-archive {_quote_powershell(asr_archive_path)} " + f"--asr-directory {_quote_powershell(asr_directory)} " + f"--split {split} " + ) + if pairs_path is not None: + command += f"--pairs {_quote_powershell(pairs_path)} " + command += f"--run-id hirest-{split}-run" + transcripts_ready = all( + (asr_directory / f"{Path(video).stem}.srt").is_file() + and (asr_directory / f"{Path(video).stem}.srt").stat().st_size > 0 + for video in video_names + ) + return PreparationPlan( + benchmark="hirest", + split=split, + root=destination, + resources=tuple(resources), + selected_count=len(ordered_pairs), + selected_video_names=video_names, + command=command, + manifest_path=destination / "preparation-manifest.json", + asr_directory=asr_directory, + extraction_reserve_bytes=( + 0 if transcripts_ready else HIREST_ASR_UNCOMPRESSED_SIZE + ), + ) + + +def _download_resource( + resource: PreparationResource, + *, + ffprobe: str, + ffmpeg: str, + progress: ProgressCallback | None, +) -> str: + if resource.existing: + return "reused" + resource.destination.parent.mkdir(parents=True, exist_ok=True) + if resource.content is not None: + temporary = resource.destination.with_name( + resource.destination.name + ".part" + ) + temporary.write_bytes(resource.content) + else: + temporary = resource.destination.with_name( + resource.destination.name + ".part" + ) + start = temporary.stat().st_size if temporary.is_file() else 0 + if start > resource.size_bytes: + temporary.unlink() + start = 0 + if start < resource.size_bytes: + request = _request(resource.url, range_start=start) + try: + with urllib.request.urlopen(request, timeout=60) as response: + status = getattr(response, "status", response.getcode()) + if start and status != 206: + start = 0 + mode = "ab" if start and status == 206 else "wb" + with temporary.open(mode) as stream: + while chunk := response.read(1024 * 1024): + stream.write(chunk) + if progress is not None: + progress(resource.name, len(chunk)) + except (OSError, urllib.error.URLError) as exc: + raise _application_error( + "benchmark_download_failed", + f"Download failed for {resource.name}.", + details={ + "url": resource.url, + "partial_path": str(temporary), + "reason": str(exc), + }, + ) from exc + if temporary.stat().st_size != resource.size_bytes: + raise ValueError( + f"{resource.name} has {temporary.stat().st_size} bytes; " + f"expected {resource.size_bytes}." + ) + if resource.expected_sha256: + observed = sha256_file(temporary) + if observed != resource.expected_sha256: + raise ValueError( + f"{resource.name} SHA-256 was {observed}; " + f"expected {resource.expected_sha256}." + ) + if resource.expected_sha1: + observed = _sha1_file(temporary) + if observed != resource.expected_sha1: + raise ValueError( + f"{resource.name} SHA-1 was {observed}; " + f"expected {resource.expected_sha1}." + ) + if resource.kind == "media": + _validate_media(temporary, ffprobe=ffprobe, ffmpeg=ffmpeg) + os.replace(temporary, resource.destination) + return "downloaded" + + +def _extract_hirest_asr( + archive: Path, + destination: Path, + video_names: Sequence[str], +) -> int: + destination.mkdir(parents=True, exist_ok=True) + expected = {f"{Path(video).stem}.srt" for video in video_names} + extracted = 0 + with zipfile.ZipFile(archive) as bundle: + members: dict[str, zipfile.ZipInfo] = {} + for info in bundle.infolist(): + name = Path(info.filename).name + if name in expected: + if name in members: + raise ValueError( + f"HiREST ASR archive contains duplicate {name}." + ) + members[name] = info + missing = sorted(expected - set(members)) + if missing: + raise ValueError( + "HiREST ASR archive is missing selected transcripts: " + + ", ".join(missing[:10]) + ) + for name, info in members.items(): + target = destination / name + if target.is_file() and target.stat().st_size > 0: + continue + with ( + bundle.open(info) as source, + tempfile.NamedTemporaryFile( + dir=destination, + prefix=name + ".", + suffix=".part", + delete=False, + ) as temporary, + ): + while chunk := source.read(1024 * 1024): + temporary.write(chunk) + temporary_path = Path(temporary.name) + os.replace(temporary_path, target) + extracted += 1 + return extracted + + +def execute_preparation( + plan: PreparationPlan, + *, + ffprobe: str = "ffprobe", + ffmpeg: str = "ffmpeg", + progress: ProgressCallback | None = None, +) -> dict[str, Any]: + plan.root.mkdir(parents=True, exist_ok=True) + outcomes: dict[str, str] = {} + failures: list[dict[str, str]] = [] + for resource in plan.resources: + try: + outcomes[resource.name] = _download_resource( + resource, + ffprobe=ffprobe, + ffmpeg=ffmpeg, + progress=progress, + ) + except Exception as exc: + failures.append( + {"resource": resource.name, "error": str(exc)} + ) + extracted = 0 + if not failures and plan.benchmark == "hirest": + archive = next( + resource.destination + for resource in plan.resources + if resource.expected_sha256 == HIREST_ASR_SHA256 + ) + try: + extracted = _extract_hirest_asr( + archive, + plan.asr_directory or plan.root / "asr", + plan.selected_video_names, + ) + except Exception as exc: + failures.append({"resource": "HiREST ASR extraction", "error": str(exc)}) + manifest = { + "schema_version": 1, + "status": "failed" if failures else "ready", + "created_at": utc_now(), + "benchmark": plan.benchmark, + "split": plan.split, + "root": str(plan.root), + "selected_count": plan.selected_count, + "selected_video_count": len(plan.selected_video_names), + "planned_max_additional_bytes": plan.additional_bytes, + "resources": [resource.public_record() for resource in plan.resources], + "outcomes": outcomes, + "failures": failures, + "asr_transcripts_extracted": extracted, + "command": plan.command, + } + write_json_atomic(plan.manifest_path, manifest) + if failures: + raise _application_error( + "benchmark_preparation_failed", + "Benchmark preparation did not complete. Partial downloads were " + "kept for a resumable retry.", + details={ + "manifest": str(plan.manifest_path), + "failures": failures, + }, + ) + return manifest diff --git a/src/vidxp/branding.py b/src/vidxp/branding.py new file mode 100644 index 0000000..9abae12 --- /dev/null +++ b/src/vidxp/branding.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import base64 +from functools import lru_cache +from importlib.resources import files +from pathlib import Path + + +PROJECT_URL = "https://github.com/grayhatdevelopers/vidxp" +ICON_MIME_TYPE = "image/png" +ICON_SIZE = "303x303" + + +def icon_path() -> str: + """Return the canonical icon in source or its installed wheel copy.""" + + packaged_icon = files("vidxp").joinpath("assets", "icon.png") + if packaged_icon.is_file(): + return str(packaged_icon) + + source_icon = ( + Path(__file__).resolve().parents[2] / "docs" / "images" / "logo.png" + ) + if source_icon.is_file(): + return str(source_icon) + raise FileNotFoundError("VidXP icon is missing from the installation.") + + +@lru_cache(maxsize=1) +def icon_bytes() -> bytes: + return Path(icon_path()).read_bytes() + + +@lru_cache(maxsize=1) +def icon_data_uri() -> str: + encoded = base64.b64encode(icon_bytes()).decode("ascii") + return f"data:{ICON_MIME_TYPE};base64,{encoded}" diff --git a/src/vidxp/capabilities/__init__.py b/src/vidxp/capabilities/__init__.py index 381c275..5902c1f 100644 --- a/src/vidxp/capabilities/__init__.py +++ b/src/vidxp/capabilities/__init__.py @@ -1,15 +1,11 @@ -"""Built-in VidXP capabilities and their explicit registry.""" +"""Capability contracts and registry construction.""" from vidxp.capabilities.registry import ( - CAPABILITIES, - capability_names, - get_capability, - index_capability_names, + CapabilityRegistry, + create_capability_registry, ) __all__ = [ - "CAPABILITIES", - "capability_names", - "get_capability", - "index_capability_names", + "CapabilityRegistry", + "create_capability_registry", ] diff --git a/src/vidxp/capabilities/actor/config.py b/src/vidxp/capabilities/actor/config.py index 8756fd0..9a11ae7 100644 --- a/src/vidxp/capabilities/actor/config.py +++ b/src/vidxp/capabilities/actor/config.py @@ -8,8 +8,8 @@ class ActorConfig(CapabilityConfig): batch_size: int = Field(default=16, gt=0) - match_threshold: float = Field(default=0.55, gt=0, lt=1) - num_jitters: int = Field(default=2, gt=0) + match_threshold: float = Field(default=0.363, gt=0, lt=1) + detection_threshold: float = Field(default=0.9, gt=0, lt=1) minimum_detections: int = Field(default=4, gt=0) diff --git a/src/vidxp/capabilities/actor/definition.py b/src/vidxp/capabilities/actor/definition.py index 690005d..f8e1e1c 100644 --- a/src/vidxp/capabilities/actor/definition.py +++ b/src/vidxp/capabilities/actor/definition.py @@ -4,25 +4,51 @@ from vidxp.capabilities.actor.config import ActorConfig, actor_config from vidxp.capabilities.actor.indexing import VISUAL_PROCESSOR +from vidxp.capabilities.actor.models import ( + get_actor_models, +) from vidxp.capabilities.actor.operations import ( + cluster_operation, clusters_operation, detections_operation, - render_operation, ) +from vidxp.capabilities.actor.specs import SFACE_MODEL, YUNET_MODEL from vidxp.capabilities.contracts import ( CapabilityDefinition, + CapabilityExecutor, + CapabilityPlugin, OperationDefinition, + PreparationContext, + module_import_check, ) from vidxp.capabilities.actor.schemas import ( + ActorClusterInput, + ActorClusterSummary, ActorClustersInput, ActorClustersOutput, ActorDetectionsInput, ActorDetectionsOutput, - ActorRenderInput, - ActorRenderResult, ) from vidxp.capabilities.visual import index_capabilities from vidxp.core.contracts import IndexConfig, VideoSource +from vidxp.core.indexing_common import ProgressCallback + + +def prepare_models( + context: PreparationContext, + progress: ProgressCallback | None, +) -> tuple[str, ...]: + if progress is not None: + progress( + { + "state": "preparing", + "stage": "actor_models", + "message": "Preparing OpenCV Zoo YuNet and SFace models.", + } + ) + get_actor_models(context.runtime, download=True, progress=progress) + return (YUNET_MODEL.filename, SFACE_MODEL.filename) + def model_manifest( config: IndexConfig, @@ -31,47 +57,69 @@ def model_manifest( settings = actor_config(config) return { "actor": { - "library": "face_recognition", + "models": { + "detector": YUNET_MODEL.identity(), + "recognizer": SFACE_MODEL.identity(), + }, "match_threshold": settings.match_threshold, - "num_jitters": settings.num_jitters, + "detection_threshold": settings.detection_threshold, "minimum_detections": settings.minimum_detections, } } -def cli_app(): - from vidxp.capabilities.actor.cli import app - - return app - - DEFINITION = CapabilityDefinition( name="actor", description="Index, inspect, and render actor clusters.", extra="actor", config_model=ActorConfig, collection_name="actor", - indexer=index_capabilities, - index_processor=VISUAL_PROCESSOR, index_stage="visual_indexing", - model_manifest=model_manifest, + execution_group="visual", + prepares_models=True, + model_specs=(YUNET_MODEL, SFACE_MODEL), operations={ + "cluster": OperationDefinition( + input_model=ActorClusterInput, + output_model=ActorClusterSummary, + public=False, + ), "clusters": OperationDefinition( input_model=ActorClustersInput, output_model=ActorClustersOutput, - handler=clusters_operation, ), "detections": OperationDefinition( input_model=ActorDetectionsInput, output_model=ActorDetectionsOutput, - handler=detections_operation, - ), - "render": OperationDefinition( - input_model=ActorRenderInput, - output_model=ActorRenderResult, - handler=render_operation, ), }, - cli_name="actors", - cli_factory=cli_app, +) + + +def create_executor() -> CapabilityExecutor: + return CapabilityExecutor( + indexer=index_capabilities, + index_processor=VISUAL_PROCESSOR, + operations={ + "cluster": cluster_operation, + "clusters": clusters_operation, + "detections": detections_operation, + }, + model_manifest=model_manifest, + prepare=prepare_models, + runtime_checks=( + module_import_check( + "OpenCV import", + "cv2", + "FaceDetectorYN", + "FaceRecognizerSF", + ), + module_import_check("Pooch import", "pooch", "retrieve"), + ), + ) + + +PLUGIN = CapabilityPlugin( + definition=DEFINITION, + executor_factory=create_executor, ) diff --git a/src/vidxp/capabilities/actor/indexing.py b/src/vidxp/capabilities/actor/indexing.py index 7328d10..35da06c 100644 --- a/src/vidxp/capabilities/actor/indexing.py +++ b/src/vidxp/capabilities/actor/indexing.py @@ -4,6 +4,7 @@ from typing import Any from vidxp.capabilities.actor.config import actor_config +from vidxp.capabilities.actor.models import ActorModels, get_actor_models from vidxp.core.contracts import ( CancellationToken, IndexConfig, @@ -12,29 +13,48 @@ stable_source_id, ) from vidxp.core.indexing_common import ProgressCallback -from vidxp.core.storage import IndexStorage +from vidxp.core.video import FrameSampling +from vidxp.ports import IndexStore, ModelRuntimePort @dataclass class ActorIndexState: + models: ActorModels known_encodings: list[Any] = field(default_factory=list) known_ids: list[str] = field(default_factory=list) histories: dict[str, list[Any]] = field(default_factory=dict) cluster_sizes: dict[str, int] = field(default_factory=dict) + cluster_ranges: dict[str, tuple[float, float]] = field( + default_factory=dict + ) processed_frames: int = 0 -def _best_face_match( - face_recognition, - known_encodings, - encoding, - threshold, -): +def _best_face_match(known_encodings, encoding, threshold): + import numpy as np + if not known_encodings: return None - distances = face_recognition.face_distance(known_encodings, encoding) - match = int(distances.argmin()) - return match if distances[match] < threshold else None + similarities = np.asarray(known_encodings) @ encoding + match = int(similarities.argmax()) + return match if similarities[match] >= threshold else None + + +def _actor_cluster_id( + config: IndexConfig, + local_cluster_id: str | int, +) -> str: + if config.video_id is None: + raise ValueError( + "IndexConfig.video_id is required for actor cluster identity." + ) + return stable_source_id( + config.run_id, + config.video_id, + "actor-cluster", + local_cluster_id, + generation_id=config.generation_id, + ) def _actor_records( @@ -48,6 +68,7 @@ def _actor_records( str(config.video_id), "actor", detection["detection_id"], + generation_id=config.generation_id, ) top, right, bottom, left = detection["bbox"] records.append( @@ -70,15 +91,48 @@ def _actor_records( return records +def _actor_cluster_records( + cluster_sizes: dict[str, int], + cluster_ranges: dict[str, tuple[float, float]], + config: IndexConfig, +) -> list[StorageRecord]: + records = [] + for cluster_id in sorted(cluster_sizes): + size = cluster_sizes[cluster_id] + first_timestamp, last_timestamp = cluster_ranges[cluster_id] + source_id = stable_source_id( + config.run_id, + str(config.video_id), + "actor-cluster-summary", + cluster_id, + generation_id=config.generation_id, + ) + records.append( + StorageRecord( + source_id=source_id, + embedding=[0.0], + metadata={ + **config.record_identity("actor", source_id), + "record_kind": "cluster_summary", + "summary_cluster_id": cluster_id, + "detection_count": size, + "first_timestamp": first_timestamp, + "last_timestamp": last_timestamp, + }, + ) + ) + return records + + def process_actor_samples( samples, *, state: ActorIndexState, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, cancellation: CancellationToken, ) -> None: - import face_recognition + import cv2 import numpy as np settings = actor_config(config) @@ -87,23 +141,34 @@ def process_actor_samples( detections = [] for sample in group: cancellation.raise_if_cancelled() - locations = face_recognition.face_locations(sample.frame) - encodings = face_recognition.face_encodings( - sample.frame, - locations, - num_jitters=settings.num_jitters, + frame = cv2.cvtColor(sample.frame, cv2.COLOR_RGB2BGR) + height, width = frame.shape[:2] + state.models.detector.setInputSize((width, height)) + state.models.detector.setScoreThreshold( + settings.detection_threshold ) - for ordinal, (encoding, location) in enumerate( - zip(encodings, locations) - ): + _, faces = state.models.detector.detect(frame) + for ordinal, face in enumerate(faces if faces is not None else ()): + aligned = state.models.recognizer.alignCrop(frame, face) + encoding = ( + state.models.recognizer.feature(aligned) + .flatten() + .astype("float32") + ) + norm = float(np.linalg.norm(encoding)) + if norm == 0: + continue + encoding /= norm match = _best_face_match( - face_recognition, state.known_encodings, encoding, settings.match_threshold, ) if match is None: - cluster_id = str(len(state.known_ids) + 1) + cluster_id = _actor_cluster_id( + config, + len(state.known_ids) + 1, + ) state.known_ids.append(cluster_id) state.known_encodings.append(encoding) state.histories[cluster_id] = [encoding] @@ -113,10 +178,23 @@ def process_actor_samples( history.append(encoding) if len(history) > 5: history.pop(0) - state.known_encodings[match] = np.mean(history, axis=0) + centroid = np.mean(history, axis=0) + state.known_encodings[match] = ( + centroid / np.linalg.norm(centroid) + ) state.cluster_sizes[cluster_id] = ( state.cluster_sizes.get(cluster_id, 0) + 1 ) + previous_range = state.cluster_ranges.get(cluster_id) + timestamp = float(sample.timestamp) + state.cluster_ranges[cluster_id] = ( + timestamp + if previous_range is None + else min(previous_range[0], timestamp), + timestamp + if previous_range is None + else max(previous_range[1], timestamp), + ) detections.append( { "detection_id": ( @@ -125,7 +203,12 @@ def process_actor_samples( "cluster_id": cluster_id, "frame_index": sample.frame_index, "timestamp": sample.timestamp, - "bbox": tuple(int(value) for value in location), + "bbox": ( + max(0, int(face[1])), + min(width, int(face[0] + face[2])), + min(height, int(face[1] + face[3])), + max(0, int(face[0])), + ), } ) state.processed_frames += 1 @@ -141,7 +224,7 @@ def finalize_actor_index( state: ActorIndexState, *, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, ) -> tuple[int, int]: settings = actor_config(config) rejected = [ @@ -160,19 +243,36 @@ def finalize_actor_index( for cluster_id, size in state.cluster_sizes.items() if size >= settings.minimum_detections } + storage.upsert( + "actor", + _actor_cluster_records( + retained, + { + cluster_id: state.cluster_ranges[cluster_id] + for cluster_id in retained + }, + config, + ), + batch_size=config.storage_batch_size, + cancellation=CancellationToken(), + ) return sum(retained.values()), len(retained) class ActorVisualProcessor: + def sampling(self, config: IndexConfig, info) -> FrameSampling: + return FrameSampling(frame_stride=config.frame_stride) + def batch_size(self, config: IndexConfig) -> int: return actor_config(config).batch_size def prepare( self, config: IndexConfig, + runtime: ModelRuntimePort, progress: ProgressCallback | None, ) -> ActorIndexState: - return ActorIndexState() + return ActorIndexState(models=get_actor_models(runtime)) def process( self, @@ -181,7 +281,7 @@ def process( state: ActorIndexState, info, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, cancellation: CancellationToken, ) -> None: process_actor_samples( @@ -197,7 +297,7 @@ def finalize( state: ActorIndexState, *, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, ) -> tuple[dict[str, Any], int]: detections, clusters = finalize_actor_index( state, diff --git a/src/vidxp/capabilities/actor/models.py b/src/vidxp/capabilities/actor/models.py new file mode 100644 index 0000000..3673fd8 --- /dev/null +++ b/src/vidxp/capabilities/actor/models.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + +from vidxp.ports import ModelRuntimePort +from vidxp.capabilities.actor.specs import ( + SFACE_MODEL, + YUNET_MODEL, + actor_model_key, +) + + +@dataclass(frozen=True) +class ActorModels: + detector: Any + recognizer: Any + + +def get_actor_models( + runtime: ModelRuntimePort, + *, + download: bool = False, + progress: Callable[[dict[str, Any]], None] | None = None, +) -> ActorModels: + key = actor_model_key(runtime.device_for("actor")) + + def load() -> ActorModels: + import cv2 + + detector_path = runtime.resolve_artifact( + YUNET_MODEL, + download=download, + progress=progress, + ) + recognizer_path = runtime.resolve_artifact( + SFACE_MODEL, + download=download, + progress=progress, + ) + if progress is not None: + progress( + { + "state": "preparing", + "stage": "loading_model", + "message": "Loading OpenCV Zoo YuNet and SFace models.", + } + ) + models = ActorModels( + detector=cv2.FaceDetectorYN.create( + str(detector_path), + "", + (320, 320), + score_threshold=0.9, + nms_threshold=0.3, + top_k=5000, + ), + recognizer=cv2.FaceRecognizerSF.create(str(recognizer_path), ""), + ) + runtime.record_compute_precision( + YUNET_MODEL.capability, + YUNET_MODEL.weights_precision, + ) + runtime.record_compute_precision( + SFACE_MODEL.capability, + SFACE_MODEL.weights_precision, + ) + return models + + return runtime.get_or_load(key, load) diff --git a/src/vidxp/capabilities/actor/operations.py b/src/vidxp/capabilities/actor/operations.py index 88b7276..4846994 100644 --- a/src/vidxp/capabilities/actor/operations.py +++ b/src/vidxp/capabilities/actor/operations.py @@ -1,27 +1,42 @@ from __future__ import annotations from vidxp.capabilities.actor.results import ( + actor_cluster, actor_clusters, actor_detections, - render_actor_result, ) -from vidxp.capabilities.contracts import CapabilityContext from vidxp.capabilities.actor.schemas import ( + ActorClusterInput, + ActorClusterSummary, ActorClustersInput, ActorClustersOutput, ActorDetectionsInput, ActorDetectionsOutput, - ActorRenderInput, - ActorRenderResult, ) +from vidxp.capabilities.contracts import CapabilityContext + + +def cluster_operation( + context: CapabilityContext, + request: ActorClusterInput, +) -> ActorClusterSummary: + return actor_cluster( + context.require_config(), + request.cluster_id, + storage=context.require_storage(), + ) def clusters_operation( context: CapabilityContext, _request: ActorClustersInput, ) -> ActorClustersOutput: - return ActorClustersOutput( - clusters=actor_clusters(context.require_config()) + return actor_clusters( + context.require_config(), + storage=context.require_storage(), + page_size=_request.page_size, + cursor=_request.cursor, + media_id=_request.media_id, ) @@ -30,23 +45,10 @@ def detections_operation( request: ActorDetectionsInput, ) -> ActorDetectionsOutput: config = context.require_config() - return ActorDetectionsOutput( - cluster_id=request.cluster_id, - detections=actor_detections( - config, - request.cluster_id, - ), - ) - - -def render_operation( - context: CapabilityContext, - request: ActorRenderInput, -) -> ActorRenderResult: - config = context.require_config() - return render_actor_result( + return actor_detections( config, request.cluster_id, - request.input_path, - request.output_path, + storage=context.require_storage(), + page_size=request.page_size, + cursor=request.cursor, ) diff --git a/src/vidxp/capabilities/actor/requirements.txt b/src/vidxp/capabilities/actor/requirements.txt index 383f8ac..a737e1f 100644 --- a/src/vidxp/capabilities/actor/requirements.txt +++ b/src/vidxp/capabilities/actor/requirements.txt @@ -1,3 +1,3 @@ -face-recognition -numpy>=2.1,<3 -opencv-python +numpy>=2.3,<3 +opencv-python-headless>=5.0.0.93,<6 +pooch>=1.9,<2 diff --git a/src/vidxp/capabilities/actor/results.py b/src/vidxp/capabilities/actor/results.py index 1ab4386..fbdf17f 100644 --- a/src/vidxp/capabilities/actor/results.py +++ b/src/vidxp/capabilities/actor/results.py @@ -1,116 +1,297 @@ from __future__ import annotations -from pathlib import Path - from vidxp.capabilities.actor.schemas import ( ActorClusterSummary, + ActorClustersOutput, ActorDetection, - ActorRenderResult, + ActorDetectionsOutput, ) +from vidxp.capabilities.contracts import CapabilityRequestError from vidxp.core.contracts import IndexConfig -from vidxp.core.storage import IndexStorage -from vidxp.core.video import render_actor_video +from vidxp.core.cursors import ( + CursorError, + decode_cursor, + decode_offset_cursor, + encode_offset_cursor, +) +from vidxp.ports import IndexReader class ActorClusterNotFoundError(LookupError): """Raised when an actor cluster has no retained detections.""" -def actor_clusters( - config: IndexConfig, - *, - storage: IndexStorage | None = None, -) -> tuple[ActorClusterSummary, ...]: - if config.video_id is None: - raise ValueError("IndexConfig.video_id is required for actor results.") - owns_storage = storage is None - active_storage = storage or IndexStorage(config) +def _decode_cursor(cursor: str | None, scope: str) -> int: + if cursor is None: + return 0 try: - records = active_storage.records( - "actor", - video_id=config.video_id, + payload = decode_cursor(cursor, scope) + if set(payload) != {"version", "scope", "offset"}: + raise CursorError("The cursor payload is invalid.") + return decode_offset_cursor(cursor, scope=scope) + except CursorError as exc: + raise CapabilityRequestError("The actor cursor is invalid.") from exc + + +def _encode_cursor( + offset: int, + *, + scope: str, + has_more: bool, +) -> str | None: + return encode_offset_cursor( + offset, + scope=scope, + has_more=has_more, + ) + + +def _snapshot_scope(config: IndexConfig) -> str: + return str(config.snapshot_id or config.fingerprint()) + + +def _actor_detection(record: dict) -> ActorDetection: + return ActorDetection( + **{ + key: value + for key, value in record.items() + if not key.startswith("bbox_") and key != "video_id" + }, + media_id=str(record["video_id"]), + bbox=( + int(record["bbox_top"]), + int(record["bbox_right"]), + int(record["bbox_bottom"]), + int(record["bbox_left"]), + ), + ) + + +def _cluster_summary( + cluster_id: str, + records: list[dict], +) -> ActorClusterSummary: + if not records: + raise ActorClusterNotFoundError( + f"Actor cluster {cluster_id} was not found in the completed index." ) - finally: - if owns_storage: - active_storage.close() - - grouped: dict[str, list[dict]] = {} - for record in records: - grouped.setdefault(str(record["cluster_id"]), []).append(record) - return tuple( - ActorClusterSummary( - cluster_id=cluster_id, - video_id=config.video_id, - detection_count=len(cluster_records), - first_timestamp=min( - float(record["timestamp"]) for record in cluster_records - ), - last_timestamp=max( - float(record["timestamp"]) for record in cluster_records - ), + media_ids = {str(record["video_id"]) for record in records} + generation_ids = { + str(record["generation_id"]) + for record in records + if record.get("generation_id") is not None + } + if len(media_ids) != 1 or len(generation_ids) != 1: + raise RuntimeError( + f"Actor cluster {cluster_id!r} spans multiple index identities." ) - for cluster_id, cluster_records in sorted(grouped.items()) + timestamps = [float(record["timestamp"]) for record in records] + return ActorClusterSummary( + cluster_id=cluster_id, + media_id=next(iter(media_ids)), + generation_id=next(iter(generation_ids)), + detection_count=len(records), + first_timestamp=min(timestamps), + last_timestamp=max(timestamps), ) -def actor_detections( +def _stored_cluster_summary(record: dict) -> ActorClusterSummary: + if record.get("record_kind") != "cluster_summary": + raise RuntimeError("The actor cluster summary record is invalid.") + return ActorClusterSummary( + cluster_id=str(record["summary_cluster_id"]), + media_id=str(record["video_id"]), + generation_id=str(record["generation_id"]), + detection_count=int(record["detection_count"]), + first_timestamp=float(record["first_timestamp"]), + last_timestamp=float(record["last_timestamp"]), + ) + + +def actor_cluster( config: IndexConfig, cluster_id: str, *, - storage: IndexStorage | None = None, -) -> list[ActorDetection]: - if config.video_id is None: - raise ValueError("IndexConfig.video_id is required for actor results.") - owns_storage = storage is None - active_storage = storage or IndexStorage(config) - try: - records = active_storage.records( + storage: IndexReader, +) -> ActorClusterSummary: + del config + summaries = storage.records( + "actor", + filters={ + "record_kind": "cluster_summary", + "summary_cluster_id": cluster_id, + }, + limit=2, + ) + if len(summaries) > 1: + raise RuntimeError( + f"Actor cluster {cluster_id!r} spans multiple index identities." + ) + if summaries: + return _stored_cluster_summary(summaries[0]) + return _cluster_summary( + cluster_id, + storage.records( "actor", - video_id=config.video_id, filters={"cluster_id": cluster_id}, + ), + ) + + +def actor_clusters( + config: IndexConfig, + *, + storage: IndexReader, + page_size: int, + cursor: str | None, + media_id: str | None = None, +) -> ActorClustersOutput: + scope = ( + f"actor:clusters:{_snapshot_scope(config)}:" + f"{media_id or '*'}" + ) + offset = _decode_cursor(cursor, scope) + media_scope = {"video_id": media_id} if media_id is not None else {} + records = storage.records( + "actor", + filters={"record_kind": "cluster_summary"}, + limit=page_size + 1, + offset=offset, + **media_scope, + ) + if records: + has_more = len(records) > page_size + summaries = tuple( + _stored_cluster_summary(record) + for record in records[:page_size] ) - finally: - if owns_storage: - active_storage.close() - - detections = [ - ActorDetection( - **{ - key: value - for key, value in record.items() - if not key.startswith("bbox_") - }, - bbox=( - int(record["bbox_top"]), - int(record["bbox_right"]), - int(record["bbox_bottom"]), - int(record["bbox_left"]), + return ActorClustersOutput( + clusters=summaries, + total=None, + next_cursor=_encode_cursor( + offset + len(summaries), + scope=scope, + has_more=has_more, ), ) - for record in records - ] - return sorted( - detections, - key=lambda item: (item.frame_index, item.detection_id), + if offset: + raise CapabilityRequestError( + "The actor cursor is outside the result set." + ) + + # Indexes created before cluster summaries were materialized remain + # readable, but must use the legacy full-scan path once. + grouped: dict[str, dict] = {} + storage_offset = 0 + while True: + records = storage.records( + "actor", + limit=1000, + offset=storage_offset, + **media_scope, + ) + for record in records: + cluster_id = str(record["cluster_id"]) + timestamp = float(record["timestamp"]) + summary = grouped.setdefault( + cluster_id, + { + "media_ids": set(), + "generation_ids": set(), + "detection_count": 0, + "first_timestamp": timestamp, + "last_timestamp": timestamp, + }, + ) + summary["media_ids"].add(str(record["video_id"])) + if record.get("generation_id") is not None: + summary["generation_ids"].add(str(record["generation_id"])) + summary["detection_count"] += 1 + summary["first_timestamp"] = min( + summary["first_timestamp"], + timestamp, + ) + summary["last_timestamp"] = max( + summary["last_timestamp"], + timestamp, + ) + if len(records) < 1000: + break + storage_offset += len(records) + + summaries = [] + for cluster_id, summary in sorted(grouped.items()): + media_ids = summary["media_ids"] + generation_ids = summary["generation_ids"] + if len(media_ids) != 1 or len(generation_ids) != 1: + raise RuntimeError( + f"Actor cluster {cluster_id!r} spans multiple index identities." + ) + summaries.append( + ActorClusterSummary( + cluster_id=cluster_id, + media_id=next(iter(media_ids)), + generation_id=next(iter(generation_ids)), + detection_count=summary["detection_count"], + first_timestamp=summary["first_timestamp"], + last_timestamp=summary["last_timestamp"], + ) + ) + + total = len(summaries) + if offset > total: + raise CapabilityRequestError( + "The actor cursor is outside the result set." + ) + selected = tuple(summaries[offset : offset + page_size]) + return ActorClustersOutput( + clusters=selected, + total=total, + next_cursor=_encode_cursor( + offset + len(selected), + scope=scope, + has_more=offset + len(selected) < total, + ), ) -def render_actor_result( +def actor_detections( config: IndexConfig, cluster_id: str, - input_path: str | Path, - output_path: str | Path, *, - storage: IndexStorage | None = None, -) -> ActorRenderResult: - detections = actor_detections(config, cluster_id, storage=storage) - if not detections: + storage: IndexReader, + page_size: int, + cursor: str | None, +) -> ActorDetectionsOutput: + scope = f"actor:detections:{_snapshot_scope(config)}:{cluster_id}" + storage_offset = _decode_cursor(cursor, scope) + filters = {"cluster_id": cluster_id} + records = storage.records( + "actor", + filters=filters, + limit=page_size + 1, + offset=storage_offset, + ) + if not records and storage_offset == 0: raise ActorClusterNotFoundError( f"Actor cluster {cluster_id} was not found in the completed index." ) - destination = Path(output_path) - render_actor_video(input_path, destination, cluster_id, detections) - return ActorRenderResult( - output_path=destination, - detection_count=len(detections), + if not records: + raise CapabilityRequestError( + "The actor cursor is outside the result set." + ) + has_more = len(records) > page_size + detections = tuple( + _actor_detection(record) for record in records[:page_size] + ) + return ActorDetectionsOutput( + cluster_id=cluster_id, + detections=detections, + total=None, + next_cursor=_encode_cursor( + storage_offset + len(detections), + scope=scope, + has_more=has_more, + ), ) diff --git a/src/vidxp/capabilities/actor/schemas.py b/src/vidxp/capabilities/actor/schemas.py index 26fcea8..9867003 100644 --- a/src/vidxp/capabilities/actor/schemas.py +++ b/src/vidxp/capabilities/actor/schemas.py @@ -1,60 +1,73 @@ from __future__ import annotations -from pathlib import Path - -from pydantic import Field +from pydantic import Field, model_validator from vidxp.capabilities.contracts import CapabilityInput, CapabilityOutput +from vidxp.core.identifiers import ( + ActorClusterId, + Identifier, + IndexGenerationId, + MediaId, +) class ActorClustersInput(CapabilityInput): - pass + page_size: int = Field(default=50, gt=0, le=100) + cursor: str | None = Field(default=None, min_length=1, max_length=512) + media_id: MediaId | None = None + + +class ActorClusterInput(CapabilityInput): + cluster_id: ActorClusterId class ActorClusterSummary(CapabilityOutput): - cluster_id: str = Field(min_length=1) - video_id: str = Field(min_length=1) + cluster_id: ActorClusterId + media_id: MediaId + generation_id: IndexGenerationId detection_count: int = Field(ge=0) first_timestamp: float = Field(ge=0) last_timestamp: float = Field(ge=0) + @model_validator(mode="after") + def _validate_interval(self) -> "ActorClusterSummary": + if self.last_timestamp < self.first_timestamp: + raise ValueError("last_timestamp must not precede first_timestamp") + return self + def to_dict(self) -> dict: return self.model_dump(mode="json") class ActorClustersOutput(CapabilityOutput): clusters: tuple[ActorClusterSummary, ...] = () + total: int | None = Field(default=None, ge=0) + next_cursor: str | None = None class ActorDetectionsInput(CapabilityInput): - cluster_id: str = Field(min_length=1) + cluster_id: ActorClusterId + page_size: int = Field(default=50, gt=0, le=100) + cursor: str | None = Field(default=None, min_length=1, max_length=512) class ActorDetection(CapabilityOutput): - detection_id: str = Field(min_length=1) - cluster_id: str = Field(min_length=1) + detection_id: Identifier + cluster_id: ActorClusterId frame_index: int = Field(ge=0) timestamp: float = Field(ge=0) bbox: tuple[int, int, int, int] dataset: str split: str run_id: str - video_id: str + media_id: MediaId + generation_id: IndexGenerationId modality: str source_id: str class ActorDetectionsOutput(CapabilityOutput): - cluster_id: str = Field(min_length=1) + cluster_id: ActorClusterId detections: tuple[ActorDetection, ...] = () - - -class ActorRenderInput(CapabilityInput): - cluster_id: str = Field(min_length=1) - input_path: Path - output_path: Path - - -class ActorRenderResult(CapabilityOutput): - output_path: Path - detection_count: int = Field(gt=0) + total: int | None = Field(default=None, ge=0) + next_cursor: str | None = None diff --git a/src/vidxp/capabilities/actor/specs.py b/src/vidxp/capabilities/actor/specs.py new file mode 100644 index 0000000..e42b773 --- /dev/null +++ b/src/vidxp/capabilities/actor/specs.py @@ -0,0 +1,51 @@ +from vidxp.model_contracts import ArtifactSpec, ModelKey + + +OPENCV_ZOO_REVISION = "47534e27c9851bb1128ccc0102f1145e27f23f98" +_MODEL_ROOT = ( + "https://media.githubusercontent.com/media/opencv/opencv_zoo/" + f"{OPENCV_ZOO_REVISION}/models" +) + +YUNET_MODEL = ArtifactSpec( + capability="actor.detector", + provider="opencv-zoo", + model_id="yunet", + revision=OPENCV_ZOO_REVISION, + download_size_bytes=229_738, + url=f"{_MODEL_ROOT}/face_detection_yunet/" + "face_detection_yunet_2026may.onnx", + filename="face_detection_yunet_2026may.onnx", + sha256="ebafce4e3c118d6554634be5c27ab333b4c047a9a8c3faf1d7cf93101c22f0f0", + license="MIT", + weights_precision="float32", +) + +SFACE_MODEL = ArtifactSpec( + capability="actor.recognizer", + provider="opencv-zoo", + model_id="sface", + revision=OPENCV_ZOO_REVISION, + download_size_bytes=38_696_353, + url=f"{_MODEL_ROOT}/face_recognition_sface/" + "face_recognition_sface_2021dec.onnx", + filename="face_recognition_sface_2021dec.onnx", + sha256="0ba9fbfa01b5270c96627c4ef784da859931e02f04419c829e83484087c34e79", + license="Apache-2.0", + weights_precision="float32", +) + + +def actor_model_key(device: str) -> ModelKey: + specs = (YUNET_MODEL, SFACE_MODEL) + providers = {spec.provider for spec in specs} + revisions = {spec.revision for spec in specs} + if len(providers) != 1 or len(revisions) != 1: + raise RuntimeError("Actor model artifacts must share provider and revision.") + return ModelKey( + capability=YUNET_MODEL.capability.split(".", 1)[0], + provider=YUNET_MODEL.provider, + model_id="+".join(spec.model_id for spec in specs), + revision=YUNET_MODEL.revision, + device=device, + ) diff --git a/src/vidxp/capabilities/contracts.py b/src/vidxp/capabilities/contracts.py index d022cde..9448077 100644 --- a/src/vidxp/capabilities/contracts.py +++ b/src/vidxp/capabilities/contracts.py @@ -1,5 +1,6 @@ from __future__ import annotations +from importlib import import_module from types import MappingProxyType from typing import Any, Callable, Mapping @@ -15,6 +16,12 @@ from vidxp.core.contracts import IndexConfig, VideoSource from vidxp.core.indexing_common import ProgressCallback +from vidxp.model_contracts import ArtifactSpec, ModelSpec +from vidxp.ports import IndexReader, ModelRuntimePort +from vidxp.application_models import CapabilityProvenance + + +CAPABILITY_CONTRACT_VERSION = 1 class _ContractModel(BaseModel): @@ -28,12 +35,19 @@ class _ContractModel(BaseModel): class CapabilityInput(BaseModel): """Base model for validated capability input.""" - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra="forbid", allow_inf_nan=False) class CapabilityOutput(_ContractModel): """Base model for validated capability output.""" + model_config = ConfigDict( + arbitrary_types_allowed=True, + extra="forbid", + frozen=True, + allow_inf_nan=False, + ) + class CapabilityConfig(_ContractModel): """Base model for settings owned and validated by one capability.""" @@ -49,11 +63,11 @@ class RuntimeCheck(_ContractModel): def inspect(self) -> dict[str, Any]: try: detail = self.check() - except Exception as exc: + except Exception: return { "name": self.label, "ok": False, - "error": f"{type(exc).__name__}: {exc}", + "error": "runtime check failed", } result: dict[str, Any] = { "name": self.label, @@ -72,21 +86,36 @@ def applies(self, source: VideoSource | None) -> bool: ) +class RuntimeCheckBinding(_ContractModel): + """Runtime check owned by a capability or platform service.""" + + capability: str = Field(min_length=1) + check: RuntimeCheck + provenance: CapabilityProvenance | None = None + + class CapabilityContext(_ContractModel): """Runtime context shared by transport-neutral capability operations.""" config: IndexConfig | None + runtime: ModelRuntimePort + storage: IndexReader | None = None def require_config(self) -> IndexConfig: if self.config is None: raise RuntimeError("This operation requires an active index.") return self.config + def require_storage(self) -> IndexReader: + if self.storage is None: + raise RuntimeError("This operation requires an active index store.") + return self.storage + class PreparationContext(_ContractModel): """Runtime values supplied to one capability's preparation hook.""" - device: str = Field(min_length=1) + runtime: ModelRuntimePort settings: CapabilityConfig @@ -94,12 +123,12 @@ class PreparationContext(_ContractModel): class OperationDefinition(_ContractModel): - """Validated input, output, and implementation for one operation.""" + """Transport-neutral input and output metadata for one operation.""" input_model: type[BaseModel] output_model: type[BaseModel] - handler: OperationHandler requires_index: bool = True + public: bool = True @field_validator("input_model", "output_model") @classmethod @@ -111,14 +140,54 @@ def _require_model( raise ValueError("Operation schemas must be Pydantic models.") return value - def invoke( - self, - context: CapabilityContext, - payload: BaseModel | Mapping[str, Any], - ) -> BaseModel: - request = self.input_model.model_validate(payload) - result = self.handler(context, request) - return self.output_model.model_validate(result) + @model_validator(mode="after") + def _validate_public_schemas(self) -> "OperationDefinition": + if not self.public: + return self + forbidden_names = { + "path", + "input_path", + "output_path", + "storage_key", + "repository_root", + "index_directory", + } + + def inspect(value: Any) -> None: + if isinstance(value, dict): + properties = value.get("properties") + if isinstance(properties, dict): + unsafe = { + name + for name in properties + if ( + name in forbidden_names + or name.endswith("_path") + or name.endswith("_directory") + ) + } + if unsafe: + fields = ", ".join(sorted(unsafe)) + raise ValueError( + f"Public operation schema exposes {fields}." + ) + if value.get("format") in {"path", "binary"}: + raise ValueError( + "Public operation schema exposes local or binary data." + ) + if value.get("contentEncoding") == "base64": + raise ValueError( + "Public operation schema embeds binary content." + ) + for nested in value.values(): + inspect(nested) + elif isinstance(value, list): + for nested in value: + inspect(nested) + + inspect(self.input_model.model_json_schema()) + inspect(self.output_model.model_json_schema()) + return self class CapabilityIndexResult(_ContractModel): @@ -141,27 +210,22 @@ class CapabilityIndexResult(_ContractModel): [IndexConfig, tuple[VideoSource, ...]], Mapping[str, Any], ] -CLIFactory = Callable[[], Any] +ExecutorFactory = Callable[[], "CapabilityExecutor"] class CapabilityDefinition(_ContractModel): - """Everything the application needs to run one named capability.""" + """Domain metadata for one named capability.""" name: str = Field(min_length=1) description: str = Field(min_length=1) extra: str = Field(min_length=1) config_model: type[CapabilityConfig] = CapabilityConfig - runtime_checks: tuple[RuntimeCheck, ...] = () collection_name: str | None = None - indexer: IndexHandler | None = None - index_processor: Any | None = None index_stage: str | None = None + execution_group: str | None = None operations: Mapping[str, OperationDefinition] = Field(default_factory=dict) - requirement_filter: RequirementFilter | None = None - prepare: PrepareHandler | None = None - model_manifest: ModelManifest | None = None - cli_name: str | None = None - cli_factory: CLIFactory | None = None + model_specs: tuple[ModelSpec | ArtifactSpec, ...] = () + prepares_models: bool = False @field_validator("config_model") @classmethod @@ -187,29 +251,61 @@ def _freeze_operations( return MappingProxyType(dict(value)) @model_validator(mode="after") - def _require_complete_integrations(self) -> CapabilityDefinition: + def _require_complete_metadata(self) -> CapabilityDefinition: indexing_fields = ( self.collection_name, - self.indexer, self.index_stage, + self.execution_group, ) if any(value is not None for value in indexing_fields) and not all( value is not None for value in indexing_fields ): raise ValueError( "Indexable capabilities must declare collection names, " - "an indexer, and an index stage together." + "an index stage, and an execution group together." ) - if (self.cli_name is None) != (self.cli_factory is None): + if self.collection_name is None and not self.operations: raise ValueError( - "cli_name and cli_factory must either both be set or both be unset." - ) - if self.indexer is None and not self.operations: - raise ValueError( - "A capability must provide an indexer or at least one operation." + "A capability must support indexing or at least one operation." ) return self + +def module_import_check( + label: str, + module_name: str, + *attributes: str, +) -> RuntimeCheck: + def check() -> None: + module = import_module(module_name) + for attribute in attributes: + if not hasattr(module, attribute): + raise AttributeError( + f"{module_name} does not expose {attribute}." + ) + + return RuntimeCheck(label=label, check=check) + + +class CapabilityExecutor(_ContractModel): + """Infrastructure hooks bound to one capability definition.""" + + indexer: IndexHandler | None = None + index_processor: Any | None = None + operations: Mapping[str, OperationHandler] = Field(default_factory=dict) + runtime_checks: tuple[RuntimeCheck, ...] = () + requirement_filter: RequirementFilter | None = None + prepare: PrepareHandler | None = None + model_manifest: ModelManifest | None = None + + @field_validator("operations") + @classmethod + def _freeze_operation_handlers( + cls, + value: Mapping[str, OperationHandler], + ) -> Mapping[str, OperationHandler]: + return MappingProxyType(dict(value)) + def source_requirements( self, source: VideoSource, @@ -220,5 +316,38 @@ def source_requirements( return self.requirement_filter(source, requirements) +class CapabilityPlugin(_ContractModel): + definition: CapabilityDefinition + executor_factory: ExecutorFactory + contract_version: int = CAPABILITY_CONTRACT_VERSION + requirements: tuple[str, ...] = () + provenance: CapabilityProvenance | None = None + + @field_validator("requirements") + @classmethod + def _validate_requirements( + cls, + values: tuple[str, ...], + ) -> tuple[str, ...]: + for value in values: + Requirement(value) + return values + + def capability_install_hint(name: str) -> str: return f'Install the capability with: pip install "vidxp[{name}]"' + + +class CapabilityRequestError(ValueError): + """Expected invalid capability selection or options.""" + + +class CapabilityDependencyError(RuntimeError): + def __init__( + self, + capabilities: tuple[str, ...], + failures: tuple[Mapping[str, Any], ...], + ) -> None: + self.capabilities = capabilities + self.failures = failures + super().__init__("Capability dependencies are unavailable.") diff --git a/src/vidxp/capabilities/dialogue/config.py b/src/vidxp/capabilities/dialogue/config.py index 3999801..8f68833 100644 --- a/src/vidxp/capabilities/dialogue/config.py +++ b/src/vidxp/capabilities/dialogue/config.py @@ -11,12 +11,6 @@ class DialogueConfig(CapabilityConfig): embedding_batch_size: int = Field(default=128, gt=0) transcription_batch_size: int = Field(default=16, gt=0) normalize_embeddings: bool = True - sentence_model: str = Field( - default="sentence-transformers/all-MiniLM-L6-v2", - min_length=1, - ) - whisper_model: str = Field(default="large-v2", min_length=1) - alignment_language: str | None = Field(default=None, min_length=1) def dialogue_config(config: IndexConfig) -> DialogueConfig: diff --git a/src/vidxp/capabilities/dialogue/definition.py b/src/vidxp/capabilities/dialogue/definition.py index f7682e5..2d6b1c7 100644 --- a/src/vidxp/capabilities/dialogue/definition.py +++ b/src/vidxp/capabilities/dialogue/definition.py @@ -7,15 +7,17 @@ from vidxp.capabilities.contracts import ( CapabilityDefinition, + CapabilityExecutor, + CapabilityPlugin, OperationDefinition, PreparationContext, - RuntimeCheck, + module_import_check, ) -from vidxp.capabilities.dialogue.config import DialogueConfig, dialogue_config -from vidxp.capabilities.dialogue.models import ( - get_alignment_model, - get_embedder, - get_whisper_model, +from vidxp.capabilities.dialogue.config import DialogueConfig +from vidxp.capabilities.dialogue.models import get_embedder, get_whisper_model +from vidxp.capabilities.dialogue.specs import ( + FASTER_WHISPER_MODEL, + QWEN3_EMBEDDING_MODEL, ) from vidxp.capabilities.dialogue.operations import ( index_capability, @@ -24,16 +26,6 @@ from vidxp.capabilities.schemas import SearchInput, SearchResult from vidxp.core.contracts import IndexConfig, VideoSource from vidxp.core.indexing_common import ProgressCallback -from vidxp.core.video import ffmpeg_binary - - -FFMPEG = RuntimeCheck( - label="FFmpeg", - check=ffmpeg_binary, - applies_to=lambda source: source.transcript is None, -) - - def filter_requirements_for_source( source: VideoSource, requirements: tuple[Requirement, ...], @@ -52,7 +44,7 @@ def prepare_models( context: PreparationContext, progress: ProgressCallback | None, ) -> tuple[str, ...]: - settings = DialogueConfig.model_validate(context.settings) + DialogueConfig.model_validate(context.settings) prepared = [] def report(stage: str, message: str) -> None: @@ -67,36 +59,31 @@ def report(stage: str, message: str) -> None: report( "dialogue_model", - f"Preparing dialogue model: {settings.sentence_model}", + f"Preparing dialogue model: {QWEN3_EMBEDDING_MODEL.model_id}", ) - get_embedder(settings.sentence_model, context.device) - prepared.append(settings.sentence_model) + get_embedder(context.runtime, download=True, progress=progress) + prepared.append(QWEN3_EMBEDDING_MODEL.model_id) report( "transcription_model", - f"Preparing transcription model: WhisperX {settings.whisper_model}", + "Preparing transcription model: faster-whisper " + f"{FASTER_WHISPER_MODEL.model_id}", ) - get_whisper_model(settings.whisper_model, context.device) - prepared.append(settings.whisper_model) - if settings.alignment_language: - report( - "alignment_model", - f"Preparing the {settings.alignment_language} alignment model.", - ) - get_alignment_model(settings.alignment_language, context.device) - prepared.append( - f"whisperx-alignment:{settings.alignment_language}" - ) + get_whisper_model(context.runtime, download=True, progress=progress) + prepared.append(FASTER_WHISPER_MODEL.model_id) return tuple(prepared) def model_manifest( - config: IndexConfig, + _config: IndexConfig, sources: tuple[VideoSource, ...], ) -> Mapping[str, Any]: - settings = dialogue_config(config) - result: dict[str, Any] = {"dialogue": settings.sentence_model} + result: dict[str, Any] = { + "dialogue": { + **QWEN3_EMBEDDING_MODEL.identity(), + } + } if any(source.transcript is None for source in sources): - result["transcription"] = settings.whisper_model + result["transcription"] = FASTER_WHISPER_MODEL.identity() return result @@ -106,17 +93,48 @@ def model_manifest( extra="dialogue", config_model=DialogueConfig, collection_name="dialogue", - indexer=index_capability, index_stage="dialogue_indexing", - runtime_checks=(FFMPEG,), - requirement_filter=filter_requirements_for_source, - prepare=prepare_models, - model_manifest=model_manifest, + execution_group="dialogue", + prepares_models=True, + model_specs=(QWEN3_EMBEDDING_MODEL, FASTER_WHISPER_MODEL), operations={ "search": OperationDefinition( input_model=SearchInput, output_model=SearchResult, - handler=search_operation, ) }, ) + + +def create_executor() -> CapabilityExecutor: + return CapabilityExecutor( + indexer=index_capability, + operations={"search": search_operation}, + requirement_filter=filter_requirements_for_source, + prepare=prepare_models, + model_manifest=model_manifest, + runtime_checks=( + module_import_check( + "faster-whisper import", + "faster_whisper", + "WhisperModel", + "BatchedInferencePipeline", + ), + module_import_check( + "Sentence Transformers import", + "sentence_transformers", + "SentenceTransformer", + ), + module_import_check( + "Hugging Face Hub import", + "huggingface_hub", + "snapshot_download", + ), + ), + ) + + +PLUGIN = CapabilityPlugin( + definition=DEFINITION, + executor_factory=create_executor, +) diff --git a/src/vidxp/capabilities/dialogue/indexing.py b/src/vidxp/capabilities/dialogue/indexing.py index 4e5201c..f90919a 100644 --- a/src/vidxp/capabilities/dialogue/indexing.py +++ b/src/vidxp/capabilities/dialogue/indexing.py @@ -1,15 +1,14 @@ from __future__ import annotations -import hashlib from dataclasses import dataclass from pathlib import Path from typing import Any, Mapping, Sequence from vidxp.capabilities.dialogue.config import dialogue_config -from vidxp.capabilities.dialogue.models import ( - get_alignment_model, - get_embedder, - get_whisper_model, +from vidxp.capabilities.dialogue.models import get_embedder, get_whisper_model +from vidxp.capabilities.dialogue.specs import ( + FASTER_WHISPER_MODEL, + QWEN3_EMBEDDING_MODEL, ) from vidxp.core.contracts import ( CancellationToken, @@ -19,8 +18,7 @@ stable_source_id, ) from vidxp.core.indexing_common import ProgressCallback, report_progress -from vidxp.core.storage import IndexStorage -from vidxp.core.video import extract_audio +from vidxp.ports import IndexStore, ModelRuntimePort @dataclass(frozen=True) @@ -118,73 +116,63 @@ def transcribe_video( input_path: str | Path, *, config: IndexConfig, - work_directory: str | Path, cancellation: CancellationToken, + runtime: ModelRuntimePort, progress: ProgressCallback | None, -) -> tuple[list[Mapping[str, Any]], str]: - import whisperx +) -> tuple[list[Mapping[str, Any]], str | None]: + import av + from faster_whisper import BatchedInferencePipeline settings = dialogue_config(config) cancellation.raise_if_cancelled() - audio_name = hashlib.sha256( - str(config.video_id).encode("utf-8") - ).hexdigest() - audio_path = Path(work_directory) / f"{audio_name}.wav" + with av.open(str(input_path)) as container: + if not container.streams.audio: + report_progress( + progress, + "dialogue_skipped", + "No audio stream was found; dialogue indexing was skipped.", + ) + return [], None report_progress( progress, - "extracting_audio", - "Extracting audio from the video.", + "preparing_transcription_model", + "Preparing transcription model: faster-whisper " + f"{FASTER_WHISPER_MODEL.model_id}.", ) - extract_audio(input_path, audio_path) - try: - cancellation.raise_if_cancelled() - report_progress( - progress, - "preparing_transcription_model", - f"Preparing transcription model: WhisperX {settings.whisper_model}.", - ) - whisper_model = get_whisper_model( - settings.whisper_model, - config.device, - ) - audio = whisperx.load_audio(str(audio_path)) - report_progress( - progress, - "transcribing_audio", - "Transcribing the video audio.", - ) - transcription = whisper_model.transcribe( - audio, - batch_size=settings.transcription_batch_size, - ) - language = str(transcription["language"]) - + whisper_model = get_whisper_model(runtime) + report_progress( + progress, + "transcribing_audio", + "Transcribing and timestamping the video audio.", + ) + segments, info = BatchedInferencePipeline( + model=whisper_model + ).transcribe( + str(input_path), + batch_size=settings.transcription_batch_size, + word_timestamps=True, + vad_filter=True, + ) + result = [] + for segment in segments: cancellation.raise_if_cancelled() - report_progress( - progress, - "preparing_alignment_model", - f"Preparing the {language} alignment model.", - ) - alignment_model, alignment_metadata = get_alignment_model( - language, - config.device, - ) - report_progress( - progress, - "aligning_audio", - "Aligning transcript timestamps.", - ) - aligned = whisperx.align( - transcription["segments"], - alignment_model, - alignment_metadata, - audio, - config.device, - return_char_alignments=False, + result.append( + { + "text": segment.text, + "start": segment.start, + "end": segment.end, + "words": [ + { + "word": word.word, + "start": word.start, + "end": word.end, + } + for word in (segment.words or ()) + if word.start is not None and word.end is not None + ], + } ) - return list(aligned["segments"]), language - finally: - audio_path.unlink(missing_ok=True) + return result, str(info.language) def _dialogue_records( @@ -199,6 +187,7 @@ def _dialogue_records( str(config.video_id), "dialogue", f"p{phrase.phrase_id:08d}", + generation_id=config.generation_id, ) records.append( StorageRecord( @@ -221,8 +210,9 @@ def index_dialogue( source: VideoSource, *, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, cancellation: CancellationToken, + runtime: ModelRuntimePort, progress: ProgressCallback | None = None, ) -> dict[str, Any]: if config.video_id is None: @@ -240,8 +230,8 @@ def index_dialogue( segments, language = transcribe_video( source.path, config=config, - work_directory=config.run_directory / "work", cancellation=cancellation, + runtime=runtime, progress=progress, ) @@ -255,11 +245,11 @@ def index_dialogue( report_progress( progress, "preparing_dialogue_model", - f"Preparing dialogue model: {settings.sentence_model}.", + f"Preparing dialogue model: {QWEN3_EMBEDDING_MODEL.model_id}.", 0, len(phrases), ) - encoder = get_embedder(settings.sentence_model, config.device) + encoder = get_embedder(runtime) report_progress( progress, "dialogue_indexing", @@ -271,7 +261,7 @@ def index_dialogue( for offset in range(0, len(phrases), settings.embedding_batch_size): cancellation.raise_if_cancelled() group = phrases[offset:offset + settings.embedding_batch_size] - vectors = encoder.encode( + vectors = encoder.encode_document( [phrase.text for phrase in group], batch_size=len(group), convert_to_numpy=True, diff --git a/src/vidxp/capabilities/dialogue/models.py b/src/vidxp/capabilities/dialogue/models.py index 449d50a..7a671ea 100644 --- a/src/vidxp/capabilities/dialogue/models.py +++ b/src/vidxp/capabilities/dialogue/models.py @@ -1,30 +1,106 @@ from __future__ import annotations -from functools import lru_cache +from typing import Any, Callable +from vidxp.ports import ModelRuntimePort +from vidxp.model_contracts import loaded_compute_precision +from vidxp.capabilities.dialogue.specs import ( + FASTER_WHISPER_MODEL, + QWEN3_EMBEDDING_MODEL, + whisper_compute_type, +) -@lru_cache -def get_embedder(model_name: str, device: str): - from sentence_transformers import SentenceTransformer - return SentenceTransformer(model_name, device=device) +def get_embedder( + runtime: ModelRuntimePort, + *, + download: bool = False, + progress: Callable[[dict[str, Any]], None] | None = None, +) -> Any: + device = runtime.device_for("dialogue.embedding") + key = QWEN3_EMBEDDING_MODEL.key(device) + def load() -> Any: + from sentence_transformers import SentenceTransformer -@lru_cache -def get_whisper_model(model_name: str, device: str): - import whisperx + snapshot = runtime.resolve_model( + QWEN3_EMBEDDING_MODEL, + download=download, + progress=progress, + ) + if progress is not None: + progress( + { + "state": "preparing", + "stage": "loading_model", + "message": ( + f"Loading {QWEN3_EMBEDDING_MODEL.model_id}." + ), + } + ) + model = SentenceTransformer( + str(snapshot), + device=device, + cache_folder=str(runtime.model_cache), + local_files_only=True, + ) + runtime.record_compute_precision( + QWEN3_EMBEDDING_MODEL.capability, + loaded_compute_precision( + model, + fallback=QWEN3_EMBEDDING_MODEL.weights_precision, + ), + ) + return model - return whisperx.load_model(model_name, device, compute_type="float32") + return runtime.get_or_load(key, load) -@lru_cache -def get_alignment_model(language: str, device: str): - import whisperx +def get_whisper_model( + runtime: ModelRuntimePort, + *, + download: bool = False, + progress: Callable[[dict[str, Any]], None] | None = None, +) -> Any: + device = runtime.device_for("dialogue.transcription") + compute_type = whisper_compute_type(device) + key = FASTER_WHISPER_MODEL.key(f"{device}:{compute_type}") - return whisperx.load_align_model(language_code=language, device=device) + def load() -> Any: + from faster_whisper import WhisperModel + snapshot = runtime.resolve_model( + FASTER_WHISPER_MODEL, + download=download, + progress=progress, + ) + if progress is not None: + progress( + { + "state": "preparing", + "stage": "loading_model", + "message": ( + f"Loading {FASTER_WHISPER_MODEL.model_id}." + ), + } + ) + model = WhisperModel( + str(snapshot), + device=device.split(":", 1)[0], + device_index=( + int(device.split(":", 1)[1]) + if ":" in device + else 0 + ), + compute_type=compute_type, + cpu_threads=runtime.cpu_thread_budget, + download_root=str(runtime.model_cache), + local_files_only=True, + ) + runtime.record_compute_precision( + FASTER_WHISPER_MODEL.capability, + compute_type, + ) + return model -def clear_model_cache() -> None: - get_embedder.cache_clear() - get_whisper_model.cache_clear() - get_alignment_model.cache_clear() + return runtime.get_or_load(key, load) diff --git a/src/vidxp/capabilities/dialogue/operations.py b/src/vidxp/capabilities/dialogue/operations.py index 9fd98e3..7c6e2e9 100644 --- a/src/vidxp/capabilities/dialogue/operations.py +++ b/src/vidxp/capabilities/dialogue/operations.py @@ -6,6 +6,7 @@ CapabilityContext, CapabilityIndexResult, ) +from vidxp.capabilities.registry import CapabilityRegistry from vidxp.capabilities.dialogue.config import dialogue_config from vidxp.capabilities.dialogue.indexing import index_dialogue from vidxp.capabilities.dialogue.models import get_embedder @@ -17,7 +18,7 @@ VideoSource, ) from vidxp.core.indexing_common import ProgressCallback -from vidxp.core.storage import IndexStorage +from vidxp.ports import IndexStore, ModelRuntimePort REQUIRED_METADATA = frozenset( @@ -40,8 +41,10 @@ def index_capability( source: VideoSource, *, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, cancellation: CancellationToken, + registry: CapabilityRegistry, + runtime: ModelRuntimePort, progress: ProgressCallback | None = None, modalities: tuple[str, ...] = ("dialogue",), ) -> CapabilityIndexResult: @@ -54,14 +57,19 @@ def index_capability( storage=storage, cancellation=cancellation, progress=progress, + runtime=runtime, ) ) -def dialogue_embedding(query: str, config: IndexConfig) -> list[float]: +def dialogue_embedding( + query: str, + config: IndexConfig, + runtime: ModelRuntimePort, +) -> list[float]: settings = dialogue_config(config) - encoder = get_embedder(settings.sentence_model, config.device) - encoded = encoder.encode( + encoder = get_embedder(runtime) + encoded = encoder.encode_query( [query], convert_to_numpy=True, normalize_embeddings=settings.normalize_embeddings, @@ -73,11 +81,12 @@ def search_dialogue( query: str, *, config: IndexConfig, + runtime: ModelRuntimePort, top_k: int = 10, video_id: str | None = None, query_id: str | None = None, filters: Mapping[str, Any] | None = None, - storage: IndexStorage | None = None, + storage: IndexStore, ) -> SearchResult: cleaned = query.strip() if not cleaned: @@ -87,7 +96,7 @@ def search_dialogue( return search_embeddings( cleaned, "dialogue", - dialogue_embedding(cleaned, config), + dialogue_embedding(cleaned, config, runtime), config=config, required_metadata=REQUIRED_METADATA, top_k=top_k, @@ -107,5 +116,7 @@ def search_operation( request.query, config=config, top_k=request.top_k, - video_id=config.video_id, + video_id=request.media_id or config.video_id, + runtime=context.runtime, + storage=context.require_storage(), ) diff --git a/src/vidxp/capabilities/dialogue/requirements.txt b/src/vidxp/capabilities/dialogue/requirements.txt index c97c911..0841436 100644 --- a/src/vidxp/capabilities/dialogue/requirements.txt +++ b/src/vidxp/capabilities/dialogue/requirements.txt @@ -1,3 +1,3 @@ -moviepy==1.0.3 -sentence-transformers>=3.4,<4 -whisperx>=3.8.6,<3.9 +faster-whisper>=1.2.1,<2 +sentence-transformers>=5.6.1,<6 +huggingface-hub>=1.25.1,<2 diff --git a/src/vidxp/capabilities/dialogue/specs.py b/src/vidxp/capabilities/dialogue/specs.py new file mode 100644 index 0000000..ac889c8 --- /dev/null +++ b/src/vidxp/capabilities/dialogue/specs.py @@ -0,0 +1,34 @@ +from vidxp.model_contracts import ModelSpec + + +QWEN3_EMBEDDING_MODEL = ModelSpec( + capability="dialogue.embedding", + provider="sentence-transformers", + model_id="Qwen/Qwen3-Embedding-0.6B", + revision="97b0c614be4d77ee51c0cef4e5f07c00f9eb65b3", + download_size_bytes=1_207_489_041, + weights_file="model.safetensors", + weights_sha256=( + "0437e45c94563b09e13cb7a64478fc406947a93cb34a7e05870fc8dcd48e23fd" + ), + license="Apache-2.0", + weights_precision="bfloat16", +) + +FASTER_WHISPER_MODEL = ModelSpec( + capability="dialogue.transcription", + provider="faster-whisper", + model_id="dropbox-dash/faster-whisper-large-v3-turbo", + revision="0a363e9161cbc7ed1431c9597a8ceaf0c4f78fcf", + download_size_bytes=1_621_668_947, + weights_file="model.bin", + weights_sha256=( + "e76620f83d5f5b69efd3d87e3dc180c1bd21df9fbebacfd4335e5e1efcc018da" + ), + license="MIT", + weights_precision="float16", +) + + +def whisper_compute_type(device: str) -> str: + return "float16" if device.startswith("cuda") else "int8" diff --git a/src/vidxp/capabilities/registry.py b/src/vidxp/capabilities/registry.py index 2a7a633..b18d9e9 100644 --- a/src/vidxp/capabilities/registry.py +++ b/src/vidxp/capabilities/registry.py @@ -1,19 +1,32 @@ from __future__ import annotations +from dataclasses import dataclass +from importlib.metadata import EntryPoint, entry_points +from pathlib import Path +from threading import RLock +from time import perf_counter from types import MappingProxyType -from typing import Any, Iterable, Mapping +from typing import Any, Callable, Iterable, Mapping from packaging.requirements import Requirement from packaging.utils import canonicalize_name -from vidxp.capabilities.actor.definition import DEFINITION as ACTOR from vidxp.capabilities.contracts import ( + CAPABILITY_CONTRACT_VERSION, CapabilityDefinition, + CapabilityDependencyError, + CapabilityExecutor, + CapabilityPlugin, + CapabilityProvenance, + CapabilityRequestError, RuntimeCheck, + RuntimeCheckBinding, capability_install_hint, ) -from vidxp.capabilities.dialogue.definition import DEFINITION as DIALOGUE -from vidxp.capabilities.scene.definition import DEFINITION as SCENE +from vidxp.application_models import ( + CapabilityDependencyCheck, + DependencyKind, +) from vidxp.core.contracts import VideoSource from vidxp.dependencies import ( active_requirements, @@ -21,189 +34,542 @@ installed_base_requirements, packaged_requirements, ) +from vidxp.model_contracts import ArtifactSpec, ModelSpec +from vidxp.model_contracts import model_artifact_cached -_BUILT_INS = (DIALOGUE, SCENE, ACTOR) -CAPABILITIES = MappingProxyType( - {capability.name: capability for capability in _BUILT_INS} -) +ENTRY_POINT_GROUP = "vidxp.capabilities" -if len(CAPABILITIES) != len(_BUILT_INS): - raise RuntimeError("Capability names must be unique.") +@dataclass(frozen=True) +class _RequirementBinding: + capability: str + provenance: CapabilityProvenance | None + requirement: Requirement -def capability_names() -> tuple[str, ...]: - return tuple(CAPABILITIES) +class CapabilityRegistry: + """Validated capability metadata with lazily constructed executors.""" -def index_capability_names() -> tuple[str, ...]: - return tuple( - name - for name, capability in CAPABILITIES.items() - if capability.indexer is not None - ) + def __init__( + self, + plugins: Iterable[CapabilityPlugin], + *, + platform_runtime_checks: Iterable[RuntimeCheckBinding] = (), + storage_requirements: Iterable[Requirement] | None = None, + ) -> None: + indexed: dict[str, CapabilityPlugin] = {} + for plugin in plugins: + if plugin.contract_version != CAPABILITY_CONTRACT_VERSION: + raise RuntimeError( + f"Capability {plugin.definition.name!r} from " + f"{self._label(plugin)} targets contract " + f"{plugin.contract_version}; VidXP requires " + f"{CAPABILITY_CONTRACT_VERSION}." + ) + name = plugin.definition.name + if name in indexed: + existing = self._label(indexed[name]) + incoming = self._label(plugin) + raise RuntimeError( + f"Duplicate capability name {name!r}: " + f"{existing} conflicts with {incoming}." + ) + indexed[name] = plugin + self._plugins = MappingProxyType(indexed) + self._platform_runtime_checks = tuple(platform_runtime_checks) + self._storage_requirements = tuple( + storage_requirements + if storage_requirements is not None + else active_requirements( + packaged_requirements( + "vidxp", + "requirements/storage.txt", + ) + ) + ) + self._executors: dict[str, CapabilityExecutor] = {} + self._executor_lock = RLock() + @staticmethod + def _label(plugin: CapabilityPlugin) -> str: + if plugin.provenance is None: + return "built-in VidXP capability" + return ( + f"{plugin.provenance.distribution}:" + f"{plugin.provenance.entry_point}" + ) -def preparable_capability_names() -> tuple[str, ...]: - return tuple( - name - for name, capability in CAPABILITIES.items() - if capability.prepare is not None - ) + @property + def definitions(self) -> Mapping[str, CapabilityDefinition]: + return MappingProxyType( + {name: plugin.definition for name, plugin in self._plugins.items()} + ) + def names(self) -> tuple[str, ...]: + return tuple(self._plugins) -def get_capability(name: str) -> CapabilityDefinition: - try: - return CAPABILITIES[name] - except KeyError as exc: - available = ", ".join(capability_names()) - raise ValueError( - f"Unknown capability {name!r}. Available capabilities: {available}." - ) from exc - - -def validate_capability_names(names: Iterable[str]) -> tuple[str, ...]: - selected = tuple(dict.fromkeys(str(name).strip() for name in names)) - if not selected: - raise ValueError("At least one capability is required.") - for name in selected: - get_capability(name) - return selected - - -def collection_names( - names: Iterable[str] | None = None, -) -> dict[str, str]: - selected = ( - index_capability_names() - if names is None - else validate_capability_names(names) - ) - return { - name: get_capability(name).collection_name - for name in selected - if get_capability(name).indexer is not None - } + def provenance(self, name: str) -> CapabilityProvenance | None: + self.get(name) + return self._plugins[name].provenance + def index_names(self) -> tuple[str, ...]: + return tuple( + name + for name, definition in self.definitions.items() + if definition.collection_name is not None + ) -def validate_capability_options( - names: Iterable[str], - options: Mapping[str, Mapping[str, Any]] | None, -) -> dict[str, dict[str, Any]]: - selected = validate_capability_names(names) - supplied = dict(options or {}) - unknown = sorted(set(supplied) - set(selected)) - if unknown: - raise ValueError( - "Options were supplied for disabled capabilities: " - + ", ".join(unknown) + def preparable_names(self) -> tuple[str, ...]: + return tuple( + name + for name, definition in self.definitions.items() + if definition.prepares_models ) - return { - name: get_capability(name) - .config_model.model_validate(supplied.get(name, {})) - .model_dump(mode="python") - for name in selected - } + def model_specs( + self, + names: Iterable[str] | None = None, + ) -> tuple[ModelSpec | ArtifactSpec, ...]: + selected = self.names() if names is None else self.validate_names(names) + return tuple( + dict.fromkeys( + spec + for name in selected + for spec in self.get(name).model_specs + ) + ) -def requirements_for( - names: Iterable[str], - *, - source: VideoSource | None = None, -) -> tuple[Requirement, ...]: - selected = tuple( - get_capability(name) - for name in validate_capability_names(names) - ) - requirements = list( - active_requirements( - packaged_requirements( - "vidxp", - "requirements/storage.txt", + def model_checks( + self, + names: Iterable[str], + *, + cache: Path, + on_check_start: ( + Callable[[str, DependencyKind, str], None] | None + ) = None, + on_check_complete: ( + Callable[[CapabilityDependencyCheck, float], None] | None + ) = None, + ) -> tuple[CapabilityDependencyCheck, ...]: + selected = self.validate_names(names) + checks = [] + for name in selected: + for spec in self.get(name).model_specs: + if on_check_start is not None: + on_check_start(name, DependencyKind.model, spec.model_id) + started = perf_counter() + cached = model_artifact_cached(cache, spec) + check = CapabilityDependencyCheck( + capability=name, + kind=DependencyKind.model, + name=spec.model_id, + download_size_bytes=spec.download_size_bytes, + ok=cached, + error=( + None + if cached + else ( + "model artifacts are not prepared; run " + f"vidxp prepare --modalities {name}" + ) + ), + ) + checks.append(check) + if on_check_complete is not None: + on_check_complete(check, perf_counter() - started) + return tuple(checks) + + def get(self, name: str) -> CapabilityDefinition: + try: + return self._plugins[name].definition + except KeyError as exc: + available = ", ".join(self.names()) + raise CapabilityRequestError( + f"Unknown capability {name!r}. " + f"Available capabilities: {available}." + ) from exc + + def executor(self, name: str) -> CapabilityExecutor: + definition = self.get(name) + with self._executor_lock: + if name not in self._executors: + executor = self._plugins[name].executor_factory() + operation_names = set(definition.operations) + if set(executor.operations) != operation_names: + raise RuntimeError( + f"Capability {name!r} from " + f"{self._label(self._plugins[name])} has operation " + "handlers that do not match its declaration." + ) + if (executor.indexer is not None) != ( + definition.collection_name is not None + ): + raise RuntimeError( + f"Capability {name!r} from " + f"{self._label(self._plugins[name])} has indexing " + "metadata that does not match its executor." + ) + if (executor.prepare is not None) != definition.prepares_models: + raise RuntimeError( + f"Capability {name!r} from " + f"{self._label(self._plugins[name])} has preparation " + "metadata that does not match its executor." + ) + self._executors[name] = executor + return self._executors[name] + + def validate_names(self, names: Iterable[str]) -> tuple[str, ...]: + selected = tuple(dict.fromkeys(str(name).strip() for name in names)) + if not selected: + raise CapabilityRequestError("At least one capability is required.") + for name in selected: + self.get(name) + return selected + + def collection_names( + self, + names: Iterable[str] | None = None, + ) -> dict[str, str]: + selected = self.index_names() if names is None else self.validate_names(names) + return { + name: collection_name + for name in selected + if (collection_name := self.get(name).collection_name) is not None + } + + def validate_options( + self, + names: Iterable[str], + options: Mapping[str, Mapping[str, Any]] | None, + ) -> dict[str, dict[str, Any]]: + selected = self.validate_names(names) + supplied = dict(options or {}) + unknown = sorted(set(supplied) - set(selected)) + if unknown: + raise CapabilityRequestError( + "Options were supplied for disabled capabilities: " + + ", ".join(unknown) + ) + return { + name: self.get(name) + .config_model.model_validate(supplied.get(name, {})) + .model_dump(mode="python") + for name in selected + } + + def requirements_for( + self, + names: Iterable[str], + *, + source: VideoSource | None = None, + ) -> tuple[Requirement, ...]: + bindings = self._requirement_bindings(names, source=source) + unique = { + str(binding.requirement): binding.requirement for binding in bindings + } + return tuple(unique.values()) + + def _requirement_bindings( + self, + names: Iterable[str], + *, + source: VideoSource | None = None, + ) -> tuple[_RequirementBinding, ...]: + selected = self.validate_names(names) + bindings = [ + _RequirementBinding("storage", None, requirement) + for requirement in self._storage_requirements + if any(self.get(name).collection_name for name in selected) + ] + for name in selected: + plugin = self._plugins[name] + capability_requirements = active_requirements( + tuple(Requirement(value) for value in plugin.requirements) + if plugin.provenance is not None + else packaged_requirements(f"vidxp.capabilities.{name}") + ) + executor = self.executor(name) + selected_requirements = ( + capability_requirements + if source is None + else executor.source_requirements(source, capability_requirements) + ) + bindings.extend( + _RequirementBinding(name, plugin.provenance, requirement) + for requirement in selected_requirements + ) + unique = { + ( + binding.capability, + str(binding.requirement), + ): binding + for binding in bindings + } + return tuple(unique.values()) + + def install_hint(self, names: Iterable[str]) -> str: + selected = self.validate_names(names) + external = [ + plugin.provenance.distribution + for name in selected + if (plugin := self._plugins[name]).provenance is not None + ] + builtins = [ + self.get(name).extra + for name in selected + if self._plugins[name].provenance is None + ] + commands = [] + if builtins: + commands.append( + capability_install_hint(",".join(dict.fromkeys(builtins))) + ) + if external: + commands.append( + "Install external capability distributions with: pip install " + + " ".join(dict.fromkeys(external)) + ) + return " ".join(commands) + + def runtime_checks_for( + self, + names: Iterable[str], + *, + source: VideoSource | None = None, + ) -> tuple[RuntimeCheck, ...]: + return tuple( + binding.check + for binding in self._runtime_check_bindings( + names, + source=source, ) ) - if any(capability.indexer is not None for capability in selected) - else () - ) - for capability in selected: - capability_requirements = active_requirements( - packaged_requirements( - f"vidxp.capabilities.{capability.name}" + + def _runtime_check_bindings( + self, + names: Iterable[str], + *, + source: VideoSource | None = None, + ) -> tuple[RuntimeCheckBinding, ...]: + selected = self.validate_names(names) + bindings = [ + binding + for binding in self._platform_runtime_checks + if any(self.get(name).collection_name for name in selected) + and binding.check.applies(source) + ] + for name in selected: + plugin = self._plugins[name] + bindings.extend( + RuntimeCheckBinding( + capability=name, + provenance=plugin.provenance, + check=check, + ) + for check in self.executor(name).runtime_checks + if check.applies(source) + ) + unique = { + ( + binding.capability, + binding.check.label, + ( + binding.provenance.distribution, + binding.provenance.entry_point, + ) + if binding.provenance is not None + else None, + ): binding + for binding in bindings + } + return tuple(unique.values()) + + def dependency_checks( + self, + names: Iterable[str], + *, + source: VideoSource | None = None, + include_runtime_checks: bool = True, + on_check_start: ( + Callable[[str, DependencyKind, str], None] | None + ) = None, + on_check_complete: ( + Callable[[CapabilityDependencyCheck, float], None] | None + ) = None, + ) -> tuple[CapabilityDependencyCheck, ...]: + selected = self.validate_names(names) + checks = [] + for binding in self._requirement_bindings(selected, source=source): + if on_check_start is not None: + on_check_start( + binding.capability, + DependencyKind.distribution, + binding.requirement.name, + ) + started = perf_counter() + result = inspect_requirement(binding.requirement) + check = CapabilityDependencyCheck( + capability=binding.capability, + provenance=binding.provenance, + kind=DependencyKind.distribution, + **result, ) + checks.append(check) + if on_check_complete is not None: + on_check_complete(check, perf_counter() - started) + if include_runtime_checks: + for binding in self._runtime_check_bindings( + selected, + source=source, + ): + if on_check_start is not None: + on_check_start( + binding.capability, + DependencyKind.runtime, + binding.check.label, + ) + started = perf_counter() + result = binding.check.inspect() + check = CapabilityDependencyCheck( + capability=binding.capability, + provenance=binding.provenance, + kind=DependencyKind.runtime, + name=result["name"], + ok=result["ok"], + error=result["error"], + ) + checks.append(check) + if on_check_complete is not None: + on_check_complete( + check, + perf_counter() - started, + ) + return tuple(checks) + + def require_dependencies( + self, + names: Iterable[str], + *, + source: VideoSource, + ) -> None: + selected = self.validate_names(names) + failures = tuple( + check + for check in self.dependency_checks(selected, source=source) + if not check.ok ) - requirements.extend( - capability_requirements - if source is None - else capability.source_requirements( - source, - capability_requirements, + if failures: + raise CapabilityDependencyError( + selected, + tuple( + check.model_dump(mode="json") + for check in failures + ), ) + + def runtime_distributions(self) -> tuple[str, ...]: + distributions = { + canonicalize_name(requirement.name) + for requirement in installed_base_requirements() + } + distributions.update( + canonicalize_name(requirement.name) + for requirement in self.requirements_for(self.names()) ) - unique = {str(requirement): requirement for requirement in requirements} - return tuple(unique.values()) + return tuple(sorted(distributions, key=str.lower)) -def runtime_checks_for( - names: Iterable[str], - *, - source: VideoSource | None = None, -) -> tuple[RuntimeCheck, ...]: - checks = ( - check - for name in validate_capability_names(names) - for check in get_capability(name).runtime_checks - if check.applies(source) - ) - return tuple({check.label: check for check in checks}.values()) +def _builtin_plugins() -> tuple[CapabilityPlugin, ...]: + from vidxp.capabilities.actor.definition import PLUGIN as actor + from vidxp.capabilities.dialogue.definition import PLUGIN as dialogue + from vidxp.capabilities.scene.definition import PLUGIN as scene + + return dialogue, scene, actor -def dependency_checks(names: Iterable[str]) -> tuple[dict, ...]: +def _external_entry_points(allowlist: tuple[str, ...]) -> tuple[EntryPoint, ...]: + allowed = { + tuple(canonicalize_name(part) for part in value.split(":", 1)) + for value in allowlist + } + candidates = entry_points(group=ENTRY_POINT_GROUP) return tuple( - inspect_requirement(requirement) - for requirement in requirements_for(names) - ) + tuple( - check.inspect() - for check in runtime_checks_for(names) + sorted( + ( + entry_point + for entry_point in candidates + if entry_point.dist is not None + and ( + canonicalize_name(entry_point.dist.name), + canonicalize_name(entry_point.name), + ) + in allowed + ), + key=lambda item: ( + canonicalize_name( + item.dist.name if item.dist is not None else "" + ), + canonicalize_name(item.name), + ), + ) ) -def require_dependencies( - names: Iterable[str], +def create_capability_registry( *, - source: VideoSource, -) -> None: - selected = validate_capability_names(names) - failures = [ - result - for requirement in requirements_for(selected, source=source) - if not (result := inspect_requirement(requirement))["ok"] - ] - failures.extend( - result - for check in runtime_checks_for(selected, source=source) - if not (result := check.inspect())["ok"] - ) - if failures: - details = "; ".join( - f"{failure['name']}: {failure['error']}" - for failure in failures - ) - extras = ",".join( - get_capability(name).extra for name in selected - ) - raise RuntimeError( - f"Capability dependencies are unavailable: {details}. " - + capability_install_hint(extras) - ) - - -def runtime_distributions() -> tuple[str, ...]: - distributions = { - canonicalize_name(requirement.name) - for requirement in installed_base_requirements() - } - distributions.update( - canonicalize_name(requirement.name) - for requirement in requirements_for(capability_names()) + external: bool = False, + allowlist: tuple[str, ...] = (), + platform_runtime_checks: tuple[RuntimeCheckBinding, ...] = (), + storage_requirements: Iterable[Requirement] | None = None, +) -> CapabilityRegistry: + plugins = list(_builtin_plugins()) + if external: + for entry_point in _external_entry_points(allowlist): + distribution = entry_point.dist + identity = ( + f"{distribution.name}:{entry_point.name}" + if distribution is not None + else entry_point.name + ) + try: + loaded = entry_point.load() + plugin = ( + loaded() + if callable(loaded) + and not isinstance(loaded, CapabilityPlugin) + else loaded + ) + except Exception as exc: + raise RuntimeError( + f"Could not load capability entry point " + f"{identity!r}." + ) from exc + if not isinstance(plugin, CapabilityPlugin): + raise RuntimeError( + f"Capability entry point {identity!r} did not " + "return a CapabilityPlugin." + ) + plugin = plugin.model_copy( + update={ + "provenance": CapabilityProvenance( + distribution=( + distribution.name + if distribution is not None + else "unknown" + ), + entry_point=entry_point.name, + version=( + distribution.version + if distribution is not None + else None + ), + ) + } + ) + plugins.append(plugin) + return CapabilityRegistry( + plugins, + platform_runtime_checks=platform_runtime_checks, + storage_requirements=storage_requirements, ) - return tuple(sorted(distributions, key=str.lower)) diff --git a/src/vidxp/capabilities/scene/config.py b/src/vidxp/capabilities/scene/config.py index 87b2e34..d52501b 100644 --- a/src/vidxp/capabilities/scene/config.py +++ b/src/vidxp/capabilities/scene/config.py @@ -8,7 +8,7 @@ class SceneConfig(CapabilityConfig): batch_size: int = Field(default=32, gt=0) - model: str = Field(default="ViT-B/32", min_length=1) + sample_fps: float = Field(default=1.0, gt=0) def scene_config(config: IndexConfig) -> SceneConfig: diff --git a/src/vidxp/capabilities/scene/definition.py b/src/vidxp/capabilities/scene/definition.py index 6deaf99..aee444c 100644 --- a/src/vidxp/capabilities/scene/definition.py +++ b/src/vidxp/capabilities/scene/definition.py @@ -4,12 +4,16 @@ from vidxp.capabilities.contracts import ( CapabilityDefinition, + CapabilityExecutor, + CapabilityPlugin, OperationDefinition, PreparationContext, + module_import_check, ) -from vidxp.capabilities.scene.config import SceneConfig, scene_config +from vidxp.capabilities.scene.config import SceneConfig from vidxp.capabilities.scene.indexing import VISUAL_PROCESSOR -from vidxp.capabilities.scene.models import get_clip_model +from vidxp.capabilities.scene.models import get_scene_model +from vidxp.capabilities.scene.specs import SIGLIP2_MODEL from vidxp.capabilities.scene.operations import search_operation from vidxp.capabilities.schemas import SearchInput, SearchResult from vidxp.capabilities.visual import index_capabilities @@ -20,26 +24,30 @@ def prepare_models( context: PreparationContext, progress: ProgressCallback | None, ) -> tuple[str, ...]: - settings = SceneConfig.model_validate(context.settings) + SceneConfig.model_validate(context.settings) if progress is not None: progress( { "state": "preparing", "stage": "scene_model", "message": ( - f"Preparing scene model: CLIP {settings.model}" + f"Preparing scene model: SigLIP2 {SIGLIP2_MODEL.model_id}" ), } ) - get_clip_model(settings.model, context.device) - return (settings.model,) + get_scene_model(context.runtime, download=True, progress=progress) + return (SIGLIP2_MODEL.model_id,) def model_manifest( config: IndexConfig, _sources: tuple[VideoSource, ...], ) -> Mapping[str, Any]: - return {"scene": scene_config(config).model} + return { + "scene": { + **SIGLIP2_MODEL.identity(), + } + } DEFINITION = CapabilityDefinition( @@ -48,16 +56,45 @@ def model_manifest( extra="scene", config_model=SceneConfig, collection_name="scene", - indexer=index_capabilities, - index_processor=VISUAL_PROCESSOR, index_stage="visual_indexing", - prepare=prepare_models, - model_manifest=model_manifest, + execution_group="visual", + prepares_models=True, + model_specs=(SIGLIP2_MODEL,), operations={ "search": OperationDefinition( input_model=SearchInput, output_model=SearchResult, - handler=search_operation, ) }, ) + + +def create_executor() -> CapabilityExecutor: + return CapabilityExecutor( + indexer=index_capabilities, + index_processor=VISUAL_PROCESSOR, + operations={"search": search_operation}, + prepare=prepare_models, + model_manifest=model_manifest, + runtime_checks=( + module_import_check("OpenCV import", "cv2", "VideoCapture"), + module_import_check("Torch import", "torch"), + module_import_check( + "Transformers import", + "transformers", + "AutoModel", + "AutoProcessor", + ), + module_import_check( + "Hugging Face Hub import", + "huggingface_hub", + "snapshot_download", + ), + ), + ) + + +PLUGIN = CapabilityPlugin( + definition=DEFINITION, + executor_factory=create_executor, +) diff --git a/src/vidxp/capabilities/scene/indexing.py b/src/vidxp/capabilities/scene/indexing.py index 33cb1a0..5f721e6 100644 --- a/src/vidxp/capabilities/scene/indexing.py +++ b/src/vidxp/capabilities/scene/indexing.py @@ -4,7 +4,8 @@ from typing import Any from vidxp.capabilities.scene.config import scene_config -from vidxp.capabilities.scene.models import get_clip_model +from vidxp.capabilities.scene.models import SceneModel, get_scene_model +from vidxp.capabilities.scene.specs import SIGLIP2_MODEL from vidxp.core.contracts import ( CancellationToken, IndexConfig, @@ -13,26 +14,35 @@ stable_source_id, ) from vidxp.core.indexing_common import ProgressCallback, report_progress -from vidxp.core.storage import IndexStorage +from vidxp.core.video import FrameSampling +from vidxp.ports import IndexStore, ModelRuntimePort @dataclass class SceneIndexState: - model: Any - preprocess: Any + provider: SceneModel stored_frames: int = 0 -def encode_scene_batch(samples, model, preprocess, device): +def scene_sampling(config: IndexConfig, info) -> FrameSampling: + return FrameSampling( + source_fps=info.fps, + target_fps=scene_config(config).sample_fps, + ) + + +def encode_scene_batch(samples, provider: SceneModel): import torch from PIL import Image - images = torch.stack( - [preprocess(Image.fromarray(sample.frame)) for sample in samples] - ).to(device) - with torch.no_grad(): - features = model.encode_image(images) - features /= features.norm(dim=-1, keepdim=True) + inputs = provider.processor( + images=[Image.fromarray(sample.frame) for sample in samples], + return_tensors="pt", + ) + inputs = {name: value.to(provider.device) for name, value in inputs.items()} + with torch.inference_mode(): + features = provider.model.get_image_features(**inputs).pooler_output + features = torch.nn.functional.normalize(features, dim=-1) return features.cpu().numpy().tolist() @@ -43,10 +53,12 @@ def scene_records( config: IndexConfig, ) -> list[StorageRecord]: records = [] + sampling = scene_sampling(config, info) for sample, vector in zip(samples, vectors): - end = min( - info.duration, - sample.timestamp + config.frame_stride / info.fps, + end = sampling.next_sample_timestamp( + sample.frame_index, + frame_count=info.frame_count, + duration=info.duration, ) if end <= sample.timestamp: end = sample.timestamp + 1 / info.fps @@ -55,6 +67,7 @@ def scene_records( str(config.video_id), "scene", f"f{sample.frame_index:012d}", + generation_id=config.generation_id, ) records.append( StorageRecord( @@ -80,7 +93,7 @@ def process_scene_samples( state: SceneIndexState, info, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, cancellation: CancellationToken, ) -> None: settings = scene_config(config) @@ -88,9 +101,7 @@ def process_scene_samples( cancellation.raise_if_cancelled() vectors = encode_scene_batch( group, - state.model, - state.preprocess, - config.device, + state.provider, ) state.stored_frames += storage.upsert( "scene", @@ -101,22 +112,25 @@ def process_scene_samples( class SceneVisualProcessor: + def sampling(self, config: IndexConfig, info) -> FrameSampling: + return scene_sampling(config, info) + def batch_size(self, config: IndexConfig) -> int: return scene_config(config).batch_size def prepare( self, config: IndexConfig, + runtime: ModelRuntimePort, progress: ProgressCallback | None, ) -> SceneIndexState: - settings = scene_config(config) report_progress( progress, "preparing_scene_model", - f"Preparing scene model: CLIP {settings.model}.", + f"Preparing scene model: SigLIP2 {SIGLIP2_MODEL.model_id}.", ) - model, preprocess = get_clip_model(settings.model, config.device) - return SceneIndexState(model, preprocess) + provider = get_scene_model(runtime) + return SceneIndexState(provider) def process( self, @@ -125,7 +139,7 @@ def process( state: SceneIndexState, info, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, cancellation: CancellationToken, ) -> None: process_scene_samples( @@ -142,7 +156,7 @@ def finalize( state: SceneIndexState, *, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, ) -> tuple[dict[str, Any], int]: return {"scene_frames": state.stored_frames}, state.stored_frames diff --git a/src/vidxp/capabilities/scene/models.py b/src/vidxp/capabilities/scene/models.py index 380f9f1..50d8da8 100644 --- a/src/vidxp/capabilities/scene/models.py +++ b/src/vidxp/capabilities/scene/models.py @@ -1,14 +1,62 @@ from __future__ import annotations -from functools import lru_cache +from dataclasses import dataclass +from typing import Any, Callable +from vidxp.ports import ModelRuntimePort +from vidxp.model_contracts import loaded_compute_precision +from vidxp.capabilities.scene.specs import SIGLIP2_MODEL -@lru_cache -def get_clip_model(model_name: str, device: str): - import clip - return clip.load(model_name, device=device) +@dataclass(frozen=True) +class SceneModel: + model: Any + processor: Any + device: str -def clear_model_cache() -> None: - get_clip_model.cache_clear() +def get_scene_model( + runtime: ModelRuntimePort, + *, + download: bool = False, + progress: Callable[[dict[str, Any]], None] | None = None, +) -> SceneModel: + device = runtime.device_for("scene") + key = SIGLIP2_MODEL.key(device) + + def load() -> SceneModel: + from transformers import AutoModel, AutoProcessor + + snapshot = runtime.resolve_model( + SIGLIP2_MODEL, + download=download, + progress=progress, + ) + if progress is not None: + progress( + { + "state": "preparing", + "stage": "loading_model", + "message": f"Loading {SIGLIP2_MODEL.model_id}.", + } + ) + common = { + "cache_dir": str(runtime.model_cache), + "local_files_only": True, + } + model = AutoModel.from_pretrained(snapshot, **common).to(device) + model.eval() + runtime.record_compute_precision( + SIGLIP2_MODEL.capability, + loaded_compute_precision( + model, + fallback=SIGLIP2_MODEL.weights_precision, + ), + ) + return SceneModel( + model=model, + processor=AutoProcessor.from_pretrained(snapshot, **common), + device=device, + ) + + return runtime.get_or_load(key, load) diff --git a/src/vidxp/capabilities/scene/operations.py b/src/vidxp/capabilities/scene/operations.py index be04be7..29b1a9b 100644 --- a/src/vidxp/capabilities/scene/operations.py +++ b/src/vidxp/capabilities/scene/operations.py @@ -3,12 +3,11 @@ from typing import Any, Mapping from vidxp.capabilities.contracts import CapabilityContext -from vidxp.capabilities.scene.config import scene_config -from vidxp.capabilities.scene.models import get_clip_model +from vidxp.capabilities.scene.models import get_scene_model from vidxp.capabilities.schemas import SearchInput, SearchResult from vidxp.capabilities.search import search_embeddings from vidxp.core.contracts import IndexConfig -from vidxp.core.storage import IndexStorage +from vidxp.ports import IndexStore, ModelRuntimePort REQUIRED_METADATA = frozenset( @@ -29,16 +28,22 @@ ) -def scene_embedding(query: str, config: IndexConfig) -> list[float]: - import clip +def scene_embedding( + query: str, + runtime: ModelRuntimePort, +) -> list[float]: import torch - settings = scene_config(config) - model, _ = get_clip_model(settings.model, config.device) - tokens = clip.tokenize([query]).to(config.device) - with torch.no_grad(): - features = model.encode_text(tokens) - features /= features.norm(dim=-1, keepdim=True) + provider = get_scene_model(runtime) + inputs = provider.processor( + text=[query], + padding="max_length", + return_tensors="pt", + ) + inputs = {name: value.to(provider.device) for name, value in inputs.items()} + with torch.inference_mode(): + features = provider.model.get_text_features(**inputs).pooler_output + features = torch.nn.functional.normalize(features, dim=-1) return features.cpu().numpy().tolist()[0] @@ -46,11 +51,12 @@ def search_scene( query: str, *, config: IndexConfig, + runtime: ModelRuntimePort, top_k: int = 10, video_id: str | None = None, query_id: str | None = None, filters: Mapping[str, Any] | None = None, - storage: IndexStorage | None = None, + storage: IndexStore, ) -> SearchResult: cleaned = query.strip() if not cleaned: @@ -60,7 +66,7 @@ def search_scene( return search_embeddings( cleaned, "scene", - scene_embedding(cleaned, config), + scene_embedding(cleaned, runtime), config=config, required_metadata=REQUIRED_METADATA, top_k=top_k, @@ -80,5 +86,7 @@ def search_operation( request.query, config=config, top_k=request.top_k, - video_id=config.video_id, + video_id=request.media_id or config.video_id, + runtime=context.runtime, + storage=context.require_storage(), ) diff --git a/src/vidxp/capabilities/scene/requirements.txt b/src/vidxp/capabilities/scene/requirements.txt index 213eddc..ac5dfd9 100644 --- a/src/vidxp/capabilities/scene/requirements.txt +++ b/src/vidxp/capabilities/scene/requirements.txt @@ -1,7 +1,6 @@ -clip-anytorch==2.6.0 -numpy>=2.1,<3 -opencv-python -Pillow>=7.0.0 -# clip-anytorch still imports pkg_resources, removed in setuptools 81. -setuptools<81 -torch +numpy>=2.3,<3 +opencv-python-headless>=5.0.0.93,<6 +Pillow>=12.3,<13 +torch>=2.13,<3 +transformers>=5.14.1,<6 +huggingface-hub>=1.25.1,<2 diff --git a/src/vidxp/capabilities/scene/specs.py b/src/vidxp/capabilities/scene/specs.py new file mode 100644 index 0000000..920abf4 --- /dev/null +++ b/src/vidxp/capabilities/scene/specs.py @@ -0,0 +1,16 @@ +from vidxp.model_contracts import ModelSpec + + +SIGLIP2_MODEL = ModelSpec( + capability="scene", + provider="transformers", + model_id="google/siglip2-base-patch16-224", + revision="75de2d55ec2d0b4efc50b3e9ad70dba96a7b2fa2", + download_size_bytes=1_539_458_338, + weights_file="model.safetensors", + weights_sha256=( + "612923381c76ec5a9bed335d1c48827e3f2e506ac31b044b63b2031fadee6a0b" + ), + license="Apache-2.0", + weights_precision="float32", +) diff --git a/src/vidxp/capabilities/schemas.py b/src/vidxp/capabilities/schemas.py index a70b948..601818a 100644 --- a/src/vidxp/capabilities/schemas.py +++ b/src/vidxp/capabilities/schemas.py @@ -1,48 +1,16 @@ from __future__ import annotations -from typing import Any - from pydantic import Field -from vidxp.capabilities.contracts import CapabilityInput, CapabilityOutput -from vidxp.core.contracts import INDEX_SCHEMA_VERSION +from vidxp.application_models import SearchHit, SearchQuery, SearchResult +from vidxp.capabilities.contracts import CapabilityInput +from vidxp.core.identifiers import MediaId class SearchInput(CapabilityInput): - query: str = Field(min_length=1) - top_k: int = Field(default=10, gt=0) - - -class SearchHit(CapabilityOutput): - rank: int = Field(gt=0) - video_id: str = Field(min_length=1) - start: float = Field(ge=0) - end: float = Field(gt=0) - score: float - raw_distance: float - modality: str = Field(min_length=1) - source_id: str = Field(min_length=1) - metadata: dict[str, Any] = Field(default_factory=dict) - - def to_dict(self) -> dict[str, Any]: - return self.model_dump(mode="json") - - -class SearchResult(CapabilityOutput): - query_id: str = Field(min_length=1) - query: str = Field(min_length=1) - modality: str = Field(min_length=1) - hits: tuple[SearchHit, ...] = () + query: SearchQuery + top_k: int = Field(default=10, gt=0, le=100) + media_id: MediaId | None = None - def to_dict(self) -> dict[str, Any]: - return { - "schema_version": INDEX_SCHEMA_VERSION, - **self.model_dump(mode="json"), - } - def to_prediction(self) -> dict[str, list[dict[str, Any]]]: - return { - self.query_id: [ - hit.model_dump(mode="json") for hit in self.hits - ] - } +__all__ = ["SearchHit", "SearchInput", "SearchResult"] diff --git a/src/vidxp/capabilities/search.py b/src/vidxp/capabilities/search.py index 4b8a6ff..484abe6 100644 --- a/src/vidxp/capabilities/search.py +++ b/src/vidxp/capabilities/search.py @@ -11,7 +11,19 @@ IndexConfig, IndexSchemaError, ) -from vidxp.core.storage import IndexStorage +from vidxp.ports import IndexStore + + +PUBLIC_SEARCH_METADATA = frozenset( + { + "text", + "phrase_id", + "frame_index", + "timestamp", + "fps", + "duration", + } +) def distance_to_score(raw_distance: float) -> float: @@ -57,7 +69,9 @@ def _to_hits( hits = [] for rank, row in enumerate(ordered, start=1): metadata = row["metadata"] - missing = sorted(required_metadata - metadata.keys()) + missing = sorted( + (required_metadata | {"generation_id"}) - metadata.keys() + ) if missing: raise IndexSchemaError( "The saved index predates the benchmark-ready schema and must " @@ -74,14 +88,20 @@ def _to_hits( hits.append( SearchHit( rank=rank, + media_id=str(metadata["video_id"]), video_id=str(metadata["video_id"]), + generation_id=str(metadata["generation_id"]), start=start, end=end, score=distance_to_score(distance), raw_distance=distance, modality=modality, source_id=str(row["source_id"]), - metadata=metadata, + metadata={ + key: value + for key, value in metadata.items() + if key in PUBLIC_SEARCH_METADATA + }, ) ) return tuple(hits) @@ -98,7 +118,7 @@ def search_embeddings( video_id: str | None = None, query_id: str | None = None, filters: Mapping[str, Any] | None = None, - storage: IndexStorage | None = None, + storage: IndexStore, ) -> SearchResult: query = query.strip() if not query: @@ -109,19 +129,13 @@ def search_embeddings( raise ValueError( f"The {modality} modality is not present in this index run." ) - owns_storage = storage is None - store = storage or IndexStorage(config) - try: - rows = store.query( - modality, - embedding, - top_k=top_k, - video_id=video_id, - filters=filters, - ) - finally: - if owns_storage: - store.close() + rows = storage.query( + modality, + embedding, + top_k=top_k, + video_id=video_id, + filters=filters, + ) return SearchResult( query_id=query_id or stable_query_id(query, modality, config), query=query, diff --git a/src/vidxp/capabilities/visual.py b/src/vidxp/capabilities/visual.py index b7ef539..a49e2dd 100644 --- a/src/vidxp/capabilities/visual.py +++ b/src/vidxp/capabilities/visual.py @@ -5,15 +5,17 @@ from typing import Any, Protocol, Sequence from vidxp.capabilities.contracts import CapabilityIndexResult +from vidxp.capabilities.registry import CapabilityRegistry from vidxp.core.contracts import ( CancellationToken, IndexConfig, VideoSource, ) from vidxp.core.indexing_common import ProgressCallback, report_progress -from vidxp.core.storage import IndexStorage +from vidxp.ports import IndexStore, ModelRuntimePort from vidxp.core.video import ( FrameSample, + FrameSampling, FrameStreamStats, iter_frame_batches, probe_video, @@ -21,11 +23,14 @@ class VisualProcessor(Protocol): + def sampling(self, config: IndexConfig, info: Any) -> FrameSampling: ... + def batch_size(self, config: IndexConfig) -> int: ... def prepare( self, config: IndexConfig, + runtime: ModelRuntimePort, progress: ProgressCallback | None, ) -> Any: ... @@ -36,7 +41,7 @@ def process( state: Any, info: Any, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, cancellation: CancellationToken, ) -> None: ... @@ -45,7 +50,7 @@ def finalize( state: Any, *, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, ) -> tuple[dict[str, Any], int]: ... @@ -54,6 +59,7 @@ class _Participant: name: str processor: VisualProcessor state: Any + sampling: FrameSampling def _rgb_samples(samples) -> list[FrameSample]: @@ -72,26 +78,63 @@ def _rgb_samples(samples) -> list[FrameSample]: def _participants( names: Sequence[str], *, + info: Any, config: IndexConfig, + registry: CapabilityRegistry, + runtime: ModelRuntimePort, progress: ProgressCallback | None, timings: dict[str, float], ) -> list[_Participant]: - from vidxp.capabilities.registry import get_capability - participants = [] for name in names: - processor = get_capability(name).index_processor + processor = registry.executor(name).index_processor if processor is None: raise ValueError( f"Capability {name!r} does not provide a visual processor." ) started = perf_counter() - state = processor.prepare(config, progress) + state = processor.prepare(config, runtime, progress) timings[name] = perf_counter() - started - participants.append(_Participant(name, processor, state)) + sampling_factory = getattr(type(processor), "sampling", None) + sampling = ( + FrameSampling(frame_stride=config.frame_stride) + if sampling_factory is None + else sampling_factory(processor, config, info) + ) + if not isinstance(sampling, FrameSampling): + raise ValueError( + f"Capability {name!r} returned invalid frame sampling." + ) + participants.append(_Participant(name, processor, state, sampling)) return participants +def _is_participant_sample( + sample: FrameSample, + participant: _Participant, +) -> bool: + return participant.sampling.includes( + sample.frame_index, + sample.timestamp, + ) + + +def _expected_sample_count( + info: Any, + participants: Sequence[_Participant], +) -> int: + return sum( + any( + participant.sampling.includes( + frame_index, + frame_index / info.fps, + ) + for participant in participants + ) + for frame_index in range(max(0, info.frame_count)) + ) + + def _consume_visual_stream( source: VideoSource, *, @@ -99,7 +142,7 @@ def _consume_visual_stream( expected: int, info: Any, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, cancellation: CancellationToken, progress: ProgressCallback | None, timings: dict[str, float], @@ -108,7 +151,9 @@ def _consume_visual_stream( stream = iter( iter_frame_batches( source.path, - frame_stride=config.frame_stride, + samplings=tuple( + participant.sampling for participant in participants + ), batch_size=max( participant.processor.batch_size(config) for participant in participants @@ -128,9 +173,16 @@ def _consume_visual_stream( timings["frame_stream"] += perf_counter() - stream_started for participant in participants: + participant_samples = [ + sample + for sample in rgb_samples + if _is_participant_sample(sample, participant) + ] + if not participant_samples: + continue processor_started = perf_counter() participant.processor.process( - rgb_samples, + participant_samples, state=participant.state, info=info, config=config, @@ -155,7 +207,7 @@ def _finalize( participants: Sequence[_Participant], *, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, timings: dict[str, float], ) -> tuple[dict[str, Any], int]: summary: dict[str, Any] = {} @@ -183,8 +235,10 @@ def index_visuals( source: VideoSource, *, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, cancellation: CancellationToken, + registry: CapabilityRegistry, + runtime: ModelRuntimePort, progress: ProgressCallback | None = None, modalities: Sequence[str] | None = None, ) -> CapabilityIndexResult: @@ -201,18 +255,19 @@ def index_visuals( started = perf_counter() info = probe_video(source.path) - expected = ( - info.frame_count + config.frame_stride - 1 - ) // config.frame_stride timings = { "frame_stream": 0.0, } participants = _participants( selected, + info=info, config=config, + registry=registry, + runtime=runtime, progress=progress, timings=timings, ) + expected = _expected_sample_count(info, participants) report_progress( progress, "visual_indexing", @@ -259,8 +314,10 @@ def index_capabilities( source: VideoSource, *, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, cancellation: CancellationToken, + registry: CapabilityRegistry, + runtime: ModelRuntimePort, progress: ProgressCallback | None = None, modalities: Sequence[str] | None = None, ) -> CapabilityIndexResult: @@ -269,6 +326,8 @@ def index_capabilities( config=config, storage=storage, cancellation=cancellation, + registry=registry, + runtime=runtime, progress=progress, modalities=modalities, ) diff --git a/src/vidxp/capability_service.py b/src/vidxp/capability_service.py new file mode 100644 index 0000000..9b3844b --- /dev/null +++ b/src/vidxp/capability_service.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from vidxp.application_models import ( + CapabilityInfo, + CapabilityOperationInfo, + CapabilitySummary, +) +from vidxp.capabilities.registry import CapabilityRegistry + + +class CapabilityService: + """Transport-neutral capability metadata projection.""" + + def __init__(self, registry: CapabilityRegistry) -> None: + self.registry = registry + + def list(self) -> tuple[CapabilitySummary, ...]: + return tuple(self._summary(name) for name in self.registry.names()) + + def get(self, name: str) -> CapabilityInfo: + return self._info(name) + + def _summary(self, name: str) -> CapabilitySummary: + definition = self.registry.get(name) + return CapabilitySummary( + name=definition.name, + description=definition.description, + install_extra=definition.extra, + supports_indexing=definition.collection_name is not None, + prepares_models=definition.prepares_models, + provenance=self.registry.provenance(name), + ) + + def _info(self, name: str) -> CapabilityInfo: + definition = self.registry.get(name) + return CapabilityInfo( + **self._summary(name).model_dump(), + operations=tuple( + CapabilityOperationInfo( + name=operation_name, + requires_index=operation.requires_index, + input_schema=operation.input_model.model_json_schema(), + output_schema=operation.output_model.model_json_schema(), + ) + for operation_name, operation in definition.operations.items() + if operation.public + ), + ) diff --git a/src/vidxp/cli.py b/src/vidxp/cli.py index 7135302..30a0985 100644 --- a/src/vidxp/cli.py +++ b/src/vidxp/cli.py @@ -9,21 +9,20 @@ import typer from vidxp import __version__ -from vidxp.application import VidXPService -from vidxp.capabilities.actor.results import ActorClusterNotFoundError -from vidxp.capabilities.registry import CAPABILITIES +from vidxp.application_models import ApplicationError +from vidxp.benchmarks.cli import app as benchmark_app +from vidxp.cli_commands.actors import app as actor_app from vidxp.cli_commands.index import app as index_app +from vidxp.cli_commands.jobs import app as jobs_app +from vidxp.cli_commands.mcp import mcp_config +from vidxp.cli_commands.media import app as media_app +from vidxp.cli_commands.artifacts import app as artifacts_app from vidxp.cli_commands.repositories import app as repositories_app -from vidxp.cli_commands.runtime import doctor, prepare, ui -from vidxp.cli_commands.search import app as search_app +from vidxp.cli_commands.runtime import doctor, initialize, prepare, ui +from vidxp.cli_commands.search import search +from vidxp.cli_commands.query import query from vidxp.cli_support import CLIState, OutputFormat -from vidxp.core.contracts import IndexSchemaError -from vidxp.dependencies import requirements_available -from vidxp.index_state import ( - IndexingInProgressError, - IndexNotReadyError, -) -from vidxp.repositories import resolve_repository +from vidxp.composition import create_local_application app = typer.Typer( @@ -31,27 +30,19 @@ help="Index and search video with installable capabilities.", ) app.add_typer(index_app, name="index") -app.add_typer(search_app, name="search") +app.add_typer(jobs_app, name="jobs") +app.add_typer(media_app, name="media") +app.add_typer(artifacts_app, name="artifacts") +app.command("search")(search) +app.command("query")(query) +app.command("mcp-config")(mcp_config) app.add_typer(repositories_app, name="repositories") +app.add_typer(actor_app, name="actors") -def _load_benchmark_app(): - if not requirements_available("vidxp.benchmarks"): - return None - from vidxp.benchmarks.cli import app as benchmark_app - - return benchmark_app - - -if _benchmark_app := _load_benchmark_app(): - app.add_typer(_benchmark_app, name="benchmark") -for _capability in CAPABILITIES.values(): - if _capability.cli_factory is not None: - app.add_typer( - _capability.cli_factory(), - name=_capability.cli_name, - ) +app.add_typer(benchmark_app, name="benchmark") app.command()(doctor) +app.command("init")(initialize) app.command()(prepare) app.command()(ui) @@ -99,6 +90,17 @@ def app_options( help="Override the selected repository index directory.", ), ] = None, + data_directory: Annotated[ + Path | None, + typer.Option( + "--data-dir", + file_okay=False, + help=( + "Store VidXP models and the default repository beneath this " + "directory." + ), + ), + ] = None, device: Annotated[ str | None, typer.Option( @@ -119,19 +121,20 @@ def app_options( typer.Option("--quiet", "-q", help="Suppress progress output."), ] = False, ) -> None: - registry, repository = resolve_repository( + if ctx.invoked_subcommand in {"init", "mcp-config"}: + return + local = create_local_application( registry_path=config_file, - name=repository_name, + repository_name=repository_name, index_directory=index_directory, + data_directory=data_directory, device=device, ) + ctx.call_on_close(local.close) ctx.obj = CLIState( - service=VidXPService( - repository.index_directory, - device=repository.device, - ), - registry=registry, - repository=repository, + local=local, + registry=local.repositories, + repository=local.repository, output_format=output_format, quiet=quiet, ) @@ -161,15 +164,20 @@ def _exit_code(exc: Exception) -> int: def _emit_error(exc: Exception, *, json_output: bool) -> None: message = _error_message(exc) if json_output: + error = ( + exc.to_dict() + if isinstance(exc, ApplicationError) + else { + "type": type(exc).__name__, + "message": message, + "exit_code": _exit_code(exc), + } + ) typer.echo( json.dumps( { "ok": False, - "error": { - "type": type(exc).__name__, - "message": message, - "exit_code": _exit_code(exc), - }, + "error": error, }, ensure_ascii=False, indent=2, @@ -185,30 +193,23 @@ def _emit_error(exc: Exception, *, json_output: bool) -> None: def main() -> None: try: - app(standalone_mode=False) + exit_code = app(standalone_mode=False) + if isinstance(exit_code, int) and exit_code: + raise SystemExit(exit_code) except typer.Exit as exc: raise SystemExit(exc.exit_code) from None except typer.Abort as exc: _emit_error(exc, json_output=_wants_json()) raise SystemExit(1) from exc except Exception as exc: + if isinstance(exc, ApplicationError): + _emit_error(exc, json_output=_wants_json()) + raise SystemExit(_exit_code(exc)) from exc is_command_error = hasattr(exc, "exit_code") and hasattr( exc, "format_message", ) - is_expected_runtime_error = isinstance( - exc, - ( - ActorClusterNotFoundError, - FileNotFoundError, - IndexNotReadyError, - IndexingInProgressError, - IndexSchemaError, - RuntimeError, - ValueError, - ), - ) - if not is_command_error and not is_expected_runtime_error: + if not is_command_error: raise _emit_error(exc, json_output=_wants_json()) raise SystemExit(_exit_code(exc)) from exc diff --git a/src/vidxp/capabilities/actor/cli.py b/src/vidxp/cli_commands/actors.py similarity index 62% rename from src/vidxp/capabilities/actor/cli.py rename to src/vidxp/cli_commands/actors.py index 51767ed..e89a4f8 100644 --- a/src/vidxp/capabilities/actor/cli.py +++ b/src/vidxp/cli_commands/actors.py @@ -1,21 +1,21 @@ from __future__ import annotations -from pathlib import Path from typing import Annotated, Iterable import typer from rich.console import Console from rich.table import Table +from vidxp.application_models import CreateActorOverlayCommand from vidxp.cli_support import ( CLIState, OutputFormat, effective_output_format, + emit_job_progress, emit_json, + emit_progress, state_from_context, ) - - app = typer.Typer( no_args_is_help=True, help="Inspect and render actor clusters.", @@ -30,7 +30,7 @@ def complete_cluster( if not isinstance(state, CLIState): return try: - clusters = state.service.actor_clusters() + clusters = state.service.actor_clusters(page_size=100).clusters except Exception: return for cluster in clusters: @@ -44,6 +44,14 @@ def complete_cluster( @app.command("list") def actors_list( ctx: typer.Context, + page_size: Annotated[ + int, + typer.Option("--page-size", min=1, max=100), + ] = 50, + cursor: Annotated[ + str | None, + typer.Option("--cursor", help="Cursor returned by the previous page."), + ] = None, json_output: Annotated[ bool, typer.Option("--json", help="Emit machine-readable JSON."), @@ -52,22 +60,30 @@ def actors_list( """List actor clusters in the selected index.""" state = state_from_context(ctx) - clusters = state.service.actor_clusters() + page = state.service.actor_clusters( + page_size=page_size, + cursor=cursor, + ) + clusters = page.clusters payload = { "clusters": [cluster.to_dict() for cluster in clusters], "count": len(clusters), + "total": page.total, + "next_cursor": page.next_cursor, } if effective_output_format(state, json_output) == OutputFormat.json: emit_json(payload) return table = Table(title="Actor clusters") table.add_column("Cluster") + table.add_column("Media") table.add_column("Detections", justify="right") table.add_column("First", justify="right") table.add_column("Last", justify="right") for cluster in clusters: table.add_row( cluster.cluster_id, + cluster.media_id, str(cluster.detection_count), f"{cluster.first_timestamp:.3f}s", f"{cluster.last_timestamp:.3f}s", @@ -89,6 +105,10 @@ def actors_inspect( int, typer.Option("--limit", min=1, help="Maximum detections to display."), ] = 20, + cursor: Annotated[ + str | None, + typer.Option("--cursor", help="Cursor returned by the previous page."), + ] = None, json_output: Annotated[ bool, typer.Option("--json", help="Emit machine-readable JSON."), @@ -97,17 +117,21 @@ def actors_inspect( """Inspect retained detections for one actor cluster.""" state = state_from_context(ctx) - detections = state.service.actor_detections(cluster_id) - if not detections: - raise typer.BadParameter(f"Actor cluster {cluster_id} was not found.") + page = state.service.actor_detections( + cluster_id, + page_size=min(limit, 100), + cursor=cursor, + ) + detections = page.detections payload = { "cluster_id": cluster_id, - "detection_count": len(detections), + "detection_count": page.total, "detections": [ detection.model_dump(mode="json") - for detection in detections[:limit] + for detection in detections ], - "truncated": len(detections) > limit, + "truncated": page.next_cursor is not None, + "next_cursor": page.next_cursor, } if effective_output_format(state, json_output) == OutputFormat.json: emit_json(payload) @@ -116,15 +140,17 @@ def actors_inspect( table.add_column("Frame", justify="right") table.add_column("Timestamp", justify="right") table.add_column("Detection") - for detection in detections[:limit]: + for detection in detections: table.add_row( str(detection.frame_index), f"{detection.timestamp:.3f}s", detection.detection_id, ) Console().print(table) - if len(detections) > limit: - typer.echo(f"Showing {limit} of {len(detections)} detections.") + if page.next_cursor is not None: + typer.echo( + f"Showing {len(detections)} detections; more are available." + ) @app.command("render") @@ -137,47 +163,46 @@ def actors_render( help="Actor cluster identifier.", ), ], - input_path: Annotated[ - Path, - typer.Argument( - exists=True, - dir_okay=False, - readable=True, - resolve_path=True, - help="Source video used to create the active index.", - ), - ], - output_path: Annotated[ - Path, - typer.Option( - "--output", - "-o", - dir_okay=False, - help="Rendered video destination.", - ), - ] = Path("output.mp4"), json_output: Annotated[ bool, typer.Option("--json", help="Emit machine-readable JSON."), ] = False, + detach: Annotated[ + bool, + typer.Option( + "--detach", + help="Return after the durable job is queued.", + ), + ] = False, ) -> None: """Render one actor cluster as a result video.""" state = state_from_context(ctx) - result = state.service.render_actor( - cluster_id, - input_path, - output_path, + output_format = effective_output_format(state, json_output) + show_progress = ( + not detach + and not state.quiet + and output_format == OutputFormat.rich ) - payload = { - "cluster_id": cluster_id, - "output_path": str(result.output_path), - "detection_count": result.detection_count, - } - if effective_output_format(state, json_output) == OutputFormat.json: + if show_progress: + emit_progress("Starting actor-overlay rendering...") + job = state.jobs.submit_actor_overlay( + CreateActorOverlayCommand(cluster_id=cluster_id) + ) + if not detach: + job = state.jobs.wait( + job.job_id, + progress=emit_job_progress if show_progress else None, + ) + payload = job.model_dump(mode="json") + if output_format == OutputFormat.json: emit_json(payload) else: typer.secho( - f"Video saved as {result.output_path}", + ( + f"Actor overlay job queued: {job.job_id}" + if detach + else f"Actor overlay job completed: {job.job_id}" + ), fg=typer.colors.GREEN, ) diff --git a/src/vidxp/cli_commands/artifacts.py b/src/vidxp/cli_commands/artifacts.py new file mode 100644 index 0000000..8d590ff --- /dev/null +++ b/src/vidxp/cli_commands/artifacts.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import Annotated +from uuid import uuid4 + +import typer + +from vidxp.application_models import ( + ArtifactJobResult, + CreateSnippetCommand, + SnippetProfile, +) +from vidxp.cli_support import ( + OutputFormat, + effective_output_format, + emit_job_progress, + emit_json, + emit_progress, + require_media_runtime, + state_from_context, +) + + +app = typer.Typer(no_args_is_help=True, help="Inspect generated artifacts.") + + +@app.command("snippet") +def create_snippet( + ctx: typer.Context, + media_id: Annotated[ + str, + typer.Argument(help="Cataloged media identifier."), + ], + start_seconds: Annotated[ + float, + typer.Argument(min=0, help="Snippet start in seconds."), + ], + end_seconds: Annotated[ + float, + typer.Argument(min=0, help="Snippet end in seconds."), + ], + profile: Annotated[ + SnippetProfile, + typer.Option(help="Output compatibility profile."), + ] = SnippetProfile.compatible_mp4, + json_output: Annotated[ + bool, + typer.Option("--json", help="Emit machine-readable JSON."), + ] = False, + detach: Annotated[ + bool, + typer.Option( + "--detach", + help="Return after the durable job is queued.", + ), + ] = False, +) -> None: + """Create a managed video snippet artifact.""" + + if end_seconds <= start_seconds: + raise typer.BadParameter( + "The snippet end must be greater than its start.", + param_hint="end_seconds", + ) + require_media_runtime() + state = state_from_context(ctx) + output_format = effective_output_format(state, json_output) + show_progress = ( + not detach + and not state.quiet + and output_format == OutputFormat.rich + ) + if show_progress: + emit_progress("Starting snippet rendering...") + job = state.jobs.submit_snippet( + CreateSnippetCommand( + media_id=media_id, + start_seconds=start_seconds, + end_seconds=end_seconds, + profile=profile, + ) + ) + if not detach: + job = state.jobs.wait( + job.job_id, + progress=emit_job_progress if show_progress else None, + ) + payload = job.model_dump(mode="json") + if output_format == OutputFormat.json: + emit_json(payload) + else: + if detach: + typer.secho( + f"Snippet job queued: {job.job_id}", + fg=typer.colors.GREEN, + ) + elif isinstance(job.result, ArtifactJobResult): + artifact = job.result.result + typer.secho( + f"Clip ready: {artifact.artifact_id} " + f"({artifact.byte_size:,} bytes)", + fg=typer.colors.GREEN, + ) + typer.echo( + "Download it with: " + f"vidxp artifacts download {artifact.artifact_id}" + ) + else: + typer.secho( + f"Snippet artifact job completed: {job.job_id}", + fg=typer.colors.GREEN, + ) + + +@app.command("show") +def show_artifact( + ctx: typer.Context, + artifact_id: Annotated[ + str, + typer.Argument(help="Artifact identifier."), + ], + json_output: Annotated[ + bool, + typer.Option("--json", help="Emit machine-readable JSON."), + ] = False, +) -> None: + """Show metadata for one generated artifact.""" + + state = state_from_context(ctx) + artifact = state.service.get_artifact(artifact_id) + payload = artifact.model_dump(mode="json") + if effective_output_format(state, json_output) == OutputFormat.json: + emit_json(payload) + else: + typer.echo( + f"{artifact.kind.value} {artifact.artifact_id} " + f"({artifact.byte_size:,} bytes)" + ) + + +@app.command("download") +def download_artifact( + ctx: typer.Context, + artifact_id: Annotated[ + str, + typer.Argument(help="Artifact identifier from a completed job."), + ], + destination: Annotated[ + Path | None, + typer.Argument( + help=( + "Output file or existing directory. Defaults to the generated " + "artifact filename in the current directory." + ), + ), + ] = None, + force: Annotated[ + bool, + typer.Option( + "--force", + help="Replace an existing output file.", + ), + ] = False, + json_output: Annotated[ + bool, + typer.Option("--json", help="Emit machine-readable JSON."), + ] = False, +) -> None: + """Download a generated clip or artifact to a local file.""" + + state = state_from_context(ctx) + resource = state.service.open_artifact_content(artifact_id) + output = destination or Path(resource.filename) + if output.is_dir(): + output = output / resource.filename + output = output.expanduser().resolve() + if not output.parent.is_dir(): + raise typer.BadParameter( + "The output directory does not exist.", + param_hint="destination", + ) + if output.exists() and not force: + raise typer.BadParameter( + "The output file already exists; pass --force to replace it.", + param_hint="destination", + ) + + temporary = output.with_name(f".{output.name}.{uuid4().hex}.tmp") + try: + shutil.copyfile(resource.path, temporary) + if output.exists() and not force: + raise typer.BadParameter( + "The output file already exists; pass --force to replace it.", + param_hint="destination", + ) + temporary.replace(output) + finally: + temporary.unlink(missing_ok=True) + + payload = { + "artifact_id": artifact_id, + "path": str(output), + "mime_type": resource.mime_type, + "byte_size": resource.byte_size, + "sha256": resource.etag, + } + if effective_output_format(state, json_output) == OutputFormat.json: + emit_json(payload) + else: + typer.secho( + f"Downloaded {resource.byte_size:,} bytes to {output}", + fg=typer.colors.GREEN, + ) diff --git a/src/vidxp/cli_commands/index.py b/src/vidxp/cli_commands/index.py index f552757..8d0036a 100644 --- a/src/vidxp/cli_commands/index.py +++ b/src/vidxp/cli_commands/index.py @@ -1,10 +1,15 @@ from __future__ import annotations -from pathlib import Path from typing import Annotated, Iterable import typer +from rich.console import Console +from rich.table import Table +from vidxp.application_models import ( + CreateIndexCommand, + RemoveIndexCommand, +) from vidxp.cli_support import ( CLIState, IndexProgress, @@ -23,28 +28,50 @@ def create_index( state: CLIState, - path: Path, + media_id: str, *, modalities: Iterable[str], frame_stride: int, + scene_sample_fps: float | None, capability_options: dict[str, dict], + detach: bool = False, ) -> dict: show_progress = ( not state.quiet and state.output_format == OutputFormat.rich ) + selected = tuple(modalities) + state.service.require_models(selected) with IndexProgress(show_progress) as progress: - summary = state.service.create_index( - path, - modalities=modalities, - frame_stride=frame_stride, - capability_options=capability_options, - progress_callback=progress.update, + job = state.jobs.submit_index( + CreateIndexCommand( + media_id=media_id, + modalities=selected, + frame_stride=frame_stride, + scene_sample_fps=scene_sample_fps, + capability_options=capability_options, + ), ) + if not detach: + job = state.jobs.wait( + job.job_id, + progress=lambda current: ( + progress.update( + current.progress.model_dump(mode="python") + ) + if current.progress is not None + else None + ), + ) + summary = job.model_dump(mode="json") if state.output_format == OutputFormat.json: emit_json(summary) else: typer.secho( - "Video indexing completed successfully.", + ( + f"Indexing job queued: {job.job_id}" + if detach + else f"Video indexing completed: {job.job_id}" + ), fg=typer.colors.GREEN, bold=True, ) @@ -54,15 +81,9 @@ def create_index( @app.command("create") def index_create( ctx: typer.Context, - path: Annotated[ - Path, - typer.Argument( - exists=True, - dir_okay=False, - readable=True, - resolve_path=True, - help="Local video file to index.", - ), + media_id: Annotated[ + str, + typer.Argument(help="Registered media identifier to index."), ], modalities: Annotated[ list[str] | None, @@ -77,9 +98,23 @@ def index_create( typer.Option( "--frame-stride", min=1, - help="Materialize every Nth frame for visual modalities.", + help=( + "Materialize every Nth frame for actor and legacy visual " + "indexing." + ), ), ] = 1, + scene_sample_fps: Annotated[ + float | None, + typer.Option( + "--scene-sample-fps", + min=0.01, + help=( + "Target scene samples per second; lower-FPS media uses every " + "available frame." + ), + ), + ] = None, capability_options: Annotated[ list[str] | None, typer.Option( @@ -90,19 +125,65 @@ def index_create( ), ), ] = None, + detach: Annotated[ + bool, + typer.Option( + "--detach", + help="Return after the durable job is queued.", + ), + ] = False, ) -> None: - """Create or replace a local index for one video.""" + """Add media or replace its immutable generation in the active index.""" state = state_from_context(ctx) + indexable = tuple( + capability.name + for capability in state.service.list_capabilities() + if capability.supports_indexing + ) create_index( state, - path, - modalities=selected_modalities(modalities), + media_id=media_id, + modalities=selected_modalities( + modalities, + indexable, + ), frame_stride=frame_stride, + scene_sample_fps=scene_sample_fps, capability_options=parse_capability_options(capability_options), + detach=detach, ) +@app.command("remove") +def index_remove( + ctx: typer.Context, + media_id: Annotated[ + str, + typer.Argument(help="Media identifier to remove from the active index."), + ], + json_output: Annotated[ + bool, + typer.Option("--json", help="Emit machine-readable JSON."), + ] = False, +) -> None: + """Remove one media item from the active snapshot.""" + + state = state_from_context(ctx) + removed = state.service.remove_from_index( + RemoveIndexCommand(media_id=media_id) + ) + payload = {"removed": removed, "media_id": media_id} + if effective_output_format(state, json_output) == OutputFormat.json: + emit_json(payload) + else: + typer.echo( + "Media removed." + if removed + else "The media identifier was not in the active index." + ) + + @app.command("status") def index_status( ctx: typer.Context, @@ -115,11 +196,75 @@ def index_status( state = state_from_context(ctx) emit_status( - state.service.index_status(), + state.service.index_status().model_dump(mode="json"), output_format=effective_output_format(state, json_output), ) +@app.command("list") +def index_list( + ctx: typer.Context, + json_output: Annotated[ + bool, + typer.Option("--json", help="Emit machine-readable JSON."), + ] = False, +) -> None: + """List registered metadata for media in the active index snapshot.""" + + state = state_from_context(ctx) + status = state.service.index_status() + summary = status.summary + assets = ( + () + if summary is None + else tuple( + state.service.get_media(media_id) + for media_id in summary.media_ids + ) + ) + payload = { + "state": status.state, + "message": status.message, + "snapshot_id": None if summary is None else summary.snapshot_id, + "media_count": 0 if summary is None else summary.media_count, + "media_ids_truncated": ( + False if summary is None else summary.media_ids_truncated + ), + "modalities": [] if summary is None else list(summary.modalities), + "items": [asset.model_dump(mode="json") for asset in assets], + } + if effective_output_format(state, json_output) == OutputFormat.json: + emit_json(payload) + return + if summary is None: + typer.echo(status.message) + return + + table = Table(title="Active index media") + table.add_column("ID") + table.add_column("Filename") + table.add_column("Duration", justify="right") + table.add_column("Size", justify="right") + for asset in assets: + table.add_row( + asset.media_id, + asset.original_filename, + f"{asset.duration_seconds:.3f}s", + f"{asset.byte_size:,}", + ) + Console().print(table) + typer.echo( + f"Snapshot {summary.snapshot_id}: {summary.media_count} media item(s); " + f"modalities: {', '.join(summary.modalities) or 'none'}." + ) + if summary.media_ids_truncated: + typer.secho( + "The active snapshot is larger than this status page; some media " + "items are not shown.", + fg=typer.colors.YELLOW, + ) + + @app.command("clear") def index_clear( ctx: typer.Context, @@ -132,18 +277,17 @@ def index_clear( typer.Option("--json", help="Emit machine-readable JSON."), ] = False, ) -> None: - """Clear indexed records and VidXP run state.""" + """Publish an empty active snapshot without deleting retained generations.""" state = state_from_context(ctx) if not yes: typer.confirm( - f"Clear the local index at {state.service.index_directory}?", + f"Clear the active index at {state.service.index_directory}?", abort=True, ) cleared = state.service.clear_index() payload = { "cleared": cleared, - "index_directory": str(state.service.index_directory), } if effective_output_format(state, json_output) == OutputFormat.json: emit_json(payload) diff --git a/src/vidxp/cli_commands/jobs.py b/src/vidxp/cli_commands/jobs.py new file mode 100644 index 0000000..b7181e9 --- /dev/null +++ b/src/vidxp/cli_commands/jobs.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from typing import Annotated + +import typer +from rich.console import Console +from rich.table import Table + +from vidxp.application_models import ListJobsCommand +from vidxp.cli_support import ( + OutputFormat, + effective_output_format, + emit_json, + state_from_context, +) + + +app = typer.Typer(no_args_is_help=True, help="Manage durable background jobs.") + + +@app.command("list") +def list_jobs( + ctx: typer.Context, + page_size: Annotated[ + int, + typer.Option("--page-size", min=1, max=100), + ] = 50, + cursor: Annotated[ + str | None, + typer.Option("--cursor", help="Cursor returned by the previous page."), + ] = None, + json_output: Annotated[ + bool, + typer.Option("--json", help="Emit machine-readable JSON."), + ] = False, +) -> None: + """List durable VidXP jobs.""" + + state = state_from_context(ctx) + page = state.jobs.list( + ListJobsCommand(page_size=page_size, cursor=cursor) + ) + payload = page.model_dump(mode="json") + if effective_output_format(state, json_output) == OutputFormat.json: + emit_json(payload) + return + table = Table(title="VidXP jobs") + table.add_column("Job") + table.add_column("Kind") + table.add_column("State") + table.add_column("Queue") + table.add_column("Progress") + for job in page.items: + table.add_row( + job.job_id, + job.kind.value, + job.state.value, + job.queue.value, + job.progress.message if job.progress is not None else "—", + ) + Console().print(table) + if page.next_cursor is not None: + typer.echo(f"Next cursor: {page.next_cursor}") + + +@app.command("show") +def show_job( + ctx: typer.Context, + job_id: Annotated[str, typer.Argument(help="Durable job identifier.")], +) -> None: + """Show one job and its typed result when available.""" + + state = state_from_context(ctx) + emit_json(state.jobs.get(job_id).model_dump(mode="json")) + + +@app.command("cancel") +def cancel_job( + ctx: typer.Context, + job_id: Annotated[str, typer.Argument(help="Durable job identifier.")], +) -> None: + """Request cancellation of a queued or running job.""" + + state = state_from_context(ctx) + emit_json(state.jobs.cancel(job_id).model_dump(mode="json")) + + +@app.command("retry") +def retry_job( + ctx: typer.Context, + job_id: Annotated[str, typer.Argument(help="Durable job identifier.")], +) -> None: + """Retry a failed or cancelled job through DBOS recovery.""" + + state = state_from_context(ctx) + emit_json(state.jobs.retry(job_id).model_dump(mode="json")) + + +@app.command("stop-worker") +def stop_worker(ctx: typer.Context) -> None: + """Stop the detached local worker; durable jobs remain recoverable.""" + + state = state_from_context(ctx) + stopped = state.jobs.stop_worker() + emit_json({"stopped": stopped}) diff --git a/src/vidxp/cli_commands/mcp.py b/src/vidxp/cli_commands/mcp.py new file mode 100644 index 0000000..5ef773a --- /dev/null +++ b/src/vidxp/cli_commands/mcp.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Annotated + +import typer + +from vidxp.mcp_cli import render_stdio_client_config + + +def mcp_config( + registry: Annotated[ + Path | None, + typer.Option( + "--registry", + dir_okay=False, + help="Path to the named-repository configuration file.", + ), + ] = None, + repository: Annotated[ + str, + typer.Option( + "--repository", + "-r", + help="Repository the local MCP server should use.", + ), + ] = "default", + index_directory: Annotated[ + Path | None, + typer.Option( + "--index-directory", + file_okay=False, + help="Override the selected repository's index directory.", + ), + ] = None, + data_directory: Annotated[ + Path | None, + typer.Option( + "--data-dir", + file_okay=False, + help="Store VidXP models and the default repository here.", + ), + ] = None, + device: Annotated[ + str | None, + typer.Option( + "--device", + help="Override the selected repository runtime device.", + ), + ] = None, +) -> None: + """Print copy/paste JSON for a local stdio MCP client.""" + + typer.echo( + render_stdio_client_config( + registry=None if registry is None else str(registry), + repository=repository, + index_directory=( + None if index_directory is None else str(index_directory) + ), + data_directory=data_directory, + device=device, + ) + ) diff --git a/src/vidxp/cli_commands/media.py b/src/vidxp/cli_commands/media.py new file mode 100644 index 0000000..83f2746 --- /dev/null +++ b/src/vidxp/cli_commands/media.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Annotated + +import typer +from rich.console import Console +from rich.table import Table + +from vidxp.application_models import ImportMediaCommand, ListMediaCommand +from vidxp.cli_support import ( + OutputFormat, + effective_output_format, + emit_json, + emit_progress, + require_media_runtime, + state_from_context, +) + + +app = typer.Typer(no_args_is_help=True, help="Import and inspect local media.") + + +@app.command("import") +def import_media( + ctx: typer.Context, + path: Annotated[ + Path, + typer.Argument( + exists=True, + dir_okay=False, + readable=True, + resolve_path=True, + help="Local video file to copy into managed storage.", + ), + ], + json_output: Annotated[ + bool, + typer.Option("--json", help="Emit machine-readable JSON."), + ] = False, +) -> None: + """Validate and register a local video, returning its stable media ID.""" + + require_media_runtime() + state = state_from_context(ctx) + output_format = effective_output_format(state, json_output) + if not state.quiet and output_format == OutputFormat.rich: + emit_progress(f"Importing {path.name} into managed storage...") + result = state.service.import_media(ImportMediaCommand(path=path)) + payload = result.model_dump(mode="json") + if output_format == OutputFormat.json: + emit_json(payload) + else: + typer.secho( + f"Imported {result.original_filename} as {result.media_id}", + fg=typer.colors.GREEN, + ) + + +@app.command("list") +def list_media( + ctx: typer.Context, + limit: Annotated[ + int, + typer.Option("--limit", min=1, max=100), + ] = 100, + cursor: Annotated[ + str | None, + typer.Option("--cursor", help="Cursor returned by the previous page."), + ] = None, + json_output: Annotated[ + bool, + typer.Option("--json", help="Emit machine-readable JSON."), + ] = False, +) -> None: + """List filenames, metadata, and stable IDs used by other commands.""" + + state = state_from_context(ctx) + page = state.service.list_media( + ListMediaCommand(page_size=limit, cursor=cursor) + ) + assets = page.items + payload = page.model_dump(mode="json") + if effective_output_format(state, json_output) == OutputFormat.json: + emit_json(payload) + return + table = Table(title="Media") + table.add_column("ID") + table.add_column("Filename") + table.add_column("Duration", justify="right") + table.add_column("Size", justify="right") + for asset in assets: + table.add_row( + asset.media_id, + asset.original_filename, + f"{asset.duration_seconds:.3f}s", + f"{asset.byte_size:,}", + ) + Console().print(table) + + +@app.command("show") +def show_media( + ctx: typer.Context, + media_id: Annotated[ + str, + typer.Argument( + help="Stable media identifier returned by import or list." + ), + ], + json_output: Annotated[ + bool, + typer.Option("--json", help="Emit machine-readable JSON."), + ] = False, +) -> None: + """Show all registered metadata for one media ID.""" + + state = state_from_context(ctx) + payload = state.service.get_media(media_id).model_dump(mode="json") + if effective_output_format(state, json_output) == OutputFormat.json: + emit_json(payload) + else: + Console().print_json(data=payload) diff --git a/src/vidxp/cli_commands/query.py b/src/vidxp/cli_commands/query.py new file mode 100644 index 0000000..36f360a --- /dev/null +++ b/src/vidxp/cli_commands/query.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from typing import Annotated + +import typer + +from vidxp.application_models import ( + QueryAnswer, + QueryJobResult, + QueryVideoCommand, +) +from vidxp.cli_support import ( + CLIState, + OutputFormat, + effective_output_format, + emit_job_progress, + emit_progress, + emit_query, + state_from_context, +) + + +def run_query( + state: CLIState, + question: str, + *, + media_id: str | None, + modalities: tuple[str, ...], + top_k: int, + json_output: bool, +) -> QueryAnswer: + output_format = effective_output_format(state, json_output) + show_progress = not state.quiet and output_format == OutputFormat.rich + if show_progress: + emit_progress("Starting grounded video query...") + job = state.jobs.submit_query( + QueryVideoCommand( + question=question, + media_id=media_id, + modalities=modalities, + top_k=top_k, + ) + ) + completed = state.jobs.wait( + job.job_id, + progress=emit_job_progress if show_progress else None, + ) + if not isinstance(completed.result, QueryJobResult): + raise RuntimeError("The completed query job has no query result.") + result = completed.result.result + emit_query( + result, + output_format=output_format, + ) + return result + + +def query( + ctx: typer.Context, + question: Annotated[ + str, + typer.Argument(help="Natural-language question about indexed media."), + ], + media_id: Annotated[ + str | None, + typer.Option( + "--media-id", + help=( + "Use evidence only from this media ID. Omit to query every " + "media item in the active index snapshot." + ), + ), + ] = None, + modality: Annotated[ + list[str] | None, + typer.Option( + "--modality", + "-m", + help="Restrict query evidence; repeat for multiple capabilities.", + ), + ] = None, + top_k: Annotated[ + int, + typer.Option("--top-k", "-k", min=1, max=50), + ] = 10, + json_output: Annotated[ + bool, + typer.Option("--json", help="Emit machine-readable JSON."), + ] = False, +) -> None: + run_query( + state_from_context(ctx), + question, + media_id=media_id, + modalities=tuple(dict.fromkeys(modality or ())), + top_k=top_k, + json_output=json_output, + ) diff --git a/src/vidxp/cli_commands/runtime.py b/src/vidxp/cli_commands/runtime.py index 94a97e6..f5992bc 100644 --- a/src/vidxp/cli_commands/runtime.py +++ b/src/vidxp/cli_commands/runtime.py @@ -1,38 +1,197 @@ from __future__ import annotations -import os +import sys +from pathlib import Path from typing import Annotated import typer -from vidxp.capabilities.registry import CAPABILITIES, capability_names +from vidxp.app_paths import available_storage_bytes +from vidxp.application_models import ( + ApplicationError, + CapabilityDependencyCheck, + DependencyCheckCommand, + DependencyKind, + ErrorCategory, + PrepareModelsCommand, +) from vidxp.cli_support import ( OutputFormat, effective_output_format, + emit_job_progress, emit_json, + emit_progress, parse_capability_options, parse_modalities, + require_media_runtime, state_from_context, ) - -ALL_CAPABILITIES = ",".join(capability_names()) -PREPARABLE_CAPABILITIES = ",".join( - name - for name, capability in CAPABILITIES.items() - if capability.prepare is not None +from vidxp.media_runtime import ( + MediaRuntimeStatus, + inspect_media_runtime, + install_media_runtime, + media_runtime_config_path, + save_media_runtime_configuration, ) +def _format_bytes(size: int) -> str: + gib = 1024**3 + mib = 1024**2 + if size >= gib: + return f"{size / gib:.2f} GiB" + return f"{size / mib:.1f} MiB" + + +def _media_runtime_payload(status: MediaRuntimeStatus) -> dict: + payload = status.model_dump(mode="json") + if status.install_plan is not None: + payload["install_command"] = status.install_plan.display_command + else: + payload["install_command"] = None + return payload + + +def initialize( + ctx: typer.Context, + ffmpeg: Annotated[ + Path | None, + typer.Option( + exists=True, + dir_okay=False, + resolve_path=True, + help="Use this FFmpeg executable instead of searching PATH.", + ), + ] = None, + ffprobe: Annotated[ + Path | None, + typer.Option( + exists=True, + dir_okay=False, + resolve_path=True, + help="Use this ffprobe executable instead of searching PATH.", + ), + ] = None, + yes: Annotated[ + bool, + typer.Option( + "--yes", + "-y", + help="Confirm the displayed system package-manager command.", + ), + ] = False, + json_output: Annotated[ + bool, + typer.Option("--json", help="Emit machine-readable JSON."), + ] = False, +) -> None: + """Verify and save the system FFmpeg runtime used by VidXP.""" + + root_format = ctx.find_root().params.get( + "output_format", + OutputFormat.rich, + ) + output_format = ( + OutputFormat.json + if json_output or root_format == OutputFormat.json + else OutputFormat.rich + ) + status = inspect_media_runtime(ffmpeg=ffmpeg, ffprobe=ffprobe) + if not status.ready: + payload = _media_runtime_payload(status) + if output_format == OutputFormat.rich: + typer.secho( + "FFmpeg and ffprobe are not ready.", + fg=typer.colors.YELLOW, + bold=True, + ) + for error in status.errors: + typer.echo(f" {error}") + if status.install_plan is not None: + typer.echo( + f"Package manager: {status.install_plan.manager}" + ) + typer.secho( + f"Command: {status.install_plan.display_command}", + fg=typer.colors.YELLOW, + ) + plan = status.install_plan + if plan is None or not plan.automatic: + if output_format == OutputFormat.json: + emit_json(payload) + elif plan is None: + typer.echo( + "Install FFmpeg and ffprobe with your operating-system " + "package manager, or pass explicit --ffmpeg and " + "--ffprobe paths, then rerun `vidxp init`." + ) + else: + typer.echo( + "Install FFmpeg with the command above, or pass explicit " + "--ffmpeg and --ffprobe paths, then rerun `vidxp init`." + ) + raise typer.Exit(1) + interactive = sys.stdin.isatty() and sys.stdout.isatty() + if not yes: + if not interactive: + if output_format == OutputFormat.json: + emit_json(payload) + else: + typer.echo( + "No installation was started. Rerun interactively or " + "pass --yes after reviewing the command." + ) + raise typer.Exit(1) + typer.confirm( + f"Install through {plan.manager} using the displayed command?", + abort=True, + ) + if output_format == OutputFormat.rich: + emit_progress(f"Installing FFmpeg through {plan.manager}...") + install_media_runtime( + plan, + output_to_stderr=output_format == OutputFormat.json, + ) + status = inspect_media_runtime(ffmpeg=ffmpeg, ffprobe=ffprobe) + if not status.ready: + if output_format == OutputFormat.json: + emit_json(_media_runtime_payload(status)) + else: + for error in status.errors: + typer.secho(error, fg=typer.colors.RED) + raise typer.Exit(1) + + configuration = save_media_runtime_configuration(status) + payload = { + **_media_runtime_payload(status), + "initialized": True, + "configuration": str(media_runtime_config_path()), + } + if output_format == OutputFormat.json: + emit_json(payload) + else: + typer.secho( + "VidXP media runtime is initialized.", + fg=typer.colors.GREEN, + bold=True, + ) + typer.echo(f"FFmpeg: {configuration.ffmpeg_executable}") + typer.echo(f"ffprobe: {configuration.ffprobe_executable}") + + def doctor( ctx: typer.Context, modalities: Annotated[ - str, + list[str] | None, typer.Option( "--modalities", "-m", - help="Only validate dependencies for these modalities.", + help=( + "Only validate dependencies for these modalities. " + "Accepts comma-separated values or repeated options." + ), ), - ] = ALL_CAPABILITIES, + ] = None, json_output: Annotated[ bool, typer.Option("--json", help="Emit machine-readable JSON."), @@ -40,29 +199,165 @@ def doctor( ) -> None: """Validate selected indexing dependencies without downloading models.""" - selected = parse_modalities(modalities) state = state_from_context(ctx) - result = state.service.check_dependencies(selected) - if effective_output_format(state, json_output) == OutputFormat.json: - emit_json(result) + capabilities = tuple(state.service.list_capabilities()) + available = tuple(capability.name for capability in capabilities) + selected = ( + available + if modalities is None + else parse_modalities(",".join(modalities), available) + ) + output_format = effective_output_format(state, json_output) + + def show_check_start( + capability: str, + kind: DependencyKind, + name: str, + ) -> None: + label = { + DependencyKind.distribution: f"package {name}", + DependencyKind.model: f"model {name}", + }.get(kind, name) + emit_progress( + f"Checking [{capability}] {label}...", + newline=False, + ) + + def show_check_complete( + check: CapabilityDependencyCheck, + elapsed_seconds: float, + ) -> None: + if check.ok: + detail = ( + f"version {check.installed_version}, {elapsed_seconds:.1f}s" + if check.kind == DependencyKind.distribution + else f"{elapsed_seconds:.1f}s" + ) + typer.secho( + f" OK ({detail})", + fg=typer.colors.GREEN, + ) + else: + typer.secho( + f" FAILED ({elapsed_seconds:.1f}s): {check.error}", + fg=typer.colors.RED, + ) + + result = state.service.check_dependencies( + DependencyCheckCommand( + modalities=selected, + include_models=True, + ), + on_check_start=( + show_check_start if output_format == OutputFormat.rich else None + ), + on_check_complete=( + show_check_complete + if output_format == OutputFormat.rich + else None + ), + ) + payload = result.model_dump(mode="json") + if output_format == OutputFormat.json: + emit_json(payload) else: - for check in result["checks"]: - if check["ok"]: - detail = f": {check['path']}" if check.get("path") else "" + python_failures = tuple( + check + for check in result.checks + if ( + not check.ok + and check.capability != "media" + and check.kind != DependencyKind.model + ) + ) + if python_failures: + selected_capabilities = { + capability.name: capability for capability in capabilities + } + builtin_extras = tuple( + dict.fromkeys( + selected_capabilities[name].install_extra + for name in selected + if selected_capabilities[name].provenance is None + ) + ) + external_distributions = tuple( + dict.fromkeys( + selected_capabilities[name].provenance.distribution + for name in selected + if selected_capabilities[name].provenance is not None + ) + ) + if builtin_extras: typer.secho( - f"OK {check['name']}{detail}", - fg=typer.colors.GREEN, + "REMEDY: In a source checkout, rerun uv sync with all " + "profiles you use and include --extra local-worker.", + fg=typer.colors.YELLOW, ) - else: typer.secho( - f"FAILED {check['name']}: {check['error']}", - fg=typer.colors.RED, + 'Published package: pip install "vidxp[' + + ",".join(builtin_extras) + + ']"', + fg=typer.colors.YELLOW, ) - if not result["ok"]: + if external_distributions: + typer.secho( + "External capabilities: pip install " + + " ".join(external_distributions), + fg=typer.colors.YELLOW, + ) + media_failures = tuple( + check + for check in result.checks + if not check.ok and check.capability == "media" + ) + if media_failures: + typer.secho( + "REMEDY: Run `vidxp init` to verify and configure FFmpeg.", + fg=typer.colors.YELLOW, + ) + missing_model_checks = tuple( + check + for check in result.checks + if not check.ok and check.kind == DependencyKind.model + ) + missing_models = tuple( + dict.fromkeys(check.capability for check in missing_model_checks) + ) + if missing_models: + typer.secho("Missing model downloads:", bold=True) + for check in missing_model_checks: + typer.echo( + f" {check.name}: " + f"{_format_bytes(check.download_size_bytes or 0)}" + ) + required_bytes = sum( + check.download_size_bytes or 0 + for check in missing_model_checks + ) + typer.secho( + "Maximum additional download and cache space: " + f"{_format_bytes(required_bytes)}", + bold=True, + ) + typer.echo(f"Model cache: {state.service.model_cache}") + free_bytes = available_storage_bytes(state.service.model_cache) + if free_bytes is not None: + typer.echo( + "Free space at model cache: " + f"{_format_bytes(free_bytes)}" + ) + command = "vidxp prepare --modalities " + ",".join(missing_models) + typer.secho( + "REMEDY: Download the selected model artifacts explicitly:", + fg=typer.colors.YELLOW, + ) + typer.secho(command, fg=typer.colors.YELLOW) + if not result.ok: raise typer.Exit(1) - if effective_output_format(state, json_output) == OutputFormat.rich: + if output_format == OutputFormat.rich: typer.secho( - "Selected VidXP dependencies are available.", + "Selected VidXP dependencies and model artifacts are available.", fg=typer.colors.GREEN, bold=True, ) @@ -71,13 +366,16 @@ def doctor( def prepare( ctx: typer.Context, modalities: Annotated[ - str, + list[str] | None, typer.Option( "--modalities", "-m", - help="Only prepare models for these modalities.", + help=( + "Only prepare models for these modalities. " + "Accepts comma-separated values or repeated options." + ), ), - ] = PREPARABLE_CAPABILITIES, + ] = None, capability_options: Annotated[ list[str] | None, typer.Option( @@ -92,27 +390,110 @@ def prepare( bool, typer.Option("--json", help="Emit machine-readable JSON."), ] = False, + detach: Annotated[ + bool, + typer.Option( + "--detach", + help="Return after the durable job is queued.", + ), + ] = False, + yes: Annotated[ + bool, + typer.Option( + "--yes", + "-y", + help="Confirm the displayed model download and cache size.", + ), + ] = False, ) -> None: """Download and cache selected runtime models before indexing.""" - selected = parse_modalities(modalities) state = state_from_context(ctx) - result = state.service.prepare_models( - selected, - capability_options=parse_capability_options(capability_options), - progress_callback=( - None - if state.quiet - or effective_output_format(state, json_output) - == OutputFormat.json - else lambda event: typer.echo(event["message"]) - ), + preparable = tuple( + capability.name + for capability in state.service.list_capabilities() + if capability.prepares_models + ) + selected = ( + preparable + if modalities is None + else parse_modalities(",".join(modalities), preparable) + ) + output_format = effective_output_format(state, json_output) + readiness = state.service.model_readiness(selected) + missing = tuple(check for check in readiness.checks if not check.ok) + required_bytes = sum( + check.download_size_bytes or 0 + for check in missing + ) + free_bytes = ( + available_storage_bytes(state.service.model_cache) + if missing + else None + ) + if missing and output_format == OutputFormat.rich: + typer.secho("Models to download:", bold=True) + for check in missing: + typer.echo( + f" {check.name}: " + f"{_format_bytes(check.download_size_bytes or 0)}" + ) + typer.secho( + "Maximum additional download and cache space: " + f"{_format_bytes(required_bytes)}", + bold=True, + ) + typer.echo(f"Model cache: {state.service.model_cache}") + if free_bytes is not None: + typer.echo( + "Free space at model cache: " + f"{_format_bytes(free_bytes)}" + ) + if ( + missing + and free_bytes is not None + and free_bytes < required_bytes + ): + raise typer.BadParameter( + "The model cache does not have enough free space for the " + f"selected downloads ({_format_bytes(required_bytes)} required, " + f"{_format_bytes(free_bytes)} free at " + f"{state.service.model_cache}). Choose another VidXP data " + "location with the global --data-dir option or VIDXP_DATA_DIR." + ) + if missing and not yes: + if output_format == OutputFormat.json: + raise typer.BadParameter( + "Model downloads require explicit confirmation; review " + "`vidxp doctor --modalities ... --json`, then rerun prepare " + "with --yes." + ) + typer.confirm("Download these models?", abort=True) + show_progress = not state.quiet and output_format == OutputFormat.rich + if show_progress: + emit_progress( + "Starting model preparation for " + ", ".join(selected) + "." + ) + job = state.jobs.submit_prepare_models( + PrepareModelsCommand( + modalities=selected, + capability_options=parse_capability_options(capability_options), + ) ) - if effective_output_format(state, json_output) == OutputFormat.json: - emit_json(result) + if not detach: + job = state.jobs.wait( + job.job_id, + progress=emit_job_progress if show_progress else None, + ) + if output_format == OutputFormat.json: + emit_json(job.model_dump(mode="json")) else: typer.secho( - "Selected VidXP runtime models are prepared.", + ( + f"Model preparation job queued: {job.job_id}" + if detach + else f"Selected runtime models are prepared: {job.job_id}" + ), fg=typer.colors.GREEN, bold=True, ) @@ -136,35 +517,45 @@ def ui( ) -> None: """Launch Streamlit with the selected repository configuration.""" + require_media_runtime() state = state_from_context(ctx) - os.environ["VIDXP_CONFIG_FILE"] = str(state.registry.path) - os.environ["VIDXP_REPOSITORY"] = state.repository.name - os.environ["VIDXP_INDEX_DIR"] = str(state.service.index_directory) - if state.service.device is None: - os.environ.pop("VIDXP_DEVICE", None) - else: - os.environ["VIDXP_DEVICE"] = state.service.device - + show_progress = ( + not state.quiet and state.output_format == OutputFormat.rich + ) + if show_progress: + emit_progress("Loading the browser interface...") try: from vidxp import frontend except ModuleNotFoundError as exc: if exc.name == "streamlit": - raise RuntimeError( - "The browser interface requires the frontend extra. " - "Install vidxp[frontend]." + raise ApplicationError( + "frontend_unavailable", + ErrorCategory.unavailable, + "The browser interface is unavailable. " + "In a source checkout, rerun uv sync with all profiles " + "you use and include --extra frontend. Published package: " + 'pip install "vidxp[frontend]".', ) from exc raise - frontend.SERVICE = state.service - frontend.SAVED_VIDEO_PATH = ( - state.service.index_directory / "source-video.mp4" - ) - frontend.ACTOR_OUTPUT_PATH = ( - state.service.index_directory / "actor-result.mp4" - ) streamlit_arguments = [] if host is not None: streamlit_arguments.append(f"--server.address={host}") if port is not None: streamlit_arguments.append(f"--server.port={port}") - frontend.main(streamlit_arguments) + if show_progress: + emit_progress("Starting the browser interface...") + try: + frontend.main( + streamlit_arguments, + settings=state.settings, + ) + finally: + try: + state.jobs.stop_worker() + except ApplicationError as exc: + typer.secho( + f"WARNING: The local background worker did not stop: {exc}", + fg=typer.colors.YELLOW, + err=True, + ) diff --git a/src/vidxp/cli_commands/search.py b/src/vidxp/cli_commands/search.py index d2c9f90..086dd46 100644 --- a/src/vidxp/cli_commands/search.py +++ b/src/vidxp/cli_commands/search.py @@ -1,72 +1,98 @@ from __future__ import annotations -from typing import Annotated, Callable +from typing import Annotated import typer -from vidxp.capabilities.registry import CAPABILITIES -from vidxp.capabilities.schemas import SearchResult +from vidxp.application_models import SearchCommand, SearchJobResult +from vidxp.application_models import FusedSearchResult from vidxp.cli_support import ( CLIState, + OutputFormat, effective_output_format, + emit_job_progress, + emit_progress, emit_search, state_from_context, ) -app = typer.Typer(no_args_is_help=True, help="Search the active index.") - - def run_search( state: CLIState, capability: str, query: str, *, + media_id: str | None, top_k: int, json_output: bool, -) -> SearchResult: - result = state.service.search(capability, query, top_k=top_k) +) -> FusedSearchResult: + output_format = effective_output_format(state, json_output) + show_progress = not state.quiet and output_format == OutputFormat.rich + if show_progress: + emit_progress(f"Starting {capability} search...") + job = state.jobs.submit_search( + SearchCommand( + modalities=(capability,), + query=query, + media_id=media_id, + top_k=top_k, + ) + ) + completed = state.jobs.wait( + job.job_id, + progress=emit_job_progress if show_progress else None, + ) + if not isinstance(completed.result, SearchJobResult): + raise RuntimeError("The completed search job has no search result.") + result = completed.result.result emit_search( result, - output_format=effective_output_format(state, json_output), + output_format=output_format, ) return result -def _search_command(capability: str) -> Callable: - def command( - ctx: typer.Context, - query: Annotated[ - str, - typer.Argument(help="Text query to find."), - ], - top_k: Annotated[ - int, - typer.Option( - "--top-k", - "-k", - min=1, - help="Maximum ranked hits.", +def search( + ctx: typer.Context, + capability: Annotated[ + str, + typer.Argument(help="Registered capability to query."), + ], + query: Annotated[ + str, + typer.Argument(help="Text query to find."), + ], + media_id: Annotated[ + str | None, + typer.Option( + "--media-id", + help=( + "Search only this media ID. Omit to rank matches across every " + "media item in the active index snapshot." ), - ] = 10, - json_output: Annotated[ - bool, - typer.Option("--json", help="Emit machine-readable JSON."), - ] = False, - ) -> None: - run_search( - state_from_context(ctx), - capability, - query, - top_k=top_k, - json_output=json_output, - ) - - command.__name__ = f"search_{capability}" - command.__doc__ = CAPABILITIES[capability].description - return command - - -for _name, _capability in CAPABILITIES.items(): - if "search" in _capability.operations: - app.command(_name)(_search_command(_name)) + ), + ] = None, + top_k: Annotated[ + int, + typer.Option( + "--top-k", + "-k", + min=1, + max=100, + help="Maximum ranked hits.", + ), + ] = 10, + json_output: Annotated[ + bool, + typer.Option("--json", help="Emit machine-readable JSON."), + ] = False, +) -> None: + state = state_from_context(ctx) + run_search( + state, + capability, + query, + media_id=media_id, + top_k=top_k, + json_output=json_output, + ) diff --git a/src/vidxp/cli_support.py b/src/vidxp/cli_support.py index 67947f9..934c3b5 100644 --- a/src/vidxp/cli_support.py +++ b/src/vidxp/cli_support.py @@ -2,6 +2,7 @@ import json from dataclasses import dataclass +from datetime import datetime from enum import Enum from typing import Any, Iterable @@ -16,13 +17,21 @@ ) from rich.table import Table -from vidxp.application import VidXPService -from vidxp.capabilities.registry import ( - index_capability_names, - validate_capability_names, +from vidxp.application_models import ( + ApplicationError, + ErrorCategory, + FusedSearchResult, + QueryAnswer, ) -from vidxp.capabilities.schemas import SearchResult +from vidxp.media_runtime import media_runtime_is_initialized from vidxp.repositories import RepositoryConfig, RepositoryRegistry +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from vidxp.application import VidXPApplication + from vidxp.composition import LocalApplicationContext + from vidxp.job_service import JobService + from vidxp.settings import VidXPSettings class OutputFormat(str, Enum): @@ -32,12 +41,24 @@ class OutputFormat(str, Enum): @dataclass class CLIState: - service: VidXPService + local: "LocalApplicationContext" registry: RepositoryRegistry repository: RepositoryConfig output_format: OutputFormat = OutputFormat.rich quiet: bool = False + @property + def service(self) -> "VidXPApplication": + return self.local.application + + @property + def jobs(self) -> "JobService": + return self.local.jobs + + @property + def settings(self) -> "VidXPSettings": + return self.local.settings + def state_from_context(ctx: typer.Context) -> CLIState: state = ctx.ensure_object(CLIState) @@ -46,6 +67,21 @@ def state_from_context(ctx: typer.Context) -> CLIState: return state +def require_media_runtime() -> None: + if media_runtime_is_initialized(): + return + raise ApplicationError( + "media_runtime_uninitialized", + ErrorCategory.unavailable, + "FFmpeg and ffprobe are not initialized for local media work. " + "Run `vidxp init`, then retry this command.", + details={ + "remediation": "vidxp init", + "install_hint": "Run vidxp init", + }, + ) + + def effective_output_format( state: CLIState, json_output: bool, @@ -64,34 +100,97 @@ def emit_json(payload: Any) -> None: ) +def emit_progress( + message: str, + *, + updated_at: datetime | None = None, + newline: bool = True, +) -> None: + timestamp = (updated_at or datetime.now().astimezone()).astimezone() + typer.secho( + f"[{timestamp:%H:%M:%S}] {message}", + fg=typer.colors.BLUE, + nl=newline, + ) + + +def emit_job_progress(job: Any) -> None: + if job.progress is not None: + message = job.progress.message + if ( + job.progress.stage == "downloading_model" + and job.progress.current is not None + and job.progress.total + ): + gib = 1024**3 + mib = 1024**2 + unit = gib if job.progress.total >= gib else mib + suffix = "GiB" if unit == gib else "MiB" + message += ( + f" {job.progress.current / unit:.1f} of " + f"{job.progress.total / unit:.1f} {suffix}" + ) + emit_progress( + message, + updated_at=job.progress.updated_at, + ) + + def emit_search( - result: SearchResult, + result: FusedSearchResult, *, output_format: OutputFormat, ) -> None: if output_format == OutputFormat.json: - emit_json(result.to_dict()) + emit_json(result.model_dump(mode="json")) return - if not result.hits: - typer.echo(f"No {result.modality} matches found.") + if not result.moments: + typer.echo("No matching moments found.") return - table = Table(title=f"{result.modality.title()} search results") + table = Table(title="Fused search results") table.add_column("Rank", justify="right") table.add_column("Start", justify="right") table.add_column("End", justify="right") table.add_column("Score", justify="right") table.add_column("Video") - for hit in result.hits: + table.add_column("Modalities") + for moment in result.moments: table.add_row( - str(hit.rank), - f"{hit.start:.3f}s", - f"{hit.end:.3f}s", - f"{hit.score:.6f}", - hit.video_id, + str(moment.rank), + f"{moment.start:.3f}s", + f"{moment.end:.3f}s", + f"{moment.score:.6f}", + moment.media_id, + ", ".join(moment.modalities), ) Console().print(table) +def emit_query( + result: QueryAnswer, + *, + output_format: OutputFormat, +) -> None: + if output_format == OutputFormat.json: + emit_json(result.model_dump(mode="json")) + return + console = Console() + console.print(f"Mode: {result.mode.value}") + if result.claims: + for claim in result.claims: + console.print(f"• {claim.text}") + console.print(f" Evidence: {', '.join(claim.evidence_ids)}") + elif result.evidence: + console.print( + f"No generated answer; returning {len(result.evidence)} " + "evidence item(s)." + ) + else: + console.print("No supporting evidence found.") + if result.fallback_reason: + console.print(f"Fallback: {result.fallback_reason}") + + def emit_status( status: dict[str, Any], *, @@ -107,15 +206,8 @@ def emit_status( table.add_row("Message", str(status.get("message", "—"))) if updated_at := status.get("updated_at"): table.add_row("Updated", str(updated_at)) - if video := status.get("video"): - table.add_row( - "Video", - str(video.get("source_name") or video.get("path") or "—"), - ) summary = status.get("summary") or {} - if modalities := (summary.get("configuration") or {}).get( - "enabled_modalities" - ): + if modalities := summary.get("modalities"): table.add_row("Modalities", ", ".join(map(str, modalities))) Console().print(table) @@ -166,25 +258,30 @@ def update(self, event: dict[str, Any]) -> None: def selected_modalities( values: Iterable[str] | None, + available: Iterable[str], ) -> tuple[str, ...]: + supported = tuple(available) if values is None: - return index_capability_names() - try: - return validate_capability_names(values) - except ValueError as exc: - raise typer.BadParameter(str(exc)) from exc + return supported + selected = tuple(dict.fromkeys(values)) + unknown = sorted(set(selected) - set(supported)) + if unknown: + raise typer.BadParameter( + "Unknown or unsupported capabilities: " + ", ".join(unknown) + ) + return selected -def parse_modalities(value: str) -> tuple[str, ...]: +def parse_modalities( + value: str, + available: Iterable[str], +) -> tuple[str, ...]: selected = tuple( item.strip().lower() for item in value.split(",") if item.strip() ) - try: - return validate_capability_names(selected) - except ValueError as exc: - raise typer.BadParameter(str(exc)) from exc + return selected_modalities(selected, available) def parse_capability_options( diff --git a/src/vidxp/composition.py b/src/vidxp/composition.py new file mode 100644 index 0000000..f0c514b --- /dev/null +++ b/src/vidxp/composition.py @@ -0,0 +1,498 @@ +from __future__ import annotations + +from dataclasses import dataclass +from functools import cached_property +from pathlib import Path + +from pydantic import ValidationError + +from vidxp.application import VidXPApplication +from vidxp.application_models import ApplicationError, ErrorCategory +from vidxp.artifact_service import ArtifactQueryService, ArtifactService +from vidxp.authentication import Authenticator, create_authenticator +from vidxp.authorization import AuthorizationPolicy +from vidxp.capability_service import CapabilityService +from vidxp.capabilities.registry import ( + CapabilityRegistry, + create_capability_registry, +) +from vidxp.control_plane import ControlPlaneApplication +from vidxp.core.storage import BUNDLED_CHROMA_SERVER_URL +from vidxp.dependencies import active_requirements, packaged_requirements +from vidxp.infrastructure.local_index import ( + LOCAL_INDEX_RUNTIME_CHECKS, + SERVER_INDEX_RUNTIME_CHECKS, + LocalIndexBackend, + LocalIndexReader, +) +from vidxp.infrastructure.local_artifacts import ( + FFmpegSnippetRenderer, + LocalActorRenderer, + LocalArtifactStore, +) +from vidxp.infrastructure.local_catalog import LocalCatalog +from vidxp.infrastructure.sql_catalog import SQLCatalog +from vidxp.infrastructure.sql_snapshots import SQLSnapshotRepository +from vidxp.infrastructure.local_media import FFprobeMediaProbe, LocalMediaStore +from vidxp.infrastructure.local_snapshots import LocalSnapshotRepository +from vidxp.infrastructure.dbos_jobs import DBOSJobBackend +from vidxp.infrastructure.local_worker import LocalWorkerSupervisor +from vidxp.job_service import JobService +from vidxp.media_service import MediaService +from vidxp.readiness_service import ReadinessService +from vidxp.read_job_planner import LocalReadJobPlanner +from vidxp.runtime import ModelRuntime, RuntimeBackendUnavailableError +from vidxp.repositories import ( + RepositoryConfig, + RepositoryConfigError, + RepositoryRegistry, + resolve_repository, +) +from vidxp.settings import ApplicationMode, VidXPSettings +from vidxp.upload_service import RemoteUploadService +from vidxp.workflow_runtime import ( + workflow_application_version, + workflow_database_url, +) + + +class LocalApplicationContext: + """Lazy local composition so lightweight CLI commands stay lightweight.""" + + def __init__( + self, + *, + repositories: RepositoryRegistry, + repository: RepositoryConfig, + settings: VidXPSettings | None = None, + application: VidXPApplication | None = None, + jobs: JobService | None = None, + ) -> None: + if settings is None and (application is None or jobs is None): + raise ValueError( + "Lazy local composition requires settings or composed services." + ) + self.repositories = repositories + self.repository = repository + self._settings = settings + if application is not None: + self.__dict__["application"] = application + if jobs is not None: + self.__dict__["jobs"] = jobs + + @cached_property + def application(self) -> VidXPApplication: + assert self._settings is not None + return create_application(self._settings) + + @property + def settings(self) -> VidXPSettings: + if self._settings is not None: + return self._settings + return settings_for_repository(self.repository) + + @cached_property + def jobs(self) -> JobService: + assert self._settings is not None + return create_job_service(self._settings) + + def close(self) -> None: + jobs = self.__dict__.get("jobs") + if jobs is not None: + jobs.close() + + +@dataclass(frozen=True, kw_only=True) +class ControlPlaneContext: + application: ControlPlaneApplication + jobs: JobService + authorization: AuthorizationPolicy + settings: VidXPSettings + catalog: SQLCatalog | None = None + uploads: RemoteUploadService | None = None + + def close(self) -> None: + try: + if self.settings.mode != ApplicationMode.server: + self.jobs.stop_worker() + finally: + try: + self.jobs.close() + finally: + if self.catalog is not None: + self.catalog.close() + + +@dataclass(frozen=True, kw_only=True) +class HttpApplicationContext(ControlPlaneContext): + readiness: ReadinessService + authenticator: Authenticator + + +@dataclass(frozen=True) +class UploadHookContext: + jobs: JobService + authenticator: Authenticator + authorization: AuthorizationPolicy + settings: VidXPSettings + catalog: SQLCatalog + uploads: RemoteUploadService + + def close(self) -> None: + self.jobs.close() + self.catalog.close() + + +@dataclass(frozen=True) +class _ControlPlaneComponents: + registry: CapabilityRegistry + catalog: SQLCatalog + media: MediaService + artifact_store: LocalArtifactStore + probe: FFprobeMediaProbe + snapshots: LocalSnapshotRepository + + +def _server_chroma_url(settings: VidXPSettings) -> str | None: + if settings.mode != ApplicationMode.server: + return None + return BUNDLED_CHROMA_SERVER_URL + + +def _create_control_plane_components( + settings: VidXPSettings, +) -> _ControlPlaneComponents: + settings.layout.ensure_local_directories() + server_mode = settings.mode == ApplicationMode.server + registry = create_capability_registry( + external=settings.external_capabilities, + allowlist=settings.capability_allowlist, + platform_runtime_checks=( + SERVER_INDEX_RUNTIME_CHECKS + if server_mode + else LOCAL_INDEX_RUNTIME_CHECKS + ), + storage_requirements=( + active_requirements( + packaged_requirements( + "vidxp", + "requirements/server-storage.txt", + ) + ) + if server_mode + else None + ), + ) + catalog = ( + SQLCatalog( + workflow_database_url(settings), + initialize=False, + ) + if settings.mode == ApplicationMode.server + else LocalCatalog(settings.layout.catalog) + ) + probe = FFprobeMediaProbe(settings.ffprobe_executable) + snapshots = ( + SQLSnapshotRepository( + settings.layout.indexes, + engine=catalog.engine, + ) + if settings.mode == ApplicationMode.server + else LocalSnapshotRepository(settings.layout.indexes) + ) + return _ControlPlaneComponents( + registry=registry, + catalog=catalog, + media=MediaService( + settings=settings, + catalog=catalog, + store=LocalMediaStore( + settings.layout.media, + max_bytes=( + settings.upload_max_bytes + if settings.mode == ApplicationMode.server + else settings.max_local_import_bytes + ), + ), + probe=probe, + ), + artifact_store=LocalArtifactStore(settings.layout.artifacts), + probe=probe, + snapshots=snapshots, + ) + + +def settings_for_repository( + repository: RepositoryConfig, + *, + data_directory: str | Path | None = None, +) -> VidXPSettings: + values = { + "mode": ApplicationMode.local, + "repository_root": repository.index_directory, + } + if data_directory is not None: + values["data_dir"] = Path(data_directory) + if repository.device is not None: + values["runtime_backend"] = repository.device + return VidXPSettings(**values) + + +def create_application( + settings: VidXPSettings | None = None, +) -> VidXPApplication: + active_settings = settings or VidXPSettings() + components = _create_control_plane_components(active_settings) + try: + runtime = ModelRuntime( + active_settings, + allowed_specs=components.registry.model_specs(), + ) + except RuntimeBackendUnavailableError as exc: + raise ApplicationError( + "runtime_unavailable", + ErrorCategory.unavailable, + "The requested runtime backend is unavailable.", + ) from exc + backend = LocalIndexBackend( + components.registry, + runtime, + active_settings.layout, + chroma_server_url=_server_chroma_url(active_settings), + snapshot_repository=components.snapshots, + ) + artifacts = ArtifactService( + catalog=components.catalog, + store=components.artifact_store, + media=components.media, + probe=components.probe, + actor_renderer=LocalActorRenderer(), + snippet_renderer=FFmpegSnippetRenderer( + active_settings.ffmpeg_executable + ), + max_snippet_duration_seconds=( + active_settings.max_snippet_duration_seconds + ), + ) + upload_service = ( + RemoteUploadService( + settings=active_settings, + catalog=components.catalog, + media=components.media, + ) + if active_settings.mode == ApplicationMode.server + else None + ) + query_model = None + if ( + active_settings.slm_base_url is not None + and active_settings.slm_model is not None + ): + from vidxp.infrastructure.ollama_query import OllamaQueryModel + + query_model = OllamaQueryModel( + base_url=active_settings.slm_base_url, + model_name=active_settings.slm_model, + timeout_seconds=active_settings.slm_timeout_seconds, + output_retries=active_settings.slm_output_retries, + ) + return VidXPApplication( + settings=active_settings, + layout=active_settings.layout, + registry=components.registry, + runtime=runtime, + index_backend=backend, + media=components.media, + artifacts=artifacts, + index_status=backend.repository.status, + completed_upload_importer=( + upload_service.import_completed + if upload_service is not None + else None + ), + query_model=query_model, + ) + + +def create_job_service( + settings: VidXPSettings, + *, + catalog: SQLCatalog | None = None, + snapshots: LocalSnapshotRepository | None = None, + include_read_planner: bool = True, +) -> JobService: + settings.layout.ensure_local_directories() + before_access = None + health_check = None + stop_executor = None + if settings.mode != ApplicationMode.server: + supervisor = LocalWorkerSupervisor(settings) + before_access = supervisor.ensure_running + health_check = supervisor.health + stop_executor = supervisor.stop + return JobService( + settings=settings, + backend=DBOSJobBackend( + system_database_url=( + None + if settings.mode == ApplicationMode.server + and catalog is not None + else workflow_database_url(settings) + ), + system_database_engine=( + catalog.engine + if settings.mode == ApplicationMode.server + and catalog is not None + else None + ), + application_version=workflow_application_version(), + before_access=before_access, + health_check=health_check, + stop_executor=stop_executor, + ), + read_planner=( + LocalReadJobPlanner( + layout=settings.layout, + index=LocalIndexReader( + settings.layout, + chroma_server_url=_server_chroma_url(settings), + snapshot_repository=snapshots, + ), + ) + if include_read_planner + else None + ), + ) + + +def create_control_plane_application( + settings: VidXPSettings | None = None, +) -> ControlPlaneContext: + active_settings = settings or VidXPSettings() + components = _create_control_plane_components(active_settings) + application = ControlPlaneApplication( + layout=active_settings.layout, + capabilities=CapabilityService(components.registry), + media=components.media, + artifacts=ArtifactQueryService( + catalog=components.catalog, + store=components.artifact_store, + ), + index_status=components.snapshots.status, + model_cache=active_settings.model_cache, + ) + jobs = create_job_service( + active_settings, + catalog=components.catalog, + snapshots=components.snapshots, + ) + uploads = ( + RemoteUploadService( + settings=active_settings, + catalog=components.catalog, + media=components.media, + jobs=jobs, + ) + if active_settings.mode == ApplicationMode.server + else None + ) + return ControlPlaneContext( + application=application, + jobs=jobs, + authorization=AuthorizationPolicy(), + settings=active_settings, + catalog=components.catalog, + uploads=uploads, + ) + + +def create_http_application( + settings: VidXPSettings | None = None, +) -> HttpApplicationContext: + active_settings = settings or VidXPSettings() + active_settings.validate_http_server() + control = create_control_plane_application(active_settings) + authenticator = create_authenticator(active_settings) + return HttpApplicationContext( + application=control.application, + jobs=control.jobs, + authorization=control.authorization, + settings=control.settings, + catalog=control.catalog, + uploads=control.uploads, + readiness=ReadinessService( + application=control.application, + jobs=control.jobs, + authenticator=authenticator, + ), + authenticator=authenticator, + ) + + +def create_upload_hook_context( + settings: VidXPSettings | None = None, +) -> UploadHookContext: + active_settings = settings or VidXPSettings() + active_settings.validate_http_server() + if active_settings.mode != ApplicationMode.server: + raise ValueError("The tusd hook service requires server mode.") + catalog = SQLCatalog( + workflow_database_url(active_settings), + initialize=False, + ) + try: + jobs = create_job_service( + active_settings, + catalog=catalog, + include_read_planner=False, + ) + uploads = RemoteUploadService( + settings=active_settings, + catalog=catalog, + media=None, + jobs=jobs, + ) + return UploadHookContext( + jobs=jobs, + authenticator=create_authenticator(active_settings), + authorization=AuthorizationPolicy(), + settings=active_settings, + catalog=catalog, + uploads=uploads, + ) + except Exception: + catalog.close() + raise + + +def create_local_application( + *, + registry_path: str | Path | None = None, + repository_name: str | None = None, + index_directory: str | Path | None = None, + data_directory: str | Path | None = None, + device: str | None = None, +) -> LocalApplicationContext: + try: + repositories, repository = resolve_repository( + registry_path=registry_path, + name=repository_name, + index_directory=index_directory, + data_directory=data_directory, + device=device, + ) + settings = settings_for_repository( + repository, + data_directory=data_directory, + ) + except (RepositoryConfigError, ValidationError) as exc: + raise ApplicationError( + "configuration_invalid", + ErrorCategory.validation, + "The repository or runtime configuration is invalid.", + ) from exc + return LocalApplicationContext( + repositories=repositories, + repository=repository, + settings=settings, + ) diff --git a/src/vidxp/control_plane.py b/src/vidxp/control_plane.py new file mode 100644 index 0000000..991f48c --- /dev/null +++ b/src/vidxp/control_plane.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Callable + +from vidxp.application_boundary import application_boundary +from vidxp.application_models import ( + Artifact, + CapabilityInfo, + CapabilitySummary, + ComponentReadiness, + DependencyCheckResult, + IndexStatus, + InvalidRequestError, + ListMediaCommand, + MediaAsset, + MediaPage, + ModelUnavailableError, + ResourceNotFoundError, + RuntimeReadiness, +) +from vidxp.artifact_service import ArtifactQueryService +from vidxp.capabilities.contracts import CapabilityRequestError +from vidxp.capability_service import CapabilityService +from vidxp.core.media import QuarantinedMedia +from vidxp.index_state import INDEX_STATUS_SCHEMA +from vidxp.media_service import MediaService +from vidxp.ports import LocalFileResource +from vidxp.repository_layout import RepositoryLayout + + +class ControlPlaneApplication: + """Model-free application facade used by HTTP and future remote adapters.""" + + def __init__( + self, + *, + layout: RepositoryLayout, + capabilities: CapabilityService, + media: MediaService, + artifacts: ArtifactQueryService, + index_status: Callable[[], dict | None], + model_cache: Path, + ) -> None: + self.layout = layout + self.capabilities = capabilities + self.media = media + self.artifacts = artifacts + self._read_index_status = index_status + self.model_cache = model_cache + + @application_boundary + def import_uploaded_media( + self, + *, + staged_path: Path, + original_filename: str, + declared_mime_type: str | None, + request_key: str | None = None, + ) -> MediaAsset: + self.layout.ensure_local_directories() + return self.media.import_quarantined( + QuarantinedMedia( + path=staged_path, + original_filename=original_filename, + declared_mime_type=declared_mime_type, + ), + request_key=request_key, + ) + + @application_boundary + def list_capabilities(self) -> tuple[CapabilitySummary, ...]: + return self.capabilities.list() + + @application_boundary + def get_capability(self, name: str) -> CapabilityInfo: + try: + return self.capabilities.get(name) + except CapabilityRequestError as exc: + raise ResourceNotFoundError("capability") from exc + + @application_boundary + def index_status(self) -> IndexStatus: + stored = self._read_index_status() + payload = ( + dict(stored) + if stored is not None + else { + "schema_version": INDEX_STATUS_SCHEMA, + "state": "missing", + "stage": "status", + "message": "No local video index was found.", + } + ) + return IndexStatus.model_validate(payload) + + @application_boundary + def get_media(self, media_id: str) -> MediaAsset: + return self.media.get(media_id) + + @application_boundary + def list_media(self, command: ListMediaCommand) -> MediaPage: + try: + return self.media.list(command) + except ValueError as exc: + raise InvalidRequestError() from exc + + @application_boundary + def open_media_content(self, media_id: str) -> LocalFileResource: + return self.media.content(media_id) + + @application_boundary + def get_artifact(self, artifact_id: str) -> Artifact: + return self.artifacts.get(artifact_id) + + @application_boundary + def open_artifact_content(self, artifact_id: str) -> LocalFileResource: + return self.artifacts.content(artifact_id) + + def control_plane_readiness(self) -> tuple[ComponentReadiness, ...]: + components: list[ComponentReadiness] = [] + try: + self.media.list(ListMediaCommand(page_size=1)) + except Exception: + components.append( + ComponentReadiness( + name="catalog", + ready=False, + message="The media catalog is unavailable.", + ) + ) + else: + components.append( + ComponentReadiness( + name="catalog", + ready=True, + message="The media catalog is available.", + ) + ) + try: + self.index_status() + except Exception: + components.append( + ComponentReadiness( + name="index", + ready=False, + message="The index catalog is unavailable.", + ) + ) + else: + components.append( + ComponentReadiness( + name="index", + ready=True, + message="The index catalog is available.", + ) + ) + return tuple(components) + + def model_readiness( + self, + modalities: tuple[str, ...] | None = None, + ) -> DependencyCheckResult: + selected = ( + self.capabilities.registry.preparable_names() + if modalities is None + else self.capabilities.registry.validate_names(modalities) + ) + checks = self.capabilities.registry.model_checks( + selected, + cache=self.model_cache, + ) + return DependencyCheckResult( + ok=all(check.ok for check in checks), + modalities=selected, + checks=checks, + ) + + def require_models(self, modalities: tuple[str, ...]) -> None: + readiness = self.model_readiness(modalities) + missing = next((check for check in readiness.checks if not check.ok), None) + if missing is not None: + raise ModelUnavailableError(missing.capability) + + def runtime_readiness(self) -> RuntimeReadiness: + components = self.control_plane_readiness() + models = self.model_readiness() + return RuntimeReadiness( + ready=( + all(component.ready for component in components) + and models.ok + ), + runtime=None, + components=components, + dependencies=models, + ) diff --git a/src/vidxp/core/artifacts.py b/src/vidxp/core/artifacts.py new file mode 100644 index 0000000..f305d36 --- /dev/null +++ b/src/vidxp/core/artifacts.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from enum import StrEnum +from pathlib import Path +from typing import Literal + +from pydantic import ( + AwareDatetime, + BaseModel, + ConfigDict, + Field, + field_validator, +) + +from vidxp.core.identifiers import ( + ArtifactId, + IndexGenerationId, + JobId, + MediaId, + MimeType, + Sha256, +) +from vidxp.core.storage_keys import validate_storage_key + + +ARTIFACT_SCHEMA_VERSION = 1 + + +class ArtifactIntegrityError(RuntimeError): + """Raised when managed artifact bytes no longer match catalog authority.""" + + +class ArtifactRenderError(RuntimeError): + """Raised when an artifact renderer cannot produce the requested output.""" + + +class ArtifactRendererUnavailableError(RuntimeError): + """Raised when the configured artifact renderer is unavailable.""" + + +class ArtifactKind(StrEnum): + actor_overlay = "actor_overlay" + snippet = "snippet" + + +class ArtifactState(StrEnum): + ready = "ready" + + +class _ArtifactModel(BaseModel): + model_config = ConfigDict( + extra="forbid", + frozen=True, + allow_inf_nan=False, + strict=True, + ) + + +class ArtifactRecord(_ArtifactModel): + """Authoritative internal artifact entry.""" + + schema_version: Literal[ARTIFACT_SCHEMA_VERSION] = ARTIFACT_SCHEMA_VERSION + artifact_id: ArtifactId + media_id: MediaId + generation_id: IndexGenerationId | None = None + request_key: Sha256 + job_id: JobId | None = None + kind: ArtifactKind + profile: str = Field(min_length=1) + mime_type: MimeType + byte_size: int = Field(gt=0) + sha256: Sha256 + storage_key: str = Field(min_length=1) + state: ArtifactState = ArtifactState.ready + created_at: AwareDatetime + expires_at: AwareDatetime | None = None + + @field_validator("storage_key") + @classmethod + def _validate_storage_key(cls, value: str) -> str: + return validate_storage_key(value) + + +class StagedArtifact(_ArtifactModel): + artifact_id: ArtifactId + path: Path + + +class StoredArtifact(_ArtifactModel): + sha256: Sha256 + byte_size: int = Field(gt=0) + storage_key: str = Field(min_length=1) + local_path: Path diff --git a/src/vidxp/core/contracts.py b/src/vidxp/core/contracts.py index cece2ce..406337f 100644 --- a/src/vidxp/core/contracts.py +++ b/src/vidxp/core/contracts.py @@ -11,8 +11,8 @@ from urllib.parse import quote -INDEX_SCHEMA_VERSION = 3 -MANIFEST_SCHEMA_VERSION = 1 +INDEX_SCHEMA_VERSION = 6 +MANIFEST_SCHEMA_VERSION = 2 class IndexCancelledError(RuntimeError): @@ -30,6 +30,16 @@ def _require_identifier(label: str, value: str) -> str: return value +def _require_sha256(label: str, value: str) -> str: + checksum = str(value).lower() + if ( + len(checksum) != 64 + or any(character not in string.hexdigits for character in checksum) + ): + raise ValueError(f"{label} must be a 64-character SHA-256 value.") + return checksum + + def _filesystem_component(value: str) -> str: value = _require_identifier("path component", value) if value in {".", ".."}: @@ -50,6 +60,8 @@ def stable_source_id( video_id: str, modality: str, local_id: str | int, + *, + generation_id: str | None = None, ) -> str: """Return an escaped, deterministic record ID. @@ -59,6 +71,9 @@ def stable_source_id( values = (run_id, video_id, modality, str(local_id)) labels = ("run_id", "video_id", "modality", "local_id") + if generation_id is not None: + values = (generation_id, *values) + labels = ("generation_id", *labels) encoded = [ quote(_require_identifier(label, value), safe="") for label, value in zip(labels, values) @@ -82,6 +97,10 @@ class IndexConfig: ) output_root: str | Path = "benchmark_runs" storage_directory: str | Path | None = None + generation_directory: str | Path | None = None + generation_id: str | None = None + snapshot_id: str | None = None + snapshot_sha256: str | None = None collection_names: Mapping[str, str] = field( default_factory=dict ) @@ -94,10 +113,26 @@ def __post_init__(self) -> None: "storage_directory", str(self.storage_directory), ) + if self.generation_directory is not None: + object.__setattr__( + self, + "generation_directory", + str(self.generation_directory), + ) for label in ("dataset", "split", "run_id"): _require_identifier(label, getattr(self, label)) if self.video_id is not None: _require_identifier("video_id", self.video_id) + if self.generation_id is not None: + _require_identifier("generation_id", self.generation_id) + if self.snapshot_id is not None: + _require_identifier("snapshot_id", self.snapshot_id) + if self.snapshot_sha256 is not None: + object.__setattr__( + self, + "snapshot_sha256", + _require_sha256("snapshot_sha256", self.snapshot_sha256), + ) modalities = tuple(dict.fromkeys(self.enabled_modalities)) if not modalities: @@ -170,20 +205,20 @@ def __post_init__(self) -> None: @classmethod def local(cls, **changes: Any) -> "IndexConfig": - """Return the single-video configuration used by the existing CLI/UI.""" + """Return the default configuration for a local repository operation.""" defaults = { "storage_directory": "chroma_data", } if "enabled_modalities" not in changes: - from vidxp.capabilities.registry import index_capability_names - - defaults["enabled_modalities"] = index_capability_names() + defaults["enabled_modalities"] = ("dialogue", "scene", "actor") defaults.update(changes) return cls(**defaults) @property def run_directory(self) -> Path: + if self.generation_directory is not None: + return Path(self.generation_directory) if self.storage_directory is not None: return Path(self.storage_directory) return ( @@ -212,7 +247,7 @@ def record_identity( raise ValueError( f"Capability {modality!r} is not enabled for this run." ) - return { + identity = { "dataset": self.dataset, "split": self.split, "run_id": self.run_id, @@ -220,6 +255,9 @@ def record_identity( "modality": modality, "source_id": source_id, } + if self.generation_id is not None: + identity["generation_id"] = self.generation_id + return identity def options_for(self, capability: str) -> dict[str, Any]: if capability not in self.enabled_modalities: @@ -238,7 +276,16 @@ def to_dict(self) -> dict[str, Any]: def fingerprint(self) -> str: payload = asdict(self) - for excluded in ("video_id", "output_root", "storage_directory"): + for excluded in ( + "video_id", + "device", + "output_root", + "storage_directory", + "generation_directory", + "generation_id", + "snapshot_id", + "snapshot_sha256", + ): payload.pop(excluded, None) encoded = json.dumps( payload, @@ -264,13 +311,11 @@ def __post_init__(self) -> None: if self.path is None and self.transcript is None: raise ValueError("A video path or timestamped transcript is required.") if self.checksum is not None: - checksum = str(self.checksum).lower() - if ( - len(checksum) != 64 - or any(character not in string.hexdigits for character in checksum) - ): - raise ValueError("checksum must be a 64-character SHA-256 value.") - object.__setattr__(self, "checksum", checksum) + object.__setattr__( + self, + "checksum", + _require_sha256("checksum", self.checksum), + ) @dataclass(frozen=True) diff --git a/src/vidxp/core/cursors.py b/src/vidxp/core/cursors.py new file mode 100644 index 0000000..518d5e2 --- /dev/null +++ b/src/vidxp/core/cursors.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import base64 +import binascii +import json +from typing import Any, Mapping + + +MAX_CURSOR_OFFSET = (1 << 63) - 1 + + +class CursorError(ValueError): + """Raised when an opaque VidXP cursor cannot be validated.""" + + +def encode_cursor(scope: str, position: Mapping[str, Any]) -> str: + payload = { + "version": 1, + "scope": scope, + **position, + } + encoded = json.dumps( + payload, + separators=(",", ":"), + sort_keys=True, + ).encode() + return base64.urlsafe_b64encode(encoded).decode() + + +def decode_cursor(cursor: str, scope: str) -> dict[str, Any]: + try: + if not isinstance(cursor, str): + raise TypeError + encoded = cursor.encode("ascii") + decoded = base64.b64decode( + encoded, + altchars=b"-_", + validate=True, + ) + if base64.urlsafe_b64encode(decoded) != encoded: + raise CursorError("The cursor is invalid.") + payload = json.loads(decoded.decode()) + except ( + CursorError, + TypeError, + UnicodeEncodeError, + UnicodeDecodeError, + ValueError, + json.JSONDecodeError, + binascii.Error, + ) as exc: + raise CursorError("The cursor is invalid.") from exc + if ( + not isinstance(payload, dict) + or payload.get("version") != 1 + or payload.get("scope") != scope + ): + raise CursorError("The cursor is invalid.") + return payload + + +def encode_offset_cursor( + offset: int, + *, + scope: str, + has_more: bool = True, +) -> str | None: + if not has_more: + return None + if ( + not isinstance(offset, int) + or isinstance(offset, bool) + or offset < 0 + or offset > MAX_CURSOR_OFFSET + ): + raise CursorError("The cursor offset is invalid.") + return encode_cursor(scope, {"offset": offset}) + + +def decode_offset_cursor(cursor: str | None, *, scope: str) -> int: + if cursor is None: + return 0 + payload = decode_cursor(cursor, scope) + offset = payload.get("offset") + if ( + not isinstance(offset, int) + or isinstance(offset, bool) + or offset < 0 + or offset > MAX_CURSOR_OFFSET + ): + raise CursorError("The cursor offset is invalid.") + return offset diff --git a/src/vidxp/core/generations.py b/src/vidxp/core/generations.py new file mode 100644 index 0000000..690db5b --- /dev/null +++ b/src/vidxp/core/generations.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import ( + AwareDatetime, + BaseModel, + ConfigDict, + Field, + JsonValue, + NonNegativeFloat, + NonNegativeInt, + model_validator, +) + +from vidxp.core.contracts import ( + INDEX_SCHEMA_VERSION, + MANIFEST_SCHEMA_VERSION, +) +from vidxp.core.identifiers import Identifier, IndexGenerationId, Sha256 + + +class _GenerationManifestModel(BaseModel): + model_config = ConfigDict( + allow_inf_nan=False, + extra="forbid", + frozen=True, + strict=True, + ) + + +class _GenerationInput(_GenerationManifestModel): + sha256: Sha256 + checksums: dict[Identifier, Sha256] + size: NonNegativeInt | None + source_name: str | None + path: str | None + metadata: dict[str, JsonValue] + + +class _GenerationStage(_GenerationManifestModel): + video_id: Identifier + stage: Identifier + seconds: NonNegativeFloat + stats: dict[str, JsonValue] + recorded_at: AwareDatetime + + +class _CompletedGenerationVideo(_GenerationManifestModel): + state: Literal["complete"] + started_at: AwareDatetime + stages: dict[Identifier, _GenerationStage] + completed_at: AwareDatetime + summary: dict[str, JsonValue] + + +class CompletedGenerationManifest(_GenerationManifestModel): + manifest_schema_version: Literal[MANIFEST_SCHEMA_VERSION] + index_schema_version: Literal[INDEX_SCHEMA_VERSION] + dataset: Identifier + split: Identifier + run_id: Identifier + generation_id: IndexGenerationId + state: Literal["complete"] + created_at: AwareDatetime + updated_at: AwareDatetime + completed_at: AwareDatetime + config_fingerprint: Sha256 + execution_fingerprint: Sha256 + configuration: dict[str, JsonValue] + models: dict[str, JsonValue] + git: dict[str, JsonValue] + environment: dict[str, JsonValue] + inputs: dict[Identifier, _GenerationInput] = Field(min_length=1, max_length=1) + videos: dict[Identifier, _CompletedGenerationVideo] = Field( + min_length=1, + max_length=1, + ) + completed_videos: tuple[Identifier, ...] = Field( + min_length=1, + max_length=1, + ) + failed_videos: tuple[Identifier, ...] = Field(max_length=0) + interrupted_videos: tuple[Identifier, ...] = Field(max_length=0) + processed_frames: NonNegativeInt + record_counts: dict[Identifier, NonNegativeInt] + store_size_bytes_at_commit: NonNegativeInt | None + + @model_validator(mode="after") + def _validate_completed_generation(self) -> CompletedGenerationManifest: + input_ids = set(self.inputs) + video_ids = set(self.videos) + completed_ids = set(self.completed_videos) + if input_ids != video_ids or input_ids != completed_ids: + raise ValueError( + "inputs, videos, and completed_videos must identify the same " + "single media item" + ) + + configured = self.configuration.get("enabled_modalities") + if ( + not isinstance(configured, list) + or not configured + or any(not isinstance(value, str) or not value for value in configured) + or len(configured) != len(set(configured)) + ): + raise ValueError( + "configuration.enabled_modalities must be a non-empty list " + "of unique identifiers" + ) + if set(self.record_counts) != set(configured): + raise ValueError( + "record_counts keys must exactly match enabled_modalities" + ) + return self diff --git a/src/vidxp/core/identifiers.py b/src/vidxp/core/identifiers.py new file mode 100644 index 0000000..8305094 --- /dev/null +++ b/src/vidxp/core/identifiers.py @@ -0,0 +1,80 @@ +from typing import Annotated, TypeAlias +from uuid import UUID + +from pydantic import AfterValidator, StringConstraints + + +Identifier: TypeAlias = Annotated[ + str, + StringConstraints( + strip_whitespace=True, + min_length=1, + max_length=255, + pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]*$", + ), +] +ActorClusterId: TypeAlias = Annotated[ + str, + StringConstraints( + strip_whitespace=True, + min_length=1, + max_length=512, + pattern=r"^[A-Za-z0-9._~:%-]+$", + ), +] + + +def _require_uuid4_hex(value: str) -> str: + identifier = UUID(hex=value) + if identifier.version != 4 or identifier.hex != value: + raise ValueError("identifier must be lowercase UUID4 hex") + return value + + +def _require_workflow_uuid(value: str) -> str: + identifier = UUID(value) + if ( + identifier.version not in {4, 7} + or value not in {identifier.hex, str(identifier)} + ): + raise ValueError("identifier must be a lowercase UUID4 or UUID7 string") + return value + + +Uuid4Hex: TypeAlias = Annotated[ + str, + StringConstraints(pattern=r"^[0-9a-f]{32}$"), + AfterValidator(_require_uuid4_hex), +] +WorkflowUuid: TypeAlias = Annotated[ + str, + StringConstraints( + pattern=( + r"^(?:[0-9a-f]{12}[47][0-9a-f]{19}|" + r"[0-9a-f]{8}-[0-9a-f]{4}-[47][0-9a-f]{3}-" + r"[89ab][0-9a-f]{3}-[0-9a-f]{12})$" + ) + ), + AfterValidator(_require_workflow_uuid), +] +Sha256: TypeAlias = Annotated[ + str, + StringConstraints(pattern=r"^[0-9a-f]{64}$"), +] +MimeType: TypeAlias = Annotated[ + str, + StringConstraints( + strip_whitespace=True, + min_length=3, + max_length=127, + pattern=r"^[A-Za-z0-9!#$&^_.+-]+/[A-Za-z0-9!#$&^_.+-]+$", + ), +] + +MediaId: TypeAlias = Uuid4Hex +VideoId: TypeAlias = MediaId +IndexGenerationId: TypeAlias = Uuid4Hex +IndexSnapshotId: TypeAlias = Uuid4Hex +JobId: TypeAlias = WorkflowUuid +ArtifactId: TypeAlias = Uuid4Hex +UploadIntentId: TypeAlias = Uuid4Hex diff --git a/src/vidxp/core/manifest.py b/src/vidxp/core/manifest.py index d2a35f0..f0790f7 100644 --- a/src/vidxp/core/manifest.py +++ b/src/vidxp/core/manifest.py @@ -2,9 +2,11 @@ import hashlib import json +import os import platform import subprocess import sys +import tempfile import time from datetime import datetime, timezone from importlib.metadata import PackageNotFoundError, version @@ -12,8 +14,7 @@ from typing import Any, Mapping from vidxp.capabilities.registry import ( - get_capability, - runtime_distributions, + CapabilityRegistry, ) from vidxp.core.contracts import ( INDEX_SCHEMA_VERSION, @@ -21,6 +22,7 @@ IndexConfig, VideoSource, ) +from vidxp.ports import ModelRuntimePort MANIFEST_FILE = "manifest.json" @@ -36,25 +38,58 @@ def utc_now() -> str: def write_json_atomic(path: Path, payload: Any) -> None: path.parent.mkdir(parents=True, exist_ok=True) - temporary = path.with_suffix(path.suffix + ".tmp") - temporary.write_text( - json.dumps( - payload, - ensure_ascii=False, - indent=2, - sort_keys=True, - ) - + "\n", - encoding="utf-8", - ) - for attempt in range(5): - try: - temporary.replace(path) - return - except PermissionError: - if attempt == 4: - raise - time.sleep(0.05 * (attempt + 1)) + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as destination: + temporary = Path(destination.name) + destination.write( + json.dumps( + payload, + ensure_ascii=False, + indent=2, + sort_keys=True, + ) + + "\n" + ) + destination.flush() + os.fsync(destination.fileno()) + + for attempt in range(5): + try: + temporary.replace(path) + break + except OSError: + if attempt == 4: + raise + time.sleep(0.05 * (attempt + 1)) + + sync_parent_directory(path.parent) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +def sync_parent_directory(path: Path) -> None: + if os.name == "nt": + return + descriptor: int | None = None + try: + descriptor = os.open(path, os.O_RDONLY) + os.fsync(descriptor) + except OSError: + # The replacement is already visible. Some supported filesystems + # reject directory fsync, which cannot roll that replacement back. + pass + finally: + if descriptor is not None: + os.close(descriptor) def _append_jsonl(path: Path, payload: Mapping[str, Any]) -> None: @@ -153,9 +188,11 @@ def implementation_digest() -> str: return digest.hexdigest() -def dependency_versions() -> dict[str, str | None]: +def dependency_versions( + registry: CapabilityRegistry, +) -> dict[str, str | None]: versions: dict[str, str | None] = {} - for package in runtime_distributions(): + for package in registry.runtime_distributions(): try: versions[package] = version(package) except PackageNotFoundError: @@ -163,7 +200,9 @@ def dependency_versions() -> dict[str, str | None]: return versions -def execution_state() -> dict[str, Any]: +def execution_state( + registry: CapabilityRegistry, +) -> dict[str, Any]: try: package_version = version("vidxp") except PackageNotFoundError: @@ -174,7 +213,7 @@ def execution_state() -> dict[str, Any]: "package_version": package_version, "python": sys.version, "platform": platform.platform(), - "dependencies": dependency_versions(), + "dependencies": dependency_versions(registry), } @@ -193,8 +232,16 @@ def execution_fingerprint(state: Mapping[str, Any]) -> str: class ManifestStore: - def __init__(self, config: IndexConfig): + def __init__( + self, + config: IndexConfig, + *, + registry: CapabilityRegistry, + runtime: ModelRuntimePort, + ): self.config = config + self.registry = registry + self.runtime = runtime self.run_directory = config.run_directory self.manifest_path = self.run_directory / MANIFEST_FILE self.timings_path = self.run_directory / TIMINGS_FILE @@ -214,10 +261,13 @@ def _model_manifest( tuple[str, VideoSource, str, Mapping[str, str]] ], ) -> dict[str, Any]: - models: dict[str, Any] = {"device": self.config.device} + models: dict[str, Any] = { + "device": self.config.device, + "runtime": self.runtime.describe(), + } source_values = tuple(source for _, source, _, _ in sources) for name in self.config.enabled_modalities: - manifest = get_capability(name).model_manifest + manifest = self.registry.executor(name).model_manifest if manifest is not None: models.update(manifest(self.config, source_values)) return models @@ -230,7 +280,7 @@ def initialize( ) -> dict[str, Any]: self.run_directory.mkdir(parents=True, exist_ok=True) config_fingerprint = self.config.fingerprint() - current_execution = execution_state() + current_execution = execution_state(self.registry) current_execution_fingerprint = execution_fingerprint( current_execution ) @@ -257,6 +307,7 @@ def initialize( "dataset": self.config.dataset, "split": self.config.split, "run_id": self.config.run_id, + "generation_id": self.config.generation_id, "state": "running", "created_at": utc_now(), "updated_at": utc_now(), @@ -272,7 +323,8 @@ def initialize( "failed_videos": [], "interrupted_videos": [], "processed_frames": 0, - "index_size_bytes": 0, + "record_counts": {}, + "store_size_bytes_at_commit": None, } if reset: for path in ( @@ -319,6 +371,9 @@ def read(self) -> dict[str, Any]: def write(self, manifest: Mapping[str, Any]) -> None: write_json_atomic(self.manifest_path, manifest) + def _refresh_runtime(self, manifest: dict[str, Any]) -> None: + manifest["models"]["runtime"] = self.runtime.describe() + def checkpoint(self, video_id: str) -> dict[str, Any] | None: path = self._checkpoint_path(video_id) if not path.is_file(): @@ -430,6 +485,7 @@ def complete_video( if video_id not in manifest["completed_videos"]: manifest["completed_videos"].append(video_id) manifest["completed_videos"].sort() + self._refresh_runtime(manifest) manifest["updated_at"] = utc_now() self.write(manifest) @@ -449,6 +505,7 @@ def fail_video(self, video_id: str, stage: str, error: str) -> None: manifest["failed_videos"].append(video_id) manifest["failed_videos"].sort() manifest["state"] = "failed" + self._refresh_runtime(manifest) manifest["updated_at"] = utc_now() self.write(manifest) @@ -465,16 +522,24 @@ def interrupt_video(self, video_id: str, stage: str) -> None: manifest["interrupted_videos"].append(video_id) manifest["interrupted_videos"].sort() manifest["state"] = "interrupted" + self._refresh_runtime(manifest) manifest["updated_at"] = utc_now() self.write(manifest) - def complete_run(self, *, index_size_bytes: int) -> dict[str, Any]: + def complete_run( + self, + *, + store_size_bytes_at_commit: int | None, + ) -> dict[str, Any]: manifest = self.read() + self._refresh_runtime(manifest) expected = set(manifest["inputs"]) completed = set(manifest["completed_videos"]) if expected != completed or manifest["failed_videos"]: manifest["state"] = "completed_with_failures" - manifest["index_size_bytes"] = index_size_bytes + manifest["store_size_bytes_at_commit"] = ( + store_size_bytes_at_commit + ) manifest["updated_at"] = utc_now() self.write(manifest) return manifest @@ -486,7 +551,7 @@ def complete_run(self, *, index_size_bytes: int) -> dict[str, Any]: manifest["state"] = "complete" manifest["completed_at"] = utc_now() manifest["processed_frames"] = processed_frames - manifest["index_size_bytes"] = index_size_bytes + manifest["store_size_bytes_at_commit"] = store_size_bytes_at_commit manifest["updated_at"] = utc_now() self.write(manifest) completion = { @@ -497,7 +562,31 @@ def complete_run(self, *, index_size_bytes: int) -> dict[str, Any]: "config_fingerprint": self.config.fingerprint(), "completed_at": manifest["completed_at"], "completed_videos": manifest["completed_videos"], - "index_size_bytes": index_size_bytes, + "store_size_bytes_at_commit": store_size_bytes_at_commit, } write_json_atomic(self.completion_path, completion) return manifest + + def record_storage_counts( + self, + record_counts: Mapping[str, int], + ) -> dict[str, Any]: + manifest = self.read() + if manifest.get("state") != "complete": + raise RuntimeError( + "Storage counts can only finalize a completed generation." + ) + counts = { + str(modality): int(count) + for modality, count in record_counts.items() + } + if set(counts) != set(self.config.enabled_modalities): + raise ValueError( + "Storage counts must cover every enabled modality exactly." + ) + if any(count < 0 for count in counts.values()): + raise ValueError("Storage record counts must be nonnegative.") + manifest["record_counts"] = counts + manifest["updated_at"] = utc_now() + self.write(manifest) + return manifest diff --git a/src/vidxp/core/media.py b/src/vidxp/core/media.py new file mode 100644 index 0000000..95b6baf --- /dev/null +++ b/src/vidxp/core/media.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from pathlib import Path +from typing import Literal + +from pydantic import ( + AwareDatetime, + BaseModel, + ConfigDict, + Field, + field_validator, + model_validator, +) + +from vidxp.core.identifiers import MediaId, MimeType, Sha256, VideoId +from vidxp.core.storage_keys import validate_storage_key + + +MEDIA_SCHEMA_VERSION = 1 + + +class InvalidMediaError(ValueError): + """Raised when media cannot be safely probed as a supported video.""" + + +class MediaProbeUnavailableError(RuntimeError): + """Raised when the configured media probe is unavailable.""" + + +class MediaStoreIntegrityError(RuntimeError): + """Raised when managed content no longer matches catalog authority.""" + + +class MediaImportLimitError(ValueError): + """Raised when a local import exceeds the configured byte limit.""" + + +class MediaUnavailableError(FileNotFoundError): + """Raised when a cataloged media asset cannot be materialized.""" + + +class MediaState(StrEnum): + ready = "ready" + + +class _MediaModel(BaseModel): + model_config = ConfigDict( + extra="forbid", + frozen=True, + allow_inf_nan=False, + strict=True, + ) + + +class MediaStream(_MediaModel): + index: int = Field(ge=0) + kind: str = Field(min_length=1) + codec: str = Field(min_length=1) + width: int | None = Field(default=None, gt=0) + height: int | None = Field(default=None, gt=0) + channels: int | None = Field(default=None, gt=0) + sample_rate: int | None = Field(default=None, gt=0) + + +class MediaProbe(_MediaModel): + detected_mime_type: MimeType + container: str = Field(min_length=1) + duration_seconds: float = Field(gt=0) + streams: tuple[MediaStream, ...] = Field(min_length=1) + + @property + def has_video(self) -> bool: + return any(stream.kind == "video" for stream in self.streams) + + @model_validator(mode="after") + def _require_video_stream(self) -> "MediaProbe": + if not self.has_video: + raise ValueError("media probe must contain a video stream") + return self + + +class StoredMedia(_MediaModel): + """Internal managed-media location produced by a media store.""" + + sha256: Sha256 + byte_size: int = Field(gt=0) + storage_key: str = Field(min_length=1) + local_path: Path + + @field_validator("storage_key") + @classmethod + def _validate_storage_key(cls, value: str) -> str: + return validate_storage_key(value) + + +class StagedMedia(_MediaModel): + """Quarantined media awaiting probe and managed-store publication.""" + + sha256: Sha256 + byte_size: int = Field(gt=0) + storage_key: str = Field(min_length=1) + path: Path + + @field_validator("storage_key") + @classmethod + def _validate_storage_key(cls, value: str) -> str: + return validate_storage_key(value) + + +class QuarantinedMedia(_MediaModel): + """Adapter-staged media accepted by the shared ingestion boundary.""" + + path: Path + original_filename: str = Field(min_length=1, max_length=255) + declared_mime_type: MimeType | None = None + + @field_validator("original_filename") + @classmethod + def _filename_only(cls, value: str) -> str: + return validate_display_filename(value) + + +class MediaRecord(_MediaModel): + """Authoritative internal catalog entry; storage_key is never projected.""" + + schema_version: Literal[MEDIA_SCHEMA_VERSION] = MEDIA_SCHEMA_VERSION + media_id: MediaId + video_id: VideoId + sha256: Sha256 + original_filename: str = Field(min_length=1, max_length=255) + byte_size: int = Field(gt=0) + declared_mime_type: MimeType | None = None + detected_mime_type: MimeType + container: str = Field(min_length=1) + duration_seconds: float = Field(gt=0) + streams: tuple[MediaStream, ...] = Field(min_length=1) + storage_key: str = Field(min_length=1) + state: MediaState = MediaState.ready + created_at: AwareDatetime + + @field_validator("original_filename") + @classmethod + def _filename_only(cls, value: str) -> str: + return validate_display_filename(value) + + @field_validator("storage_key") + @classmethod + def _validate_storage_key(cls, value: str) -> str: + return validate_storage_key(value) + + @model_validator(mode="after") + def _require_video_stream(self) -> "MediaRecord": + if not any(stream.kind == "video" for stream in self.streams): + raise ValueError("ready media must contain a video stream") + return self + + +def utc_now() -> datetime: + from datetime import timezone + + return datetime.now(timezone.utc) + + +def validate_display_filename(value: str) -> str: + if ( + Path(value).name != value + or "/" in value + or "\\" in value + or value in {".", ".."} + or any(ord(character) < 32 or ord(character) == 127 for character in value) + ): + raise ValueError( + "original_filename must be a basename without control characters" + ) + return value + + +def safe_media_suffix(path: Path) -> str: + """Return a bounded suffix suitable for temporary media staging.""" + + suffix = path.suffix.lower() + if ( + len(suffix) <= 10 + and suffix.startswith(".") + and suffix[1:].isalnum() + ): + return suffix + return ".bin" diff --git a/src/vidxp/core/runner.py b/src/vidxp/core/runner.py index 896652a..8c47099 100644 --- a/src/vidxp/core/runner.py +++ b/src/vidxp/core/runner.py @@ -1,22 +1,17 @@ from __future__ import annotations from pathlib import Path -from threading import Lock from time import perf_counter from typing import Any, Callable, Sequence from filelock import FileLock, Timeout -from vidxp.capabilities.registry import ( - get_capability, - require_dependencies, -) +from vidxp.capabilities.registry import CapabilityRegistry from vidxp.core.contracts import ( INDEX_SCHEMA_VERSION, CancellationToken, IndexCancelledError, IndexConfig, - IndexSchemaError, VideoSource, ) from vidxp.core.manifest import ( @@ -25,15 +20,22 @@ source_checksum, source_checksums, ) -from vidxp.core.storage import IndexStorage -from vidxp.index_state import ( - IndexingInProgressError, - write_index_status, -) +from vidxp.index_state import IndexingInProgressError +from vidxp.ports import IndexStore, ModelRuntimePort ProgressCallback = Callable[[dict[str, Any]], None] -_INDEXING_LOCK = Lock() + + +def require_dependencies( + names: tuple[str, ...], + *, + source: VideoSource, + registry: CapabilityRegistry, +) -> None: + """Single testable dependency gate for an injected registry.""" + + registry.require_dependencies(names, source=source) class _RunLock: @@ -68,47 +70,8 @@ def _run_lock_held(run_directory: Path) -> bool: return False -def indexing_in_progress(config: IndexConfig | None = None) -> bool: - if _INDEXING_LOCK.locked(): - return True - active_config = config or IndexConfig.local() - return _run_lock_held(active_config.run_directory) - - -def local_config_from_status( - status: dict[str, Any], - *, - storage_directory: str | Path | None = None, -) -> IndexConfig: - summary = status.get("summary") or {} - if summary.get("index_schema_version") != INDEX_SCHEMA_VERSION: - raise IndexSchemaError( - "The saved index predates the benchmark-ready schema. " - "Re-index the video before searching." - ) - stored = dict(summary.get("configuration") or {}) - stored = { - key: value - for key, value in stored.items() - if key in IndexConfig.__dataclass_fields__ - } - stored.update( - { - "dataset": str(summary["dataset"]), - "split": str(summary["split"]), - "run_id": str(summary["run_id"]), - "video_id": str(summary["video_id"]), - } - ) - if storage_directory is not None: - stored["storage_directory"] = str(storage_directory) - else: - stored.setdefault("storage_directory", "chroma_data") - if "enabled_modalities" in stored: - stored["enabled_modalities"] = tuple(stored["enabled_modalities"]) - if "collection_names" in stored: - stored["collection_names"] = dict(stored["collection_names"]) - return IndexConfig(**stored) +def indexing_in_progress(config: IndexConfig) -> bool: + return _run_lock_held(config.run_directory) def _resolve_sources( @@ -140,19 +103,22 @@ def _run_capability_group( names: tuple[str, ...], source: VideoSource, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, manifest: ManifestStore, cancellation: CancellationToken, progress_callback: ProgressCallback | None, + registry: CapabilityRegistry, + runtime: ModelRuntimePort, ) -> dict[str, Any]: - definitions = tuple(get_capability(name) for name in names) - indexer = definitions[0].indexer + definitions = tuple(registry.get(name) for name in names) + indexers = tuple(registry.executor(name).indexer for name in names) + indexer = indexers[0] index_stage = definitions[0].index_stage if indexer is None or index_stage is None: raise ValueError( f"Capability {names[0]!r} does not support indexing." ) - if any(definition.indexer is not indexer for definition in definitions): + if any(candidate is not indexer for candidate in indexers): raise RuntimeError("Grouped capabilities must share one indexer.") if any( definition.index_stage != index_stage @@ -192,6 +158,8 @@ def stage_progress(event: dict[str, Any]) -> None: cancellation=cancellation, progress=stage_progress, modalities=names, + registry=registry, + runtime=runtime, ) except BaseException: if active_substage is not None and active_substage != index_stage: @@ -241,23 +209,27 @@ def stage_progress(event: dict[str, Any]) -> None: return dict(result.summary) -def _index_groups(names: tuple[str, ...]) -> tuple[tuple[str, ...], ...]: +def _index_groups( + names: tuple[str, ...], + registry: CapabilityRegistry, +) -> tuple[tuple[str, ...], ...]: groups: list[list[str]] = [] - handlers = [] + execution_groups: list[str] = [] for name in names: - handler = get_capability(name).indexer - if handler is None: + definition = registry.get(name) + if registry.executor(name).indexer is None: raise ValueError( f"Capability {name!r} does not support indexing." ) - try: - group_index = next( - index - for index, existing in enumerate(handlers) - if existing is handler + execution_group = definition.execution_group + if execution_group is None: + raise RuntimeError( + f"Capability {name!r} has no execution group." ) - except StopIteration: - handlers.append(handler) + try: + group_index = execution_groups.index(execution_group) + except ValueError: + execution_groups.append(execution_group) groups.append([name]) else: groups[group_index].append(name) @@ -267,16 +239,18 @@ def _index_groups(names: tuple[str, ...]) -> tuple[tuple[str, ...], ...]: def _run_enabled_modalities( source: VideoSource, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, manifest: ManifestStore, cancellation: CancellationToken, progress_callback: ProgressCallback | None, set_stage: Callable[[str], None], + registry: CapabilityRegistry, + runtime: ModelRuntimePort, ) -> dict[str, Any]: summary: dict[str, Any] = {} - for names in _index_groups(config.enabled_modalities): + for names in _index_groups(config.enabled_modalities, registry): cancellation.raise_if_cancelled() - set_stage(get_capability(names[0]).index_stage) + set_stage(str(registry.get(names[0]).index_stage)) summary.update( _run_capability_group( names, @@ -286,6 +260,8 @@ def _run_enabled_modalities( manifest, cancellation, progress_callback, + registry, + runtime, ) ) return summary @@ -296,12 +272,14 @@ def _process_video( source: VideoSource, checksum: str, config: IndexConfig, - storage: IndexStorage, + storage: IndexStore, manifest: ManifestStore, cancellation: CancellationToken, progress_callback: ProgressCallback | None, *, fail_fast: bool, + registry: CapabilityRegistry, + runtime: ModelRuntimePort, ) -> None: video_config = config.for_video(video_id) manifest.start_video(video_id) @@ -320,10 +298,19 @@ def set_stage(value: str) -> None: require_dependencies( config.enabled_modalities, source=source, + registry=registry, ) stage = "preparing_storage" - for modality in config.enabled_modalities: - storage.delete_video(modality, video_id) + if config.generation_id is not None: + for modality in config.enabled_modalities: + storage.delete_records( + modality, + video_id=video_id, + filters={"generation_id": config.generation_id}, + ) + else: + for modality in config.enabled_modalities: + storage.delete_video(modality, video_id) summary.update( _run_enabled_modalities( @@ -334,6 +321,8 @@ def set_stage(value: str) -> None: cancellation, progress_callback, set_stage, + registry, + runtime, ) ) manifest.complete_video( @@ -380,54 +369,53 @@ def _run_index_unlocked( resume: bool = True, reset: bool = False, fail_fast: bool = True, - storage: IndexStorage | None = None, - manifest_store: ManifestStore | None = None, + storage: IndexStore, + manifest_store: ManifestStore, + registry: CapabilityRegistry, + runtime: ModelRuntimePort, ) -> dict[str, Any]: if not sources: raise ValueError("At least one video or transcript source is required.") cancellation = cancellation or CancellationToken() resolved = _resolve_sources(sources, config) - owns_storage = storage is None - store = storage or IndexStorage(config) - try: - manifest = manifest_store or ManifestStore(config) - if reset: - store.clear() - manifest.initialize(resolved, reset=reset) - - for video_id, source, checksum, _ in resolved: - if resume and manifest.completed( - video_id, - checksum=checksum, - config_fingerprint=config.fingerprint(), - ): - _report( - progress_callback, - { - "state": "skipped", - "stage": "checkpoint", - "message": f"Skipping completed video {video_id}.", - "video_id": video_id, - }, - ) - continue + if reset: + storage.clear() + manifest_store.initialize(resolved, reset=reset) - _process_video( - video_id, - source, - checksum, - config, - store, - manifest, - cancellation, + for video_id, source, checksum, _ in resolved: + if resume and manifest_store.completed( + video_id, + checksum=checksum, + config_fingerprint=config.fingerprint(), + ): + _report( progress_callback, - fail_fast=fail_fast, + { + "state": "skipped", + "stage": "checkpoint", + "message": f"Skipping completed video {video_id}.", + "video_id": video_id, + }, ) + continue - return manifest.complete_run(index_size_bytes=store.size_bytes()) - finally: - if owns_storage: - store.close() + _process_video( + video_id, + source, + checksum, + config, + storage, + manifest_store, + cancellation, + progress_callback, + fail_fast=fail_fast, + registry=registry, + runtime=runtime, + ) + + return manifest_store.complete_run( + store_size_bytes_at_commit=storage.size_bytes() + ) def run_index( @@ -439,8 +427,10 @@ def run_index( resume: bool = True, reset: bool = False, fail_fast: bool = True, - storage: IndexStorage | None = None, - manifest_store: ManifestStore | None = None, + storage: IndexStore, + manifest_store: ManifestStore, + registry: CapabilityRegistry, + runtime: ModelRuntimePort, ) -> dict[str, Any]: with _RunLock(config.run_directory): return _run_index_unlocked( @@ -453,36 +443,32 @@ def run_index( fail_fast=fail_fast, storage=storage, manifest_store=manifest_store, + registry=registry, + runtime=runtime, ) -def _video_status(path: Path, source_name: str, checksum: str) -> dict[str, Any]: - stat = path.stat() - return { - "path": str(path.resolve()), - "source_name": source_name, - "size": stat.st_size, - "sha256": checksum, - } - - def index_video( path: str, progress_callback: ProgressCallback | None = None, source_name: str | None = None, + checksum: str | None = None, *, - config: IndexConfig | None = None, + config: IndexConfig, cancellation: CancellationToken | None = None, + storage: IndexStore, + manifest_store: ManifestStore, + registry: CapabilityRegistry, + runtime: ModelRuntimePort, ) -> dict[str, Any]: input_path = Path(path) if not input_path.is_file(): raise FileNotFoundError(f"Video not found: {input_path}") - if not _INDEXING_LOCK.acquire(blocking=False): - raise IndexingInProgressError("Another video is already being indexed.") source = VideoSource( path=input_path, source_name=source_name or input_path.name, + checksum=checksum, ) checksum = source_checksum(source) source = VideoSource( @@ -490,13 +476,8 @@ def index_video( source_name=source.source_name, checksum=checksum, ) - active_config = config or IndexConfig.local(video_id=checksum) + active_config = config video_id = active_config.video_id or checksum - video = _video_status( - input_path, - source.source_name or input_path.name, - checksum, - ) latest_event: dict[str, Any] = { "state": "indexing", "stage": "initializing", @@ -504,17 +485,6 @@ def index_video( def report(event: dict[str, Any]) -> None: latest_event.update(event) - write_index_status( - state=event["state"], - stage=event["stage"], - message=event["message"], - video=video, - current=event.get("current"), - total=event.get("total"), - summary=event.get("summary"), - error=event.get("error"), - index_directory=active_config.index_directory, - ) if progress_callback is not None: progress_callback(event) @@ -522,10 +492,7 @@ def report(event: dict[str, Any]) -> None: { "state": "indexing", "stage": "initializing", - "message": ( - "Preparing the selected indexing modalities. " - "Missing model weights will download before their first use." - ), + "message": "Preparing the selected indexing modalities.", } ) try: @@ -534,8 +501,12 @@ def report(event: dict[str, Any]) -> None: active_config, progress_callback=report, cancellation=cancellation, + storage=storage, + manifest_store=manifest_store, + registry=registry, + runtime=runtime, resume=False, - reset=True, + reset=False, fail_fast=True, ) summary = dict(manifest["videos"][video_id]["summary"]) @@ -546,7 +517,6 @@ def report(event: dict[str, Any]) -> None: "split": active_config.split, "run_id": active_config.run_id, "video_id": video_id, - "configuration": active_config.to_dict(), } ) report( @@ -579,5 +549,3 @@ def report(event: dict[str, Any]) -> None: } ) raise - finally: - _INDEXING_LOCK.release() diff --git a/src/vidxp/core/snapshots.py b/src/vidxp/core/snapshots.py new file mode 100644 index 0000000..b03c2fe --- /dev/null +++ b/src/vidxp/core/snapshots.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import ( + AwareDatetime, + BaseModel, + ConfigDict, + Field, + JsonValue, + model_validator, +) + +from vidxp.core.identifiers import ( + Identifier, + IndexGenerationId, + IndexSnapshotId, + Sha256, +) + + +INDEX_SNAPSHOT_SCHEMA_VERSION = 1 +ACTIVE_SNAPSHOT_POINTER_SCHEMA_VERSION = 1 + +class _SnapshotModel(BaseModel): + model_config = ConfigDict( + allow_inf_nan=False, + extra="forbid", + frozen=True, + strict=True, + ) + + +class GenerationReference(_SnapshotModel): + generation_id: IndexGenerationId + media_id: Identifier + manifest_sha256: Sha256 + input_sha256: Sha256 + config_fingerprint: Sha256 + modalities: tuple[Identifier, ...] = Field(min_length=1) + record_counts: dict[Identifier, int] + store_size_bytes_at_commit: int | None = Field(ge=0) + + @model_validator(mode="after") + def _validate_record_counts(self) -> GenerationReference: + if set(self.record_counts) != set(self.modalities): + raise ValueError( + "record_counts keys must exactly match modalities" + ) + if any(count < 0 for count in self.record_counts.values()): + raise ValueError("record_counts values must be nonnegative") + return self + + +class IndexSnapshot(_SnapshotModel): + schema_version: Literal[INDEX_SNAPSHOT_SCHEMA_VERSION] = ( + INDEX_SNAPSHOT_SCHEMA_VERSION + ) + snapshot_id: IndexSnapshotId + created_at: AwareDatetime + config_fingerprint: Sha256 + configuration: dict[str, JsonValue] + generations: dict[Identifier, GenerationReference] + + @model_validator(mode="after") + def _validate_generation_keys(self) -> IndexSnapshot: + mismatched = sorted( + media_id + for media_id, reference in self.generations.items() + if media_id != reference.media_id + ) + if mismatched: + raise ValueError( + "generation mapping keys must match reference media_id values" + ) + return self + + +class ActiveSnapshotPointer(_SnapshotModel): + schema_version: Literal[ACTIVE_SNAPSHOT_POINTER_SCHEMA_VERSION] = ( + ACTIVE_SNAPSHOT_POINTER_SCHEMA_VERSION + ) + snapshot_id: IndexSnapshotId + snapshot_sha256: Sha256 + updated_at: AwareDatetime diff --git a/src/vidxp/core/storage.py b/src/vidxp/core/storage.py index 50ac3db..6dfd9d5 100644 --- a/src/vidxp/core/storage.py +++ b/src/vidxp/core/storage.py @@ -1,7 +1,8 @@ from __future__ import annotations from pathlib import Path -from typing import Any, Iterable, Mapping +from typing import Any, Callable, Iterable, Mapping, TypeVar +from urllib.parse import urlsplit from vidxp.core.contracts import ( CancellationToken, @@ -10,11 +11,39 @@ batched, ) +_T = TypeVar("_T") + +BUNDLED_CHROMA_SERVER_URL = "http://chroma:8000" + + +class IndexStorageUnavailableError(RuntimeError): + """The configured remote vector store could not serve an operation.""" + + +def _is_remote_unavailable(exc: Exception) -> bool: + """Return whether an exception represents a retryable remote failure.""" + + import httpx + from chromadb.errors import ( + InternalError, + RateLimitError, + ) + + return isinstance( + exc, + ( + httpx.TransportError, + InternalError, + RateLimitError, + ), + ) + def metadata_filter( config: IndexConfig, *, video_id: str | None = None, + generation_ids: Iterable[str] | None = None, extra: Mapping[str, Any] | None = None, ) -> dict[str, Any]: values: dict[str, Any] = { @@ -24,6 +53,11 @@ def metadata_filter( } if video_id is not None: values["video_id"] = video_id + selected_generations = _generation_ids(generation_ids) + if selected_generations is not None: + if not selected_generations: + raise ValueError("generation_ids must not be empty.") + values["generation_id"] = {"$in": list(selected_generations)} if extra: reserved = set(values) & set(extra) if reserved: @@ -36,20 +70,91 @@ def metadata_filter( return clauses[0] if len(clauses) == 1 else {"$and": clauses} -def _client_for_path(path: str): - import chromadb +def _generation_ids( + generation_ids: Iterable[str] | None, +) -> tuple[str, ...] | None: + if generation_ids is None: + return None + if isinstance(generation_ids, str): + raise TypeError("generation_ids must be an iterable of identifiers.") + values = tuple(generation_ids) + if any(not isinstance(value, str) or not value for value in values): + raise ValueError("generation_ids must contain non-empty strings.") + return tuple(dict.fromkeys(values)) + - return chromadb.PersistentClient(path=path) +class ChromaClientFactory: + def __init__(self, server_url: str | None = None) -> None: + self.server_url = server_url + + @property + def remote(self) -> bool: + return self.server_url is not None + + def create(self, path: Path): + import chromadb + + if self.server_url is None: + return chromadb.PersistentClient(path=str(path.resolve())) + parsed = urlsplit(self.server_url) + return chromadb.HttpClient( + host=parsed.hostname or "", + port=parsed.port or (443 if parsed.scheme == "https" else 80), + ssl=parsed.scheme == "https", + ) + + def heartbeat(self) -> int: + client = self.create(Path(".")) + try: + return int(client.heartbeat()) + finally: + close = getattr(client, "close", None) + if close is not None: + close() class IndexStorage: - def __init__(self, config: IndexConfig): + def __init__( + self, + config: IndexConfig, + *, + create: bool = True, + client_factory: ChromaClientFactory | None = None, + ): self.config = config self.path = config.index_directory - self.path.mkdir(parents=True, exist_ok=True) - self.client = _client_for_path(str(self.path.resolve())) + self._create = create + self._client_factory = client_factory or ChromaClientFactory() self._names = dict(config.collection_names) self._collections: dict[str, Any] = {} + if create and not self._client_factory.remote: + self.path.mkdir(parents=True, exist_ok=True) + elif not self._client_factory.remote and not self.path.is_dir(): + raise FileNotFoundError( + f"The committed Chroma store is missing at {self.path}." + ) + try: + self.client = self._client_factory.create(self.path) + except Exception as exc: + if ( + self._client_factory.remote + and _is_remote_unavailable(exc) + ): + raise IndexStorageUnavailableError( + "The remote Chroma service is unavailable." + ) from exc + raise + if not create: + try: + existing = self._call(self.client.list_collections) + except Exception: + self.close() + raise + if not existing: + self.close() + raise FileNotFoundError( + f"The committed Chroma store is missing at {self.path}." + ) def close(self) -> None: self._collections.clear() @@ -70,10 +175,24 @@ def collection(self, modality: str): name = self._names[modality] except KeyError as exc: raise ValueError(f"Unsupported collection modality: {modality}") from exc - collection = self.client.get_or_create_collection( - name=name, - metadata={"hnsw:space": self.config.vector_distance}, - ) + if self._create: + collection = self._call( + self.client.get_or_create_collection, + name=name, + metadata={"hnsw:space": self.config.vector_distance}, + ) + else: + from chromadb.errors import NotFoundError + + try: + collection = self._call( + self.client.get_collection, + name=name, + ) + except NotFoundError as exc: + raise FileNotFoundError( + f"Committed Chroma collection {name!r} is missing." + ) from exc configuration = getattr(collection, "configuration", {}) or {} actual_distance = (configuration.get("hnsw") or {}).get("space") if ( @@ -87,20 +206,34 @@ def collection(self, modality: str): self._collections[modality] = collection return collection + def _call(self, operation: Callable[..., _T], /, *args, **kwargs) -> _T: + try: + return operation(*args, **kwargs) + except Exception as exc: + if ( + self._client_factory.remote + and _is_remote_unavailable(exc) + ): + raise IndexStorageUnavailableError( + "The remote Chroma service is unavailable." + ) from exc + raise + def clear(self, modalities: Iterable[str] | None = None) -> None: selected = tuple(modalities or self._names) existing = { getattr(collection, "name", collection) - for collection in self.client.list_collections() + for collection in self._call(self.client.list_collections) } for modality in selected: name = self._names[modality] if name in existing: - self.client.delete_collection(name) + self._call(self.client.delete_collection, name) self._collections.pop(modality, None) def delete_video(self, modality: str, video_id: str) -> None: - self.collection(modality).delete( + self._call( + self.collection(modality).delete, where=metadata_filter(self.config, video_id=video_id), ) @@ -111,7 +244,8 @@ def delete_records( video_id: str, filters: Mapping[str, Any] | None = None, ) -> None: - self.collection(modality).delete( + self._call( + self.collection(modality).delete, where=metadata_filter( self.config, video_id=video_id, @@ -119,6 +253,23 @@ def delete_records( ), ) + def delete_generation( + self, + generation_id: str, + modalities: Iterable[str] | None = None, + ) -> None: + selected = ( + tuple(self._names) + if modalities is None + else tuple(modalities) + ) + where = metadata_filter( + self.config, + generation_ids=(generation_id,), + ) + for modality in selected: + self._call(self.collection(modality).delete, where=where) + def upsert( self, modality: str, @@ -142,7 +293,7 @@ def upsert( } if any(record.document is not None for record in group): options["documents"] = [record.document or "" for record in group] - collection.upsert(**options) + self._call(collection.upsert, **options) stored += len(group) return stored @@ -153,10 +304,14 @@ def query( *, top_k: int, video_id: str | None = None, + generation_ids: Iterable[str] | None = None, filters: Mapping[str, Any] | None = None, ) -> list[dict[str, Any]]: if top_k <= 0: raise ValueError("top_k must be greater than zero.") + selected_generations = _generation_ids(generation_ids) + if selected_generations == (): + return [] options: dict[str, Any] = { "query_embeddings": [embedding], @@ -165,10 +320,11 @@ def query( "where": metadata_filter( self.config, video_id=video_id, + generation_ids=selected_generations, extra=filters, ), } - result = self.collection(modality).query(**options) + result = self._call(self.collection(modality).query, **options) ids = (result.get("ids") or [[]])[0] metadatas = (result.get("metadatas") or [[]])[0] distances = (result.get("distances") or [[]])[0] @@ -190,15 +346,29 @@ def records( modality: str, *, video_id: str | None = None, + generation_ids: Iterable[str] | None = None, filters: Mapping[str, Any] | None = None, + limit: int | None = None, + offset: int = 0, ) -> list[dict[str, Any]]: - result = self.collection(modality).get( + selected_generations = _generation_ids(generation_ids) + if selected_generations == (): + return [] + if limit is not None and limit <= 0: + raise ValueError("limit must be positive") + if offset < 0: + raise ValueError("offset must be nonnegative") + result = self._call( + self.collection(modality).get, where=metadata_filter( self.config, video_id=video_id, + generation_ids=selected_generations, extra=filters, ), include=["metadatas"], + limit=limit, + offset=offset, ) return [ dict(metadata) @@ -206,10 +376,111 @@ def records( if metadata ] - def size_bytes(self) -> int: + def count_records( + self, + modality: str, + *, + video_id: str | None = None, + generation_ids: Iterable[str] | None = None, + filters: Mapping[str, Any] | None = None, + ) -> int: + selected_generations = _generation_ids(generation_ids) + if selected_generations == (): + return 0 + result = self._call( + self.collection(modality).get, + where=metadata_filter( + self.config, + video_id=video_id, + generation_ids=selected_generations, + extra=filters, + ), + include=[], + ) + return len(result.get("ids") or ()) + + def size_bytes(self) -> int | None: + if self._client_factory.remote: + return None return directory_size(self.path) +class SnapshotScopedIndexStore: + """Read-only view of records referenced by one immutable snapshot.""" + + def __init__( + self, + store: IndexStorage, + generation_ids: Iterable[str], + ) -> None: + self._store = store + self._generation_ids = _generation_ids(generation_ids) or () + + def close(self) -> None: + self._store.close() + + def __enter__(self) -> "SnapshotScopedIndexStore": + self._store.__enter__() + return self + + def __exit__(self, *args: Any) -> Any: + return self._store.__exit__(*args) + + def size_bytes(self) -> int | None: + return self._store.size_bytes() + + def query( + self, + modality: str, + embedding: list[float], + *, + top_k: int, + video_id: str | None = None, + filters: Mapping[str, Any] | None = None, + ) -> list[dict[str, Any]]: + return self._store.query( + modality, + embedding, + top_k=top_k, + video_id=video_id, + generation_ids=self._generation_ids, + filters=filters, + ) + + def records( + self, + modality: str, + *, + video_id: str | None = None, + filters: Mapping[str, Any] | None = None, + limit: int | None = None, + offset: int = 0, + ) -> list[dict[str, Any]]: + options: dict[str, Any] = { + "video_id": video_id, + "generation_ids": self._generation_ids, + "filters": filters, + } + if limit is not None: + options["limit"] = limit + if offset: + options["offset"] = offset + return self._store.records(modality, **options) + + def count_records( + self, + modality: str, + *, + video_id: str | None = None, + filters: Mapping[str, Any] | None = None, + ) -> int: + return self._store.count_records( + modality, + video_id=video_id, + generation_ids=self._generation_ids, + filters=filters, + ) + def directory_size(path: str | Path) -> int: root = Path(path) if not root.exists(): diff --git a/src/vidxp/core/storage_keys.py b/src/vidxp/core/storage_keys.py new file mode 100644 index 0000000..f1fc183 --- /dev/null +++ b/src/vidxp/core/storage_keys.py @@ -0,0 +1,13 @@ +from pathlib import PurePosixPath + + +def validate_storage_key(value: str) -> str: + key = PurePosixPath(value) + if ( + key.is_absolute() + or ".." in key.parts + or "\\" in value + or value != key.as_posix() + ): + raise ValueError("storage_key must be a normalized relative key") + return value diff --git a/src/vidxp/core/uploads.py b/src/vidxp/core/uploads.py new file mode 100644 index 0000000..d373a2b --- /dev/null +++ b/src/vidxp/core/uploads.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from enum import StrEnum + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator + +from vidxp.core.identifiers import ( + JobId, + MediaId, + MimeType, + Uuid4Hex, +) +from vidxp.core.media import validate_display_filename + + +class UploadState(StrEnum): + pending = "pending" + accepted = "accepted" + processing = "processing" + ready = "ready" + failed = "failed" + expired = "expired" + + +class UploadIntentRecord(BaseModel): + """Authoritative upload state; tus upload identity stays internal.""" + + model_config = ConfigDict( + extra="forbid", + frozen=True, + allow_inf_nan=False, + ) + + intent_id: Uuid4Hex + request_key: str = Field(pattern=r"^[0-9a-f]{64}$") + original_filename: str = Field(min_length=1, max_length=255) + byte_size: int = Field(gt=0) + declared_mime_type: MimeType | None = None + state: UploadState + created_at: AwareDatetime + expires_at: AwareDatetime + upload_id: str | None = Field( + default=None, + min_length=1, + max_length=255, + pattern=r"^[A-Za-z0-9][A-Za-z0-9._~-]*$", + ) + job_id: JobId | None = None + media_id: MediaId | None = None + + @model_validator(mode="after") + def _validate_state(self) -> "UploadIntentRecord": + validate_display_filename(self.original_filename) + if self.expires_at <= self.created_at: + raise ValueError("upload expiry must follow creation") + if self.state == UploadState.pending and self.upload_id is not None: + raise ValueError("pending uploads cannot have an upload identifier") + if self.state in { + UploadState.accepted, + UploadState.processing, + UploadState.failed, + } and self.upload_id is None: + raise ValueError(f"{self.state} uploads require an upload identifier") + if self.state in { + UploadState.processing, + UploadState.failed, + UploadState.ready, + } and self.job_id is None: + raise ValueError(f"{self.state} uploads require a job identifier") + if self.state in { + UploadState.pending, + UploadState.accepted, + } and self.job_id is not None: + raise ValueError(f"{self.state} uploads cannot have a job identifier") + if self.state == UploadState.ready and self.media_id is None: + raise ValueError("ready uploads require a media identifier") + if self.media_id is not None and self.state != UploadState.ready: + raise ValueError("only ready uploads may reference media") + return self diff --git a/src/vidxp/core/video.py b/src/vidxp/core/video.py index 0f6e3b7..538cba4 100644 --- a/src/vidxp/core/video.py +++ b/src/vidxp/core/video.py @@ -1,11 +1,12 @@ from __future__ import annotations from dataclasses import dataclass +from math import floor from pathlib import Path -from shutil import which -from typing import Iterator +from typing import Iterator, Sequence from vidxp.core.contracts import CancellationToken +from vidxp.core.indexing_common import ProgressCallback @dataclass(frozen=True) @@ -30,19 +31,57 @@ class FrameSample: frame: object -def ffmpeg_binary() -> str: - from moviepy.config import get_setting +@dataclass(frozen=True) +class FrameSampling: + frame_stride: int | None = None + source_fps: float | None = None + target_fps: float | None = None - configured = get_setting("FFMPEG_BINARY") - configured_path = Path(str(configured)) - resolved = ( - str(configured_path.resolve()) - if configured_path.is_file() - else which(str(configured)) - ) - if not resolved: - raise RuntimeError(f"FFmpeg executable was not found: {configured}") - return resolved + def __post_init__(self) -> None: + frame_based = self.frame_stride is not None + time_based = self.source_fps is not None or self.target_fps is not None + if frame_based == time_based: + raise ValueError( + "Frame sampling requires either a stride or a source/target FPS pair." + ) + if frame_based and self.frame_stride <= 0: + raise ValueError("frame_stride must be greater than zero.") + if time_based and ( + self.source_fps is None + or self.target_fps is None + or self.source_fps <= 0 + or self.target_fps <= 0 + ): + raise ValueError("source_fps and target_fps must be greater than zero.") + + def includes(self, frame_index: int, timestamp: float) -> bool: + if self.frame_stride is not None: + return frame_index % self.frame_stride == 0 + assert self.source_fps is not None + assert self.target_fps is not None + if self.source_fps <= self.target_fps or frame_index == 0: + return True + previous_timestamp = (frame_index - 1) / self.source_fps + return floor(timestamp * self.target_fps) > floor( + previous_timestamp * self.target_fps + ) + + def next_sample_timestamp( + self, + frame_index: int, + *, + frame_count: int, + duration: float, + ) -> float: + if self.source_fps is None: + raise ValueError( + "Next sample timestamps require time-based sampling." + ) + for next_index in range(frame_index + 1, max(0, frame_count)): + timestamp = next_index / self.source_fps + if self.includes(next_index, timestamp): + return min(duration, timestamp) + return duration def probe_video(path: str | Path) -> VideoInfo: @@ -68,15 +107,20 @@ def probe_video(path: str | Path) -> VideoInfo: def iter_frame_batches( path: str | Path, *, - frame_stride: int, + frame_stride: int | None = None, + frame_strides: Sequence[int] | None = None, + samplings: Sequence[FrameSampling] | None = None, batch_size: int, cancellation: CancellationToken, stats: FrameStreamStats | None = None, ) -> Iterator[list[FrameSample]]: import cv2 - if frame_stride <= 0: - raise ValueError("frame_stride must be greater than zero.") + strides = tuple(dict.fromkeys(frame_strides or ())) + if frame_stride is not None: + strides = tuple(dict.fromkeys((frame_stride, *strides))) + if any(stride <= 0 for stride in strides): + raise ValueError("frame strides must be greater than zero.") if batch_size <= 0: raise ValueError("batch_size must be greater than zero.") @@ -85,13 +129,23 @@ def iter_frame_batches( if fps <= 0: video.release() raise ValueError("The selected video has an invalid frame rate.") + active_samplings = tuple(samplings or ()) + tuple( + FrameSampling(frame_stride=stride) for stride in strides + ) + if not active_samplings: + video.release() + raise ValueError("At least one frame sampling schedule is required.") stream_stats = stats or FrameStreamStats() batch: list[FrameSample] = [] frame_index = 0 try: while True: - sampled = frame_index % frame_stride == 0 + timestamp = frame_index / fps + sampled = any( + sampling.includes(frame_index, timestamp) + for sampling in active_samplings + ) if sampled: retrieved, frame = video.read() else: @@ -105,7 +159,7 @@ def iter_frame_batches( batch.append( FrameSample( frame_index=frame_index, - timestamp=frame_index / fps, + timestamp=timestamp, frame=frame, ) ) @@ -121,26 +175,18 @@ def iter_frame_batches( video.release() -def extract_audio(input_path: str | Path, output_path: str | Path) -> Path: - from moviepy.editor import VideoFileClip - - destination = Path(output_path) - destination.parent.mkdir(parents=True, exist_ok=True) - with VideoFileClip(str(input_path)) as source_video: - if source_video.audio is None: - raise ValueError("The selected video does not contain an audio track.") - source_video.audio.write_audiofile(str(destination), logger=None) - return destination - - def render_actor_video( input_path: str | Path, output_path: str | Path, cluster_id: str, detections: list[dict], + *, + cancellation: CancellationToken | None = None, + progress: ProgressCallback | None = None, ) -> None: import cv2 + active_cancellation = cancellation or CancellationToken() source_path = Path(input_path) destination = Path(output_path) if not source_path.is_file(): @@ -157,6 +203,7 @@ def render_actor_video( fps = float(source.get(cv2.CAP_PROP_FPS)) width = int(source.get(cv2.CAP_PROP_FRAME_WIDTH)) height = int(source.get(cv2.CAP_PROP_FRAME_HEIGHT)) + frame_count = int(source.get(cv2.CAP_PROP_FRAME_COUNT)) if fps <= 0 or width <= 0 or height <= 0: source.release() raise RuntimeError( @@ -188,6 +235,7 @@ def render_actor_video( try: frame_index = 0 while True: + active_cancellation.raise_if_cancelled() retrieved, frame = source.read() if not retrieved: break @@ -215,10 +263,32 @@ def render_actor_video( writer.write(frame) frame_index += 1 frames_written += 1 + if progress is not None and frames_written % 30 == 0: + progress( + { + "state": "rendering", + "stage": "rendering", + "message": "Rendering the actor overlay.", + "current": frames_written, + "total": frame_count if frame_count > 0 else None, + } + ) finally: source.release() writer.release() + active_cancellation.raise_if_cancelled() + if progress is not None and frames_written: + progress( + { + "state": "rendering", + "stage": "rendering", + "message": "Rendered the actor overlay.", + "current": frames_written, + "total": frame_count if frame_count > 0 else frames_written, + } + ) + if ( frames_written == 0 or not destination.is_file() diff --git a/src/vidxp/database_cli.py b/src/vidxp/database_cli.py new file mode 100644 index 0000000..147c81f --- /dev/null +++ b/src/vidxp/database_cli.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import argparse +from importlib.resources import as_file, files +from typing import Sequence + +from alembic import command +from alembic.config import Config + +from vidxp.settings import ApplicationMode, VidXPSettings +from vidxp.workflow_runtime import workflow_database_url + + +def upgrade_database(database_url: str) -> None: + migration_root = files("vidxp.migrations") + with as_file(migration_root) as path: + config = Config() + config.set_main_option("script_location", str(path)) + config.set_main_option( + "sqlalchemy.url", + database_url.replace("%", "%%"), + ) + command.upgrade(config, "head") + + +def main(arguments: Sequence[str] | None = None) -> None: + parser = argparse.ArgumentParser( + description="Apply VidXP relational database migrations." + ) + parser.parse_args(arguments) + settings = VidXPSettings( + mode=ApplicationMode.server, + runtime_backend="cpu", + ) + upgrade_database(workflow_database_url(settings)) + + +if __name__ == "__main__": + main() diff --git a/src/vidxp/entrypoint.py b/src/vidxp/entrypoint.py new file mode 100644 index 0000000..8d8b3c1 --- /dev/null +++ b/src/vidxp/entrypoint.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import os +import sys +from datetime import datetime + + +def _wants_json(arguments: list[str]) -> bool: + if "--json" in arguments: + return True + for index, value in enumerate(arguments): + if value == "--format" and index + 1 < len(arguments): + return arguments[index + 1].lower() == "json" + if value.startswith("--format="): + return value.split("=", 1)[1].lower() == "json" + return os.environ.get("VIDXP_OUTPUT_FORMAT", "").lower() == "json" + + +def startup_command(arguments: list[str]) -> str | None: + if ( + _wants_json(arguments) + or any( + value in arguments + for value in ("--quiet", "-q", "--help", "--version", "-V") + ) + ): + return None + root_values = { + "--repository", + "-r", + "--config", + "--index-dir", + "--data-dir", + "--device", + "--format", + } + command = [] + skip_next = False + for value in arguments: + if skip_next: + skip_next = False + continue + if not command and value in root_values: + skip_next = True + continue + if not command and value.startswith("-"): + continue + command.append(value) + if len(command) == 2: + break + path = tuple(command) + if path and path[0] in { + "benchmark", + "doctor", + "init", + "prepare", + "query", + "search", + "ui", + }: + return path[0] + if path in { + ("actors", "render"), + ("artifacts", "snippet"), + ("index", "create"), + ("media", "import"), + }: + return " ".join(path) + return None + + +def main() -> None: + command = startup_command(sys.argv[1:]) + if command is not None: + timestamp = datetime.now().astimezone() + print( + f"[{timestamp:%H:%M:%S}] Starting VidXP {command}...", + file=sys.stderr, + flush=True, + ) + from vidxp.cli import main as cli_main + + cli_main() diff --git a/src/vidxp/execution.py b/src/vidxp/execution.py new file mode 100644 index 0000000..d1e94fc --- /dev/null +++ b/src/vidxp/execution.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import hashlib +from dataclasses import dataclass, field +from typing import Any +from uuid import UUID + +from vidxp.core.contracts import CancellationToken +from vidxp.core.indexing_common import ProgressCallback + + +@dataclass(frozen=True) +class ExecutionContext: + """Runtime-only progress and cancellation controls for worker operations.""" + + job_id: str | None = None + progress: ProgressCallback | None = None + cancellation: CancellationToken = field(default_factory=CancellationToken) + + def report(self, event: dict[str, Any]) -> None: + self.cancellation.raise_if_cancelled() + if self.progress is not None: + self.progress(event) + + def checkpoint(self) -> None: + self.cancellation.raise_if_cancelled() + + @property + def operation_id(self) -> str | None: + """Return a filesystem-safe UUID identity without changing the job ID.""" + + if self.job_id is None: + return None + identifier = UUID(self.job_id) + if identifier.version == 4: + return identifier.hex + digest = hashlib.sha256(self.job_id.encode()).digest()[:16] + return UUID(bytes=digest, version=4).hex + + +def execution_context(value: ExecutionContext | None) -> ExecutionContext: + return value if value is not None else ExecutionContext() diff --git a/src/vidxp/frontend.py b/src/vidxp/frontend.py index 84a26e2..ce15d84 100644 --- a/src/vidxp/frontend.py +++ b/src/vidxp/frontend.py @@ -1,50 +1,148 @@ -import hashlib +import argparse +import logging +import shutil import sys +import tempfile +from functools import lru_cache from pathlib import Path from typing import Sequence import streamlit as st -from vidxp.application import VidXPService -from vidxp.capabilities.registry import index_capability_names -from vidxp.index_state import IndexNotReadyError -from vidxp.index_worker import ( - cancel_indexing, - indexing_in_progress, - start_indexing, +from vidxp.app_paths import available_storage_bytes +from vidxp.application import VidXPApplication +from vidxp.application_models import ( + ApplicationError, + CreateActorOverlayCommand, + CreateIndexCommand, + DependencyCheckCommand, + ErrorCategory, + ImportMediaCommand, + JobState, + ListMediaCommand, + PrepareModelsCommand, + QueryVideoCommand, + SearchCommand, ) -from vidxp.repositories import resolve_repository +from vidxp.branding import PROJECT_URL, icon_path +from vidxp.composition import create_application, create_job_service +from vidxp.index_state import IndexNotReadyError +from vidxp.job_service import JobService +from vidxp.settings import LocalExecutionSettings, VidXPSettings + + +LOGGER = logging.getLogger(__name__) + +@lru_cache(maxsize=1) +def _configured_service( + settings: VidXPSettings | None = None, +) -> VidXPApplication: + return create_application(settings or _settings_from_arguments()) -def _configured_service() -> VidXPService: - _, repository = resolve_repository() - return VidXPService( - repository.index_directory, - device=repository.device, + +@lru_cache(maxsize=1) +def _configured_jobs(settings: VidXPSettings | None = None) -> JobService: + return create_job_service(settings or _settings_from_arguments()) + + +def _settings_from_arguments( + arguments: Sequence[str] | None = None, +) -> VidXPSettings: + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--vidxp-settings-json") + parsed, _ = parser.parse_known_args( + list(sys.argv[1:] if arguments is None else arguments) ) + if parsed.vidxp_settings_json is None: + return VidXPSettings() + return LocalExecutionSettings.model_validate_json( + parsed.vidxp_settings_json + ).application_settings() -SERVICE = _configured_service() -SAVED_VIDEO_PATH = SERVICE.index_directory / "source-video.mp4" -ACTOR_OUTPUT_PATH = SERVICE.index_directory / "actor-result.mp4" INDEX_REQUESTED_KEY = "_vidxp_index_requested" INDEX_ERROR_KEY = "_vidxp_index_error" +INDEX_JOB_ID_KEY = "_vidxp_index_job_id" +PREPARE_JOB_ID_KEY = "_vidxp_prepare_job_id" +PREPARE_CANCEL_REQUESTED_KEY = "_vidxp_prepare_cancel_requested" SEARCH_RESULT_KEY = "_vidxp_search_result" CANCEL_REQUESTED_KEY = "_vidxp_cancel_requested" +MEDIA_ID_KEY = "_vidxp_media_id" +UPLOAD_TOKEN_KEY = "_vidxp_upload_token" +MEDIA_NOTICE_KEY = "_vidxp_media_notice" +INDEX_JOB_QUERY_PARAM = "index_job" +PREPARE_JOB_QUERY_PARAM = "prepare_job" +SEARCH_JOB_QUERY_PARAM = "search_job" +SEARCH_TYPE_QUERY_PARAM = "search_type" +SCENE_SAMPLE_FPS_DEFAULT = 1.0 +SCENE_DETAIL_PRESETS = (0.5, SCENE_SAMPLE_FPS_DEFAULT, 2.0) +SCENE_DETAIL_LABELS = { + 0.5: "Faster — every 2 seconds", + 1.0: "Balanced — every second", + 2.0: "Detailed — twice per second", +} + + +def _format_bytes(size: int) -> str: + gib = 1024**3 + mib = 1024**2 + if size >= gib: + return f"{size / gib:.2f} GiB" + return f"{size / mib:.1f} MiB" -def _video_hash(uploaded_video) -> str | None: +def _remember_job( + *, + job_id: str, + query_param: str, + search_type: str | None = None, +) -> None: + st.query_params[query_param] = job_id + if search_type is not None: + st.query_params[SEARCH_TYPE_QUERY_PARAM] = search_type + + +def _forget_job(query_param: str) -> None: + st.query_params.pop(query_param, None) + if query_param == SEARCH_JOB_QUERY_PARAM: + st.query_params.pop(SEARCH_TYPE_QUERY_PARAM, None) + + +def _restore_durable_jobs() -> None: + if INDEX_JOB_ID_KEY not in st.session_state: + if job_id := st.query_params.get(INDEX_JOB_QUERY_PARAM): + st.session_state[INDEX_JOB_ID_KEY] = str(job_id) + if PREPARE_JOB_ID_KEY not in st.session_state: + if job_id := st.query_params.get(PREPARE_JOB_QUERY_PARAM): + st.session_state[PREPARE_JOB_ID_KEY] = str(job_id) + if SEARCH_RESULT_KEY not in st.session_state: + if job_id := st.query_params.get(SEARCH_JOB_QUERY_PARAM): + st.session_state[SEARCH_RESULT_KEY] = { + "type": str( + st.query_params.get(SEARCH_TYPE_QUERY_PARAM) or "search" + ), + "query": "", + "job_id": str(job_id), + } + + +def _upload_token(uploaded_video): if uploaded_video is None: return None - return hashlib.sha256(uploaded_video.getvalue()).hexdigest() + return ( + getattr(uploaded_video, "file_id", None), + uploaded_video.name, + getattr(uploaded_video, "size", None), + ) -def _is_search_ready(status, uploaded_video) -> bool: +def _is_search_ready(status, media_id: str | None) -> bool: if not status or status.get("state") != "ready": return False - if uploaded_video is None: - return SAVED_VIDEO_PATH.is_file() - return status.get("video", {}).get("sha256") == _video_hash(uploaded_video) + return media_id is not None and media_id in ( + (status.get("summary") or {}).get("media_ids") or () + ) def _render_summary(summary): @@ -53,10 +151,9 @@ def _render_summary(summary): st.caption( " · ".join( ( - f"Language: {summary.get('language', '—')}", - f"Dialogue phrases: {summary.get('dialogue_phrases', 0):,}", - f"Scene frames: {summary.get('scene_frames', 0):,}", - f"Actor clusters: {summary.get('actor_clusters', 0):,}", + f"Media: {summary.get('media_count', 0):,}", + "Capabilities: " + + ", ".join(summary.get("modalities", ())), ) ) ) @@ -66,13 +163,34 @@ def _render_progress(event): st.markdown(f"⏳ {event['message']}") current, total = event.get("current"), event.get("total") if current is not None and total: + if event.get("stage") == "downloading_model": + text = f"{_format_bytes(current)} of {_format_bytes(total)}" + else: + text = f"{current:,} of {total:,}" st.progress( min(current / total, 1.0), - text=f"{current:,} of {total:,}", + text=text, + ) + + +def _get_job(jobs: JobService, job_id: str | None): + if job_id is None: + return None + try: + return jobs.get(job_id) + except ApplicationError as exc: + if exc.category == ErrorCategory.not_found: + LOGGER.info("Background job %s no longer exists.", job_id) + return None + LOGGER.warning( + "Could not query background job %s: %s", + job_id, + exc.code, ) + raise -def _render_index_status(status, active, uploaded_video, request_error=None): +def _render_index_status(status, active, media_id, request_error=None): if request_error: st.error(request_error) return @@ -85,9 +203,9 @@ def _render_index_status(status, active, uploaded_video, request_error=None): } _render_progress(event) elif not status or status.get("state") == "missing": - st.caption("First indexing may download missing runtime model weights.") + st.caption("Prepare the selected models, then start indexing.") elif status["state"] == "ready": - if _is_search_ready(status, uploaded_video): + if _is_search_ready(status, media_id): st.success(status.get("message", "The video index is ready.")) _render_summary(status.get("summary")) else: @@ -114,7 +232,9 @@ def _request_indexing(): def _request_cancellation(): - if cancel_indexing(): + job_id = st.session_state.get(INDEX_JOB_ID_KEY) + if job_id is not None: + _configured_jobs().cancel(job_id) st.session_state[CANCEL_REQUESTED_KEY] = True st.session_state.pop(INDEX_ERROR_KEY, None) else: @@ -123,83 +243,209 @@ def _request_cancellation(): ) +def _request_prepare_cancellation(): + job_id = st.session_state.get(PREPARE_JOB_ID_KEY) + if job_id is not None: + _configured_jobs().cancel(job_id) + st.session_state[PREPARE_CANCEL_REQUESTED_KEY] = True + + def _available_index_modalities() -> tuple[str, ...]: + service = _configured_service() + return tuple( + capability.name + for capability in service.list_capabilities() + if capability.supports_indexing + if service.check_dependencies( + DependencyCheckCommand( + modalities=(capability.name,), + include_runtime_checks=False, + ) + ).ok + ) + + +def _available_query_modalities( + configured: tuple[str, ...], +) -> tuple[str, ...]: + service = _configured_service() + + def supports_query(capability_name: str) -> bool: + capability = service.get_capability(capability_name) + operations = { + operation.name for operation in capability.operations + } + return ( + "search" in operations + or {"clusters", "detections"}.issubset(operations) + ) + return tuple( - name - for name in index_capability_names() - if SERVICE.check_dependencies((name,))["ok"] + capability.name + for capability in service.list_capabilities() + if capability.name in configured + if supports_query(capability.name) ) -def _run_indexing(uploaded_video, status, modalities): +def _finish_index_job(job) -> None: + _forget_job(INDEX_JOB_QUERY_PARAM) + if job.state == JobState.succeeded: + st.session_state.pop(INDEX_ERROR_KEY, None) + elif job.error is not None: + st.session_state[INDEX_ERROR_KEY] = job.error.message + elif job.state == JobState.cancelled: + st.session_state[INDEX_ERROR_KEY] = "Indexing was cancelled." + else: + st.session_state[INDEX_ERROR_KEY] = ( + "Indexing did not complete successfully." + ) + st.session_state.pop(INDEX_JOB_ID_KEY, None) + + +def _run_indexing( + uploaded_video, + status, + modalities, + *, + scene_sample_fps: float | None = None, +): + service = _configured_service() + temporary_path = None try: if uploaded_video is not None: - SAVED_VIDEO_PATH.parent.mkdir(parents=True, exist_ok=True) - SAVED_VIDEO_PATH.write_bytes(uploaded_video.getvalue()) - source_name = uploaded_video.name + suffix = Path(uploaded_video.name).suffix + with tempfile.NamedTemporaryFile( + mode="w+b", + suffix=suffix, + delete=False, + ) as temporary: + temporary_path = Path(temporary.name) + uploaded_video.seek(0) + shutil.copyfileobj(uploaded_video, temporary) + asset = service.import_media( + ImportMediaCommand( + path=temporary_path, + original_filename=Path(uploaded_video.name).name, + declared_mime_type=getattr(uploaded_video, "type", None), + ) + ) + media_id = asset.media_id + st.session_state[MEDIA_ID_KEY] = media_id + st.session_state[UPLOAD_TOKEN_KEY] = _upload_token(uploaded_video) else: - source_name = ( - status.get("video", {}).get("source_name", SAVED_VIDEO_PATH.name) - if status - else SAVED_VIDEO_PATH.name + media_id = st.session_state.get(MEDIA_ID_KEY) + if media_id is None and status: + media_ids = (status.get("summary") or {}).get("media_ids") or () + media_id = media_ids[0] if len(media_ids) == 1 else None + if media_id is None: + raise ValueError("Select or import media before indexing.") + service.require_models(modalities) + job = _configured_jobs().submit_index( + CreateIndexCommand( + media_id=media_id, + modalities=modalities, + scene_sample_fps=scene_sample_fps, ) - start_indexing( - str(SAVED_VIDEO_PATH), - source_name, - SERVICE, - modalities=modalities, - ) - except Exception as exc: - st.session_state[INDEX_ERROR_KEY] = f"{type(exc).__name__}: {exc}" + ) + st.session_state[INDEX_JOB_ID_KEY] = job.job_id + _remember_job( + job_id=job.job_id, + query_param=INDEX_JOB_QUERY_PARAM, + ) + except ApplicationError as exc: + st.session_state[INDEX_ERROR_KEY] = str(exc) + except Exception: + LOGGER.exception("Unexpected indexing request failure") + st.session_state[INDEX_ERROR_KEY] = ( + "Indexing could not be started. Check the application logs." + ) else: st.session_state.pop(INDEX_ERROR_KEY, None) finally: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) st.session_state[INDEX_REQUESTED_KEY] = False st.rerun() -def _run_search(search_type, query): +def _scene_sample_fps_control( + modalities: tuple[str, ...], + *, + disabled: bool, +) -> float | None: + if "scene" not in modalities: + return None + return float( + st.selectbox( + "Scene detail", + SCENE_DETAIL_PRESETS, + index=1, + format_func=SCENE_DETAIL_LABELS.__getitem__, + disabled=disabled, + help=( + "More scene detail can improve coverage but takes longer " + "to index and uses more storage." + ), + ) + ) + + +def _run_search(search_type, query, media_id=None): + service = _configured_service() try: - status = SERVICE.index_status() + status = service.index_status().model_dump(mode="json") if status.get("state") != "ready": raise IndexNotReadyError("The video index is not ready.") if search_type == "actor": - ACTOR_OUTPUT_PATH.unlink(missing_ok=True) - SERVICE.render_actor( - query, - SAVED_VIDEO_PATH, - ACTOR_OUTPUT_PATH, + job = _configured_jobs().submit_actor_overlay( + CreateActorOverlayCommand(cluster_id=query) + ) + _remember_job( + job_id=job.job_id, + query_param=SEARCH_JOB_QUERY_PARAM, + search_type=search_type, ) - if ( - not ACTOR_OUTPUT_PATH.is_file() - or ACTOR_OUTPUT_PATH.stat().st_size == 0 - ): - return {"error": "Actor result video could not be generated."} return { "type": search_type, "query": query, - "video_path": str(ACTOR_OUTPUT_PATH), + "job_id": job.job_id, } - result = SERVICE.search( - search_type, - query, - top_k=1, + if search_type == "natural-language": + job = _configured_jobs().submit_query( + QueryVideoCommand( + question=query, + media_id=media_id, + top_k=10, + ) + ) + else: + job = _configured_jobs().submit_search( + SearchCommand( + modalities=(search_type,), + query=query, + media_id=media_id, + top_k=1, + ) + ) + _remember_job( + job_id=job.job_id, + query_param=SEARCH_JOB_QUERY_PARAM, + search_type=search_type, ) - if not result.hits: - return {"error": f"No {search_type} match was found."} - hit = result.hits[0] return { "type": search_type, "query": query, - "timestamp": hit.start, - "hit": hit.to_dict(), - "video_path": str(SAVED_VIDEO_PATH), + "job_id": job.job_id, } except IndexNotReadyError as exc: return {"error": str(exc)} - except Exception as exc: - return {"error": f"{type(exc).__name__}: {exc}"} + except ApplicationError as exc: + return {"error": str(exc)} + except Exception: + LOGGER.exception("Unexpected search failure") + return {"error": "Search failed. Check the application logs."} def _render_search_result(result): @@ -209,49 +455,284 @@ def _render_search_result(result): st.error(error) return - video_path = Path(result["video_path"]) - if not video_path.is_file(): + if "job_id" in result: + + @st.fragment(run_every="1s") + def poll_search_job(): + label = ( + "actor overlay" + if result["type"] == "actor" + else "natural-language query" + if result["type"] == "natural-language" + else f"{result['type']} search" + ) + try: + job = _get_job(_configured_jobs(), result["job_id"]) + except ApplicationError as exc: + message = ( + f"The {label} status is temporarily unavailable. Retrying." + if exc.retryable + else f"The {label} status could not be read: {exc}" + ) + st.warning(message) + return + if job is None: + _forget_job(SEARCH_JOB_QUERY_PARAM) + st.session_state[SEARCH_RESULT_KEY] = { + "type": result["type"], + "query": result["query"], + "error": f"The {label} job is unavailable.", + } + st.rerun() + return + if job.state in {JobState.queued, JobState.running}: + if job.progress is not None: + _render_progress(job.progress.model_dump(mode="json")) + else: + action = ( + "Starting" + if job.state == JobState.queued + else "Running" + ) + st.markdown(f"⏳ {action} {label}...") + return + if job.state != JobState.succeeded or job.result is None: + _forget_job(SEARCH_JOB_QUERY_PARAM) + message = { + JobState.cancelled: f"The {label} was cancelled.", + }.get( + job.state, + ( + job.error.message + if job.error is not None + else f"The {label} did not complete successfully." + ), + ) + st.session_state[SEARCH_RESULT_KEY] = { + "type": result["type"], + "query": result["query"], + "error": message, + } + st.rerun() + return + completed = job.result.result + _forget_job(SEARCH_JOB_QUERY_PARAM) + if result["type"] == "actor": + resolved = { + "type": "actor", + "query": result["query"], + "artifact_id": completed.artifact_id, + } + elif result["type"] == "natural-language": + first_moment = ( + completed.moments[0] if completed.moments else None + ) + first_evidence = ( + completed.evidence[0] if completed.evidence else None + ) + resolved = { + "type": result["type"], + "query": completed.question, + "answer": completed.model_dump(mode="json"), + "media_id": ( + first_moment.media_id + if first_moment is not None + else getattr(first_evidence, "media_id", None) + ), + "timestamp": ( + first_moment.start + if first_moment is not None + else getattr(first_evidence, "start", 0) + ), + } + elif not completed.moments: + resolved = { + "type": result["type"], + "query": completed.query, + "error": f"No {result['type']} match was found.", + } + else: + moment = completed.moments[0] + resolved = { + "type": result["type"], + "query": completed.query, + "timestamp": moment.start, + "moment": moment.model_dump(mode="json"), + "media_id": moment.media_id, + } + st.session_state[SEARCH_RESULT_KEY] = resolved + st.rerun() + + poll_search_job() + return + + if result["type"] == "natural-language": + answer = result["answer"] + if answer["claims"]: + for claim in answer["claims"]: + st.markdown(f"- {claim['text']}") + st.caption( + "Evidence: " + ", ".join(claim["evidence_ids"]) + ) + elif answer["evidence"]: + st.info( + "The query returned evidence without generating unsupported " + "claims." + ) + else: + st.info("No supporting evidence was found.") + if answer.get("fallback_reason"): + st.caption(f"Fallback: {answer['fallback_reason']}") + if result.get("media_id") is None: + return + + service = _configured_service() + try: + resource = ( + service.open_artifact_content(result["artifact_id"]) + if result["type"] == "actor" + else service.open_media_content(result["media_id"]) + ) + except ApplicationError: st.error("The search result video is no longer available.") return search_type = result["type"] if search_type == "actor": - st.success(f"Actor cluster {result['query']}") - st.video(str(video_path), format="video/mp4", width="stretch") + st.success( + ( + f"Actor cluster {result['query']}" + if result.get("query") + else "Actor overlay" + ) + ) + st.video( + str(resource.path), + format=resource.mime_type, + width="stretch", + ) return timestamp = result["timestamp"] - st.success(f"Best {search_type} match: {timestamp:.3f} seconds") + result_label = ( + "Closest sampled scene" + if search_type == "scene" + else "Closest supporting evidence" + if search_type == "natural-language" + else f"Closest {search_type} match" + ) + st.success(f"{result_label}: {timestamp:.1f} seconds") + if search_type == "scene": + st.caption( + "Scene search ranks sampled frames by visual similarity. " + "It does not identify the first occurrence and is not reliable " + "for counting people." + ) st.video( - str(video_path), + str(resource.path), start_time=timestamp, width="stretch", ) -def _select_video(busy): +def _default_media_id(media_id, assets): + available_ids = {asset.media_id for asset in assets} + if media_id in available_ids: + return media_id + if len(assets) == 1: + return assets[0].media_id + return None + + +def _import_local_video(service, raw_path): + return service.import_media( + ImportMediaCommand(path=Path(raw_path.strip()).expanduser()) + ) + + +def _select_video(busy, media_id, media_page): + service = _configured_service() st.subheader("Video") - upload_slot = st.empty() - has_session_upload = st.session_state.get("video_upload") is not None - if busy and not has_session_upload and SAVED_VIDEO_PATH.is_file(): - uploaded_video = None - st.caption("Indexing the saved video.") + assets = tuple(media_page.items) if media_page is not None else () + media_id = _default_media_id(media_id, assets) + if media_id is not None: + st.session_state[MEDIA_ID_KEY] = media_id else: - with upload_slot: - uploaded_video = st.file_uploader( - "Upload an MP4, MOV, or AVI video", - type=["mp4", "mov", "avi"], - disabled=busy, - key="video_upload", + st.session_state.pop(MEDIA_ID_KEY, None) + + if assets: + asset_by_id = {asset.media_id: asset for asset in assets} + selected_media_id = st.selectbox( + "Registered video", + tuple(asset_by_id), + index=( + tuple(asset_by_id).index(media_id) + if media_id is not None + else 0 + ), + format_func=lambda value: ( + f"{asset_by_id[value].original_filename} " + f"({asset_by_id[value].duration_seconds:.1f}s)" + ), + disabled=busy, + ) + if selected_media_id != media_id: + st.session_state.pop(SEARCH_RESULT_KEY, None) + media_id = selected_media_id + st.session_state[MEDIA_ID_KEY] = media_id + if media_page.next_cursor is not None: + st.caption( + "Showing the first 100 registered videos. " + "Use the CLI or API to inspect the full catalog." ) + with st.expander("Import a large local video"): + st.caption( + "Use a local path to avoid sending large files through the " + "browser uploader." + ) + local_path = st.text_input( + "Local video path", + disabled=busy, + placeholder="C:\\Videos\\example.mp4 or /Users/me/Videos/example.mp4", + ) + if st.button( + "Register local video", + disabled=busy or not local_path.strip(), + ): + try: + imported = _import_local_video(service, local_path) + except ApplicationError as exc: + st.error(str(exc)) + else: + st.session_state[MEDIA_ID_KEY] = imported.media_id + st.session_state.pop(SEARCH_RESULT_KEY, None) + st.session_state[MEDIA_NOTICE_KEY] = ( + f"Registered {imported.original_filename}." + ) + st.rerun() + + uploaded_video = st.file_uploader( + "Upload an MP4, MOV, or AVI video", + type=["mp4", "mov", "avi"], + disabled=busy, + key="video_upload", + ) + if busy and uploaded_video is None and media_id is not None: + st.caption("Indexing the registered video.") + if uploaded_video is not None: st.video(uploaded_video, width=560) - elif SAVED_VIDEO_PATH.is_file(): - if not busy: - st.caption("Using the saved video.") - st.video(str(SAVED_VIDEO_PATH), width=560) - return uploaded_video + elif media_id is not None: + try: + resource = service.open_media_content(media_id) + except ApplicationError: + st.warning("The registered video is no longer available.") + else: + if not busy: + st.caption("Using the registered video.") + st.video(str(resource.path), width=560) + return uploaded_video, media_id def _search_controls(ready, uploaded_video, available_modalities): @@ -264,43 +745,159 @@ def _search_controls(ready, uploaded_video, available_modalities): ) st.caption(message) - type_column, query_column = st.columns( - [0.35, 0.65], - gap="small", - vertical_alignment="bottom", - ) - with type_column: - search_type = st.selectbox( - "Search type", - list(available_modalities), - disabled=not ready, + with st.form( + "video_search", + clear_on_submit=False, + enter_to_submit=True, + border=False, + ): + type_column, query_column = st.columns( + [0.35, 0.65], + gap="small", + vertical_alignment="bottom", ) - with query_column: - query = st.text_input( - "Actor cluster ID" if search_type == "actor" else "Search query", - placeholder=( - "For example: 1" - if search_type == "actor" - else "For example: Chef makes pizza and cuts it up." - ), + with type_column: + search_type = st.selectbox( + "Search type", + ["natural-language", *available_modalities], + disabled=not ready, + ) + with query_column: + query = st.text_input( + ( + "Actor cluster ID" + if search_type == "actor" + else "Question" + if search_type == "natural-language" + else "Search query" + ), + placeholder=( + "For example: 1" + if search_type == "actor" + else "For example: What happens after the taxi arrives?" + if search_type == "natural-language" + else "For example: Chef makes pizza and cuts it up." + ), + disabled=not ready, + key="video_search_query", + ) + clicked = st.form_submit_button( + "Search", disabled=not ready, ) - clicked = st.button("Search", disabled=not ready or not query.strip()) + if clicked and not query.strip(): + st.warning("Enter a search query.") + clicked = False return clicked, search_type, query def run(): - st.set_page_config(page_title="VidXP", page_icon="🎬", layout="wide") + application_icon = icon_path() + st.set_page_config( + page_title="VidXP", + page_icon=application_icon, + layout="wide", + ) + st.logo(application_icon, size="large", link=PROJECT_URL) + service = _configured_service() st.title("VidXP") st.caption("Index and search video by dialogue, scene, and actor.") - st.caption(f"Index repository: {SERVICE.index_directory}") + st.caption(f"Index repository: {service.layout.root}") + if notice := st.session_state.pop(MEDIA_NOTICE_KEY, None): + st.success(notice) - active = indexing_in_progress(SERVICE) + jobs = _configured_jobs() + _restore_durable_jobs() + job_id = st.session_state.get(INDEX_JOB_ID_KEY) + job_lookup_error = None + try: + current_job = _get_job(jobs, job_id) + except ApplicationError as exc: + current_job = None + job_lookup_error = exc + if job_id is not None and current_job is None: + if job_lookup_error is None: + st.session_state.pop(INDEX_JOB_ID_KEY, None) + _forget_job(INDEX_JOB_QUERY_PARAM) + job_id = None + elif ( + current_job is not None + and current_job.state not in {JobState.queued, JobState.running} + ): + _finish_index_job(current_job) + job_id = None + active = ( + job_id is not None + and ( + job_lookup_error is not None + or ( + current_job is not None + and current_job.state in {JobState.queued, JobState.running} + ) + ) + ) + prepare_job_id = st.session_state.get(PREPARE_JOB_ID_KEY) + prepare_error = None + try: + prepare_job = _get_job(jobs, prepare_job_id) + except ApplicationError as exc: + prepare_job = None + prepare_error = exc + if prepare_job_id is not None and prepare_job is None: + if prepare_error is None: + st.session_state.pop(PREPARE_JOB_ID_KEY, None) + _forget_job(PREPARE_JOB_QUERY_PARAM) + prepare_job_id = None + elif ( + prepare_job is not None + and prepare_job.state not in {JobState.queued, JobState.running} + ): + st.session_state.pop(PREPARE_JOB_ID_KEY, None) + _forget_job(PREPARE_JOB_QUERY_PARAM) + prepare_job_id = None + if prepare_job.state == JobState.succeeded: + st.session_state[MEDIA_NOTICE_KEY] = ( + "Selected model artifacts are prepared." + ) + elif prepare_job.error is not None: + prepare_error = ApplicationError( + prepare_job.error.code, + prepare_job.error.category, + prepare_job.error.message, + details=prepare_job.error.details, + retryable=prepare_job.error.retryable, + ) + preparing = ( + prepare_job_id is not None + and ( + prepare_error is not None + or ( + prepare_job is not None + and prepare_job.state in {JobState.queued, JobState.running} + ) + ) + ) + if not preparing: + st.session_state.pop(PREPARE_CANCEL_REQUESTED_KEY, None) if not active: st.session_state.pop(CANCEL_REQUESTED_KEY, None) requested = st.session_state.get(INDEX_REQUESTED_KEY, False) - busy = active or requested - status = SERVICE.index_status() + busy = active or requested or preparing + status = service.index_status().model_dump(mode="json") + media_id = st.session_state.get(MEDIA_ID_KEY) + if media_id is None: + indexed_media = (status.get("summary") or {}).get("media_ids") or () + if len(indexed_media) == 1: + media_id = indexed_media[0] + st.session_state[MEDIA_ID_KEY] = media_id + try: + media_page = service.list_media(ListMediaCommand(page_size=100)) + except ApplicationError: + media_page = None + st.warning( + "Registered videos are temporarily unavailable. " + "The current index remains usable." + ) installed_modalities = _available_index_modalities() video_column, workflow_column = st.columns( [0.95, 1.05], @@ -309,7 +906,18 @@ def run(): ) with video_column: - uploaded_video = _select_video(busy) + uploaded_video, media_id = _select_video( + busy, + media_id, + media_page, + ) + selected_media_id = ( + media_id + if uploaded_video is None + or st.session_state.get(UPLOAD_TOKEN_KEY) + == _upload_token(uploaded_video) + else None + ) with workflow_column: st.subheader("Build index") @@ -322,21 +930,146 @@ def run(): help="Install another capability extra to make it available here.", ) ) + scene_sample_fps = _scene_sample_fps_control( + selected_modalities, + disabled=busy, + ) + model_readiness = ( + service.model_readiness(selected_modalities) + if selected_modalities + else None + ) + missing_checks = ( + tuple( + check + for check in model_readiness.checks + if not check.ok + ) + if model_readiness is not None + else () + ) + missing_models = tuple(check.name for check in missing_checks) + required_download_bytes = sum( + check.download_size_bytes or 0 + for check in missing_checks + ) + model_cache_free_bytes = ( + available_storage_bytes(service.model_cache) + if missing_checks + else None + ) + insufficient_model_space = ( + model_cache_free_bytes is not None + and model_cache_free_bytes < required_download_bytes + ) if not installed_modalities: st.warning( "No indexing capabilities are installed. " 'Install one, for example: pip install "vidxp[scene]"' ) + if missing_models and not preparing: + st.warning( + "The following model artifacts must be downloaded:" + ) + for check in missing_checks: + st.caption( + f"• {check.name} — " + f"{_format_bytes(check.download_size_bytes or 0)}" + ) + st.caption( + "Maximum additional download and cache space: " + f"{_format_bytes(required_download_bytes)}" + ) + st.caption(f"Model cache: {service.model_cache}") + if model_cache_free_bytes is not None: + st.caption( + "Free space at model cache: " + f"{_format_bytes(model_cache_free_bytes)}" + ) + if insufficient_model_space: + st.error( + "There is not enough free space for these model " + "downloads. Free space at the displayed location or " + "restart VidXP with a different global --data-dir." + ) + confirmed = st.checkbox( + "I want to download these models and use this cache space.", + key=( + "_vidxp_confirm_models_" + + "_".join(selected_modalities) + ), + disabled=insufficient_model_space, + ) + if st.button( + "Download models", + type="primary", + disabled=busy + or insufficient_model_space + or not confirmed, + ): + preparation = jobs.submit_prepare_models( + PrepareModelsCommand(modalities=selected_modalities) + ) + st.session_state[PREPARE_JOB_ID_KEY] = preparation.job_id + _remember_job( + job_id=preparation.job_id, + query_param=PREPARE_JOB_QUERY_PARAM, + ) + st.rerun() + if preparing: + st.button( + "Cancel model preparation", + on_click=_request_prepare_cancellation, + disabled=st.session_state.get( + PREPARE_CANCEL_REQUESTED_KEY, + False, + ), + ) + if st.session_state.get(PREPARE_CANCEL_REQUESTED_KEY, False): + st.caption("Cancellation requested.") + if preparing: + + @st.fragment(run_every="1s") + def poll_model_preparation(): + try: + latest = _get_job(jobs, prepare_job_id) + except ApplicationError as exc: + st.warning( + "Model preparation status is temporarily unavailable. " + f"Retrying: {exc}" + ) + return + if latest is None: + st.error("The model preparation job is unavailable.") + return + if latest.state not in {JobState.queued, JobState.running}: + st.rerun() + return + if latest.progress is not None: + _render_progress( + latest.progress.model_dump(mode="json") + ) + else: + st.markdown("⏳ Model preparation is queued.") + + poll_model_preparation() + elif prepare_error is not None: + st.error(f"Model preparation failed: {prepare_error}") st.button( "Index video", type="primary", disabled=busy or not selected_modalities - or (uploaded_video is None and not SAVED_VIDEO_PATH.is_file()), + or bool(missing_models) + or (uploaded_video is None and media_id is None), help=( "Indexing is already running." - if busy - else "Build or replace the index. First use may download model weights." + if active or requested + else "Model preparation is running." + if preparing + else "Prepare the selected models before indexing." + if missing_models + else "Build or replace the index." ), on_click=_request_indexing, ) @@ -358,38 +1091,67 @@ def run(): @st.fragment(run_every="1s") def poll_index_status(): - latest_active = indexing_in_progress(SERVICE) + try: + latest_job = _get_job(jobs, job_id) + except ApplicationError as exc: + message = ( + "The indexing job status is temporarily unavailable. " + "Retrying." + if exc.retryable + else f"The indexing job status could not be read: {exc}" + ) + st.warning(message) + return + if latest_job is None: + st.session_state.pop(INDEX_JOB_ID_KEY, None) + _forget_job(INDEX_JOB_QUERY_PARAM) + st.error("The background indexing job is unavailable.") + st.rerun() + return + latest_active = latest_job.state in { + JobState.queued, + JobState.running, + } + if not latest_active: + _finish_index_job(latest_job) + st.rerun() + return + latest_status = ( + latest_job.progress.model_dump(mode="json") + if latest_job.progress is not None + else { + "state": "indexing", + "stage": "queued", + "message": "Indexing is queued.", + } + ) _render_index_status( - SERVICE.index_status(), - latest_active, - uploaded_video, + latest_status, + True, + selected_media_id, st.session_state.get(INDEX_ERROR_KEY), ) - if not latest_active: - st.rerun() poll_index_status() else: _render_index_status( status, False, - uploaded_video, + selected_media_id, st.session_state.get(INDEX_ERROR_KEY), ) - ready = not busy and _is_search_ready(status, uploaded_video) + ready = not busy and _is_search_ready(status, selected_media_id) configured_modalities = ( - (status.get("summary", {}).get("configuration") or {}).get( - "enabled_modalities", - ("scene", "dialogue", "actor"), + (status.get("summary") or {}).get( + "modalities", + (), ) if ready - else ("scene", "dialogue", "actor") + else () ) - available_modalities = tuple( - modality - for modality in ("scene", "dialogue", "actor") - if modality in configured_modalities + available_modalities = _available_query_modalities( + tuple(configured_modalities) ) search_clicked, search_type, query = _search_controls( ready, @@ -397,21 +1159,41 @@ def poll_index_status(): available_modalities, ) if search_clicked: - st.session_state[SEARCH_RESULT_KEY] = _run_search(search_type, query) + st.session_state[SEARCH_RESULT_KEY] = _run_search( + search_type, + query, + selected_media_id, + ) _render_search_result(st.session_state.get(SEARCH_RESULT_KEY)) if requested: - _run_indexing(uploaded_video, status, selected_modalities) + _run_indexing( + uploaded_video, + status, + selected_modalities, + scene_sample_fps=scene_sample_fps, + ) -def main(arguments: Sequence[str] = ()): +def main( + arguments: Sequence[str] = (), + settings: VidXPSettings | None = None, +): from streamlit.web import cli as streamlit_cli + application_arguments = [] + if settings is not None: + application_arguments = [ + "--", + "--vidxp-settings-json", + LocalExecutionSettings.from_settings(settings).model_dump_json(), + ] sys.argv = [ "streamlit", "run", str(Path(__file__).resolve()), *arguments, + *application_arguments, ] raise SystemExit(streamlit_cli.main()) diff --git a/src/vidxp/health_cli.py b/src/vidxp/health_cli.py new file mode 100644 index 0000000..da6b269 --- /dev/null +++ b/src/vidxp/health_cli.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import argparse +from collections.abc import Sequence +from time import monotonic, sleep +from urllib.request import urlopen + +from sqlalchemy import create_engine, select + +from vidxp.core.storage import ( + BUNDLED_CHROMA_SERVER_URL, + ChromaClientFactory, +) +from vidxp.settings import VidXPSettings +from vidxp.workflow_runtime import workflow_database_url + + +def main(arguments: Sequence[str] | None = None) -> None: + parser = argparse.ArgumentParser(description="Check VidXP dependencies.") + parser.add_argument("role", choices=("chroma", "database", "worker")) + options = parser.parse_args(arguments) + settings = VidXPSettings() + if options.role == "chroma": + deadline = monotonic() + 120 + endpoint = BUNDLED_CHROMA_SERVER_URL + "/api/v2/heartbeat" + while True: + try: + with urlopen(endpoint, timeout=3) as response: + if response.status == 200: + return + except OSError: + if monotonic() >= deadline: + raise + sleep(1) + engine = create_engine( + workflow_database_url(settings), + pool_pre_ping=True, + ) + try: + with engine.connect() as connection: + connection.execute(select(1)).scalar_one() + finally: + engine.dispose() + if options.role == "worker": + ChromaClientFactory(BUNDLED_CHROMA_SERVER_URL).heartbeat() + + +if __name__ == "__main__": + main() diff --git a/src/vidxp/hook_app.py b/src/vidxp/hook_app.py new file mode 100644 index 0000000..fbb6b5a --- /dev/null +++ b/src/vidxp/hook_app.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import asyncio +import logging +from contextlib import asynccontextmanager, suppress +from typing import AsyncIterator + +from fastapi import FastAPI +from fastapi import HTTPException + +from vidxp.composition import UploadHookContext, create_upload_hook_context +from vidxp.infrastructure.tusd_contracts import ( + TusdHookRequest, + TusdHookResponse, +) +from vidxp.settings import VidXPSettings +from vidxp.tusd_hooks import TusdHookService + +LOGGER = logging.getLogger(__name__) + + +def create_hook_app( + settings: VidXPSettings | None = None, + *, + context: UploadHookContext | None = None, +) -> FastAPI: + active_context = context or create_upload_hook_context(settings) + owns_context = context is None + hooks = TusdHookService( + uploads=active_context.uploads, + authenticator=active_context.authenticator, + authorization=active_context.authorization, + ) + recovery_task: asyncio.Task[None] | None = None + + async def recover() -> None: + interval = active_context.settings.upload_recovery_interval_seconds + while True: + try: + await asyncio.to_thread(active_context.uploads.reconcile) + except Exception: + LOGGER.exception("The resumable-upload recovery sweep failed.") + await asyncio.sleep(interval) + + @asynccontextmanager + async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + nonlocal recovery_task + recovery_task = asyncio.create_task(recover()) + try: + yield + finally: + recovery_task.cancel() + with suppress(asyncio.CancelledError): + await recovery_task + recovery_task = None + if owns_context: + active_context.close() + + app = FastAPI( + title="VidXP tusd hooks", + docs_url=None, + redoc_url=None, + openapi_url=None, + lifespan=lifespan, + ) + + @app.get("/health") + def health() -> dict[str, str]: + try: + if recovery_task is None or recovery_task.done(): + raise RuntimeError("The upload recovery task is not running.") + active_context.catalog.health() + except Exception as exc: + raise HTTPException( + status_code=503, + detail="The upload catalog is unavailable.", + ) from exc + return {"status": "ok"} + + @app.post("/hooks") + def handle(hook: TusdHookRequest) -> dict: + response: TusdHookResponse = hooks.handle(hook) + return response.wire() + + return app diff --git a/src/vidxp/hook_cli.py b/src/vidxp/hook_cli.py new file mode 100644 index 0000000..0041da2 --- /dev/null +++ b/src/vidxp/hook_cli.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import uvicorn + +from vidxp.hook_app import create_hook_app +from vidxp.settings import VidXPSettings + + +def main() -> None: + settings = VidXPSettings() + uvicorn.run( + create_hook_app(settings), + host=settings.http_bind_host, + port=settings.http_port, + access_log=False, + ) + + +if __name__ == "__main__": + main() diff --git a/src/vidxp/idempotency.py b/src/vidxp/idempotency.py new file mode 100644 index 0000000..a54c1ae --- /dev/null +++ b/src/vidxp/idempotency.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from hashlib import sha256 +from typing import Annotated, Literal, TypeAlias +from uuid import UUID + +from pydantic import StringConstraints + +from vidxp.application_models import Principal + + +IdempotencyKey: TypeAlias = Annotated[ + str, + StringConstraints( + min_length=8, + max_length=200, + pattern=r"^[\x21-\x7e]+$", + ), +] +RequestTransport: TypeAlias = Literal["http", "mcp"] +_SINGLE_REPOSITORY_SCOPE = "default" + + +def scoped_request_key( + *, + principal: Principal, + transport: RequestTransport, + operation: str, + idempotency_key: str, +) -> str: + """Derive a non-reversible request identity within one adapter namespace.""" + + material = "\0".join( + ( + f"vidxp-{transport}-request-v1", + _SINGLE_REPOSITORY_SCOPE, + principal.subject, + operation, + idempotency_key, + ) + ).encode() + return sha256(material).hexdigest() + + +def scoped_job_id( + *, + principal: Principal, + transport: RequestTransport, + operation: str, + idempotency_key: str, +) -> str: + """Project a scoped request identity into a valid deterministic job UUID.""" + + digest = scoped_request_key( + principal=principal, + transport=transport, + operation=operation, + idempotency_key=idempotency_key, + ) + value = bytearray(bytes.fromhex(digest)[:16]) + value[6] = (value[6] & 0x0F) | 0x40 + value[8] = (value[8] & 0x3F) | 0x80 + return UUID(bytes=bytes(value)).hex diff --git a/src/vidxp/index_state.py b/src/vidxp/index_state.py index c3e4b74..3fcd530 100644 --- a/src/vidxp/index_state.py +++ b/src/vidxp/index_state.py @@ -1,107 +1,51 @@ -import hashlib -import json -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - - -INDEX_DIRECTORY = Path("chroma_data") -INDEX_STATUS_FILE = "index_status.json" -INDEX_STATUS_SCHEMA = 1 - - -class IndexNotReadyError(RuntimeError): - """Raised when a search is attempted without a completed index.""" +from __future__ import annotations +from typing import Any -class IndexingInProgressError(RuntimeError): - """Raised when another indexing run is already active.""" +from vidxp.core.contracts import INDEX_SCHEMA_VERSION +from vidxp.core.snapshots import IndexSnapshot -def fingerprint_file(path: str | Path) -> dict[str, Any]: - file_path = Path(path) - digest = hashlib.sha256() +INDEX_STATUS_SCHEMA = 1 +INDEX_STATUS_MEDIA_ID_LIMIT = 100 - with file_path.open("rb") as source: - for chunk in iter(lambda: source.read(1024 * 1024), b""): - digest.update(chunk) - stat = file_path.stat() +def bounded_media_ids(media_ids: list[str]) -> dict[str, object]: + selected = media_ids[:INDEX_STATUS_MEDIA_ID_LIMIT] return { - "path": str(file_path.resolve()), - "size": stat.st_size, - "sha256": digest.hexdigest(), + "media_ids": selected, + "media_ids_truncated": len(media_ids) > len(selected), } -def read_index_status(index_directory: str | Path = INDEX_DIRECTORY) -> dict[str, Any] | None: - status_path = Path(index_directory) / INDEX_STATUS_FILE - if not status_path.is_file(): - return None - - try: - return json.loads(status_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return { - "schema_version": INDEX_STATUS_SCHEMA, - "state": "failed", - "stage": "status", - "message": "The saved index status is unreadable. Re-index the video.", - } - - -def write_index_status( - *, - state: str, - stage: str, - message: str, - video: dict[str, Any] | None = None, - current: int | None = None, - total: int | None = None, - summary: dict[str, Any] | None = None, - error: str | None = None, - index_directory: str | Path = INDEX_DIRECTORY, -) -> dict[str, Any]: - index_path = Path(index_directory) - index_path.mkdir(parents=True, exist_ok=True) - status_path = index_path / INDEX_STATUS_FILE - temporary_path = status_path.with_suffix(".tmp") - - payload: dict[str, Any] = { +def snapshot_status(snapshot: IndexSnapshot) -> dict[str, Any]: + media_ids = sorted(snapshot.generations) + ready = bool(media_ids) + return { "schema_version": INDEX_STATUS_SCHEMA, - "state": state, - "stage": stage, - "message": message, - "updated_at": datetime.now(timezone.utc).isoformat(), + "state": "ready" if ready else "empty", + "stage": "status", + "message": ( + f"The active snapshot contains {len(media_ids)} media item(s)." + if ready + else "The active index snapshot is empty." + ), + "updated_at": snapshot.created_at.isoformat(), + "summary": { + "index_schema_version": INDEX_SCHEMA_VERSION, + "snapshot_id": snapshot.snapshot_id, + "media_count": len(media_ids), + **bounded_media_ids(media_ids), + "modalities": tuple( + snapshot.configuration.get("enabled_modalities", ()) + ), + }, } - if video is not None: - payload["video"] = video - if current is not None: - payload["current"] = current - if total is not None: - payload["total"] = total - if summary is not None: - payload["summary"] = summary - if error is not None: - payload["error"] = error - temporary_path.write_text( - json.dumps(payload, indent=2, sort_keys=True), - encoding="utf-8", - ) - temporary_path.replace(status_path) - return payload + +class IndexNotReadyError(RuntimeError): + """Raised when a search is attempted without a completed index.""" -def require_ready_index( - index_directory: str | Path = INDEX_DIRECTORY, -) -> dict[str, Any]: - status = read_index_status(index_directory) - if status is None: - raise IndexNotReadyError( - "No completed video index was found. Index a video before searching." - ) - if status.get("state") != "ready": - message = status.get("message", "The video index is not ready.") - raise IndexNotReadyError(message) - return status +class IndexingInProgressError(RuntimeError): + """Raised when another indexing run is already active.""" diff --git a/src/vidxp/index_worker.py b/src/vidxp/index_worker.py deleted file mode 100644 index d96a9f4..0000000 --- a/src/vidxp/index_worker.py +++ /dev/null @@ -1,82 +0,0 @@ -from multiprocessing import get_context -from multiprocessing.process import BaseProcess -from threading import Lock - -from vidxp.application import VidXPService -from vidxp.core.contracts import CancellationToken -from vidxp.index_state import IndexingInProgressError -from vidxp.repositories import resolve_repository - -_process: BaseProcess | None = None -_cancel_event = None -_start_lock = Lock() - - -def _configured_service() -> VidXPService: - _, repository = resolve_repository() - return VidXPService( - repository.index_directory, - device=repository.device, - ) - - -def _run_indexing( - path: str, - source_name: str, - cancel_event, - index_directory: str, - device: str | None, - modalities: tuple[str, ...], -) -> None: - VidXPService(index_directory, device=device).create_index( - path, - modalities=modalities, - source_name=source_name, - cancellation=CancellationToken(cancel_event), - ) - - -def indexing_in_progress(service: VidXPService | None = None) -> bool: - active_service = service or _configured_service() - return ( - _process is not None and _process.is_alive() - ) or active_service.indexing_in_progress() - - -def start_indexing( - path: str, - source_name: str, - service: VidXPService | None = None, - *, - modalities: tuple[str, ...], -) -> None: - global _cancel_event, _process - - active_service = service or _configured_service() - with _start_lock: - if indexing_in_progress(active_service): - raise IndexingInProgressError("Another video is already being indexed.") - - context = get_context("spawn") - _cancel_event = context.Event() - _process = context.Process( - target=_run_indexing, - args=( - path, - source_name, - _cancel_event, - str(active_service.index_directory), - active_service.device, - modalities, - ), - name="vidxp-indexer", - daemon=True, - ) - _process.start() - - -def cancel_indexing() -> bool: - if _process is None or not _process.is_alive() or _cancel_event is None: - return False - _cancel_event.set() - return True diff --git a/src/vidxp/infrastructure/__init__.py b/src/vidxp/infrastructure/__init__.py new file mode 100644 index 0000000..80f6700 --- /dev/null +++ b/src/vidxp/infrastructure/__init__.py @@ -0,0 +1 @@ +"""Concrete infrastructure adapters selected by the composition root.""" diff --git a/src/vidxp/infrastructure/dbos_jobs.py b/src/vidxp/infrastructure/dbos_jobs.py new file mode 100644 index 0000000..75559ab --- /dev/null +++ b/src/vidxp/infrastructure/dbos_jobs.py @@ -0,0 +1,455 @@ +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime, timezone +from time import time_ns +from typing import Any +from uuid import uuid4 + +from dbos import DBOSClient, EnqueueOptions +from pydantic import TypeAdapter, ValidationError + +from vidxp.application_models import ( + Artifact, + ArtifactJobResult, + ErrorDetail, + ErrorCategory, + IndexJobResult, + IndexResult, + Job, + JobKind, + JobPage, + JobProgress, + JobQueue, + JobRequest, + JobState, + ListJobsCommand, + MediaAsset, + MediaImportJobResult, + PrepareModelsJobResult, + PrepareModelsResult, + QueryAnswer, + QueryJobResult, + SearchJobResult, + FusedSearchResult, +) +from vidxp.capabilities.schemas import SearchResult +from vidxp.core.identifiers import JobId +from vidxp.core.cursors import ( + MAX_CURSOR_OFFSET, + CursorError, + decode_cursor, + decode_offset_cursor, + encode_cursor, +) +from vidxp.ports import ( + InvalidJobBackendRequestError, + JobIdempotencyConflictError, +) +from vidxp.search_fusion import fuse_search_results +from vidxp.workflow_contracts import ( + ERROR_EVENT, + PROGRESS_EVENT, + QUEUE_KINDS, + QUEUE_NAMES, + WORKFLOW_CLASS_NAME, + WORKFLOW_INSTANCE_NAME, + WORKFLOW_KINDS, + WORKFLOW_NAMES, + decode_workflow_request, +) + + +_JOB_ID_ADAPTER = TypeAdapter(JobId) +_LIST_SCOPE = "vidxp:jobs" +_WINDOW_END_FIELD = "window_end_ms" +_STATUS_STATES = { + "ENQUEUED": JobState.queued, + "DELAYED": JobState.queued, + "PENDING": JobState.running, + "SUCCESS": JobState.succeeded, + "ERROR": JobState.failed, + "CANCELLED": JobState.cancelled, + "MAX_RECOVERY_ATTEMPTS_EXCEEDED": JobState.recovery_exhausted, +} + + +def _job_id(value: str) -> str: + try: + return _JOB_ID_ADAPTER.validate_python(value) + except ValidationError as exc: + raise InvalidJobBackendRequestError( + "The job identifier is invalid." + ) from exc + + +def _timestamp(value: int | None) -> datetime | None: + if value is None: + return None + return datetime.fromtimestamp(value / 1000, timezone.utc) + + +def _now_ms() -> int: + return time_ns() // 1_000_000 + + +def _decode_search_result(output: Any) -> FusedSearchResult: + try: + return FusedSearchResult.model_validate(output) + except ValidationError: + legacy = SearchResult.model_validate(output) + return fuse_search_results( + query=legacy.query, + requested_modalities=(legacy.modality,), + results=(legacy,), + top_k=max(1, len(legacy.hits)), + ) + + +def _window_end_iso(value: int) -> str: + return datetime.fromtimestamp(value / 1000, timezone.utc).isoformat( + timespec="milliseconds" + ) + + +class DBOSJobBackend: + """Thin projection of DBOS workflow state into VidXP job contracts.""" + + def __init__( + self, + *, + system_database_url: str | None, + application_version: str, + system_database_engine: Any | None = None, + before_access: Callable[[], None] | None = None, + health_check: Callable[[], None] | None = None, + stop_executor: Callable[[], bool] | None = None, + ) -> None: + self.client = DBOSClient( + system_database_url=system_database_url, + system_database_engine=system_database_engine, + dbos_system_schema=( + "dbos" + if system_database_engine is not None + or ( + system_database_url is not None + and not system_database_url.startswith("sqlite:///") + ) + else None + ), + ) + self.application_version = application_version + self.before_access = before_access + self.health_check = health_check + self.stop_executor = stop_executor + + def close(self) -> None: + self.client.destroy() + + def start(self) -> None: + self._prepare_access() + + def submit( + self, + request: JobRequest, + *, + queue: JobQueue, + job_id: str | None = None, + ) -> Job: + self._prepare_access() + identifier = _job_id(job_id or uuid4().hex) + if job_id is not None: + existing = self._status(identifier, load_input=True) + if existing is not None: + return self._idempotent_job(existing, request) + self.client.enqueue( + self._enqueue_options( + request=request, + queue=queue, + identifier=identifier, + ), + request.model_dump(mode="json"), + ) + status = self._status(identifier, load_input=True) + if status is None: + raise RuntimeError("DBOS did not persist the submitted workflow.") + return self._idempotent_job(status, request) + + def enqueue_in_transaction( + self, + connection: Any, + request: JobRequest, + *, + queue: JobQueue, + job_id: str, + ) -> str: + """Enqueue through a caller-owned transaction on the DBOS database.""" + + self._prepare_access() + identifier = _job_id(job_id) + handle = self.client.enqueue_in_transaction( + connection, + self._enqueue_options( + request=request, + queue=queue, + identifier=identifier, + ), + request.model_dump(mode="json"), + ) + if handle.get_workflow_id() != identifier: + raise RuntimeError("DBOS returned an unexpected workflow identity.") + return identifier + + def _enqueue_options( + self, + *, + request: JobRequest, + queue: JobQueue, + identifier: str, + ) -> EnqueueOptions: + return { + "workflow_name": WORKFLOW_NAMES[request.kind], + "queue_name": QUEUE_NAMES[queue], + "workflow_id": identifier, + "app_version": self.application_version, + "attributes": {"vidxp_queue": queue.value}, + "class_name": WORKFLOW_CLASS_NAME, + "instance_name": WORKFLOW_INSTANCE_NAME, + } + + def get(self, job_id: str) -> Job | None: + self._prepare_access() + identifier = _job_id(job_id) + status = self._status(identifier) + if status is None or status.name not in WORKFLOW_KINDS: + return None + return self._job(status) + + def _status( + self, + identifier: str, + *, + load_input: bool = False, + ) -> Any | None: + statuses = self.client.list_workflows( + workflow_ids=[identifier], + limit=1, + load_input=load_input, + load_output=True, + ) + return statuses[0] if statuses else None + + def _idempotent_job(self, status: Any, request: JobRequest) -> Job: + try: + stored = decode_workflow_request(status.input["args"][0]) + except (KeyError, IndexError, TypeError, ValidationError) as exc: + raise JobIdempotencyConflictError from exc + if ( + status.name not in WORKFLOW_KINDS + or WORKFLOW_KINDS[status.name] != request.kind + or stored != request + ): + raise JobIdempotencyConflictError + return self._job(status) + + def list(self, command: ListJobsCommand) -> JobPage: + self._prepare_access() + try: + offset = decode_offset_cursor( + command.cursor, + scope=_LIST_SCOPE, + ) + if command.cursor is None: + window_end_ms = _now_ms() + else: + payload = decode_cursor(command.cursor, _LIST_SCOPE) + window_end_ms = payload.get(_WINDOW_END_FIELD) + if window_end_ms is None: + # Legacy v1 offset cursors did not freeze the result + # window. Preserve them on a best-effort basis, then + # upgrade the next cursor to the stable representation. + window_end_ms = _now_ms() + if ( + not isinstance(window_end_ms, int) + or isinstance(window_end_ms, bool) + or window_end_ms < 0 + or window_end_ms > MAX_CURSOR_OFFSET + ): + raise CursorError("The cursor window is invalid.") + except CursorError as exc: + raise InvalidJobBackendRequestError( + "The job cursor is invalid." + ) from exc + statuses = self.client.list_workflows( + name=list(WORKFLOW_KINDS), + limit=command.page_size + 1, + offset=offset, + sort_desc=True, + end_time=_window_end_iso(window_end_ms), + load_input=False, + load_output=True, + ) + has_more = len(statuses) > command.page_size + selected = statuses[: command.page_size] + next_offset = offset + len(selected) + if next_offset > MAX_CURSOR_OFFSET: + raise InvalidJobBackendRequestError( + "The job cursor is invalid." + ) + return JobPage( + items=tuple(self._job(status) for status in selected), + next_cursor=( + encode_cursor( + _LIST_SCOPE, + { + "offset": next_offset, + _WINDOW_END_FIELD: window_end_ms, + }, + ) + if has_more + else None + ), + ) + + def cancel(self, job_id: str) -> Job | None: + self._prepare_access() + current = self.get(job_id) + if current is None: + return None + self.client.cancel_workflow(current.job_id) + return self.get(current.job_id) + + def retry( + self, + job_id: str, + *, + retry_id: str | None = None, + ) -> Job | None: + self._prepare_access() + current = self.get(job_id) + if current is None: + return None + status = self._status(current.job_id, load_input=True) + if status is None: + return None + if retry_id is not None or status.name == "vidxp.search.v1": + try: + request = decode_workflow_request(status.input["args"][0]) + except (KeyError, IndexError, TypeError, ValidationError) as exc: + raise InvalidJobBackendRequestError from exc + return self.submit( + request, + queue=current.queue, + job_id=retry_id, + ) + handle = self.client.fork_workflow( + current.job_id, + 1, + application_version=self.application_version, + queue_name=QUEUE_NAMES[current.queue], + ) + retried_id = handle.get_workflow_id() + return self.get(retried_id) + + def health(self) -> None: + if self.health_check is not None: + self.health_check() + self.client.list_workflows(limit=1, load_input=False, load_output=False) + + def stop_worker(self) -> bool: + if self.stop_executor is None: + raise RuntimeError( + "This workflow backend does not own a local worker." + ) + return self.stop_executor() + + def _prepare_access(self) -> None: + if self.before_access is not None: + self.before_access() + + def _job(self, status: Any) -> Job: + kind = WORKFLOW_KINDS[status.name] + recorded_queue = (status.attributes or {}).get("vidxp_queue") + try: + queue = ( + JobQueue(recorded_queue) + if recorded_queue is not None + else QUEUE_KINDS.get(status.queue_name) + ) + except ValueError: + queue = None + if queue is None: + raise RuntimeError("A VidXP workflow has an unexpected queue.") + state = _STATUS_STATES[status.status] + progress = self._event(status.workflow_id, PROGRESS_EVENT, JobProgress) + error = ( + self._event(status.workflow_id, ERROR_EVENT, ErrorDetail) + if state + in { + JobState.failed, + JobState.cancelled, + JobState.recovery_exhausted, + } + else None + ) + result = None + if state == JobState.succeeded: + if kind == JobKind.media_import: + result = MediaImportJobResult( + result=MediaAsset.model_validate(status.output) + ) + elif kind == JobKind.index: + result = IndexJobResult( + result=IndexResult.model_validate(status.output) + ) + elif kind == JobKind.search: + result = SearchJobResult( + result=_decode_search_result(status.output) + ) + elif kind == JobKind.query: + result = QueryJobResult( + result=QueryAnswer.model_validate(status.output) + ) + elif kind in {JobKind.snippet, JobKind.actor_overlay}: + result = ArtifactJobResult( + kind=kind, + result=Artifact.model_validate(status.output), + ) + else: + result = PrepareModelsJobResult( + result=PrepareModelsResult.model_validate(status.output) + ) + if error is None and state == JobState.failed: + error = ErrorDetail( + code="job_execution_failed", + category=ErrorCategory.internal, + message="The background job failed unexpectedly.", + ) + elif error is None and state == JobState.recovery_exhausted: + error = ErrorDetail( + code="job_recovery_exhausted", + category=ErrorCategory.unavailable, + message="The background job exhausted its recovery attempts.", + retryable=True, + ) + return Job( + job_id=status.workflow_id, + kind=kind, + state=state, + queue=queue, + progress=progress, + result=result, + error=error, + recovery_attempts=status.recovery_attempts or 0, + created_at=_timestamp(status.created_at), + updated_at=_timestamp(status.updated_at), + ) + + def _event(self, job_id: str, key: str, model: type[Any]) -> Any | None: + payload = self.client.get_event(job_id, key, timeout_seconds=0) + if payload is None: + return None + try: + return model.model_validate(payload) + except ValidationError: + return None diff --git a/src/vidxp/infrastructure/dbos_workflows.py b/src/vidxp/infrastructure/dbos_workflows.py new file mode 100644 index 0000000..898c4a9 --- /dev/null +++ b/src/vidxp/infrastructure/dbos_workflows.py @@ -0,0 +1,359 @@ +from __future__ import annotations + +import logging +from collections.abc import Callable +from datetime import datetime, timezone +from time import monotonic +from typing import Any + +from dbos import DBOS, DBOSConfiguredInstance +from pydantic import TypeAdapter + +from vidxp.application import VidXPApplication +from vidxp.application_models import ( + ActorOverlayJobRequest, + ApplicationError, + ErrorCategory, + ErrorDetail, + IndexJobRequest, + JobKind, + JobProgress, + MediaImportJobRequest, + SnippetJobRequest, + PrepareModelsJobRequest, + QueryJobRequest, + SearchJobRequest, +) +from vidxp.core.contracts import CancellationToken +from vidxp.core.contracts import IndexCancelledError +from vidxp.execution import ExecutionContext +from vidxp.workflow_contracts import ( + ERROR_EVENT, + PROGRESS_EVENT, + WORKFLOW_CLASS_NAME, + WORKFLOW_INSTANCE_NAME, + WORKFLOW_NAMES, + decode_workflow_request, +) + + +_ARTIFACT_REQUEST = TypeAdapter( + ActorOverlayJobRequest | SnippetJobRequest +) +LOGGER = logging.getLogger(__name__) + + +class _DBOSCancellationEvent: + def __init__(self, poll_interval_seconds: float = 0.2) -> None: + self._poll_interval_seconds = poll_interval_seconds + self._checked_at = 0.0 + self._cancelled = False + + def is_set(self) -> bool: + if self._cancelled: + return True + now = monotonic() + if now - self._checked_at < self._poll_interval_seconds: + return False + self._checked_at = now + status = DBOS.get_workflow_status(DBOS.workflow_id) + self._cancelled = status is not None and status.status == "CANCELLED" + return self._cancelled + + def set(self) -> None: + if DBOS.workflow_id is not None: + DBOS.cancel_workflow(DBOS.workflow_id) + self._cancelled = True + + +def _publish_progress(event: dict[str, Any]) -> None: + stage = str(event.get("stage") or "working")[:128] + message = str(event.get("message") or stage.replace("_", " "))[:512] + current = event.get("current") + total = event.get("total") + progress = JobProgress( + stage=stage, + message=message, + current=current if isinstance(current, int) else None, + total=total if isinstance(total, int) and total > 0 else None, + updated_at=datetime.now(timezone.utc), + ) + DBOS.set_event(PROGRESS_EVENT, progress.model_dump(mode="json")) + + +def _execution() -> ExecutionContext: + cancellation = CancellationToken(_DBOSCancellationEvent()) + + def publish(event: dict[str, Any]) -> None: + cancellation.raise_if_cancelled() + _publish_progress(event) + + return ExecutionContext( + job_id=DBOS.workflow_id, + progress=publish, + cancellation=cancellation, + ) + + +def _publish_error(exc: Exception) -> None: + if isinstance(exc, ApplicationError): + error = exc.detail + else: + error = ErrorDetail( + code="job_execution_failed", + category=ErrorCategory.internal, + message="The background job failed unexpectedly.", + ) + DBOS.set_event(ERROR_EVENT, error.model_dump(mode="json")) + + +def _step_boundary( + operation: Callable[[], dict[str, Any]], +) -> dict[str, Any]: + try: + return operation() + except IndexCancelledError: + raise + except Exception as exc: + if isinstance(exc, ApplicationError): + LOGGER.warning( + "VidXP workflow %s failed with %s.", + DBOS.workflow_id, + exc.detail.code, + ) + else: + LOGGER.exception( + "VidXP workflow %s failed unexpectedly.", + DBOS.workflow_id, + ) + _publish_error(exc) + raise RuntimeError("VidXP job execution failed.") from exc + + +@DBOS.dbos_class(class_name=WORKFLOW_CLASS_NAME) +class VidXPWorkerWorkflows(DBOSConfiguredInstance): + """Official DBOS configured instance for one composed worker application.""" + + def __init__(self, application: VidXPApplication) -> None: + self.application = application + super().__init__(WORKFLOW_INSTANCE_NAME) + + @DBOS.step(name="vidxp.run_index.v1") + def run_index_step(self, payload: dict[str, Any]) -> dict[str, Any]: + def execute() -> dict[str, Any]: + request = IndexJobRequest.model_validate(payload) + execution = _execution() + _publish_progress( + { + "stage": "starting", + "message": "Starting the indexing job.", + } + ) + result = self.application.create_index( + request.command, + execution=execution, + ) + _publish_progress( + { + "stage": "complete", + "message": "The index generation is ready.", + "current": 1, + "total": 1, + } + ) + return result.model_dump(mode="json") + + return _step_boundary(execute) + + @DBOS.step(name="vidxp.run_media_import.v1") + def run_media_import_step( + self, + payload: dict[str, Any], + ) -> dict[str, Any]: + def execute() -> dict[str, Any]: + request = MediaImportJobRequest.model_validate(payload) + _publish_progress( + { + "stage": "importing", + "message": "Validating and importing the completed upload.", + } + ) + result = self.application.import_completed_upload( + request.upload_id + ) + _publish_progress( + { + "stage": "complete", + "message": "The uploaded media is ready.", + "current": 1, + "total": 1, + } + ) + return result.model_dump(mode="json") + + return _step_boundary(execute) + + @DBOS.step(name="vidxp.run_artifact.v1") + def run_artifact_step(self, payload: dict[str, Any]) -> dict[str, Any]: + def execute() -> dict[str, Any]: + request = _ARTIFACT_REQUEST.validate_python(payload) + execution = _execution() + if isinstance(request, SnippetJobRequest): + result = self.application.create_snippet( + request.command, + execution=execution, + ) + else: + result = self.application.render_actor( + request.command, + snapshot=request.snapshot, + execution=execution, + ) + return result.model_dump(mode="json") + + return _step_boundary(execute) + + def _run_search(self, payload: dict[str, Any]) -> dict[str, Any]: + def execute() -> dict[str, Any]: + request = decode_workflow_request(payload) + if not isinstance(request, SearchJobRequest): + raise ValueError("A search workflow requires a search request.") + execution = _execution() + _publish_progress( + { + "stage": "searching", + "message": "Searching the committed index.", + } + ) + execution.checkpoint() + result = self.application.search( + request.command, + snapshot=request.snapshot, + ) + execution.checkpoint() + _publish_progress( + { + "stage": "complete", + "message": "Search results are ready.", + "current": 1, + "total": 1, + } + ) + return result.model_dump(mode="json") + + return _step_boundary(execute) + + @DBOS.step(name="vidxp.run_search.v1") + def run_legacy_search_step( + self, + payload: dict[str, Any], + ) -> dict[str, Any]: + return self._run_search(payload) + + @DBOS.step(name="vidxp.run_search.v2") + def run_search_step(self, payload: dict[str, Any]) -> dict[str, Any]: + return self._run_search(payload) + + @DBOS.step(name="vidxp.run_query.v1") + def run_query_step(self, payload: dict[str, Any]) -> dict[str, Any]: + def execute() -> dict[str, Any]: + request = QueryJobRequest.model_validate(payload) + execution = _execution() + _publish_progress( + { + "stage": "querying", + "message": "Querying the committed index.", + } + ) + execution.checkpoint() + result = self.application.query_video( + request.command, + snapshot=request.snapshot, + execution=execution, + ) + execution.checkpoint() + _publish_progress( + { + "stage": "complete", + "message": "The grounded query result is ready.", + "current": 1, + "total": 1, + } + ) + return result.model_dump(mode="json") + + return _step_boundary(execute) + + @DBOS.step(name="vidxp.prepare_models_step.v1") + def prepare_models_step( + self, + payload: dict[str, Any], + ) -> dict[str, Any]: + def execute() -> dict[str, Any]: + request = PrepareModelsJobRequest.model_validate(payload) + execution = _execution() + _publish_progress( + { + "stage": "preparing_models", + "message": "Preparing model artifacts.", + } + ) + result = self.application.prepare_models( + request.command, + execution=execution, + ) + _publish_progress( + { + "stage": "complete", + "message": "Model artifacts are ready.", + "current": 1, + "total": 1, + } + ) + return result.model_dump(mode="json") + + return _step_boundary(execute) + + @DBOS.workflow(name=WORKFLOW_NAMES[JobKind.index]) + def index_workflow(self, payload: dict[str, Any]) -> dict[str, Any]: + return self.run_index_step(payload) + + @DBOS.workflow(name=WORKFLOW_NAMES[JobKind.media_import]) + def media_import_workflow( + self, + payload: dict[str, Any], + ) -> dict[str, Any]: + return self.run_media_import_step(payload) + + @DBOS.workflow(name=WORKFLOW_NAMES[JobKind.snippet]) + def snippet_workflow(self, payload: dict[str, Any]) -> dict[str, Any]: + return self.run_artifact_step(payload) + + @DBOS.workflow(name=WORKFLOW_NAMES[JobKind.search]) + def search_workflow(self, payload: dict[str, Any]) -> dict[str, Any]: + return self.run_search_step(payload) + + @DBOS.workflow(name="vidxp.search.v1") + def legacy_search_workflow( + self, + payload: dict[str, Any], + ) -> dict[str, Any]: + return self.run_legacy_search_step(payload) + + @DBOS.workflow(name=WORKFLOW_NAMES[JobKind.query]) + def query_workflow(self, payload: dict[str, Any]) -> dict[str, Any]: + return self.run_query_step(payload) + + @DBOS.workflow(name=WORKFLOW_NAMES[JobKind.actor_overlay]) + def actor_overlay_workflow( + self, + payload: dict[str, Any], + ) -> dict[str, Any]: + return self.run_artifact_step(payload) + + @DBOS.workflow(name=WORKFLOW_NAMES[JobKind.prepare_models]) + def prepare_models_workflow( + self, + payload: dict[str, Any], + ) -> dict[str, Any]: + return self.prepare_models_step(payload) diff --git a/src/vidxp/infrastructure/local_artifacts.py b/src/vidxp/infrastructure/local_artifacts.py new file mode 100644 index 0000000..872d893 --- /dev/null +++ b/src/vidxp/infrastructure/local_artifacts.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path +from time import monotonic, sleep + +from pydantic import TypeAdapter + +from vidxp.core.artifacts import ( + ArtifactRenderError, + ArtifactRendererUnavailableError, + StagedArtifact, + StoredArtifact, +) +from vidxp.core.contracts import CancellationToken +from vidxp.core.identifiers import ArtifactId +from vidxp.core.indexing_common import ProgressCallback +from vidxp.core.video import render_actor_video +from vidxp.core.manifest import sha256_file +from vidxp.infrastructure.local_files import ( + prepare_managed_destination, +) +from vidxp.infrastructure.local_objects import LocalObjectStore + + +class LocalArtifactStore: + def __init__(self, root: Path) -> None: + self.root = root + self.objects = root / "objects" + self.staging = root / ".staging" + self._managed = LocalObjectStore(root) + + def stage(self, artifact_id: str, *, suffix: str) -> StagedArtifact: + validated_id = TypeAdapter(ArtifactId).validate_python(artifact_id) + if ( + not suffix.startswith(".") + or not suffix[1:].isalnum() + or len(suffix) > 10 + ): + raise ValueError("Artifact suffix is invalid.") + path = prepare_managed_destination( + self.root, + f".staging/{validated_id}.tmp{suffix.lower()}", + ) + path.unlink(missing_ok=True) + return StagedArtifact(artifact_id=artifact_id, path=path) + + def publish(self, staged: StagedArtifact) -> StoredArtifact: + suffix = staged.path.suffix.lower() + storage_key = ( + f"objects/{staged.artifact_id[:2]}/" + f"{staged.artifact_id}{suffix}" + ) + path, checksum, byte_size = self._managed.publish( + staged.path, + storage_key, + expected_sha256=None, + replace_corrupt=False, + ) + return StoredArtifact( + sha256=checksum, + byte_size=byte_size, + storage_key=storage_key, + local_path=path, + ) + + def recover( + self, + artifact_id: str, + *, + suffix: str, + ) -> StoredArtifact | None: + validated_id = TypeAdapter(ArtifactId).validate_python(artifact_id) + storage_key = ( + f"objects/{validated_id[:2]}/" + f"{validated_id}{suffix.lower()}" + ) + try: + path = self._managed.resolve(storage_key) + except FileNotFoundError: + return None + return StoredArtifact( + sha256=sha256_file(path), + byte_size=path.stat().st_size, + storage_key=storage_key, + local_path=path, + ) + + def discard(self, staged: StagedArtifact) -> None: + staged.path.unlink(missing_ok=True) + + def delete(self, storage_key: str) -> None: + self._managed.delete(storage_key) + + def verify( + self, + storage_key: str, + *, + sha256: str, + byte_size: int, + ) -> Path: + return self._managed.verify( + storage_key, + sha256=sha256, + byte_size=byte_size, + ) + + def resolve(self, storage_key: str) -> Path: + self.objects.mkdir(parents=True, exist_ok=True) + return self._managed.resolve(storage_key) + + +class LocalActorRenderer: + def render( + self, + source: Path, + destination: Path, + cluster_id: str, + detections: list[dict], + *, + cancellation: CancellationToken, + progress: ProgressCallback | None, + ) -> None: + render_actor_video( + source, + destination, + cluster_id, + detections, + cancellation=cancellation, + progress=progress, + ) + + +class FFmpegSnippetRenderer: + def __init__( + self, + executable: str = "ffmpeg", + *, + timeout_seconds: float = 600, + ) -> None: + self.executable = executable + self.timeout_seconds = timeout_seconds + + def render( + self, + source: Path, + destination: Path, + *, + start_seconds: float, + end_seconds: float, + compatible_mp4: bool, + cancellation: CancellationToken, + progress: ProgressCallback | None, + ) -> None: + duration = end_seconds - start_seconds + encoding = ( + ["-c:v", "libx264", "-c:a", "aac", "-movflags", "+faststart"] + if compatible_mp4 + else ["-c", "copy"] + ) + try: + process = subprocess.Popen( + [ + self.executable, + "-nostdin", + "-v", + "error", + "-protocol_whitelist", + "file,pipe", + "-ss", + str(start_seconds), + "-i", + str(source), + "-t", + str(duration), + *encoding, + str(destination), + ], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except FileNotFoundError as exc: + raise ArtifactRendererUnavailableError( + "The configured ffmpeg executable is unavailable." + ) from exc + + started_at = monotonic() + last_progress_at = started_at + try: + while process.poll() is None: + cancellation.raise_if_cancelled() + now = monotonic() + if now - started_at > self.timeout_seconds: + raise ArtifactRenderError( + "The requested snippet render timed out." + ) + if progress is not None and now - last_progress_at >= 1: + progress( + { + "state": "rendering", + "stage": "rendering", + "message": "Rendering the requested video snippet.", + } + ) + last_progress_at = now + sleep(0.1) + + if process.returncode != 0: + raise ArtifactRenderError( + "The requested snippet could not be rendered." + ) + except BaseException: + if process.poll() is None: + self._stop(process) + raise + + @staticmethod + def _stop(process: subprocess.Popen) -> None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) diff --git a/src/vidxp/infrastructure/local_catalog.py b/src/vidxp/infrastructure/local_catalog.py new file mode 100644 index 0000000..5d337b8 --- /dev/null +++ b/src/vidxp/infrastructure/local_catalog.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from pathlib import Path + +from sqlalchemy import Column, Integer, MetaData, Table, insert, select, update + +from vidxp.infrastructure.sql_catalog import SQLCatalog +from vidxp.infrastructure.sql_tables import ( + artifact_requests, + artifacts, + media, + media_import_requests, + metadata, +) + +CATALOG_SCHEMA_VERSION = 3 +_local_metadata = MetaData() +catalog_metadata = Table( + "catalog_metadata", + _local_metadata, + Column("schema_version", Integer, nullable=False), +) + + +class LocalCatalog(SQLCatalog): + """Repository-scoped SQLite catalog using the shared SQL adapter.""" + + def __init__(self, database: Path) -> None: + database.parent.mkdir(parents=True, exist_ok=True) + super().__init__( + f"sqlite:///{database.resolve().as_posix()}", + initialize=False, + ) + metadata.create_all( + self.engine, + tables=( + media, + artifacts, + artifact_requests, + media_import_requests, + ), + ) + _local_metadata.create_all(self.engine) + with self.transaction() as connection: + version = connection.execute( + select(catalog_metadata.c.schema_version) + ).scalar_one_or_none() + if version is None: + connection.execute( + insert(catalog_metadata).values( + schema_version=CATALOG_SCHEMA_VERSION + ) + ) + elif version in {1, 2}: + connection.execute( + update(catalog_metadata).values( + schema_version=CATALOG_SCHEMA_VERSION + ) + ) + elif version != CATALOG_SCHEMA_VERSION: + raise RuntimeError( + "The repository catalog schema is incompatible." + ) diff --git a/src/vidxp/infrastructure/local_files.py b/src/vidxp/infrastructure/local_files.py new file mode 100644 index 0000000..66650c9 --- /dev/null +++ b/src/vidxp/infrastructure/local_files.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from pathlib import Path, PurePosixPath + +from vidxp.core.manifest import sync_parent_directory +from vidxp.core.storage_keys import validate_storage_key + + +def resolve_managed_file(root: Path, storage_key: str) -> Path: + """Resolve an internal storage key without permitting link escapes.""" + + validate_storage_key(storage_key) + resolved_root = root.resolve(strict=True) + relative = PurePosixPath(storage_key) + candidate = root.joinpath(*relative.parts) + resolved = candidate.resolve(strict=True) + if not resolved.is_relative_to(resolved_root) or not resolved.is_file(): + raise FileNotFoundError("The managed file is unavailable.") + + current = root + for part in relative.parts: + current = current / part + is_junction = getattr(current, "is_junction", lambda: False) + if current.is_symlink() or is_junction(): + raise PermissionError("Managed storage links are not permitted.") + return resolved + + +def prepare_managed_destination(root: Path, storage_key: str) -> Path: + """Create a confined parent tree and reject pre-existing links.""" + + validate_storage_key(storage_key) + _ensure_directory(root) + resolved_root = root.resolve(strict=True) + relative = PurePosixPath(storage_key) + current = root + for part in relative.parts[:-1]: + current = current / part + if current.exists(): + is_junction = getattr(current, "is_junction", lambda: False) + if current.is_symlink() or is_junction() or not current.is_dir(): + raise PermissionError( + "Managed storage parent links are not permitted." + ) + else: + _ensure_directory(current) + if not current.resolve(strict=True).is_relative_to(resolved_root): + raise PermissionError("Managed storage escaped its configured root.") + + destination = current / relative.name + if destination.exists(): + is_junction = getattr(destination, "is_junction", lambda: False) + if destination.is_symlink() or is_junction() or not destination.is_file(): + raise PermissionError("Managed storage links are not permitted.") + return destination + + +def _ensure_directory(path: Path) -> None: + missing = [] + current = path + while not current.exists(): + missing.append(current) + current = current.parent + for directory in reversed(missing): + try: + directory.mkdir() + except FileExistsError: + if not directory.is_dir(): + raise + else: + sync_parent_directory(directory.parent) + + +def durable_replace(source: Path, destination: Path) -> None: + """Replace a file and synchronize both affected directory entries.""" + + source_parent = source.parent + destination_parent = destination.parent + source.replace(destination) + sync_parent_directory(destination_parent) + if source_parent != destination_parent: + sync_parent_directory(source_parent) + + +def durable_unlink(path: Path, *, missing_ok: bool = False) -> bool: + """Remove a file and synchronize the removed directory entry.""" + + try: + path.unlink() + except FileNotFoundError: + if missing_ok: + return False + raise + sync_parent_directory(path.parent) + return True diff --git a/src/vidxp/infrastructure/local_index.py b/src/vidxp/infrastructure/local_index.py new file mode 100644 index 0000000..e333d3e --- /dev/null +++ b/src/vidxp/infrastructure/local_index.py @@ -0,0 +1,575 @@ +from __future__ import annotations + +import json +import shutil +from dataclasses import replace +from pathlib import Path +from typing import Any +from uuid import UUID + +from vidxp.capabilities.contracts import ( + RuntimeCheckBinding, + module_import_check, +) +from vidxp.capabilities.registry import CapabilityRegistry +from vidxp.core.contracts import ( + CancellationToken, + IndexConfig, + IndexSchemaError, +) +from vidxp.core.indexing_common import ProgressCallback +from vidxp.core.manifest import MANIFEST_FILE, ManifestStore +from vidxp.core.runner import index_video +from vidxp.core.storage import ( + ChromaClientFactory, + IndexStorage, + SnapshotScopedIndexStore, +) +from vidxp.infrastructure.local_snapshots import LocalSnapshotRepository +from vidxp.repository_layout import RepositoryLayout +from vidxp.runtime import ModelRuntime + + +def _index_runtime_checks( + chroma_client: str, +) -> tuple[RuntimeCheckBinding, ...]: + return ( + RuntimeCheckBinding( + capability="storage", + check=module_import_check( + "Chroma storage import", + "chromadb", + chroma_client, + ), + ), + RuntimeCheckBinding( + capability="storage", + check=module_import_check( + "Host resource monitor import", + "psutil", + "virtual_memory", + ), + ), + ) + + +LOCAL_INDEX_RUNTIME_CHECKS = _index_runtime_checks("PersistentClient") +SERVER_INDEX_RUNTIME_CHECKS = _index_runtime_checks("HttpClient") + + +class LocalIndexReader: + """Model-free access to immutable local index snapshots.""" + + def __init__( + self, + layout: RepositoryLayout, + *, + chroma_server_url: str | None = None, + snapshot_repository: LocalSnapshotRepository | None = None, + ) -> None: + self.layout = layout + self.repository = snapshot_repository or LocalSnapshotRepository( + layout.indexes + ) + self.chroma_clients = ChromaClientFactory(chroma_server_url) + + def _require_index_directory(self, index_directory: str | Path) -> None: + if Path(index_directory) != self.layout.indexes: + raise ValueError( + "The index operation is outside the configured repository." + ) + + def active_config( + self, + index_directory: Path, + *, + device: str, + ) -> IndexConfig: + self._require_index_directory(index_directory) + config, _snapshot = self.repository.active_config(device=device) + return config + + def config_for_snapshot( + self, + index_directory: Path, + *, + snapshot_id: str, + snapshot_sha256: str, + device: str, + ) -> IndexConfig: + self._require_index_directory(index_directory) + return self.repository.config_for_snapshot( + snapshot_id, + snapshot_sha256=snapshot_sha256, + device=device, + ) + + def open_store(self, config: IndexConfig) -> SnapshotScopedIndexStore: + if config.snapshot_id is None: + raise IndexSchemaError("A snapshot ID is required for index reads.") + if config.snapshot_sha256 is None: + raise IndexSchemaError( + "A snapshot checksum is required for index reads." + ) + snapshot = self.repository.read_snapshot( + config.snapshot_id, + expected_sha256=config.snapshot_sha256, + ) + generation_ids = tuple( + reference.generation_id + for reference in snapshot.generations.values() + ) + storage = self._open_committed_storage(config) + return SnapshotScopedIndexStore( + storage, + generation_ids=generation_ids, + ) + + @classmethod + def _validate_snapshot_storage( + cls, + storage: IndexStorage, + snapshot, + base_config: IndexConfig | None = None, + ) -> None: + active_config = base_config or storage.config + for reference in snapshot.generations.values(): + try: + actual = { + modality: storage.count_records( + modality, + video_id=reference.media_id, + generation_ids=(reference.generation_id,), + ) + for modality in active_config.enabled_modalities + } + except FileNotFoundError as exc: + raise IndexSchemaError( + "A committed Chroma collection is missing." + ) from exc + if actual != dict(reference.record_counts): + raise IndexSchemaError( + "Stored generation record counts do not match the " + "authoritative manifest." + ) + + def _open_committed_storage(self, config: IndexConfig) -> IndexStorage: + try: + return IndexStorage( + config, + create=False, + client_factory=self.chroma_clients, + ) + except FileNotFoundError as exc: + raise IndexSchemaError( + "The committed Chroma store is missing." + ) from exc + + +class LocalIndexBackend(LocalIndexReader): + def __init__( + self, + registry: CapabilityRegistry, + runtime: ModelRuntime, + layout: RepositoryLayout, + *, + chroma_server_url: str | None = None, + snapshot_repository: LocalSnapshotRepository | None = None, + ) -> None: + super().__init__( + layout, + chroma_server_url=chroma_server_url, + snapshot_repository=snapshot_repository, + ) + self.registry = registry + self.runtime = runtime + + def status(self, index_directory: Path) -> dict[str, Any] | None: + self._require_index_directory(index_directory) + status = self.repository.status() + if status is not None and status.get("state") == "ready": + config, snapshot = self.repository.active_config( + device=self.runtime.backends.torch_device + ) + with self._open_committed_storage(config) as storage: + self._validate_snapshot_storage(storage, snapshot) + return status + + def create( + self, + path: Path, + *, + config: IndexConfig, + progress: ProgressCallback | None, + cancellation: CancellationToken | None, + source_name: str | None, + source_checksum: str, + operation_id: str | None = None, + ) -> dict[str, Any]: + self._require_index_directory(config.index_directory) + repository = self.repository + media_id = config.video_id + if media_id is None: + raise ValueError("A catalog media_id is required for indexing.") + generation_id = operation_id or repository.new_generation_id() + generation_directory = repository.generation_directory(generation_id) + build_config = replace( + config, + video_id=media_id, + storage_directory=repository.store, + generation_directory=generation_directory, + generation_id=generation_id, + snapshot_id=None, + snapshot_sha256=None, + ) + + with repository.lease(): + active = repository.require_compatible_profile( + media_id=media_id, + config_fingerprint=build_config.fingerprint(), + ) + if active is not None and active.generations: + active_config, _ = repository.active_config( + device=build_config.device + ) + with self._open_committed_storage( + active_config + ) as active_store: + self._validate_snapshot_storage( + active_store, + active, + active_config, + ) + if operation_id is not None and active is not None: + committed = active.generations.get(media_id) + if ( + committed is not None + and committed.generation_id == operation_id + ): + if ( + committed.input_sha256 != source_checksum + or committed.config_fingerprint + != build_config.fingerprint() + ): + raise IndexSchemaError( + "The indexing operation ID is already bound to " + "different input or configuration." + ) + return { + "media_id": media_id, + "generation_id": committed.generation_id, + "snapshot_id": active.snapshot_id, + "active_media_count": len(active.generations), + "record_counts": dict(committed.record_counts), + } + completed_manifest = generation_directory / MANIFEST_FILE + if operation_id is not None and completed_manifest.is_file(): + completed = None + try: + completed = repository.generation_reference( + generation_id=operation_id, + media_id=media_id, + ) + except IndexSchemaError: + try: + raw_manifest = json.loads( + completed_manifest.read_text(encoding="utf-8") + ) + except (OSError, json.JSONDecodeError): + pass + else: + if raw_manifest.get("state") == "complete": + manifest_store = ManifestStore( + build_config, + registry=self.registry, + runtime=self.runtime, + ) + with IndexStorage( + build_config, + create=False, + client_factory=self.chroma_clients, + ) as storage: + recovered_counts = ( + self._validate_generation_records( + storage, + build_config, + ) + ) + manifest_store.record_storage_counts( + recovered_counts + ) + completed = repository.generation_reference( + generation_id=operation_id, + media_id=media_id, + ) + if completed is not None: + if ( + completed.input_sha256 != source_checksum + or completed.config_fingerprint + != build_config.fingerprint() + ): + raise IndexSchemaError( + "The indexing operation ID is already bound to " + "different input or configuration." + ) + snapshot = repository.publish_generation( + completed, + build_config, + ) + return { + "media_id": media_id, + "generation_id": completed.generation_id, + "snapshot_id": snapshot.snapshot_id, + "active_media_count": len(snapshot.generations), + "record_counts": dict(completed.record_counts), + } + self._cleanup_abandoned_generations( + repository, + build_config, + client_factory=self.chroma_clients, + ) + generation_validated = False + try: + manifest_store = ManifestStore( + build_config, + registry=self.registry, + runtime=self.runtime, + ) + with IndexStorage( + build_config, + client_factory=self.chroma_clients, + ) as storage: + index_video( + str(path), + progress_callback=progress, + source_name=source_name, + checksum=source_checksum, + config=build_config, + cancellation=cancellation, + storage=storage, + manifest_store=manifest_store, + registry=self.registry, + runtime=self.runtime, + ) + if cancellation is not None: + cancellation.raise_if_cancelled() + record_counts = self._validate_generation_records( + storage, + build_config, + ) + manifest_store.record_storage_counts(record_counts) + if cancellation is not None: + cancellation.raise_if_cancelled() + reference = repository.generation_reference( + generation_id=generation_id, + media_id=media_id, + ) + generation_validated = True + if cancellation is not None: + cancellation.raise_if_cancelled() + snapshot = repository.publish_generation( + reference, + build_config, + ) + except BaseException: + try: + self._remove_uncommitted_generation( + repository, + build_config, + preserve_completed=generation_validated, + ) + except Exception: + # Preserve the primary build/publication failure. + pass + raise + + return { + "media_id": media_id, + "generation_id": generation_id, + "snapshot_id": snapshot.snapshot_id, + "active_media_count": len(snapshot.generations), + "record_counts": record_counts, + } + + @staticmethod + def _validate_generation_records( + storage: IndexStorage, + config: IndexConfig, + expected_counts: dict[str, int] | None = None, + ) -> dict[str, int]: + if config.generation_id is None or config.video_id is None: + raise IndexSchemaError( + "Generation and media identity are required before publication." + ) + counts: dict[str, int] = {} + for modality in config.enabled_modalities: + records = storage.records( + modality, + generation_ids=(config.generation_id,), + ) + if any( + record.get("generation_id") != config.generation_id + or record.get("video_id") != config.video_id + or record.get("modality") != modality + for record in records + ): + raise IndexSchemaError( + f"Stored {modality} records do not match the generation " + "being published." + ) + counts[modality] = len(records) + if expected_counts is not None and counts != expected_counts: + raise IndexSchemaError( + "Stored generation record counts do not match the " + "authoritative manifest." + ) + return counts + + @staticmethod + def _cleanup_abandoned_generations( + repository: LocalSnapshotRepository, + config: IndexConfig, + *, + client_factory: ChromaClientFactory | None = None, + ) -> None: + clients = client_factory or ChromaClientFactory() + completed: set[str] = set() + incomplete: dict[str, Path] = {} + if repository.generations.is_dir(): + for path in repository.generations.iterdir(): + if ( + not path.is_dir() + or not LocalIndexBackend._is_generation_id(path.name) + ): + continue + manifest_path = path / MANIFEST_FILE + try: + manifest = json.loads( + manifest_path.read_text(encoding="utf-8") + ) + except (OSError, json.JSONDecodeError): + incomplete[path.name] = path + continue + if ( + manifest.get("state") == "complete" + and manifest.get("generation_id") == path.name + ): + completed.add(path.name) + else: + incomplete[path.name] = path + + cleanup_config = replace( + config, + storage_directory=repository.store, + generation_directory=None, + video_id=None, + generation_id=None, + snapshot_id=None, + snapshot_sha256=None, + ) + if clients.remote or repository.store.is_dir(): + try: + with IndexStorage( + cleanup_config, + create=False, + client_factory=clients, + ) as storage: + stored_ids: set[str] = set() + for modality in cleanup_config.enabled_modalities: + try: + records = storage.records(modality) + except FileNotFoundError: + continue + for record in records: + generation_id = record.get("generation_id") + if ( + isinstance(generation_id, str) + and LocalIndexBackend._is_generation_id( + generation_id + ) + ): + stored_ids.add(generation_id) + for generation_id in sorted( + set(incomplete) | (stored_ids - completed) + ): + for modality in cleanup_config.enabled_modalities: + try: + storage.delete_generation( + generation_id, + modalities=(modality,), + ) + except FileNotFoundError: + continue + except FileNotFoundError: + pass + + for generation_id, path in incomplete.items(): + resolved = path.resolve() + if repository.generations.resolve() not in resolved.parents: + raise RuntimeError( + "Refusing to clean a generation outside the repository." + ) + shutil.rmtree(resolved) + + @staticmethod + def _is_generation_id(value: str) -> bool: + try: + identifier = UUID(hex=value) + except ValueError: + return False + return identifier.version == 4 and identifier.hex == value + + def _remove_uncommitted_generation( + self, + repository: LocalSnapshotRepository, + config: IndexConfig, + *, + preserve_completed: bool, + ) -> None: + generation_id = config.generation_id + if generation_id is None: + return + generation_directory = config.run_directory.resolve() + generations_root = repository.generations.resolve() + if generations_root not in generation_directory.parents: + raise RuntimeError( + "Refusing to clean a generation outside the repository." + ) + manifest_path = generation_directory / MANIFEST_FILE + state = None + if manifest_path.is_file(): + try: + state = json.loads( + manifest_path.read_text(encoding="utf-8") + ).get("state") + except (OSError, json.JSONDecodeError): + state = None + if state == "complete" and preserve_completed: + return + try: + with IndexStorage( + config, + client_factory=self.chroma_clients, + ) as storage: + storage.delete_generation(generation_id) + except Exception: + # Cleanup is best-effort and must never replace the build failure. + pass + if generation_directory.is_dir(): + shutil.rmtree(generation_directory) + + def indexing_in_progress(self, config: IndexConfig) -> bool: + self._require_index_directory(config.index_directory) + return self.repository.mutation_in_progress() + + def remove(self, config: IndexConfig, media_id: str) -> bool: + self._require_index_directory(config.index_directory) + repository = self.repository + with repository.lease(): + return repository.remove(media_id) + + def clear(self, config: IndexConfig) -> bool: + self._require_index_directory(config.index_directory) + repository = self.repository + with repository.lease(): + return repository.clear() diff --git a/src/vidxp/infrastructure/local_media.py b/src/vidxp/infrastructure/local_media.py new file mode 100644 index 0000000..ecbc35b --- /dev/null +++ b/src/vidxp/infrastructure/local_media.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +import hashlib +import json +import math +import mimetypes +import os +import subprocess +import tempfile +from contextlib import AbstractContextManager +from pathlib import Path +from typing import Any +from filelock import FileLock + +from vidxp.core.media import ( + InvalidMediaError, + MediaProbeUnavailableError, + MediaProbe, + MediaImportLimitError, + MediaStream, + MediaStoreIntegrityError, + safe_media_suffix, + StagedMedia, + StoredMedia, +) +from vidxp.infrastructure.local_files import ( + prepare_managed_destination, +) +from vidxp.infrastructure.local_objects import LocalObjectStore + + +class LocalMediaStore: + def __init__(self, root: Path, *, max_bytes: int) -> None: + self.root = root + self.objects = root / "objects" + self.staging = root / ".staging" + self.max_bytes = max_bytes + self._managed = LocalObjectStore(root) + + def stage_local(self, path: Path) -> StagedMedia: + source = path.resolve(strict=True) + if not source.is_file(): + raise FileNotFoundError("The local media source is not a file.") + initial = source.stat() + if initial.st_size > self.max_bytes: + raise MediaImportLimitError( + "The media exceeds the configured import limit." + ) + + prepare_managed_destination( + self.root, + ".staging/.media-placeholder", + ) + temporary: Path | None = None + digest = hashlib.sha256() + byte_size = 0 + try: + with source.open("rb") as reader: + opened_before = os.fstat(reader.fileno()) + with tempfile.NamedTemporaryFile( + mode="w+b", + dir=self.staging, + prefix=".media.", + suffix=".tmp", + delete=False, + ) as writer: + temporary = Path(writer.name) + while chunk := reader.read(1024 * 1024): + byte_size += len(chunk) + if byte_size > self.max_bytes: + raise MediaImportLimitError( + "The media exceeds the configured import limit." + ) + digest.update(chunk) + writer.write(chunk) + writer.flush() + os.fsync(writer.fileno()) + opened_after = os.fstat(reader.fileno()) + + current = source.stat() + if ( + opened_before.st_size != opened_after.st_size + or opened_before.st_mtime_ns != opened_after.st_mtime_ns + or opened_after.st_size != current.st_size + or opened_after.st_mtime_ns != current.st_mtime_ns + or ( + opened_after.st_ino + and current.st_ino + and opened_after.st_ino != current.st_ino + ) + ): + raise MediaStoreIntegrityError( + "The media changed while it was being imported." + ) + + checksum = digest.hexdigest() + if byte_size == 0: + raise InvalidMediaError("The media file is empty.") + suffix = safe_media_suffix(source) + storage_key = ( + f"objects/{checksum[:2]}/{checksum[2:4]}/" + f"{checksum}{suffix}" + ) + staged = StagedMedia( + sha256=checksum, + byte_size=byte_size, + storage_key=storage_key, + path=temporary, + ) + temporary = None + return staged + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + def publication_lock(self, sha256: str) -> AbstractContextManager[None]: + lock_path = prepare_managed_destination( + self.root, + f".locks/{sha256}.lock", + ) + return FileLock(lock_path) + + def publish(self, staged: StagedMedia) -> StoredMedia: + try: + path, checksum, byte_size = self._managed.publish( + staged.path, + staged.storage_key, + expected_sha256=staged.sha256, + replace_corrupt=True, + ) + except RuntimeError as exc: + raise MediaStoreIntegrityError( + "The staged media failed its integrity check." + ) from exc + return StoredMedia( + sha256=checksum, + byte_size=byte_size, + storage_key=staged.storage_key, + local_path=path, + ) + + def discard(self, staged: StagedMedia) -> None: + staged.path.unlink(missing_ok=True) + + def delete(self, storage_key: str) -> None: + self._managed.delete(storage_key) + + def verify( + self, + storage_key: str, + *, + sha256: str, + byte_size: int, + ) -> Path: + try: + return self._managed.verify( + storage_key, + sha256=sha256, + byte_size=byte_size, + ) + except RuntimeError as exc: + raise MediaStoreIntegrityError( + "Managed media failed its integrity check." + ) from exc + + def resolve(self, storage_key: str) -> Path: + self.objects.mkdir(parents=True, exist_ok=True) + return self._managed.resolve(storage_key) + + +class FFprobeMediaProbe: + _MIME_TYPES = { + "avi": "video/x-msvideo", + "matroska": "video/x-matroska", + "mov": "video/quicktime", + "mp4": "video/mp4", + "mpeg": "video/mpeg", + "webm": "video/webm", + } + + def __init__( + self, + executable: str = "ffprobe", + *, + timeout_seconds: float = 30.0, + ) -> None: + self.executable = executable + self.timeout_seconds = timeout_seconds + + def probe(self, path: Path) -> MediaProbe: + output = self._output(path) + try: + payload = json.loads(output) + if not isinstance(payload, dict): + raise TypeError("ffprobe root must be an object") + stream_items = payload.get("streams", ()) + if not isinstance(stream_items, list): + raise TypeError("ffprobe streams must be a list") + streams = tuple( + self._stream(item) + for item in stream_items + if isinstance(item, dict) + ) + if len(streams) != len(stream_items): + raise TypeError("ffprobe stream entries must be objects") + format_data = payload["format"] + if not isinstance(format_data, dict): + raise TypeError("ffprobe format must be an object") + formats = tuple( + part.strip() + for part in str(format_data["format_name"]).split(",") + if part.strip() + ) + duration = float(format_data["duration"]) + except ( + AttributeError, + KeyError, + OverflowError, + TypeError, + UnicodeDecodeError, + ValueError, + json.JSONDecodeError, + ) as exc: + raise InvalidMediaError("The media probe result is invalid.") from exc + if ( + not formats + or not math.isfinite(duration) + or duration <= 0 + or len(streams) > 128 + or not any(stream.kind == "video" for stream in streams) + ): + raise InvalidMediaError("The file is not a valid supported video.") + container = self._preferred_container(formats, path) + return MediaProbe( + detected_mime_type=self._MIME_TYPES.get( + container, + mimetypes.guess_type(path.name)[0] + or "application/octet-stream", + ), + container=container, + duration_seconds=duration, + streams=streams, + ) + + def _output(self, path: Path) -> bytes: + try: + with ( + tempfile.TemporaryFile() as stdout, + tempfile.TemporaryFile() as stderr, + ): + subprocess.run( + [ + self.executable, + "-v", + "error", + "-protocol_whitelist", + "file,pipe", + "-show_entries", + ( + "format=format_name,duration:" + "stream=index,codec_type,codec_name,width,height," + "channels,sample_rate" + ), + "-of", + "json", + str(path), + ], + check=True, + stdout=stdout, + stderr=stderr, + timeout=self.timeout_seconds, + ) + stdout.seek(0, os.SEEK_END) + if stdout.tell() > 1024 * 1024: + raise InvalidMediaError( + "The media probe result is unexpectedly large." + ) + stdout.seek(0) + return stdout.read() + except OSError as exc: + raise MediaProbeUnavailableError( + "The configured ffprobe executable is unavailable." + ) from exc + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: + raise InvalidMediaError( + "The media could not be validated by ffprobe." + ) from exc + + @staticmethod + def _stream(item: dict[str, Any]) -> MediaStream: + def optional_int(name: str) -> int | None: + value = item.get(name) + return None if value in (None, "", 0, "0") else int(value) + + return MediaStream( + index=int(item["index"]), + kind=str(item["codec_type"]), + codec=str(item["codec_name"]), + width=optional_int("width"), + height=optional_int("height"), + channels=optional_int("channels"), + sample_rate=optional_int("sample_rate"), + ) + + @staticmethod + def _preferred_container( + formats: tuple[str, ...], + path: Path, + ) -> str: + if "matroska" in formats and "webm" in formats: + return "webm" if path.suffix.lower() == ".webm" else "matroska" + for candidate in ("matroska", "webm", "mp4", "mov", "avi", "mpeg"): + if candidate in formats: + return candidate + return formats[0] diff --git a/src/vidxp/infrastructure/local_objects.py b/src/vidxp/infrastructure/local_objects.py new file mode 100644 index 0000000..7c7bf24 --- /dev/null +++ b/src/vidxp/infrastructure/local_objects.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import os +from pathlib import Path + +from vidxp.core.manifest import sha256_file +from vidxp.infrastructure.local_files import ( + durable_replace, + durable_unlink, + prepare_managed_destination, + resolve_managed_file, +) + + +class LocalObjectStore: + """Shared confined-file lifecycle for local media and artifacts.""" + + def __init__(self, root: Path) -> None: + self.root = root + + def publish( + self, + source: Path, + storage_key: str, + *, + expected_sha256: str | None, + replace_corrupt: bool, + ) -> tuple[Path, str, int]: + if not source.is_file() or source.stat().st_size <= 0: + raise FileNotFoundError("The staged object was not created.") + with source.open("r+b") as handle: + os.fsync(handle.fileno()) + checksum = sha256_file(source) + if expected_sha256 is not None and checksum != expected_sha256: + raise RuntimeError("The staged object checksum changed.") + + destination = prepare_managed_destination(self.root, storage_key) + created = not destination.exists() + published = False + try: + if destination.exists(): + if sha256_file(destination) == checksum: + durable_unlink(source) + elif replace_corrupt: + durable_replace(source, destination) + published = True + else: + raise FileExistsError( + "The managed object destination already exists." + ) + else: + durable_replace(source, destination) + published = True + resolved = destination.resolve(strict=True) + byte_size = resolved.stat().st_size + return resolved, checksum, byte_size + except BaseException: + if created and published: + durable_unlink(destination, missing_ok=True) + raise + + def verify( + self, + storage_key: str, + *, + sha256: str, + byte_size: int, + ) -> Path: + path = self.resolve(storage_key) + if path.stat().st_size != byte_size: + raise RuntimeError("Managed object has an unexpected byte size.") + if sha256_file(path) != sha256: + raise RuntimeError("Managed object has an unexpected checksum.") + return path + + def delete(self, storage_key: str) -> None: + durable_unlink(self.resolve(storage_key)) + + def resolve(self, storage_key: str) -> Path: + return resolve_managed_file(self.root, storage_key) diff --git a/src/vidxp/infrastructure/local_snapshots.py b/src/vidxp/infrastructure/local_snapshots.py new file mode 100644 index 0000000..e364f27 --- /dev/null +++ b/src/vidxp/infrastructure/local_snapshots.py @@ -0,0 +1,482 @@ +from __future__ import annotations + +from contextlib import contextmanager +from dataclasses import asdict +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterator, Mapping +from uuid import uuid4 + +from filelock import FileLock, Timeout +from pydantic import ValidationError + +from vidxp.core.contracts import ( + IndexConfig, + IndexSchemaError, +) +from vidxp.core.manifest import MANIFEST_FILE, sha256_file, write_json_atomic +from vidxp.core.generations import CompletedGenerationManifest +from vidxp.core.snapshots import ( + ActiveSnapshotPointer, + GenerationReference, + IndexSnapshot, +) +from vidxp.index_state import ( + IndexNotReadyError, + IndexingInProgressError, + snapshot_status, +) +from vidxp.repository_layout import RepositoryLayout + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +class LocalSnapshotRepository: + """Durable snapshot metadata for one local index repository. + + The repository is intended for a local filesystem with working advisory + file locks and atomic same-filesystem replacement semantics. + """ + + def __init__(self, indexes: str | Path): + index_path = Path(indexes) + self.layout = RepositoryLayout(root=index_path.parent) + if index_path != self.layout.indexes: + raise ValueError( + "LocalSnapshotRepository requires a RepositoryLayout indexes " + "directory." + ) + + @property + def indexes(self) -> Path: + return self.layout.indexes + + @property + def store(self) -> Path: + return self.layout.index_store + + @property + def generations(self) -> Path: + return self.layout.generations + + @property + def snapshots(self) -> Path: + return self.layout.snapshots + + @property + def active_pointer(self) -> Path: + return self.layout.active_snapshot + + @property + def lease_path(self) -> Path: + return self.layout.index_lease + + def ensure_directories(self) -> None: + for path in ( + self.indexes, + self.store, + self.generations, + self.snapshots, + ): + path.mkdir(parents=True, exist_ok=True) + + @contextmanager + def lease(self) -> Iterator[None]: + self.ensure_directories() + lock = FileLock(self.lease_path) + try: + lock.acquire(timeout=0) + except Timeout as exc: + raise IndexingInProgressError( + f"An index mutation is already active for {self.indexes}." + ) from exc + try: + yield + finally: + lock.release() + + def mutation_in_progress(self) -> bool: + self.ensure_directories() + lock = FileLock(self.lease_path) + try: + lock.acquire(timeout=0) + except Timeout: + return True + lock.release() + return False + + def new_generation_id(self) -> str: + return uuid4().hex + + def generation_directory(self, generation_id: str) -> Path: + return self.generations / generation_id + + def _snapshot_path(self, snapshot_id: str) -> Path: + return self.snapshots / f"{snapshot_id}.json" + + def read_active(self, *, required: bool = False) -> IndexSnapshot | None: + resolved = self._read_active(required=required) + return None if resolved is None else resolved[1] + + def _read_active( + self, + *, + required: bool = False, + ) -> tuple[ActiveSnapshotPointer, IndexSnapshot] | None: + if not self.active_pointer.is_file(): + if required: + raise IndexNotReadyError( + "No active index snapshot was found. Index media first." + ) + return None + try: + pointer = ActiveSnapshotPointer.model_validate_json( + self.active_pointer.read_text(encoding="utf-8") + ) + snapshot = self.read_snapshot( + pointer.snapshot_id, + expected_sha256=pointer.snapshot_sha256, + ) + return pointer, snapshot + except IndexSchemaError: + raise + except (OSError, ValueError, ValidationError) as exc: + raise IndexSchemaError( + "The active snapshot metadata is invalid." + ) from exc + + def read_snapshot( + self, + snapshot_id: str, + *, + expected_sha256: str | None = None, + ) -> IndexSnapshot: + snapshot_path = self._snapshot_path(snapshot_id) + if not snapshot_path.is_file(): + raise IndexSchemaError( + f"Index snapshot {snapshot_id} is missing." + ) + if ( + expected_sha256 is not None + and sha256_file(snapshot_path) != expected_sha256 + ): + raise IndexSchemaError( + f"Index snapshot {snapshot_id} failed integrity validation." + ) + try: + snapshot = IndexSnapshot.model_validate_json( + snapshot_path.read_text(encoding="utf-8") + ) + except (OSError, ValueError, ValidationError) as exc: + raise IndexSchemaError( + f"Index snapshot {snapshot_id} is invalid." + ) from exc + if snapshot.snapshot_id != snapshot_id: + raise IndexSchemaError( + "The snapshot filename and document identifier differ." + ) + self._validate_generations(snapshot) + return snapshot + + def _validate_generations(self, snapshot: IndexSnapshot) -> None: + for media_id, reference in snapshot.generations.items(): + if reference.config_fingerprint != snapshot.config_fingerprint: + raise IndexSchemaError( + f"Generation metadata for {media_id!r} has a " + "different index profile." + ) + self.validate_generation(reference) + + def validate_generation( + self, + reference: GenerationReference, + ) -> CompletedGenerationManifest: + manifest_path = ( + self.generation_directory(reference.generation_id) / MANIFEST_FILE + ) + if not manifest_path.is_file(): + raise IndexSchemaError( + f"Manifest for generation {reference.generation_id} is missing." + ) + if sha256_file(manifest_path) != reference.manifest_sha256: + raise IndexSchemaError( + f"Manifest for generation {reference.generation_id} " + "failed integrity validation." + ) + try: + manifest = CompletedGenerationManifest.model_validate_json( + manifest_path.read_text(encoding="utf-8") + ) + except (OSError, ValueError, ValidationError) as exc: + raise IndexSchemaError( + f"Manifest for generation {reference.generation_id} is invalid." + ) from exc + if ( + manifest.generation_id != reference.generation_id + or manifest.config_fingerprint != reference.config_fingerprint + ): + raise IndexSchemaError( + f"Manifest for generation {reference.generation_id} " + "does not describe a completed compatible generation." + ) + source = manifest.inputs.get(reference.media_id) + if source is None or source.sha256 != reference.input_sha256: + raise IndexSchemaError( + f"Input checksum for generation {reference.generation_id} " + "does not match its snapshot reference." + ) + if ( + manifest.store_size_bytes_at_commit + != reference.store_size_bytes_at_commit + ): + raise IndexSchemaError( + f"Index size for generation {reference.generation_id} " + "does not match its snapshot reference." + ) + if set(manifest.completed_videos) != {reference.media_id}: + raise IndexSchemaError( + f"Generation {reference.generation_id} is not a complete " + "single-media generation." + ) + if tuple(manifest.configuration["enabled_modalities"]) != ( + reference.modalities + ) or dict(manifest.record_counts) != dict(reference.record_counts): + raise IndexSchemaError( + f"Modalities for generation {reference.generation_id} " + "do not match its snapshot reference." + ) + return manifest + + def generation_reference( + self, + *, + generation_id: str, + media_id: str, + ) -> GenerationReference: + manifest_path = self.generation_directory(generation_id) / MANIFEST_FILE + try: + manifest = CompletedGenerationManifest.model_validate_json( + manifest_path.read_text(encoding="utf-8") + ) + reference = GenerationReference( + generation_id=generation_id, + media_id=media_id, + manifest_sha256=sha256_file(manifest_path), + input_sha256=manifest.inputs[media_id].sha256, + config_fingerprint=manifest.config_fingerprint, + modalities=tuple( + manifest.configuration["enabled_modalities"] + ), + record_counts=dict(manifest.record_counts), + store_size_bytes_at_commit=( + manifest.store_size_bytes_at_commit + ), + ) + except (OSError, KeyError, TypeError, ValueError, ValidationError) as exc: + raise IndexSchemaError( + f"Completed generation {generation_id} has an invalid manifest." + ) from exc + self.validate_generation(reference) + return reference + + def publish_generation( + self, + reference: GenerationReference, + config: IndexConfig, + ) -> IndexSnapshot: + active = self.require_compatible_profile( + media_id=reference.media_id, + config_fingerprint=reference.config_fingerprint, + ) + generations = dict(active.generations) if active is not None else {} + other_media = set(generations) - {reference.media_id} + generations[reference.media_id] = reference + configuration = ( + dict(active.configuration) + if active is not None and other_media + else self.snapshot_configuration(config) + ) + return self._publish( + generations=generations, + config_fingerprint=reference.config_fingerprint, + configuration=configuration, + ) + + def require_compatible_profile( + self, + *, + media_id: str, + config_fingerprint: str, + ) -> IndexSnapshot | None: + active = self.read_active() + if active is None: + return None + other_media = set(active.generations) - {media_id} + if ( + other_media + and active.config_fingerprint != config_fingerprint + ): + raise IndexSchemaError( + "All media in one active snapshot must use the same index " + "profile. Re-index or clear the other media first." + ) + return active + + def remove(self, media_id: str) -> bool: + active = self.read_active() + if active is None or media_id not in active.generations: + return False + generations = dict(active.generations) + del generations[media_id] + self._publish( + generations=generations, + config_fingerprint=active.config_fingerprint, + configuration=dict(active.configuration), + ) + return True + + def clear(self) -> bool: + active = self.read_active() + if active is None or not active.generations: + return False + self._publish( + generations={}, + config_fingerprint=active.config_fingerprint, + configuration=dict(active.configuration), + ) + return True + + def _publish( + self, + *, + generations: Mapping[str, GenerationReference], + config_fingerprint: str, + configuration: dict[str, Any], + ) -> IndexSnapshot: + snapshot = IndexSnapshot( + snapshot_id=uuid4().hex, + created_at=_utc_now(), + config_fingerprint=config_fingerprint, + configuration=configuration, + generations=dict(generations), + ) + snapshot_path = self._snapshot_path(snapshot.snapshot_id) + if snapshot_path.exists(): + raise FileExistsError( + f"Snapshot {snapshot.snapshot_id} already exists." + ) + write_json_atomic( + snapshot_path, + snapshot.model_dump(mode="json"), + ) + pointer = ActiveSnapshotPointer( + snapshot_id=snapshot.snapshot_id, + snapshot_sha256=sha256_file(snapshot_path), + updated_at=_utc_now(), + ) + write_json_atomic( + self.active_pointer, + pointer.model_dump(mode="json"), + ) + return snapshot + + @staticmethod + def snapshot_configuration(config: IndexConfig) -> dict[str, Any]: + payload = asdict(config) + for key in ( + "video_id", + "output_root", + "storage_directory", + "generation_directory", + "generation_id", + "snapshot_id", + "snapshot_sha256", + ): + payload.pop(key, None) + payload["enabled_modalities"] = list(config.enabled_modalities) + payload["collection_names"] = dict(config.collection_names) + payload["capability_options"] = { + name: dict(values) + for name, values in config.capability_options.items() + } + return payload + + def active_config( + self, + *, + device: str, + ) -> tuple[IndexConfig, IndexSnapshot]: + resolved = self._read_active(required=True) + assert resolved is not None + pointer, snapshot = resolved + if not snapshot.generations: + raise IndexNotReadyError( + "The active index snapshot contains no media." + ) + return ( + self._config_for_snapshot( + snapshot, + snapshot_sha256=pointer.snapshot_sha256, + device=device, + ), + snapshot, + ) + + def config_for_snapshot( + self, + snapshot_id: str, + *, + snapshot_sha256: str, + device: str, + ) -> IndexConfig: + snapshot = self.read_snapshot( + snapshot_id, + expected_sha256=snapshot_sha256, + ) + if not snapshot.generations: + raise IndexNotReadyError( + "The requested index snapshot contains no media." + ) + return self._config_for_snapshot( + snapshot, + snapshot_sha256=snapshot_sha256, + device=device, + ) + + def _config_for_snapshot( + self, + snapshot: IndexSnapshot, + *, + snapshot_sha256: str, + device: str, + ) -> IndexConfig: + stored = dict(snapshot.configuration) + stored["enabled_modalities"] = tuple(stored["enabled_modalities"]) + stored["collection_names"] = dict(stored["collection_names"]) + stored.update( + { + "storage_directory": str(self.store), + "snapshot_id": snapshot.snapshot_id, + "snapshot_sha256": snapshot_sha256, + "device": device, + } + ) + try: + config = IndexConfig(**stored) + except (TypeError, ValueError) as exc: + raise IndexSchemaError( + "The snapshot configuration is invalid." + ) from exc + if config.fingerprint() != snapshot.config_fingerprint: + raise IndexSchemaError( + "The snapshot configuration fingerprint is invalid." + ) + return config + + def status(self) -> dict[str, Any] | None: + resolved = self._read_active() + if resolved is None: + return None + return snapshot_status(resolved[1]) diff --git a/src/vidxp/infrastructure/local_worker.py b/src/vidxp/infrastructure/local_worker.py new file mode 100644 index 0000000..08626b7 --- /dev/null +++ b/src/vidxp/infrastructure/local_worker.py @@ -0,0 +1,331 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path +from secrets import token_hex +from threading import Lock +from time import monotonic, sleep + +from filelock import FileLock, Timeout +from pydantic import ValidationError + +from vidxp.core.manifest import write_json_atomic +from vidxp.infrastructure.local_files import durable_unlink +from vidxp.settings import VidXPSettings +from vidxp.workflow_runtime import ( + LOCAL_WORKER_BOOTSTRAP_ENV, + LocalWorkerBootstrap, + LocalWorkerReady, + LocalWorkerStopRequest, + local_worker_bootstrap, + local_executor_id, + workflow_application_version, +) + + +class LocalWorkerSupervisor: + """Start one detached repository-scoped worker without owning job state.""" + + _retry_delay_seconds = 5.0 + _startup_timeout_seconds = 15.0 + + def __init__(self, settings: VidXPSettings) -> None: + self.settings = settings + self.layout = settings.layout + self._startup_lock = Lock() + self._retry_after = 0.0 + + def ensure_running(self) -> None: + with self._startup_lock: + if monotonic() < self._retry_after: + raise RuntimeError( + "The local background worker is in startup backoff." + ) + try: + self._ensure_running() + except Exception: + self._retry_after = ( + monotonic() + self._retry_delay_seconds + ) + raise + self._retry_after = 0.0 + + def _ensure_running(self) -> None: + self.layout.ensure_local_directories() + version = workflow_application_version() + bootstrap = local_worker_bootstrap(self.settings) + worker_lock_path = ( + self.layout.local_workflows / f"worker-{version}.lock" + ) + ready_path = ( + self.layout.local_workflows / f"worker-{version}.ready" + ) + stop_path = ( + self.layout.local_workflows / f"worker-{version}.stop" + ) + start_lock = FileLock( + self.layout.local_workflows / f"worker-{version}-start.lock" + ) + with start_lock: + self._remove_stale_bootstraps(version) + worker_lock = FileLock(worker_lock_path) + if self._wait_for_existing_worker( + worker_lock, + ready_path, + fingerprint=bootstrap.fingerprint, + ): + return + durable_unlink(ready_path, missing_ok=True) + durable_unlink(stop_path, missing_ok=True) + + command = [ + sys.executable, + "-m", + "vidxp.workflow_worker", + "--executor-id", + local_executor_id(self.settings), + "--role", + "all", + "--lock-file", + str(worker_lock_path.resolve()), + "--ready-file", + str(ready_path.resolve()), + "--stop-file", + str(stop_path.resolve()), + "--log-file", + str(self._worker_log(version).resolve()), + ] + bootstrap_path = self._write_bootstrap(bootstrap) + environment = { + key: value + for key, value in os.environ.items() + if not key.upper().startswith(("VIDXP_", "DBOS_")) + } + environment[LOCAL_WORKER_BOOTSTRAP_ENV] = str( + bootstrap_path.resolve() + ) + options: dict = { + "stdin": subprocess.DEVNULL, + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL, + "close_fds": True, + "env": environment, + } + if sys.platform == "win32": + options["creationflags"] = ( + subprocess.CREATE_NEW_PROCESS_GROUP + | subprocess.CREATE_NO_WINDOW + ) + else: + options["start_new_session"] = True + process = None + try: + process = subprocess.Popen( + command, + **options, + ) + self._wait_for_worker( + process, + worker_lock, + ready_path, + fingerprint=bootstrap.fingerprint, + timeout_seconds=self._startup_timeout_seconds, + ) + except Exception: + if process is not None: + self._terminate(process) + raise + finally: + durable_unlink(bootstrap_path, missing_ok=True) + + def _worker_log(self, version: str) -> Path: + return self.layout.local_workflows / f"worker-{version}.log" + + def _write_bootstrap( + self, + bootstrap: LocalWorkerBootstrap, + ) -> Path: + path = ( + self.layout.local_workflows + / ( + f".worker-{workflow_application_version()}-" + f"bootstrap-{token_hex(16)}.json" + ) + ) + descriptor = os.open( + path, + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600, + ) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as destination: + destination.write(bootstrap.model_dump_json()) + destination.flush() + os.fsync(destination.fileno()) + except BaseException: + durable_unlink(path, missing_ok=True) + raise + return path + + def _remove_stale_bootstraps(self, version: str) -> None: + pattern = f".worker-{version}-bootstrap-*.json" + for path in self.layout.local_workflows.glob(pattern): + if path.is_file(): + durable_unlink(path) + + def health(self) -> None: + version = workflow_application_version() + worker_lock = FileLock( + self.layout.local_workflows / f"worker-{version}.lock" + ) + ready_path = ( + self.layout.local_workflows / f"worker-{version}.ready" + ) + fingerprint = local_worker_bootstrap(self.settings).fingerprint + if not self._wait_for_existing_worker( + worker_lock, + ready_path, + fingerprint=fingerprint, + timeout_seconds=0.1, + ): + raise RuntimeError("The local background worker is not running.") + + def stop(self) -> bool: + version = workflow_application_version() + worker_lock = FileLock( + self.layout.local_workflows / f"worker-{version}.lock" + ) + ready_path = ( + self.layout.local_workflows / f"worker-{version}.ready" + ) + stop_path = ( + self.layout.local_workflows / f"worker-{version}.stop" + ) + try: + worker_lock.acquire(timeout=0) + except Timeout: + ready = self._load_ready(ready_path) + if ready.application_version != version: + raise RuntimeError( + "The running local worker has an invalid version identity." + ) + write_json_atomic( + stop_path, + LocalWorkerStopRequest( + pid=ready.pid, + application_version=version, + ).model_dump(mode="json"), + ) + deadline = monotonic() + 35 + while monotonic() < deadline: + try: + worker_lock.acquire(timeout=0) + except Timeout: + sleep(0.05) + continue + else: + worker_lock.release() + durable_unlink(ready_path, missing_ok=True) + durable_unlink(stop_path, missing_ok=True) + return True + raise RuntimeError( + "The local background worker did not stop in time." + ) + else: + worker_lock.release() + durable_unlink(ready_path, missing_ok=True) + durable_unlink(stop_path, missing_ok=True) + return False + + @staticmethod + def _wait_for_existing_worker( + worker_lock: FileLock, + ready_path: Path, + *, + fingerprint: str, + timeout_seconds: float = _startup_timeout_seconds, + ) -> bool: + deadline = monotonic() + timeout_seconds + while monotonic() < deadline: + try: + worker_lock.acquire(timeout=0) + except Timeout: + if ready_path.is_file(): + LocalWorkerSupervisor._validate_ready( + ready_path, + fingerprint, + ) + return True + sleep(0.05) + continue + else: + worker_lock.release() + return False + raise RuntimeError( + "The existing local background worker did not become ready." + ) + + @staticmethod + def _wait_for_worker( + process: subprocess.Popen, + worker_lock: FileLock, + ready_path: Path, + *, + fingerprint: str, + timeout_seconds: float = _startup_timeout_seconds, + ) -> None: + deadline = monotonic() + timeout_seconds + while monotonic() < deadline: + if process.poll() is not None: + raise RuntimeError( + "The local background worker exited during startup." + ) + try: + worker_lock.acquire(timeout=0) + except Timeout: + if ready_path.is_file(): + LocalWorkerSupervisor._validate_ready( + ready_path, + fingerprint, + ) + return + else: + worker_lock.release() + sleep(0.05) + raise RuntimeError( + "The local background worker did not become ready in time." + ) + + @staticmethod + def _validate_ready(ready_path: Path, fingerprint: str) -> None: + ready = LocalWorkerSupervisor._load_ready(ready_path) + if ready.fingerprint != fingerprint: + raise RuntimeError( + "The running local worker uses different execution settings " + f"(PID {ready.pid}); run `vidxp jobs stop-worker` before " + "applying the new configuration." + ) + + @staticmethod + def _load_ready(ready_path: Path) -> LocalWorkerReady: + try: + return LocalWorkerReady.model_validate_json( + ready_path.read_text(encoding="utf-8") + ) + except (OSError, ValidationError) as exc: + raise RuntimeError( + "The local background worker published invalid readiness." + ) from exc + + @staticmethod + def _terminate(process: subprocess.Popen) -> None: + if process.poll() is not None: + return + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) diff --git a/src/vidxp/infrastructure/ollama_query.py b/src/vidxp/infrastructure/ollama_query.py new file mode 100644 index 0000000..9347d04 --- /dev/null +++ b/src/vidxp/infrastructure/ollama_query.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import httpx +from openai import OpenAIError +from pydantic import ValidationError +from pydantic_ai import Agent, NativeOutput +from pydantic_ai.exceptions import AgentRunError +from pydantic_ai.models.ollama import OllamaModel +from pydantic_ai.providers.ollama import OllamaProvider + +from vidxp.application_models import ( + DraftAnswer, + QueryModelIdentity, + QueryPlan, + QueryPlanningRequest, + QuerySynthesisRequest, +) +from vidxp.ports import QueryProviderError + + +_PLANNING_INSTRUCTIONS = """ +Return exactly one search_moments step for every allowed modality, using a +short retrieval query derived from the question. Include actor_overview +exactly once when actor_overview_allowed is true. Do not invent modalities, +filters, identifiers, limits, paths, model names, or operations. +""".strip() + +_SYNTHESIS_INSTRUCTIONS = """ +Write concise factual claims supported only by the supplied evidence. Every +claim must cite one or more evidence_id values exactly as supplied. Do not +infer visual facts from similarity scores, introduce outside knowledge, or +mention evidence that was not supplied. +""".strip() + + +class OllamaQueryModel: + """Structured-output adapter for a configured self-hosted Ollama server.""" + + def __init__( + self, + *, + base_url: str, + model_name: str, + timeout_seconds: float, + output_retries: int, + http_client: httpx.AsyncClient | None = None, + ) -> None: + model = OllamaModel( + model_name, + provider=OllamaProvider( + base_url=base_url, + http_client=http_client, + ), + ) + retries = {"output": output_retries} + self._identity = QueryModelIdentity( + provider="ollama", + model=model_name, + ) + self._planner = Agent( + model, + output_type=NativeOutput(QueryPlan), + instructions=_PLANNING_INSTRUCTIONS, + retries=retries, + model_settings={ + "temperature": 0, + "max_tokens": 1024, + "timeout": timeout_seconds, + }, + ) + self._synthesizer = Agent( + model, + output_type=NativeOutput(DraftAnswer), + instructions=_SYNTHESIS_INSTRUCTIONS, + retries=retries, + model_settings={ + "temperature": 0, + "max_tokens": 2048, + "timeout": timeout_seconds, + }, + ) + + @property + def identity(self) -> QueryModelIdentity: + return self._identity + + def plan(self, request: QueryPlanningRequest) -> QueryPlan: + return self._run(self._planner, request.model_dump_json()) + + def synthesize(self, request: QuerySynthesisRequest) -> DraftAnswer: + return self._run(self._synthesizer, request.model_dump_json()) + + @staticmethod + def _run(agent: Agent, prompt: str): + try: + return agent.run_sync(prompt).output + except ( + AgentRunError, + OpenAIError, + httpx.HTTPError, + TimeoutError, + ValidationError, + ) as exc: + raise QueryProviderError( + "The configured Ollama query model is unavailable." + ) from exc diff --git a/src/vidxp/infrastructure/sql_catalog.py b/src/vidxp/infrastructure/sql_catalog.py new file mode 100644 index 0000000..695505a --- /dev/null +++ b/src/vidxp/infrastructure/sql_catalog.py @@ -0,0 +1,698 @@ +from __future__ import annotations + +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from datetime import datetime +from typing import Any + +from sqlalchemy import Engine, create_engine, delete, event, func, insert, select, update +from sqlalchemy.engine import Connection +from sqlalchemy.exc import IntegrityError +from sqlalchemy.pool import NullPool + +from vidxp.core.artifacts import ArtifactRecord, ArtifactState +from vidxp.core.media import MediaRecord, utc_now +from vidxp.core.uploads import UploadIntentRecord, UploadState +from vidxp.infrastructure.sql_tables import ( + artifact_requests, + artifacts, + media, + media_import_requests, + metadata, + upload_intents, + upload_quota, +) + +_RESERVED_UPLOAD_STATES = { + UploadState.pending.value, + UploadState.accepted.value, + UploadState.processing.value, + UploadState.failed.value, +} +_UPLOAD_QUOTA_ID = "1" + + +class UploadQuotaExceededError(RuntimeError): + """Raised when an atomic upload reservation exceeds its quota.""" + + +def _payload(value: Any) -> dict[str, Any]: + if not isinstance(value, dict): + raise RuntimeError("The relational catalog payload is invalid.") + return value + + +def _record(model: Any, value: Any) -> Any: + return model.model_validate(_payload(value), strict=False) + + +def _upload_record(row: Any) -> UploadIntentRecord: + return UploadIntentRecord( + intent_id=row.intent_id, + request_key=row.request_key, + original_filename=row.original_filename, + byte_size=row.byte_size, + declared_mime_type=row.declared_mime_type, + state=row.state, + created_at=datetime.fromisoformat(row.created_at), + expires_at=datetime.fromisoformat(row.expires_at), + upload_id=row.upload_id, + job_id=row.job_id, + media_id=row.media_id, + ) + + +class SQLCatalog: + """SQLAlchemy Core catalog shared by SQLite and PostgreSQL adapters.""" + + def __init__( + self, + database_url: str, + *, + initialize: bool = False, + engine: Engine | None = None, + ) -> None: + sqlite = database_url.startswith("sqlite:") + self.engine = engine or create_engine( + database_url, + pool_pre_ping=True, + **( + { + "poolclass": NullPool, + "connect_args": {"timeout": 30}, + } + if sqlite + else {} + ), + ) + self._owns_engine = engine is None + if sqlite and engine is None: + event.listen(self.engine, "connect", self._configure_sqlite) + if initialize: + metadata.create_all(self.engine) + + def close(self) -> None: + if self._owns_engine: + self.engine.dispose() + + def health(self) -> None: + with self.engine.connect() as connection: + connection.execute(select(1)).scalar_one() + + @contextmanager + def transaction(self) -> Iterator[Connection]: + with self._write_transaction() as connection: + yield connection + + @staticmethod + def _configure_sqlite(dbapi_connection: Any, _record: Any) -> None: + cursor = dbapi_connection.cursor() + try: + cursor.execute("PRAGMA foreign_keys = ON") + cursor.execute("PRAGMA busy_timeout = 30000") + finally: + cursor.close() + + @contextmanager + def _write_transaction(self) -> Iterator[Connection]: + with self.engine.connect() as connection: + if self.engine.dialect.name == "sqlite": + connection.exec_driver_sql("BEGIN IMMEDIATE") + try: + yield connection + except BaseException: + connection.rollback() + raise + else: + connection.commit() + else: + with connection.begin(): + yield connection + + def get_media(self, media_id: str) -> MediaRecord | None: + with self.engine.connect() as connection: + payload = connection.execute( + select(media.c.payload).where(media.c.media_id == media_id) + ).scalar_one_or_none() + return ( + None + if payload is None + else _record(MediaRecord, payload) + ) + + def get_media_by_checksum(self, sha256: str) -> MediaRecord | None: + with self.engine.connect() as connection: + payload = connection.execute( + select(media.c.payload).where(media.c.sha256 == sha256) + ).scalar_one_or_none() + return ( + None + if payload is None + else _record(MediaRecord, payload) + ) + + def put_media(self, record: MediaRecord) -> MediaRecord: + values = { + "media_id": record.media_id, + "sha256": record.sha256, + "created_at": record.created_at.isoformat(), + "payload": record.model_dump(mode="json"), + } + with self._write_transaction() as connection: + existing = self._media_by_id(connection, record.media_id) + if existing is not None: + if existing != record: + raise FileExistsError( + f"Media {record.media_id} already has another record." + ) + return existing + checksum_match = self._media_by_checksum(connection, record.sha256) + if checksum_match is not None: + return checksum_match + try: + with connection.begin_nested(): + connection.execute(insert(media).values(**values)) + except IntegrityError: + authoritative = self._media_by_checksum( + connection, + record.sha256, + ) + if authoritative is not None: + return authoritative + existing = self._media_by_id(connection, record.media_id) + if existing == record: + return existing + raise + return record + + @staticmethod + def _media_by_id( + connection: Connection, + media_id: str, + ) -> MediaRecord | None: + payload = connection.execute( + select(media.c.payload).where(media.c.media_id == media_id) + ).scalar_one_or_none() + return ( + None + if payload is None + else _record(MediaRecord, payload) + ) + + @staticmethod + def _media_by_checksum( + connection: Connection, + sha256: str, + ) -> MediaRecord | None: + payload = connection.execute( + select(media.c.payload).where(media.c.sha256 == sha256) + ).scalar_one_or_none() + return ( + None + if payload is None + else _record(MediaRecord, payload) + ) + + def list_media( + self, + *, + limit: int, + offset: int = 0, + ) -> tuple[MediaRecord, ...]: + if limit <= 0 or offset < 0: + raise ValueError("limit must be positive and offset nonnegative") + with self.engine.connect() as connection: + payloads = connection.execute( + select(media.c.payload) + .order_by(media.c.created_at, media.c.media_id) + .limit(limit) + .offset(offset) + ).scalars() + return tuple( + _record(MediaRecord, payload) + for payload in payloads + ) + + def count_media(self) -> int: + with self.engine.connect() as connection: + return int( + connection.execute( + select(func.count()).select_from(media) + ).scalar_one() + ) + + def reserve_media_import( + self, + request_key: str, + request_fingerprint: str, + ) -> MediaRecord | None: + with self._write_transaction() as connection: + row = connection.execute( + select( + media_import_requests.c.request_fingerprint, + media_import_requests.c.media_id, + ) + .where(media_import_requests.c.request_key == request_key) + .with_for_update() + ).one_or_none() + if row is None: + try: + with connection.begin_nested(): + connection.execute( + insert(media_import_requests).values( + request_key=request_key, + request_fingerprint=request_fingerprint, + ) + ) + return None + except IntegrityError: + row = connection.execute( + select( + media_import_requests.c.request_fingerprint, + media_import_requests.c.media_id, + ) + .where( + media_import_requests.c.request_key == request_key + ) + .with_for_update() + ).one() + if row.request_fingerprint != request_fingerprint: + raise FileExistsError( + "The media import key is bound to different content." + ) + if row.media_id is None: + return None + stored = self._media_by_id(connection, row.media_id) + if stored is None: + raise RuntimeError( + "A completed media import references missing media." + ) + return stored + + def complete_media_import( + self, + request_key: str, + request_fingerprint: str, + record: MediaRecord, + ) -> None: + with self._write_transaction() as connection: + row = connection.execute( + select( + media_import_requests.c.request_fingerprint, + media_import_requests.c.media_id, + ) + .where(media_import_requests.c.request_key == request_key) + .with_for_update() + ).one_or_none() + if row is None or row.request_fingerprint != request_fingerprint: + raise FileExistsError( + "The media import reservation does not match the content." + ) + if row.media_id is not None and row.media_id != record.media_id: + raise FileExistsError( + "The media import key is already completed." + ) + connection.execute( + update(media_import_requests) + .where(media_import_requests.c.request_key == request_key) + .values(media_id=record.media_id) + ) + + def get_artifact(self, artifact_id: str) -> ArtifactRecord | None: + with self.engine.connect() as connection: + payload = connection.execute( + select(artifacts.c.payload).where( + artifacts.c.artifact_id == artifact_id + ) + ).scalar_one_or_none() + return ( + None + if payload is None + else _record(ArtifactRecord, payload) + ) + + def get_artifact_by_request( + self, + request_key: str, + ) -> ArtifactRecord | None: + with self.engine.connect() as connection: + payload = connection.execute( + select(artifacts.c.payload) + .select_from( + artifact_requests.join( + artifacts, + artifact_requests.c.artifact_id + == artifacts.c.artifact_id, + ) + ) + .where(artifact_requests.c.request_key == request_key) + ).scalar_one_or_none() + if payload is None: + return None + record = _record(ArtifactRecord, payload) + if ( + record.state != ArtifactState.ready + or ( + record.expires_at is not None + and record.expires_at <= utc_now() + ) + ): + return None + return record + + def put_artifact(self, record: ArtifactRecord) -> ArtifactRecord: + with self._write_transaction() as connection: + request_match = self._artifact_by_request( + connection, + record.request_key, + ) + if request_match is not None: + if ( + request_match.state == ArtifactState.ready + and ( + request_match.expires_at is None + or request_match.expires_at > utc_now() + ) + ): + return request_match + connection.execute( + delete(artifact_requests).where( + artifact_requests.c.request_key == record.request_key + ) + ) + existing = self._artifact_by_id( + connection, + record.artifact_id, + ) + if existing is not None: + if existing != record: + raise FileExistsError( + f"Artifact {record.artifact_id} already exists." + ) + return existing + connection.execute( + insert(artifacts).values( + artifact_id=record.artifact_id, + media_id=record.media_id, + created_at=record.created_at.isoformat(), + payload=record.model_dump(mode="json"), + ) + ) + connection.execute( + insert(artifact_requests).values( + request_key=record.request_key, + artifact_id=record.artifact_id, + ) + ) + return record + + @staticmethod + def _artifact_by_id( + connection: Connection, + artifact_id: str, + ) -> ArtifactRecord | None: + payload = connection.execute( + select(artifacts.c.payload).where( + artifacts.c.artifact_id == artifact_id + ) + ).scalar_one_or_none() + return ( + None + if payload is None + else _record(ArtifactRecord, payload) + ) + + @staticmethod + def _artifact_by_request( + connection: Connection, + request_key: str, + ) -> ArtifactRecord | None: + payload = connection.execute( + select(artifacts.c.payload) + .select_from( + artifact_requests.join( + artifacts, + artifact_requests.c.artifact_id == artifacts.c.artifact_id, + ) + ) + .where(artifact_requests.c.request_key == request_key) + ).scalar_one_or_none() + return ( + None + if payload is None + else _record(ArtifactRecord, payload) + ) + + def invalidate_artifact_request( + self, + request_key: str, + artifact_id: str, + ) -> None: + with self._write_transaction() as connection: + connection.execute( + delete(artifact_requests).where( + artifact_requests.c.request_key == request_key, + artifact_requests.c.artifact_id == artifact_id, + ) + ) + + def create_upload_intent( + self, + record: UploadIntentRecord, + *, + quota_limit: int, + ) -> UploadIntentRecord: + with self._write_transaction() as connection: + self._reserve_upload_quota( + connection, + byte_size=record.byte_size, + quota_limit=quota_limit, + ) + connection.execute( + insert(upload_intents).values( + intent_id=record.intent_id, + request_key=record.request_key, + original_filename=record.original_filename, + byte_size=record.byte_size, + declared_mime_type=record.declared_mime_type, + state=record.state.value, + created_at=record.created_at.isoformat(), + expires_at=record.expires_at.isoformat(), + upload_id=record.upload_id, + job_id=record.job_id, + media_id=record.media_id, + ) + ) + return record + + @staticmethod + def _reserve_upload_quota( + connection: Connection, + *, + byte_size: int, + quota_limit: int, + ) -> None: + row = connection.execute( + select(upload_quota.c.reserved_bytes) + .where(upload_quota.c.singleton_id == _UPLOAD_QUOTA_ID) + .with_for_update() + ).one_or_none() + if row is None: + try: + with connection.begin_nested(): + connection.execute( + insert(upload_quota).values( + singleton_id=_UPLOAD_QUOTA_ID, + reserved_bytes=0, + ) + ) + except IntegrityError: + pass + row = connection.execute( + select(upload_quota.c.reserved_bytes) + .where(upload_quota.c.singleton_id == _UPLOAD_QUOTA_ID) + .with_for_update() + ).one() + reserved = int(row.reserved_bytes) + if reserved + byte_size > quota_limit: + raise UploadQuotaExceededError + connection.execute( + update(upload_quota) + .where(upload_quota.c.singleton_id == _UPLOAD_QUOTA_ID) + .values(reserved_bytes=reserved + byte_size) + ) + + def get_upload_intent_by_request( + self, + request_key: str, + ) -> UploadIntentRecord | None: + with self.engine.connect() as connection: + row = connection.execute( + select(upload_intents).where( + upload_intents.c.request_key == request_key + ) + ).one_or_none() + return None if row is None else _upload_record(row) + + def get_upload_intent_by_upload_id( + self, + upload_id: str, + *, + connection: Connection | None = None, + for_update: bool = False, + ) -> UploadIntentRecord | None: + statement = select(upload_intents).where( + upload_intents.c.upload_id == upload_id + ) + if for_update: + statement = statement.with_for_update() + if connection is not None: + row = connection.execute(statement).one_or_none() + else: + with self.engine.connect() as active: + row = active.execute(statement).one_or_none() + return None if row is None else _upload_record(row) + + def get_upload_intent( + self, + intent_id: str, + *, + connection: Connection | None = None, + for_update: bool = False, + ) -> UploadIntentRecord | None: + statement = select(upload_intents).where( + upload_intents.c.intent_id == intent_id + ) + if for_update: + statement = statement.with_for_update() + if connection is not None: + row = connection.execute(statement).one_or_none() + else: + with self.engine.connect() as active: + row = active.execute(statement).one_or_none() + return None if row is None else _upload_record(row) + + def update_upload( + self, + intent_id: str, + *, + state: UploadState, + connection: Connection, + upload_id: str | None = None, + job_id: str | None = None, + media_id: str | None = None, + clear_upload_id: bool = False, + expected_states: set[UploadState] | None = None, + ) -> bool: + current = connection.execute( + select( + upload_intents.c.byte_size, + upload_intents.c.state, + ) + .where(upload_intents.c.intent_id == intent_id) + .with_for_update() + ).one_or_none() + if current is None: + raise RuntimeError("The upload intent update was lost.") + if ( + expected_states is not None + and UploadState(current.state) not in expected_states + ): + return False + values: dict[str, Any] = {"state": state.value} + if clear_upload_id: + values["upload_id"] = None + elif upload_id is not None: + values["upload_id"] = upload_id + if job_id is not None: + values["job_id"] = job_id + if media_id is not None: + values["media_id"] = media_id + result = connection.execute( + update(upload_intents) + .where(upload_intents.c.intent_id == intent_id) + .values(**values) + ) + if result.rowcount != 1: + raise RuntimeError("The upload intent update was lost.") + if ( + current.state in _RESERVED_UPLOAD_STATES + and state.value not in _RESERVED_UPLOAD_STATES + ): + quota = connection.execute( + select(upload_quota.c.reserved_bytes) + .where( + upload_quota.c.singleton_id == _UPLOAD_QUOTA_ID, + ) + .with_for_update() + ).scalar_one() + connection.execute( + update(upload_quota) + .where( + upload_quota.c.singleton_id == _UPLOAD_QUOTA_ID, + ) + .values( + reserved_bytes=max(0, int(quota) - current.byte_size) + ) + ) + return True + + def expired_uploads( + self, + *, + now: datetime, + ) -> tuple[UploadIntentRecord, ...]: + with self.engine.connect() as connection: + rows = connection.execute( + select(upload_intents).where( + upload_intents.c.expires_at <= now.isoformat(), + upload_intents.c.state.in_( + ( + UploadState.pending.value, + UploadState.accepted.value, + UploadState.failed.value, + ) + ), + ) + ) + return tuple(_upload_record(row) for row in rows) + + def recoverable_uploads(self) -> tuple[UploadIntentRecord, ...]: + with self.engine.connect() as connection: + rows = connection.execute( + select(upload_intents).where( + upload_intents.c.state == UploadState.accepted.value + ) + ) + return tuple(_upload_record(row) for row in rows) + + def cleanup_uploads(self) -> tuple[UploadIntentRecord, ...]: + with self.engine.connect() as connection: + rows = connection.execute( + select(upload_intents).where( + upload_intents.c.upload_id.is_not(None), + upload_intents.c.state.in_( + ( + UploadState.ready.value, + UploadState.expired.value, + ) + ), + ) + ) + return tuple(_upload_record(row) for row in rows) + + def processing_uploads(self) -> tuple[UploadIntentRecord, ...]: + with self.engine.connect() as connection: + rows = connection.execute( + select(upload_intents).where( + upload_intents.c.state == UploadState.processing.value + ) + ) + return tuple(_upload_record(row) for row in rows) + + def with_upload_transaction( + self, + operation: Callable[[Connection], Any], + ) -> Any: + with self._write_transaction() as connection: + return operation(connection) diff --git a/src/vidxp/infrastructure/sql_snapshots.py b/src/vidxp/infrastructure/sql_snapshots.py new file mode 100644 index 0000000..43a1947 --- /dev/null +++ b/src/vidxp/infrastructure/sql_snapshots.py @@ -0,0 +1,371 @@ +from __future__ import annotations + +import hashlib +import json +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterator +from uuid import uuid4 + +from sqlalchemy import Engine, insert, select, text, update +from sqlalchemy.engine import Connection +from sqlalchemy.exc import IntegrityError +from pydantic import ValidationError + +from vidxp.core.contracts import IndexConfig, IndexSchemaError +from vidxp.core.generations import CompletedGenerationManifest +from vidxp.core.manifest import MANIFEST_FILE, sha256_file +from vidxp.core.snapshots import GenerationReference, IndexSnapshot +from vidxp.index_state import ( + IndexNotReadyError, + IndexingInProgressError, + snapshot_status, +) +from vidxp.infrastructure.local_snapshots import LocalSnapshotRepository +from vidxp.infrastructure.sql_tables import ( + index_generations, + index_snapshots, + index_state, +) + +_INDEX_STATE_ID = "1" +_INDEX_LOCK_IDENTITY = "vidxp:index" + + +def _checksum(snapshot: IndexSnapshot) -> str: + payload = json.dumps( + snapshot.model_dump(mode="json"), + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + +class SQLSnapshotRepository(LocalSnapshotRepository): + """PostgreSQL-authoritative snapshots with shared generation manifests.""" + + def __init__( + self, + indexes: str | Path, + *, + engine: Engine, + ) -> None: + super().__init__(indexes) + self.engine = engine + + @contextmanager + def lease(self) -> Iterator[None]: + self.ensure_directories() + with self.engine.connect() as connection: + acquired = connection.execute( + text("SELECT pg_try_advisory_lock(hashtext(:identity))"), + {"identity": _INDEX_LOCK_IDENTITY}, + ).scalar_one() + if not acquired: + raise IndexingInProgressError( + "An index mutation is already active for this repository." + ) + try: + yield + finally: + connection.execute( + text("SELECT pg_advisory_unlock(hashtext(:identity))"), + {"identity": _INDEX_LOCK_IDENTITY}, + ) + + def mutation_in_progress(self) -> bool: + with self.engine.connect() as connection: + acquired = connection.execute( + text("SELECT pg_try_advisory_lock(hashtext(:identity))"), + {"identity": _INDEX_LOCK_IDENTITY}, + ).scalar_one() + if acquired: + connection.execute( + text("SELECT pg_advisory_unlock(hashtext(:identity))"), + {"identity": _INDEX_LOCK_IDENTITY}, + ) + return not bool(acquired) + + @staticmethod + def _ensure_index_state(connection: Connection) -> None: + if connection.execute( + select(index_state.c.singleton_id).where( + index_state.c.singleton_id == _INDEX_STATE_ID + ) + ).scalar_one_or_none() is not None: + return + try: + with connection.begin_nested(): + connection.execute( + insert(index_state).values(singleton_id=_INDEX_STATE_ID) + ) + except IntegrityError: + pass + + def read_active(self, *, required: bool = False) -> IndexSnapshot | None: + with self.engine.connect() as connection: + row = connection.execute( + select( + index_state.c.active_snapshot_id, + index_state.c.active_snapshot_sha256, + ).where(index_state.c.singleton_id == _INDEX_STATE_ID) + ).one_or_none() + if row is None or row.active_snapshot_id is None: + if required: + raise IndexNotReadyError( + "No active index snapshot was found. Index media first." + ) + return None + return self._read_snapshot( + connection, + row.active_snapshot_id, + expected_sha256=row.active_snapshot_sha256, + ) + + def read_snapshot( + self, + snapshot_id: str, + *, + expected_sha256: str | None = None, + ) -> IndexSnapshot: + with self.engine.connect() as connection: + return self._read_snapshot( + connection, + snapshot_id, + expected_sha256=expected_sha256, + ) + + def _read_snapshot( + self, + connection: Connection, + snapshot_id: str, + *, + expected_sha256: str | None, + ) -> IndexSnapshot: + row = connection.execute( + select( + index_snapshots.c.sha256, + index_snapshots.c.payload, + ).where(index_snapshots.c.snapshot_id == snapshot_id) + ).one_or_none() + if row is None: + raise IndexSchemaError(f"Index snapshot {snapshot_id} is missing.") + if expected_sha256 is not None and row.sha256 != expected_sha256: + raise IndexSchemaError( + f"Index snapshot {snapshot_id} failed integrity validation." + ) + try: + snapshot = IndexSnapshot.model_validate(row.payload, strict=False) + except ValueError as exc: + raise IndexSchemaError( + f"Index snapshot {snapshot_id} is invalid." + ) from exc + if snapshot.snapshot_id != snapshot_id or _checksum(snapshot) != row.sha256: + raise IndexSchemaError( + f"Index snapshot {snapshot_id} failed integrity validation." + ) + self._validate_generations(snapshot) + return snapshot + + def validate_generation( + self, + reference: GenerationReference, + ): + with self.engine.connect() as connection: + payload = connection.execute( + select(index_generations.c.payload).where( + index_generations.c.generation_id + == reference.generation_id, + ) + ).scalar_one_or_none() + if ( + payload is None + or GenerationReference.model_validate(payload, strict=False) + != reference + ): + raise IndexSchemaError( + f"Generation {reference.generation_id} is not published." + ) + return super().validate_generation(reference) + + def generation_reference( + self, + *, + generation_id: str, + media_id: str, + ) -> GenerationReference: + manifest_path = self.generation_directory(generation_id) / MANIFEST_FILE + try: + manifest = CompletedGenerationManifest.model_validate_json( + manifest_path.read_text(encoding="utf-8") + ) + reference = GenerationReference( + generation_id=generation_id, + media_id=media_id, + manifest_sha256=sha256_file(manifest_path), + input_sha256=manifest.inputs[media_id].sha256, + config_fingerprint=manifest.config_fingerprint, + modalities=tuple( + manifest.configuration["enabled_modalities"] + ), + record_counts=dict(manifest.record_counts), + store_size_bytes_at_commit=( + manifest.store_size_bytes_at_commit + ), + ) + except (OSError, KeyError, TypeError, ValueError, ValidationError) as exc: + raise IndexSchemaError( + f"Completed generation {generation_id} has an invalid manifest." + ) from exc + LocalSnapshotRepository.validate_generation(self, reference) + return reference + + def publish_generation( + self, + reference: GenerationReference, + config: IndexConfig, + ) -> IndexSnapshot: + return self._publish( + replacement=reference, + remove_media_id=None, + config_fingerprint=reference.config_fingerprint, + configuration=self.snapshot_configuration(config), + ) + + def remove(self, media_id: str) -> bool: + active = self.read_active() + if active is None or media_id not in active.generations: + return False + self._publish( + replacement=None, + remove_media_id=media_id, + config_fingerprint=active.config_fingerprint, + configuration=dict(active.configuration), + ) + return True + + def clear(self) -> bool: + active = self.read_active() + if active is None or not active.generations: + return False + self._publish( + replacement=None, + remove_media_id="*", + config_fingerprint=active.config_fingerprint, + configuration=dict(active.configuration), + ) + return True + + def _publish( + self, + *, + replacement: GenerationReference | None, + remove_media_id: str | None, + config_fingerprint: str, + configuration: dict[str, Any], + ) -> IndexSnapshot: + with self.engine.begin() as connection: + self._ensure_index_state(connection) + state = connection.execute( + select( + index_state.c.active_snapshot_id, + index_state.c.active_snapshot_sha256, + ) + .where(index_state.c.singleton_id == _INDEX_STATE_ID) + .with_for_update() + ).one() + active = ( + None + if state.active_snapshot_id is None + else self._read_snapshot( + connection, + state.active_snapshot_id, + expected_sha256=state.active_snapshot_sha256, + ) + ) + generations = dict(active.generations) if active is not None else {} + if remove_media_id == "*": + generations.clear() + elif remove_media_id is not None: + generations.pop(remove_media_id, None) + if replacement is not None: + existing = connection.execute( + select(index_generations.c.payload).where( + index_generations.c.generation_id + == replacement.generation_id + ) + ).scalar_one_or_none() + if existing is None: + connection.execute( + insert(index_generations).values( + generation_id=replacement.generation_id, + media_id=replacement.media_id, + manifest_sha256=replacement.manifest_sha256, + payload=replacement.model_dump(mode="json"), + ) + ) + elif ( + GenerationReference.model_validate(existing, strict=False) + != replacement + ): + raise IndexSchemaError( + "The generation identity is already published " + "with different metadata." + ) + other_media = set(generations) - {replacement.media_id} + if other_media and active is not None: + configuration = dict(active.configuration) + generations[replacement.media_id] = replacement + snapshot = IndexSnapshot( + snapshot_id=uuid4().hex, + created_at=datetime.now(timezone.utc), + config_fingerprint=config_fingerprint, + configuration=configuration, + generations=generations, + ) + checksum = _checksum(snapshot) + connection.execute( + insert(index_snapshots).values( + snapshot_id=snapshot.snapshot_id, + created_at=snapshot.created_at.isoformat(), + sha256=checksum, + payload=snapshot.model_dump(mode="json"), + ) + ) + connection.execute( + update(index_state) + .where(index_state.c.singleton_id == _INDEX_STATE_ID) + .values( + active_snapshot_id=snapshot.snapshot_id, + active_snapshot_sha256=checksum, + ) + ) + return snapshot + + def active_config( + self, + *, + device: str, + ) -> tuple[IndexConfig, IndexSnapshot]: + snapshot = self.read_active(required=True) + assert snapshot is not None + if not snapshot.generations: + raise IndexNotReadyError( + "The active index snapshot contains no media." + ) + return ( + self._config_for_snapshot( + snapshot, + snapshot_sha256=_checksum(snapshot), + device=device, + ), + snapshot, + ) + + def status(self) -> dict[str, Any] | None: + snapshot = self.read_active() + if snapshot is None: + return None + return snapshot_status(snapshot) diff --git a/src/vidxp/infrastructure/sql_tables.py b/src/vidxp/infrastructure/sql_tables.py new file mode 100644 index 0000000..9e1030f --- /dev/null +++ b/src/vidxp/infrastructure/sql_tables.py @@ -0,0 +1,144 @@ +from sqlalchemy import ( + BigInteger, + CheckConstraint, + Column, + ForeignKey, + Index, + JSON, + MetaData, + String, + Table, + Text, +) + + +metadata = MetaData() + +media = Table( + "media", + metadata, + Column("media_id", String(32), primary_key=True), + Column("sha256", String(64), nullable=False, unique=True), + Column("created_at", Text, nullable=False), + Column("payload", JSON, nullable=False), +) + +artifacts = Table( + "artifacts", + metadata, + Column("artifact_id", String(32), primary_key=True), + Column( + "media_id", + String(32), + ForeignKey("media.media_id"), + nullable=False, + ), + Column("created_at", Text, nullable=False), + Column("payload", JSON, nullable=False), +) +Index("artifacts_media_id", artifacts.c.media_id) + +artifact_requests = Table( + "artifact_requests", + metadata, + Column("request_key", String(64), primary_key=True), + Column( + "artifact_id", + String(32), + ForeignKey("artifacts.artifact_id"), + nullable=False, + unique=True, + ), +) + +media_import_requests = Table( + "media_import_requests", + metadata, + Column("request_key", String(64), primary_key=True), + Column("request_fingerprint", String(64), nullable=False), + Column( + "media_id", + String(32), + ForeignKey("media.media_id"), + nullable=True, + ), +) + +upload_intents = Table( + "upload_intents", + metadata, + Column("intent_id", String(32), primary_key=True), + Column("request_key", String(64), nullable=False, unique=True), + Column("original_filename", String(255), nullable=False), + Column("byte_size", BigInteger, nullable=False), + Column("declared_mime_type", String(127), nullable=True), + Column("state", String(32), nullable=False), + Column("created_at", Text, nullable=False), + Column("expires_at", Text, nullable=False), + Column("upload_id", String(255), nullable=True, unique=True), + Column("job_id", String(36), nullable=True, unique=True), + Column( + "media_id", + String(32), + ForeignKey("media.media_id"), + nullable=True, + ), +) + +upload_quota = Table( + "upload_quota", + metadata, + Column("singleton_id", String(1), primary_key=True), + Column("reserved_bytes", BigInteger, nullable=False, default=0), + CheckConstraint( + "reserved_bytes >= 0", + name="upload_quota_reserved_bytes_nonnegative", + ), + CheckConstraint( + "singleton_id = '1'", + name="upload_quota_singleton", + ), +) + +index_state = Table( + "index_state", + metadata, + Column("singleton_id", String(1), primary_key=True), + Column("active_snapshot_id", String(32), nullable=True), + Column("active_snapshot_sha256", String(64), nullable=True), + CheckConstraint( + "singleton_id = '1'", + name="index_state_singleton", + ), +) + +index_generations = Table( + "index_generations", + metadata, + Column("generation_id", String(32), primary_key=True), + Column("media_id", String(32), nullable=False), + Column("manifest_sha256", String(64), nullable=False), + Column("payload", JSON, nullable=False), +) +Index( + "index_generations_media", + index_generations.c.media_id, +) + +index_snapshots = Table( + "index_snapshots", + metadata, + Column("snapshot_id", String(32), primary_key=True), + Column("created_at", Text, nullable=False), + Column("sha256", String(64), nullable=False), + Column("payload", JSON, nullable=False), +) +Index( + "index_snapshots_created", + index_snapshots.c.created_at, +) +Index( + "upload_intents_expiry", + upload_intents.c.expires_at, + upload_intents.c.state, +) diff --git a/src/vidxp/infrastructure/tusd_contracts.py b/src/vidxp/infrastructure/tusd_contracts.py new file mode 100644 index 0000000..12e51c6 --- /dev/null +++ b/src/vidxp/infrastructure/tusd_contracts.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class TusdModel(BaseModel): + model_config = ConfigDict( + extra="ignore", + frozen=True, + populate_by_name=True, + ) + + +class TusdHTTPRequest(TusdModel): + method: str = Field(default="", alias="Method") + uri: str = Field(default="", alias="URI") + headers: dict[str, list[str]] = Field( + default_factory=dict, + alias="Header", + ) + + def header(self, name: str) -> tuple[str, ...]: + lowered = name.lower() + for key, values in self.headers.items(): + if key.lower() == lowered: + return tuple(values) + return () + + +class TusdUpload(TusdModel): + upload_id: str = Field(default="", alias="ID") + size: int = Field(alias="Size", ge=0) + offset: int = Field(default=0, alias="Offset", ge=0) + metadata: dict[str, str] = Field( + default_factory=dict, + alias="MetaData", + ) + size_is_deferred: bool = Field( + default=False, + alias="SizeIsDeferred", + ) + is_partial: bool = Field(default=False, alias="IsPartial") + is_final: bool = Field(default=False, alias="IsFinal") + partial_uploads: list[str] | None = Field( + default=None, + alias="PartialUploads", + ) + + +class TusdEvent(TusdModel): + upload: TusdUpload = Field(alias="Upload") + request: TusdHTTPRequest = Field(alias="HTTPRequest") + + +class TusdHookRequest(TusdModel): + hook_type: Literal[ + "pre-create", + "post-finish", + "pre-terminate", + "post-terminate", + ] = Field(alias="Type") + event: TusdEvent = Field(alias="Event") + + +class TusdHTTPResponse(TusdModel): + status_code: int = Field(alias="StatusCode", ge=100, le=599) + body: str = Field(default="", alias="Body") + headers: dict[str, str] = Field(default_factory=dict, alias="Header") + + +class TusdChangeFileInfo(TusdModel): + upload_id: str = Field(alias="ID") + + +class TusdHookResponse(TusdModel): + reject_upload: bool = Field(default=False, alias="RejectUpload") + reject_termination: bool = Field( + default=False, + alias="RejectTermination", + ) + http_response: TusdHTTPResponse | None = Field( + default=None, + alias="HTTPResponse", + ) + change_file_info: TusdChangeFileInfo | None = Field( + default=None, + alias="ChangeFileInfo", + ) + + def wire(self) -> dict[str, Any]: + return self.model_dump( + mode="json", + by_alias=True, + exclude_none=True, + ) diff --git a/src/vidxp/job_service.py b/src/vidxp/job_service.py new file mode 100644 index 0000000..dcad2df --- /dev/null +++ b/src/vidxp/job_service.py @@ -0,0 +1,340 @@ +from __future__ import annotations + +from functools import wraps +from time import sleep +from typing import Any, Callable, Protocol + +from vidxp.application_models import ( + ActorOverlayJobRequest, + ApplicationError, + ComponentReadiness, + CreateActorOverlayCommand, + CreateIndexCommand, + CreateSnippetCommand, + ErrorCategory, + IndexJobRequest, + Job, + JobKind, + JobPage, + JobQueue, + JobResult, + JobState, + InvalidRequestError, + ListJobsCommand, + MediaImportJobRequest, + PrepareModelsCommand, + PrepareModelsJobRequest, + QueryJobRequest, + QueryVideoCommand, + ResourceNotFoundError, + SearchCommand, + SearchJobRequest, + SnippetJobRequest, +) +from vidxp.ports import ( + InvalidJobBackendRequestError, + JobBackend, + JobIdempotencyConflictError, +) +from vidxp.settings import VidXPSettings + + +class ReadJobPlanner(Protocol): + """Resolve immutable index identities before durable read jobs enqueue.""" + + def plan_search(self, command: SearchCommand) -> SearchJobRequest: ... + + def plan_query(self, command: QueryVideoCommand) -> QueryJobRequest: ... + + def plan_actor_overlay( + self, + command: CreateActorOverlayCommand, + ) -> ActorOverlayJobRequest: ... + + +def job_boundary(handler: Callable) -> Callable: + """Translate job-backend failures once for every adapter.""" + + @wraps(handler) + def wrapped(*args: Any, **kwargs: Any) -> Any: + try: + return handler(*args, **kwargs) + except ApplicationError: + raise + except InvalidJobBackendRequestError as exc: + raise InvalidRequestError() from exc + except JobIdempotencyConflictError as exc: + raise ApplicationError( + "idempotency_key_reused", + ErrorCategory.validation, + "The idempotency key was already used for another request.", + ) from exc + except Exception as exc: + raise ApplicationError( + "job_backend_unavailable", + ErrorCategory.unavailable, + "The durable job backend is unavailable.", + retryable=True, + ) from exc + + return wrapped + + +class JobService: + """Transport-neutral durable job commands backed by one workflow engine.""" + + def __init__( + self, + *, + settings: VidXPSettings, + backend: JobBackend, + read_planner: ReadJobPlanner | None = None, + ) -> None: + self.settings = settings + self.backend = backend + self.read_planner = read_planner + + def _read_job_planner(self) -> ReadJobPlanner: + if self.read_planner is None: + raise ApplicationError( + "read_job_planner_unavailable", + ErrorCategory.unavailable, + "Durable index reads are not configured.", + ) + return self.read_planner + + @job_boundary + def start(self) -> None: + self.backend.start() + + @job_boundary + def submit_index( + self, + command: CreateIndexCommand, + *, + job_id: str | None = None, + ) -> Job: + return self.backend.submit( + IndexJobRequest(command=command), + queue=self._model_queue(), + job_id=job_id, + ) + + @job_boundary + def submit_search( + self, + command: SearchCommand, + *, + job_id: str | None = None, + ) -> Job: + return self.backend.submit( + self._read_job_planner().plan_search(command), + queue=self._model_queue(), + job_id=job_id, + ) + + @job_boundary + def submit_query( + self, + command: QueryVideoCommand, + *, + job_id: str | None = None, + ) -> Job: + return self.backend.submit( + self._read_job_planner().plan_query(command), + queue=self._model_queue(), + job_id=job_id, + ) + + @job_boundary + def submit_snippet( + self, + command: CreateSnippetCommand, + *, + job_id: str | None = None, + ) -> Job: + return self.backend.submit( + SnippetJobRequest(command=command), + queue=JobQueue.cpu, + job_id=job_id, + ) + + @job_boundary + def submit_actor_overlay( + self, + command: CreateActorOverlayCommand, + *, + job_id: str | None = None, + ) -> Job: + return self.backend.submit( + self._read_job_planner().plan_actor_overlay(command), + queue=JobQueue.cpu, + job_id=job_id, + ) + + @job_boundary + def submit_prepare_models( + self, + command: PrepareModelsCommand, + *, + job_id: str | None = None, + ) -> Job: + return self.backend.submit( + PrepareModelsJobRequest(command=command), + queue=self._model_queue(), + job_id=job_id, + ) + + def enqueue_media_import_in_transaction( + self, + upload_id: str, + *, + connection: Any, + job_id: str, + ) -> str: + enqueue = getattr(self.backend, "enqueue_in_transaction", None) + if enqueue is None: + raise RuntimeError( + "The durable job backend cannot join an upload transaction." + ) + return enqueue( + connection, + MediaImportJobRequest(upload_id=upload_id), + queue=JobQueue.cpu, + job_id=job_id, + ) + + @job_boundary + def get(self, job_id: str) -> Job: + job = self.backend.get(job_id) + if job is None: + raise ResourceNotFoundError("job") + return job + + @job_boundary + def list(self, command: ListJobsCommand) -> JobPage: + return self.backend.list(command) + + @job_boundary + def cancel(self, job_id: str) -> Job: + current = self.get(job_id) + if current.kind == JobKind.media_import: + raise ApplicationError( + "job_not_cancellable", + ErrorCategory.conflict, + "Media import jobs are managed by the upload lifecycle.", + ) + if current.state in { + JobState.succeeded, + JobState.failed, + JobState.cancelled, + JobState.recovery_exhausted, + }: + return current + cancelled = self.backend.cancel(job_id) + if cancelled is None: + raise ResourceNotFoundError("job") + return cancelled + + @job_boundary + def retry( + self, + job_id: str, + *, + retry_id: str | None = None, + ) -> Job: + current = self.get(job_id) + if current.kind == JobKind.media_import: + raise ApplicationError( + "job_not_retryable", + ErrorCategory.conflict, + "Create a new upload intent to retry a media import.", + ) + if current.state not in { + JobState.failed, + JobState.cancelled, + JobState.recovery_exhausted, + }: + raise ApplicationError( + "job_not_retryable", + ErrorCategory.conflict, + "Only failed or cancelled jobs can be retried.", + ) + retried = self.backend.retry(job_id, retry_id=retry_id) + if retried is None: + raise ResourceNotFoundError("job") + return retried + + @job_boundary + def result(self, job_id: str) -> JobResult: + job = self.get(job_id) + if job.state == JobState.cancelled: + raise ApplicationError( + "job_cancelled", + ErrorCategory.cancelled, + "The job was cancelled.", + ) + if job.state != JobState.succeeded: + if job.error is not None: + raise ApplicationError( + job.error.code, + job.error.category, + job.error.message, + details=job.error.details, + retryable=job.error.retryable, + ) + raise ApplicationError( + "job_not_complete", + ErrorCategory.conflict, + "The job has not completed successfully.", + retryable=job.state in {JobState.queued, JobState.running}, + ) + if job.result is None: + raise ApplicationError( + "job_result_unavailable", + ErrorCategory.internal, + "The completed job result is unavailable.", + ) + return job.result + + @job_boundary + def wait( + self, + job_id: str, + *, + progress: Callable[[Job], None] | None = None, + ) -> Job: + last_progress = None + while True: + job = self.get(job_id) + if progress is not None and job.progress != last_progress: + progress(job) + last_progress = job.progress + if job.state not in {JobState.queued, JobState.running}: + if job.state != JobState.succeeded: + self.result(job.job_id) + return job + sleep(self.settings.workflow_poll_interval_seconds) + + def _model_queue(self) -> JobQueue: + return ( + JobQueue.gpu + if self.settings.runtime_backend.startswith("cuda") + else JobQueue.cpu + ) + + @job_boundary + def readiness(self) -> ComponentReadiness: + self.backend.health() + return ComponentReadiness( + name="workflow", + ready=True, + message="The durable workflow database is available.", + ) + + @job_boundary + def stop_worker(self) -> bool: + return self.backend.stop_worker() + + def close(self) -> None: + self.backend.close() diff --git a/src/vidxp/mcp.py b/src/vidxp/mcp.py new file mode 100644 index 0000000..e9a297f --- /dev/null +++ b/src/vidxp/mcp.py @@ -0,0 +1,823 @@ +from __future__ import annotations + +import logging +import json +from contextvars import ContextVar +from dataclasses import dataclass +from functools import partial +from typing import Annotated, Callable, TypeVar + +import anyio +from mcp.server import MCPServer +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.auth.provider import AccessToken +from mcp.server.auth.settings import AuthSettings +from mcp.server.transport_security import TransportSecuritySettings +from mcp.server.transport_security import TransportSecurityMiddleware +from mcp.shared.exceptions import MCPError +from mcp.server.mcpserver.exceptions import ToolError +from mcp.types import Icon, ResourceLink, ToolAnnotations +from pydantic import Field +from starlette.requests import Request +from starlette.types import ASGIApp, Receive, Scope, Send + +from vidxp import __version__ +from vidxp.application_models import ( + ApplicationError, + CapabilityInfo, + CapabilityList, + CreateSnippetCommand, + CreateIndexCommand, + ErrorCategory, + Identifier, + IndexStatus, + Job, + JobId, + JobPage, + ListJobsCommand, + ListMediaCommand, + MediaAsset, + MediaId, + MediaPage, + Principal, + PrepareModelsCommand, + QueryVideoCommand, + RuntimeReadiness, + SearchCommand, +) +from vidxp.authentication import ( + OIDCBearerAuthenticator, + create_authenticator, +) +from vidxp.authorization import RepositoryPermission +from vidxp.branding import ( + ICON_MIME_TYPE, + ICON_SIZE, + PROJECT_URL, + icon_data_uri, +) +from vidxp.composition import ControlPlaneContext, HttpApplicationContext +from vidxp.idempotency import IdempotencyKey, scoped_job_id +from vidxp.core.identifiers import ArtifactId +from vidxp.settings import HttpAuthMode + + +_LOGGER = logging.getLogger(__name__) +_T = TypeVar("_T") +_REQUEST_PRINCIPAL: ContextVar[Principal | None] = ContextVar( + "vidxp_mcp_principal", + default=None, +) +_ERROR_CODES = { + ErrorCategory.validation: -32602, + ErrorCategory.authentication: -32001, + ErrorCategory.authorization: -32003, + ErrorCategory.not_found: -32004, + ErrorCategory.conflict: -32009, + ErrorCategory.resource_limit: -32029, + ErrorCategory.cancelled: -32040, + ErrorCategory.unavailable: -32050, + ErrorCategory.internal: -32603, +} +_READ_ONLY = ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, +) +_SUBMIT = ToolAnnotations( + read_only_hint=False, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, +) +_CANCEL = ToolAnnotations( + read_only_hint=False, + destructive_hint=True, + idempotent_hint=True, + open_world_hint=False, +) + + +class PrincipalBridge: + """Carry the principal validated by the outer ASGI boundary into tools.""" + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__( + self, + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + principal = scope.get("vidxp.principal") + reset = None + if isinstance(principal, Principal): + reset = _REQUEST_PRINCIPAL.set(principal) + try: + await self.app(scope, receive, send) + finally: + if reset is not None: + _REQUEST_PRINCIPAL.reset(reset) + + +class VidXPTokenVerifier: + """Adapt the shared OIDC validator to the SDK resource-server contract.""" + + def __init__(self, authenticator: OIDCBearerAuthenticator) -> None: + self.authenticator = authenticator + + async def verify_token(self, token: str) -> AccessToken | None: + try: + verified = await anyio.to_thread.run_sync( + self.authenticator.authenticate_bearer, + token, + ) + except ApplicationError: + return None + principal = verified.principal + return AccessToken( + token=token, + client_id=principal.client_id or principal.subject, + subject=principal.subject, + scopes=sorted(principal.scopes), + expires_at=verified.expires_at, + resource=verified.resource, + claims=verified.claims, + ) + + +@dataclass(frozen=True) +class RemoteMCP: + server: MCPServer + app: ASGIApp + owns_authentication: bool + transport_security: TransportSecuritySettings + + +def _principal(default: Principal | None) -> Principal: + current = _REQUEST_PRINCIPAL.get() + if current is not None: + return current + token = get_access_token() + if token is not None: + return Principal( + subject=token.subject or token.client_id, + client_id=token.client_id, + scopes=frozenset(token.scopes), + ) + if default is not None: + return default + raise MCPError( + -32001, + "Valid bearer authentication is required.", + { + "code": "authentication_required", + "category": "authentication", + "retryable": False, + }, + ) + + +def _application_error(exc: ApplicationError) -> ToolError: + detail = exc.detail + data = { + "code": detail.code, + "category": detail.category.value, + "retryable": detail.retryable, + } + if detail.correlation_id is not None: + data["correlation_id"] = detail.correlation_id + for key in ( + "capability", + "errors", + "install_hint", + "remediation", + "required_scope", + ): + if key in detail.details: + data[key] = detail.details[key] + return ToolError( + json.dumps( + { + "error": { + "protocol_code": _ERROR_CODES[detail.category], + "message": detail.message, + **data, + } + }, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ) + ) + + +def _invoke( + context: ControlPlaneContext, + *, + default_principal: Principal | None, + permission: RepositoryPermission, + operation: Callable[[Principal], _T], +) -> _T: + try: + principal = context.authorization.require( + _principal(default_principal), + permission, + ) + return operation(principal) + except MCPError: + raise + except ApplicationError as exc: + raise _application_error(exc) from exc + except Exception as exc: + _LOGGER.exception("Unexpected MCP tool failure.") + raise MCPError( + -32603, + "The MCP tool failed unexpectedly.", + { + "code": "internal_error", + "category": "internal", + "retryable": False, + }, + ) from exc + + +async def _invoke_async( + context: ControlPlaneContext, + *, + default_principal: Principal | None, + permission: RepositoryPermission, + operation: Callable[[Principal], _T], +) -> _T: + return await anyio.to_thread.run_sync( + partial( + _invoke, + context, + default_principal=default_principal, + permission=permission, + operation=operation, + ), + abandon_on_cancel=True, + ) + + +class MCPTransportSecurityBoundary: + """Run the SDK's transport checks before outer static authentication.""" + + def __init__( + self, + app: ASGIApp, + *, + settings: TransportSecuritySettings, + ) -> None: + self.app = app + self.security = TransportSecurityMiddleware(settings) + + async def __call__( + self, + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + if ( + scope["type"] == "http" + and str(scope.get("path", "")) in {"/mcp", "/mcp/"} + ): + response = await self.security.validate_request( + Request(scope, receive=receive), + is_post=str(scope.get("method", "")).upper() == "POST", + ) + if response is not None: + await response(scope, receive, send) + return + await self.app(scope, receive, send) + + +def create_mcp_server( + context: ControlPlaneContext, + *, + default_principal: Principal | None = None, + oidc_authentication: bool = False, +) -> MCPServer: + settings = context.settings + token_verifier = None + auth = None + if oidc_authentication: + assert settings.http_oidc_issuer is not None + assert settings.mcp_public_url is not None + if ( + isinstance(context, HttpApplicationContext) + and isinstance( + context.authenticator, + OIDCBearerAuthenticator, + ) + ): + authenticator = context.authenticator.for_audience( + settings.mcp_public_url, + ) + else: + authenticator = create_authenticator( + settings, + audience=settings.mcp_public_url, + required_scopes=(), + ) + if not isinstance(authenticator, OIDCBearerAuthenticator): + raise ValueError("OIDC MCP requires the OIDC authenticator.") + token_verifier = VidXPTokenVerifier(authenticator) + auth = AuthSettings( + issuer_url=settings.http_oidc_issuer, + resource_server_url=settings.mcp_public_url, + required_scopes=list(settings.http_required_scopes), + ) + + server = MCPServer( + name="vidxp", + title="VidXP", + description="Index and search registered video media.", + website_url=PROJECT_URL, + icons=[ + Icon( + src=icon_data_uri(), + mimeType=ICON_MIME_TYPE, + sizes=[ICON_SIZE], + ) + ], + instructions=( + "Call get_runtime_readiness before indexing. If selected model " + "artifacts are missing, submit prepare_models and poll get_job " + "until it completes. Discover registered media with list_media. " + "Register and upload new video through the HTTP/tus API, then use " + "its media_id with start_indexing. get_index_status identifies the " + "media included in the active index snapshot. For search_moments " + "and query_video, provide command.media_id to restrict work to one " + "video, or omit it to search/query across every media item in that " + "snapshot. To deliver a matching time range, submit create_clip, " + "poll get_job, then call get_artifact_download with the completed " + "job's artifact_id. Use list_jobs to recover job IDs across agent " + "sessions." + ), + version=__version__, + token_verifier=token_verifier, + auth=auth, + ) + + async def artifact_bytes( + artifact_id: ArtifactId, + *, + expected_mime_type: str, + ) -> bytes: + resource = await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.read, + operation=lambda _actor: ( + context.application.open_artifact_content(artifact_id) + ), + ) + if resource.mime_type != expected_mime_type: + raise MCPError( + -32602, + "The artifact URI does not match its media type.", + { + "code": "artifact_media_type_mismatch", + "category": "validation", + "retryable": False, + }, + ) + return await anyio.to_thread.run_sync(resource.path.read_bytes) + + @server.resource( + "vidxp://artifacts/{artifact_id}/content.mp4", + name="vidxp_artifact_mp4", + title="VidXP MP4 artifact", + description=( + "Binary content for a generated VidXP clip or video artifact." + ), + mime_type="video/mp4", + ) + async def read_mp4_artifact(artifact_id: ArtifactId) -> bytes: + return await artifact_bytes( + artifact_id, + expected_mime_type="video/mp4", + ) + + @server.resource( + "vidxp://artifacts/{artifact_id}/content.mkv", + name="vidxp_artifact_matroska", + title="VidXP Matroska artifact", + description=( + "Binary content for a source-profile VidXP clip artifact." + ), + mime_type="video/x-matroska", + ) + async def read_matroska_artifact(artifact_id: ArtifactId) -> bytes: + return await artifact_bytes( + artifact_id, + expected_mime_type="video/x-matroska", + ) + + @server.tool( + description="List installed VidXP capabilities.", + annotations=_READ_ONLY, + structured_output=True, + ) + async def list_capabilities() -> CapabilityList: + return await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.read, + operation=lambda _actor: CapabilityList( + items=context.application.list_capabilities() + ), + ) + + @server.tool( + description="Get one capability and its public operation schemas.", + annotations=_READ_ONLY, + structured_output=True, + ) + async def get_capability(name: Identifier) -> CapabilityInfo: + return await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.read, + operation=lambda _actor: context.application.get_capability(name), + ) + + @server.tool( + description=( + "Check runtime components and whether model artifacts are prepared." + ), + annotations=_READ_ONLY, + structured_output=True, + ) + async def get_runtime_readiness() -> RuntimeReadiness: + return await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.read, + operation=lambda _actor: context.application.runtime_readiness(), + ) + + @server.tool( + description=( + "List registered video filenames, metadata, and stable media IDs " + "without transferring video bytes. Registration does not imply " + "that a video is present in the active index snapshot." + ), + annotations=_READ_ONLY, + structured_output=True, + ) + async def list_media( + page_size: Annotated[int, Field(gt=0, le=100)] = 50, + cursor: Annotated[ + str | None, + Field(min_length=1, max_length=512), + ] = None, + ) -> MediaPage: + return await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.read, + operation=lambda _actor: context.application.list_media( + ListMediaCommand(page_size=page_size, cursor=cursor) + ), + ) + + @server.tool( + description=( + "Get one registered video's metadata by the stable media ID " + "returned by list_media." + ), + annotations=_READ_ONLY, + structured_output=True, + ) + async def get_media(media_id: MediaId) -> MediaAsset: + return await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.read, + operation=lambda _actor: context.application.get_media(media_id), + ) + + @server.tool( + description=( + "Inspect the active index snapshot, including its indexed media " + "count and media IDs." + ), + annotations=_READ_ONLY, + structured_output=True, + ) + async def get_index_status() -> IndexStatus: + return await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.read, + operation=lambda _actor: context.application.index_status(), + ) + + @server.tool( + description=( + "Add or replace one registered media ID in the active multi-video " + "index snapshot. Obtain the ID from list_media or a completed " + "upload, then poll get_job." + ), + annotations=_SUBMIT, + structured_output=True, + ) + async def start_indexing( + command: CreateIndexCommand, + idempotency_key: IdempotencyKey, + ) -> Job: + def submit(actor: Principal) -> Job: + context.application.require_models(command.modalities) + return context.jobs.submit_index( + command, + job_id=scoped_job_id( + principal=actor, + transport="mcp", + operation="index", + idempotency_key=idempotency_key, + ), + ) + + return await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.write, + operation=submit, + ) + + @server.tool( + description=( + "Explicitly download and validate selected model artifacts. Poll " + "get_job for byte progress and completion before indexing." + ), + annotations=_SUBMIT, + structured_output=True, + ) + async def prepare_models( + command: PrepareModelsCommand, + idempotency_key: IdempotencyKey, + ) -> Job: + def submit(actor: Principal) -> Job: + return context.jobs.submit_prepare_models( + command, + job_id=scoped_job_id( + principal=actor, + transport="mcp", + operation="prepare-models", + idempotency_key=idempotency_key, + ), + ) + + return await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.write, + operation=submit, + ) + + @server.tool( + description=( + "Submit a durable ranked moment search. Set command.media_id to " + "search one registered video; omit it to search across every media " + "item in the active index snapshot. Poll get_job for top-k results." + ), + annotations=_SUBMIT, + structured_output=True, + ) + async def search_moments( + command: SearchCommand, + idempotency_key: IdempotencyKey, + ) -> Job: + def submit(actor: Principal) -> Job: + return context.jobs.submit_search( + command, + job_id=scoped_job_id( + principal=actor, + transport="mcp", + operation="search", + idempotency_key=idempotency_key, + ), + ) + + return await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.read, + operation=submit, + ) + + @server.tool( + description=( + "Submit a durable grounded natural-language query over indexed " + "moments and actor evidence. Set command.media_id for one video, " + "or omit it to query across every media item in the active index " + "snapshot. Poll get_job for the answer and evidence." + ), + annotations=_SUBMIT, + structured_output=True, + ) + async def query_video( + command: QueryVideoCommand, + idempotency_key: IdempotencyKey, + ) -> Job: + def submit(actor: Principal) -> Job: + return context.jobs.submit_query( + command, + job_id=scoped_job_id( + principal=actor, + transport="mcp", + operation="query", + idempotency_key=idempotency_key, + ), + ) + + return await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.read, + operation=submit, + ) + + @server.tool( + description=( + "Create a downloadable clip from a media ID and time range returned " + "by search_moments or query_video. Poll get_job, then pass the " + "completed result's artifact_id to get_artifact_download." + ), + annotations=_SUBMIT, + structured_output=True, + ) + async def create_clip( + command: CreateSnippetCommand, + idempotency_key: IdempotencyKey, + ) -> Job: + def submit(actor: Principal) -> Job: + return context.jobs.submit_snippet( + command, + job_id=scoped_job_id( + principal=actor, + transport="mcp", + operation="snippet", + idempotency_key=idempotency_key, + ), + ) + + return await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.write, + operation=submit, + ) + + @server.tool( + description=( + "Return a readable MCP resource link for a completed clip or video " + "artifact. The artifact_id is in the completed create_clip job " + "result; clients read the link only when they need the video bytes." + ), + annotations=_READ_ONLY, + structured_output=True, + ) + async def get_artifact_download( + artifact_id: ArtifactId, + ) -> ResourceLink: + artifact = await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.read, + operation=lambda _actor: ( + context.application.get_artifact(artifact_id) + ), + ) + suffix = ( + "mp4" + if artifact.mime_type == "video/mp4" + else "mkv" + ) + filename = f"{artifact.kind.value}-{artifact.artifact_id}.{suffix}" + return ResourceLink( + name=filename, + title=f"VidXP {artifact.kind.value.replace('_', ' ')}", + uri=( + f"vidxp://artifacts/{artifact.artifact_id}/" + f"content.{suffix}" + ), + description=( + f"Generated from media {artifact.media_id}; " + f"{artifact.byte_size:,} bytes." + ), + mimeType=artifact.mime_type, + size=artifact.byte_size, + ) + + @server.tool( + description="List durable jobs so work can be recovered across sessions.", + annotations=_READ_ONLY, + structured_output=True, + ) + async def list_jobs( + page_size: Annotated[int, Field(gt=0, le=100)] = 50, + cursor: Annotated[ + str | None, + Field(min_length=1, max_length=512), + ] = None, + ) -> JobPage: + return await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.read, + operation=lambda _actor: context.jobs.list( + ListJobsCommand(page_size=page_size, cursor=cursor) + ), + ) + + @server.tool( + description="Poll a durable VidXP job and its typed result.", + annotations=_READ_ONLY, + structured_output=True, + ) + async def get_job(job_id: JobId) -> Job: + return await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.read, + operation=lambda _actor: context.jobs.get(job_id), + ) + + @server.tool( + description="Retry a failed or cancelled durable job.", + annotations=_SUBMIT, + structured_output=True, + ) + async def retry_job( + job_id: JobId, + idempotency_key: IdempotencyKey, + ) -> Job: + def retry(actor: Principal) -> Job: + return context.jobs.retry( + job_id, + retry_id=scoped_job_id( + principal=actor, + transport="mcp", + operation=f"retry:{job_id}", + idempotency_key=idempotency_key, + ), + ) + + return await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.write, + operation=retry, + ) + + @server.tool( + description="Explicitly cancel an active durable job.", + annotations=_CANCEL, + structured_output=True, + ) + async def cancel_job(job_id: JobId) -> Job: + return await _invoke_async( + context, + default_principal=default_principal, + permission=RepositoryPermission.write, + operation=lambda _actor: context.jobs.cancel(job_id), + ) + + return server + + +def create_remote_mcp(context: ControlPlaneContext) -> RemoteMCP: + owns_authentication = ( + context.settings.http_auth_mode == HttpAuthMode.oidc + ) + server = create_mcp_server( + context, + oidc_authentication=owns_authentication, + ) + transport_security = TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=list(context.settings.mcp_allowed_hosts), + allowed_origins=list(context.settings.mcp_allowed_origins), + ) + app = server.streamable_http_app( + streamable_http_path="/mcp", + stateless_http=True, + json_response=False, + max_request_body_size=( + context.settings.mcp_max_request_body_bytes + ), + transport_security=transport_security, + host=context.settings.http_bind_host, + ) + return RemoteMCP( + server=server, + app=PrincipalBridge(app), + owns_authentication=owns_authentication, + transport_security=transport_security, + ) diff --git a/src/vidxp/mcp_cli.py b/src/vidxp/mcp_cli.py new file mode 100644 index 0000000..a4de11e --- /dev/null +++ b/src/vidxp/mcp_cli.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import shutil +import sys +from pathlib import Path +from typing import Any, Sequence + + +def mcp_executable() -> str: + """Return the most reliable executable path for desktop MCP clients.""" + + discovered = shutil.which("vidxp-mcp") + if discovered is not None: + return str(Path(discovered).resolve()) + executable_name = "vidxp-mcp.exe" if os.name == "nt" else "vidxp-mcp" + sibling = Path(sys.executable).with_name(executable_name) + if sibling.is_file(): + return str(sibling.resolve()) + return "vidxp-mcp" + + +def stdio_client_config( + *, + command: str | None = None, + registry: str | None = None, + repository: str | None = "default", + index_directory: str | None = None, + data_directory: Path | None = None, + device: str | None = None, +) -> dict[str, Any]: + """Build a broadly supported copy/paste MCP client configuration.""" + + arguments: list[str] = [] + for flag, value in ( + ("--registry", registry), + ("--repository", repository), + ("--index-directory", index_directory), + ("--data-dir", data_directory), + ("--device", device), + ): + if value is not None: + arguments.extend((flag, str(value))) + return { + "mcpServers": { + "vidxp": { + "command": command or mcp_executable(), + "args": arguments, + } + } + } + + +def render_stdio_client_config(**options: Any) -> str: + return json.dumps( + stdio_client_config(**options), + ensure_ascii=False, + indent=2, + ) + + +def _parser() -> argparse.ArgumentParser: + example = render_stdio_client_config() + return argparse.ArgumentParser( + description="Run the local VidXP MCP server over stdio.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "COPY/PASTE MCP CLIENT CONFIG\n" + "Import this JSON into any stdio MCP client:\n\n" + f"{example}\n\n" + "Run `vidxp-mcp --print-config` to print only the JSON. Add the " + "same repository/data/device options to either command when you " + "need non-default values." + ), + ) + + +async def _inspect_server(server: Any) -> dict[str, Any]: + from mcp.client import Client + + async with Client(server) as client: + discovered = await client.list_tools() + status = await client.call_tool("get_index_status", {}) + if status.is_error: + raise RuntimeError("The MCP index-status probe failed.") + server_info = client.server_info + return { + "server": server_info.title or server_info.name, + "version": server_info.version, + "tools": [tool.name for tool in discovered.tools], + "index_state": (status.structured_content or {}).get( + "state", + "unknown", + ), + } + + +def main(arguments: Sequence[str] | None = None) -> None: + parser = _parser() + parser.add_argument( + "--registry", + help="Path to the named-repository configuration file.", + ) + parser.add_argument( + "--repository", + help="Named repository to use; the implicit default is 'default'.", + ) + parser.add_argument( + "--index-directory", + help="Override the selected repository's index directory.", + ) + parser.add_argument( + "--data-dir", + type=Path, + help="Store VidXP models and the default repository here.", + ) + parser.add_argument( + "--device", + help="Override the selected repository runtime device.", + ) + actions = parser.add_mutually_exclusive_group() + actions.add_argument( + "--print-config", + action="store_true", + help="Print import-ready MCP client JSON and exit.", + ) + actions.add_argument( + "--check", + action="store_true", + help=( + "Perform a local MCP handshake and tool probe, print the resolved " + "runtime paths, and exit." + ), + ) + options = parser.parse_args(arguments) + if options.print_config: + print( + render_stdio_client_config( + registry=options.registry, + repository=options.repository or "default", + index_directory=options.index_directory, + data_directory=options.data_dir, + device=options.device, + ) + ) + return + + from vidxp.application_models import Principal + from vidxp.composition import ( + create_control_plane_application, + create_local_application, + ) + try: + from vidxp.mcp import create_mcp_server + except ModuleNotFoundError as exc: + if exc.name == "mcp": + parser.error( + 'MCP support is not installed. Install "vidxp[mcp]" in this ' + "same environment, then run the command again." + ) + raise + + local = create_local_application( + registry_path=options.registry, + repository_name=options.repository, + index_directory=options.index_directory, + data_directory=options.data_dir, + device=options.device, + ) + context = create_control_plane_application(local.settings) + try: + server = create_mcp_server( + context, + default_principal=Principal( + subject="local", + client_id="stdio", + scopes=frozenset({"*"}), + ), + ) + if options.check: + result = asyncio.run(_inspect_server(server)) + print(f"OK {result['server']} MCP {result['version']}") + print(f"Repository: {local.repository.name}") + print(f"Data: {context.settings.data_dir}") + print(f"Index: {context.settings.repository_root}") + print(f"Index state: {result['index_state']}") + print( + f"Tools: {len(result['tools'])} " + f"({', '.join(result['tools'])})" + ) + return + server.run("stdio") + finally: + context.close() + local.close() + + +if __name__ == "__main__": + main() diff --git a/src/vidxp/media_runtime.py b/src/vidxp/media_runtime.py new file mode 100644 index 0000000..0fad17a --- /dev/null +++ b/src/vidxp/media_runtime.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +import os +import shlex +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Literal +from uuid import uuid4 + +from pydantic import BaseModel, ConfigDict, Field + +from vidxp.app_paths import default_config_directory + + +MEDIA_RUNTIME_SCHEMA_VERSION = 1 +MEDIA_RUNTIME_FILENAME = "media-runtime.json" +REQUIRED_FFMPEG_ENCODERS = ("libx264", "aac") + + +class MediaRuntimeModel(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class MediaRuntimeConfiguration(MediaRuntimeModel): + schema_version: Literal[MEDIA_RUNTIME_SCHEMA_VERSION] = ( + MEDIA_RUNTIME_SCHEMA_VERSION + ) + ffmpeg_executable: Path + ffprobe_executable: Path + + +class SystemInstallPlan(MediaRuntimeModel): + manager: str = Field(min_length=1) + command: tuple[str, ...] = Field(min_length=1) + automatic: bool + + @property + def display_command(self) -> str: + if sys.platform == "win32": + return subprocess.list2cmdline(self.command) + return shlex.join(self.command) + + +class MediaRuntimeStatus(MediaRuntimeModel): + ready: bool + initialized: bool + ffmpeg_executable: Path | None = None + ffprobe_executable: Path | None = None + required_encoders: tuple[str, ...] = REQUIRED_FFMPEG_ENCODERS + errors: tuple[str, ...] = () + install_plan: SystemInstallPlan | None = None + + +def media_runtime_config_path( + config_directory: Path | None = None, +) -> Path: + return (config_directory or default_config_directory()) / ( + MEDIA_RUNTIME_FILENAME + ) + + +def load_media_runtime_configuration( + config_directory: Path | None = None, +) -> MediaRuntimeConfiguration | None: + path = media_runtime_config_path(config_directory) + try: + contents = path.read_text(encoding="utf-8") + except FileNotFoundError: + return None + return MediaRuntimeConfiguration.model_validate_json(contents) + + +def default_media_executable(name: Literal["ffmpeg", "ffprobe"]) -> str: + try: + configuration = load_media_runtime_configuration() + except (OSError, ValueError): + return name + if configuration is None: + return name + return str(getattr(configuration, f"{name}_executable")) + + +def _resolve_executable(value: str | Path) -> Path | None: + candidate = Path(value).expanduser() + if candidate.is_absolute() or candidate.parent != Path("."): + return candidate.resolve() if candidate.is_file() else None + resolved = shutil.which(str(value)) + return Path(resolved).resolve() if resolved else None + + +def _command_output(arguments: list[str], *, timeout: float = 15) -> str: + completed = subprocess.run( + arguments, + check=True, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + timeout=timeout, + ) + return completed.stdout.decode(errors="replace") + + +def _verify_ffmpeg(path: Path) -> tuple[str, ...]: + errors = [] + try: + _command_output([str(path), "-version"]) + encoders = _command_output([str(path), "-hide_banner", "-encoders"]) + except (OSError, subprocess.SubprocessError) as exc: + return (f"FFmpeg could not be executed: {exc}",) + available = { + token + for line in encoders.splitlines() + for token in line.split() + } + missing = tuple( + encoder + for encoder in REQUIRED_FFMPEG_ENCODERS + if encoder not in available + ) + if missing: + errors.append( + "FFmpeg is missing required encoder(s): " + ", ".join(missing) + ) + return tuple(errors) + + +def _verify_ffprobe(path: Path) -> tuple[str, ...]: + try: + _command_output([str(path), "-version"]) + except (OSError, subprocess.SubprocessError) as exc: + return (f"ffprobe could not be executed: {exc}",) + return () + + +def system_install_plan() -> SystemInstallPlan | None: + if sys.platform == "win32": + if shutil.which("winget"): + return SystemInstallPlan( + manager="Windows Package Manager", + command=( + "winget", + "install", + "--id", + "Gyan.FFmpeg", + "--exact", + "--source", + "winget", + "--accept-package-agreements", + "--accept-source-agreements", + ), + automatic=True, + ) + return None + if sys.platform == "darwin": + brew = shutil.which("brew") + if brew is None: + for candidate in ( + Path("/opt/homebrew/bin/brew"), + Path("/usr/local/bin/brew"), + ): + if candidate.is_file(): + brew = str(candidate) + break + if brew: + return SystemInstallPlan( + manager="Homebrew", + command=(brew, "install", "ffmpeg"), + automatic=True, + ) + return None + if shutil.which("apt-get"): + return SystemInstallPlan( + manager="APT", + command=("sudo", "apt-get", "install", "ffmpeg"), + automatic=False, + ) + if shutil.which("dnf"): + return SystemInstallPlan( + manager="DNF", + command=("sudo", "dnf", "install", "ffmpeg"), + automatic=False, + ) + if shutil.which("pacman"): + return SystemInstallPlan( + manager="pacman", + command=("sudo", "pacman", "-S", "ffmpeg"), + automatic=False, + ) + return None + + +def inspect_media_runtime( + *, + ffmpeg: str | Path | None = None, + ffprobe: str | Path | None = None, + config_directory: Path | None = None, +) -> MediaRuntimeStatus: + configuration = None + configuration_error = None + try: + configuration = load_media_runtime_configuration(config_directory) + except (OSError, ValueError) as exc: + configuration_error = f"The saved media runtime is invalid: {exc}" + + ffmpeg_value = ( + ffmpeg + or ( + configuration.ffmpeg_executable + if configuration is not None + else "ffmpeg" + ) + ) + ffprobe_value = ( + ffprobe + or ( + configuration.ffprobe_executable + if configuration is not None + else "ffprobe" + ) + ) + ffmpeg_path = _resolve_executable(ffmpeg_value) + ffprobe_path = _resolve_executable(ffprobe_value) + errors = [] + if configuration_error: + errors.append(configuration_error) + if ffmpeg_path is None: + errors.append(f"FFmpeg was not found: {ffmpeg_value}") + else: + errors.extend(_verify_ffmpeg(ffmpeg_path)) + if ffprobe_path is None: + errors.append(f"ffprobe was not found: {ffprobe_value}") + else: + errors.extend(_verify_ffprobe(ffprobe_path)) + return MediaRuntimeStatus( + ready=not errors, + initialized=configuration is not None, + ffmpeg_executable=ffmpeg_path, + ffprobe_executable=ffprobe_path, + errors=tuple(errors), + install_plan=system_install_plan() if errors else None, + ) + + +def save_media_runtime_configuration( + status: MediaRuntimeStatus, + *, + config_directory: Path | None = None, +) -> MediaRuntimeConfiguration: + if ( + not status.ready + or status.ffmpeg_executable is None + or status.ffprobe_executable is None + ): + raise ValueError("Only a verified media runtime can be saved.") + configuration = MediaRuntimeConfiguration( + ffmpeg_executable=status.ffmpeg_executable, + ffprobe_executable=status.ffprobe_executable, + ) + destination = media_runtime_config_path(config_directory) + destination.parent.mkdir(parents=True, exist_ok=True) + temporary = destination.with_name( + f".{destination.name}.{uuid4().hex}.tmp" + ) + try: + temporary.write_text( + configuration.model_dump_json(indent=2), + encoding="utf-8", + ) + temporary.replace(destination) + finally: + temporary.unlink(missing_ok=True) + return configuration + + +def install_media_runtime( + plan: SystemInstallPlan, + *, + output_to_stderr: bool = False, +) -> None: + if not plan.automatic: + raise ValueError( + "This package-manager command must be run in a system terminal." + ) + destination = sys.stderr if output_to_stderr else None + subprocess.run( + list(plan.command), + check=True, + stdout=destination, + stderr=destination, + ) + + +def explicitly_configured_by_environment() -> bool: + return bool( + os.environ.get("VIDXP_FFMPEG_EXECUTABLE") + and os.environ.get("VIDXP_FFPROBE_EXECUTABLE") + ) + + +def media_runtime_is_initialized( + config_directory: Path | None = None, +) -> bool: + if explicitly_configured_by_environment(): + return True + try: + configuration = load_media_runtime_configuration(config_directory) + except (OSError, ValueError): + return False + if bool( + configuration is not None + and configuration.ffmpeg_executable.is_file() + and configuration.ffprobe_executable.is_file() + ): + return True + return bool(shutil.which("ffmpeg") and shutil.which("ffprobe")) diff --git a/src/vidxp/media_service.py b/src/vidxp/media_service.py new file mode 100644 index 0000000..97dd12a --- /dev/null +++ b/src/vidxp/media_service.py @@ -0,0 +1,313 @@ +from __future__ import annotations + +import json +from pathlib import Path +import hashlib +from uuid import uuid4 + +from vidxp.application_models import ( + ImportMediaCommand, + ListMediaCommand, + MediaAsset, + MediaPage, +) +from vidxp.core.media import ( + MediaRecord, + MediaState, + QuarantinedMedia, + StagedMedia, + MediaUnavailableError, + utc_now, +) +from vidxp.core.cursors import ( + CursorError, + decode_offset_cursor, + encode_offset_cursor, +) +from vidxp.ports import ( + LocalFileResource, + MediaCatalogPort, + MediaProbePort, + MediaStorePort, +) +from vidxp.settings import ApplicationMode, VidXPSettings + + +class MediaImportNotAllowedError(PermissionError): + """Raised when a local path is outside the local import policy.""" + + +class MediaIdempotencyConflictError(FileExistsError): + """Raised when an import key is reused for different media content.""" + + +def media_asset(record: MediaRecord) -> MediaAsset: + return MediaAsset( + schema_version=record.schema_version, + media_id=record.media_id, + video_id=record.video_id, + original_filename=record.original_filename, + sha256=record.sha256, + byte_size=record.byte_size, + declared_mime_type=record.declared_mime_type, + detected_mime_type=record.detected_mime_type, + container=record.container, + duration_seconds=record.duration_seconds, + streams=record.streams, + state=record.state, + created_at=record.created_at, + ) + + +class MediaService: + def __init__( + self, + *, + settings: VidXPSettings, + catalog: MediaCatalogPort, + store: MediaStorePort, + probe: MediaProbePort, + ) -> None: + self.settings = settings + self.catalog = catalog + self.store = store + self.probe = probe + + def import_local(self, command: ImportMediaCommand) -> MediaAsset: + if self.settings.mode != ApplicationMode.local: + raise MediaImportNotAllowedError( + "Local path imports are available only in local mode." + ) + source = self._resolve_import_source(command.path) + return self._import( + source, + original_filename=command.original_filename or source.name, + declared_mime_type=command.declared_mime_type, + ) + + def import_quarantined( + self, + media: QuarantinedMedia, + *, + request_key: str | None = None, + ) -> MediaAsset: + try: + source = media.path.resolve(strict=True) + except (FileNotFoundError, OSError) as exc: + raise MediaUnavailableError( + "The quarantined media source is unavailable." + ) from exc + if not source.is_file(): + raise MediaUnavailableError( + "The quarantined media source is not a file." + ) + return self._import( + source, + original_filename=media.original_filename, + declared_mime_type=media.declared_mime_type, + request_key=request_key, + ) + + def _import( + self, + source: Path, + *, + original_filename: str, + declared_mime_type: str | None, + request_key: str | None = None, + ) -> MediaAsset: + staged = self.store.stage_local(source) + try: + with self.store.publication_lock(staged.sha256): + if request_key is not None: + request_fingerprint = self._import_fingerprint( + staged, + original_filename=original_filename, + declared_mime_type=declared_mime_type, + ) + try: + completed = self.catalog.reserve_media_import( + request_key, + request_fingerprint, + ) + except FileExistsError as exc: + raise MediaIdempotencyConflictError from exc + if completed is not None: + return media_asset(completed) + result = self._publish_import( + original_filename=original_filename, + declared_mime_type=declared_mime_type, + staged=staged, + ) + if request_key is not None: + record = self.catalog.get_media(result.media_id) + if record is None: + raise RuntimeError( + "The imported media record is unavailable." + ) + try: + self.catalog.complete_media_import( + request_key, + request_fingerprint, + record, + ) + except FileExistsError as exc: + raise MediaIdempotencyConflictError from exc + return result + finally: + self.store.discard(staged) + + @staticmethod + def _import_fingerprint( + staged: StagedMedia, + *, + original_filename: str, + declared_mime_type: str | None, + ) -> str: + payload = json.dumps( + { + "version": 1, + "sha256": staged.sha256, + "original_filename": original_filename, + "declared_mime_type": declared_mime_type, + }, + sort_keys=True, + separators=(",", ":"), + ).encode() + return hashlib.sha256(payload).hexdigest() + + def _publish_import( + self, + *, + original_filename: str, + declared_mime_type: str | None, + staged: StagedMedia, + ) -> MediaAsset: + if existing := self.catalog.get_media_by_checksum(staged.sha256): + self.store.publish( + staged.model_copy( + update={"storage_key": existing.storage_key} + ) + ) + return media_asset(existing) + probe = self.probe.probe(staged.path) + stored = self.store.publish(staged) + media_id = uuid4().hex + record = MediaRecord( + media_id=media_id, + video_id=media_id, + sha256=stored.sha256, + original_filename=original_filename, + byte_size=stored.byte_size, + declared_mime_type=declared_mime_type, + detected_mime_type=probe.detected_mime_type, + container=probe.container, + duration_seconds=probe.duration_seconds, + streams=probe.streams, + storage_key=stored.storage_key, + state=MediaState.ready, + created_at=utc_now(), + ) + try: + authoritative = self.catalog.put_media(record) + except BaseException: + try: + retained = self.catalog.get_media_by_checksum(stored.sha256) + except Exception: + retained = None + if retained is None: + try: + self.store.delete(stored.storage_key) + except OSError: + pass + raise + if authoritative.storage_key != stored.storage_key: + try: + self.store.delete(stored.storage_key) + except OSError: + pass + return media_asset(authoritative) + + def get(self, media_id: str) -> MediaAsset: + return media_asset(self.require_record(media_id)) + + def list(self, command: ListMediaCommand) -> MediaPage: + scope = hashlib.sha256( + str(self.settings.repository_root.resolve()).encode() + ).hexdigest() + try: + offset = decode_offset_cursor(command.cursor, scope=scope) + except CursorError as exc: + raise ValueError("The media cursor is invalid.") from exc + total = self.catalog.count_media() + if offset > total: + raise ValueError("The media cursor is outside the result set.") + items = tuple( + media_asset(record) + for record in self.catalog.list_media( + limit=command.page_size, + offset=offset, + ) + ) + next_offset = offset + len(items) + cursor = ( + encode_offset_cursor(next_offset, scope=scope) + if next_offset < total + else None + ) + return MediaPage( + items=items, + total=total, + next_cursor=cursor, + ) + + def require_record(self, media_id: str) -> MediaRecord: + record = self.catalog.get_media(media_id) + if record is None or record.state != MediaState.ready: + raise MediaUnavailableError("The media asset is unavailable.") + return record + + def content(self, media_id: str) -> LocalFileResource: + record = self.require_record(media_id) + try: + path = self.store.verify( + record.storage_key, + sha256=record.sha256, + byte_size=record.byte_size, + ) + except (FileNotFoundError, PermissionError) as exc: + raise MediaUnavailableError( + "The managed media content is unavailable." + ) from exc + return LocalFileResource( + path=path, + filename=record.original_filename, + mime_type=record.detected_mime_type, + byte_size=record.byte_size, + etag=record.sha256, + ) + + def _resolve_import_source(self, path: Path) -> Path: + try: + source = path.expanduser().resolve(strict=True) + except (FileNotFoundError, OSError) as exc: + raise MediaUnavailableError( + "The local media source is unavailable." + ) from exc + if not source.is_file(): + raise MediaUnavailableError( + "The local media source is not a file." + ) + roots = self.settings.trusted_local_import_roots + if not roots: + return source + for configured in roots: + try: + root = configured.expanduser().resolve(strict=True) + except (FileNotFoundError, OSError): + continue + if root.is_dir() and source.is_relative_to(root): + return source + raise MediaImportNotAllowedError( + "The local media source is outside the trusted import roots." + ) diff --git a/src/vidxp/migrations/__init__.py b/src/vidxp/migrations/__init__.py new file mode 100644 index 0000000..8038de5 --- /dev/null +++ b/src/vidxp/migrations/__init__.py @@ -0,0 +1 @@ +"""VidXP Alembic migration package.""" diff --git a/src/vidxp/migrations/env.py b/src/vidxp/migrations/env.py new file mode 100644 index 0000000..106d917 --- /dev/null +++ b/src/vidxp/migrations/env.py @@ -0,0 +1,39 @@ +from alembic import context +from sqlalchemy import engine_from_config, pool + +from vidxp.infrastructure.sql_tables import metadata + + +def run_migrations_offline() -> None: + context.configure( + url=context.config.get_main_option("sqlalchemy.url"), + target_metadata=metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + engine = engine_from_config( + context.config.get_section(context.config.config_ini_section) or {}, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with engine.connect() as connection: + context.configure( + connection=connection, + target_metadata=metadata, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + engine.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/src/vidxp/migrations/versions/20260729_01_catalog_uploads.py b/src/vidxp/migrations/versions/20260729_01_catalog_uploads.py new file mode 100644 index 0000000..a94f834 --- /dev/null +++ b/src/vidxp/migrations/versions/20260729_01_catalog_uploads.py @@ -0,0 +1,173 @@ +"""Create the single-repository server catalog. + +Revision ID: 20260729_01 +Revises: +Create Date: 2026-07-29 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "20260729_01" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "media", + sa.Column("media_id", sa.String(length=32), nullable=False), + sa.Column("sha256", sa.String(length=64), nullable=False), + sa.Column("created_at", sa.Text(), nullable=False), + sa.Column("payload", sa.JSON(), nullable=False), + sa.PrimaryKeyConstraint("media_id"), + sa.UniqueConstraint("sha256"), + ) + op.create_table( + "artifacts", + sa.Column("artifact_id", sa.String(length=32), nullable=False), + sa.Column("media_id", sa.String(length=32), nullable=False), + sa.Column("created_at", sa.Text(), nullable=False), + sa.Column("payload", sa.JSON(), nullable=False), + sa.ForeignKeyConstraint(["media_id"], ["media.media_id"]), + sa.PrimaryKeyConstraint("artifact_id"), + ) + op.create_index( + "artifacts_media_id", + "artifacts", + ["media_id"], + unique=False, + ) + op.create_table( + "artifact_requests", + sa.Column("request_key", sa.String(length=64), nullable=False), + sa.Column("artifact_id", sa.String(length=32), nullable=False), + sa.ForeignKeyConstraint( + ["artifact_id"], + ["artifacts.artifact_id"], + ), + sa.PrimaryKeyConstraint("request_key"), + sa.UniqueConstraint("artifact_id"), + ) + op.create_table( + "media_import_requests", + sa.Column("request_key", sa.String(length=64), nullable=False), + sa.Column( + "request_fingerprint", + sa.String(length=64), + nullable=False, + ), + sa.Column("media_id", sa.String(length=32), nullable=True), + sa.ForeignKeyConstraint(["media_id"], ["media.media_id"]), + sa.PrimaryKeyConstraint("request_key"), + ) + op.create_table( + "index_state", + sa.Column("singleton_id", sa.String(length=1), nullable=False), + sa.Column("active_snapshot_id", sa.String(length=32), nullable=True), + sa.Column( + "active_snapshot_sha256", + sa.String(length=64), + nullable=True, + ), + sa.CheckConstraint( + "singleton_id = '1'", + name="index_state_singleton", + ), + sa.PrimaryKeyConstraint("singleton_id"), + ) + op.create_table( + "index_generations", + sa.Column("generation_id", sa.String(length=32), nullable=False), + sa.Column("media_id", sa.String(length=32), nullable=False), + sa.Column("manifest_sha256", sa.String(length=64), nullable=False), + sa.Column("payload", sa.JSON(), nullable=False), + sa.PrimaryKeyConstraint("generation_id"), + ) + op.create_index( + "index_generations_media", + "index_generations", + ["media_id"], + unique=False, + ) + op.create_table( + "index_snapshots", + sa.Column("snapshot_id", sa.String(length=32), nullable=False), + sa.Column("created_at", sa.Text(), nullable=False), + sa.Column("sha256", sa.String(length=64), nullable=False), + sa.Column("payload", sa.JSON(), nullable=False), + sa.PrimaryKeyConstraint("snapshot_id"), + ) + op.create_index( + "index_snapshots_created", + "index_snapshots", + ["created_at"], + unique=False, + ) + op.create_table( + "upload_quota", + sa.Column("singleton_id", sa.String(length=1), nullable=False), + sa.Column("reserved_bytes", sa.BigInteger(), nullable=False), + sa.CheckConstraint( + "reserved_bytes >= 0", + name="upload_quota_reserved_bytes_nonnegative", + ), + sa.CheckConstraint( + "singleton_id = '1'", + name="upload_quota_singleton", + ), + sa.PrimaryKeyConstraint("singleton_id"), + ) + op.create_table( + "upload_intents", + sa.Column("intent_id", sa.String(length=32), nullable=False), + sa.Column("request_key", sa.String(length=64), nullable=False), + sa.Column("original_filename", sa.String(length=255), nullable=False), + sa.Column("byte_size", sa.BigInteger(), nullable=False), + sa.Column( + "declared_mime_type", + sa.String(length=127), + nullable=True, + ), + sa.Column("state", sa.String(length=32), nullable=False), + sa.Column("created_at", sa.Text(), nullable=False), + sa.Column("expires_at", sa.Text(), nullable=False), + sa.Column("upload_id", sa.String(length=255), nullable=True), + sa.Column("job_id", sa.String(length=36), nullable=True), + sa.Column("media_id", sa.String(length=32), nullable=True), + sa.ForeignKeyConstraint(["media_id"], ["media.media_id"]), + sa.PrimaryKeyConstraint("intent_id"), + sa.UniqueConstraint("job_id"), + sa.UniqueConstraint("request_key"), + sa.UniqueConstraint("upload_id"), + ) + op.create_index( + "upload_intents_expiry", + "upload_intents", + ["expires_at", "state"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("upload_intents_expiry", table_name="upload_intents") + op.drop_table("upload_intents") + op.drop_table("upload_quota") + op.drop_index( + "index_snapshots_created", + table_name="index_snapshots", + ) + op.drop_table("index_snapshots") + op.drop_index( + "index_generations_media", + table_name="index_generations", + ) + op.drop_table("index_generations") + op.drop_table("index_state") + op.drop_table("media_import_requests") + op.drop_table("artifact_requests") + op.drop_index("artifacts_media_id", table_name="artifacts") + op.drop_table("artifacts") + op.drop_table("media") diff --git a/src/vidxp/migrations/versions/__init__.py b/src/vidxp/migrations/versions/__init__.py new file mode 100644 index 0000000..8d639b7 --- /dev/null +++ b/src/vidxp/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""Reviewed VidXP database revisions.""" diff --git a/src/vidxp/model_contracts.py b/src/vidxp/model_contracts.py new file mode 100644 index 0000000..6b6788e --- /dev/null +++ b/src/vidxp/model_contracts.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import re +from typing import Any + + +_IMMUTABLE_REVISION = re.compile(r"^[0-9a-f]{40,64}$") +_SHA256 = re.compile(r"^[0-9a-f]{64}$") + + +def loaded_compute_precision(model: Any, *, fallback: str) -> str: + try: + value = next(model.parameters()).dtype + except (AttributeError, StopIteration, TypeError): + return fallback + return str(value).removeprefix("torch.") + + +class ModelArtifactUnavailableError(RuntimeError): + def __init__(self, capability: str) -> None: + self.capability = capability + super().__init__(f"Model artifacts for {capability} are unavailable.") + + +@dataclass(frozen=True) +class ModelKey: + capability: str + provider: str + model_id: str + revision: str + device: str + + +@dataclass(frozen=True) +class ModelSpec: + capability: str + provider: str + model_id: str + revision: str + download_size_bytes: int + weights_file: str + weights_sha256: str + license: str + weights_precision: str + + def __post_init__(self) -> None: + if not _IMMUTABLE_REVISION.fullmatch(self.revision): + raise ValueError("Model revisions must be immutable commit hashes.") + if self.download_size_bytes <= 0: + raise ValueError("Model download sizes must be positive.") + if not _SHA256.fullmatch(self.weights_sha256): + raise ValueError("Model weight checksums must be SHA-256 hashes.") + + def key(self, device: str) -> ModelKey: + return ModelKey( + capability=self.capability, + provider=self.provider, + model_id=self.model_id, + revision=self.revision, + device=device, + ) + + def identity(self, *, cached: bool | None = None) -> dict[str, Any]: + identity = { + "provider": self.provider, + "model": self.model_id, + "revision": self.revision, + "download_size_bytes": self.download_size_bytes, + "weights": { + "file": self.weights_file, + "sha256": self.weights_sha256, + }, + "license": self.license, + "weights_precision": self.weights_precision, + } + if cached is not None: + identity["cached"] = cached + return identity + + +@dataclass(frozen=True) +class ArtifactSpec: + capability: str + provider: str + model_id: str + revision: str + download_size_bytes: int + url: str + filename: str + sha256: str + license: str + weights_precision: str + + def __post_init__(self) -> None: + if not _IMMUTABLE_REVISION.fullmatch(self.revision): + raise ValueError("Artifact revisions must be immutable commit hashes.") + if self.download_size_bytes <= 0: + raise ValueError("Artifact download sizes must be positive.") + if not _SHA256.fullmatch(self.sha256): + raise ValueError("Artifact checksums must be SHA-256 hashes.") + if not self.url.startswith("https://"): + raise ValueError("Artifact URLs must use HTTPS.") + + def key(self, device: str) -> ModelKey: + return ModelKey( + capability=self.capability, + provider=self.provider, + model_id=self.model_id, + revision=self.revision, + device=device, + ) + + def identity(self, *, cached: bool | None = None) -> dict[str, Any]: + identity = { + "provider": self.provider, + "model": self.model_id, + "revision": self.revision, + "download_size_bytes": self.download_size_bytes, + "artifact": { + "file": self.filename, + "sha256": self.sha256, + "url": self.url, + }, + "license": self.license, + "weights_precision": self.weights_precision, + } + if cached is not None: + identity["cached"] = cached + return identity + + +def model_artifact_path( + cache: Path, + spec: ModelSpec | ArtifactSpec, +) -> Path: + if isinstance(spec, ModelSpec): + repository = "models--" + spec.model_id.replace("/", "--") + return ( + cache + / repository + / "snapshots" + / spec.revision + / spec.weights_file + ) + return cache / spec.provider / spec.filename + + +def model_artifact_cached( + cache: Path, + spec: ModelSpec | ArtifactSpec, +) -> bool: + return model_artifact_path(cache, spec).is_file() diff --git a/src/vidxp/ports.py b/src/vidxp/ports.py new file mode 100644 index 0000000..4365800 --- /dev/null +++ b/src/vidxp/ports.py @@ -0,0 +1,413 @@ +from __future__ import annotations + +from pathlib import Path +from typing import ( + Any, + Callable, + ContextManager, + Iterable, + Mapping, + Protocol, + runtime_checkable, +) + +from vidxp.application_models import ( + DraftAnswer, + Job, + JobPage, + JobQueue, + JobRequest, + ListJobsCommand, + QueryModelIdentity, + QueryPlan, + QueryPlanningRequest, + QuerySynthesisRequest, + RuntimeProfile, +) +from vidxp.core.artifacts import ( + ArtifactRecord, + StagedArtifact, + StoredArtifact, +) +from vidxp.core.contracts import ( + CancellationToken, + IndexConfig, + StorageRecord, +) +from vidxp.core.indexing_common import ProgressCallback +from vidxp.core.media import ( + MediaProbe, + MediaRecord, + StagedMedia, + StoredMedia, +) +from vidxp.model_contracts import ArtifactSpec, ModelKey, ModelSpec + + +class InvalidJobBackendRequestError(ValueError): + """Raised when a durable job identifier or cursor is malformed.""" + + +class JobIdempotencyConflictError(RuntimeError): + """Raised when one workflow ID is reused for a different request.""" + + +class QueryProviderError(RuntimeError): + """Raised when the optional language-model provider cannot respond.""" + + +@runtime_checkable +class QueryModelPort(Protocol): + @property + def identity(self) -> QueryModelIdentity: ... + + def plan(self, request: QueryPlanningRequest) -> QueryPlan: ... + + def synthesize(self, request: QuerySynthesisRequest) -> DraftAnswer: ... + + +class LocalFileResource: + """Authorized local delivery handle; never serialize this object.""" + + __slots__ = ("path", "filename", "mime_type", "byte_size", "etag") + + def __init__( + self, + *, + path: Path, + filename: str, + mime_type: str, + byte_size: int, + etag: str, + ) -> None: + self.path = path + self.filename = filename + self.mime_type = mime_type + self.byte_size = byte_size + self.etag = etag + + +@runtime_checkable +class MediaCatalogPort(Protocol): + def get_media(self, media_id: str) -> MediaRecord | None: ... + + def get_media_by_checksum(self, sha256: str) -> MediaRecord | None: ... + + def put_media(self, record: MediaRecord) -> MediaRecord: ... + + def list_media( + self, + *, + limit: int, + offset: int = 0, + ) -> tuple[MediaRecord, ...]: ... + + def count_media(self) -> int: ... + + def reserve_media_import( + self, + request_key: str, + request_fingerprint: str, + ) -> MediaRecord | None: ... + + def complete_media_import( + self, + request_key: str, + request_fingerprint: str, + record: MediaRecord, + ) -> None: ... + + +@runtime_checkable +class MediaStorePort(Protocol): + def stage_local(self, path: Path) -> StagedMedia: ... + + def publication_lock(self, sha256: str) -> ContextManager[None]: ... + + def publish(self, staged: StagedMedia) -> StoredMedia: ... + + def discard(self, staged: StagedMedia) -> None: ... + + def delete(self, storage_key: str) -> None: ... + + def verify( + self, + storage_key: str, + *, + sha256: str, + byte_size: int, + ) -> Path: ... + + def resolve(self, storage_key: str) -> Path: ... + + +@runtime_checkable +class MediaProbePort(Protocol): + def probe(self, path: Path) -> MediaProbe: ... + + +@runtime_checkable +class ArtifactCatalogPort(Protocol): + def get_artifact(self, artifact_id: str) -> ArtifactRecord | None: ... + + def get_artifact_by_request( + self, + request_key: str, + ) -> ArtifactRecord | None: ... + + def invalidate_artifact_request( + self, + request_key: str, + artifact_id: str, + ) -> None: ... + + def put_artifact(self, record: ArtifactRecord) -> ArtifactRecord: ... + + +@runtime_checkable +class ArtifactStorePort(Protocol): + def stage(self, artifact_id: str, *, suffix: str) -> StagedArtifact: ... + + def recover( + self, + artifact_id: str, + *, + suffix: str, + ) -> StoredArtifact | None: ... + + def publish(self, staged: StagedArtifact) -> StoredArtifact: ... + + def discard(self, staged: StagedArtifact) -> None: ... + + def delete(self, storage_key: str) -> None: ... + + def verify( + self, + storage_key: str, + *, + sha256: str, + byte_size: int, + ) -> Path: ... + + def resolve(self, storage_key: str) -> Path: ... + + +@runtime_checkable +class ActorRendererPort(Protocol): + def render( + self, + source: Path, + destination: Path, + cluster_id: str, + detections: list[dict[str, Any]], + *, + cancellation: CancellationToken, + progress: ProgressCallback | None, + ) -> None: ... + + +@runtime_checkable +class SnippetRendererPort(Protocol): + def render( + self, + source: Path, + destination: Path, + *, + start_seconds: float, + end_seconds: float, + compatible_mp4: bool, + cancellation: CancellationToken, + progress: ProgressCallback | None, + ) -> None: ... + + +@runtime_checkable +class ResourceSchedulerPort(Protocol): + def indexing(self): ... + + def inference(self): ... + + +@runtime_checkable +class ModelRuntimePort(Protocol): + backends: RuntimeProfile + scheduler: ResourceSchedulerPort + + @property + def model_cache(self) -> Path: ... + + @property + def cpu_thread_budget(self) -> int: ... + + def device_for(self, capability: str) -> str: ... + + def get_or_load( + self, + key: ModelKey, + loader: Callable[[], Any], + ) -> Any: ... + + def resolve_model( + self, + spec: ModelSpec, + *, + download: bool = False, + progress: Callable[[dict[str, Any]], None] | None = None, + ) -> Path: ... + + def resolve_artifact( + self, + spec: ArtifactSpec, + *, + download: bool = False, + progress: Callable[[dict[str, Any]], None] | None = None, + ) -> Path: ... + + def record_compute_precision( + self, + capability: str, + precision: str, + ) -> None: ... + + def describe(self) -> dict[str, Any]: ... + + +@runtime_checkable +class IndexReader(Protocol): + """Read-only vector records available to application queries.""" + + def size_bytes(self) -> int | None: ... + + def query( + self, + modality: str, + embedding: list[float], + *, + top_k: int, + video_id: str | None = None, + filters: Mapping[str, Any] | None = None, + ) -> list[dict[str, Any]]: ... + + def records( + self, + modality: str, + *, + video_id: str | None = None, + filters: Mapping[str, Any] | None = None, + limit: int | None = None, + offset: int = 0, + ) -> list[dict[str, Any]]: ... + + def count_records( + self, + modality: str, + *, + video_id: str | None = None, + filters: Mapping[str, Any] | None = None, + ) -> int: ... + + +@runtime_checkable +class IndexStore(IndexReader, Protocol): + """Mutable vector records available only to indexing infrastructure.""" + + def clear(self, modalities: Iterable[str] | None = None) -> None: ... + + def delete_video(self, modality: str, video_id: str) -> None: ... + + def delete_generation( + self, + generation_id: str, + modalities: Iterable[str] | None = None, + ) -> None: ... + + def delete_records( + self, + modality: str, + *, + video_id: str, + filters: Mapping[str, Any] | None = None, + ) -> None: ... + + def upsert( + self, + modality: str, + records: list[StorageRecord], + *, + batch_size: int, + cancellation: CancellationToken, + ) -> int: ... + +class IndexBackend(Protocol): + """Infrastructure operations needed by the application layer.""" + + def status(self, index_directory: Path) -> dict[str, Any] | None: ... + + def active_config( + self, + index_directory: Path, + *, + device: str, + ) -> IndexConfig: ... + + def config_for_snapshot( + self, + index_directory: Path, + *, + snapshot_id: str, + snapshot_sha256: str, + device: str, + ) -> IndexConfig: ... + + def create( + self, + path: Path, + *, + config: IndexConfig, + progress: ProgressCallback | None, + cancellation: CancellationToken | None, + source_name: str | None, + source_checksum: str, + operation_id: str | None = None, + ) -> dict[str, Any]: ... + + def indexing_in_progress(self, config: IndexConfig) -> bool: ... + + def open_store(self, config: IndexConfig) -> ContextManager[IndexReader]: ... + + def remove(self, config: IndexConfig, media_id: str) -> bool: ... + + def clear(self, config: IndexConfig) -> bool: ... + + +class JobBackend(Protocol): + """Durable lifecycle operations owned by the workflow engine.""" + + def start(self) -> None: ... + + def submit( + self, + request: JobRequest, + *, + queue: JobQueue, + job_id: str | None = None, + ) -> Job: ... + + def get(self, job_id: str) -> Job | None: ... + + def list(self, command: ListJobsCommand) -> JobPage: ... + + def cancel(self, job_id: str) -> Job | None: ... + + def retry( + self, + job_id: str, + *, + retry_id: str | None = None, + ) -> Job | None: ... + + def health(self) -> None: ... + + def stop_worker(self) -> bool: ... + + def close(self) -> None: ... diff --git a/src/vidxp/query_service.py b/src/vidxp/query_service.py new file mode 100644 index 0000000..c8d0aaf --- /dev/null +++ b/src/vidxp/query_service.py @@ -0,0 +1,291 @@ +from __future__ import annotations + +import hashlib +import json + +from vidxp.application_models import ( + ActorEvidence, + DraftAnswer, + Evidence, + FusedSearchResult, + GroundedClaim, + IndexSnapshotReference, + MomentEvidence, + QueryAnswer, + QueryAnswerMode, + QueryPlan, + QueryPlanningRequest, + QuerySynthesisRequest, + QueryVideoCommand, + SearchMomentsPlanStep, + ActorOverviewPlanStep, +) +from vidxp.capabilities.actor.schemas import ActorClusterSummary +from vidxp.ports import QueryModelPort, QueryProviderError + + +def _evidence_id(payload: dict[str, object]) -> str: + encoded = json.dumps( + payload, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _default_plan( + command: QueryVideoCommand, + *, + search_modalities: tuple[str, ...], + actor_overview: bool, +) -> QueryPlan: + steps = [ + SearchMomentsPlanStep( + modality=modality, + query=command.question, + ) + for modality in search_modalities + ] + if actor_overview: + steps.append(ActorOverviewPlanStep()) + return QueryPlan(steps=tuple(steps)) + + +def _valid_plan( + plan: QueryPlan, + *, + search_modalities: tuple[str, ...], + actor_overview: bool, +) -> bool: + searches = [ + step.modality + for step in plan.steps + if isinstance(step, SearchMomentsPlanStep) + ] + actor_steps = sum( + isinstance(step, ActorOverviewPlanStep) for step in plan.steps + ) + return ( + len(searches) == len(set(searches)) + and set(searches) == set(search_modalities) + and actor_steps == int(actor_overview) + ) + + +class GroundedQueryService: + """Apply bounded model assistance to application-owned retrieval evidence.""" + + def __init__(self, model: QueryModelPort | None = None) -> None: + self.model = model + + def plan( + self, + command: QueryVideoCommand, + *, + search_modalities: tuple[str, ...], + actor_overview: bool, + ) -> tuple[QueryPlan, str | None]: + fallback = _default_plan( + command, + search_modalities=search_modalities, + actor_overview=actor_overview, + ) + if self.model is None: + return fallback, "query_model_not_configured" + try: + proposed = self.model.plan( + QueryPlanningRequest( + question=command.question, + allowed_modalities=search_modalities, + actor_overview_allowed=actor_overview, + ) + ) + except QueryProviderError: + return fallback, "query_model_unavailable" + if not _valid_plan( + proposed, + search_modalities=search_modalities, + actor_overview=actor_overview, + ): + return fallback, "query_plan_rejected" + return proposed, None + + @staticmethod + def evidence( + *, + snapshot: IndexSnapshotReference, + fused: FusedSearchResult, + actors: tuple[ActorClusterSummary, ...], + ) -> tuple[Evidence, ...]: + items: list[Evidence] = [] + evidence_ids: set[str] = set() + for moment in fused.moments: + for hit in moment.hits: + display_text = hit.metadata.get("text") + if not isinstance(display_text, str) or not display_text.strip(): + display_text = None + identity = { + "kind": "moment", + "snapshot_id": snapshot.snapshot_id, + "media_id": hit.media_id, + "generation_id": hit.generation_id, + "modality": hit.modality, + "source_id": hit.source_id, + "start": hit.start, + "end": hit.end, + } + evidence_id = _evidence_id(identity) + if evidence_id in evidence_ids: + continue + evidence_ids.add(evidence_id) + items.append( + MomentEvidence( + evidence_id=evidence_id, + snapshot_id=snapshot.snapshot_id, + display_text=display_text, + hit=hit, + **{ + key: value + for key, value in identity.items() + if key not in {"kind", "snapshot_id"} + }, + ) + ) + if len(items) == 200: + return tuple(items) + for actor in actors: + identity = { + "kind": "actor", + "snapshot_id": snapshot.snapshot_id, + "media_id": actor.media_id, + "generation_id": actor.generation_id, + "cluster_id": actor.cluster_id, + "start": actor.first_timestamp, + "end": actor.last_timestamp, + } + evidence_id = _evidence_id(identity) + if evidence_id in evidence_ids: + continue + evidence_ids.add(evidence_id) + items.append( + ActorEvidence( + evidence_id=evidence_id, + snapshot_id=snapshot.snapshot_id, + media_id=actor.media_id, + generation_id=actor.generation_id, + cluster_id=actor.cluster_id, + start=actor.first_timestamp, + end=actor.last_timestamp, + detection_count=actor.detection_count, + display_text=( + f"Actor cluster {actor.cluster_id} appears " + f"{actor.detection_count} times from " + f"{actor.first_timestamp:.3f}s to " + f"{actor.last_timestamp:.3f}s." + ), + ) + ) + if len(items) == 200: + break + return tuple(items) + + def answer( + self, + command: QueryVideoCommand, + *, + plan: QueryPlan, + planning_fallback: str | None, + evidence: tuple[Evidence, ...], + fused: FusedSearchResult, + ) -> QueryAnswer: + common = { + "question": command.question, + "plan": plan, + "model": self.model.identity if self.model is not None else None, + "evidence": evidence, + "moments": fused.moments, + "fusion": fused.fusion, + } + if not evidence: + return QueryAnswer( + mode=QueryAnswerMode.no_evidence, + fallback_reason="no_evidence", + **common, + ) + + citable = tuple( + item + for item in evidence + if isinstance(item, ActorEvidence) + or ( + isinstance(item, MomentEvidence) + and item.display_text is not None + ) + )[:200] + if self.model is None: + return QueryAnswer( + mode=QueryAnswerMode.evidence_only, + fallback_reason=planning_fallback, + **common, + ) + if planning_fallback == "query_model_unavailable": + return QueryAnswer( + mode=QueryAnswerMode.evidence_only, + fallback_reason=planning_fallback, + **common, + ) + if not citable: + return QueryAnswer( + mode=QueryAnswerMode.evidence_only, + fallback_reason="no_textual_evidence", + **common, + ) + try: + draft = self.model.synthesize( + QuerySynthesisRequest( + question=command.question, + evidence=citable, + ) + ) + except QueryProviderError: + return QueryAnswer( + mode=QueryAnswerMode.evidence_only, + fallback_reason="query_model_unavailable", + **common, + ) + claims = self._validated_claims(draft, citable) + if claims is None: + return QueryAnswer( + mode=QueryAnswerMode.evidence_only, + fallback_reason="query_citations_rejected", + **common, + ) + return QueryAnswer( + mode=QueryAnswerMode.generated, + claims=claims, + fallback_reason=planning_fallback, + **common, + ) + + @staticmethod + def _validated_claims( + draft: DraftAnswer, + evidence: tuple[Evidence, ...], + ) -> tuple[GroundedClaim, ...] | None: + allowed = {item.evidence_id for item in evidence} + claims = tuple( + GroundedClaim( + text=claim.text, + evidence_ids=claim.evidence_ids, + ) + for claim in draft.claims + ) + cited = { + evidence_id + for claim in claims + for evidence_id in claim.evidence_ids + } + if not cited.issubset(allowed): + return None + return claims diff --git a/src/vidxp/read_job_planner.py b/src/vidxp/read_job_planner.py new file mode 100644 index 0000000..19079b8 --- /dev/null +++ b/src/vidxp/read_job_planner.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from vidxp.application_boundary import application_boundary +from vidxp.application_models import ( + ActorOverlayJobRequest, + CreateActorOverlayCommand, + IndexSnapshotReference, + QueryJobRequest, + QueryVideoCommand, + SearchCommand, + SearchJobRequest, +) +from vidxp.capabilities.contracts import CapabilityRequestError +from vidxp.infrastructure.local_index import LocalIndexReader +from vidxp.repository_layout import RepositoryLayout + + +class LocalReadJobPlanner: + """Bind durable reads to one immutable snapshot without loading models.""" + + def __init__( + self, + *, + layout: RepositoryLayout, + index: LocalIndexReader | None = None, + ) -> None: + self.layout = layout + self.index = index or LocalIndexReader(layout) + + def _active(self): + config = self.index.active_config( + self.layout.indexes, + device="cpu", + ) + if config.snapshot_id is None or config.snapshot_sha256 is None: + raise RuntimeError( + "The active index did not provide an immutable reference." + ) + return config, IndexSnapshotReference( + snapshot_id=config.snapshot_id, + snapshot_sha256=config.snapshot_sha256, + ) + + @staticmethod + def _require_capability(capability: str, config) -> None: + if capability not in config.enabled_modalities: + raise CapabilityRequestError( + f"The {capability} capability is not present in this index." + ) + + @application_boundary + def plan_search(self, command: SearchCommand) -> SearchJobRequest: + config, reference = self._active() + for capability in command.modalities: + self._require_capability(capability, config) + return SearchJobRequest(command=command, snapshot=reference) + + @application_boundary + def plan_query(self, command: QueryVideoCommand) -> QueryJobRequest: + config, reference = self._active() + for capability in command.modalities: + self._require_capability(capability, config) + return QueryJobRequest(command=command, snapshot=reference) + + @application_boundary + def plan_actor_overlay( + self, + command: CreateActorOverlayCommand, + ) -> ActorOverlayJobRequest: + config, reference = self._active() + self._require_capability("actor", config) + return ActorOverlayJobRequest( + command=command, + snapshot=reference, + ) diff --git a/src/vidxp/readiness_service.py b/src/vidxp/readiness_service.py new file mode 100644 index 0000000..4b539cc --- /dev/null +++ b/src/vidxp/readiness_service.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from vidxp.application_models import ComponentReadiness, RuntimeReadiness +from vidxp.authentication import Authenticator +from vidxp.control_plane import ControlPlaneApplication +from vidxp.job_service import JobService + + +class ReadinessService: + """Transport-neutral aggregate health over injected application services.""" + + def __init__( + self, + *, + application: ControlPlaneApplication, + jobs: JobService, + authenticator: Authenticator, + ) -> None: + self.application = application + self.jobs = jobs + self.authenticator = authenticator + + def details(self) -> RuntimeReadiness: + try: + application = self.application.runtime_readiness() + except Exception: + application = RuntimeReadiness( + ready=False, + runtime=None, + components=( + ComponentReadiness( + name="application", + ready=False, + message="Application readiness is unavailable.", + ), + ), + dependencies=None, + ) + components = self._components(application.components) + return application.model_copy( + update={ + "ready": all(component.ready for component in components), + "components": tuple(components), + } + ) + + def ready(self) -> bool: + try: + application = self.application.control_plane_readiness() + except Exception: + application = ( + ComponentReadiness( + name="application", + ready=False, + message="Application readiness is unavailable.", + ), + ) + return all( + component.ready + for component in self._components(application) + ) + + def _components( + self, + application: tuple[ComponentReadiness, ...], + ) -> list[ComponentReadiness]: + components = list(application) + try: + components.append(self.authenticator.readiness()) + except Exception: + components.append( + ComponentReadiness( + name="authentication", + ready=False, + message="Authentication readiness is unavailable.", + ) + ) + try: + components.append(self.jobs.readiness()) + except Exception: + components.append( + ComponentReadiness( + name="workflow", + ready=False, + message="The durable workflow database is unavailable.", + ) + ) + return components diff --git a/src/vidxp/repositories.py b/src/vidxp/repositories.py index b0e4df7..db03dcf 100644 --- a/src/vidxp/repositories.py +++ b/src/vidxp/repositories.py @@ -3,17 +3,21 @@ import json import os import re -import sys from dataclasses import dataclass from pathlib import Path from typing import Any, Mapping from filelock import FileLock +from vidxp.app_paths import ( + default_config_directory, + default_data_directory, + default_repository_directory, +) REPOSITORY_SCHEMA_VERSION = 1 DEFAULT_REPOSITORY_NAME = "default" -DEFAULT_INDEX_DIRECTORY = Path("chroma_data") +DEFAULT_INDEX_DIRECTORY = default_repository_directory() _NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") @@ -25,15 +29,7 @@ def default_config_path() -> Path: configured = os.environ.get("VIDXP_CONFIG_FILE") if configured: return Path(configured).expanduser() - if sys.platform == "win32": - root = Path(os.environ.get("APPDATA", Path.home() / "AppData/Roaming")) - elif sys.platform == "darwin": - root = Path.home() / "Library/Application Support" - else: - root = Path( - os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config") - ) - return root / "vidxp" / "repositories.json" + return default_config_directory() / "repositories.json" def _repository_name(value: str) -> str: @@ -74,8 +70,18 @@ def to_dict(self) -> dict[str, Any]: class RepositoryRegistry: - def __init__(self, path: str | Path | None = None) -> None: + def __init__( + self, + path: str | Path | None = None, + *, + default_index_directory: str | Path | None = None, + ) -> None: self.path = Path(path) if path is not None else default_config_path() + self.default_index_directory = Path( + default_index_directory + if default_index_directory is not None + else default_repository_directory() + ).expanduser() def read(self) -> dict[str, Any]: return self._read_unlocked() @@ -115,7 +121,7 @@ def list(self) -> tuple[RepositoryConfig, ...]: return ( RepositoryConfig( DEFAULT_REPOSITORY_NAME, - DEFAULT_INDEX_DIRECTORY, + self.default_index_directory, configured=False, ), *configured, @@ -134,7 +140,7 @@ def resolve(self, name: str | None = None) -> RepositoryConfig: if selected == DEFAULT_REPOSITORY_NAME: return RepositoryConfig( DEFAULT_REPOSITORY_NAME, - DEFAULT_INDEX_DIRECTORY, + self.default_index_directory, configured=False, ) raise RepositoryConfigError( @@ -193,7 +199,7 @@ def use(self, name: str) -> RepositoryConfig: elif selected == DEFAULT_REPOSITORY_NAME: repository = RepositoryConfig( DEFAULT_REPOSITORY_NAME, - DEFAULT_INDEX_DIRECTORY, + self.default_index_directory, configured=False, ) payload["active_repository"] = None @@ -275,9 +281,20 @@ def resolve_repository( registry_path: str | Path | None = None, name: str | None = None, index_directory: str | Path | None = None, + data_directory: str | Path | None = None, device: str | None = None, ) -> tuple[RepositoryRegistry, RepositoryConfig]: - registry = RepositoryRegistry(registry_path) + active_data_directory = Path( + data_directory + if data_directory is not None + else os.environ.get("VIDXP_DATA_DIR") or default_data_directory() + ).expanduser() + registry = RepositoryRegistry( + registry_path, + default_index_directory=default_repository_directory( + active_data_directory + ), + ) selected_name = name or os.environ.get("VIDXP_REPOSITORY") repository = registry.resolve(selected_name) resolved_index = ( @@ -285,8 +302,12 @@ def resolve_repository( if index_directory is not None else Path(os.environ["VIDXP_INDEX_DIR"]) if os.environ.get("VIDXP_INDEX_DIR") + else default_repository_directory(active_data_directory) + if not repository.configured else repository.index_directory ) + if not repository.configured: + registry.default_index_directory = resolved_index.expanduser() resolved_device = ( device if device is not None diff --git a/src/vidxp/repository_layout.py b/src/vidxp/repository_layout.py new file mode 100644 index 0000000..5014c87 --- /dev/null +++ b/src/vidxp/repository_layout.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from pathlib import Path + +from pydantic import BaseModel, ConfigDict + + +class RepositoryLayout(BaseModel): + model_config = ConfigDict(frozen=True) + + root: Path + + @property + def descriptor(self) -> Path: + return self.root / "repository.json" + + @property + def media(self) -> Path: + return self.root / "media" + + @property + def catalog(self) -> Path: + return self.root / "catalog.sqlite3" + + @property + def media_objects(self) -> Path: + return self.media / "objects" + + @property + def indexes(self) -> Path: + return self.root / "indexes" + + @property + def local_index(self) -> Path: + return self.indexes + + @property + def generations(self) -> Path: + return self.indexes / "generations" + + @property + def index_store(self) -> Path: + return self.indexes / "store" + + @property + def snapshots(self) -> Path: + return self.indexes / "snapshots" + + @property + def active_snapshot(self) -> Path: + return self.indexes / "active-snapshot.json" + + @property + def index_lease(self) -> Path: + return self.indexes / ".repository.lock" + + @property + def artifacts(self) -> Path: + return self.root / "artifacts" + + @property + def artifact_objects(self) -> Path: + return self.artifacts / "objects" + + @property + def local_workflows(self) -> Path: + return self.root / "local-workflows" + + @property + def workflow_database(self) -> Path: + return self.local_workflows / "jobs.sqlite3" + + def ensure_local_directories(self) -> None: + for path in ( + self.root, + self.media, + self.indexes, + self.index_store, + self.generations, + self.snapshots, + self.artifacts, + self.local_workflows, + ): + path.mkdir(parents=True, exist_ok=True) diff --git a/src/vidxp/requirements/frontend.txt b/src/vidxp/requirements/frontend.txt index c8038a5..116dc32 100644 --- a/src/vidxp/requirements/frontend.txt +++ b/src/vidxp/requirements/frontend.txt @@ -1 +1 @@ -streamlit>=1.37 +streamlit>=1.60,<2 diff --git a/src/vidxp/requirements/mcp.txt b/src/vidxp/requirements/mcp.txt new file mode 100644 index 0000000..12a0a3a --- /dev/null +++ b/src/vidxp/requirements/mcp.txt @@ -0,0 +1 @@ +mcp>=2.0,<3 diff --git a/src/vidxp/requirements/server-storage.txt b/src/vidxp/requirements/server-storage.txt new file mode 100644 index 0000000..dfb0435 --- /dev/null +++ b/src/vidxp/requirements/server-storage.txt @@ -0,0 +1 @@ +chromadb-client>=1.5.9,<2 diff --git a/src/vidxp/requirements/server.txt b/src/vidxp/requirements/server.txt new file mode 100644 index 0000000..9810ff7 --- /dev/null +++ b/src/vidxp/requirements/server.txt @@ -0,0 +1,8 @@ +asgi-correlation-id>=5.0.1,<6 +alembic>=1.18.5,<2 +fastapi>=0.140.13,<0.141 +psutil>=7.2.2,<8 +psycopg[binary]>=3.3.4,<4 +pyjwt[crypto]>=2.13,<3 +python-multipart>=0.0.32,<0.1 +uvicorn[standard]>=0.51,<0.52 diff --git a/src/vidxp/requirements/slm.txt b/src/vidxp/requirements/slm.txt new file mode 100644 index 0000000..d4d765f --- /dev/null +++ b/src/vidxp/requirements/slm.txt @@ -0,0 +1 @@ +pydantic-ai-slim[openai]>=2.13,<3 diff --git a/src/vidxp/requirements/storage.txt b/src/vidxp/requirements/storage.txt index 99812b1..963f2c1 100644 --- a/src/vidxp/requirements/storage.txt +++ b/src/vidxp/requirements/storage.txt @@ -1 +1,2 @@ -chromadb +chromadb>=1.5.9,<2 +psutil>=7.2.2,<8 diff --git a/src/vidxp/requirements/test.txt b/src/vidxp/requirements/test.txt new file mode 100644 index 0000000..f1de72e --- /dev/null +++ b/src/vidxp/requirements/test.txt @@ -0,0 +1,2 @@ +httpx>=0.28.1,<0.29 +pytest>=9.1.1,<10 diff --git a/src/vidxp/runtime.py b/src/vidxp/runtime.py new file mode 100644 index 0000000..4fe70d7 --- /dev/null +++ b/src/vidxp/runtime.py @@ -0,0 +1,407 @@ +from __future__ import annotations + +import platform +import hashlib +from collections import OrderedDict +from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeout +from contextlib import contextmanager +from threading import BoundedSemaphore, Lock, RLock +from time import monotonic +from typing import Any, Callable, Iterator +from pathlib import Path + +from vidxp.application_models import RuntimeProfile +from vidxp.model_contracts import ( + ArtifactSpec, + ModelArtifactUnavailableError, + ModelKey, + ModelSpec, +) +from vidxp.settings import VidXPSettings + + +class RuntimeBackendUnavailableError(RuntimeError): + """Raised when an explicitly requested compute backend cannot be used.""" + + +def _torch_accelerators() -> tuple[bool, bool]: + try: + import torch + except ModuleNotFoundError: + return False, False + mps = bool( + getattr(torch.backends, "mps", None) + and torch.backends.mps.is_available() + ) + return mps, bool(torch.cuda.is_available()) + + +def resolve_backends(requested: str) -> RuntimeProfile: + mps_available, cuda_available = _torch_accelerators() + normalized = requested.lower() + if normalized == "auto": + torch_device = "cpu" + elif normalized == "mps": + if not mps_available: + raise RuntimeBackendUnavailableError( + "MPS was requested but is unavailable." + ) + torch_device = "mps" + elif normalized.startswith("cuda"): + if not cuda_available: + raise RuntimeBackendUnavailableError( + "CUDA was requested but is unavailable." + ) + if ":" in normalized: + import torch + + index = int(normalized.split(":", 1)[1]) + if index >= torch.cuda.device_count(): + raise RuntimeBackendUnavailableError( + f"CUDA device {index} was requested but only " + f"{torch.cuda.device_count()} device(s) are available." + ) + torch_device = normalized + elif normalized == "cpu": + torch_device = "cpu" + else: + raise RuntimeBackendUnavailableError( + f"Unsupported runtime backend: {requested!r}." + ) + return RuntimeProfile( + requested=normalized, + torch_device=torch_device, + transcription_device=( + normalized if normalized.startswith("cuda") else "cpu" + ), + mps_available=mps_available, + cuda_available=cuda_available, + ) + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +class ResourceScheduler: + """Bound concurrent model work without owning workflow state.""" + + def __init__( + self, + *, + indexing_slots: int, + inference_slots: int, + ) -> None: + self._indexing = BoundedSemaphore(indexing_slots) + self._inference = BoundedSemaphore(inference_slots) + + @contextmanager + def indexing(self) -> Iterator[None]: + with self._indexing: + yield + + @contextmanager + def inference(self) -> Iterator[None]: + with self._inference: + yield + + +class ModelRuntime: + """One injected model cache and backend resolver for all capabilities.""" + + def __init__( + self, + settings: VidXPSettings, + *, + allowed_specs: tuple[ModelSpec | ArtifactSpec, ...] = (), + ) -> None: + self.settings = settings + self._allowed_specs = frozenset(allowed_specs) + self.backends = resolve_backends(settings.runtime_backend) + self.scheduler = ResourceScheduler( + indexing_slots=settings.max_concurrent_indexing, + inference_slots=settings.max_concurrent_inference, + ) + self._resources: OrderedDict[ModelKey, Any] = OrderedDict() + self._resolved_models: dict[str, dict[str, Any]] = {} + self._compute_precision: dict[str, str] = {} + self._load_locks: dict[ModelKey, Lock] = {} + self._lock = RLock() + + @property + def model_cache(self) -> Path: + return self.settings.model_cache + + @property + def cpu_thread_budget(self) -> int: + return self.settings.cpu_thread_budget + + def _configure_cpu_threads(self) -> None: + try: + import torch + except ModuleNotFoundError: + pass + else: + torch.set_num_threads(self.cpu_thread_budget) + try: + import cv2 + except ModuleNotFoundError: + pass + else: + cv2.setNumThreads(self.cpu_thread_budget) + + def device_for(self, capability: str) -> str: + if capability == "dialogue.transcription": + return self.backends.transcription_device + if capability == "actor": + return self.backends.actor_device + return self.backends.torch_device + + @staticmethod + def _download_snapshot( + spec: ModelSpec, + *, + cache: Path, + progress: Callable[[dict[str, Any]], None] | None, + ) -> Path: + from huggingface_hub import constants, snapshot_download + from tqdm.auto import tqdm + + # Xet can remain parked at zero bytes without surfacing an error. + # The regular HTTP path has bounded read timeouts and reports bytes + # through tqdm, which is required for durable preparation progress. + constants.HF_HUB_DISABLE_XET = True + state_lock = Lock() + state: dict[str, Any] = { + "current": 0, + "total": spec.download_size_bytes, + "message": f"Connecting to download {spec.model_id}.", + } + + class ReportingTqdm(tqdm): + def display(self, msg=None, pos=None) -> None: + return None + + def update(self, n=1): + result = super().update(n) + if self.unit == "B": + with state_lock: + state.update( + { + "current": int(self.n), + "total": ( + int(self.total) + if self.total + else None + ), + "message": f"Downloading {spec.model_id}.", + } + ) + return result + + def download() -> str: + return snapshot_download( + repo_id=spec.model_id, + revision=spec.revision, + cache_dir=str(cache), + local_files_only=False, + tqdm_class=ReportingTqdm, + ) + + reported_at = 0.0 + with ThreadPoolExecutor(max_workers=1) as pool: + future = pool.submit(download) + while True: + try: + snapshot = future.result(timeout=0.5) + if progress is not None: + with state_lock: + event = dict(state) + progress( + { + "state": "preparing", + "stage": "downloading_model", + **event, + } + ) + break + except FutureTimeout: + now = monotonic() + if progress is None or now - reported_at < 1: + continue + with state_lock: + event = dict(state) + progress( + { + "state": "preparing", + "stage": "downloading_model", + **event, + } + ) + reported_at = now + return Path(snapshot) + + def resolve_model( + self, + spec: ModelSpec, + *, + download: bool = False, + progress: Callable[[dict[str, Any]], None] | None = None, + ) -> Path: + if spec not in self._allowed_specs: + raise ModelArtifactUnavailableError(spec.capability) + from huggingface_hub import snapshot_download + + try: + try: + snapshot = Path( + snapshot_download( + repo_id=spec.model_id, + revision=spec.revision, + cache_dir=str(self.settings.model_cache), + local_files_only=True, + ) + ) + except Exception: + if not download or not self.settings.allow_model_downloads: + raise ModelArtifactUnavailableError(spec.capability) + snapshot = self._download_snapshot( + spec, + cache=self.settings.model_cache, + progress=progress, + ) + weights = snapshot / spec.weights_file + if not weights.is_file() or _sha256(weights) != spec.weights_sha256: + raise ModelArtifactUnavailableError(spec.capability) + except ModelArtifactUnavailableError: + raise + except Exception as exc: + raise ModelArtifactUnavailableError(spec.capability) from exc + with self._lock: + self._resolved_models[spec.capability] = spec.identity(cached=True) + return snapshot + + def resolve_artifact( + self, + spec: ArtifactSpec, + *, + download: bool = False, + progress: Callable[[dict[str, Any]], None] | None = None, + ) -> Path: + if spec not in self._allowed_specs: + raise ModelArtifactUnavailableError(spec.capability) + try: + destination = self.settings.model_cache / spec.provider + path = destination / spec.filename + if not path.is_file() or _sha256(path) != spec.sha256: + if not download or not self.settings.allow_model_downloads: + raise ModelArtifactUnavailableError(spec.capability) + if progress is not None: + progress( + { + "state": "preparing", + "stage": "downloading_model", + "message": f"Downloading {spec.model_id}.", + } + ) + import pooch + + resolved = Path( + pooch.retrieve( + url=spec.url, + known_hash=f"sha256:{spec.sha256}", + fname=spec.filename, + path=destination, + progressbar=False, + ) + ) + else: + resolved = path + if not resolved.is_file() or _sha256(resolved) != spec.sha256: + raise ModelArtifactUnavailableError(spec.capability) + except ModelArtifactUnavailableError: + raise + except Exception as exc: + raise ModelArtifactUnavailableError(spec.capability) from exc + with self._lock: + self._resolved_models[spec.capability] = spec.identity(cached=True) + return resolved + + def record_compute_precision( + self, + capability: str, + precision: str, + ) -> None: + with self._lock: + self._compute_precision[capability] = precision + + def get_or_load( + self, + key: ModelKey, + loader: Callable[[], Any], + ) -> Any: + with self._lock: + if key in self._resources: + resource = self._resources.pop(key) + self._resources[key] = resource + return resource + key_lock = self._load_locks.setdefault(key, Lock()) + + with key_lock: + with self._lock: + if key in self._resources: + resource = self._resources.pop(key) + self._resources[key] = resource + return resource + self._configure_cpu_threads() + resource = loader() + with self._lock: + existing = self._resources.pop(key, None) + if existing is not None: + return existing + self._resources[key] = resource + while len(self._resources) > self.settings.max_loaded_models: + self._resources.popitem(last=False) + self._load_locks.pop(key, None) + return resource + + def clear(self) -> None: + with self._lock: + self._resources.clear() + self._load_locks.clear() + try: + import torch + except ModuleNotFoundError: + return + if torch.cuda.is_available(): + torch.cuda.empty_cache() + if ( + getattr(torch.backends, "mps", None) + and torch.backends.mps.is_available() + ): + torch.mps.empty_cache() + + def describe(self) -> dict[str, Any]: + return { + "platform": platform.system().lower(), + "architecture": platform.machine().lower(), + **self.backends.model_dump(mode="json"), + "model_cache": str(self.settings.model_cache), + "allow_model_downloads": self.settings.allow_model_downloads, + "limits": { + "max_loaded_models": self.settings.max_loaded_models, + "max_concurrent_indexing": ( + self.settings.max_concurrent_indexing + ), + "max_concurrent_inference": ( + self.settings.max_concurrent_inference + ), + "cpu_thread_budget": self.settings.cpu_thread_budget, + }, + "resolved_models": dict(self._resolved_models), + "compute_precision": dict(self._compute_precision), + } diff --git a/src/vidxp/search_fusion.py b/src/vidxp/search_fusion.py new file mode 100644 index 0000000..b2f4828 --- /dev/null +++ b/src/vidxp/search_fusion.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import hashlib + +from vidxp.application_models import ( + FusedMoment, + FusedSearchResult, + FusionProvenance, + SearchHit, + SearchResult, +) + + +RRF_RANK_CONSTANT = 60 + + +def _query_id( + query: str, + modalities: tuple[str, ...], + media_id: str | None, + atomic_query_ids: tuple[str, ...], +) -> str: + identity = "\0".join( + ( + "rrf_v1", + query, + ",".join(modalities), + media_id or "*", + *atomic_query_ids, + ) + ) + return "fused:" + hashlib.sha256(identity.encode("utf-8")).hexdigest() + + +def _connected_components( + hits: tuple[SearchHit, ...], +) -> list[list[SearchHit]]: + ordered = sorted( + hits, + key=lambda hit: ( + hit.media_id, + hit.start, + hit.end, + hit.modality, + hit.rank, + hit.source_id, + ), + ) + components: list[list[SearchHit]] = [] + current: list[SearchHit] = [] + current_media: str | None = None + current_end = 0.0 + for hit in ordered: + if ( + not current + or hit.media_id != current_media + or hit.start > current_end + ): + if current: + components.append(current) + current = [hit] + current_media = hit.media_id + current_end = hit.end + else: + current.append(hit) + current_end = max(current_end, hit.end) + if current: + components.append(current) + return components + + +def _score(hits: list[SearchHit]) -> float: + best_ranks: dict[str, int] = {} + for hit in hits: + best_ranks[hit.modality] = min( + hit.rank, + best_ranks.get(hit.modality, hit.rank), + ) + return sum( + 1.0 / (RRF_RANK_CONSTANT + rank) + for rank in best_ranks.values() + ) + + +def fuse_search_results( + *, + query: str, + requested_modalities: tuple[str, ...], + results: tuple[SearchResult, ...], + media_id: str | None = None, + top_k: int = 10, +) -> FusedSearchResult: + by_modality = {result.modality: result for result in results} + if len(by_modality) != len(results): + raise ValueError("Fusion accepts one result per modality.") + searched_modalities = tuple( + modality + for modality in requested_modalities + if modality in by_modality + ) + tuple( + sorted(set(by_modality) - set(requested_modalities)) + ) + ordered_results = tuple( + by_modality[modality] for modality in searched_modalities + ) + flattened = tuple( + hit for result in ordered_results for hit in result.hits + ) + candidates = [] + for hits in _connected_components(flattened): + ordered_hits = tuple( + sorted( + hits, + key=lambda hit: ( + hit.modality, + hit.rank, + hit.source_id, + ), + ) + ) + candidates.append( + { + "score": _score(hits), + "media_id": hits[0].media_id, + "start": min(hit.start for hit in hits), + "end": max(hit.end for hit in hits), + "modalities": tuple( + sorted({hit.modality for hit in hits}) + ), + "hits": ordered_hits, + } + ) + candidates.sort( + key=lambda item: ( + -item["score"], + item["media_id"], + item["start"], + item["end"], + tuple(hit.source_id for hit in item["hits"]), + ) + ) + moments = tuple( + FusedMoment(rank=rank, **candidate) + for rank, candidate in enumerate(candidates[:top_k], start=1) + ) + return FusedSearchResult( + query_id=_query_id( + query, + searched_modalities, + media_id, + tuple(result.query_id for result in ordered_results), + ), + query=query, + modalities=searched_modalities, + moments=moments, + fusion=FusionProvenance( + requested_modalities=requested_modalities, + searched_modalities=searched_modalities, + ), + ) diff --git a/src/vidxp/settings.py b/src/vidxp/settings.py new file mode 100644 index 0000000..18a2f1c --- /dev/null +++ b/src/vidxp/settings.py @@ -0,0 +1,673 @@ +from __future__ import annotations + +import os +import re +from enum import StrEnum +from pathlib import Path +from urllib.parse import urlsplit + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + SecretStr, + ValidationInfo, + field_validator, + model_validator, +) +from pydantic_settings import BaseSettings, SettingsConfigDict + +from vidxp.app_paths import ( + default_data_directory, + default_model_directory, + default_repository_directory, +) +from vidxp.media_runtime import default_media_executable +from vidxp.repository_layout import RepositoryLayout + + +class ApplicationMode(StrEnum): + local = "local" + remote = "remote" + server = "server" + + +class HttpAuthMode(StrEnum): + none = "none" + static = "static" + oidc = "oidc" + + +class VidXPSettings(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="VIDXP_", + env_nested_delimiter="__", + extra="ignore", + frozen=True, + ) + + mode: ApplicationMode = ApplicationMode.local + data_dir: Path = Field(default_factory=default_data_directory) + repository_root: Path = Field( + default_factory=default_repository_directory + ) + runtime_backend: str = "auto" + model_cache: Path = Field( + default_factory=default_model_directory + ) + allow_model_downloads: bool = True + max_loaded_models: int = Field(default=3, gt=0, le=16) + max_concurrent_indexing: int = Field(default=1, gt=0, le=16) + max_concurrent_inference: int = Field(default=2, gt=0, le=64) + workflow_poll_interval_seconds: float = Field( + default=0.25, + gt=0, + le=10, + ) + cpu_thread_budget: int = Field( + default_factory=lambda: min(256, max(1, os.cpu_count() or 1)), + gt=0, + le=256, + ) + max_local_import_bytes: int = Field( + default=50 * 1024 * 1024 * 1024, + gt=0, + ) + max_snippet_duration_seconds: float = Field( + default=300, + gt=0, + le=3600, + ) + http_bind_host: str = Field(default="127.0.0.1", min_length=1) + http_port: int = Field(default=8000, gt=0, le=65535) + http_auth_mode: HttpAuthMode = HttpAuthMode.none + http_static_bearer_token: SecretStr | None = None + http_oidc_issuer: str | None = Field( + default=None, + min_length=1, + max_length=2048, + ) + http_oidc_audience: str | None = Field( + default=None, + min_length=1, + max_length=512, + ) + http_oidc_jwks_url: str | None = Field( + default=None, + min_length=1, + max_length=2048, + ) + http_oidc_algorithms: tuple[str, ...] = ("RS256",) + http_required_scopes: tuple[str, ...] = () + http_trusted_hosts: tuple[str, ...] = ( + "127.0.0.1", + "::1", + "localhost", + "testserver", + ) + http_allowed_origins: tuple[str, ...] = () + http_max_json_body_bytes: int = Field( + default=4 * 1024 * 1024, + gt=0, + le=16 * 1024 * 1024, + ) + http_max_small_upload_bytes: int = Field( + default=256 * 1024 * 1024, + gt=0, + le=256 * 1024 * 1024, + ) + mcp_public_url: str | None = Field( + default=None, + min_length=1, + max_length=2048, + ) + mcp_max_request_body_bytes: int = Field( + default=4 * 1024 * 1024, + gt=0, + le=16 * 1024 * 1024, + ) + mcp_allowed_hosts: tuple[str, ...] = ( + "127.0.0.1:*", + "[::1]:*", + "localhost:*", + "testserver", + ) + mcp_allowed_origins: tuple[str, ...] = () + upload_public_endpoint: str | None = Field( + default=None, + min_length=1, + max_length=2048, + ) + upload_internal_endpoint: str | None = Field( + default=None, + min_length=1, + max_length=2048, + ) + upload_max_bytes: int = Field( + default=50 * 1024 * 1024 * 1024, + gt=0, + ) + upload_quota_bytes: int = Field( + default=100 * 1024 * 1024 * 1024, + gt=0, + ) + upload_intent_ttl_seconds: int = Field( + default=24 * 60 * 60, + ge=300, + le=7 * 24 * 60 * 60, + ) + upload_recovery_interval_seconds: int = Field( + default=60, + ge=10, + le=3600, + ) + upload_quarantine_root: Path | None = None + upload_cleanup_token: SecretStr | None = None + slm_base_url: str | None = Field( + default=None, + min_length=1, + max_length=2048, + ) + slm_model: str | None = Field( + default=None, + min_length=1, + max_length=255, + ) + slm_timeout_seconds: float = Field(default=60, gt=0, le=600) + slm_output_retries: int = Field(default=1, ge=0, le=3) + trusted_local_import_roots: tuple[Path, ...] = () + ffprobe_executable: str = Field( + default_factory=lambda: default_media_executable("ffprobe"), + min_length=1, + ) + ffmpeg_executable: str = Field( + default_factory=lambda: default_media_executable("ffmpeg"), + min_length=1, + ) + external_capabilities: bool = False + capability_allowlist: tuple[str, ...] = () + + @model_validator(mode="before") + @classmethod + def _derive_storage_paths(cls, value): + if not isinstance(value, dict): + return value + configured = dict(value) + data_directory = Path( + configured.get("data_dir") or default_data_directory() + ).expanduser() + configured.setdefault("data_dir", data_directory) + configured.setdefault( + "repository_root", + default_repository_directory(data_directory), + ) + configured.setdefault( + "model_cache", + default_model_directory(data_directory), + ) + return configured + + @field_validator("slm_base_url", "slm_model", mode="before") + @classmethod + def _normalize_empty_optional_slm( + cls, + value: str | None, + ) -> str | None: + return None if value == "" else value + + @field_validator("runtime_backend") + @classmethod + def _validate_runtime_backend(cls, value: str) -> str: + backend = value.strip().lower() + if not re.fullmatch(r"(auto|cpu|mps|cuda(?::[0-9]+)?)", backend): + raise ValueError( + "runtime_backend must be auto, cpu, mps, cuda, " + "or cuda:." + ) + return backend + + @field_validator("capability_allowlist") + @classmethod + def _clean_allowlist(cls, values: tuple[str, ...]) -> tuple[str, ...]: + cleaned = tuple( + dict.fromkeys(value.strip() for value in values if value.strip()) + ) + invalid = [ + value + for value in cleaned + if value.count(":") != 1 + or not all(part.strip() for part in value.split(":", 1)) + ] + if invalid: + raise ValueError( + "capability_allowlist entries must use " + "DISTRIBUTION:ENTRY_POINT." + ) + return cleaned + + @field_validator( + "http_required_scopes", + "http_trusted_hosts", + "http_allowed_origins", + "mcp_allowed_hosts", + "mcp_allowed_origins", + ) + @classmethod + def _clean_http_lists(cls, values: tuple[str, ...]) -> tuple[str, ...]: + return tuple( + dict.fromkeys(value.strip() for value in values if value.strip()) + ) + + @field_validator("http_trusted_hosts") + @classmethod + def _validate_trusted_hosts( + cls, + values: tuple[str, ...], + ) -> tuple[str, ...]: + normalized = tuple(value.lower() for value in values) + for value in normalized: + if ( + "*" in value[1:] + or ( + value.startswith("*") + and value != "*" + and not value.startswith("*.") + ) + ): + raise ValueError( + "Trusted-host wildcards must use *.example.com." + ) + return normalized + + @field_validator("mcp_allowed_hosts") + @classmethod + def _validate_mcp_hosts( + cls, + values: tuple[str, ...], + ) -> tuple[str, ...]: + normalized = tuple(value.lower() for value in values) + for value in normalized: + if "*" in value and not value.endswith(":*"): + raise ValueError( + "MCP host wildcards are supported only as host:*." + ) + if "/" in value or "://" in value: + raise ValueError( + "MCP allowed hosts must be Host header values." + ) + return normalized + + @field_validator("mcp_allowed_origins") + @classmethod + def _validate_mcp_origins( + cls, + values: tuple[str, ...], + ) -> tuple[str, ...]: + for value in values: + candidate = value[:-2] if value.endswith(":*") else value + parsed = urlsplit(candidate) + if ( + value == "null" + or parsed.scheme not in {"http", "https"} + or parsed.hostname is None + or parsed.username is not None + or parsed.password is not None + or parsed.path + or parsed.query + or parsed.fragment + ): + raise ValueError( + "MCP allowed origins must be serialized HTTP origins." + ) + try: + parsed.port + except ValueError as exc: + raise ValueError( + "An MCP allowed origin contains an invalid port." + ) from exc + if "*" in value and not value.endswith(":*"): + raise ValueError( + "MCP origin wildcards are supported only as origin:*." + ) + return values + + @field_validator("http_oidc_algorithms") + @classmethod + def _validate_oidc_algorithms( + cls, + values: tuple[str, ...], + ) -> tuple[str, ...]: + allowed = { + "RS256", + "RS384", + "RS512", + "ES256", + "ES384", + "ES512", + "EdDSA", + } + cleaned = tuple(dict.fromkeys(values)) + if not cleaned or any(value not in allowed for value in cleaned): + raise ValueError( + "http_oidc_algorithms must contain supported asymmetric " + "signature algorithms." + ) + return cleaned + + @field_validator("http_oidc_issuer", "http_oidc_jwks_url") + @classmethod + def _validate_oidc_url( + cls, + value: str | None, + info: ValidationInfo, + ) -> str | None: + if value is None: + return None + if value != value.strip(): + raise ValueError(f"{info.field_name} must not contain whitespace.") + if "\\" in value or any( + character.isspace() + or ord(character) < 32 + or ord(character) == 127 + for character in value + ): + raise ValueError( + f"{info.field_name} contains an unsafe URL character." + ) + parsed = urlsplit(value) + if parsed.scheme not in {"http", "https"} or parsed.hostname is None: + raise ValueError(f"{info.field_name} must be an HTTP URL.") + try: + parsed.port + except ValueError as exc: + raise ValueError( + f"{info.field_name} contains an invalid port." + ) from exc + if parsed.username is not None or parsed.password is not None: + raise ValueError(f"{info.field_name} must not contain credentials.") + if "#" in value: + raise ValueError(f"{info.field_name} must not contain a fragment.") + if parsed.scheme != "https" and parsed.hostname.lower() not in { + "localhost", + "127.0.0.1", + "::1", + }: + raise ValueError( + f"{info.field_name} must use HTTPS outside loopback." + ) + if info.field_name == "http_oidc_issuer" and "?" in value: + raise ValueError("http_oidc_issuer must not contain a query.") + return value + + @field_validator( + "upload_public_endpoint", + "upload_internal_endpoint", + "slm_base_url", + ) + @classmethod + def _validate_service_url( + cls, + value: str | None, + info: ValidationInfo, + ) -> str | None: + if value is None: + return None + if value != value.strip() or "\\" in value: + raise ValueError(f"{info.field_name} contains unsafe characters.") + parsed = urlsplit(value) + if ( + parsed.scheme not in {"http", "https"} + or parsed.hostname is None + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + ): + raise ValueError(f"{info.field_name} must be a plain HTTP URL.") + try: + parsed.port + except ValueError as exc: + raise ValueError( + f"{info.field_name} contains an invalid port." + ) from exc + if ( + info.field_name == "upload_public_endpoint" + and parsed.scheme != "https" + and parsed.hostname.lower() not in { + "localhost", + "127.0.0.1", + "::1", + } + ): + raise ValueError( + "upload_public_endpoint must use HTTPS outside loopback." + ) + if info.field_name == "slm_base_url": + if parsed.path.rstrip("/") != "/v1": + raise ValueError("slm_base_url must end with /v1.") + if parsed.hostname.lower() in {"ollama.com", "www.ollama.com"}: + raise ValueError( + "slm_base_url must use a self-hosted Ollama service." + ) + if ( + info.field_name != "slm_base_url" + and not value.endswith("/") + ): + raise ValueError(f"{info.field_name} must end with a slash.") + return value + + @field_validator("mcp_public_url") + @classmethod + def _validate_mcp_public_url(cls, value: str | None) -> str | None: + if value is None: + return None + if value != value.strip() or "\\" in value: + raise ValueError("mcp_public_url contains unsafe characters.") + parsed = urlsplit(value) + if ( + parsed.scheme not in {"http", "https"} + or parsed.hostname is None + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + or parsed.path.rstrip("/") != "/mcp" + ): + raise ValueError( + "mcp_public_url must be a plain HTTP URL ending in /mcp." + ) + if ( + parsed.scheme != "https" + and parsed.hostname.lower() + not in {"localhost", "127.0.0.1", "::1"} + ): + raise ValueError( + "mcp_public_url must use HTTPS outside loopback." + ) + return value.rstrip("/") + + @field_validator("trusted_local_import_roots") + @classmethod + def _clean_import_roots( + cls, + values: tuple[Path, ...], + ) -> tuple[Path, ...]: + return tuple( + dict.fromkeys(path.expanduser() for path in values) + ) + + @model_validator(mode="after") + def _require_explicit_server_backend(self) -> "VidXPSettings": + if self.mode == ApplicationMode.remote: + raise ValueError( + "Remote client mode is not available in this release. " + "Connect agents to the remote MCP endpoint or use the HTTP " + "API directly." + ) + if ( + self.mode == ApplicationMode.server + and not re.fullmatch(r"(cpu|cuda(?::[0-9]+)?)", self.runtime_backend) + ): + raise ValueError( + "Server mode requires an explicit cpu or cuda runtime backend." + ) + if self.http_auth_mode == HttpAuthMode.static: + if self.http_static_bearer_token is None or len( + self.http_static_bearer_token.get_secret_value() + ) < 32: + raise ValueError( + "Static HTTP authentication requires a bearer token of " + "at least 32 characters." + ) + if any( + value is not None + for value in ( + self.http_oidc_issuer, + self.http_oidc_audience, + self.http_oidc_jwks_url, + ) + ): + raise ValueError( + "Static HTTP authentication cannot include OIDC settings." + ) + elif self.http_auth_mode == HttpAuthMode.oidc: + if ( + self.http_oidc_issuer is None + or self.http_oidc_audience is None + or self.http_oidc_jwks_url is None + ): + raise ValueError( + "OIDC HTTP authentication requires issuer, audience, " + "and JWKS URL settings." + ) + if self.http_static_bearer_token is not None: + raise ValueError( + "OIDC HTTP authentication cannot include a static token." + ) + if not self.http_required_scopes: + raise ValueError( + "OIDC HTTP authentication requires at least one scope." + ) + elif self.http_static_bearer_token is not None or any( + value is not None + for value in ( + self.http_oidc_issuer, + self.http_oidc_audience, + self.http_oidc_jwks_url, + ) + ): + raise ValueError( + "HTTP credentials require an explicit authentication mode." + ) + if self.upload_public_endpoint is not None: + if ( + self.upload_internal_endpoint is None + or self.upload_cleanup_token is None + or len(self.upload_cleanup_token.get_secret_value()) < 32 + ): + raise ValueError( + "Remote uploads require an internal tusd endpoint and " + "a cleanup token of at least 32 characters." + ) + if (self.slm_base_url is None) != (self.slm_model is None): + raise ValueError( + "slm_base_url and slm_model must be configured together." + ) + if self.slm_model is not None and self.slm_model.endswith("-cloud"): + raise ValueError("slm_model must be a self-hosted Ollama model.") + return self + + def validate_http_server(self) -> None: + if ( + self.mode == ApplicationMode.server + and self.http_auth_mode == HttpAuthMode.none + ): + raise ValueError( + "Server-mode HTTP requires static bearer or OIDC " + "authentication." + ) + if ( + self.http_auth_mode == HttpAuthMode.none + and self.http_bind_host not in {"127.0.0.1", "::1", "localhost"} + ): + raise ValueError( + "Unauthenticated HTTP may bind only to a loopback address." + ) + if not self.http_trusted_hosts: + raise ValueError("At least one trusted HTTP host is required.") + if not self.mcp_allowed_hosts: + raise ValueError("At least one allowed MCP host is required.") + if ( + self.http_auth_mode == HttpAuthMode.oidc + and self.mcp_public_url is None + ): + raise ValueError( + "OIDC MCP authentication requires mcp_public_url." + ) + + @property + def layout(self) -> RepositoryLayout: + return RepositoryLayout(root=self.repository_root.expanduser()) + + @property + def quarantine_root(self) -> Path: + return ( + self.upload_quarantine_root + if self.upload_quarantine_root is not None + else self.repository_root / "upload-quarantine" + ).expanduser() + + +class LocalExecutionSettings(BaseModel): + """Non-secret settings allowed to cross into local execution processes.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + data_dir: Path + repository_root: Path + runtime_backend: str + model_cache: Path + allow_model_downloads: bool + max_loaded_models: int + max_concurrent_indexing: int + max_concurrent_inference: int + workflow_poll_interval_seconds: float + cpu_thread_budget: int + max_local_import_bytes: int + max_snippet_duration_seconds: float + trusted_local_import_roots: tuple[Path, ...] + ffprobe_executable: str + ffmpeg_executable: str + external_capabilities: bool + capability_allowlist: tuple[str, ...] + slm_base_url: str | None + slm_model: str | None + slm_timeout_seconds: float + slm_output_retries: int + + @classmethod + def from_settings( + cls, + settings: VidXPSettings, + ) -> "LocalExecutionSettings": + return cls( + **settings.model_dump( + include=set(cls.model_fields), + mode="python", + ) + ) + + def application_settings(self) -> VidXPSettings: + defaults = VidXPSettings.model_construct().model_dump(mode="python") + defaults.update(self.model_dump(mode="python")) + defaults["mode"] = ApplicationMode.local + defaults["upload_public_endpoint"] = None + defaults["upload_internal_endpoint"] = None + defaults["upload_cleanup_token"] = None + defaults["http_auth_mode"] = HttpAuthMode.none + defaults["http_static_bearer_token"] = None + defaults["http_oidc_issuer"] = None + defaults["http_oidc_audience"] = None + defaults["http_oidc_jwks_url"] = None + defaults["http_required_scopes"] = () + return VidXPSettings(**defaults) diff --git a/src/vidxp/tusd_hooks.py b/src/vidxp/tusd_hooks.py new file mode 100644 index 0000000..d891b4a --- /dev/null +++ b/src/vidxp/tusd_hooks.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from vidxp.application_models import ( + ApplicationError, + ErrorCategory, +) +from vidxp.authentication import Authenticator +from vidxp.authorization import AuthorizationPolicy, RepositoryPermission +from vidxp.infrastructure.tusd_contracts import ( + TusdChangeFileInfo, + TusdHookRequest, + TusdHookResponse, + TusdHTTPResponse, +) +from vidxp.upload_service import RemoteUploadService + + +def _reject( + *, + status: int, + code: str, + message: str, + stop: bool = False, +) -> TusdHookResponse: + return TusdHookResponse( + reject_upload=not stop, + reject_termination=stop, + http_response=TusdHTTPResponse( + status_code=status, + body=message, + headers={"X-VidXP-Error": code}, + ), + ) + + +def _status(error: ApplicationError) -> int: + return { + ErrorCategory.authentication: 401, + ErrorCategory.authorization: 403, + ErrorCategory.not_found: 404, + ErrorCategory.conflict: 409, + ErrorCategory.resource_limit: 429, + ErrorCategory.validation: 400, + ErrorCategory.unavailable: 503, + }.get(error.detail.category, 500) + + +def _bearer(request) -> str | None: + values = request.header("authorization") + if len(values) != 1: + return None + scheme, separator, token = values[0].partition(" ") + if separator != " " or scheme.lower() != "bearer" or not token: + return None + return token + + +class TusdHookService: + """Translate tusd hook events into the upload application service.""" + + def __init__( + self, + *, + uploads: RemoteUploadService, + authenticator: Authenticator, + authorization: AuthorizationPolicy, + ) -> None: + self.uploads = uploads + self.authenticator = authenticator + self.authorization = authorization + + def handle(self, hook: TusdHookRequest) -> TusdHookResponse: + try: + if hook.hook_type == "pre-create": + return self._pre_create(hook) + if hook.hook_type == "post-finish": + self._post_finish(hook) + return TusdHookResponse() + if hook.hook_type == "pre-terminate": + self._pre_terminate(hook) + return TusdHookResponse() + self.uploads.record_terminated(hook.event.upload.upload_id) + return TusdHookResponse() + except ApplicationError as exc: + if hook.hook_type in {"post-finish", "post-terminate"}: + raise + return _reject( + status=_status(exc), + code=exc.detail.code, + message=exc.detail.message, + stop=hook.hook_type == "pre-terminate", + ) + + def _pre_create(self, hook: TusdHookRequest) -> TusdHookResponse: + upload = hook.event.upload + if ( + upload.size_is_deferred + or upload.is_partial + or upload.is_final + or upload.partial_uploads + or set(upload.metadata) != {"intent_id"} + ): + raise ApplicationError( + "upload_protocol_unsupported", + ErrorCategory.validation, + "Deferred-length and concatenated uploads are not supported.", + ) + principal = self.authorization.require( + self.authenticator.authenticate(_bearer(hook.event.request)), + RepositoryPermission.write, + ) + record = self.uploads.accept_creation( + upload.metadata["intent_id"], + principal=principal, + byte_size=upload.size, + ) + assert record.upload_id is not None + return TusdHookResponse( + change_file_info=TusdChangeFileInfo(upload_id=record.upload_id) + ) + + def _post_finish(self, hook: TusdHookRequest) -> None: + upload = hook.event.upload + intent_id = upload.metadata.get("intent_id") + if intent_id is None: + raise ApplicationError( + "upload_completion_invalid", + ErrorCategory.validation, + "The completed upload is missing its intent.", + ) + self.uploads.complete_upload( + intent_id=intent_id, + upload_id=upload.upload_id, + byte_size=upload.size, + offset=upload.offset, + ) + + def _pre_terminate(self, hook: TusdHookRequest) -> None: + values = hook.event.request.header("x-vidxp-cleanup-token") + token = values[0] if len(values) == 1 else None + self.uploads.authorize_termination( + hook.event.upload.upload_id, + cleanup_token=token, + ) diff --git a/src/vidxp/upload_service.py b/src/vidxp/upload_service.py new file mode 100644 index 0000000..2a0bb90 --- /dev/null +++ b/src/vidxp/upload_service.py @@ -0,0 +1,659 @@ +from __future__ import annotations + +import hashlib +import json +import logging +from datetime import timedelta +from hmac import compare_digest +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen +from uuid import uuid4 + +from sqlalchemy.engine import Connection +from sqlalchemy.exc import IntegrityError + +from vidxp.application_models import ( + ApplicationError, + CreateUploadIntentCommand, + ErrorCategory, + JobState, + Principal, + UploadIntent, +) +from vidxp.core.media import QuarantinedMedia, utc_now +from vidxp.core.uploads import UploadIntentRecord, UploadState +from vidxp.infrastructure.sql_catalog import ( + SQLCatalog, + UploadQuotaExceededError, +) +from vidxp.media_service import MediaService +from vidxp.settings import VidXPSettings + +LOGGER = logging.getLogger(__name__) + + +def _public_intent(record: UploadIntentRecord) -> UploadIntent: + return UploadIntent( + intent_id=record.intent_id, + original_filename=record.original_filename, + byte_size=record.byte_size, + declared_mime_type=record.declared_mime_type, + state=record.state, + created_at=record.created_at, + expires_at=record.expires_at, + job_id=record.job_id, + media_id=record.media_id, + ) + + +class RemoteUploadService: + """Intent policy and minimal glue around tusd, DBOS, and media import.""" + + def __init__( + self, + *, + settings: VidXPSettings, + catalog: SQLCatalog, + media: MediaService | None, + jobs: Any | None = None, + ) -> None: + self.settings = settings + self.catalog = catalog + self.media = media + self.jobs = jobs + + def create_intent( + self, + command: CreateUploadIntentCommand, + *, + principal: Principal, + request_key: str, + ) -> UploadIntent: + self._require_configured() + del principal + if command.byte_size > self.settings.upload_max_bytes: + raise ApplicationError( + "upload_too_large", + ErrorCategory.resource_limit, + "The requested upload exceeds the configured limit.", + ) + if existing := self.catalog.get_upload_intent_by_request(request_key): + self._require_same_request(existing, command) + return _public_intent(existing) + now = utc_now() + record = UploadIntentRecord( + intent_id=uuid4().hex, + request_key=request_key, + original_filename=command.original_filename, + byte_size=command.byte_size, + declared_mime_type=command.declared_mime_type, + state=UploadState.pending, + created_at=now, + expires_at=now + + timedelta(seconds=self.settings.upload_intent_ttl_seconds), + ) + try: + return _public_intent( + self.catalog.create_upload_intent( + record, + quota_limit=self.settings.upload_quota_bytes, + ) + ) + except UploadQuotaExceededError as exc: + raise ApplicationError( + "upload_quota_exceeded", + ErrorCategory.resource_limit, + "The principal upload quota would be exceeded.", + ) from exc + except IntegrityError: + replay = self.catalog.get_upload_intent_by_request(request_key) + if replay is not None: + self._require_same_request(replay, command) + return _public_intent(replay) + raise + + def get_intent( + self, + intent_id: str, + *, + principal: Principal, + ) -> UploadIntent: + del principal + record = self.catalog.get_upload_intent(intent_id) + if record is None: + raise ApplicationError( + "resource_not_found", + ErrorCategory.not_found, + "The requested upload intent was not found.", + ) + return _public_intent(record) + + def upload_url( + self, + intent_id: str, + *, + principal: Principal, + ) -> str | None: + del principal + record = self.catalog.get_upload_intent(intent_id) + if record is None: + raise ApplicationError( + "resource_not_found", + ErrorCategory.not_found, + "The requested upload intent was not found.", + ) + if record.state != UploadState.accepted or record.upload_id is None: + return None + assert self.settings.upload_public_endpoint is not None + return self.settings.upload_public_endpoint + record.upload_id + + def accept_creation( + self, + intent_id: str, + *, + principal: Principal, + byte_size: int, + ) -> UploadIntentRecord: + del principal + now = utc_now() + + def accept(connection: Connection) -> UploadIntentRecord | None: + record = self.catalog.get_upload_intent( + intent_id, + connection=connection, + for_update=True, + ) + if record is None: + raise ApplicationError( + "upload_intent_invalid", + ErrorCategory.validation, + "The upload intent is invalid.", + ) + if record.expires_at <= now: + self.catalog.update_upload( + record.intent_id, + state=UploadState.expired, + connection=connection, + ) + return None + if byte_size != record.byte_size: + raise ApplicationError( + "upload_size_mismatch", + ErrorCategory.validation, + "The upload size does not match its intent.", + ) + if record.state == UploadState.accepted: + assert record.upload_id is not None + if self._quarantine_path( + f"{record.upload_id}.info" + ).exists(): + raise ApplicationError( + "upload_already_created", + ErrorCategory.conflict, + "The upload was already created; resume its existing URL.", + ) + return record + if record.state != UploadState.pending: + raise ApplicationError( + "upload_intent_consumed", + ErrorCategory.conflict, + "The upload intent has already been consumed.", + ) + upload_id = uuid4().hex + self.catalog.update_upload( + record.intent_id, + state=UploadState.accepted, + connection=connection, + upload_id=upload_id, + ) + return record.model_copy( + update={ + "state": UploadState.accepted, + "upload_id": upload_id, + } + ) + + accepted = self.catalog.with_upload_transaction(accept) + if accepted is None: + raise ApplicationError( + "upload_intent_expired", + ErrorCategory.validation, + "The upload intent has expired.", + ) + return accepted + + def complete_upload( + self, + *, + intent_id: str, + upload_id: str, + byte_size: int, + offset: int, + ) -> str: + if self.jobs is None: + raise RuntimeError("Upload job submission is not configured.") + + def complete(connection: Connection) -> str: + record = self.catalog.get_upload_intent( + intent_id, + connection=connection, + for_update=True, + ) + if ( + record is None + or record.upload_id != upload_id + ): + raise ApplicationError( + "upload_completion_invalid", + ErrorCategory.validation, + "The completed upload does not match its intent.", + ) + if byte_size != record.byte_size or offset != record.byte_size: + raise ApplicationError( + "upload_incomplete", + ErrorCategory.validation, + "The upload is not complete.", + ) + if record.job_id is not None: + return record.job_id + if record.state != UploadState.accepted: + raise ApplicationError( + "upload_completion_invalid", + ErrorCategory.conflict, + "The upload is not ready for completion.", + ) + job_id = self.jobs.enqueue_media_import_in_transaction( + upload_id, + connection=connection, + job_id=upload_id, + ) + self.catalog.update_upload( + record.intent_id, + state=UploadState.processing, + connection=connection, + job_id=job_id, + ) + return job_id + + return self.catalog.with_upload_transaction(complete) + + def import_completed( + self, + upload_id: str, + ): + if self.media is None: + raise RuntimeError("Media import is not configured in this process.") + record = self.catalog.get_upload_intent_by_upload_id(upload_id) + if record is None: + raise ApplicationError( + "upload_not_found", + ErrorCategory.not_found, + "The completed upload is unavailable.", + ) + if record.state == UploadState.ready and record.media_id is not None: + asset = self.media.get(record.media_id) + self._cleanup_after_import(upload_id) + return asset + if record.state not in { + UploadState.processing, + UploadState.failed, + }: + raise ApplicationError( + "upload_not_ready", + ErrorCategory.conflict, + "The upload is not ready for import.", + ) + path = self._quarantine_path(upload_id) + request_key = hashlib.sha256( + f"vidxp-upload-import-v1\0{record.intent_id}".encode() + ).hexdigest() + try: + asset = self.media.import_quarantined( + QuarantinedMedia( + path=path, + original_filename=record.original_filename, + declared_mime_type=record.declared_mime_type, + ), + request_key=request_key, + ) + except Exception: + self.catalog.with_upload_transaction( + lambda connection: self.catalog.update_upload( + record.intent_id, + state=UploadState.failed, + connection=connection, + ) + ) + raise + self.catalog.with_upload_transaction( + lambda connection: self.catalog.update_upload( + record.intent_id, + state=UploadState.ready, + connection=connection, + media_id=asset.media_id, + ) + ) + self._cleanup_after_import(upload_id) + return asset + + def reconcile(self) -> dict[str, int]: + recovered = 0 + errors = 0 + for record in self.catalog.recoverable_uploads(): + try: + if record.upload_id is None: + continue + info = self._read_upload_info(record.upload_id) + if info is None: + continue + metadata = info.get("MetaData") + if not isinstance(metadata, dict): + continue + if metadata.get("intent_id") != record.intent_id: + continue + size = info.get("Size") + offset = info.get("Offset") + if ( + isinstance(size, int) + and isinstance(offset, int) + and size == offset == record.byte_size + ): + self.complete_upload( + intent_id=record.intent_id, + upload_id=record.upload_id, + byte_size=size, + offset=offset, + ) + recovered += 1 + except Exception: + errors += 1 + LOGGER.exception( + "Upload recovery failed for intent %s.", + record.intent_id, + ) + failed = 0 + if self.jobs is not None: + for record in self.catalog.processing_uploads(): + try: + assert record.job_id is not None + job = self.jobs.get(record.job_id) + if job.state in { + JobState.failed, + JobState.cancelled, + JobState.recovery_exhausted, + }: + changed = self.catalog.with_upload_transaction( + lambda connection, item=record: ( + self.catalog.update_upload( + item.intent_id, + state=UploadState.failed, + connection=connection, + expected_states={ + UploadState.processing, + }, + ) + ) + ) + failed += int(changed) + except Exception: + errors += 1 + LOGGER.exception( + "Upload job reconciliation failed for intent %s.", + record.intent_id, + ) + expired = 0 + now = utc_now() + for record in self.catalog.expired_uploads(now=now): + try: + if ( + record.state == UploadState.accepted + and record.upload_id is not None + and self._upload_is_active(record.upload_id, now=now) + ): + continue + upload_id = self._expire_intent(record.intent_id) + if upload_id is not None: + self._cleanup_upload(upload_id) + expired += 1 + except Exception: + errors += 1 + LOGGER.exception( + "Upload expiration failed for intent %s.", + record.intent_id, + ) + cleaned = 0 + for record in self.catalog.cleanup_uploads(): + assert record.upload_id is not None + try: + self._cleanup_upload(record.upload_id) + cleaned += 1 + except Exception: + errors += 1 + LOGGER.exception( + "Upload cleanup failed for intent %s.", + record.intent_id, + ) + return { + "recovered": recovered, + "expired": expired, + "cleaned": cleaned, + "failed": failed, + "errors": errors, + } + + def authorize_termination( + self, + upload_id: str, + *, + cleanup_token: str | None, + ) -> None: + def authorize(connection: Connection) -> None: + record = self.catalog.get_upload_intent_by_upload_id( + upload_id, + connection=connection, + for_update=True, + ) + if record is None: + raise ApplicationError( + "upload_not_found", + ErrorCategory.not_found, + "The upload is unavailable.", + ) + expected = self.settings.upload_cleanup_token + internal = ( + expected is not None + and cleanup_token is not None + and compare_digest( + expected.get_secret_value(), + cleanup_token, + ) + ) + if record.state == UploadState.accepted: + self.catalog.update_upload( + record.intent_id, + state=UploadState.expired, + connection=connection, + ) + return + if internal and record.state in { + UploadState.ready, + UploadState.failed, + UploadState.expired, + }: + if record.state == UploadState.failed: + self.catalog.update_upload( + record.intent_id, + state=UploadState.expired, + connection=connection, + ) + return + raise ApplicationError( + "upload_termination_forbidden", + ErrorCategory.conflict, + "The upload cannot be terminated in its current state.", + ) + + self.catalog.with_upload_transaction(authorize) + + def record_terminated(self, upload_id: str) -> None: + def terminate(connection: Connection) -> None: + record = self.catalog.get_upload_intent_by_upload_id( + upload_id, + connection=connection, + for_update=True, + ) + if record is None: + return + if record.state == UploadState.processing: + raise ApplicationError( + "upload_termination_forbidden", + ErrorCategory.conflict, + "A processing upload cannot be terminated.", + ) + state = ( + UploadState.ready + if record.state == UploadState.ready + else UploadState.expired + ) + self.catalog.update_upload( + record.intent_id, + state=state, + connection=connection, + clear_upload_id=True, + ) + + self.catalog.with_upload_transaction(terminate) + + def _expire_intent(self, intent_id: str) -> str | None: + def expire(connection: Connection) -> str | None: + record = self.catalog.get_upload_intent( + intent_id, + connection=connection, + for_update=True, + ) + if record is None or record.state not in { + UploadState.pending, + UploadState.accepted, + UploadState.failed, + }: + return None + self.catalog.update_upload( + intent_id, + state=UploadState.expired, + connection=connection, + ) + return record.upload_id + + return self.catalog.with_upload_transaction(expire) + + def _upload_is_active(self, upload_id: str, *, now) -> bool: + cutoff = now.timestamp() - self.settings.upload_intent_ttl_seconds + for suffix in ("", ".info"): + path = self._quarantine_path(f"{upload_id}{suffix}") + try: + if ( + path.is_file() + and not path.is_symlink() + and path.stat().st_mtime > cutoff + ): + return True + except OSError: + continue + return False + + def _read_upload_info(self, upload_id: str) -> dict[str, Any] | None: + path = self._quarantine_path(f"{upload_id}.info") + if not path.is_file() or path.is_symlink(): + return None + try: + with path.open("rb") as handle: + payload = handle.read(65_537) + if len(payload) > 65_536: + return None + result = json.loads(payload) + except (OSError, json.JSONDecodeError): + return None + return result if isinstance(result, dict) else None + + def _quarantine_path(self, name: str) -> Path: + if ( + not name + or len(name) > 260 + or any( + character + not in "abcdefghijklmnopqrstuvwxyz" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._~-" + for character in name + ) + ): + raise ApplicationError( + "upload_identifier_invalid", + ErrorCategory.validation, + "The upload identifier is invalid.", + ) + root = self.settings.quarantine_root.resolve() + path = (root / name).resolve() + if path.parent != root: + raise RuntimeError("The upload escaped its quarantine root.") + return path + + def _terminate_upload(self, upload_id: str) -> None: + endpoint = self.settings.upload_internal_endpoint + if endpoint is None: + return + request = Request(endpoint + upload_id, method="DELETE") + request.add_header("Tus-Resumable", "1.0.0") + if self.settings.upload_cleanup_token is not None: + request.add_header( + "X-VidXP-Cleanup-Token", + self.settings.upload_cleanup_token.get_secret_value(), + ) + try: + with urlopen(request, timeout=5) as response: + if response.status not in {204, 404}: + raise RuntimeError("tusd rejected upload cleanup.") + except HTTPError as exc: + if exc.code != 404: + raise RuntimeError("tusd upload cleanup failed.") from exc + except URLError as exc: + raise RuntimeError("tusd upload cleanup is unavailable.") from exc + + def _cleanup_upload(self, upload_id: str) -> None: + self._terminate_upload(upload_id) + self.record_terminated(upload_id) + + def _cleanup_after_import(self, upload_id: str) -> None: + try: + self._cleanup_upload(upload_id) + except Exception: + LOGGER.warning( + "Imported upload %s is awaiting quarantine cleanup.", + upload_id, + exc_info=True, + ) + + def _require_configured(self) -> None: + if self.settings.upload_public_endpoint is None: + raise ApplicationError( + "remote_upload_unavailable", + ErrorCategory.unavailable, + "Remote resumable uploads are not configured.", + ) + + @staticmethod + def _require_same_request( + record: UploadIntentRecord, + command: CreateUploadIntentCommand, + ) -> None: + if ( + record.original_filename != command.original_filename + or record.byte_size != command.byte_size + or record.declared_mime_type != command.declared_mime_type + ): + raise ApplicationError( + "idempotency_key_reused", + ErrorCategory.validation, + "The idempotency key was already used for another request.", + ) diff --git a/src/vidxp/workflow_contracts.py b/src/vidxp/workflow_contracts.py new file mode 100644 index 0000000..e49fe75 --- /dev/null +++ b/src/vidxp/workflow_contracts.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import TypeAdapter + +from vidxp.application_models import JobKind, JobQueue, JobRequest + + +APPLICATION_NAME = "vidxp" +WORKFLOW_CLASS_NAME = "VidXPWorkerWorkflows" +WORKFLOW_INSTANCE_NAME = "default" +PROGRESS_EVENT = "vidxp.progress" +ERROR_EVENT = "vidxp.error" + +WORKFLOW_NAMES: dict[JobKind, str] = { + JobKind.media_import: "vidxp.media_import.v1", + JobKind.index: "vidxp.index.v1", + JobKind.search: "vidxp.search.v2", + JobKind.query: "vidxp.query.v1", + JobKind.snippet: "vidxp.snippet.v1", + JobKind.actor_overlay: "vidxp.actor_overlay.v1", + JobKind.prepare_models: "vidxp.prepare_models.v1", +} +WORKFLOW_KINDS = {name: kind for kind, name in WORKFLOW_NAMES.items()} +WORKFLOW_KINDS["vidxp.search.v1"] = JobKind.search +_JOB_REQUEST_ADAPTER = TypeAdapter(JobRequest) + + +def decode_workflow_request(payload: Any) -> JobRequest: + """Validate current jobs while upgrading the retired search-v1 shape.""" + if isinstance(payload, dict) and payload.get("kind") == JobKind.search: + command = payload.get("command") + if ( + isinstance(command, dict) + and "modality" in command + and "modalities" not in command + ): + modality = command["modality"] + command = { + key: value + for key, value in command.items() + if key != "modality" + } + command["modalities"] = (modality,) + payload = {**payload, "command": command} + return _JOB_REQUEST_ADAPTER.validate_python(payload) + +QUEUE_NAMES: dict[JobQueue, str] = { + JobQueue.cpu: "vidxp-cpu", + JobQueue.gpu: "vidxp-gpu", +} +QUEUE_KINDS = {name: queue for queue, name in QUEUE_NAMES.items()} diff --git a/src/vidxp/workflow_runtime.py b/src/vidxp/workflow_runtime.py new file mode 100644 index 0000000..7f2b142 --- /dev/null +++ b/src/vidxp/workflow_runtime.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import hashlib +import json +import sys +from functools import lru_cache +from pathlib import Path + +from pydantic import BaseModel, ConfigDict, Field + +from vidxp import __version__ +from vidxp.capabilities.registry import create_capability_registry +from vidxp.core.manifest import dependency_versions, implementation_digest +from vidxp.settings import ( + ApplicationMode, + LocalExecutionSettings, + VidXPSettings, +) + + +LOCAL_WORKER_BOOTSTRAP_ENV = "VIDXP_LOCAL_WORKER_BOOTSTRAP_FILE" +BUNDLED_POSTGRES_DATABASE_URL = ( + "postgresql+psycopg://vidxp@postgres:5432/vidxp" +) + + +class LocalWorkerBootstrap(BaseModel): + """One-use local worker configuration stored outside process arguments.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + settings: LocalExecutionSettings + database_url: str = Field(min_length=1) + fingerprint: str = Field(pattern=r"^[a-f0-9]{64}$") + + +class LocalWorkerReady(BaseModel): + """Non-secret identity published by a ready local worker.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + pid: int = Field(gt=0) + application_version: str = Field(min_length=1) + fingerprint: str = Field(pattern=r"^[a-f0-9]{64}$") + + +class LocalWorkerStopRequest(BaseModel): + """Repository-local request for the supervised worker to shut down.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + pid: int = Field(gt=0) + application_version: str = Field(min_length=1) + + +def workflow_database_url(settings: VidXPSettings) -> str: + if settings.mode == ApplicationMode.server: + return BUNDLED_POSTGRES_DATABASE_URL + database = settings.layout.workflow_database.resolve() + return f"sqlite:///{database.as_posix()}" + + +@lru_cache(maxsize=1) +def workflow_application_version() -> str: + """Identify the exact workflow implementation, not only the release.""" + + return f"{__version__}+{implementation_digest()[:16]}" + + +@lru_cache(maxsize=16) +def _local_execution_provenance( + external_capabilities: bool, + capability_allowlist: tuple[str, ...], +) -> dict: + registry = create_capability_registry( + external=external_capabilities, + allowlist=capability_allowlist, + ) + return { + "executable": str(Path(sys.executable).resolve()), + "implementation_sha256": implementation_digest(), + "python": sys.version, + "dependencies": dependency_versions(registry), + } + + +def local_worker_bootstrap(settings: VidXPSettings) -> LocalWorkerBootstrap: + local_settings = LocalExecutionSettings.from_settings(settings) + active_database_url = workflow_database_url(settings) + identity = { + "application_version": workflow_application_version(), + "database_url": active_database_url, + "execution": _local_execution_provenance( + settings.external_capabilities, + settings.capability_allowlist, + ), + "settings": local_settings.model_dump(mode="json"), + } + fingerprint = hashlib.sha256( + json.dumps( + identity, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + ).hexdigest() + return LocalWorkerBootstrap( + settings=local_settings, + database_url=active_database_url, + fingerprint=fingerprint, + ) + + +def local_executor_id(settings: VidXPSettings) -> str: + if settings.mode == ApplicationMode.server: + raise ValueError("A local executor ID cannot be used in server mode.") + return f"{workflow_application_version()}-local-0" + + +def server_executor_id(*, role: str) -> str: + if role not in {"cpu", "gpu"}: + raise ValueError("The worker role is invalid.") + return f"{workflow_application_version()}-{role}-0" + + +def sqlite_database_path(database_url: str) -> Path | None: + prefix = "sqlite:///" + if not database_url.startswith(prefix): + return None + return Path(database_url.removeprefix(prefix)) diff --git a/src/vidxp/workflow_worker.py b/src/vidxp/workflow_worker.py new file mode 100644 index 0000000..2c7c4a9 --- /dev/null +++ b/src/vidxp/workflow_worker.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +import argparse +import logging +import os +import signal +from logging.handlers import RotatingFileHandler +from pathlib import Path +from threading import Event +from typing import Callable, Sequence + +from dbos import DBOS, DBOSConfig +from filelock import FileLock + +from vidxp.application_models import JobQueue +from vidxp.composition import create_application +from vidxp.core.manifest import write_json_atomic +from vidxp.infrastructure.dbos_workflows import VidXPWorkerWorkflows +from vidxp.infrastructure.local_files import durable_unlink +from vidxp.settings import VidXPSettings +from vidxp.workflow_contracts import APPLICATION_NAME, QUEUE_NAMES +from vidxp.workflow_runtime import ( + LOCAL_WORKER_BOOTSTRAP_ENV, + LocalWorkerBootstrap, + LocalWorkerReady, + LocalWorkerStopRequest, + local_worker_bootstrap, + server_executor_id, + workflow_application_version, + workflow_database_url, +) + +LOGGER = logging.getLogger(__name__) +_MAXIMUM_LOG_BYTES = 5 * 1024 * 1024 + + +def _arguments(values: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run VidXP DBOS workflows.") + parser.add_argument("--executor-id") + parser.add_argument("--role", choices=("all", "cpu", "gpu"), default="cpu") + parser.add_argument("--lock-file", type=Path) + parser.add_argument("--ready-file", type=Path) + parser.add_argument("--stop-file", type=Path) + parser.add_argument("--log-file", type=Path) + return parser.parse_args(values) + + +def _configure_logging(path: Path | None) -> None: + if path is None: + return + path.parent.mkdir(parents=True, exist_ok=True) + handler = RotatingFileHandler( + path, + maxBytes=_MAXIMUM_LOG_BYTES, + backupCount=2, + encoding="utf-8", + ) + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s %(message)s", + handlers=[handler], + force=True, + ) + + +def _local_bootstrap() -> LocalWorkerBootstrap | None: + raw_path = os.environ.pop(LOCAL_WORKER_BOOTSTRAP_ENV, None) + if raw_path is None: + return None + path = Path(raw_path) + try: + return LocalWorkerBootstrap.model_validate_json( + path.read_text(encoding="utf-8") + ) + finally: + durable_unlink(path, missing_ok=True) + + +def run_worker( + *, + settings: VidXPSettings, + database_url: str, + executor_id: str, + role: str, + ready: Callable[[], None] | None = None, + stop_event: Event | None = None, + stop_file: Path | None = None, +) -> None: + config: DBOSConfig = { + "name": APPLICATION_NAME, + "system_database_url": database_url, + "application_version": workflow_application_version(), + "executor_id": executor_id, + "max_executor_threads": 1 if role == "all" else None, + "use_listen_notify": not database_url.startswith("sqlite:///"), + "dbos_system_schema": ( + None if database_url.startswith("sqlite:///") else "dbos" + ), + } + DBOS(config=config) + try: + VidXPWorkerWorkflows(create_application(settings)) + roles = ("cpu", "gpu") if role == "all" else (role,) + queue_names = [ + QUEUE_NAMES[queue_role] + for queue_role in (JobQueue(value) for value in roles) + ] + DBOS.listen_queues(queue_names) + active_stop_event = stop_event or Event() + DBOS.launch() + for queue_name in queue_names: + DBOS.register_queue( + queue_name, + worker_concurrency=1, + ) + if ready is not None: + ready() + while not active_stop_event.wait(0.25): + if stop_file is None or not stop_file.is_file(): + continue + try: + request = LocalWorkerStopRequest.model_validate_json( + stop_file.read_text(encoding="utf-8") + ) + except (OSError, ValueError): + continue + if ( + request.pid == os.getpid() + and request.application_version + == workflow_application_version() + ): + break + finally: + DBOS.destroy(workflow_completion_timeout_sec=30) + + +def main(arguments: Sequence[str] | None = None) -> None: + options = _arguments(arguments) + _configure_logging(options.log_file) + bootstrap = _local_bootstrap() + settings = ( + bootstrap.settings.application_settings() + if bootstrap is not None + else VidXPSettings() + ) + if bootstrap is not None: + expected_bootstrap = local_worker_bootstrap(settings) + if expected_bootstrap.fingerprint != bootstrap.fingerprint: + raise ValueError( + "The local worker bootstrap identity is invalid." + ) + database_url = ( + bootstrap.database_url + if bootstrap is not None + else workflow_database_url(settings) + ) + role = options.role + executor_id = options.executor_id + if executor_id is None: + if role == "all": + raise ValueError("The all-queue worker requires an executor ID.") + executor_id = server_executor_id(role=role) + + if options.lock_file is None: + stop_event = Event() + previous_handlers = _install_shutdown_handlers(stop_event) + try: + run_worker( + settings=settings, + database_url=database_url, + executor_id=executor_id, + role=role, + stop_event=stop_event, + ) + finally: + _restore_shutdown_handlers(previous_handlers) + return + + lock = FileLock(options.lock_file) + ready_file = options.ready_file + + def mark_ready() -> None: + if ready_file is None: + return + if bootstrap is None: + raise RuntimeError( + "A supervised local worker requires bootstrap identity." + ) + ready_file.parent.mkdir(parents=True, exist_ok=True) + write_json_atomic( + ready_file, + LocalWorkerReady( + pid=os.getpid(), + application_version=workflow_application_version(), + fingerprint=bootstrap.fingerprint, + ).model_dump(mode="json"), + ) + + stop_event = Event() + previous_handlers = _install_shutdown_handlers(stop_event) + try: + with lock: + try: + run_worker( + settings=settings, + database_url=database_url, + executor_id=executor_id, + role=role, + ready=mark_ready, + stop_event=stop_event, + stop_file=options.stop_file, + ) + finally: + if ready_file is not None: + durable_unlink(ready_file, missing_ok=True) + if options.stop_file is not None: + durable_unlink(options.stop_file, missing_ok=True) + finally: + _restore_shutdown_handlers(previous_handlers) + + +def _install_shutdown_handlers( + stop_event: Event, +) -> dict[signal.Signals, signal.Handlers]: + previous_handlers = {} + + def request_shutdown( + _signal_number: int, + _frame: object, + ) -> None: + stop_event.set() + + for signal_name in ("SIGINT", "SIGTERM", "SIGBREAK"): + shutdown_signal = getattr(signal, signal_name, None) + if shutdown_signal is None: + continue + previous_handlers[shutdown_signal] = signal.signal( + shutdown_signal, + request_shutdown, + ) + return previous_handlers + + +def _restore_shutdown_handlers( + previous_handlers: dict[signal.Signals, signal.Handlers], +) -> None: + for shutdown_signal, previous_handler in previous_handlers.items(): + signal.signal(shutdown_signal, previous_handler) + + +if __name__ == "__main__": + try: + main() + except Exception: + LOGGER.exception("The VidXP workflow worker exited unexpectedly.") + raise diff --git a/tests/test_actor_results.py b/tests/test_actor_results.py index d916368..71d9570 100644 --- a/tests/test_actor_results.py +++ b/tests/test_actor_results.py @@ -1,20 +1,26 @@ import unittest -from tempfile import TemporaryDirectory -from pathlib import Path -from unittest.mock import Mock, patch +from unittest.mock import Mock from vidxp.capabilities.actor.results import ( ActorClusterNotFoundError, + actor_cluster, actor_clusters, actor_detections, - render_actor_result, ) +from vidxp.capabilities.actor.schemas import ActorDetectionsInput from vidxp.core.contracts import IndexConfig +from vidxp.core.cursors import encode_cursor +from vidxp.capabilities.contracts import CapabilityRequestError + + +MEDIA_ID = "123456781234423481234567890abcde" +OTHER_MEDIA_ID = "223456781234423481234567890abcde" +GENERATION_ID = "323456781234423481234567890abcde" class ActorResultTests(unittest.TestCase): def setUp(self): - self.config = IndexConfig.local(video_id="video-1") + self.config = IndexConfig.local(video_id=MEDIA_ID) def test_actor_detection_metadata_is_converted_once(self): storage = Mock() @@ -31,65 +37,171 @@ def test_actor_detection_metadata_is_converted_once(self): "dataset": "local", "split": "local", "run_id": "default", - "video_id": "video-1", + "video_id": MEDIA_ID, + "generation_id": GENERATION_ID, "modality": "actor", "source_id": "actor:d2", } ] - detections = actor_detections( + page = actor_detections( self.config, "3", storage=storage, + page_size=50, + cursor=None, ) - self.assertEqual(detections[0].bbox, (1, 4, 5, 0)) + self.assertEqual(page.detections[0].bbox, (1, 4, 5, 0)) storage.records.assert_called_once_with( "actor", - video_id="video-1", filters={"cluster_id": "3"}, + limit=51, + offset=0, ) - storage.close.assert_not_called() def test_actor_clusters_summarize_detection_ranges(self): storage = Mock() - storage.records.return_value = [ - {"cluster_id": "1", "timestamp": 4.5}, - {"cluster_id": "1", "timestamp": 1.5}, - {"cluster_id": "2", "timestamp": 9.0}, + legacy_records = [ + { + "cluster_id": "cluster-1", + "video_id": MEDIA_ID, + "generation_id": GENERATION_ID, + "timestamp": 4.5, + }, + { + "cluster_id": "cluster-1", + "video_id": MEDIA_ID, + "generation_id": GENERATION_ID, + "timestamp": 1.5, + }, + { + "cluster_id": "cluster-2", + "video_id": OTHER_MEDIA_ID, + "generation_id": GENERATION_ID, + "timestamp": 9.0, + }, ] + storage.records.side_effect = [[], legacy_records] - clusters = actor_clusters(self.config, storage=storage) + page = actor_clusters( + self.config, + storage=storage, + page_size=50, + cursor=None, + ) + clusters = page.clusters self.assertEqual( [cluster.cluster_id for cluster in clusters], - ["1", "2"], + ["cluster-1", "cluster-2"], ) self.assertEqual(clusters[0].detection_count, 2) self.assertEqual(clusters[0].first_timestamp, 1.5) self.assertEqual(clusters[0].last_timestamp, 4.5) + self.assertEqual(clusters[1].media_id, OTHER_MEDIA_ID) + self.assertEqual( + storage.records.call_args_list[0].kwargs, + { + "filters": {"record_kind": "cluster_summary"}, + "limit": 51, + "offset": 0, + }, + ) + self.assertEqual( + storage.records.call_args_list[1].kwargs, + {"limit": 1000, "offset": 0}, + ) - def test_render_actor_result_rejects_an_empty_cluster(self): + def test_actor_cluster_reads_only_the_selected_cluster_once(self): + storage = Mock() + detection_records = [ + { + "cluster_id": "cluster-1", + "video_id": MEDIA_ID, + "generation_id": GENERATION_ID, + "timestamp": 4.5, + }, + { + "cluster_id": "cluster-1", + "video_id": MEDIA_ID, + "generation_id": GENERATION_ID, + "timestamp": 1.5, + }, + ] + storage.records.side_effect = [[], detection_records] + + summary = actor_cluster( + self.config, + "cluster-1", + storage=storage, + ) + + self.assertEqual(summary.cluster_id, "cluster-1") + self.assertEqual(summary.detection_count, 2) + self.assertEqual(summary.first_timestamp, 1.5) + self.assertEqual(summary.last_timestamp, 4.5) + self.assertEqual( + storage.records.call_args_list[0].kwargs, + { + "filters": { + "record_kind": "cluster_summary", + "summary_cluster_id": "cluster-1", + }, + "limit": 2, + }, + ) + self.assertEqual( + storage.records.call_args_list[1].kwargs, + {"filters": {"cluster_id": "cluster-1"}}, + ) + + def test_actor_cluster_uses_the_typed_not_found_error(self): storage = Mock() storage.records.return_value = [] with self.assertRaises(ActorClusterNotFoundError): - render_actor_result( + actor_cluster( self.config, "missing", - "input.mp4", - "output.mp4", storage=storage, ) - def test_render_actor_result_returns_output_details(self): + def test_actor_clusters_reject_cross_media_identity_collision(self): storage = Mock() - storage.records.return_value = [ + legacy_records = [ { - "detection_id": "d2", - "cluster_id": "3", - "frame_index": 2, - "timestamp": 0.2, + "cluster_id": "legacy-cluster", + "video_id": MEDIA_ID, + "generation_id": GENERATION_ID, + "timestamp": 1.0, + }, + { + "cluster_id": "legacy-cluster", + "video_id": OTHER_MEDIA_ID, + "generation_id": GENERATION_ID, + "timestamp": 2.0, + }, + ] + storage.records.side_effect = [[], legacy_records] + + with self.assertRaisesRegex(RuntimeError, "multiple index identities"): + actor_clusters( + self.config, + storage=storage, + page_size=50, + cursor=None, + ) + + def test_actor_detection_cursor_round_trips_through_public_input(self): + cluster_id = "cluster-" + ("a" * 64) + storage = Mock() + records = [ + { + "detection_id": f"detection-{index}", + "cluster_id": cluster_id, + "frame_index": index, + "timestamp": index / 10, "bbox_top": 1, "bbox_right": 4, "bbox_bottom": 5, @@ -97,27 +209,136 @@ def test_render_actor_result_returns_output_details(self): "dataset": "local", "split": "local", "run_id": "default", - "video_id": "video-1", + "video_id": MEDIA_ID, + "generation_id": GENERATION_ID, "modality": "actor", - "source_id": "actor:d2", + "source_id": f"actor:detection-{index}", } + for index in range(3) + ] + storage.records.side_effect = lambda *args, **options: records[ + options["offset"] : options["offset"] + options["limit"] ] - with TemporaryDirectory() as directory: - output = Path(directory) / "actor.mp4" - with patch( - "vidxp.capabilities.actor.results.render_actor_video" - ) as renderer: - result = render_actor_result( - self.config, - "3", - "input.mp4", - output, - storage=storage, - ) - - renderer.assert_called_once() - self.assertEqual(result.output_path, output) - self.assertEqual(result.detection_count, 1) + + first = actor_detections( + self.config, + cluster_id, + storage=storage, + page_size=2, + cursor=None, + ) + + self.assertIsNotNone(first.next_cursor) + self.assertGreater(len(first.next_cursor or ""), 128) + request = ActorDetectionsInput( + cluster_id=cluster_id, + page_size=2, + cursor=first.next_cursor, + ) + second = actor_detections( + self.config, + request.cluster_id, + storage=storage, + page_size=request.page_size, + cursor=request.cursor, + ) + + self.assertEqual( + [detection.detection_id for detection in second.detections], + ["detection-2"], + ) + self.assertIsNone(second.next_cursor) + self.assertEqual( + [call.kwargs["offset"] for call in storage.records.call_args_list], + [0, 2], + ) + self.assertEqual( + [call.kwargs["limit"] for call in storage.records.call_args_list], + [3, 3], + ) + + def test_actor_detection_cursor_rejects_noncanonical_payloads(self): + cluster_id = "cluster-1" + scope = ( + f"actor:detections:{self.config.fingerprint()}:{cluster_id}" + ) + storage = Mock() + + for position in ( + {"offset": True}, + {"offset": 0, "after": [0, "detection-1"]}, + ): + with self.subTest(position=position): + with self.assertRaises(CapabilityRequestError): + actor_detections( + self.config, + cluster_id, + storage=storage, + page_size=2, + cursor=encode_cursor(scope, position), + ) + storage.records.assert_not_called() + + def test_actor_cluster_pages_use_opaque_cursors(self): + storage = Mock() + records = [ + { + "record_kind": "cluster_summary", + "summary_cluster_id": f"cluster-{index}", + "video_id": MEDIA_ID, + "generation_id": GENERATION_ID, + "detection_count": index + 1, + "first_timestamp": float(index), + "last_timestamp": float(index) + 0.5, + } + for index in range(3) + ] + storage.records.side_effect = lambda *args, **options: records[ + options["offset"] : options["offset"] + options["limit"] + ] + first = actor_clusters( + self.config, + storage=storage, + page_size=2, + cursor=None, + ) + second = actor_clusters( + self.config, + storage=storage, + page_size=2, + cursor=first.next_cursor, + ) + + self.assertIsNone(first.total) + self.assertEqual(len(first.clusters), 2) + self.assertIsNotNone(first.next_cursor) + self.assertEqual( + [cluster.cluster_id for cluster in second.clusters], + ["cluster-2"], + ) + self.assertIsNone(second.next_cursor) + + with self.assertRaises(CapabilityRequestError): + actor_detections( + self.config, + "cluster-2", + storage=storage, + page_size=2, + cursor=first.next_cursor, + ) + + def test_actor_detections_rejects_an_empty_cluster(self): + storage = Mock() + storage.records.return_value = [] + + with self.assertRaises(ActorClusterNotFoundError): + actor_detections( + self.config, + "missing", + storage=storage, + page_size=50, + cursor=None, + ) if __name__ == "__main__": diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..b862c1e --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,1092 @@ +import asyncio +import gc +import unittest +import warnings +from dataclasses import replace +from datetime import datetime, timezone +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import Mock +from unittest.mock import patch + +import httpx +import jwt +from cryptography.hazmat.primitives.asymmetric import rsa +from fastapi.testclient import TestClient + +from vidxp.api import create_app +from vidxp.api_routes.dependencies import scoped_job_id +from vidxp.application_models import ( + ApplicationError, + ErrorCategory, + ComponentReadiness, + Job, + JobKind, + JobQueue, + JobState, + MediaAsset, + Principal, + SearchCommand, + QueryVideoCommand, + UploadIntent, +) +from vidxp.composition import ( + HttpApplicationContext, + create_http_application, +) +from vidxp.control_plane import ControlPlaneApplication +from vidxp.authentication import create_authenticator +from vidxp.authorization import AuthorizationPolicy +from vidxp.core.media import MediaState, MediaStream +from vidxp.core.uploads import UploadState +from vidxp.job_service import JobService +from vidxp.ports import LocalFileResource +from vidxp.readiness_service import ReadinessService +from vidxp.settings import HttpAuthMode, VidXPSettings +from vidxp.upload_service import RemoteUploadService + + +MEDIA_ID = "123456781234423481234567890abcde" +JOB_ID = "223456781234423481234567890abcde" +IDEMPOTENCY_KEY = "323456781234423481234567890abcde" +ARTIFACT_ID = "423456781234423481234567890abcde" +TOKEN = "a" * 32 + + +def media_asset() -> MediaAsset: + return MediaAsset( + media_id=MEDIA_ID, + video_id=MEDIA_ID, + original_filename="video.mp4", + sha256="1" * 64, + byte_size=10, + detected_mime_type="video/mp4", + container="mp4", + duration_seconds=2, + streams=( + MediaStream( + index=0, + kind="video", + codec="h264", + width=1, + height=1, + ), + ), + state=MediaState.ready, + created_at=datetime.now(timezone.utc), + ) + + +def queued_job() -> Job: + return Job( + job_id=JOB_ID, + kind=JobKind.index, + state=JobState.queued, + queue=JobQueue.cpu, + ) + + +class ApiTests(unittest.TestCase): + def context( + self, + root: Path, + *, + auth: HttpAuthMode = HttpAuthMode.none, + upload_limit: int = 256 * 1024 * 1024, + json_limit: int = 4 * 1024 * 1024, + mcp_limit: int = 4 * 1024 * 1024, + allowed_origins: tuple[str, ...] = (), + remote_uploads: bool = False, + ) -> HttpApplicationContext: + settings = VidXPSettings( + repository_root=root, + runtime_backend="cpu", + http_auth_mode=auth, + http_static_bearer_token=TOKEN if auth == HttpAuthMode.static else None, + http_oidc_issuer=( + "https://issuer.example" + if auth == HttpAuthMode.oidc + else None + ), + http_oidc_audience=( + "https://api.example" + if auth == HttpAuthMode.oidc + else None + ), + http_oidc_jwks_url=( + "https://issuer.example/jwks" + if auth == HttpAuthMode.oidc + else None + ), + http_required_scopes=( + ("vidxp.read",) + if auth == HttpAuthMode.oidc + else () + ), + mcp_public_url=( + "https://api.example/mcp" + if auth == HttpAuthMode.oidc + else None + ), + http_max_small_upload_bytes=upload_limit, + http_max_json_body_bytes=json_limit, + mcp_max_request_body_bytes=mcp_limit, + http_allowed_origins=allowed_origins, + upload_public_endpoint=( + "http://localhost:8080/uploads/" + if remote_uploads + else None + ), + upload_internal_endpoint=( + "http://localhost:8080/uploads/" + if remote_uploads + else None + ), + upload_cleanup_token="c" * 32 if remote_uploads else None, + ) + application = Mock() + jobs = Mock(spec=JobService) + readiness = Mock() + readiness.ready.return_value = True + uploads = Mock(spec=RemoteUploadService) if remote_uploads else None + return HttpApplicationContext( + application=application, + jobs=jobs, + readiness=readiness, + authenticator=create_authenticator(settings), + authorization=AuthorizationPolicy(), + settings=settings, + uploads=uploads, + ) + + @staticmethod + def auth() -> dict[str, str]: + return {"Authorization": f"Bearer {TOKEN}"} + + def test_local_http_context_stops_worker_when_closed(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + + context.close() + + context.jobs.stop_worker.assert_called_once_with() + context.jobs.close.assert_called_once_with() + + def test_health_and_minimal_readiness_are_public(self): + with TemporaryDirectory() as directory: + context = self.context( + Path(directory), + auth=HttpAuthMode.static, + ) + with TestClient(create_app(context=context)) as client: + health = client.get("/health") + ready = client.get("/ready") + + self.assertEqual(health.status_code, 200) + self.assertEqual(health.json(), {"status": "ok"}) + self.assertEqual(ready.status_code, 200) + self.assertEqual(ready.json(), {"ready": True, "status": "ready"}) + + def test_resumable_upload_api_uses_typed_intents_and_private_urls(self): + with TemporaryDirectory() as directory: + context = self.context( + Path(directory), + auth=HttpAuthMode.static, + remote_uploads=True, + ) + assert context.uploads is not None + now = datetime.now(timezone.utc) + intent = UploadIntent( + intent_id="123456781234423481234567890abcde", + original_filename="video.mp4", + byte_size=20, + declared_mime_type="video/mp4", + state=UploadState.pending, + created_at=now, + expires_at=now.replace(year=now.year + 1), + ) + context.uploads.create_intent.return_value = intent + context.uploads.upload_url.return_value = None + with TestClient(create_app(context=context)) as client: + created = client.post( + "/api/v1/media/uploads", + headers={ + **self.auth(), + "Idempotency-Key": IDEMPOTENCY_KEY, + }, + json={ + "original_filename": "video.mp4", + "byte_size": 20, + "declared_mime_type": "video/mp4", + }, + ) + + self.assertEqual(created.status_code, 201) + self.assertEqual(created.headers["cache-control"], "private, no-store") + self.assertEqual(created.headers["referrer-policy"], "no-referrer") + self.assertEqual( + created.headers["location"], + f"/api/v1/media/uploads/{intent.intent_id}", + ) + payload = created.json() + self.assertEqual(payload["intent"]["state"], "pending") + self.assertEqual( + payload["creation_url"], + "http://localhost:8080/uploads/", + ) + self.assertIsNone(payload["resume_url"]) + self.assertTrue(payload["upload_metadata"].startswith("intent_id ")) + + def test_readiness_failure_returns_503_without_details(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.readiness.ready.return_value = False + with TestClient(create_app(context=context)) as client: + response = client.get("/ready") + + self.assertEqual(response.status_code, 503) + self.assertEqual( + response.json(), + {"ready": False, "status": "not_ready"}, + ) + + def test_static_bearer_is_enforced_before_dispatch(self): + with TemporaryDirectory() as directory: + context = self.context( + Path(directory), + auth=HttpAuthMode.static, + ) + context.application.list_capabilities.return_value = () + with TestClient(create_app(context=context)) as client: + missing = client.get("/api/v1/capabilities") + accepted = client.get( + "/api/v1/capabilities", + headers=self.auth(), + ) + + self.assertEqual(missing.status_code, 401) + self.assertEqual( + missing.json()["error"]["code"], + "authentication_required", + ) + self.assertEqual(missing.headers["www-authenticate"], "Bearer") + self.assertEqual(accepted.status_code, 200) + self.assertEqual(accepted.json(), {"items": []}) + context.application.list_capabilities.assert_called_once_with() + + def test_repository_scopes_are_enforced_per_operation(self): + with TemporaryDirectory() as directory: + context = self.context( + Path(directory), + auth=HttpAuthMode.static, + ) + authenticator = Mock() + authenticator.authenticate.return_value = Principal( + subject="reader", + scopes=frozenset({"vidxp.read"}), + ) + authenticator.readiness.return_value = ComponentReadiness( + name="authentication", + ready=True, + message="Authentication is ready.", + ) + context = replace(context, authenticator=authenticator) + context.application.list_capabilities.return_value = () + with TestClient(create_app(context=context)) as client: + readable = client.get( + "/api/v1/capabilities", + headers=self.auth(), + ) + forbidden = client.post( + "/api/v1/jobs/index", + headers={ + **self.auth(), + "Idempotency-Key": IDEMPOTENCY_KEY, + }, + json={ + "media_id": MEDIA_ID, + "modalities": ["scene"], + "frame_stride": 1, + "capability_options": {}, + }, + ) + + self.assertEqual(readable.status_code, 200) + self.assertEqual(forbidden.status_code, 403) + self.assertEqual( + forbidden.json()["error"]["code"], + "insufficient_scope", + ) + context.jobs.submit_index.assert_not_called() + + def test_validation_errors_use_safe_shared_envelope(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + with TestClient(create_app(context=context)) as client: + response = client.get("/api/v1/media/not-an-id") + + self.assertEqual(response.status_code, 422) + error = response.json()["error"] + self.assertEqual(error["code"], "invalid_request") + self.assertEqual( + error["details"]["errors"][0]["location"], + ["path", "media_id"], + ) + self.assertTrue(error["correlation_id"]) + + def test_unexpected_errors_are_logged_and_masked(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.application.get_media.side_effect = RuntimeError( + "secret-path" + ) + with TestClient( + create_app(context=context), + raise_server_exceptions=False, + ) as client: + response = client.get(f"/api/v1/media/{MEDIA_ID}") + + self.assertEqual(response.status_code, 500) + payload = response.json() + self.assertEqual(payload["error"]["code"], "internal_error") + self.assertNotIn("secret-path", response.text) + + def test_job_submission_is_thin_idempotent_delegation(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.jobs.submit_index.return_value = queued_job() + with TestClient(create_app(context=context)) as client: + response = client.post( + "/api/v1/jobs/index", + headers={"Idempotency-Key": IDEMPOTENCY_KEY}, + json={ + "media_id": MEDIA_ID, + "modalities": ["scene"], + "frame_stride": 1, + "scene_sample_fps": 0.5, + "capability_options": {}, + }, + ) + + self.assertEqual(response.status_code, 202) + self.assertEqual(response.headers["location"], f"/api/v1/jobs/{JOB_ID}") + command = context.jobs.submit_index.call_args.args[0] + self.assertEqual(command.media_id, MEDIA_ID) + self.assertEqual(command.modalities, ("scene",)) + self.assertEqual(command.scene_sample_fps, 0.5) + context.application.require_models.assert_called_once_with(("scene",)) + expected_job_id = scoped_job_id( + context, + context.authenticator.authenticate(None), + operation="index", + idempotency_key=IDEMPOTENCY_KEY, + ) + self.assertEqual( + context.jobs.submit_index.call_args.kwargs["job_id"], + expected_job_id, + ) + self.assertNotEqual(expected_job_id, IDEMPOTENCY_KEY) + self.assertNotEqual( + expected_job_id, + scoped_job_id( + context, + Principal(subject="other", scopes=frozenset({"*"})), + operation="index", + idempotency_key=IDEMPOTENCY_KEY, + ), + ) + self.assertNotEqual( + expected_job_id, + scoped_job_id( + context, + context.authenticator.authenticate(None), + operation="snippet", + idempotency_key=IDEMPOTENCY_KEY, + ), + ) + + def test_missing_models_fail_before_job_submission(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.application.require_models.side_effect = ApplicationError( + "model_unavailable", + ErrorCategory.unavailable, + "Run vidxp prepare --modalities scene.", + details={ + "capability": "scene", + "remediation": "vidxp prepare --modalities scene", + }, + ) + with TestClient(create_app(context=context)) as client: + response = client.post( + "/api/v1/jobs/index", + headers={"Idempotency-Key": IDEMPOTENCY_KEY}, + json={ + "media_id": MEDIA_ID, + "modalities": ["scene"], + }, + ) + + self.assertEqual(response.status_code, 503) + self.assertEqual(response.json()["error"]["code"], "model_unavailable") + self.assertEqual( + response.json()["error"]["details"]["remediation"], + "vidxp prepare --modalities scene", + ) + context.jobs.submit_index.assert_not_called() + + def test_job_submission_requires_an_idempotency_key(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + with TestClient(create_app(context=context)) as client: + response = client.post( + "/api/v1/jobs/index", + json={ + "media_id": MEDIA_ID, + "modalities": ["scene"], + "frame_stride": 1, + "capability_options": {}, + }, + ) + + self.assertEqual(response.status_code, 422) + context.jobs.submit_index.assert_not_called() + + def test_search_submission_uses_the_durable_query_boundary(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.jobs.submit_search.return_value = queued_job().model_copy( + update={"kind": JobKind.search} + ) + with TestClient(create_app(context=context)) as client: + response = client.post( + "/api/v1/jobs/search", + headers={"Idempotency-Key": IDEMPOTENCY_KEY}, + json={ + "modalities": ["scene"], + "query": "yellow taxi", + "top_k": 3, + }, + ) + + self.assertEqual(response.status_code, 202) + context.jobs.submit_search.assert_called_once() + command = context.jobs.submit_search.call_args.args[0] + self.assertEqual( + command, + SearchCommand( + modalities=("scene",), + query="yellow taxi", + top_k=3, + ), + ) + + def test_grounded_query_submission_uses_the_durable_boundary(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.jobs.submit_query.return_value = queued_job().model_copy( + update={"kind": JobKind.query} + ) + with TestClient(create_app(context=context)) as client: + response = client.post( + "/api/v1/jobs/query", + headers={"Idempotency-Key": IDEMPOTENCY_KEY}, + json={ + "question": "What happens after the taxi arrives?", + "media_id": MEDIA_ID, + "modalities": ["scene", "dialogue"], + "top_k": 5, + }, + ) + + self.assertEqual(response.status_code, 202) + context.jobs.submit_query.assert_called_once() + command = context.jobs.submit_query.call_args.args[0] + self.assertEqual( + command, + QueryVideoCommand( + question="What happens after the taxi arrives?", + media_id=MEDIA_ID, + modalities=("scene", "dialogue"), + top_k=5, + ), + ) + + def test_idempotency_payload_mismatch_returns_422(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.jobs.submit_index.side_effect = ApplicationError( + "idempotency_key_reused", + ErrorCategory.validation, + "The idempotency key was used for another request.", + ) + with TestClient(create_app(context=context)) as client: + response = client.post( + "/api/v1/jobs/index", + headers={"Idempotency-Key": IDEMPOTENCY_KEY}, + json={ + "media_id": MEDIA_ID, + "modalities": ["scene"], + "frame_stride": 1, + "capability_options": {}, + }, + ) + + self.assertEqual(response.status_code, 422) + self.assertEqual( + response.json()["error"]["code"], + "idempotency_key_reused", + ) + + def test_small_upload_stages_then_calls_application_ingestion(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory), upload_limit=32) + captured = {} + + def ingest(**kwargs): + staged = kwargs["staged_path"] + captured["path"] = staged + captured["bytes"] = staged.read_bytes() + captured["filename"] = kwargs["original_filename"] + captured["mime"] = kwargs["declared_mime_type"] + captured["request_key"] = kwargs["request_key"] + return media_asset() + + context.application.import_uploaded_media.side_effect = ingest + with TestClient(create_app(context=context)) as client: + response = client.post( + "/api/v1/media", + headers={"Idempotency-Key": IDEMPOTENCY_KEY}, + files={"upload": ("video.mp4", b"0123456789", "video/mp4")}, + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json()["media_id"], MEDIA_ID) + self.assertEqual(captured["bytes"], b"0123456789") + self.assertEqual(captured["filename"], "video.mp4") + self.assertEqual(captured["mime"], "video/mp4") + self.assertEqual(len(captured["request_key"]), 64) + self.assertFalse(captured["path"].exists()) + + def test_small_upload_enforces_streamed_file_limit(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory), upload_limit=4) + with TestClient(create_app(context=context)) as client: + response = client.post( + "/api/v1/media", + headers={"Idempotency-Key": IDEMPOTENCY_KEY}, + files={"upload": ("video.mp4", b"12345", "video/mp4")}, + ) + + self.assertEqual(response.status_code, 413) + self.assertEqual(response.json()["error"]["code"], "media_too_large") + context.application.import_uploaded_media.assert_not_called() + + def test_json_body_limit_rejects_before_job_dispatch(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory), json_limit=32) + with TestClient(create_app(context=context)) as client: + response = client.post( + "/api/v1/jobs/index", + json={ + "media_id": MEDIA_ID, + "modalities": ["scene"], + "frame_stride": 1, + "capability_options": {}, + }, + ) + + self.assertEqual(response.status_code, 413) + self.assertEqual( + response.json()["error"]["code"], + "request_body_too_large", + ) + context.jobs.submit_index.assert_not_called() + + def test_streamed_body_limit_does_not_require_content_length(self): + async def request() -> httpx.Response: + with TemporaryDirectory() as directory: + context = self.context(Path(directory), upload_limit=4) + app = create_app(context=context) + + async def body(): + yield ( + b"--boundary\r\n" + b'Content-Disposition: form-data; name="upload"; ' + b'filename="video.mp4"\r\n' + b"Content-Type: video/mp4\r\n\r\n" + ) + yield b"x" * (1024 * 1024 + 32) + yield b"\r\n--boundary--\r\n" + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://testserver", + ) as client: + return await client.post( + "/api/v1/media", + headers={ + "Content-Type": ( + "multipart/form-data; boundary=boundary" + ), + "Idempotency-Key": IDEMPOTENCY_KEY, + }, + content=body(), + ) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", ResourceWarning) + response = asyncio.run(request()) + gc.collect() + + self.assertEqual(response.status_code, 413) + self.assertEqual( + response.json()["error"]["code"], + "request_body_too_large", + ) + self.assertFalse( + [ + warning + for warning in caught + if issubclass(warning.category, ResourceWarning) + and "Unclosed file" in str(warning.message) + ] + ) + + def test_file_delivery_uses_starlette_range_and_strong_etag(self): + with TemporaryDirectory() as directory: + root = Path(directory) + content = root / "video.mp4" + content.write_bytes(b"0123456789") + context = self.context(root) + context.application.open_media_content.return_value = ( + LocalFileResource( + path=content, + filename="video.mp4", + mime_type="video/mp4", + byte_size=10, + etag="1" * 64, + ) + ) + context.application.open_artifact_content.return_value = ( + LocalFileResource( + path=content, + filename="snippet.mp4", + mime_type="video/mp4", + byte_size=10, + etag="1" * 64, + ) + ) + with TestClient(create_app(context=context)) as client: + ranged = client.get( + f"/api/v1/media/{MEDIA_ID}/content", + headers={"Range": "bytes=2-5"}, + ) + cached = client.get( + f"/api/v1/media/{MEDIA_ID}/content", + headers={"If-None-Match": f'"{"1" * 64}"'}, + ) + artifact = client.get( + f"/api/v1/artifacts/{ARTIFACT_ID}/content", + headers={"Range": "bytes=6-9"}, + ) + + self.assertEqual(ranged.status_code, 206) + self.assertEqual(ranged.content, b"2345") + self.assertEqual(ranged.headers["content-range"], "bytes 2-5/10") + self.assertEqual(ranged.headers["etag"], f'"{"1" * 64}"') + self.assertEqual(cached.status_code, 304) + self.assertEqual(cached.content, b"") + self.assertEqual(artifact.status_code, 206) + self.assertEqual(artifact.content, b"6789") + self.assertIn( + "snippet.mp4", + artifact.headers["content-disposition"], + ) + + def test_openapi_is_curated_and_has_unique_operation_ids(self): + with TemporaryDirectory() as directory: + app = create_app(context=self.context(Path(directory))) + schema = app.openapi() + + operations = [ + operation + for path in schema["paths"].values() + for method, operation in path.items() + if method.lower() in { + "get", + "post", + "put", + "patch", + "delete", + "head", + } + ] + operation_ids = [operation["operationId"] for operation in operations] + self.assertEqual(len(operation_ids), len(set(operation_ids))) + self.assertNotIn("/api/v1/execute", schema["paths"]) + self.assertNotIn("/mcp", schema["paths"]) + upload_schema = schema["paths"]["/api/v1/media"]["post"] + self.assertIn("multipart/form-data", upload_schema["requestBody"]["content"]) + self.assertNotIn('"format": "path"', str(upload_schema)) + clip_schema = schema["paths"]["/api/v1/jobs/snippet"]["post"] + self.assertIn("downloadable", clip_schema["summary"].lower()) + artifact_schema = schema["paths"][ + "/api/v1/artifacts/{artifact_id}/content" + ]["get"] + self.assertIn("download", artifact_schema["summary"].lower()) + + def test_openapi_declares_bearer_security_without_securing_health(self): + with TemporaryDirectory() as directory: + app = create_app( + context=self.context( + Path(directory), + auth=HttpAuthMode.static, + ) + ) + schema = app.openapi() + + bearer = schema["components"]["securitySchemes"]["BearerAuth"] + self.assertEqual(bearer["type"], "http") + self.assertEqual(bearer["scheme"], "bearer") + self.assertEqual( + schema["paths"]["/api/v1/capabilities"]["get"]["security"], + [{"BearerAuth": []}], + ) + self.assertNotIn("security", schema["paths"]["/health"]["get"]) + + def test_cors_is_scoped_to_the_rest_namespace(self): + with TemporaryDirectory() as directory: + context = self.context( + Path(directory), + allowed_origins=("https://client.example",), + ) + headers = { + "Origin": "https://client.example", + "Access-Control-Request-Method": "GET", + } + with TestClient(create_app(context=context)) as client: + api = client.options("/api/v1/media", headers=headers) + mcp = client.options("/mcp", headers=headers) + + self.assertEqual(api.status_code, 200) + self.assertEqual( + api.headers["access-control-allow-origin"], + "https://client.example", + ) + self.assertNotIn("access-control-allow-origin", mcp.headers) + + def test_security_middleware_rejections_use_typed_envelopes(self): + with TemporaryDirectory() as directory: + context = self.context( + Path(directory), + allowed_origins=("https://client.example",), + ) + with TestClient(create_app(context=context)) as client: + host = client.get( + "/health", + headers={"Host": "untrusted.example"}, + ) + ipv6_loopback = client.get( + "/health", + headers={"Host": "[::1]:8000"}, + ) + origin = client.options( + "/api/v1/media", + headers={ + "Origin": "https://untrusted.example", + "Access-Control-Request-Method": "GET", + }, + ) + method = client.options( + "/api/v1/media", + headers={ + "Origin": "https://client.example", + "Access-Control-Request-Method": "TRACE", + }, + ) + + self.assertEqual(host.status_code, 400) + self.assertEqual(host.json()["error"]["code"], "host_not_allowed") + self.assertEqual(ipv6_loopback.status_code, 200) + self.assertEqual(origin.status_code, 403) + self.assertEqual( + origin.json()["error"]["code"], + "cors_origin_forbidden", + ) + self.assertEqual(method.status_code, 400) + self.assertEqual( + method.json()["error"]["code"], + "cors_preflight_invalid", + ) + + def test_authenticated_profiles_protect_schema_and_hide_interactive_docs(self): + with TemporaryDirectory() as directory: + context = self.context( + Path(directory), + auth=HttpAuthMode.static, + ) + with TestClient(create_app(context=context)) as client: + unauthenticated = client.get("/docs") + authenticated = client.get( + "/docs", + headers=self.auth(), + ) + schema_without_token = client.get("/openapi.json") + schema = client.get( + "/openapi.json", + headers=self.auth(), + ) + + self.assertEqual(unauthenticated.status_code, 401) + self.assertEqual(authenticated.status_code, 404) + self.assertEqual(schema_without_token.status_code, 401) + self.assertEqual(schema.status_code, 200) + self.assertIn("/api/v1/capabilities", schema.json()["paths"]) + + def test_readiness_masks_component_probe_failures(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.application.control_plane_readiness.side_effect = OSError( + "private-catalog-detail" + ) + context.application.runtime_readiness.side_effect = OSError( + "private-runtime-detail" + ) + context.jobs.readiness.return_value = ComponentReadiness( + name="workflow", + ready=True, + message="Workflow is ready.", + ) + readiness = ReadinessService( + application=context.application, + jobs=context.jobs, + authenticator=context.authenticator, + ) + context = replace(context, readiness=readiness) + with TestClient(create_app(context=context)) as client: + minimal = client.get("/ready") + details = client.get("/api/v1/runtime/readiness") + + self.assertEqual(minimal.status_code, 503) + self.assertEqual( + minimal.json(), + {"ready": False, "status": "not_ready"}, + ) + self.assertEqual(details.status_code, 200) + self.assertFalse(details.json()["ready"]) + self.assertNotIn("private-", details.text) + + def test_owned_context_is_closed_when_lifespan_exits(self): + with TemporaryDirectory() as directory: + owned = self.context(Path(directory)) + with patch( + "vidxp.api.create_http_application", + return_value=owned, + ): + with TestClient(create_app()) as client: + self.assertEqual(client.get("/health").status_code, 200) + + owned.jobs.close.assert_called_once_with() + + def test_local_worker_startup_failure_fails_api_startup(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.jobs.start.side_effect = RuntimeError( + "transient worker failure" + ) + + with self.assertRaisesRegex( + RuntimeError, + "transient worker failure", + ): + with TestClient(create_app(context=context)): + pass + + context.jobs.start.assert_called_once_with() + + def test_http_composition_does_not_construct_model_runtime(self): + with TemporaryDirectory(ignore_cleanup_errors=True) as directory: + settings = VidXPSettings( + repository_root=Path(directory), + runtime_backend="cpu", + ) + with patch( + "vidxp.composition.ModelRuntime", + side_effect=AssertionError("model runtime constructed"), + ) as runtime: + context = create_http_application(settings) + try: + self.assertIsInstance( + context.application, + ControlPlaneApplication, + ) + runtime.assert_not_called() + finally: + context.close() + + def test_mcp_namespace_uses_static_auth_and_sdk_body_policy(self): + with TemporaryDirectory() as directory: + context = self.context( + Path(directory), + auth=HttpAuthMode.static, + mcp_limit=32, + ) + with TestClient(create_app(context=context)) as client: + unauthorized = client.post( + "/mcp", + headers={"Content-Type": "application/json"}, + content=b"{}", + ) + oversized = client.post( + "/mcp", + headers={ + **self.auth(), + "Content-Type": "application/json", + }, + content=b"x" * 64, + ) + metadata = client.get( + "/.well-known/oauth-protected-resource/mcp" + ) + + self.assertEqual(unauthorized.status_code, 401) + self.assertEqual( + unauthorized.json()["error"]["code"], + "authentication_required", + ) + self.assertEqual(oversized.status_code, 413) + self.assertEqual(metadata.status_code, 401) + self.assertEqual( + metadata.json()["error"]["code"], + "authentication_required", + ) + + def test_oidc_mcp_metadata_is_public_and_sdk_auth_challenges(self): + with TemporaryDirectory() as directory: + context = self.context( + Path(directory), + auth=HttpAuthMode.oidc, + ) + with TestClient(create_app(context=context)) as client: + metadata = client.get( + "/.well-known/oauth-protected-resource/mcp" + ) + challenged = client.post( + "/mcp", + headers={ + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + }, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2026-07-28", + "capabilities": {}, + "clientInfo": { + "name": "test", + "version": "1", + }, + }, + }, + ) + + self.assertEqual(metadata.status_code, 200) + self.assertEqual( + metadata.json()["resource"], + "https://api.example/mcp", + ) + self.assertEqual( + metadata.json()["authorization_servers"], + ["https://issuer.example"], + ) + self.assertEqual(challenged.status_code, 401) + self.assertIn( + "/.well-known/oauth-protected-resource/mcp", + challenged.headers["www-authenticate"], + ) + + def test_oidc_mcp_valid_token_with_missing_scope_returns_403(self): + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + public_jwk = jwt.PyJWK.from_dict( + { + **jwt.algorithms.RSAAlgorithm.to_jwk( + private_key.public_key(), + as_dict=True, + ), + "kid": "mcp-key", + "alg": "RS256", + "use": "sig", + } + ) + now = datetime.now(timezone.utc) + token = jwt.encode( + { + "sub": "user-1", + "iss": "https://issuer.example", + "aud": "https://api.example/mcp", + "scope": "other", + "iat": now, + "exp": now.replace(year=now.year + 1), + }, + private_key, + algorithm="RS256", + headers={"kid": "mcp-key"}, + ) + with TemporaryDirectory() as directory: + context = self.context( + Path(directory), + auth=HttpAuthMode.oidc, + ) + context.authenticator._jwks = Mock() + context.authenticator._jwks.get_signing_key_from_jwt.return_value = ( + public_jwk + ) + with TestClient(create_app(context=context)) as client: + response = client.post( + "/mcp", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + }, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2026-07-28", + "capabilities": {}, + "clientInfo": { + "name": "test", + "version": "1", + }, + }, + }, + ) + + self.assertEqual(response.status_code, 403) + + def test_typed_application_error_status_mapping(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.application.get_media.side_effect = ApplicationError( + "media_conflict", + ErrorCategory.conflict, + "The media is in conflict.", + ) + with TestClient(create_app(context=context)) as client: + response = client.get(f"/api/v1/media/{MEDIA_ID}") + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "media_conflict") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_api_cli.py b/tests/test_api_cli.py new file mode 100644 index 0000000..60682e6 --- /dev/null +++ b/tests/test_api_cli.py @@ -0,0 +1,20 @@ +import unittest +from contextlib import redirect_stdout +from io import StringIO + +from vidxp.api_cli import main + + +class ApiCliTests(unittest.TestCase): + def test_help_exits_without_starting_the_service(self): + output = StringIO() + + with redirect_stdout(output), self.assertRaises(SystemExit) as caught: + main(["--help"]) + + self.assertEqual(caught.exception.code, 0) + self.assertIn("VIDXP_* environment variables", output.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_application.py b/tests/test_application.py index 5ea2650..f3bb2d4 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -1,299 +1,1184 @@ +import json import unittest from pathlib import Path from tempfile import TemporaryDirectory -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, patch -from vidxp.application import VidXPService +from pydantic import ValidationError + +from vidxp.application import VidXPApplication +from vidxp.application_models import ( + ApplicationError, + CapabilityDependencyCheck, + ComponentReadiness, + CreateActorOverlayCommand, + CreateIndexCommand, + DependencyKind, + DependencyCheckCommand, + DependencyCheckResult, + DependencyUnavailableError, + FusedSearchResult, + IndexResult, + IndexSnapshotReference, + ModelUnavailableError, + PrepareModelsCommand, + QueryAnswerMode, + QueryVideoCommand, + RemoveIndexCommand, + SearchCommand, +) +from vidxp.core.media import MediaUnavailableError +from vidxp.core.contracts import IndexConfig, IndexSchemaError +from vidxp.infrastructure.local_index import LocalIndexBackend +from vidxp.model_contracts import ModelArtifactUnavailableError from vidxp.capabilities.contracts import ( CapabilityDefinition, + CapabilityExecutor, + CapabilityPlugin, OperationDefinition, - PreparationContext, ) -from vidxp.capabilities.scene.config import SceneConfig +from vidxp.capabilities.registry import ( + CapabilityRegistry, + create_capability_registry, +) from vidxp.capabilities.schemas import SearchInput, SearchResult -from vidxp.core.contracts import IndexConfig +from vidxp.capabilities.actor.schemas import ( + ActorClusterInput, + ActorClusterSummary, + ActorClustersInput, + ActorClustersOutput, + ActorDetection, + ActorDetectionsInput, + ActorDetectionsOutput, +) +from vidxp.control_plane import ControlPlaneApplication +from vidxp.composition import create_local_application +from vidxp.repository_layout import RepositoryLayout +from vidxp.repositories import RepositoryConfigError +from vidxp.runtime import ModelRuntime +from vidxp.settings import VidXPSettings +from vidxp.ports import IndexStore -class ApplicationServiceTests(unittest.TestCase): - def test_execute_supports_validated_operation_without_an_index(self): - capability = CapabilityDefinition( - name="export", - description="Export results.", - extra="export", +MEDIA_ID = "123456781234423481234567890abcde" +GENERATION_ID = "223456781234423481234567890abcde" +SNAPSHOT_ID = "323456781234423481234567890abcde" +SNAPSHOT_SHA256 = "a" * 64 + + +class ApplicationTests(unittest.TestCase): + def application( + self, + root: str | Path, + *, + registry: CapabilityRegistry | None = None, + backend: Mock | None = None, + ) -> tuple[VidXPApplication, Mock]: + settings = VidXPSettings( + repository_root=Path(root), + runtime_backend="cpu", + ) + runtime = ModelRuntime(settings) + active_backend = backend or Mock() + media_service = Mock() + artifact_service = Mock() + return ( + VidXPApplication( + settings=settings, + layout=RepositoryLayout(root=Path(root)), + registry=registry or create_capability_registry(), + runtime=runtime, + index_backend=active_backend, + media=media_service, + artifacts=artifact_service, + index_status=lambda: active_backend.status(Path(root)), + ), + active_backend, + ) + + def indexed_application( + self, + handler, + manager, + ) -> VidXPApplication: + definition = CapabilityDefinition( + name="indexed", + description="Indexed provider.", + extra="indexed", + collection_name="indexed", + index_stage="indexed", + execution_group="indexed", operations={ - "run": OperationDefinition( + "search": OperationDefinition( input_model=SearchInput, output_model=SearchResult, - handler=lambda context, request: { - "query_id": "export:1", - "query": request.query, - "modality": "export", - "hits": (), - }, - requires_index=False, ) }, ) - service = VidXPService() + + registry = CapabilityRegistry( + ( + CapabilityPlugin( + definition=definition, + executor_factory=lambda: CapabilityExecutor( + indexer=Mock(), + operations={"search": handler}, + ), + ), + ) + ) + application, backend = self.application( + "unused", + registry=registry, + ) + backend.active_config.return_value = IndexConfig.local( + enabled_modalities=("indexed",), + collection_names={"indexed": "indexed"}, + ) + backend.open_store.return_value = manager + return application + + def test_local_composition_maps_typed_repository_errors_safely(self): with ( patch( - "vidxp.application.get_capability", - return_value=capability, - ), - patch.object( - service, - "active_config", - side_effect=AssertionError("index should not be loaded"), + "vidxp.composition.resolve_repository", + side_effect=RepositoryConfigError("secret-path"), ), + self.assertRaises(ApplicationError) as caught, ): - result = service.execute( - "export", - "run", - {"query": "result bundle"}, - ) + create_local_application() - self.assertEqual(result.query, "result bundle") + self.assertEqual(caught.exception.code, "configuration_invalid") + self.assertNotIn( + "secret-path", + json.dumps(caught.exception.to_dict()), + ) - def test_missing_index_has_a_stable_status_contract(self): - service = VidXPService("missing-index") - with patch( - "vidxp.application.read_index_status", - return_value=None, + def test_local_composition_does_not_reclassify_unexpected_value_errors(self): + with ( + patch( + "vidxp.composition.resolve_repository", + side_effect=ValueError("programming error"), + ), + self.assertRaisesRegex(ValueError, "programming error"), ): - status = service.index_status() - - self.assertEqual(status["state"], "missing") - self.assertEqual(status["schema_version"], 1) + create_local_application() - def test_active_config_uses_selected_directory_and_device(self): - service = VidXPService("selected-index", device="cuda") - ready = {"state": "ready"} - stored_config = IndexConfig.local( - video_id="video-1", - storage_directory="selected-index", - ) + def test_local_composition_opens_and_closes_services_lazily(self): + application = Mock() + jobs = Mock() with ( + TemporaryDirectory() as directory, patch( - "vidxp.application.require_ready_index", - return_value=ready, - ) as require, + "vidxp.composition.create_application", + return_value=application, + ) as create_application, patch( - "vidxp.application.local_config_from_status", - return_value=stored_config, - ) as restore, + "vidxp.composition.create_job_service", + return_value=jobs, + ) as create_jobs, ): - config, status = service.active_config() + local = create_local_application(index_directory=directory) + self.assertEqual(local.repository.index_directory, Path(directory)) + create_application.assert_not_called() + create_jobs.assert_not_called() - require.assert_called_once_with(Path("selected-index")) - restore.assert_called_once_with( - ready, - storage_directory=Path("selected-index"), - ) - self.assertEqual(config.device, "cuda") - self.assertIs(status, ready) + self.assertIs(local.application, application) + self.assertIs(local.application, application) + create_application.assert_called_once() + create_jobs.assert_not_called() + local.close() + jobs.close.assert_not_called() + + self.assertIs(local.jobs, jobs) + create_jobs.assert_called_once() + local.close() + jobs.close.assert_called_once() + + def test_missing_index_has_shared_status_model(self): + application, backend = self.application("missing") + backend.status.return_value = None + + status = application.index_status() - def test_search_is_a_thin_adapter_over_the_core(self): - service = VidXPService() - expected = SearchResult( - query_id="scene:1", - query="yellow taxi", - modality="scene", + self.assertEqual(status.state, "missing") + self.assertEqual(status.schema_version, 1) + self.assertIsInstance(application, ControlPlaneApplication) + + def test_actor_cluster_is_a_typed_projection_over_execute(self): + application, _ = self.application("repository") + expected = ActorClusterSummary( + cluster_id="actor-1", + media_id=MEDIA_ID, + generation_id=GENERATION_ID, + detection_count=2, + first_timestamp=1, + last_timestamp=2, ) with patch.object( - service, + application, "execute", return_value=expected, ) as execute: - result = service.search("scene", "yellow taxi", top_k=7) + cluster = application.actor_cluster("actor-1") - self.assertIs(result, expected) + self.assertEqual(cluster, expected) execute.assert_called_once_with( - "scene", - "search", - {"query": "yellow taxi", "top_k": 7}, + "actor", + "cluster", + {"cluster_id": "actor-1"}, ) - def test_execute_translates_missing_optional_dependency(self): - def missing_dependency(_context, _request): - raise ModuleNotFoundError("No module named 'clip'", name="clip") + def test_pinned_search_reopens_the_requested_snapshot(self): + contexts = [] - operation = OperationDefinition( - input_model=SearchInput, - output_model=SearchResult, - handler=missing_dependency, - requires_index=False, + def handler(context, request): + contexts.append(context) + return SearchResult( + query_id="indexed:query", + query=request.query, + modality="indexed", + ) + + manager = MagicMock() + manager.__enter__.return_value = Mock(spec=IndexStore) + application = self.indexed_application(handler, manager) + backend = application.index_backend + pinned = IndexConfig.local( + enabled_modalities=("indexed",), + collection_names={"indexed": "indexed"}, + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, ) - capability = CapabilityDefinition( - name="scene", - description="Search visual scenes.", - extra="scene", - operations={"search": operation}, + backend.config_for_snapshot.return_value = pinned + + result = application.search( + SearchCommand(modalities=("indexed",), query="query"), + snapshot=IndexSnapshotReference( + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, + ), ) - service = VidXPService() - with patch( - "vidxp.application.get_capability", - return_value=capability, - ): - with self.assertRaisesRegex( - RuntimeError, - r'clip is unavailable.*pip install "vidxp\[scene\]"', - ): - service.execute( - "scene", - "search", - {"query": "yellow taxi"}, - ) - def test_create_index_centralizes_storage_and_runtime_configuration(self): - service = VidXPService("selected-index", device="cuda") - with patch( - "vidxp.application.index_video", - return_value={"scene_frames": 1}, - ) as index: - summary = service.create_index( - "video.mp4", - modalities=("scene",), - frame_stride=5, + self.assertEqual(result.query, "query") + backend.active_config.assert_not_called() + backend.config_for_snapshot.assert_called_once_with( + application.index_directory, + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, + device="cpu", + ) + backend.open_store.assert_called_once_with(pinned) + self.assertIs(contexts[0].storage, manager.__enter__.return_value) + + def test_query_reuses_one_pinned_store_and_preserves_media_scope(self): + requests = [] + + def handler(_context, request): + requests.append(request) + return SearchResult( + query_id="indexed:query", + query=request.query, + modality="indexed", ) - self.assertEqual(summary, {"scene_frames": 1}) - config = index.call_args.kwargs["config"] - self.assertEqual(config.storage_directory, "selected-index") - self.assertEqual(config.device, "cuda") + manager = MagicMock() + manager.__enter__.return_value = Mock(spec=IndexStore) + application = self.indexed_application(handler, manager) + pinned = IndexConfig.local( + enabled_modalities=("indexed",), + collection_names={"indexed": "indexed"}, + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, + ) + application.index_backend.config_for_snapshot.return_value = pinned + + result = application.query_video( + QueryVideoCommand( + question="What happens?", + media_id=MEDIA_ID, + modalities=("indexed",), + ), + snapshot=IndexSnapshotReference( + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, + ), + ) + + self.assertEqual(result.mode, QueryAnswerMode.no_evidence) + self.assertEqual(requests[0].media_id, MEDIA_ID) + application.index_backend.open_store.assert_called_once_with(pinned) + + def test_actor_render_reuses_one_pinned_store_and_context(self): + contexts = [] + calls = [] + + def cluster_handler(context, request): + contexts.append(context) + calls.append(("cluster", request.cluster_id)) + return ActorClusterSummary( + cluster_id=request.cluster_id, + media_id=MEDIA_ID, + generation_id=GENERATION_ID, + detection_count=1, + first_timestamp=1, + last_timestamp=1, + ) + + def detections_handler(context, request): + contexts.append(context) + calls.append(("detections", request.cursor)) + return ActorDetectionsOutput( + cluster_id=request.cluster_id, + detections=( + ActorDetection( + detection_id="detection-1", + cluster_id=request.cluster_id, + frame_index=1, + timestamp=1, + bbox=(0, 0, 10, 10), + dataset="local", + split="local", + run_id="default", + media_id=MEDIA_ID, + generation_id=GENERATION_ID, + modality="actor", + source_id="frame-1", + ), + ), + ) + + definition = CapabilityDefinition( + name="actor", + description="Actor provider.", + extra="actor", + collection_name="actor", + index_stage="actor", + execution_group="actor", + operations={ + "cluster": OperationDefinition( + input_model=ActorClusterInput, + output_model=ActorClusterSummary, + ), + "detections": OperationDefinition( + input_model=ActorDetectionsInput, + output_model=ActorDetectionsOutput, + ), + }, + ) + registry = CapabilityRegistry( + ( + CapabilityPlugin( + definition=definition, + executor_factory=lambda: CapabilityExecutor( + indexer=Mock(), + operations={ + "cluster": cluster_handler, + "detections": detections_handler, + }, + ), + ), + ) + ) + manager = MagicMock() + manager.__enter__.return_value = Mock(spec=IndexStore) + application, backend = self.application( + "repository", + registry=registry, + ) + pinned = IndexConfig.local( + enabled_modalities=("actor",), + collection_names={"actor": "actor"}, + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, + ) + backend.config_for_snapshot.return_value = pinned + backend.open_store.return_value = manager + application.artifacts.create_actor_overlay.return_value = Mock() + + application.render_actor( + CreateActorOverlayCommand(cluster_id="actor-1"), + snapshot=IndexSnapshotReference( + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, + ), + media_id=MEDIA_ID, + generation_id=GENERATION_ID, + ) + + backend.active_config.assert_not_called() + backend.open_store.assert_called_once_with(pinned) + self.assertEqual( + calls, + [("cluster", "actor-1"), ("detections", None)], + ) + self.assertEqual(len({id(context) for context in contexts}), 1) + application.artifacts.create_actor_overlay.assert_called_once() + artifact_call = ( + application.artifacts.create_actor_overlay.call_args.kwargs + ) + self.assertEqual(artifact_call["media_id"], MEDIA_ID) + self.assertEqual(artifact_call["generation_id"], GENERATION_ID) + + def test_create_index_builds_one_central_config(self): + with TemporaryDirectory() as directory: + application, backend = self.application(directory) + application.media.require_record.return_value = Mock( + original_filename="video.mp4", + sha256="1" * 64, + ) + application.media.content.return_value = Mock( + path=Path("managed.mp4") + ) + backend.create.return_value = { + "media_id": MEDIA_ID, + "generation_id": GENERATION_ID, + "snapshot_id": SNAPSHOT_ID, + "active_media_count": 1, + "record_counts": {"scene": 1}, + } + + result = application.create_index( + CreateIndexCommand( + media_id=MEDIA_ID, + modalities=("scene",), + frame_stride=5, + scene_sample_fps=2.0, + ) + ) + + self.assertEqual( + result, + IndexResult( + media_id=MEDIA_ID, + generation_id=GENERATION_ID, + snapshot_id=SNAPSHOT_ID, + active_media_count=1, + record_counts={"scene": 1}, + ), + ) + config = backend.create.call_args.kwargs["config"] self.assertEqual(config.enabled_modalities, ("scene",)) self.assertEqual(config.frame_stride, 5) + self.assertEqual(config.options_for("scene")["sample_fps"], 2.0) + self.assertEqual(config.device, "cpu") - def test_dependency_checks_return_a_transport_neutral_contract(self): - service = VidXPService() - with patch( - "vidxp.application.dependency_checks", - return_value=( - {"name": "CLIP", "ok": False, "error": "missing"}, - ), - ): - result = service.check_dependencies(("scene",)) - - self.assertFalse(result["ok"]) - failed = [check for check in result["checks"] if not check["ok"]] - self.assertEqual(failed, [ - {"name": "CLIP", "ok": False, "error": "missing"} - ]) - - def test_model_preparation_reports_progress_without_cli_dependencies(self): - service = VidXPService(device="cuda") - events = [] - prepare = Mock( - side_effect=lambda context, progress: ( - progress( - { - "state": "preparing", - "stage": "scene_model", - "message": "Preparing scene model", + def test_operation_definition_is_metadata_and_executor_owns_handler(self): + definition = CapabilityDefinition( + name="export", + description="Export results.", + extra="export", + operations={ + "run": OperationDefinition( + input_model=SearchInput, + output_model=SearchResult, + requires_index=False, + ) + }, + ) + plugin = CapabilityPlugin( + definition=definition, + executor_factory=lambda: CapabilityExecutor( + operations={ + "run": lambda _context, request: { + "query_id": "export:1", + "query": request.query, + "modality": "export", + "hits": (), } - ), - (SceneConfig.model_validate(context.settings).model,), - )[1] + } + ), ) - capability = Mock( - prepare=prepare, - config_model=SceneConfig, + application, _ = self.application( + "unused", + registry=CapabilityRegistry((plugin,)), ) - with ( - patch( - "vidxp.application.dependency_checks", - return_value=(), + + result = application.execute( + "export", + "run", + {"query": "bundle"}, + ) + + self.assertEqual(result.query, "bundle") + + def test_search_reuses_the_registered_capability_operation(self): + calls = [] + + def handler(context, request): + calls.append((context, request)) + return SearchResult( + query_id="indexed:1", + query=request.query, + modality="indexed", + ) + + manager = MagicMock() + manager.__enter__.return_value = Mock(spec=IndexStore) + application = self.indexed_application(handler, manager) + + result = application.search( + SearchCommand( + modalities=("indexed",), + query="yellow taxi", + top_k=7, + ) + ) + + self.assertIsInstance(result, FusedSearchResult) + self.assertEqual(result.modalities, ("indexed",)) + self.assertEqual(calls[0][1].query, "yellow taxi") + self.assertEqual(calls[0][1].top_k, 7) + self.assertIs( + calls[0][0].storage, + manager.__enter__.return_value, + ) + + def test_default_search_filters_indexed_non_search_capabilities(self): + searched: list[str] = [] + + def search_plugin(name: str) -> CapabilityPlugin: + definition = CapabilityDefinition( + name=name, + description=f"{name} search.", + extra=name, + collection_name=name, + index_stage=name, + execution_group=name, + operations={ + "search": OperationDefinition( + input_model=SearchInput, + output_model=SearchResult, + ) + }, + ) + + def handler(_context, request): + searched.append(name) + return SearchResult( + query_id=f"{name}:query", + query=request.query, + modality=name, + ) + + return CapabilityPlugin( + definition=definition, + executor_factory=lambda: CapabilityExecutor( + indexer=Mock(), + operations={"search": handler}, + ), + ) + + actor = CapabilityPlugin( + definition=CapabilityDefinition( + name="actor", + description="Actor clusters.", + extra="actor", + collection_name="actor", + index_stage="actor", + execution_group="actor", + operations={ + "clusters": OperationDefinition( + input_model=ActorClustersInput, + output_model=ActorClustersOutput, + ) + }, ), - patch( - "vidxp.application.get_capability", - return_value=capability, + executor_factory=lambda: CapabilityExecutor( + indexer=Mock(), + operations={ + "clusters": Mock( + side_effect=AssertionError( + "Search must not execute actor clusters." + ) + ) + }, ), - ): - result = service.prepare_models( - ("scene",), - progress_callback=events.append, + ) + registry = CapabilityRegistry( + ( + search_plugin("scene"), + search_plugin("dialogue"), + actor, + ) + ) + manager = MagicMock() + manager.__enter__.return_value = Mock(spec=IndexStore) + application, backend = self.application( + "repository", + registry=registry, + ) + backend.active_config.return_value = IndexConfig.local( + enabled_modalities=("scene", "dialogue", "actor"), + collection_names={ + "scene": "scene", + "dialogue": "dialogue", + "actor": "actor", + }, + ) + backend.open_store.return_value = manager + + result = application.search(SearchCommand(query="taxi")) + + self.assertEqual(searched, ["scene", "dialogue"]) + self.assertEqual(result.modalities, ("scene", "dialogue")) + + def test_application_boundary_returns_stable_validation_error(self): + application, _ = self.application("unused") + + with self.assertRaises(ApplicationError) as raised: + application.execute("scene", "unknown", {}) + + self.assertEqual(raised.exception.code, "invalid_request") + self.assertEqual( + raised.exception.to_dict()["category"], + "validation", + ) + + def test_validation_error_does_not_expose_input(self): + definition = CapabilityDefinition( + name="validate", + description="Validate input.", + extra="validate", + operations={ + "run": OperationDefinition( + input_model=SearchInput, + output_model=SearchResult, + requires_index=False, + ) + }, + ) + application, _ = self.application( + "unused", + registry=CapabilityRegistry( + ( + CapabilityPlugin( + definition=definition, + executor_factory=lambda: CapabilityExecutor( + operations={ + "run": lambda _context, request: { + "query_id": "validate:1", + "query": request.query, + "modality": "validate", + } + } + ), + ), + ) + ), + ) + + with self.assertRaises(ApplicationError) as raised: + application.execute( + "validate", + "run", + { + "query": "hello", + "secret": "do-not-leak", + }, + ) + + self.assertEqual(raised.exception.code, "invalid_request") + self.assertNotIn( + "do-not-leak", + json.dumps(raised.exception.to_dict()), + ) + + def test_missing_media_error_does_not_expose_path(self): + application, _ = self.application("unused") + secret_path = Path("private/customer/video.mp4") + application.media.require_record.side_effect = ( + MediaUnavailableError("secret") + ) + + with self.assertRaises(ApplicationError) as raised: + application.create_index( + CreateIndexCommand( + media_id=MEDIA_ID, + modalities=("scene",), + ) ) - prepare.assert_called_once() - context = prepare.call_args.args[0] - self.assertIsInstance(context, PreparationContext) - self.assertEqual(context.device, "cuda") - self.assertEqual(result["device"], "cuda") - self.assertEqual(events[0]["stage"], "scene_model") + self.assertEqual(raised.exception.code, "resource_not_found") + self.assertNotIn( + str(secret_path), + json.dumps(raised.exception.to_dict()), + ) - def test_clear_removes_only_known_run_state_after_clearing_collections(self): + def test_downstream_missing_file_is_not_misclassified_as_media(self): with TemporaryDirectory() as directory: - index_directory = Path(directory) / "index" - index_directory.mkdir() - for name in ( - "index_status.json", - "manifest.json", - "timings.jsonl", - "failures.jsonl", - "run.complete.json", - ): - (index_directory / name).write_text("{}", encoding="utf-8") - unrelated = index_directory / "keep.txt" - unrelated.write_text("keep", encoding="utf-8") - checkpoint_directory = index_directory / "checkpoints" - checkpoint_directory.mkdir() - (checkpoint_directory / "one.json").write_text( - "{}", - encoding="utf-8", + path = Path(directory) / "video.mp4" + path.write_bytes(b"video") + application, backend = self.application(directory) + application.media.require_record.return_value = Mock( + original_filename="video.mp4", + sha256="1" * 64, ) + application.media.content.return_value = Mock(path=path) + backend.create.side_effect = FileNotFoundError("ffmpeg") - storage = Mock() - storage.__enter__ = Mock(return_value=storage) - storage.__exit__ = Mock(return_value=None) - service = VidXPService(index_directory) - with ( - patch( - "vidxp.application.indexing_in_progress", - return_value=False, + with self.assertRaises(DependencyUnavailableError) as raised: + application.create_index( + CreateIndexCommand( + media_id=MEDIA_ID, + modalities=("scene",), + ) + ) + + self.assertEqual(raised.exception.code, "dependency_unavailable") + self.assertNotEqual(raised.exception.category, "not_found") + + def test_open_store_dependency_failure_is_stable(self): + application, backend = self.application("unused") + backend.active_config.return_value = IndexConfig.local( + enabled_modalities=("scene",), + collection_names={"scene": "scene"}, + ) + backend.open_store.side_effect = ModuleNotFoundError("chromadb") + + with self.assertRaises(DependencyUnavailableError) as raised: + application.execute( + "scene", + "search", + {"query": "hello"}, + ) + + payload = json.dumps(raised.exception.to_dict()) + self.assertNotIn("chromadb", payload) + self.assertIsInstance( + raised.exception.__cause__, + ModuleNotFoundError, + ) + + def test_prepare_dependency_failure_is_stable(self): + definition = CapabilityDefinition( + name="prepare-only", + description="Prepare a provider.", + extra="prepare-only", + operations={ + "noop": OperationDefinition( + input_model=SearchInput, + output_model=SearchResult, + requires_index=False, + ) + }, + prepares_models=True, + ) + plugin = CapabilityPlugin( + definition=definition, + executor_factory=lambda: CapabilityExecutor( + operations={ + "noop": lambda _context, request: { + "query_id": "noop:1", + "query": request.query, + "modality": "noop", + } + }, + prepare=Mock( + side_effect=ModuleNotFoundError("provider.internal") ), - patch( - "vidxp.application.read_index_status", - return_value=None, + ), + ) + registry = CapabilityRegistry((plugin,)) + registry.dependency_checks = Mock(return_value=()) + application, _ = self.application("unused", registry=registry) + + with self.assertRaises(DependencyUnavailableError) as raised: + application.prepare_models( + PrepareModelsCommand(modalities=("prepare-only",)) + ) + + self.assertNotIn( + "provider.internal", + json.dumps(raised.exception.to_dict()), + ) + + def test_missing_model_is_not_misclassified_as_a_package_dependency(self): + application, backend = self.application("unused") + application.media.require_record.return_value = Mock( + original_filename="video.mp4", + sha256="1" * 64, + ) + application.media.content.return_value = Mock( + path=Path("video.mp4") + ) + backend.create.side_effect = ModelArtifactUnavailableError("scene") + + with self.assertRaises(ModelUnavailableError) as raised: + application.create_index( + CreateIndexCommand( + media_id=MEDIA_ID, + modalities=("scene",), + ) + ) + + self.assertEqual(raised.exception.code, "model_unavailable") + self.assertNotIn( + "pip install", + json.dumps(raised.exception.to_dict()), + ) + + def test_unexpected_handler_error_is_not_misclassified(self): + failure = RuntimeError("implementation bug") + definition = CapabilityDefinition( + name="broken", + description="Broken provider.", + extra="broken", + operations={ + "run": OperationDefinition( + input_model=SearchInput, + output_model=SearchResult, + requires_index=False, + ) + }, + ) + plugin = CapabilityPlugin( + definition=definition, + executor_factory=lambda: CapabilityExecutor( + operations={ + "run": Mock(side_effect=failure), + } + ), + ) + application, _ = self.application( + "unused", + registry=CapabilityRegistry((plugin,)), + ) + + with self.assertRaises(RuntimeError) as raised: + application.execute("broken", "run", {"query": "hello"}) + + self.assertIs(raised.exception, failure) + + def test_indexed_operation_uses_and_closes_exact_injected_store(self): + store = Mock(spec=IndexStore) + manager = MagicMock() + manager.__enter__.return_value = store + seen = [] + + def handle(context, request): + seen.append(context.require_storage()) + return { + "query_id": "indexed:1", + "query": request.query, + "modality": "indexed", + } + + application = self.indexed_application(handle, manager) + + result = application.execute( + "indexed", + "search", + {"query": "hello"}, + ) + + self.assertEqual(result.query_id, "indexed:1") + self.assertEqual(seen, [store]) + manager.__enter__.assert_called_once_with() + manager.__exit__.assert_called_once_with(None, None, None) + + def test_store_closes_when_output_validation_fails(self): + store = Mock(spec=IndexStore) + manager = MagicMock() + manager.__enter__.return_value = store + application = self.indexed_application( + lambda _context, _request: {"query": "missing fields"}, + manager, + ) + + with self.assertRaises(ApplicationError) as raised: + application.execute( + "indexed", + "search", + {"query": "hello"}, + ) + + self.assertEqual(raised.exception.code, "invalid_request") + exit_args = manager.__exit__.call_args.args + self.assertIs(exit_args[0], ValidationError) + + def test_store_closes_when_handler_fails(self): + store = Mock(spec=IndexStore) + manager = MagicMock() + manager.__enter__.return_value = store + failure = RuntimeError("implementation bug") + application = self.indexed_application( + Mock(side_effect=failure), + manager, + ) + + with self.assertRaises(RuntimeError) as raised: + application.execute( + "indexed", + "search", + {"query": "hello"}, + ) + + self.assertIs(raised.exception, failure) + exit_args = manager.__exit__.call_args.args + self.assertIs(exit_args[0], RuntimeError) + self.assertIs(exit_args[1], failure) + + def test_dependency_check_returns_shared_result_model(self): + registry = create_capability_registry() + registry.dependency_checks = Mock( + return_value=( + CapabilityDependencyCheck( + capability="scene", + kind=DependencyKind.distribution, + name="transformers", + requirement="transformers>=5,<6", + ok=False, + error="distribution is not installed", ), - patch( - "vidxp.application.IndexStorage", - return_value=storage, + ) + ) + application, _ = self.application("unused", registry=registry) + + result = application.check_dependencies( + DependencyCheckCommand(modalities=("scene",)) + ) + + self.assertFalse(result.ok) + self.assertEqual(result.modalities, ("scene",)) + + @patch("vidxp.application.which", return_value=None) + def test_dependency_check_reports_missing_media_executables(self, _which): + registry = create_capability_registry() + registry.dependency_checks = Mock(return_value=()) + application, _ = self.application("unused", registry=registry) + + result = application.check_dependencies( + DependencyCheckCommand(modalities=("scene",)) + ) + + self.assertFalse(result.ok) + self.assertEqual( + [(check.capability, check.name) for check in result.checks], + [("media", "ffmpeg"), ("media", "ffprobe")], + ) + self.assertTrue( + all( + "does not install OS packages" in (check.error or "") + for check in result.checks + ) + ) + + def test_runtime_readiness_includes_dependency_failures(self): + application, _ = self.application("unused") + application.control_plane_readiness = Mock( + return_value=( + ComponentReadiness( + name="catalog", + ready=True, + message="available", ), - ): - cleared = service.clear_index() + ) + ) + application._index_storage_readiness = Mock( + return_value=ComponentReadiness( + name="index_storage", + ready=True, + message="available", + ) + ) + application.check_dependencies = Mock( + return_value=DependencyCheckResult( + ok=False, + modalities=("scene",), + checks=(), + ) + ) + + readiness = application.runtime_readiness() + + self.assertFalse(readiness.ready) + self.assertFalse(readiness.dependencies.ok) + + def test_prepare_uses_injected_runtime_and_executor(self): + definition = CapabilityDefinition( + name="prepare-only", + description="Prepare a provider.", + extra="prepare-only", + operations={ + "noop": OperationDefinition( + input_model=SearchInput, + output_model=SearchResult, + requires_index=False, + ) + }, + prepares_models=True, + ) + prepare = Mock(return_value=("model",)) + plugin = CapabilityPlugin( + definition=definition, + executor_factory=lambda: CapabilityExecutor( + operations={ + "noop": lambda _context, request: { + "query_id": "noop:1", + "query": request.query, + "modality": "noop", + "hits": (), + } + }, + prepare=prepare, + ), + ) + registry = CapabilityRegistry((plugin,)) + registry.dependency_checks = Mock(return_value=()) + application, _ = self.application("unused", registry=registry) + + result = application.prepare_models( + PrepareModelsCommand(modalities=("prepare-only",)) + ) + + self.assertEqual(result.prepared, ("model",)) + self.assertIs( + prepare.call_args.args[0].runtime, + application.runtime, + ) + + def test_clear_delegates_all_persistence_cleanup_to_backend(self): + with TemporaryDirectory() as directory: + application, backend = self.application(directory) + index = application.index_directory + index.mkdir(parents=True) + (index / "manifest.json").write_text("{}", encoding="utf-8") + unrelated = index / "keep.txt" + unrelated.write_text("keep", encoding="utf-8") + backend.indexing_in_progress.return_value = False + backend.status.return_value = None + + self.assertTrue(application.clear_index()) - self.assertTrue(cleared) - storage.clear.assert_called_once_with() - self.assertTrue(unrelated.is_file()) - self.assertFalse( - (index_directory / "index_status.json").exists() + backend.clear.assert_called_once() + self.assertTrue((index / "manifest.json").exists()) + self.assertTrue(unrelated.exists()) + + def test_remove_delegates_to_index_backend(self): + application, backend = self.application("repository") + backend.remove.return_value = True + + self.assertTrue( + application.remove_from_index( + RemoveIndexCommand(media_id=MEDIA_ID) ) - self.assertFalse(checkpoint_directory.exists()) + ) + + config, media_id = backend.remove.call_args.args + self.assertEqual( + Path(config.storage_directory), + Path("repository") / "indexes", + ) + self.assertEqual(media_id, MEDIA_ID) - def test_clear_translates_missing_storage_dependency(self): + def test_generation_storage_validation_checks_identity_and_count(self): + config = IndexConfig.local( + video_id="episode-1", + generation_id="generation-1", + enabled_modalities=("scene",), + collection_names={"scene": "scene"}, + ) + storage = Mock() + storage.records.return_value = [ + { + "generation_id": "generation-1", + "video_id": "episode-1", + "modality": "scene", + } + ] + LocalIndexBackend._validate_generation_records( + storage, + config, + {"scene": 1}, + ) + + with self.assertRaisesRegex( + IndexSchemaError, + "record count", + ): + LocalIndexBackend._validate_generation_records( + storage, + config, + {"scene": 2}, + ) + + def test_local_backend_injects_storage_for_generation_build(self): with TemporaryDirectory() as directory: - service = VidXPService(directory) + settings = VidXPSettings( + repository_root=directory, + runtime_backend="cpu", + ) + registry = create_capability_registry() + backend = LocalIndexBackend( + registry, + ModelRuntime(settings), + settings.layout, + ) + cleanup_storage = MagicMock() + storage = MagicMock() + storage.__enter__.return_value = storage + config = IndexConfig.local( + video_id=MEDIA_ID, + enabled_modalities=("scene",), + collection_names={"scene": "scene"}, + storage_directory=settings.layout.indexes, + ) + snapshot = Mock( + snapshot_id="a" * 32, + generations={"media": Mock()}, + ) with ( patch( - "vidxp.application.indexing_in_progress", - return_value=False, + "vidxp.infrastructure.local_index.IndexStorage", + side_effect=(cleanup_storage, storage), ), patch( - "vidxp.application.read_index_status", - return_value=None, + "vidxp.infrastructure.local_index.index_video", + return_value={"scene_frames": 1}, + ) as index_video, + patch( + "vidxp.infrastructure.local_index.LocalSnapshotRepository." + "generation_reference", + return_value=Mock(), ), patch( - "vidxp.application.IndexStorage", - side_effect=ModuleNotFoundError( - "No module named 'chromadb'", - name="chromadb", - ), + "vidxp.infrastructure.local_index.LocalSnapshotRepository." + "publish_generation", + return_value=snapshot, + ), + patch.object( + LocalIndexBackend, + "_validate_generation_records", + return_value={"scene": 1}, + ), + patch( + "vidxp.infrastructure.local_index.ManifestStore." + "record_storage_counts", ), ): - with self.assertRaisesRegex( - RuntimeError, - r'chromadb is unavailable.*pip install "vidxp\[storage\]"', - ): - service.clear_index() + result = backend.create( + Path("video.mp4"), + config=config, + progress=None, + cancellation=None, + source_name=None, + source_checksum="1" * 64, + ) + + self.assertEqual(result["record_counts"], {"scene": 1}) + self.assertEqual(result["media_id"], MEDIA_ID) + self.assertEqual(result["snapshot_id"], "a" * 32) + self.assertIs(index_video.call_args.kwargs["storage"], storage) + self.assertIs( + index_video.call_args.kwargs["manifest_store"].runtime, + backend.runtime, + ) + self.assertIsNotNone( + index_video.call_args.kwargs["config"].generation_id + ) + cleanup_storage.__exit__.assert_called_once() + storage.__exit__.assert_called_once() if __name__ == "__main__": diff --git a/tests/test_application_boundary.py b/tests/test_application_boundary.py new file mode 100644 index 0000000..21deedb --- /dev/null +++ b/tests/test_application_boundary.py @@ -0,0 +1,43 @@ +import unittest + +from vidxp.application_boundary import application_boundary +from vidxp.application_models import ApplicationError +from vidxp.core.artifacts import ArtifactRendererUnavailableError +from vidxp.core.media import MediaProbeUnavailableError + + +class ApplicationBoundaryTests(unittest.TestCase): + def test_missing_media_probe_carries_init_remediation(self): + @application_boundary + def operation(): + raise MediaProbeUnavailableError("missing") + + with self.assertRaises(ApplicationError) as raised: + operation() + + self.assertEqual(raised.exception.code, "media_probe_unavailable") + self.assertEqual( + raised.exception.detail.details["remediation"], + "vidxp init", + ) + + def test_missing_artifact_renderer_carries_init_remediation(self): + @application_boundary + def operation(): + raise ArtifactRendererUnavailableError("missing") + + with self.assertRaises(ApplicationError) as raised: + operation() + + self.assertEqual( + raised.exception.code, + "artifact_renderer_unavailable", + ) + self.assertEqual( + raised.exception.detail.details["remediation"], + "vidxp init", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_authentication.py b/tests/test_authentication.py new file mode 100644 index 0000000..401cbca --- /dev/null +++ b/tests/test_authentication.py @@ -0,0 +1,339 @@ +import unittest +from datetime import datetime, timedelta, timezone +from unittest.mock import Mock, patch + +import jwt +from cryptography.hazmat.primitives.asymmetric import rsa +from pydantic import ValidationError + +from vidxp.application_models import ApplicationError +from vidxp.authentication import ( + OIDCBearerAuthenticator, + StaticBearerAuthenticator, +) +from vidxp.settings import ApplicationMode, VidXPSettings + + +class AuthenticationTests(unittest.TestCase): + def test_static_bearer_uses_one_typed_principal(self): + authenticator = StaticBearerAuthenticator("a" * 32) + + principal = authenticator.authenticate("a" * 32) + + self.assertEqual(principal.subject, "static") + self.assertIn("*", principal.scopes) + with self.assertRaises(ApplicationError) as caught: + authenticator.authenticate("b" * 32) + self.assertEqual(caught.exception.code, "authentication_required") + + def test_oidc_uses_cached_jwks_and_fixed_validation_contract(self): + decoder = Mock( + return_value={ + "sub": "user-1", + "client_id": "client-1", + "scope": "vidxp.read vidxp.write", + } + ) + signing_key = Mock(key="public-key") + jwks = Mock() + jwks.get_signing_key_from_jwt.return_value = signing_key + fake_jwt = Mock() + fake_jwt.PyJWTError = Exception + fake_jwt.PyJWKClient.return_value = jwks + fake_jwt.decode = decoder + + with patch.dict("sys.modules", {"jwt": fake_jwt}): + authenticator = OIDCBearerAuthenticator( + issuer="https://issuer.example/", + audience="https://api.example", + jwks_url="https://issuer.example/jwks", + algorithms=("RS256",), + required_scopes=("vidxp.read",), + ) + principal = authenticator.authenticate("signed-token") + + self.assertEqual(principal.subject, "user-1") + self.assertEqual(principal.client_id, "client-1") + fake_jwt.PyJWKClient.assert_called_once_with( + "https://issuer.example/jwks", + cache_keys=True, + cache_jwk_set=True, + lifespan=300, + timeout=5, + ) + self.assertEqual(decoder.call_args.kwargs["algorithms"], ["RS256"]) + self.assertEqual( + decoder.call_args.kwargs["issuer"], + "https://issuer.example/", + ) + self.assertEqual( + decoder.call_args.kwargs["audience"], + "https://api.example", + ) + self.assertEqual( + decoder.call_args.kwargs["options"]["require"], + ["exp", "iss", "aud", "sub"], + ) + + def test_oidc_preserves_jwk_algorithm_binding(self): + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + public_jwk = jwt.PyJWK.from_dict( + { + **jwt.algorithms.RSAAlgorithm.to_jwk( + private_key.public_key(), + as_dict=True, + ), + "kid": "test-key", + "alg": "RS256", + "use": "sig", + } + ) + now = datetime.now(timezone.utc) + token = jwt.encode( + { + "sub": "user-1", + "iss": "https://issuer.example", + "aud": "https://api.example", + "scope": "vidxp.read", + "iat": now, + "exp": now + timedelta(minutes=5), + }, + private_key, + algorithm="RS512", + headers={"kid": "test-key"}, + ) + authenticator = OIDCBearerAuthenticator( + issuer="https://issuer.example", + audience="https://api.example", + jwks_url="https://issuer.example/jwks", + algorithms=("RS256", "RS512"), + required_scopes=("vidxp.read",), + ) + authenticator._jwks = Mock() + authenticator._jwks.get_signing_key_from_jwt.return_value = public_jwk + + with self.assertRaises(ApplicationError) as caught: + authenticator.authenticate(token) + + self.assertEqual(caught.exception.code, "authentication_required") + + def test_oidc_rejects_missing_configured_scope(self): + signing_key = Mock(key="public-key") + jwks = Mock() + jwks.get_signing_key_from_jwt.return_value = signing_key + fake_jwt = Mock() + fake_jwt.PyJWTError = Exception + fake_jwt.PyJWKClient.return_value = jwks + fake_jwt.decode.return_value = {"sub": "user-1", "scope": "other"} + + with ( + patch.dict("sys.modules", {"jwt": fake_jwt}), + self.assertRaises(ApplicationError) as caught, + ): + OIDCBearerAuthenticator( + issuer="https://issuer.example", + audience="https://api.example", + jwks_url="https://issuer.example/jwks", + algorithms=("RS256",), + required_scopes=("vidxp.read",), + ).authenticate("signed-token") + + self.assertEqual(caught.exception.code, "insufficient_scope") + + def test_oidc_readiness_checks_cached_signing_keys(self): + key_set = Mock( + keys=[ + Mock( + public_key_use="sig", + algorithm_name="RS256", + _jwk_data={"key_ops": ["verify"]}, + ) + ] + ) + jwks = Mock() + jwks.get_jwk_set.return_value = key_set + fake_jwt = Mock() + fake_jwt.PyJWTError = Exception + fake_jwt.PyJWKClient.return_value = jwks + + with patch.dict("sys.modules", {"jwt": fake_jwt}): + authenticator = OIDCBearerAuthenticator( + issuer="https://issuer.example", + audience="https://api.example", + jwks_url="https://issuer.example/jwks", + algorithms=("RS256",), + required_scopes=(), + ) + ready = authenticator.readiness() + jwks.get_jwk_set.return_value = Mock( + keys=[ + Mock( + public_key_use="enc", + algorithm_name="RSA-OAEP", + _jwk_data={"key_ops": ["decrypt"]}, + ) + ] + ) + unusable = authenticator.readiness() + jwks.get_jwk_set.side_effect = OSError("private-network-detail") + unavailable = authenticator.readiness() + + self.assertTrue(ready.ready) + self.assertFalse(unusable.ready) + self.assertFalse(unavailable.ready) + self.assertNotIn("private-network-detail", unavailable.message) + + def test_server_mode_refuses_unauthenticated_http(self): + settings = VidXPSettings( + mode=ApplicationMode.server, + runtime_backend="cpu", + ) + + with self.assertRaisesRegex(ValueError, "requires static bearer or OIDC"): + settings.validate_http_server() + + def test_oidc_settings_require_an_explicit_scope(self): + with self.assertRaisesRegex(ValueError, "at least one scope"): + VidXPSettings( + runtime_backend="cpu", + http_auth_mode="oidc", + http_oidc_issuer="https://issuer.example", + http_oidc_audience="https://api.example", + http_oidc_jwks_url="https://issuer.example/jwks", + ) + + def test_oidc_settings_preserve_exact_issuer(self): + settings = VidXPSettings( + runtime_backend="cpu", + http_auth_mode="oidc", + http_oidc_issuer="https://issuer.example", + http_oidc_audience="https://api.example", + http_oidc_jwks_url="https://issuer.example/jwks", + http_required_scopes=("vidxp.read",), + ) + + self.assertEqual( + settings.http_oidc_issuer, + "https://issuer.example", + ) + + def test_oidc_settings_preserve_uppercase_scheme_for_exact_issuer(self): + settings = VidXPSettings( + runtime_backend="cpu", + http_auth_mode="oidc", + http_oidc_issuer="HTTPS://issuer.example", + http_oidc_audience="https://api.example", + http_oidc_jwks_url="https://issuer.example/jwks", + http_required_scopes=("vidxp.read",), + ) + + self.assertEqual( + settings.http_oidc_issuer, + "HTTPS://issuer.example", + ) + + def test_oidc_http_server_requires_canonical_mcp_resource(self): + settings = VidXPSettings( + runtime_backend="cpu", + http_auth_mode="oidc", + http_oidc_issuer="https://issuer.example", + http_oidc_audience="https://api.example", + http_oidc_jwks_url="https://issuer.example/jwks", + http_required_scopes=("vidxp.read",), + ) + + with self.assertRaisesRegex(ValueError, "mcp_public_url"): + settings.validate_http_server() + + def test_mcp_public_resource_and_host_policy_are_strict(self): + with self.assertRaisesRegex(ValidationError, "ending in /mcp"): + VidXPSettings(mcp_public_url="https://api.example/not-mcp") + with self.assertRaisesRegex( + ValidationError, + "host wildcards", + ): + VidXPSettings(mcp_allowed_hosts=("*.example.com",)) + for origin in ( + "null", + "file://local", + "https://user@example.com", + "https://client.example/path", + "https://client.example?query", + ): + with self.subTest(origin=origin), self.assertRaisesRegex( + ValidationError, + "serialized HTTP origins", + ): + VidXPSettings(mcp_allowed_origins=(origin,)) + + settings = VidXPSettings( + mcp_public_url="https://api.example/mcp/", + mcp_allowed_hosts=("API.EXAMPLE.COM",), + mcp_allowed_origins=("http://localhost:*",), + ) + self.assertEqual( + settings.mcp_public_url, + "https://api.example/mcp", + ) + self.assertEqual( + settings.mcp_allowed_hosts, + ("api.example.com",), + ) + self.assertEqual( + settings.mcp_allowed_origins, + ("http://localhost:*",), + ) + + disabled = VidXPSettings(mcp_allowed_hosts=()) + with self.assertRaisesRegex(ValueError, "allowed MCP host"): + disabled.validate_http_server() + + def test_oidc_rejects_empty_query_and_fragment_delimiters(self): + for field, value in ( + ("http_oidc_issuer", "https://issuer.example?"), + ("http_oidc_issuer", "https://issuer.example#"), + ("http_oidc_jwks_url", "https://issuer.example/jwks#"), + ): + values = { + "runtime_backend": "cpu", + "http_auth_mode": "oidc", + "http_oidc_issuer": "https://issuer.example", + "http_oidc_audience": "https://api.example", + "http_oidc_jwks_url": "https://issuer.example/jwks", + "http_required_scopes": ("vidxp.read",), + field: value, + } + with self.subTest(field=field, value=value): + with self.assertRaises(ValueError): + VidXPSettings(**values) + + def test_oidc_rejects_cleartext_non_loopback_metadata(self): + with self.assertRaisesRegex(ValueError, "must use HTTPS"): + VidXPSettings( + runtime_backend="cpu", + http_auth_mode="oidc", + http_oidc_issuer="http://issuer.example", + http_oidc_audience="https://api.example", + http_oidc_jwks_url="http://issuer.example/jwks", + http_required_scopes=("vidxp.read",), + ) + + def test_oidc_rejects_url_parser_disagreement_characters(self): + with self.assertRaisesRegex(ValueError, "unsafe URL character"): + VidXPSettings( + runtime_backend="cpu", + http_auth_mode="oidc", + http_oidc_issuer="http://localhost", + http_oidc_audience="https://api.example", + http_oidc_jwks_url=( + "http://localhost\\@evil.example/jwks" + ), + http_required_scopes=("vidxp.read",), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_benchmark_cli.py b/tests/test_benchmark_cli.py index 21abef4..4b0b7e6 100644 --- a/tests/test_benchmark_cli.py +++ b/tests/test_benchmark_cli.py @@ -1,18 +1,50 @@ import json +import os +import subprocess +import sys import unittest from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import Mock, patch +from packaging.requirements import Requirement +import typer from typer.testing import CliRunner from vidxp import cli +from vidxp.benchmarks import cli as benchmark_cli +from vidxp.composition import LocalApplicationContext +from vidxp.repositories import RepositoryConfig, RepositoryRegistry class BenchmarkCliTests(unittest.TestCase): - def test_benchmark_app_is_not_loaded_without_its_extra(self): - with patch.object(cli, "requirements_available", return_value=False): - self.assertIsNone(cli._load_benchmark_app()) + def test_benchmark_group_is_available_without_srt_parser(self): + source = Path(__file__).resolve().parents[1] / "src" + code = """ +import importlib.abc +import sys + +class RejectSrt(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname == "srt" or fullname.startswith("srt."): + raise ModuleNotFoundError("blocked optional srt dependency") + return None + +sys.meta_path.insert(0, RejectSrt()) +from vidxp import cli +assert cli.benchmark_app is not None +""" + environment = dict(os.environ) + environment["PYTHONPATH"] = str(source) + completed = subprocess.run( + [sys.executable, "-c", code], + env=environment, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + self.assertEqual(completed.returncode, 0, completed.stderr) def test_benchmark_uses_global_device_and_json_output(self): runner = CliRunner() @@ -25,14 +57,31 @@ def test_benchmark_uses_global_device_and_json_output(self): annotations = root / "annotations.json" evaluator = root / "evaluator.py" media = root / "media" + replacement = root / "replacement.webm" + overrides = root / "media-overrides.json" annotations.write_text("[]", encoding="utf-8") evaluator.write_text("", encoding="utf-8") media.mkdir() + replacement.write_bytes(b"replacement") + overrides.write_text( + json.dumps({"broken.mp4": replacement.name}), + encoding="utf-8", + ) with ( patch.object( cli, - "VidXPService", - return_value=service, + "create_local_application", + return_value=LocalApplicationContext( + application=service, + jobs=Mock(), + repositories=RepositoryRegistry(config), + repository=RepositoryConfig( + "default", + Path("chroma_data"), + device="cuda", + configured=False, + ), + ), ), patch( "vidxp.benchmarks.cli.run_didemo", @@ -58,12 +107,180 @@ def test_benchmark_uses_global_device_and_json_output(self): str(media), "--run-id", "run-1", + "--media-overrides", + str(overrides), + "--scene-sample-fps", + "2", ], ) self.assertEqual(response.exit_code, 0, response.output) self.assertEqual(json.loads(response.stdout)["rank_at_1"], 0.5) self.assertEqual(run.call_args.kwargs["device"], "cuda") + self.assertEqual(run.call_args.kwargs["scene_sample_fps"], 2.0) + self.assertEqual( + run.call_args.kwargs["media_overrides"], + {"broken.mp4": replacement.resolve()}, + ) + + def test_missing_adapter_dependencies_fail_with_exact_extra_hint(self): + registry = Mock() + registry.requirements_for.return_value = ( + Requirement("missing-runtime>=1"), + ) + failure = { + "name": "missing-runtime", + "requirement": "missing-runtime>=1", + "installed_version": None, + "ok": False, + "error": "distribution is not installed", + } + with ( + patch.object( + benchmark_cli, + "create_capability_registry", + return_value=registry, + ), + patch.object( + benchmark_cli, + "inspect_requirement", + return_value=failure, + ), + self.assertRaises(typer.BadParameter) as raised, + ): + benchmark_cli._require_benchmark_dependencies("scene") + + self.assertIn( + 'pip install "vidxp[scene]"', + str(raised.exception), + ) + + def test_hirest_dependency_hint_includes_benchmark_parser(self): + registry = Mock() + registry.requirements_for.return_value = () + failure = { + "name": "srt", + "requirement": "srt<4,>=3.5", + "installed_version": None, + "ok": False, + "error": "distribution is not installed", + } + with ( + patch.object( + benchmark_cli, + "create_capability_registry", + return_value=registry, + ), + patch.object( + benchmark_cli, + "inspect_requirement", + return_value=failure, + ), + self.assertRaises(typer.BadParameter) as raised, + ): + benchmark_cli._require_benchmark_dependencies( + "dialogue", + include_benchmark_extra=True, + ) + + self.assertIn( + 'pip install "vidxp[dialogue,benchmarks]"', + str(raised.exception), + ) + + def test_invalid_hirest_pair_json_is_reported_as_input_error(self): + with TemporaryDirectory() as directory: + pairs = Path(directory) / "pairs.json" + pairs.write_text("{", encoding="utf-8") + + with self.assertRaises(typer.BadParameter) as raised: + benchmark_cli._pair_file(pairs) + + self.assertIn("valid readable JSON", str(raised.exception)) + self.assertEqual(raised.exception.param_hint, "--pairs") + + def test_invalid_didemo_media_override_is_reported_as_input_error(self): + with TemporaryDirectory() as directory: + overrides = Path(directory) / "media-overrides.json" + overrides.write_text( + json.dumps({"broken.mp4": "missing.webm"}), + encoding="utf-8", + ) + + with self.assertRaises(typer.BadParameter) as raised: + benchmark_cli._media_override_file(overrides) + + self.assertIn("was not found", str(raised.exception)) + self.assertEqual(raised.exception.param_hint, "--media-overrides") + + def test_hirest_rejects_closed_temporal_fraction_before_dependencies(self): + runner = CliRunner() + service = Mock() + service.index_directory = Path("chroma_data") + with TemporaryDirectory() as directory: + root = Path(directory) + config = root / "repositories.json" + required_files = { + name: root / name + for name in ( + "ground-truth.json", + "categories.json", + "evaluator.py", + "asr.zip", + ) + } + for path in required_files.values(): + path.write_text("", encoding="utf-8") + asr_directory = root / "asr" + asr_directory.mkdir() + with ( + patch.object( + cli, + "create_local_application", + return_value=LocalApplicationContext( + application=service, + jobs=Mock(), + repositories=RepositoryRegistry(config), + repository=RepositoryConfig( + "default", + Path("chroma_data"), + device="cpu", + configured=False, + ), + ), + ), + patch.object( + benchmark_cli, + "_require_benchmark_dependencies", + ) as dependencies, + ): + response = runner.invoke( + cli.app, + [ + "--config", + str(config), + "benchmark", + "hirest", + "--ground-truth", + str(required_files["ground-truth.json"]), + "--categories", + str(required_files["categories.json"]), + "--evaluator", + str(required_files["evaluator.py"]), + "--asr-archive", + str(required_files["asr.zip"]), + "--asr-directory", + str(asr_directory), + "--run-id", + "run-1", + "--temporal-window-fraction", + "1", + ], + ) + + self.assertEqual(response.exit_code, 2, response.output) + self.assertIn("less than one", response.output) + dependencies.assert_not_called() if __name__ == "__main__": diff --git a/tests/test_benchmark_prepare.py b/tests/test_benchmark_prepare.py new file mode 100644 index 0000000..134e67a --- /dev/null +++ b/tests/test_benchmark_prepare.py @@ -0,0 +1,385 @@ +import hashlib +import json +import unittest +import zipfile +from dataclasses import replace +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import Mock, patch + +from typer.testing import CliRunner + +from vidxp import cli +from vidxp.benchmarks.prepare import ( + DIDEMO_KNOWN_REPLACEMENT_VIDEO, + DIDEMO_KNOWN_REPLACEMENT_URL, + PreparationPlan, + PreparationResource, + _didemo_aws_url, + _extract_hirest_asr, + execute_preparation, + plan_didemo, + plan_hirest, +) +from vidxp.composition import LocalApplicationContext +from vidxp.repositories import RepositoryConfig, RepositoryRegistry + + +def _annotation(video: str, annotation_id: int = 1) -> dict: + return { + "annotation_id": annotation_id, + "description": "a visible action", + "num_segments": 6, + "times": [[0, 0]], + "video": video, + } + + +def _resolve_sizes(resources): + return tuple( + replace(resource, size_bytes=resource.size_bytes or 123) + for resource in resources + ) + + +class BenchmarkPreparationTests(unittest.TestCase): + def test_didemo_aws_url_uses_official_yfcc_mapping(self): + url = _didemo_aws_url( + "owner_4253489686_source.m4v", + {"4253489686": "abcdef012345"}, + ) + + self.assertEqual( + url, + "https://multimedia-commons.s3-us-west-2.amazonaws.com/" + "data/videos/mp4/abc/def/abcdef012345.mp4", + ) + + def test_didemo_plan_is_subset_aware_and_copy_paste_ready(self): + first = "owner_4253489686_source.m4v" + second = "owner_7071386095_source.mpg" + annotations = json.dumps( + [_annotation(first), _annotation(second, 2)] + ).encode() + + def fetched(url, _checksum, _name): + if url.endswith("test_data.json"): + return annotations + if url.endswith("yfcc100m_hash.txt"): + return ( + b"4253489686\tabcdef012345\n" + b"7071386095\t123456abcdef\n" + ) + return b"evaluator" + + with ( + TemporaryDirectory() as directory, + patch( + "vidxp.benchmarks.prepare.inspect_media_runtime", + return_value=Mock(ready=True, errors=()), + ), + patch( + "vidxp.benchmarks.prepare._fetch_verified", + side_effect=fetched, + ), + patch( + "vidxp.benchmarks.prepare._with_remote_sizes", + side_effect=_resolve_sizes, + ), + patch( + "vidxp.benchmarks.prepare._valid_media", + return_value=False, + ), + patch( + "vidxp.benchmarks.prepare._valid_artifact", + return_value=False, + ), + ): + plan = plan_didemo( + root=directory, + split="test", + annotation_indices=[1], + ffprobe="ffprobe", + ffmpeg="ffmpeg", + ) + + self.assertEqual(plan.selected_count, 1) + self.assertEqual(plan.selected_video_names, (second,)) + media = [ + resource + for resource in plan.resources + if resource.kind == "media" + ] + self.assertEqual(len(media), 1) + self.assertIn("123/456/123456abcdef.mp4", media[0].url) + self.assertIn("--annotation-indices 1", plan.command) + self.assertIn("--media-directory", plan.command) + + def test_known_didemo_replacement_is_explicit_and_manifested(self): + annotations = json.dumps( + [_annotation(DIDEMO_KNOWN_REPLACEMENT_VIDEO)] + ).encode() + + def fetched(url, _checksum, _name): + if url.endswith("test_data.json"): + return annotations + if url.endswith("yfcc100m_hash.txt"): + return b"13482799053\tdeb3d8c8aba7077b378d16b236b0a5\n" + return b"evaluator" + + with ( + TemporaryDirectory() as directory, + patch( + "vidxp.benchmarks.prepare.inspect_media_runtime", + return_value=Mock(ready=True, errors=()), + ), + patch( + "vidxp.benchmarks.prepare._fetch_verified", + side_effect=fetched, + ), + patch( + "vidxp.benchmarks.prepare._with_remote_sizes", + side_effect=_resolve_sizes, + ), + patch( + "vidxp.benchmarks.prepare._valid_media", + return_value=False, + ), + patch( + "vidxp.benchmarks.prepare._valid_artifact", + return_value=False, + ), + ): + plan = plan_didemo( + root=directory, + split="test", + annotation_indices=[0], + ffprobe="ffprobe", + ffmpeg="ffmpeg", + ) + + replacement = next( + resource + for resource in plan.resources + if resource.replacement_for is not None + ) + self.assertEqual(replacement.url, DIDEMO_KNOWN_REPLACEMENT_URL) + self.assertEqual( + replacement.replacement_for, + DIDEMO_KNOWN_REPLACEMENT_VIDEO, + ) + self.assertIsNotNone(plan.media_overrides_path) + self.assertIn("--media-overrides", plan.command) + + def test_hirest_plan_prepares_released_asr_without_video_downloads(self): + ground_truth = { + "Make tea": { + "tea.mp4": { + "clip": True, + "bounds": [1, 2], + "v_duration": 3, + }, + "other.mp4": { + "clip": True, + "bounds": [0, 1], + "v_duration": 2, + }, + } + } + + def fetched(url, _checksum, _name): + if "all_data_val.json" in url: + return json.dumps(ground_truth).encode() + if url.endswith("categories.json"): + return b"{}" + return b"evaluator" + + with ( + TemporaryDirectory() as directory, + patch( + "vidxp.benchmarks.prepare._fetch_verified", + side_effect=fetched, + ), + patch( + "vidxp.benchmarks.prepare._with_remote_sizes", + side_effect=_resolve_sizes, + ), + patch( + "vidxp.benchmarks.prepare._valid_artifact", + return_value=False, + ), + ): + plan = plan_hirest( + root=directory, + split="validation", + pairs=[("Make tea", "tea.mp4")], + ) + + self.assertEqual(plan.selected_count, 1) + self.assertEqual(plan.selected_video_names, ("tea.mp4",)) + self.assertFalse( + any(resource.kind == "media" for resource in plan.resources) + ) + self.assertGreater(plan.additional_bytes, 17_000_000) + self.assertIn("--asr-archive", plan.command) + self.assertIn("--asr-directory", plan.command) + self.assertIn("--pairs", plan.command) + + def test_hirest_extraction_keeps_only_selected_transcripts(self): + with TemporaryDirectory() as directory: + root = Path(directory) + archive = root / "ASR.zip" + with zipfile.ZipFile(archive, "w") as bundle: + bundle.writestr("ASR/first.srt", "first") + bundle.writestr("ASR/second.srt", "second") + destination = root / "asr" + + extracted = _extract_hirest_asr( + archive, + destination, + ["first.mp4"], + ) + + self.assertEqual(extracted, 1) + self.assertEqual( + (destination / "first.srt").read_text(), + "first", + ) + self.assertFalse((destination / "second.srt").exists()) + + def test_execution_writes_verified_artifacts_and_manifest(self): + with TemporaryDirectory() as directory: + root = Path(directory) + content = b"official" + destination = root / "annotations.json" + plan = PreparationPlan( + benchmark="didemo", + split="test", + root=root, + resources=( + PreparationResource( + name="annotations", + url="https://example.invalid/annotations", + destination=destination, + size_bytes=len(content), + kind="artifact", + content=content, + expected_sha256=hashlib.sha256( + content + ).hexdigest(), + ), + ), + selected_count=1, + selected_video_names=("video.mp4",), + command="vidxp benchmark didemo ...", + manifest_path=root / "preparation-manifest.json", + ) + + result = execute_preparation(plan) + + self.assertEqual(result["status"], "ready") + self.assertEqual(destination.read_bytes(), content) + self.assertTrue(plan.manifest_path.is_file()) + + def test_complete_partial_download_is_verified_without_network_retry(self): + with TemporaryDirectory() as directory: + root = Path(directory) + content = b"resume" + destination = root / "ASR.zip" + partial = destination.with_name(destination.name + ".part") + partial.write_bytes(content) + plan = PreparationPlan( + benchmark="didemo", + split="test", + root=root, + resources=( + PreparationResource( + name="archive", + url="https://example.invalid/ASR.zip", + destination=destination, + size_bytes=len(content), + kind="artifact", + expected_sha256=hashlib.sha256( + content + ).hexdigest(), + ), + ), + selected_count=1, + selected_video_names=(), + command="vidxp benchmark didemo ...", + manifest_path=root / "preparation-manifest.json", + ) + + with patch( + "vidxp.benchmarks.prepare.urllib.request.urlopen" + ) as urlopen: + result = execute_preparation(plan) + + urlopen.assert_not_called() + self.assertEqual(result["status"], "ready") + self.assertEqual(destination.read_bytes(), content) + + +class BenchmarkPreparationCliTests(unittest.TestCase): + def test_prepare_command_prints_generated_run_command(self): + runner = CliRunner() + service = Mock() + with TemporaryDirectory() as directory: + root = Path(directory) + config = root / "repositories.json" + plan = PreparationPlan( + benchmark="didemo", + split="test", + root=root / "prepared", + resources=(), + selected_count=1, + selected_video_names=("video.mp4",), + command="vidxp benchmark didemo --run-id ready", + manifest_path=root / "prepared" / "manifest.json", + ) + with ( + patch.object( + cli, + "create_local_application", + return_value=LocalApplicationContext( + application=service, + jobs=Mock(), + repositories=RepositoryRegistry(config), + repository=RepositoryConfig( + "default", + root / "repository", + device="cpu", + configured=False, + ), + ), + ), + patch( + "vidxp.benchmarks.cli.plan_didemo", + return_value=plan, + ), + patch( + "vidxp.benchmarks.cli.execute_preparation", + return_value={"status": "ready"}, + ), + ): + response = runner.invoke( + cli.app, + [ + "--config", + str(config), + "benchmark", + "prepare", + "didemo", + "--annotation-indices", + "0", + "--yes", + ], + ) + + self.assertEqual(response.exit_code, 0, response.output) + self.assertIn("Benchmark inputs are ready.", response.output) + self.assertIn(plan.command, response.output) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_benchmarks.py b/tests/test_benchmarks.py index 055e3cb..42303db 100644 --- a/tests/test_benchmarks.py +++ b/tests/test_benchmarks.py @@ -1,11 +1,16 @@ import hashlib import json import unittest +from uuid import UUID from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import patch -from vidxp.benchmarks.common import verify_artifact +from vidxp.benchmarks.common import ( + benchmark_generation_id, + benchmark_media_id, + verify_artifact, +) from vidxp.benchmarks.didemo import ( DIDEMO_MOMENTS, _result_classification as didemo_result_classification, @@ -26,10 +31,16 @@ from vidxp.capabilities.schemas import SearchHit +MEDIA_ID = "123456781234423481234567890abcde" +GENERATION_ID = "223456781234423481234567890abcde" + + def scene_hit(chunk, score): return SearchHit( rank=chunk + 1, - video_id="video", + media_id=MEDIA_ID, + video_id=MEDIA_ID, + generation_id=GENERATION_ID, start=chunk * 5.0, end=chunk * 5.0 + 1.0, score=score, @@ -43,7 +54,9 @@ def scene_hit(chunk, score): def timed_hit(start, end, score, rank=1): return SearchHit( rank=rank, - video_id="video", + media_id=MEDIA_ID, + video_id=MEDIA_ID, + generation_id=GENERATION_ID, start=start, end=end, score=score, @@ -55,6 +68,34 @@ def timed_hit(start, end, score, rank=1): class BenchmarkCommonTests(unittest.TestCase): + def test_generation_identity_is_stable_and_run_scoped(self): + first = benchmark_generation_id("hirest", "validation", "run-1") + + self.assertEqual( + first, + benchmark_generation_id("hirest", "validation", "run-1"), + ) + self.assertEqual(len(first), 32) + self.assertEqual(UUID(hex=first).version, 4) + self.assertNotEqual( + first, + benchmark_generation_id("hirest", "validation", "run-2"), + ) + self.assertNotEqual( + first, + benchmark_generation_id("didemo", "validation", "run-1"), + ) + + def test_official_video_key_maps_to_stable_media_id(self): + first = benchmark_media_id("hirest", "video.mp4") + + self.assertEqual(first, benchmark_media_id("hirest", "video.mp4")) + self.assertEqual(UUID(hex=first).version, 4) + self.assertNotEqual( + first, + benchmark_media_id("didemo", "video.mp4"), + ) + def test_artifact_checksum_is_verified(self): with TemporaryDirectory() as directory: path = Path(directory) / "artifact.json" @@ -79,6 +120,23 @@ def test_artifact_checksum_is_verified(self): revision="abc123", ) + def test_artifact_accepts_declared_line_ending_variants(self): + with TemporaryDirectory() as directory: + path = Path(directory) / "evaluator.py" + path.write_bytes(b"print('official')\r\n") + raw_sha = hashlib.sha256(b"print('official')\n").hexdigest() + checkout_sha = hashlib.sha256(path.read_bytes()).hexdigest() + + artifact = verify_artifact( + path, + name="test evaluator", + expected_sha256=(raw_sha, checkout_sha), + source="https://example.invalid/evaluator.py", + revision="abc123", + ) + + self.assertEqual(artifact["sha256"], checkout_sha) + class DiDeMoAdapterTests(unittest.TestCase): def test_media_override_is_disclosed_in_result_classification(self): @@ -205,11 +263,11 @@ def test_held_out_test_writes_unscored_submission(self): patch( "vidxp.benchmarks.hirest.run_index", return_value={}, - ), + ) as run_index, patch( "vidxp.benchmarks.hirest._generate_predictions", return_value=predictions, - ), + ) as generate_predictions, patch( "vidxp.benchmarks.hirest._evaluate_predictions" ) as evaluate_predictions, @@ -233,6 +291,22 @@ def test_held_out_test_writes_unscored_submission(self): ) self.assertFalse((run_directory / "metrics.json").exists()) evaluate_predictions.assert_not_called() + index_config = run_index.call_args.args[1] + prediction_config = ( + generate_predictions.call_args.kwargs["config"] + ) + self.assertEqual( + index_config.generation_id, + benchmark_generation_id( + "hirest", + "test", + "test-submission", + ), + ) + self.assertEqual( + prediction_config.generation_id, + index_config.generation_id, + ) @patch("vidxp.benchmarks.hirest.run_logged_evaluator") def test_official_evaluator_is_forced_to_utf8( diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index 07847ec..8a9d8b5 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -1,25 +1,28 @@ import unittest +from importlib.metadata import PackageNotFoundError +from types import SimpleNamespace +from unittest.mock import Mock, patch from pydantic import BaseModel, ValidationError +from vidxp.capabilities.actor.config import ActorConfig from vidxp.capabilities.contracts import ( - CapabilityContext, CapabilityDefinition, + CapabilityExecutor, CapabilityIndexResult, CapabilityInput, CapabilityOutput, + CapabilityPlugin, + CapabilityProvenance, OperationDefinition, + RuntimeCheck, ) -from vidxp.capabilities.actor.config import ActorConfig from vidxp.capabilities.dialogue.config import DialogueConfig from vidxp.capabilities.registry import ( - CAPABILITIES, - capability_names, - collection_names, - index_capability_names, - preparable_capability_names, - validate_capability_options, + CapabilityRegistry, + create_capability_registry, ) +from vidxp.capability_service import CapabilityService from vidxp.capabilities.scene.config import SceneConfig from vidxp.core.contracts import IndexConfig from vidxp.core.runner import _index_groups @@ -34,114 +37,139 @@ class ExampleOutput(CapabilityOutput): class CapabilityTests(unittest.TestCase): - def test_registry_is_explicit_and_drives_index_collections(self): + def setUp(self): + self.registry = create_capability_registry() + + def test_registry_drives_capability_metadata(self): self.assertEqual( - capability_names(), + self.registry.names(), ("dialogue", "scene", "actor"), ) + self.assertEqual(self.registry.index_names(), self.registry.names()) self.assertEqual( - index_capability_names(), - capability_names(), - ) - self.assertEqual( - preparable_capability_names(), - ("dialogue", "scene"), + self.registry.preparable_names(), + ("dialogue", "scene", "actor"), ) self.assertEqual( - collection_names(), + self.registry.collection_names(), { "dialogue": "dialogue", "scene": "scene", "actor": "actor", }, ) - self.assertEqual(collection_names(("scene",)), {"scene": "scene"}) - def test_registered_operations_use_pydantic_contracts(self): - for capability in CAPABILITIES.values(): - self.assertIsInstance(capability, BaseModel) - for operation in capability.operations.values(): - self.assertIsInstance(operation, BaseModel) - self.assertTrue( - issubclass(operation.input_model, BaseModel) - ) - self.assertTrue( - issubclass(operation.output_model, BaseModel) - ) + def test_registered_operations_are_schema_only_metadata(self): + self.assertNotIn("handler", OperationDefinition.model_fields) + for definition in self.registry.definitions.values(): + self.assertIsInstance(definition, BaseModel) + for operation in definition.operations.values(): + self.assertTrue(issubclass(operation.input_model, BaseModel)) + self.assertTrue(issubclass(operation.output_model, BaseModel)) + + def test_internal_actor_cluster_lookup_is_not_advertised(self): + operations = { + operation.name + for operation in CapabilityService(self.registry) + .get("actor") + .operations + } + + self.assertNotIn("cluster", operations) + self.assertIn("clusters", operations) + self.assertIn("detections", operations) + + def test_capability_list_is_summary_only(self): + service = CapabilityService(self.registry) + + summaries = service.list() + detail = service.get("scene") + + self.assertEqual( + tuple(summary.name for summary in summaries), + self.registry.names(), + ) + self.assertTrue(detail.operations) + self.assertTrue( + all(not hasattr(summary, "operations") for summary in summaries) + ) - def test_capability_contracts_are_frozen_and_schema_validated(self): + def test_executor_handlers_match_declared_operations(self): + for name, definition in self.registry.definitions.items(): + self.assertEqual( + set(self.registry.executor(name).operations), + set(definition.operations), + ) + + def test_contracts_are_frozen_and_index_metadata_is_complete(self): with self.assertRaises(ValidationError): - CAPABILITIES["scene"].name = "changed" + self.registry.get("scene").name = "changed" with self.assertRaises(ValidationError): CapabilityDefinition( name="broken", description="Incomplete index integration.", extra="broken", collection_name="broken", - indexer=lambda **_: None, ) with self.assertRaises(ValidationError): - CapabilityIndexResult( - summary={}, - timings={"index": -1}, - ) - - def test_built_in_settings_are_capability_owned_and_validated(self): - self.assertIs(CAPABILITIES["dialogue"].config_model, DialogueConfig) - self.assertIs(CAPABILITIES["scene"].config_model, SceneConfig) - self.assertIs(CAPABILITIES["actor"].config_model, ActorConfig) + CapabilityIndexResult(summary={}, timings={"index": -1}) - options = validate_capability_options( - ("scene",), - {"scene": {"batch_size": 4, "model": "test-model"}}, + def test_built_in_settings_are_owned_and_validated(self): + self.assertIs( + self.registry.get("dialogue").config_model, + DialogueConfig, ) + self.assertIs(self.registry.get("scene").config_model, SceneConfig) + self.assertIs(self.registry.get("actor").config_model, ActorConfig) - self.assertEqual( - options["scene"], - {"batch_size": 4, "model": "test-model"}, + options = self.registry.validate_options( + ("scene",), + {"scene": {"batch_size": 4, "sample_fps": 0.5}}, ) + self.assertEqual(options["scene"]["batch_size"], 4) + self.assertEqual(options["scene"]["sample_fps"], 0.5) + self.assertNotIn("model", options["scene"]) + with self.assertRaises(ValidationError): + self.registry.validate_options( + ("scene",), + {"scene": {"sample_fps": 0}}, + ) + with self.assertRaises(ValidationError): + self.registry.validate_options( + ("scene",), + {"scene": {"model": "unapproved/model"}}, + ) with self.assertRaises(ValidationError): - validate_capability_options( + self.registry.validate_options( ("actor",), {"actor": {"match_threshold": 2}}, ) - def test_core_config_has_no_built_in_capability_fields(self): - fields = IndexConfig.__dataclass_fields__ - for name in ( - "sentence_model", - "whisper_model", - "clip_model", - "dialogue_batch_size", - "scene_batch_size", - "actor_batch_size", - "face_match_threshold", - ): - self.assertNotIn(name, fields) - - def test_operation_validates_both_input_and_output(self): - operation = OperationDefinition( - input_model=ExampleInput, - output_model=ExampleOutput, - handler=lambda _context, request: { - "doubled": request.value * 2 + def test_registry_rejects_executor_metadata_drift(self): + definition = CapabilityDefinition( + name="export", + description="Export.", + extra="export", + operations={ + "run": OperationDefinition( + input_model=ExampleInput, + output_model=ExampleOutput, + requires_index=False, + ) }, - requires_index=False, ) - - result = operation.invoke( - CapabilityContext(config=None), - {"value": 3}, - ) - - self.assertEqual(result, ExampleOutput(doubled=6)) - with self.assertRaises(ValidationError): - operation.invoke( - CapabilityContext(config=None), - {"value": 3, "unexpected": True}, + registry = CapabilityRegistry( + ( + CapabilityPlugin( + definition=definition, + executor_factory=lambda: CapabilityExecutor(), + ), ) + ) + with self.assertRaisesRegex(RuntimeError, "operation handlers"): + registry.executor("export") - def test_operation_only_capability_needs_no_dummy_indexer(self): + def test_operation_only_capability_needs_no_index_metadata(self): capability = CapabilityDefinition( name="export", description="Export results.", @@ -150,37 +178,274 @@ def test_operation_only_capability_needs_no_dummy_indexer(self): "run": OperationDefinition( input_model=ExampleInput, output_model=ExampleOutput, - handler=lambda _context, request: { - "doubled": request.value * 2 - }, requires_index=False, ) }, ) - - self.assertIsNone(capability.indexer) self.assertIsNone(capability.collection_name) + self.assertIsNone(capability.execution_group) - def test_shared_visual_handler_is_grouped_without_name_switches(self): + def test_visual_execution_group_is_explicit(self): self.assertEqual( - _index_groups(("dialogue", "scene", "actor")), + _index_groups( + ("dialogue", "scene", "actor"), + self.registry, + ), (("dialogue",), ("scene", "actor")), ) - self.assertIsNotNone(CAPABILITIES["scene"].index_processor) - self.assertIsNotNone(CAPABILITIES["actor"].index_processor) - self.assertIsNone(CAPABILITIES["dialogue"].index_processor) + self.assertIsNotNone( + self.registry.executor("scene").index_processor + ) + self.assertIsNotNone( + self.registry.executor("actor").index_processor + ) + self.assertIsNone( + self.registry.executor("dialogue").index_processor + ) + + def test_core_config_has_no_provider_specific_fields(self): + fields = IndexConfig.__dataclass_fields__ + for name in ( + "sentence_model", + "whisper_model", + "clip_model", + "actor_batch_size", + "face_match_threshold", + ): + self.assertNotIn(name, fields) - def test_capability_options_do_not_require_core_config_fields(self): - config = IndexConfig( - enabled_modalities=("ocr",), - collection_names={"ocr": "ocr"}, - capability_options={"ocr": {"language": "en"}}, + def test_external_plugins_require_exact_distribution_entry_point_pair(self): + plugin = CapabilityPlugin( + definition=CapabilityDefinition( + name="ocr", + description="OCR.", + extra="ocr", + operations={ + "run": OperationDefinition( + input_model=ExampleInput, + output_model=ExampleOutput, + requires_index=False, + ) + }, + ), + executor_factory=lambda: CapabilityExecutor( + operations={ + "run": lambda _context, request: { + "doubled": request.value * 2 + } + } + ), + requirements=("example-runtime>=1,<2",), + ) + distribution = SimpleNamespace( + name="acme-capabilities", + version="1.2.3", + ) + entry_point = SimpleNamespace( + name="ocr", + dist=distribution, + load=Mock(return_value=plugin), ) + with patch( + "vidxp.capabilities.registry.entry_points", + return_value=(entry_point,), + ): + rejected = create_capability_registry( + external=True, + allowlist=("acme-capabilities:other",), + ) + accepted = create_capability_registry( + external=True, + allowlist=("acme-capabilities:ocr",), + ) + self.assertNotIn("ocr", rejected.names()) + self.assertIn("ocr", accepted.names()) self.assertEqual( - config.options_for("ocr"), - {"language": "en"}, + accepted.provenance("ocr"), + CapabilityProvenance( + distribution="acme-capabilities", + entry_point="ocr", + version="1.2.3", + ), + ) + self.assertEqual( + [item.name for item in accepted.requirements_for(("ocr",))], + ["example-runtime"], + ) + with patch( + "vidxp.dependencies.version", + side_effect=PackageNotFoundError("example-runtime"), + ): + check = accepted.dependency_checks(("ocr",))[0] + self.assertFalse(check.ok) + self.assertEqual(check.capability, "ocr") + self.assertEqual(check.provenance.distribution, "acme-capabilities") + self.assertEqual(entry_point.load.call_count, 1) + + def test_plugin_contract_and_collision_errors_include_provenance(self): + provenance = CapabilityProvenance( + distribution="acme-capabilities", + entry_point="ocr", + version="1.2.3", + ) + invalid = CapabilityPlugin( + definition=CapabilityDefinition( + name="ocr", + description="OCR.", + extra="ocr", + operations={ + "run": OperationDefinition( + input_model=ExampleInput, + output_model=ExampleOutput, + requires_index=False, + ) + }, + ), + executor_factory=lambda: CapabilityExecutor( + operations={"run": lambda _context, _request: {"doubled": 2}} + ), + contract_version=999, + provenance=provenance, ) + with self.assertRaisesRegex( + RuntimeError, + "acme-capabilities:ocr", + ): + CapabilityRegistry((invalid,)) + + collision = invalid.model_copy( + update={ + "definition": self.registry.get("scene"), + "contract_version": 1, + } + ) + built_in = CapabilityPlugin( + definition=self.registry.get("scene"), + executor_factory=lambda: CapabilityExecutor(), + ) + with self.assertRaisesRegex( + RuntimeError, + "built-in VidXP capability.*acme-capabilities:ocr", + ): + CapabilityRegistry((built_in, collision)) + + def test_external_plugin_loading_is_deterministic_and_errors_are_chained(self): + def plugin(name): + return CapabilityPlugin( + definition=CapabilityDefinition( + name=name, + description=f"{name} capability.", + extra=name, + operations={ + "run": OperationDefinition( + input_model=ExampleInput, + output_model=ExampleOutput, + requires_index=False, + ) + }, + ), + executor_factory=lambda: CapabilityExecutor( + operations={ + "run": lambda _context, request: { + "doubled": request.value * 2 + } + } + ), + ) + + distribution = SimpleNamespace(name="acme", version="1") + zeta = SimpleNamespace( + name="zeta", + dist=distribution, + load=Mock(return_value=plugin("zeta")), + ) + alpha = SimpleNamespace( + name="alpha", + dist=distribution, + load=Mock(return_value=plugin("alpha")), + ) + with patch( + "vidxp.capabilities.registry.entry_points", + return_value=(zeta, alpha), + ): + registry = create_capability_registry( + external=True, + allowlist=("acme:zeta", "acme:alpha"), + ) + self.assertEqual(registry.names()[-2:], ("alpha", "zeta")) + + failure = RuntimeError("factory failure") + broken = SimpleNamespace( + name="broken", + dist=distribution, + load=Mock(side_effect=failure), + ) + with ( + patch( + "vidxp.capabilities.registry.entry_points", + return_value=(broken,), + ), + self.assertRaisesRegex(RuntimeError, "acme:broken") as raised, + ): + create_capability_registry( + external=True, + allowlist=("acme:broken",), + ) + self.assertIs(raised.exception.__cause__, failure) + + def test_external_runtime_check_errors_are_sanitized_with_provenance(self): + events = [] + + def fail_runtime_check(): + events.append("inspect") + raise RuntimeError("token=do-not-leak") + + provenance = CapabilityProvenance( + distribution="acme-capabilities", + entry_point="ocr", + version="1.2.3", + ) + plugin = CapabilityPlugin( + definition=CapabilityDefinition( + name="ocr", + description="OCR.", + extra="ocr", + operations={ + "run": OperationDefinition( + input_model=ExampleInput, + output_model=ExampleOutput, + requires_index=False, + ) + }, + ), + executor_factory=lambda: CapabilityExecutor( + operations={ + "run": lambda _context, request: { + "doubled": request.value * 2 + } + }, + runtime_checks=( + RuntimeCheck( + label="ocr runtime", + check=fail_runtime_check, + ), + ), + ), + provenance=provenance, + ) + + check = CapabilityRegistry((plugin,)).dependency_checks( + ("ocr",), + on_check_start=lambda capability, _kind, name: events.append( + (capability, name) + ), + )[0] + + self.assertEqual(events, [("ocr", "ocr runtime"), "inspect"]) + self.assertEqual(check.error, "runtime check failed") + self.assertNotIn("do-not-leak", check.model_dump_json()) + self.assertEqual(check.provenance, provenance) if __name__ == "__main__": diff --git a/tests/test_cli.py b/tests/test_cli.py index 9e996e7..b3b4466 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,426 +1,1006 @@ import json import os -import sys import unittest -from contextlib import redirect_stderr -from io import StringIO +from datetime import datetime, timezone from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import Mock, patch +from click import unstyle from typer.testing import CliRunner from vidxp import cli -from vidxp.capabilities.actor.schemas import ( - ActorClusterSummary, - ActorDetection, - ActorRenderResult, +from vidxp.entrypoint import startup_command +from vidxp.composition import LocalApplicationContext, settings_for_repository +from vidxp.media_runtime import ( + MediaRuntimeConfiguration, + MediaRuntimeStatus, + SystemInstallPlan, ) -from vidxp.capabilities.schemas import SearchHit, SearchResult -from vidxp.index_state import IndexNotReadyError - - -def result(modality, starts): - return SearchResult( - query_id="q", - query="example", - modality=modality, - hits=tuple( - SearchHit( - rank=index + 1, - video_id="video-1", - start=start, - end=start + 1, - score=-float(index), - raw_distance=float(index), - modality=modality, - source_id=f"source-{index}", - ) - for index, start in enumerate(starts) - ), - ) +from vidxp.settings import ApplicationMode +from vidxp.application_models import ( + Artifact, + ArtifactJobResult, + CapabilityDependencyCheck, + CreateIndexCommand, + DependencyCheckResult, + DependencyKind, + FusedSearchResult, + FusionProvenance, + IndexJobResult, + IndexResult, + IndexStatus, + IndexStatusSummary, + Job, + JobKind, + JobQueue, + JobState, + MediaAsset, + PrepareModelsResult, + PrepareModelsJobResult, + QueryAnswer, + QueryAnswerMode, + QueryJobResult, + QueryPlan, + QueryVideoCommand, + RemoveIndexCommand, + SearchCommand, + SearchJobResult, + SearchMomentsPlanStep, +) +from vidxp.core.artifacts import ArtifactKind, ArtifactState +from vidxp.core.media import MediaState, MediaStream +from vidxp.capabilities.registry import create_capability_registry +from vidxp.capability_service import CapabilityService +from vidxp.repositories import RepositoryConfig, RepositoryRegistry +from vidxp.ports import LocalFileResource + + +MEDIA_ID = "123456781234423481234567890abcde" +GENERATION_ID = "223456781234423481234567890abcde" +SNAPSHOT_ID = "323456781234423481234567890abcde" +JOB_ID = "423456781234423481234567890abcde" +ARTIFACT_ID = "523456781234423481234567890abcde" class CliTests(unittest.TestCase): def setUp(self): self.runner = CliRunner() - self.temporary_directory = TemporaryDirectory() - self.addCleanup(self.temporary_directory.cleanup) - self.config_file = ( - Path(self.temporary_directory.name) / "repositories.json" - ) self.service = Mock() - self.service.index_directory = Path("chroma_data") - self.service.device = None - - def invoke(self, arguments): - with patch.object( - cli, - "VidXPService", - return_value=self.service, + self.service.registry = create_capability_registry() + self.service.list_capabilities.return_value = CapabilityService( + self.service.registry + ).list() + self.service.index_directory = Path("repo/indexes") + self.service.layout.root = Path("repo") + self.service.model_cache = Path("model-cache") + self.service.runtime.backends.requested = "cpu" + self.service.model_readiness.return_value = DependencyCheckResult( + ok=True, + modalities=(), + checks=(), + ) + self.jobs = Mock() + self.registry = Mock(spec=RepositoryRegistry) + self.registry.path = Path("repositories.json") + self.repository = RepositoryConfig( + "default", + Path("repo"), + device="cpu", + configured=False, + ) + + def invoke(self, arguments, *, media_runtime_initialized=True): + with ( + patch.object( + cli, + "create_local_application", + return_value=LocalApplicationContext( + application=self.service, + jobs=self.jobs, + repositories=self.registry, + repository=self.repository, + ), + ) as create_local_application, + patch( + "vidxp.cli_support.media_runtime_is_initialized", + return_value=media_runtime_initialized, + ), ): - return self.runner.invoke( - cli.app, - ["--config", str(self.config_file), *arguments], - ) + result = self.runner.invoke(cli.app, arguments) + self.create_local_application = create_local_application + return result - def test_grouped_commands_are_exposed(self): - response = self.invoke(["--help"]) + def test_data_directory_is_forwarded_to_local_composition(self): + result = self.invoke( + [ + "--data-dir", + "custom-data", + "repositories", + "show", + "--json", + ] + ) - self.assertEqual(response.exit_code, 0) + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual( + self.create_local_application.call_args.kwargs["data_directory"], + Path("custom-data"), + ) + + def test_grouped_commands_are_exposed(self): + result = self.invoke(["--help"]) + self.assertEqual(result.exit_code, 0, result.output) for command in ( + "media", "index", + "jobs", "search", "actors", - "repositories", - "benchmark", + "artifacts", + "init", + "doctor", + "prepare", + "mcp-config", + ): + self.assertIn(command, result.output) + + def test_mcp_config_is_copy_paste_json_without_opening_repository(self): + result = self.invoke(["mcp-config"]) + + self.assertEqual(result.exit_code, 0, result.output) + self.create_local_application.assert_not_called() + config = json.loads(result.output) + server = config["mcpServers"]["vidxp"] + self.assertIn( + Path(server["command"]).name.lower(), + {"vidxp-mcp", "vidxp-mcp.exe"}, + ) + self.assertEqual(server["args"], ["--repository", "default"]) + + def test_startup_notice_targets_long_interactive_commands(self): + self.assertEqual(startup_command(["init"]), "init") + self.assertEqual(startup_command(["doctor"]), "doctor") + self.assertEqual( + startup_command(["media", "import", "video.mp4"]), + "media import", + ) + self.assertEqual( + startup_command(["--data-dir", "custom-data", "ui"]), "ui", + ) + self.assertIsNone(startup_command(["doctor", "--json"])) + self.assertIsNone(startup_command(["--quiet", "prepare"])) + self.assertIsNone(startup_command(["repositories", "list"])) + + def test_init_saves_verified_paths_without_opening_a_repository(self): + ffmpeg = Path("tools/ffmpeg.exe").resolve() + ffprobe = Path("tools/ffprobe.exe").resolve() + status = MediaRuntimeStatus( + ready=True, + initialized=False, + ffmpeg_executable=ffmpeg, + ffprobe_executable=ffprobe, + ) + configuration = MediaRuntimeConfiguration( + ffmpeg_executable=ffmpeg, + ffprobe_executable=ffprobe, + ) + with ( + patch( + "vidxp.cli_commands.runtime.inspect_media_runtime", + return_value=status, + ), + patch( + "vidxp.cli_commands.runtime.save_media_runtime_configuration", + return_value=configuration, + ) as save, + patch( + "vidxp.cli_commands.runtime.media_runtime_config_path", + return_value=Path("config/media-runtime.json").resolve(), + ), ): - self.assertIn(command, response.stdout) + result = self.invoke(["init", "--json"]) + + self.assertEqual(result.exit_code, 0, result.output) + payload = json.loads(result.output) + self.assertTrue(payload["ready"]) + self.assertTrue(payload["initialized"]) + save.assert_called_once_with(status) + self.create_local_application.assert_not_called() + + def test_noninteractive_init_reports_command_without_installing(self): + plan = SystemInstallPlan( + manager="Windows Package Manager", + command=( + "winget", + "install", + "--id", + "Gyan.FFmpeg", + "--exact", + ), + automatic=True, + ) + status = MediaRuntimeStatus( + ready=False, + initialized=False, + errors=("FFmpeg was not found.",), + install_plan=plan, + ) + with ( + patch( + "vidxp.cli_commands.runtime.inspect_media_runtime", + return_value=status, + ), + patch( + "vidxp.cli_commands.runtime.install_media_runtime", + ) as install, + ): + result = self.invoke(["init", "--json"]) + + self.assertEqual(result.exit_code, 1, result.output) + payload = json.loads(result.output) + self.assertFalse(payload["ready"]) + self.assertIn("Gyan.FFmpeg", payload["install_command"]) + install.assert_not_called() + self.create_local_application.assert_not_called() + + def test_yes_explicitly_runs_the_displayed_installer_and_verifies(self): + plan = SystemInstallPlan( + manager="Windows Package Manager", + command=("winget", "install", "--id", "Gyan.FFmpeg"), + automatic=True, + ) + missing = MediaRuntimeStatus( + ready=False, + initialized=False, + errors=("FFmpeg was not found.",), + install_plan=plan, + ) + ready = MediaRuntimeStatus( + ready=True, + initialized=False, + ffmpeg_executable=Path("tools/ffmpeg.exe").resolve(), + ffprobe_executable=Path("tools/ffprobe.exe").resolve(), + ) + configuration = MediaRuntimeConfiguration( + ffmpeg_executable=ready.ffmpeg_executable, + ffprobe_executable=ready.ffprobe_executable, + ) + with ( + patch( + "vidxp.cli_commands.runtime.inspect_media_runtime", + side_effect=(missing, ready), + ), + patch( + "vidxp.cli_commands.runtime.install_media_runtime", + ) as install, + patch( + "vidxp.cli_commands.runtime.save_media_runtime_configuration", + return_value=configuration, + ), + ): + result = self.invoke(["init", "--yes", "--json"]) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertTrue(json.loads(result.output)["ready"]) + install.assert_called_once_with(plan, output_to_stderr=True) + self.create_local_application.assert_not_called() + + def test_first_media_command_points_to_init_when_uninitialized(self): + with TemporaryDirectory() as directory: + video = Path(directory) / "video.mp4" + video.write_bytes(b"video") + result = self.invoke( + ["media", "import", str(video)], + media_runtime_initialized=False, + ) - def test_removed_legacy_commands_are_rejected(self): - for command in ("videoindex", "dialogue", "scene", "actor"): - with self.subTest(command=command): - response = self.invoke([command]) + self.assertEqual(result.exit_code, 1) + self.assertEqual(result.exception.code, "media_runtime_uninitialized") + self.assertIn("vidxp init", str(result.exception)) + self.service.import_media.assert_not_called() - self.assertEqual(response.exit_code, 2) - self.assertIn("No such command", response.output) + def test_ui_shutdown_stops_its_local_worker(self): + with patch( + "vidxp.frontend.main", + side_effect=SystemExit(0), + ): + result = self.invoke(["ui"]) + + self.assertEqual(result.exit_code, 0, result.output) + self.jobs.stop_worker.assert_called_once_with() - def test_search_returns_ranked_json_and_passes_top_k(self): - self.service.search.return_value = result( - "dialogue", - [7.5, 12.0], + def test_snippet_rejects_an_inverted_time_range_before_submission(self): + result = self.invoke( + ["artifacts", "snippet", MEDIA_ID, "3", "2"] ) - response = self.invoke( - [ - "search", - "dialogue", - "fresh bread", - "--top-k", - "2", - "--json", - ] + self.assertEqual(result.exit_code, 2, result.output) + self.assertIn("snippet end must be greater than its", result.output) + self.assertIn("start.", result.output) + self.jobs.submit_snippet.assert_not_called() + + def test_snippet_completion_points_to_the_download_command(self): + artifact = Artifact( + artifact_id=ARTIFACT_ID, + media_id=MEDIA_ID, + kind=ArtifactKind.snippet, + profile="compatible_mp4", + mime_type="video/mp4", + byte_size=12, + sha256="1" * 64, + state=ArtifactState.ready, + created_at=datetime.now(timezone.utc), + ) + self.jobs.submit_snippet.return_value = Job( + job_id=JOB_ID, + kind=JobKind.snippet, + state=JobState.queued, + queue=JobQueue.cpu, + ) + self.jobs.wait.return_value = Job( + job_id=JOB_ID, + kind=JobKind.snippet, + state=JobState.succeeded, + queue=JobQueue.cpu, + result=ArtifactJobResult( + kind=JobKind.snippet, + result=artifact, + ), ) - self.assertEqual(response.exit_code, 0, response.output) - payload = json.loads(response.stdout) - self.assertEqual( - [hit["start"] for hit in payload["hits"]], - [7.5, 12.0], - ) - self.service.search.assert_called_once_with( - "dialogue", - "fresh bread", - top_k=2, - ) - - def test_global_index_directory_and_device_configure_service(self): - with patch.object(cli, "VidXPService") as service_type: - service_type.return_value.index_status.return_value = { - "state": "missing", - } - response = self.runner.invoke( - cli.app, + result = self.invoke( + ["artifacts", "snippet", MEDIA_ID, "9", "17"] + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertIn(f"Clip ready: {ARTIFACT_ID}", result.output) + self.assertIn( + f"vidxp artifacts download {ARTIFACT_ID}", + result.output, + ) + + def test_artifact_download_copies_authorized_content(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "managed.mp4" + source.write_bytes(b"clip-content") + destination = root / "exported.mp4" + self.service.open_artifact_content.return_value = ( + LocalFileResource( + path=source, + filename=f"snippet-{ARTIFACT_ID}.mp4", + mime_type="video/mp4", + byte_size=12, + etag="1" * 64, + ) + ) + + result = self.invoke( [ - "--config", - str(self.config_file), - "--index-dir", - "custom-index", - "--device", - "cuda", - "index", - "status", + "artifacts", + "download", + ARTIFACT_ID, + str(destination), "--json", - ], + ] ) - self.assertEqual(response.exit_code, 0, response.output) - service_type.assert_called_once_with(Path("custom-index"), device="cuda") + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(destination.read_bytes(), b"clip-content") + payload = json.loads(result.output) + self.assertEqual(payload["artifact_id"], ARTIFACT_ID) + self.assertEqual(payload["path"], str(destination.resolve())) + + refused = self.invoke( + [ + "artifacts", + "download", + ARTIFACT_ID, + str(destination), + ] + ) - def test_repository_commands_persist_and_select_named_indexes(self): - index_directory = ( - Path(self.temporary_directory.name) / "team-index" + self.assertEqual(refused.exit_code, 2, refused.output) + self.assertIn("already exists", refused.output) + + def test_search_constructs_shared_command(self): + search_result = FusedSearchResult( + query_id="fused:1", + query="yellow taxi", + modalities=("scene",), + fusion=FusionProvenance( + requested_modalities=("scene",), + searched_modalities=("scene",), + ), + ) + self.jobs.submit_search.return_value = Job( + job_id=JOB_ID, + kind=JobKind.search, + state=JobState.queued, + queue=JobQueue.cpu, ) - added = self.invoke( + self.jobs.wait.return_value = Job( + job_id=JOB_ID, + kind=JobKind.search, + state=JobState.succeeded, + queue=JobQueue.cpu, + result=SearchJobResult(result=search_result), + ) + + result = self.invoke( [ - "repositories", - "add", - "team", - "--index-dir", - str(index_directory), - "--device", - "cuda", - "--use", + "search", + "scene", + "yellow taxi", + "--media-id", + MEDIA_ID, + "--top-k", + "7", "--json", ] ) - listed = self.invoke(["repositories", "list", "--json"]) - self.assertEqual(added.exit_code, 0, added.output) - self.assertEqual(json.loads(added.stdout)["name"], "team") - payload = json.loads(listed.stdout) - self.assertEqual(payload["active_repository"], "team") - configured = { - item["name"]: item for item in payload["repositories"] - } - self.assertEqual( - configured["team"]["index_directory"], - str(index_directory.resolve()), + self.assertEqual(result.exit_code, 0, result.output) + self.jobs.submit_search.assert_called_once_with( + SearchCommand( + modalities=("scene",), + query="yellow taxi", + media_id=MEDIA_ID, + top_k=7, + ) + ) + self.assertEqual(json.loads(result.output)["query"], "yellow taxi") + + def test_search_help_explains_cross_media_default(self): + result = self.invoke(["search", "--help"]) + + self.assertEqual(result.exit_code, 0, result.output) + normalized = " ".join(unstyle(result.output).replace("│", " ").split()) + self.assertIn( + "Omit to rank matches across every media item in the active index snapshot.", + normalized, ) - def test_repository_removal_leaves_index_data_untouched(self): - index_directory = ( - Path(self.temporary_directory.name) / "team-index" + def test_query_constructs_shared_command_and_emits_typed_answer(self): + answer = QueryAnswer( + question="What happens?", + mode=QueryAnswerMode.no_evidence, + plan=QueryPlan( + steps=( + SearchMomentsPlanStep( + modality="scene", + query="What happens?", + ), + ) + ), + fusion=FusionProvenance( + requested_modalities=("scene",), + searched_modalities=("scene",), + ), + fallback_reason="no_evidence", + ) + self.jobs.submit_query.return_value = Job( + job_id=JOB_ID, + kind=JobKind.query, + state=JobState.queued, + queue=JobQueue.cpu, ) - index_directory.mkdir() - marker = index_directory / "keep" - marker.write_text("data", encoding="utf-8") - self.invoke( + self.jobs.wait.return_value = Job( + job_id=JOB_ID, + kind=JobKind.query, + state=JobState.succeeded, + queue=JobQueue.cpu, + result=QueryJobResult(result=answer), + ) + + result = self.invoke( [ - "repositories", - "add", - "team", - "--index-dir", - str(index_directory), + "query", + "What happens?", + "--media-id", + MEDIA_ID, + "--modality", + "scene", + "--json", ] ) - removed = self.invoke( - ["repositories", "remove", "team", "--yes", "--json"] + self.assertEqual(result.exit_code, 0, result.output) + self.jobs.submit_query.assert_called_once_with( + QueryVideoCommand( + question="What happens?", + media_id=MEDIA_ID, + modalities=("scene",), + ) + ) + self.assertEqual(json.loads(result.output)["mode"], "no_evidence") + + def test_index_constructs_shared_command(self): + result_value = IndexResult( + media_id=MEDIA_ID, + generation_id=GENERATION_ID, + snapshot_id=SNAPSHOT_ID, + active_media_count=1, + record_counts={"scene": 1}, + ) + self.jobs.submit_index.return_value = Job( + job_id=JOB_ID, + kind=JobKind.index, + state=JobState.queued, + queue=JobQueue.cpu, + ) + self.jobs.wait.return_value = Job( + job_id=JOB_ID, + kind=JobKind.index, + state=JobState.succeeded, + queue=JobQueue.cpu, + result=IndexJobResult(result=result_value), ) + result = self.invoke( + [ + "--format", + "json", + "index", + "create", + MEDIA_ID, + "--modality", + "scene", + "--frame-stride", + "5", + "--scene-sample-fps", + "2", + ] + ) + + self.assertEqual(result.exit_code, 0, result.output) + command = self.jobs.submit_index.call_args.args[0] + self.assertIsInstance(command, CreateIndexCommand) + self.assertEqual(command.modalities, ("scene",)) + self.assertEqual(command.frame_stride, 5) + self.assertEqual(command.scene_sample_fps, 2.0) + self.assertEqual(command.media_id, MEDIA_ID) + + def test_remove_uses_shared_media_id_command(self): + self.service.remove_from_index.return_value = True + removed = self.invoke(["index", "remove", MEDIA_ID, "--json"]) self.assertEqual(removed.exit_code, 0, removed.output) - self.assertFalse(json.loads(removed.stdout)["index_deleted"]) - self.assertTrue(marker.is_file()) + self.service.remove_from_index.assert_called_once_with( + RemoveIndexCommand(media_id=MEDIA_ID) + ) - def test_index_create_uses_repeated_typed_modalities(self): - self.service.create_index.return_value = {"scene_frames": 10} + def test_media_import_uses_the_local_import_command(self): + self.service.import_media.return_value = MediaAsset( + schema_version=1, + media_id=MEDIA_ID, + video_id=MEDIA_ID, + original_filename="video.mp4", + sha256="1" * 64, + byte_size=5, + detected_mime_type="video/mp4", + container="mp4", + duration_seconds=1, + streams=( + MediaStream( + index=0, + kind="video", + codec="h264", + width=1, + height=1, + ), + ), + state=MediaState.ready, + created_at=datetime.now(timezone.utc), + ) with TemporaryDirectory() as directory: - video = Path(directory) / "sample.mp4" + video = Path(directory) / "video.mp4" video.write_bytes(b"video") - response = self.invoke( - [ - "--format", - "json", - "index", - "create", - str(video), - "--modality", - "scene", - "--frame-stride", - "5", - "--option", - "scene.batch_size=4", - "--option", - "scene.model=test-model", - ] - ) + result = self.invoke(["media", "import", str(video), "--json"]) - self.assertEqual(response.exit_code, 0, response.output) - self.assertEqual(json.loads(response.stdout), {"scene_frames": 10}) - self.service.create_index.assert_called_once() - call = self.service.create_index.call_args - self.assertEqual(call.kwargs["modalities"], ("scene",)) - self.assertEqual(call.kwargs["frame_stride"], 5) + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(json.loads(result.output)["media_id"], MEDIA_ID) self.assertEqual( - call.kwargs["capability_options"], - {"scene": {"batch_size": 4, "model": "test-model"}}, + self.service.import_media.call_args.args[0].path, + video.resolve(), ) - def test_index_status_reports_missing_index_as_json(self): - self.service.index_status.return_value = { - "state": "missing", - "message": "No local video index was found.", - } - - response = self.invoke(["index", "status", "--json"]) + def test_media_show_returns_registered_metadata(self): + self.service.get_media.return_value = MediaAsset( + schema_version=1, + media_id=MEDIA_ID, + video_id=MEDIA_ID, + original_filename="video.mp4", + sha256="1" * 64, + byte_size=5, + detected_mime_type="video/mp4", + container="mp4", + duration_seconds=1, + streams=( + MediaStream( + index=0, + kind="video", + codec="h264", + width=1, + height=1, + ), + ), + state=MediaState.ready, + created_at=datetime.now(timezone.utc), + ) - self.assertEqual(response.exit_code, 0, response.output) - self.assertEqual(json.loads(response.stdout)["state"], "missing") + result = self.invoke(["media", "show", MEDIA_ID, "--json"]) - def test_index_clear_requires_explicit_confirmation_for_automation(self): - self.service.clear_index.return_value = True + self.assertEqual(result.exit_code, 0, result.output) + self.service.get_media.assert_called_once_with(MEDIA_ID) + self.assertEqual(json.loads(result.output)["media_id"], MEDIA_ID) - response = self.invoke(["index", "clear", "--yes", "--json"]) + def test_status_serializes_shared_model(self): + self.service.index_status.return_value = IndexStatus( + schema_version=1, + state="missing", + stage="status", + message="No index.", + ) - self.assertEqual(response.exit_code, 0, response.output) - self.assertTrue(json.loads(response.stdout)["cleared"]) - self.service.clear_index.assert_called_once_with() + result = self.invoke(["index", "status", "--json"]) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(json.loads(result.output)["state"], "missing") + + def test_index_list_joins_active_ids_to_registered_metadata(self): + self.service.index_status.return_value = IndexStatus( + schema_version=1, + state="ready", + stage="status", + message="Index ready.", + summary=IndexStatusSummary( + index_schema_version=1, + snapshot_id=SNAPSHOT_ID, + media_count=1, + media_ids=(MEDIA_ID,), + modalities=("scene", "dialogue"), + ), + ) + self.service.get_media.return_value = MediaAsset( + schema_version=1, + media_id=MEDIA_ID, + video_id=MEDIA_ID, + original_filename="video.mp4", + sha256="1" * 64, + byte_size=5, + detected_mime_type="video/mp4", + container="mp4", + duration_seconds=1, + streams=( + MediaStream( + index=0, + kind="video", + codec="h264", + width=1, + height=1, + ), + ), + state=MediaState.ready, + created_at=datetime.now(timezone.utc), + ) - def test_doctor_and_prepare_use_the_reusable_service(self): - self.service.check_dependencies.return_value = { - "ok": True, - "modalities": ["scene"], - "checks": [{"name": "CLIP", "ok": True, "error": None}], - } - self.service.prepare_models.return_value = { - "prepared": ["ViT-B/32"], - "modalities": ["scene"], - "device": "cpu", - } + result = self.invoke(["index", "list", "--json"]) + + self.assertEqual(result.exit_code, 0, result.output) + self.service.get_media.assert_called_once_with(MEDIA_ID) + payload = json.loads(result.output) + self.assertEqual(payload["snapshot_id"], SNAPSHOT_ID) + self.assertEqual(payload["media_count"], 1) + self.assertEqual(payload["modalities"], ["scene", "dialogue"]) + self.assertEqual(payload["items"][0]["original_filename"], "video.mp4") + + def test_doctor_and_prepare_use_shared_models(self): + self.service.check_dependencies.return_value = DependencyCheckResult( + ok=True, + modalities=("scene",), + checks=(), + ) + prepared_value = PrepareModelsResult( + prepared=("scene-model",), + modalities=("scene",), + runtime={ + "requested": "cpu", + "torch_device": "cpu", + "transcription_device": "cpu", + }, + ) + self.jobs.submit_prepare_models.return_value = Job( + job_id=JOB_ID, + kind=JobKind.prepare_models, + state=JobState.queued, + queue=JobQueue.cpu, + ) + self.jobs.wait.return_value = Job( + job_id=JOB_ID, + kind=JobKind.prepare_models, + state=JobState.succeeded, + queue=JobQueue.cpu, + result=PrepareModelsJobResult(result=prepared_value), + ) checked = self.invoke( ["doctor", "--modalities", "scene", "--json"] ) - prepared = self.invoke([ - "prepare", - "--modalities", - "scene", - "--option", - "scene.model=test-model", - "--json", - ]) - - self.assertTrue(json.loads(checked.stdout)["ok"]) + prepared = self.invoke( + ["prepare", "--modalities", "scene", "--json"] + ) + + self.assertEqual(checked.exit_code, 0, checked.output) + self.assertEqual(prepared.exit_code, 0, prepared.output) self.assertEqual( - json.loads(prepared.stdout)["prepared"], - ["ViT-B/32"], + self.service.check_dependencies.call_args.args[0].modalities, + ("scene",), + ) + self.assertTrue( + self.service.check_dependencies.call_args.args[0].include_models ) - self.service.check_dependencies.assert_called_once_with(("scene",)) - self.service.prepare_models.assert_called_once() self.assertEqual( - self.service.prepare_models.call_args.kwargs[ - "capability_options" + self.jobs.submit_prepare_models.call_args.args[0].modalities, + ("scene",), + ) + + def test_doctor_accepts_repeated_modality_options(self): + self.service.check_dependencies.return_value = DependencyCheckResult( + ok=True, + modalities=("dialogue", "scene"), + checks=(), + ) + result = self.invoke( + [ + "doctor", + "--modalities", + "dialogue", + "--modalities", + "scene", + "--json", ], - {"scene": {"model": "test-model"}}, ) - def test_ui_receives_the_selected_service_configuration(self): - self.service.index_directory = Path("selected-index") - self.service.device = "cuda" - from vidxp import frontend + self.assertEqual(result.exit_code, 0, result.output) + command = self.service.check_dependencies.call_args.args[0] + self.assertEqual(command.modalities, ("dialogue", "scene")) + + def test_prepare_announces_start_and_subscribes_to_job_progress(self): + self.service.model_readiness.return_value = DependencyCheckResult( + ok=False, + modalities=("scene",), + checks=( + CapabilityDependencyCheck( + capability="scene", + kind=DependencyKind.model, + name="google/siglip2-base-patch16-224", + download_size_bytes=1_539_458_338, + ok=False, + error="model artifacts are not prepared", + ), + ), + ) + prepared = PrepareModelsResult( + prepared=("scene-model",), + modalities=("scene",), + runtime={ + "requested": "cpu", + "torch_device": "cpu", + "transcription_device": "cpu", + }, + ) + self.jobs.submit_prepare_models.return_value = Job( + job_id=JOB_ID, + kind=JobKind.prepare_models, + state=JobState.queued, + queue=JobQueue.cpu, + ) + self.jobs.wait.return_value = Job( + job_id=JOB_ID, + kind=JobKind.prepare_models, + state=JobState.succeeded, + queue=JobQueue.cpu, + result=PrepareModelsJobResult(result=prepared), + ) + + result = self.invoke( + ["prepare", "--modalities", "scene", "--yes"] + ) - with ( - patch.dict(os.environ, {}, clear=False), - patch.object(frontend, "SERVICE"), - patch.object(frontend, "SAVED_VIDEO_PATH"), - patch.object(frontend, "ACTOR_OUTPUT_PATH"), - patch.object(frontend, "main") as launch, + self.assertEqual(result.exit_code, 0, result.output) + self.assertIn("1.43 GiB", result.output) + self.assertRegex( + result.output, + r"\[\d{2}:\d{2}:\d{2}\] Starting model preparation for scene\.", + ) + self.assertTrue(callable(self.jobs.wait.call_args.kwargs["progress"])) + + def test_prepare_discloses_size_and_requires_confirmation(self): + self.service.model_readiness.return_value = DependencyCheckResult( + ok=False, + modalities=("scene",), + checks=( + CapabilityDependencyCheck( + capability="scene", + kind=DependencyKind.model, + name="google/siglip2-base-patch16-224", + download_size_bytes=1_539_458_338, + ok=False, + error="model artifacts are not prepared", + ), + ), + ) + + declined = self.invoke( + ["prepare", "--modalities", "scene"], + ) + + self.assertNotEqual(declined.exit_code, 0) + self.assertIn("1.43 GiB", declined.output) + self.assertIn("Model cache: model-cache", declined.output) + self.assertIn("Download these models?", declined.output) + self.jobs.submit_prepare_models.assert_not_called() + + def test_doctor_streams_timestamped_runtime_check_progress(self): + def check_dependencies( + _command, + *, + on_check_start, + on_check_complete, ): - response = self.invoke( - ["ui", "--host", "0.0.0.0", "--port", "8501"] + on_check_start( + "scene", + DependencyKind.distribution, + "torch", ) - - self.assertEqual(response.exit_code, 0, response.output) - launch.assert_called_once_with( - ["--server.address=0.0.0.0", "--server.port=8501"] + on_check_complete( + CapabilityDependencyCheck( + capability="scene", + kind=DependencyKind.distribution, + name="torch", + requirement="torch==2.13.0", + installed_version="2.13.0", + ok=True, + ), + 0.01, ) - self.assertEqual(os.environ["VIDXP_DEVICE"], "cuda") - self.assertEqual( - os.environ["VIDXP_INDEX_DIR"], - "selected-index", + on_check_start( + "scene", + DependencyKind.runtime, + "Torch import", ) - - def test_actor_commands_expose_clusters_detections_and_rendering(self): - cluster = ActorClusterSummary( - cluster_id="3", - video_id="video-1", - detection_count=4, - first_timestamp=1.0, - last_timestamp=8.0, - ) - self.service.actor_clusters.return_value = (cluster,) - self.service.actor_detections.return_value = [ - ActorDetection( - detection_id="d2", - cluster_id="3", - frame_index=2, - timestamp=1.5, - bbox=(1, 2, 3, 0), - dataset="local", - split="local", - run_id="default", - video_id="video-1", - modality="actor", - source_id="actor:d2", + on_check_complete( + CapabilityDependencyCheck( + capability="scene", + kind=DependencyKind.runtime, + name="Torch import", + ok=True, + ), + 1.25, ) - ] - self.service.render_actor.return_value = ActorRenderResult( - output_path=Path("actor.mp4"), - detection_count=4, - ) - - listed = self.invoke(["actors", "list", "--json"]) - inspected = self.invoke(["actors", "inspect", "3", "--json"]) - with TemporaryDirectory() as directory: - source = Path(directory) / "source.mp4" - source.write_bytes(b"video") - rendered = self.invoke( - [ - "actors", - "render", - "3", - str(source), - "--output", - "actor.mp4", - "--json", - ] + return DependencyCheckResult( + ok=True, + modalities=("scene",), + checks=( + CapabilityDependencyCheck( + capability="scene", + kind=DependencyKind.distribution, + name="torch", + requirement="torch==2.13.0", + installed_version="2.13.0", + ok=True, + ), + CapabilityDependencyCheck( + capability="scene", + kind=DependencyKind.runtime, + name="Torch import", + ok=True, + ), + ), ) - self.assertEqual(json.loads(listed.stdout)["count"], 1) - self.assertEqual( - json.loads(inspected.stdout)["detection_count"], - 1, + self.service.check_dependencies.side_effect = check_dependencies + + result = self.invoke(["doctor", "--modalities", "scene"]) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertRegex( + result.output, + r"\[\d{2}:\d{2}:\d{2}\] Checking \[scene\] Torch import\.\.\.", ) - self.assertEqual( - json.loads(rendered.stdout)["output_path"], - "actor.mp4", + self.assertIn("OK (1.2s)", result.output) + self.assertIn("OK (version 2.13.0, 0.0s)", result.output) + self.assertEqual(result.output.count("package torch"), 1) + + def test_doctor_prints_install_remedy_for_python_failures(self): + self.service.check_dependencies.return_value = DependencyCheckResult( + ok=False, + modalities=("scene",), + checks=( + CapabilityDependencyCheck( + capability="scene", + kind=DependencyKind.distribution, + name="transformers", + requirement="transformers>=5,<6", + ok=False, + error="distribution is not installed", + ), + ), ) - def test_benchmark_commands_are_exposed(self): - response = self.invoke(["benchmark", "--help"]) + result = self.invoke(["doctor", "--modalities", "scene"]) - self.assertEqual(response.exit_code, 0) - self.assertIn("didemo", response.stdout) - self.assertIn("hirest", response.stdout) + self.assertEqual(result.exit_code, 1, result.output) + self.assertIn( + "include --extra local-worker", + result.output, + ) + self.assertIn( + 'pip install "vidxp[scene]"', + result.output, + ) - def test_version_options_report_installed_package_version(self): - for option in ("--version", "-V"): - with self.subTest(option=option): - response = self.invoke([option]) + def test_doctor_reports_missing_models_and_prepare_command(self): + self.service.check_dependencies.return_value = DependencyCheckResult( + ok=False, + modalities=("scene",), + checks=( + CapabilityDependencyCheck( + capability="scene", + kind=DependencyKind.model, + name="google/siglip2-base-patch16-224", + download_size_bytes=1_539_458_338, + ok=False, + error="model artifacts are not prepared", + ), + ), + ) - self.assertEqual(response.exit_code, 0) - self.assertEqual( - response.stdout.strip(), - f"VidXP {cli.__version__}", - ) + result = self.invoke(["doctor", "--modalities", "scene"]) - def test_main_emits_uniform_json_for_runtime_errors(self): - self.service.search.side_effect = IndexNotReadyError( - "Index is not ready." - ) - stderr = StringIO() - arguments = [ - "vidxp", - "--config", - str(self.config_file), - "--format", - "json", - "search", - "scene", - "yellow taxi", - ] - with ( - patch.object(sys, "argv", arguments), - patch.object( - cli, - "VidXPService", - return_value=self.service, - ), - redirect_stderr(stderr), - self.assertRaises(SystemExit) as raised, + self.assertEqual(result.exit_code, 1, result.output) + self.assertIn( + "vidxp prepare --modalities scene", + result.output, + ) + self.assertIn("1.43 GiB", result.output) + self.assertNotIn("pip install", result.output) + + def test_invalid_capability_is_a_cli_parameter_error(self): + result = self.invoke( + ["doctor", "--modalities", "unknown", "--json"] + ) + self.assertNotEqual(result.exit_code, 0) + self.assertIn("Unknown or unsupported capabilities", result.output) + + def test_repository_without_device_preserves_runtime_environment(self): + repository = RepositoryConfig( + "default", + Path("repo"), + device=None, + configured=False, + ) + with patch.dict( + os.environ, + {"VIDXP_RUNTIME_BACKEND": "cpu"}, ): - cli.main() + settings = settings_for_repository(repository) - self.assertEqual(raised.exception.code, 1) - payload = json.loads(stderr.getvalue()) - self.assertFalse(payload["ok"]) - self.assertEqual( - payload["error"]["message"], - "Index is not ready.", + self.assertEqual(settings.runtime_backend, "cpu") + + def test_local_repository_ignores_server_mode_environment(self): + repository = RepositoryConfig( + "default", + Path("repo"), + device=None, + configured=False, ) - self.assertEqual(payload["error"]["exit_code"], 1) + with patch.dict(os.environ, {"VIDXP_MODE": "server"}): + settings = settings_for_repository(repository) + + self.assertEqual(settings.mode, ApplicationMode.local) if __name__ == "__main__": diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 33fa14e..fb22c7d 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -12,6 +12,10 @@ from vidxp.capabilities.schemas import SearchHit, SearchResult +MEDIA_ID = "123456781234423481234567890abcde" +GENERATION_ID = "223456781234423481234567890abcde" + + class ContractTests(unittest.TestCase): def test_stable_source_ids_escape_delimiters_without_collisions(self): first = stable_source_id("run:a", "video", "scene", "1") @@ -23,6 +27,15 @@ def test_stable_source_ids_escape_delimiters_without_collisions(self): stable_source_id("r", "فيديو:1", "scene", "0"), ) self.assertEqual(first.count(":"), 3) + scoped = stable_source_id( + "run:a", + "video", + "scene", + "1", + generation_id=GENERATION_ID, + ) + self.assertNotEqual(first, scoped) + self.assertEqual(scoped.count(":"), 4) def test_config_is_validated_and_run_paths_are_isolated(self): with TemporaryDirectory() as directory: @@ -59,6 +72,17 @@ def test_config_is_validated_and_run_paths_are_isolated(self): enabled_modalities=("scene",), ) self.assertEqual(first.fingerprint(), relocated.fingerprint()) + generation_directory = Path(directory) / "generation" + generated = IndexConfig.local( + storage_directory=Path(directory) / "store", + generation_directory=generation_directory, + generation_id="generation-1", + ) + self.assertEqual(generated.run_directory, generation_directory) + self.assertEqual( + generated.index_directory, + Path(directory) / "store", + ) def test_path_objects_are_normalized_for_manifest_serialization(self): config = IndexConfig.local( @@ -116,7 +140,9 @@ def test_invalid_config_is_rejected(self): def test_result_contract_serializes_complete_ranked_hits(self): hit = SearchHit( rank=1, - video_id="video-1", + media_id=MEDIA_ID, + video_id=MEDIA_ID, + generation_id=GENERATION_ID, start=1.0, end=2.0, score=-0.2, @@ -132,7 +158,7 @@ def test_result_contract_serializes_complete_ranked_hits(self): hits=(hit,), ) - self.assertEqual(result.to_prediction()["q1"][0]["video_id"], "video-1") + self.assertEqual(result.to_prediction()["q1"][0]["video_id"], MEDIA_ID) self.assertEqual(result.to_dict()["hits"][0]["raw_distance"], 0.2) def test_cancellation_is_cooperative(self): diff --git a/tests/test_cursors.py b/tests/test_cursors.py new file mode 100644 index 0000000..5b86e81 --- /dev/null +++ b/tests/test_cursors.py @@ -0,0 +1,56 @@ +import base64 +import unittest + +from vidxp.core.cursors import ( + MAX_CURSOR_OFFSET, + CursorError, + decode_cursor, + decode_offset_cursor, + encode_cursor, + encode_offset_cursor, +) + + +class CursorTests(unittest.TestCase): + def test_cursor_rejects_noncanonical_or_malformed_base64(self): + cursor = encode_cursor("test", {"offset": 1}) + + for invalid in ( + cursor.rstrip("="), + f"{cursor}=", + f" {cursor}", + cursor.replace("-", "+").replace("_", "/"), + "not-base64!", + ): + if invalid == cursor: + continue + with self.subTest(cursor=invalid): + with self.assertRaises(CursorError): + decode_cursor(invalid, "test") + + decoded = base64.urlsafe_b64decode(cursor) + noncanonical = base64.b64encode(decoded).decode() + if noncanonical != cursor: + with self.assertRaises(CursorError): + decode_cursor(noncanonical, "test") + + def test_offset_cursor_is_bounded_to_signed_database_bigint(self): + cursor = encode_offset_cursor(MAX_CURSOR_OFFSET, scope="test") + self.assertEqual( + decode_offset_cursor(cursor, scope="test"), + MAX_CURSOR_OFFSET, + ) + + with self.assertRaises(CursorError): + encode_offset_cursor(MAX_CURSOR_OFFSET + 1, scope="test") + + oversized = encode_cursor( + "test", + {"offset": MAX_CURSOR_OFFSET + 1}, + ) + with self.assertRaises(CursorError): + decode_offset_cursor(oversized, scope="test") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_database_cli.py b/tests/test_database_cli.py new file mode 100644 index 0000000..48c7284 --- /dev/null +++ b/tests/test_database_cli.py @@ -0,0 +1,37 @@ +import unittest +from unittest.mock import patch + +from alembic.config import Config +from alembic.script import ScriptDirectory + +from vidxp.database_cli import main +from vidxp.workflow_runtime import BUNDLED_POSTGRES_DATABASE_URL + + +class DatabaseCliTests(unittest.TestCase): + def test_migrations_use_the_bundled_server_database(self): + with patch("vidxp.database_cli.upgrade_database") as upgrade: + main([]) + + upgrade.assert_called_once_with(BUNDLED_POSTGRES_DATABASE_URL) + + def test_database_url_command_line_override_is_not_supported(self): + with self.assertRaises(SystemExit) as raised: + main(["--database-url", "postgresql://external.example/vidxp"]) + + self.assertEqual(raised.exception.code, 2) + + def test_single_repository_baseline_is_the_only_migration_head(self): + config = Config() + config.set_main_option( + "script_location", + "src/vidxp/migrations", + ) + + scripts = ScriptDirectory.from_config(config) + + self.assertEqual(scripts.get_heads(), ["20260729_01"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_dbos_jobs.py b/tests/test_dbos_jobs.py new file mode 100644 index 0000000..fb977cf --- /dev/null +++ b/tests/test_dbos_jobs.py @@ -0,0 +1,558 @@ +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from threading import Event +from time import sleep +from unittest.mock import ANY, Mock + +from dbos import DBOS + +from vidxp.application_models import ( + ApplicationError, + CreateIndexCommand, + ErrorCategory, + FusedSearchResult, + FusionProvenance, + IndexSnapshotReference, + IndexResult, + JobKind, + JobQueue, + JobState, + ListJobsCommand, + QueryAnswer, + QueryAnswerMode, + QueryJobRequest, + QueryPlan, + QueryVideoCommand, + SearchCommand, + SearchHit, + SearchJobRequest, + SearchMomentsPlanStep, + SearchResult, +) +from vidxp.core.cursors import MAX_CURSOR_OFFSET, encode_cursor +from vidxp.infrastructure.dbos_jobs import ( + DBOSJobBackend, + _decode_search_result, +) +from vidxp.job_service import JobService +from vidxp.ports import InvalidJobBackendRequestError +from vidxp.settings import VidXPSettings +from vidxp.workflow_contracts import ( + QUEUE_NAMES, + WORKFLOW_CLASS_NAME, + WORKFLOW_INSTANCE_NAME, + WORKFLOW_KINDS, + decode_workflow_request, +) + + +MEDIA_ID = "123456781234423481234567890abcde" +GENERATION_ID = "223456781234423481234567890abcde" +SNAPSHOT_ID = "323456781234423481234567890abcde" +IDEMPOTENCY_KEY = "423456781234423481234567890abcde" +SNAPSHOT_SHA256 = "a" * 64 + + +class DBOSJobIntegrationTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + from vidxp.infrastructure.dbos_workflows import VidXPWorkerWorkflows + + cls.worker = VidXPWorkerWorkflows(Mock()) + + def setUp(self): + self.directory = TemporaryDirectory(ignore_cleanup_errors=True) + database = Path(self.directory.name) / "jobs.sqlite3" + self.database_url = f"sqlite:///{database.as_posix()}" + DBOS.destroy() + DBOS( + config={ + "name": "vidxp", + "system_database_url": self.database_url, + "application_version": "test-v1", + "executor_id": "test-worker", + "use_listen_notify": False, + } + ) + self.application = Mock() + self.application.create_index.return_value = IndexResult( + media_id=MEDIA_ID, + generation_id=GENERATION_ID, + snapshot_id=SNAPSHOT_ID, + active_media_count=1, + record_counts={"scene": 1}, + ) + self.application.search.return_value = FusedSearchResult( + query_id="fused:taxi", + query="taxi", + modalities=("scene",), + fusion=FusionProvenance( + requested_modalities=("scene",), + searched_modalities=("scene",), + ), + ) + self.application.query_video.return_value = QueryAnswer( + question="What happens?", + mode=QueryAnswerMode.no_evidence, + plan=QueryPlan( + steps=( + SearchMomentsPlanStep( + modality="scene", + query="What happens?", + ), + ) + ), + fusion=FusionProvenance( + requested_modalities=("scene",), + searched_modalities=("scene",), + ), + fallback_reason="no_evidence", + ) + self.worker.application = self.application + DBOS.reset_system_database() + DBOS.listen_queues([QUEUE_NAMES[JobQueue.cpu]]) + DBOS.launch() + DBOS.register_queue( + QUEUE_NAMES[JobQueue.cpu], + worker_concurrency=1, + ) + self.backend = DBOSJobBackend( + system_database_url=self.database_url, + application_version="test-v1", + ) + self.jobs = JobService( + settings=VidXPSettings( + repository_root=self.directory.name, + runtime_backend="cpu", + workflow_poll_interval_seconds=0.01, + ), + backend=self.backend, + read_planner=Mock( + plan_search=Mock( + side_effect=lambda command: SearchJobRequest( + command=command, + snapshot=IndexSnapshotReference( + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, + ), + ) + ), + plan_query=Mock( + side_effect=lambda command: QueryJobRequest( + command=command, + snapshot=IndexSnapshotReference( + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, + ), + ) + ), + ), + ) + + def tearDown(self): + self.backend.close() + DBOS.destroy() + self.directory.cleanup() + + def test_sqlite_queue_persists_progress_and_typed_result(self): + def create_index(command, *, execution): + execution.report( + { + "stage": "frames", + "message": "Indexed one frame.", + "current": 1, + "total": 1, + } + ) + return IndexResult( + media_id=command.media_id, + generation_id=GENERATION_ID, + snapshot_id=SNAPSHOT_ID, + active_media_count=1, + record_counts={"scene": 1}, + ) + + self.application.create_index.side_effect = create_index + submitted = self.jobs.submit_index( + CreateIndexCommand( + media_id=MEDIA_ID, + modalities=("scene",), + ) + ) + completed = self.jobs.wait(submitted.job_id) + + self.assertEqual(completed.state, JobState.succeeded) + self.assertEqual(completed.kind, JobKind.index) + self.assertEqual(completed.progress.stage, "complete") + self.assertEqual( + completed.result.result.generation_id, + GENERATION_ID, + ) + page = self.jobs.list(ListJobsCommand(page_size=1)) + self.assertEqual(page.items[0].job_id, submitted.job_id) + self.application.create_index.assert_called_once() + + def test_health_requires_owned_executor_health(self): + health_check = Mock(side_effect=RuntimeError("worker unavailable")) + self.backend.health_check = health_check + + with self.assertRaisesRegex(RuntimeError, "worker unavailable"): + self.backend.health() + + health_check.assert_called_once_with() + + def test_model_search_runs_in_worker_and_returns_typed_result(self): + submitted = self.jobs.submit_search( + SearchCommand( + modalities=("scene",), + query="taxi", + top_k=1, + ) + ) + completed = self.jobs.wait(submitted.job_id) + + self.assertEqual(completed.state, JobState.succeeded) + self.assertEqual(completed.kind, JobKind.search) + self.assertEqual(completed.result.result.query, "taxi") + self.application.search.assert_called_once_with( + SearchCommand( + modalities=("scene",), + query="taxi", + top_k=1, + ), + snapshot=IndexSnapshotReference( + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, + ), + ) + + def test_grounded_query_runs_in_worker_and_returns_typed_result(self): + command = QueryVideoCommand( + question="What happens?", + modalities=("scene",), + ) + + submitted = self.jobs.submit_query(command) + completed = self.jobs.wait(submitted.job_id) + + self.assertEqual(completed.state, JobState.succeeded) + self.assertEqual(completed.kind, JobKind.query) + self.assertEqual( + completed.result.result.mode, + QueryAnswerMode.no_evidence, + ) + self.application.query_video.assert_called_once_with( + command, + snapshot=IndexSnapshotReference( + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, + ), + execution=ANY, + ) + + def test_legacy_atomic_search_output_remains_readable(self): + legacy = SearchResult( + query_id="scene:legacy", + query="taxi", + modality="scene", + hits=( + SearchHit( + rank=1, + media_id=MEDIA_ID, + video_id=MEDIA_ID, + generation_id=GENERATION_ID, + start=1, + end=2, + score=-0.1, + raw_distance=0.1, + modality="scene", + source_id="scene:1", + ), + ), + ) + + decoded = _decode_search_result(legacy.model_dump(mode="json")) + + self.assertEqual(decoded.query, "taxi") + self.assertEqual(decoded.moments[0].hits[0].source_id, "scene:1") + self.assertEqual(WORKFLOW_KINDS["vidxp.search.v1"], JobKind.search) + + def test_legacy_search_request_upgrades_to_the_v2_command(self): + upgraded = decode_workflow_request( + { + "kind": "search", + "command": { + "modality": "scene", + "query": "taxi", + "top_k": 3, + }, + "snapshot": { + "snapshot_id": SNAPSHOT_ID, + "snapshot_sha256": SNAPSHOT_SHA256, + }, + } + ) + + self.assertIsInstance(upgraded, SearchJobRequest) + self.assertEqual(upgraded.command.modalities, ("scene",)) + self.assertEqual(upgraded.command.top_k, 3) + self.assertTrue(hasattr(self.worker, "legacy_search_workflow")) + self.assertTrue(hasattr(self.worker, "run_legacy_search_step")) + + def test_registered_legacy_search_workflow_executes_upgraded_request(self): + legacy_id = "923456781234423481234567890abcde" + self.backend.client.enqueue( + { + "workflow_name": "vidxp.search.v1", + "queue_name": QUEUE_NAMES[JobQueue.cpu], + "workflow_id": legacy_id, + "app_version": "test-v1", + "attributes": {"vidxp_queue": JobQueue.cpu.value}, + "class_name": WORKFLOW_CLASS_NAME, + "instance_name": WORKFLOW_INSTANCE_NAME, + }, + { + "kind": "search", + "command": { + "modality": "scene", + "query": "taxi", + "top_k": 1, + }, + "snapshot": { + "snapshot_id": SNAPSHOT_ID, + "snapshot_sha256": SNAPSHOT_SHA256, + }, + }, + ) + + completed = self.jobs.wait(legacy_id) + + self.assertEqual(completed.state, JobState.succeeded) + self.application.search.assert_called_once_with( + SearchCommand( + modalities=("scene",), + query="taxi", + top_k=1, + ), + snapshot=IndexSnapshotReference( + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, + ), + ) + + def test_failed_legacy_search_retries_as_a_v2_workflow(self): + legacy_id = "a23456781234423481234567890abcde" + self.application.search.side_effect = ( + ApplicationError( + "temporary_search_failure", + ErrorCategory.unavailable, + "The temporary search dependency is unavailable.", + retryable=True, + ), + self.application.search.return_value, + ) + payload = { + "kind": "search", + "command": { + "modality": "scene", + "query": "taxi", + "top_k": 1, + }, + "snapshot": { + "snapshot_id": SNAPSHOT_ID, + "snapshot_sha256": SNAPSHOT_SHA256, + }, + } + self.backend.client.enqueue( + { + "workflow_name": "vidxp.search.v1", + "queue_name": QUEUE_NAMES[JobQueue.cpu], + "workflow_id": legacy_id, + "app_version": "test-v1", + "attributes": {"vidxp_queue": JobQueue.cpu.value}, + "class_name": WORKFLOW_CLASS_NAME, + "instance_name": WORKFLOW_INSTANCE_NAME, + }, + payload, + ) + with self.assertRaises(ApplicationError): + self.jobs.wait(legacy_id) + + retried = self.jobs.retry( + legacy_id, + retry_id=IDEMPOTENCY_KEY, + ) + completed = self.jobs.wait(retried.job_id) + + self.assertEqual(retried.job_id, IDEMPOTENCY_KEY) + self.assertEqual(completed.state, JobState.succeeded) + self.assertEqual(completed.result.result.query, "taxi") + status = self.backend._status(retried.job_id) + self.assertEqual(status.name, "vidxp.search.v2") + + def test_huge_cursor_offset_is_rejected_before_database_access(self): + cursor = encode_cursor( + "vidxp:jobs", + {"offset": MAX_CURSOR_OFFSET + 1}, + ) + self.backend.client = Mock(wraps=self.backend.client) + + with self.assertRaises(InvalidJobBackendRequestError): + self.backend.list( + ListJobsCommand(page_size=1, cursor=cursor) + ) + + self.backend.client.list_workflows.assert_not_called() + + def test_submission_idempotency_replays_only_the_same_request(self): + command = CreateIndexCommand( + media_id=MEDIA_ID, + modalities=("scene",), + ) + + first = self.jobs.submit_index(command, job_id=IDEMPOTENCY_KEY) + second = self.jobs.submit_index(command, job_id=IDEMPOTENCY_KEY) + completed = self.jobs.wait(first.job_id) + + self.assertEqual(first.job_id, IDEMPOTENCY_KEY) + self.assertEqual(second.job_id, IDEMPOTENCY_KEY) + self.assertEqual(completed.state, JobState.succeeded) + self.application.create_index.assert_called_once() + + with self.assertRaises(ApplicationError) as caught: + self.jobs.submit_index( + command.model_copy(update={"frame_stride": 2}), + job_id=IDEMPOTENCY_KEY, + ) + self.assertEqual(caught.exception.code, "idempotency_key_reused") + self.assertEqual( + caught.exception.category, + ErrorCategory.validation, + ) + + def test_descending_pages_are_frozen_against_new_submissions(self): + original_ids = ( + "523456781234423481234567890abcde", + "623456781234423481234567890abcde", + "723456781234423481234567890abcde", + ) + command = CreateIndexCommand( + media_id=MEDIA_ID, + modalities=("scene",), + ) + for job_id in original_ids: + self.jobs.submit_index(command, job_id=job_id) + sleep(0.01) + + first = self.jobs.list(ListJobsCommand(page_size=2)) + self.assertIsNotNone(first.next_cursor) + + later_id = "823456781234423481234567890abcde" + sleep(0.01) + self.jobs.submit_index(command, job_id=later_id) + second = self.jobs.list( + ListJobsCommand(page_size=2, cursor=first.next_cursor) + ) + + paged_ids = {job.job_id for job in (*first.items, *second.items)} + self.assertEqual(paged_ids, set(original_ids)) + self.assertNotIn(later_id, paged_ids) + + def test_cancellation_reaches_the_running_application_operation(self): + started = Event() + stopped = Event() + + def create_index(_command, *, execution): + started.set() + try: + while True: + execution.checkpoint() + sleep(0.01) + finally: + stopped.set() + + self.application.create_index.side_effect = create_index + submitted = self.jobs.submit_index( + CreateIndexCommand( + media_id=MEDIA_ID, + modalities=("scene",), + ) + ) + + self.assertTrue(started.wait(timeout=5)) + cancelled = self.jobs.cancel(submitted.job_id) + + self.assertEqual(cancelled.state, JobState.cancelled) + self.assertTrue(stopped.wait(timeout=5)) + + def test_failed_job_retries_idempotently_as_new_typed_execution(self): + attempts = 0 + + def create_index(command, *, execution): + nonlocal attempts + attempts += 1 + if attempts == 1: + raise ApplicationError( + "temporary_index_failure", + ErrorCategory.unavailable, + "The temporary indexing dependency is unavailable.", + retryable=True, + ) + return IndexResult( + media_id=command.media_id, + generation_id=GENERATION_ID, + snapshot_id=SNAPSHOT_ID, + active_media_count=1, + record_counts={"scene": 1}, + ) + + self.application.create_index.side_effect = create_index + submitted = self.jobs.submit_index( + CreateIndexCommand( + media_id=MEDIA_ID, + modalities=("scene",), + ) + ) + with self.assertRaises(ApplicationError): + self.jobs.wait(submitted.job_id) + + failed = self.jobs.get(submitted.job_id) + steps = self.backend.client.list_workflow_steps( + failed.job_id, + load_output=True, + ) + failed_step = next(step for step in steps if step["error"] is not None) + self.assertEqual( + str(failed_step["error"]), + "VidXP job execution failed.", + ) + self.assertNotIn( + "temporary indexing dependency", + repr(failed_step["error"]).lower(), + ) + retried = self.jobs.retry( + failed.job_id, + retry_id=IDEMPOTENCY_KEY, + ) + replayed = self.jobs.retry( + failed.job_id, + retry_id=IDEMPOTENCY_KEY, + ) + completed = self.jobs.wait(retried.job_id) + + self.assertNotEqual(retried.job_id, failed.job_id) + self.assertEqual(retried.job_id, IDEMPOTENCY_KEY) + self.assertEqual(replayed.job_id, IDEMPOTENCY_KEY) + self.assertEqual(completed.state, JobState.succeeded) + self.assertEqual(completed.queue, JobQueue.cpu) + self.assertEqual( + completed.result.result.generation_id, + GENERATION_ID, + ) + self.assertIsNone(completed.error) + self.assertEqual(attempts, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_frontend.py b/tests/test_frontend.py index d0afd30..ee426c4 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -1,258 +1,740 @@ import unittest +from contextlib import nullcontext +from types import SimpleNamespace from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import Mock, patch -from streamlit.testing.v1 import AppTest - from vidxp import frontend -from vidxp.capabilities.schemas import SearchHit, SearchResult -from vidxp.core.contracts import INDEX_SCHEMA_VERSION -from vidxp.index_state import IndexNotReadyError - - -READY_STATUS = { - "state": "ready", - "message": "Video indexing completed successfully.", - "summary": { - "index_schema_version": INDEX_SCHEMA_VERSION, - "dataset": "local", - "split": "local", - "run_id": "default", - "video_id": "video-1", - }, -} - - -def result_for(modality, timestamp): - return SearchResult( - query_id="query-1", - query="example", - modality=modality, - hits=( - SearchHit( - rank=1, - video_id="video-1", - start=timestamp, - end=timestamp + 1, - score=-0.1, - raw_distance=0.1, - modality=modality, - source_id=f"run:video-1:{modality}:1", - metadata={}, - ), - ), - ) - - -def frontend_harness(video_path, actor_output_path): - from pathlib import Path - from shutil import copyfile - from unittest.mock import Mock, patch - - from vidxp import frontend - from vidxp.capabilities.schemas import SearchHit, SearchResult - from vidxp.core.contracts import INDEX_SCHEMA_VERSION - - video_path = Path(video_path) - actor_output_path = Path(actor_output_path) - service = Mock() - service.check_dependencies.return_value = {"ok": True} - service.index_status.return_value = { - "state": "ready", - "message": "Video indexing completed successfully.", - "summary": { - "index_schema_version": INDEX_SCHEMA_VERSION, - "dataset": "local", - "split": "local", - "run_id": "default", - "video_id": "video-1", - }, - } - - def search_result(modality, timestamp): - return SearchResult( - query_id="q", - query="example", - modality=modality, - hits=( - SearchHit( - rank=1, - video_id="video-1", - start=timestamp, - end=timestamp + 1, - score=-0.1, - raw_distance=0.1, - modality=modality, - source_id=f"run:video-1:{modality}:1", - ), - ), +from vidxp.application_models import ( + ApplicationError, + DependencyCheckResult, + CreateActorOverlayCommand, + ErrorCategory, + FusedMoment, + FusedSearchResult, + FusionProvenance, + IndexStatus, + JobState, + SearchHit, +) +from vidxp.capabilities.registry import create_capability_registry +from vidxp.capability_service import CapabilityService +from vidxp.control_plane import ControlPlaneApplication +from vidxp.repository_layout import RepositoryLayout +from vidxp.settings import LocalExecutionSettings, VidXPSettings + + +MEDIA_ID = "123456781234423481234567890abcde" +GENERATION_ID = "223456781234423481234567890abcde" + + +class UploadedVideo: + name = "video.mp4" + + def __init__(self, content: bytes): + self.content = content + + def getvalue(self): + return self.content + + +class FrontendTests(unittest.TestCase): + def test_model_download_sizes_are_human_readable(self): + self.assertEqual(frontend._format_bytes(1_539_458_338), "1.43 GiB") + self.assertEqual(frontend._format_bytes(38_926_091), "37.1 MiB") + + def test_durable_job_ids_restore_from_query_parameters(self): + session_state = {} + query_params = { + frontend.INDEX_JOB_QUERY_PARAM: "index-job", + frontend.SEARCH_JOB_QUERY_PARAM: "search-job", + frontend.SEARCH_TYPE_QUERY_PARAM: "scene", + } + with ( + patch.object(frontend.st, "session_state", session_state), + patch.object(frontend.st, "query_params", query_params), + ): + frontend._restore_durable_jobs() + + self.assertEqual( + session_state[frontend.INDEX_JOB_ID_KEY], + "index-job", ) + self.assertEqual( + session_state[frontend.SEARCH_RESULT_KEY], + { + "type": "scene", + "query": "", + "job_id": "search-job", + }, + ) + + def test_query_modalities_use_real_capability_service_contracts(self): + with TemporaryDirectory() as directory: + root = Path(directory) + service = ControlPlaneApplication( + layout=RepositoryLayout(root=root), + capabilities=CapabilityService( + create_capability_registry() + ), + media=Mock(), + artifacts=Mock(), + index_status=lambda: None, + model_cache=root / "models", + ) + with patch.object( + frontend, + "_configured_service", + return_value=service, + ): + available = frontend._available_query_modalities( + ("dialogue", "scene", "actor"), + ) - def search(modality, *_args, **_kwargs): - return search_result( - modality, - 17.25 if modality == "dialogue" else 42.5, + self.assertEqual( + available, + ("dialogue", "scene", "actor"), ) - def generate_actor_result(*_): - copyfile(video_path, actor_output_path) + def tearDown(self): + frontend._configured_service.cache_clear() + frontend._configured_jobs.cache_clear() + + def service(self, root: Path) -> Mock: + service = Mock() + service.layout.media = root / "media" + service.layout.artifacts = root / "artifacts" + service.layout.root = root + service.registry = create_capability_registry() + capabilities = CapabilityService(service.registry) + service.list_capabilities.side_effect = capabilities.list + service.get_capability.side_effect = capabilities.get + return service + + def test_service_is_composed_lazily_and_cached(self): + application = Mock() + with patch.object( + frontend, + "create_application", + return_value=application, + ) as create: + expected = VidXPSettings( + repository_root=Path("repository"), + runtime_backend="cpu", + model_cache=Path("model-cache"), + allow_model_downloads=False, + max_loaded_models=5, + max_concurrent_indexing=2, + max_concurrent_inference=3, + cpu_thread_budget=6, + external_capabilities=True, + capability_allowlist=("acme:ocr",), + ) + settings = frontend._settings_from_arguments( + ( + "--vidxp-settings-json", + LocalExecutionSettings.from_settings( + expected + ).model_dump_json(), + ) + ) + first = frontend._configured_service(settings) + second = frontend._configured_service(settings) - service.search.side_effect = search - service.render_actor.side_effect = generate_actor_result - with ( - patch.object(frontend, "SERVICE", service), - patch.object(frontend, "SAVED_VIDEO_PATH", video_path), - patch.object(frontend, "ACTOR_OUTPUT_PATH", actor_output_path), - patch.object(frontend, "indexing_in_progress", return_value=False), - ): - frontend.run() + self.assertIs(first, application) + self.assertIs(second, application) + create.assert_called_once() + self.assertEqual(settings, expected) + def test_media_identity_controls_search_readiness(self): + with TemporaryDirectory() as directory: + service = self.service(Path(directory)) + with patch.object( + frontend, + "_configured_service", + return_value=service, + ): + matching = { + "state": "ready", + "summary": {"media_ids": [MEDIA_ID]}, + } + stale = { + "state": "ready", + "summary": {"media_ids": []}, + } + self.assertTrue(frontend._is_search_ready(matching, MEDIA_ID)) + self.assertFalse(frontend._is_search_ready(stale, MEDIA_ID)) + + def test_single_registered_media_is_selected_before_indexing(self): + asset = SimpleNamespace(media_id=MEDIA_ID) + + self.assertEqual( + frontend._default_media_id(None, (asset,)), + MEDIA_ID, + ) + self.assertEqual( + frontend._default_media_id("missing", (asset,)), + MEDIA_ID, + ) -class FrontendSearchTests(unittest.TestCase): - def setUp(self): - self.service = Mock() - self.service.index_status.return_value = READY_STATUS + def test_multiple_registered_media_preserves_only_valid_selection(self): + assets = ( + SimpleNamespace(media_id=MEDIA_ID), + SimpleNamespace(media_id="323456781234423481234567890abcde"), + ) - def test_scene_search_uses_shared_service(self): - self.service.search.return_value = result_for("scene", 12.5) - with patch.object(frontend, "SERVICE", self.service): - result = frontend._run_search("scene", "yellow taxi") + self.assertEqual( + frontend._default_media_id(MEDIA_ID, assets), + MEDIA_ID, + ) + self.assertIsNone(frontend._default_media_id("missing", assets)) - self.assertEqual(result["timestamp"], 12.5) - self.assertEqual(result["hit"]["video_id"], "video-1") - self.service.search.assert_called_once_with( - "scene", - "yellow taxi", - top_k=1, + def test_busy_video_layout_keeps_controls_and_preview_stable(self): + service = Mock() + service.open_media_content.return_value = SimpleNamespace( + path=Path("video.mp4") ) + media_page = SimpleNamespace( + items=( + SimpleNamespace( + media_id=MEDIA_ID, + original_filename="video.mp4", + duration_seconds=27.2, + ), + ), + next_cursor=None, + ) + with ( + patch.object( + frontend, + "_configured_service", + return_value=service, + ), + patch.object(frontend.st, "session_state", {}), + patch.object(frontend.st, "subheader"), + patch.object( + frontend.st, + "selectbox", + return_value=MEDIA_ID, + ) as selectbox, + patch.object( + frontend.st, + "expander", + return_value=nullcontext(), + ), + patch.object(frontend.st, "caption"), + patch.object(frontend.st, "text_input", return_value=""), + patch.object(frontend.st, "button", return_value=False), + patch.object( + frontend.st, + "file_uploader", + return_value=None, + ) as uploader, + patch.object(frontend.st, "video") as video, + ): + uploaded, media_id = frontend._select_video( + True, + MEDIA_ID, + media_page, + ) - def test_dialogue_search_returns_a_renderable_result(self): - self.service.search.return_value = result_for("dialogue", 8) - with patch.object(frontend, "SERVICE", self.service): - result = frontend._run_search("dialogue", "fresh bread") + self.assertIsNone(uploaded) + self.assertEqual(media_id, MEDIA_ID) + self.assertTrue(selectbox.call_args.kwargs["disabled"]) + self.assertTrue(uploader.call_args.kwargs["disabled"]) + video.assert_called_once_with("video.mp4", width=560) - self.assertEqual(result["type"], "dialogue") - self.assertEqual(result["timestamp"], 8.0) + def test_local_path_import_uses_the_shared_application_command(self): + service = Mock() + service.import_media.return_value = SimpleNamespace(media_id=MEDIA_ID) - def test_actor_search_uses_shared_service(self): + result = frontend._import_local_video( + service, + " sample.mp4 ", + ) + + self.assertEqual(result.media_id, MEDIA_ID) + command = service.import_media.call_args.args[0] + self.assertEqual(command.path, Path("sample.mp4")) + + def test_available_modalities_use_application_dependency_commands(self): with TemporaryDirectory() as directory: - output_path = Path(directory) / "actor.mp4" + service = self.service(Path(directory)) - def generate_result(*_): - output_path.write_bytes(b"video") + def check(command): + return DependencyCheckResult( + ok=command.modalities != ("actor",), + modalities=command.modalities, + checks=(), + ) - self.service.render_actor.side_effect = generate_result - with ( - patch.object(frontend, "SERVICE", self.service), - patch.object(frontend, "ACTOR_OUTPUT_PATH", output_path), + service.check_dependencies.side_effect = check + with patch.object( + frontend, + "_configured_service", + return_value=service, ): - result = frontend._run_search("actor", "3") + available = frontend._available_index_modalities() - self.service.render_actor.assert_called_once() - self.assertEqual(result["type"], "actor") + self.assertEqual(available, ("dialogue", "scene")) + self.assertTrue( + all( + not call.args[0].include_runtime_checks + for call in service.check_dependencies.call_args_list + ) + ) + + def test_scene_detail_control_is_conditional_and_defaults_to_balanced(self): + with patch.object( + frontend.st, + "selectbox", + return_value=2.0, + ) as selectbox: + selected = frontend._scene_sample_fps_control( + ("dialogue", "scene"), + disabled=False, + ) - def test_search_error_is_returned_for_persistent_rendering(self): - self.service.index_status.side_effect = IndexNotReadyError( - "Index is not ready." + self.assertEqual(selected, 2.0) + self.assertEqual( + selectbox.call_args.args[:2], + ("Scene detail", (0.5, 1.0, 2.0)), ) - with patch.object(frontend, "SERVICE", self.service): - result = frontend._run_search("scene", "yellow taxi") + self.assertEqual(selectbox.call_args.kwargs["index"], 1) - self.assertEqual(result, {"error": "Index is not ready."}) + with patch.object(frontend.st, "selectbox") as selectbox: + selected = frontend._scene_sample_fps_control( + ("dialogue",), + disabled=False, + ) - def test_starting_a_new_index_clears_the_previous_result(self): - state = { - frontend.SEARCH_RESULT_KEY: { - "type": "scene", - "timestamp": 12.5, - }, - frontend.INDEX_ERROR_KEY: "old error", - } - with patch.object(frontend.st, "session_state", state): - frontend._request_indexing() + self.assertIsNone(selected) + selectbox.assert_not_called() - self.assertTrue(state[frontend.INDEX_REQUESTED_KEY]) - self.assertNotIn(frontend.SEARCH_RESULT_KEY, state) - self.assertNotIn(frontend.INDEX_ERROR_KEY, state) + def test_indexing_submits_selected_scene_sample_rate(self): + jobs = Mock() + jobs.submit_index.return_value = SimpleNamespace(job_id="job-1") + service = Mock() + session_state = {frontend.MEDIA_ID_KEY: MEDIA_ID} + with ( + patch.object( + frontend, + "_configured_service", + return_value=service, + ), + patch.object(frontend, "_configured_jobs", return_value=jobs), + patch.object(frontend.st, "session_state", session_state), + patch.object(frontend.st, "query_params", {}), + patch.object(frontend.st, "rerun"), + ): + frontend._run_indexing( + None, + {}, + ("scene",), + scene_sample_fps=2.0, + ) - def test_available_index_modalities_excludes_missing_extras(self): - def dependency_status(modalities): - return {"ok": modalities == ("scene",)} + command = jobs.submit_index.call_args.args[0] + self.assertEqual(command.scene_sample_fps, 2.0) + service.require_models.assert_called_once_with(("scene",)) - self.service.check_dependencies.side_effect = dependency_status - with patch.object(frontend, "SERVICE", self.service): - available = frontend._available_index_modalities() + def test_indexing_omits_scene_sample_rate_without_scene(self): + jobs = Mock() + jobs.submit_index.return_value = SimpleNamespace(job_id="job-1") + session_state = {frontend.MEDIA_ID_KEY: MEDIA_ID} + with ( + patch.object(frontend, "_configured_service", return_value=Mock()), + patch.object(frontend, "_configured_jobs", return_value=jobs), + patch.object(frontend.st, "session_state", session_state), + patch.object(frontend.st, "query_params", {}), + patch.object(frontend.st, "rerun"), + ): + frontend._run_indexing(None, {}, ("dialogue",)) - self.assertEqual(available, ("scene",)) + command = jobs.submit_index.call_args.args[0] + self.assertIsNone(command.scene_sample_fps) - def test_indexing_passes_selected_modalities_to_the_worker(self): - uploaded_video = Mock() - uploaded_video.name = "source.mp4" - uploaded_video.getvalue.return_value = b"video" + def test_search_queues_shared_command_without_loading_models(self): with TemporaryDirectory() as directory: - saved_video = Path(directory) / "source-video.mp4" + root = Path(directory) + service = self.service(root) + service.index_status.return_value = IndexStatus( + schema_version=1, + state="ready", + stage="complete", + message="ready", + ) + jobs = Mock() + jobs.submit_search.return_value = Mock(job_id="job-1") with ( - patch.object(frontend, "SAVED_VIDEO_PATH", saved_video), - patch.object(frontend, "start_indexing") as start, - patch.object(frontend.st, "rerun"), + patch.object( + frontend, + "_configured_service", + return_value=service, + ), + patch.object( + frontend, + "_configured_jobs", + return_value=jobs, + ), ): - frontend._run_indexing( - uploaded_video, - {}, - ("scene",), - ) + result = frontend._run_search("scene", "taxi") - start.assert_called_once_with( - str(saved_video), - "source.mp4", - frontend.SERVICE, - modalities=("scene",), + self.assertEqual(result["job_id"], "job-1") + command = jobs.submit_search.call_args.args[0] + self.assertEqual(command.modalities, ("scene",)) + self.assertEqual(command.query, "taxi") + service.search.assert_not_called() + + def test_actor_search_queues_without_blocking_ui(self): + with TemporaryDirectory() as directory: + service = self.service(Path(directory)) + service.index_status.return_value = IndexStatus( + schema_version=1, + state="ready", + stage="complete", + message="ready", + ) + jobs = Mock() + jobs.submit_actor_overlay.return_value = Mock(job_id="job-1") + with ( + patch.object( + frontend, + "_configured_service", + return_value=service, + ), + patch.object( + frontend, + "_configured_jobs", + return_value=jobs, + ), + ): + result = frontend._run_search("actor", "actor-1") + + self.assertEqual( + result, + { + "type": "actor", + "query": "actor-1", + "job_id": "job-1", + }, + ) + jobs.submit_actor_overlay.assert_called_once_with( + CreateActorOverlayCommand(cluster_id="actor-1") ) + jobs.wait.assert_not_called() + + @staticmethod + def _render_job(job, result=None): + jobs = Mock() + jobs.get.return_value = job + session_state = {} + query_params = { + frontend.SEARCH_JOB_QUERY_PARAM: "job-1", + frontend.SEARCH_TYPE_QUERY_PARAM: "actor", + } + + def fragment(*, run_every): + assert run_every == "1s" + return lambda function: function - def test_cancellation_request_uses_the_worker_token(self): - state = {} with ( - patch.object(frontend.st, "session_state", state), - patch.object(frontend, "cancel_indexing", return_value=True), + patch.object( + frontend, + "_configured_jobs", + return_value=jobs, + ), + patch.object(frontend.st, "fragment", side_effect=fragment), + patch.object(frontend.st, "session_state", session_state), + patch.object(frontend.st, "query_params", query_params), + patch.object(frontend.st, "rerun") as rerun, ): - frontend._request_cancellation() + frontend._render_search_result( + result or { + "type": "actor", + "query": "actor-1", + "job_id": "job-1", + } + ) + return session_state, rerun - self.assertTrue(state[frontend.CANCEL_REQUESTED_KEY]) + def test_terminal_index_failure_survives_browser_refresh(self): + session_state = { + frontend.INDEX_JOB_ID_KEY: "job-1", + } + query_params = { + frontend.INDEX_JOB_QUERY_PARAM: "job-1", + } + error = Mock(message="Indexing failed.") + job = Mock(state=JobState.failed, error=error) - def test_search_results_render_and_survive_widget_reruns(self): - with TemporaryDirectory() as directory: - video_path = Path(directory) / "video.mp4" - actor_output_path = Path(directory) / "actor.mp4" - video_path.write_bytes(b"video") - app = AppTest.from_function( - frontend_harness, - args=(str(video_path), str(actor_output_path)), - ).run() - - app.text_input[0].set_value("yellow taxi").run() - app.button[1].click().run() - self.assertEqual(list(app.exception), []) - self.assertEqual(len(app.get("video")), 2) - self.assertEqual( - app.session_state[frontend.SEARCH_RESULT_KEY]["type"], - "scene", - ) + with ( + patch.object(frontend.st, "session_state", session_state), + patch.object(frontend.st, "query_params", query_params), + ): + frontend._finish_index_job(job) + + self.assertNotIn(frontend.INDEX_JOB_ID_KEY, session_state) + self.assertNotIn(frontend.INDEX_JOB_QUERY_PARAM, query_params) + self.assertEqual( + session_state[frontend.INDEX_ERROR_KEY], + "Indexing failed.", + ) + + def test_actor_poll_persists_success_before_stopping(self): + job = Mock(state=JobState.succeeded, error=None) + job.result.result.artifact_id = "artifact-1" + + session_state, rerun = self._render_job(job) - app.selectbox[0].select("dialogue").run() - self.assertEqual(len(app.get("video")), 2) - self.assertEqual( - app.session_state[frontend.SEARCH_RESULT_KEY]["type"], - "scene", + self.assertEqual( + session_state[frontend.SEARCH_RESULT_KEY], + { + "type": "actor", + "query": "actor-1", + "artifact_id": "artifact-1", + }, + ) + rerun.assert_called_once_with() + + def test_queued_search_is_presented_as_starting(self): + job = Mock(state=JobState.queued, progress=None) + + with patch.object(frontend.st, "markdown") as markdown: + self._render_job( + job, + { + "type": "scene", + "query": "three people at the counter", + "job_id": "job-1", + }, ) - app.text_input[0].set_value("fresh bread").run() - app.button[1].click().run() - self.assertEqual( - app.session_state[frontend.SEARCH_RESULT_KEY]["type"], - "dialogue", + markdown.assert_called_once_with("⏳ Starting scene search...") + + def test_scene_result_describes_similarity_and_limitations(self): + service = Mock() + service.open_media_content.return_value = SimpleNamespace( + path=Path("video.mp4"), + ) + with ( + patch.object( + frontend, + "_configured_service", + return_value=service, + ), + patch.object(frontend.st, "success") as success, + patch.object(frontend.st, "caption") as caption, + patch.object(frontend.st, "video") as video, + ): + frontend._render_search_result( + { + "type": "scene", + "query": "three people at the counter", + "timestamp": 24.024, + "media_id": MEDIA_ID, + } ) + success.assert_called_once_with( + "Closest sampled scene: 24.0 seconds" + ) + caption.assert_called_once_with( + "Scene search ranks sampled frames by visual similarity. " + "It does not identify the first occurrence and is not reliable " + "for counting people." + ) + video.assert_called_once_with( + "video.mp4", + start_time=24.024, + width="stretch", + ) + + def test_actor_poll_persists_unavailable_job_error(self): + session_state, rerun = self._render_job(None) + + self.assertEqual( + session_state[frontend.SEARCH_RESULT_KEY], + { + "type": "actor", + "query": "actor-1", + "error": "The actor overlay job is unavailable.", + }, + ) + rerun.assert_called_once_with() + + def test_search_poll_keeps_durable_job_on_retryable_backend_failure(self): + jobs = Mock() + jobs.get.side_effect = ApplicationError( + "job_backend_unavailable", + ErrorCategory.unavailable, + "The durable job backend is unavailable.", + retryable=True, + ) + result = { + "type": "scene", + "query": "taxi", + "job_id": "job-1", + } + session_state = {frontend.SEARCH_RESULT_KEY: result} + + def fragment(*, run_every): + self.assertEqual(run_every, "1s") + return lambda function: function + + with ( + patch.object( + frontend, + "_configured_jobs", + return_value=jobs, + ), + patch.object(frontend.st, "fragment", side_effect=fragment), + patch.object(frontend.st, "session_state", session_state), + patch.object(frontend.st, "warning") as warning, + patch.object(frontend.st, "rerun") as rerun, + ): + frontend._render_search_result(result) + + self.assertEqual(session_state[frontend.SEARCH_RESULT_KEY], result) + warning.assert_called_once_with( + "The scene search status is temporarily unavailable. Retrying." + ) + rerun.assert_not_called() + + def test_job_lookup_only_suppresses_not_found(self): + jobs = Mock() + jobs.get.side_effect = ApplicationError( + "resource_not_found", + ErrorCategory.not_found, + "The requested job was not found.", + ) + self.assertIsNone(frontend._get_job(jobs, "job-1")) + + outage = ApplicationError( + "job_backend_unavailable", + ErrorCategory.unavailable, + "The durable job backend is unavailable.", + retryable=True, + ) + jobs.get.side_effect = outage + with self.assertRaises(ApplicationError) as raised: + frontend._get_job(jobs, "job-1") + self.assertIs(raised.exception, outage) + + def test_actor_poll_persists_failed_job_error(self): + error = Mock(message="Overlay rendering failed.") + job = Mock(state=JobState.failed, error=error, result=None) + + session_state, rerun = self._render_job(job) + + self.assertEqual( + session_state[frontend.SEARCH_RESULT_KEY], + { + "type": "actor", + "query": "actor-1", + "error": "Overlay rendering failed.", + }, + ) + rerun.assert_called_once_with() + + def test_actor_poll_persists_cancelled_job_error(self): + job = Mock(state=JobState.cancelled, error=None, result=None) + + session_state, rerun = self._render_job(job) + + self.assertEqual( + session_state[frontend.SEARCH_RESULT_KEY], + { + "type": "actor", + "query": "actor-1", + "error": "The actor overlay was cancelled.", + }, + ) + rerun.assert_called_once_with() + + def test_search_poll_persists_the_best_typed_hit(self): + hit = SearchHit( + rank=1, + media_id=MEDIA_ID, + video_id=MEDIA_ID, + generation_id=GENERATION_ID, + start=12.5, + end=13.0, + score=-0.1, + raw_distance=0.1, + modality="scene", + source_id="scene:1", + ) + completed = FusedSearchResult( + query_id="fused:taxi", + query="taxi", + modalities=("scene",), + moments=( + FusedMoment( + rank=1, + score=1 / 61, + media_id=MEDIA_ID, + start=12.5, + end=13.0, + modalities=("scene",), + hits=(hit,), + ), + ), + fusion=FusionProvenance( + requested_modalities=("scene",), + searched_modalities=("scene",), + ), + ) + job = Mock(state=JobState.succeeded, error=None) + job.result.result = completed + + session_state, rerun = self._render_job( + job, + { + "type": "scene", + "query": "taxi", + "job_id": "job-1", + }, + ) + + persisted = session_state[frontend.SEARCH_RESULT_KEY] + self.assertEqual(persisted["media_id"], MEDIA_ID) + self.assertEqual(persisted["timestamp"], 12.5) + rerun.assert_called_once_with() + + def test_restored_search_uses_completed_query_when_no_hit_exists(self): + completed = FusedSearchResult( + query_id="fused:taxi", + query="yellow taxi", + modalities=("scene",), + fusion=FusionProvenance( + requested_modalities=("scene",), + searched_modalities=("scene",), + ), + ) + job = Mock(state=JobState.succeeded, error=None) + job.result.result = completed + + session_state, rerun = self._render_job( + job, + { + "type": "scene", + "query": "", + "job_id": "job-1", + }, + ) + + self.assertEqual( + session_state[frontend.SEARCH_RESULT_KEY], + { + "type": "scene", + "query": "yellow taxi", + "error": "No scene match was found.", + }, + ) + rerun.assert_called_once_with() + if __name__ == "__main__": unittest.main() diff --git a/tests/test_frontend_app.py b/tests/test_frontend_app.py new file mode 100644 index 0000000..833d2fa --- /dev/null +++ b/tests/test_frontend_app.py @@ -0,0 +1,358 @@ +import unittest +from datetime import datetime, timezone +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace + +from streamlit.testing.v1 import AppTest + +from vidxp.application_models import ( + DependencyCheckResult, + FusedMoment, + FusedSearchResult, + FusionProvenance, + IndexStatus, + IndexStatusSummary, + Job, + JobKind, + JobProgress, + JobQueue, + JobState, + MediaAsset, + MediaPage, + SearchHit, + SearchJobResult, +) +from vidxp.capabilities.registry import create_capability_registry +from vidxp.capability_service import CapabilityService +from vidxp.core.media import MediaState, MediaStream +from vidxp.ports import LocalFileResource + + +MEDIA_ID = "123456781234423481234567890abcde" +GENERATION_ID = "223456781234423481234567890abcde" +SNAPSHOT_ID = "323456781234423481234567890abcde" +INDEX_JOB_ID = "423456781234423481234567890abcde" +SEARCH_JOB_ID = "523456781234423481234567890abcde" + + +def render_frontend(service, jobs): + from unittest.mock import patch + + from vidxp import frontend + + with ( + patch.object( + frontend, + "_configured_service", + return_value=service, + ), + patch.object( + frontend, + "_configured_jobs", + return_value=jobs, + ), + ): + frontend.run() + + +class FrontendApplicationStub: + def __init__(self, root: Path, status: IndexStatus): + self.layout = SimpleNamespace(root=root) + self.model_cache = root / "models" + self._status = status + self._capabilities = CapabilityService( + create_capability_registry() + ) + self._media = MediaAsset( + media_id=MEDIA_ID, + video_id=MEDIA_ID, + original_filename="video.mp4", + sha256="1" * 64, + byte_size=10, + detected_mime_type="video/mp4", + container="mp4", + duration_seconds=27.2, + streams=( + MediaStream( + index=0, + kind="video", + codec="h264", + width=1, + height=1, + ), + ), + state=MediaState.ready, + created_at=datetime.now(timezone.utc), + ) + self._video_path = root / "video.mp4" + self._video_path.write_bytes(b"test video") + + def list_capabilities(self): + return self._capabilities.list() + + def get_capability(self, name): + return self._capabilities.get(name) + + def check_dependencies(self, command): + return DependencyCheckResult( + ok=True, + modalities=command.modalities, + checks=(), + ) + + def model_readiness(self, modalities): + return DependencyCheckResult( + ok=True, + modalities=modalities, + checks=(), + ) + + def index_status(self): + return self._status + + def list_media(self, command): + del command + return MediaPage(items=(self._media,), total=1) + + def open_media_content(self, media_id): + if media_id != MEDIA_ID: + raise AssertionError(f"unexpected media ID: {media_id}") + return LocalFileResource( + path=self._video_path, + filename=self._media.original_filename, + mime_type=self._media.detected_mime_type, + byte_size=self._media.byte_size, + etag=self._media.sha256, + ) + + +class FrontendJobStub: + def __init__(self): + self.jobs = {} + self.submitted_searches = [] + + def get(self, job_id): + return self.jobs.get(job_id) + + def submit_search(self, command): + self.submitted_searches.append(command) + job = Job( + job_id=SEARCH_JOB_ID, + kind=JobKind.search, + state=JobState.queued, + queue=JobQueue.cpu, + ) + self.jobs[job.job_id] = job + return job + + def cancel(self, job_id): + raise AssertionError(f"unexpected cancellation: {job_id}") + + +def ready_status() -> IndexStatus: + return IndexStatus( + schema_version=1, + state="ready", + stage="complete", + message="The video index is ready.", + summary=IndexStatusSummary( + index_schema_version=1, + snapshot_id=SNAPSHOT_ID, + media_count=1, + media_ids=(MEDIA_ID,), + modalities=("dialogue", "scene", "actor"), + ), + ) + + +def scene_search_result() -> FusedSearchResult: + hit = SearchHit( + rank=1, + media_id=MEDIA_ID, + video_id=MEDIA_ID, + generation_id=GENERATION_ID, + start=3.0, + end=4.0, + score=0.8, + raw_distance=0.2, + modality="scene", + source_id="scene:3", + ) + return FusedSearchResult( + query_id="query-1", + query="man in gray pants", + modalities=("scene",), + moments=( + FusedMoment( + rank=1, + score=0.8, + media_id=MEDIA_ID, + start=3.0, + end=4.0, + modalities=("scene",), + hits=(hit,), + ), + ), + fusion=FusionProvenance( + requested_modalities=("scene",), + searched_modalities=("scene",), + ), + ) + + +class FrontendAppTests(unittest.TestCase): + def setUp(self): + self.temporary_directory = TemporaryDirectory() + self.root = Path(self.temporary_directory.name) + + def tearDown(self): + self.temporary_directory.cleanup() + + @staticmethod + def widget(elements, label): + return next(item for item in elements if item.label == label) + + def app(self, service, jobs): + return AppTest.from_function( + render_frontend, + args=(service, jobs), + default_timeout=10, + ) + + def test_ready_page_renders_once_and_submits_first_search(self): + service = FrontendApplicationStub(self.root, ready_status()) + jobs = FrontendJobStub() + app = self.app(service, jobs).run() + + self.assertEqual(tuple(app.exception), ()) + self.assertEqual(len(app.get("video")), 1) + search_form = app.get("form") + self.assertEqual(len(search_form), 1) + self.assertTrue(search_form[0].proto.form.enter_to_submit) + search_type = self.widget(app.selectbox, "Search type") + search_query = app.text_input(key="video_search_query") + search_button = self.widget(app.button, "Search") + self.assertFalse(search_type.disabled) + self.assertFalse(search_query.disabled) + self.assertFalse(search_button.disabled) + + search_type.select("scene") + search_query.input("man in gray pants") + search_button.click() + app.run() + + self.assertEqual(tuple(app.exception), ()) + self.assertEqual(len(jobs.submitted_searches), 1) + self.assertEqual( + jobs.submitted_searches[0].query, + "man in gray pants", + ) + self.assertTrue( + any( + item.value == "⏳ Starting scene search..." + for item in app.markdown + ) + ) + + def test_ready_page_rejects_empty_search_without_disabling_form(self): + service = FrontendApplicationStub(self.root, ready_status()) + jobs = FrontendJobStub() + app = self.app(service, jobs).run() + + search_button = self.widget(app.button, "Search") + search_button.click() + app.run() + + self.assertEqual(tuple(app.exception), ()) + self.assertFalse(self.widget(app.button, "Search").disabled) + self.assertEqual(app.warning[-1].value, "Enter a search query.") + self.assertEqual(jobs.submitted_searches, []) + + def test_running_index_keeps_one_preview_and_disables_mutations(self): + service = FrontendApplicationStub(self.root, ready_status()) + jobs = FrontendJobStub() + jobs.jobs[INDEX_JOB_ID] = Job( + job_id=INDEX_JOB_ID, + kind=JobKind.index, + state=JobState.running, + queue=JobQueue.cpu, + progress=JobProgress( + stage="scene", + message="Indexing sampled scenes.", + current=2, + total=10, + updated_at=datetime.now(timezone.utc), + ), + ) + app = self.app(service, jobs) + app.query_params["index_job"] = INDEX_JOB_ID + app.run() + + self.assertEqual(tuple(app.exception), ()) + self.assertEqual(len(app.get("video")), 1) + self.assertTrue( + self.widget(app.selectbox, "Registered video").disabled + ) + self.assertTrue(app.file_uploader[0].disabled) + self.assertTrue(app.multiselect[0].disabled) + self.assertTrue(self.widget(app.selectbox, "Scene detail").disabled) + self.assertTrue(self.widget(app.selectbox, "Search type").disabled) + self.assertTrue( + app.text_input(key="video_search_query").disabled + ) + self.assertTrue(self.widget(app.button, "Index video").disabled) + self.assertTrue(self.widget(app.button, "Search").disabled) + self.assertFalse( + self.widget(app.button, "Cancel indexing").disabled + ) + self.assertTrue( + any( + item.value == "⏳ Indexing sampled scenes." + for item in app.markdown + ) + ) + + def test_restored_search_job_moves_from_queued_to_typed_result(self): + service = FrontendApplicationStub(self.root, ready_status()) + jobs = FrontendJobStub() + jobs.jobs[SEARCH_JOB_ID] = Job( + job_id=SEARCH_JOB_ID, + kind=JobKind.search, + state=JobState.queued, + queue=JobQueue.cpu, + ) + app = self.app(service, jobs) + app.query_params["search_job"] = SEARCH_JOB_ID + app.query_params["search_type"] = "scene" + app.run() + + self.assertEqual(tuple(app.exception), ()) + self.assertTrue( + any( + item.value == "⏳ Starting scene search..." + for item in app.markdown + ) + ) + + jobs.jobs[SEARCH_JOB_ID] = Job( + job_id=SEARCH_JOB_ID, + kind=JobKind.search, + state=JobState.succeeded, + queue=JobQueue.cpu, + result=SearchJobResult(result=scene_search_result()), + ) + app.run() + + self.assertEqual(tuple(app.exception), ()) + self.assertEqual(len(app.get("video")), 2) + self.assertEqual( + app.success[-1].value, + "Closest sampled scene: 3.0 seconds", + ) + self.assertNotIn("search_job", app.query_params) + self.assertNotIn("search_type", app.query_params) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_generation_manifest.py b/tests/test_generation_manifest.py new file mode 100644 index 0000000..1e10efe --- /dev/null +++ b/tests/test_generation_manifest.py @@ -0,0 +1,190 @@ +import json +import unittest +from copy import deepcopy +from datetime import datetime, timezone +from uuid import uuid1, uuid4 + +from pydantic import ValidationError + +from vidxp.core.contracts import ( + INDEX_SCHEMA_VERSION, + MANIFEST_SCHEMA_VERSION, +) +from vidxp.core.generations import CompletedGenerationManifest + + +SHA256 = "a" * 64 +OTHER_SHA256 = "b" * 64 + + +def completed_manifest() -> dict: + generation_id = uuid4().hex + created_at = datetime(2026, 7, 29, tzinfo=timezone.utc).isoformat() + completed_at = datetime(2026, 7, 29, 0, 1, tzinfo=timezone.utc).isoformat() + return { + "manifest_schema_version": MANIFEST_SCHEMA_VERSION, + "index_schema_version": INDEX_SCHEMA_VERSION, + "dataset": "local", + "split": "local", + "run_id": "default", + "generation_id": generation_id, + "state": "complete", + "created_at": created_at, + "updated_at": completed_at, + "completed_at": completed_at, + "config_fingerprint": SHA256, + "execution_fingerprint": OTHER_SHA256, + "configuration": { + "enabled_modalities": ["scene", "dialogue"], + "frame_stride": 1, + }, + "models": {"runtime": {"requested": "cpu"}}, + "git": {"commit": None, "dirty": None}, + "environment": { + "python": "3.13", + "dependencies": {"chromadb": "1.5.9"}, + }, + "inputs": { + "episode-1": { + "sha256": SHA256, + "checksums": {"video": SHA256}, + "size": 1024, + "source_name": "episode.mp4", + "path": "/media/episode.mp4", + "metadata": {}, + } + }, + "videos": { + "episode-1": { + "state": "complete", + "started_at": created_at, + "stages": { + "scene": { + "video_id": "episode-1", + "stage": "scene", + "seconds": 1.5, + "stats": {"scene_frames": 4}, + "recorded_at": completed_at, + } + }, + "completed_at": completed_at, + "summary": {"processed_frames": 4}, + } + }, + "completed_videos": ["episode-1"], + "failed_videos": [], + "interrupted_videos": [], + "processed_frames": 4, + "record_counts": {"scene": 4, "dialogue": 2}, + "store_size_bytes_at_commit": 4096, + } + + +class CompletedGenerationManifestTests(unittest.TestCase): + def validate(self, payload: dict) -> CompletedGenerationManifest: + return CompletedGenerationManifest.model_validate_json( + json.dumps(payload) + ) + + def test_valid_completed_manifest_is_strict_frozen_and_json_safe(self): + manifest = self.validate(completed_manifest()) + + self.assertEqual(manifest.state, "complete") + self.assertEqual(manifest.record_counts["scene"], 4) + with self.assertRaises(ValidationError): + manifest.state = "running" + + invalid = completed_manifest() + invalid["unexpected"] = True + with self.assertRaises(ValidationError): + self.validate(invalid) + + invalid = completed_manifest() + invalid["store_size_bytes_at_commit"] = "4096" + with self.assertRaises(ValidationError): + CompletedGenerationManifest.model_validate(invalid) + + invalid = completed_manifest() + invalid["models"] = {"runtime": object()} + with self.assertRaises(ValidationError): + CompletedGenerationManifest.model_validate(invalid) + + def test_requires_versions_uuid4_complete_state_and_aware_timestamps(self): + invalid_values = ( + ("manifest_schema_version", MANIFEST_SCHEMA_VERSION + 1), + ("index_schema_version", INDEX_SCHEMA_VERSION + 1), + ("generation_id", uuid1().hex), + ("generation_id", str(uuid4())), + ("state", "running"), + ("completed_at", "2026-07-29T00:01:00"), + ) + for field, value in invalid_values: + with self.subTest(field=field, value=value): + payload = completed_manifest() + payload[field] = value + with self.assertRaises(ValidationError): + self.validate(payload) + + def test_requires_one_consistent_completed_media_without_failures(self): + mutations = [] + + second_input = deepcopy(completed_manifest()["inputs"]["episode-1"]) + mutations.append( + lambda payload: payload["inputs"].update( + {"episode-2": second_input} + ) + ) + mutations.append( + lambda payload: payload.update( + {"completed_videos": ["different-media"]} + ) + ) + mutations.append( + lambda payload: payload["videos"]["episode-1"].update( + {"state": "failed"} + ) + ) + mutations.append( + lambda payload: payload.update({"failed_videos": ["episode-1"]}) + ) + mutations.append( + lambda payload: payload.update( + {"interrupted_videos": ["episode-1"]} + ) + ) + + for mutate in mutations: + payload = completed_manifest() + mutate(payload) + with self.assertRaises(ValidationError): + self.validate(payload) + + def test_record_counts_exactly_match_modalities_and_sizes_are_nonnegative(self): + invalid_counts = ( + {"scene": 4}, + {"scene": 4, "dialogue": 2, "actor": 1}, + {"scene": -1, "dialogue": 2}, + ) + for record_counts in invalid_counts: + with self.subTest(record_counts=record_counts): + payload = completed_manifest() + payload["record_counts"] = record_counts + with self.assertRaises(ValidationError): + self.validate(payload) + + unknown_size = completed_manifest() + unknown_size["store_size_bytes_at_commit"] = None + self.assertIsNone( + self.validate(unknown_size).store_size_bytes_at_commit + ) + + for field in ("processed_frames", "store_size_bytes_at_commit"): + with self.subTest(field=field): + payload = completed_manifest() + payload[field] = -1 + with self.assertRaises(ValidationError): + self.validate(payload) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_index_state.py b/tests/test_index_state.py deleted file mode 100644 index 9395802..0000000 --- a/tests/test_index_state.py +++ /dev/null @@ -1,95 +0,0 @@ -import json -import tempfile -import unittest -from pathlib import Path - -from vidxp.index_state import ( - IndexNotReadyError, - fingerprint_file, - read_index_status, - require_ready_index, - write_index_status, -) - - -class IndexStateTests(unittest.TestCase): - def test_missing_status_is_not_ready(self): - with tempfile.TemporaryDirectory() as directory: - with self.assertRaisesRegex( - IndexNotReadyError, - "Index a video before searching", - ): - require_ready_index(directory) - - def test_status_round_trip_and_readiness_guard(self): - with tempfile.TemporaryDirectory() as directory: - write_index_status( - state="indexing", - stage="scene_indexing", - message="Indexing video scenes.", - current=5, - total=10, - index_directory=directory, - ) - - status = read_index_status(directory) - self.assertEqual(status["state"], "indexing") - self.assertEqual(status["current"], 5) - with self.assertRaises(IndexNotReadyError): - require_ready_index(directory) - - write_index_status( - state="ready", - stage="complete", - message="Video indexing completed successfully.", - summary={"scene_frames": 10}, - index_directory=directory, - ) - self.assertEqual(require_ready_index(directory)["state"], "ready") - - def test_unreadable_status_is_failed(self): - with tempfile.TemporaryDirectory() as directory: - status_path = Path(directory) / "index_status.json" - status_path.write_text("{not-json", encoding="utf-8") - - status = read_index_status(directory) - self.assertEqual(status["state"], "failed") - with self.assertRaises(IndexNotReadyError): - require_ready_index(directory) - - def test_file_fingerprint_is_stable(self): - with tempfile.TemporaryDirectory() as directory: - video_path = Path(directory) / "sample.mp4" - video_path.write_bytes(b"vidxp-test-video") - - first = fingerprint_file(video_path) - second = fingerprint_file(video_path) - - self.assertEqual(first["sha256"], second["sha256"]) - self.assertEqual(first["size"], len(b"vidxp-test-video")) - self.assertEqual(first["path"], str(video_path.resolve())) - - def test_status_write_replaces_previous_payload(self): - with tempfile.TemporaryDirectory() as directory: - write_index_status( - state="failed", - stage="audio", - message="Failed.", - error="example", - index_directory=directory, - ) - write_index_status( - state="ready", - stage="complete", - message="Ready.", - index_directory=directory, - ) - - status_path = Path(directory) / "index_status.json" - payload = json.loads(status_path.read_text(encoding="utf-8")) - self.assertEqual(payload["state"], "ready") - self.assertNotIn("error", payload) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_index_worker.py b/tests/test_index_worker.py deleted file mode 100644 index aeb1a84..0000000 --- a/tests/test_index_worker.py +++ /dev/null @@ -1,150 +0,0 @@ -import unittest -from pathlib import Path -from unittest.mock import Mock, patch - -from vidxp import index_worker -from vidxp.index_state import IndexingInProgressError - - -class FakeProcess: - def __init__(self, **options): - self.options = options - self.alive = False - - def start(self): - self.alive = True - - def is_alive(self): - return self.alive - - -class FakeEvent: - def __init__(self): - self.set_called = False - - def set(self): - self.set_called = True - - def is_set(self): - return self.set_called - - -class FakeContext: - def __init__(self): - self.process = None - self.event = FakeEvent() - - def Event(self): - return self.event - - def Process(self, **options): - self.process = FakeProcess(**options) - return self.process - - -class IndexWorkerTests(unittest.TestCase): - def setUp(self): - index_worker._process = None - index_worker._cancel_event = None - self.service = Mock() - self.service.index_directory = Path("selected-index") - self.service.device = "cuda" - self.service.indexing_in_progress.return_value = False - - def tearDown(self): - index_worker._process = None - index_worker._cancel_event = None - - def test_worker_starts_indexing_in_a_separate_process(self): - context = FakeContext() - - with patch.object(index_worker, "get_context", return_value=context): - index_worker.start_indexing( - "video.mp4", - "source.mp4", - self.service, - modalities=("scene",), - ) - - self.assertTrue(context.process.is_alive()) - self.assertEqual(context.process.options["name"], "vidxp-indexer") - self.assertTrue(context.process.options["daemon"]) - self.assertEqual( - context.process.options["args"], - ( - "video.mp4", - "source.mp4", - context.event, - "selected-index", - "cuda", - ("scene",), - ), - ) - self.assertIs(context.process.options["target"], index_worker._run_indexing) - - def test_worker_process_reconstructs_the_selected_service(self): - service = Mock() - with patch.object( - index_worker, - "VidXPService", - return_value=service, - ) as service_type: - index_worker._run_indexing( - "video.mp4", - "source.mp4", - FakeEvent(), - "selected-index", - "cuda", - ("scene",), - ) - - service_type.assert_called_once_with("selected-index", device="cuda") - service.create_index.assert_called_once() - self.assertEqual( - service.create_index.call_args.kwargs["source_name"], - "source.mp4", - ) - self.assertEqual( - service.create_index.call_args.kwargs["modalities"], - ("scene",), - ) - - def test_worker_rejects_a_second_indexing_run(self): - context = FakeContext() - - with patch.object(index_worker, "get_context", return_value=context): - index_worker.start_indexing( - "video.mp4", - "source.mp4", - self.service, - modalities=("scene",), - ) - with self.assertRaises(IndexingInProgressError): - index_worker.start_indexing( - "video.mp4", - "source.mp4", - self.service, - modalities=("scene",), - ) - - def test_existing_service_run_remains_visible(self): - self.service.indexing_in_progress.return_value = True - - self.assertTrue(index_worker.indexing_in_progress(self.service)) - - def test_cancellation_requests_are_cooperative(self): - context = FakeContext() - with patch.object(index_worker, "get_context", return_value=context): - index_worker.start_indexing( - "video.mp4", - "source.mp4", - self.service, - modalities=("scene",), - ) - - self.assertTrue(index_worker.cancel_indexing()) - self.assertTrue(context.event.set_called) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_indexing.py b/tests/test_indexing.py index b8ebb7d..91766c1 100644 --- a/tests/test_indexing.py +++ b/tests/test_indexing.py @@ -1,137 +1,121 @@ -import sys -import types import unittest +from contextlib import nullcontext +import sys +from types import SimpleNamespace from unittest.mock import Mock, patch import numpy as np -from vidxp.capabilities import visual as indexing_visual_module +from vidxp.capabilities.actor.indexing import ( + _actor_cluster_id, + _actor_cluster_records, + _actor_records, +) +from vidxp.capabilities.contracts import ( + CapabilityDefinition, + CapabilityExecutor, + CapabilityPlugin, +) from vidxp.capabilities.dialogue.indexing import ( build_dialogue_phrases, index_dialogue, + transcribe_video, +) +from vidxp.capabilities.scene.indexing import ( + encode_scene_batch, + scene_records, + scene_sampling, +) +from vidxp.capabilities.scene.models import SceneModel +from vidxp.capabilities.registry import ( + CapabilityRegistry, + create_capability_registry, ) from vidxp.capabilities.visual import index_visuals -from vidxp.core.contracts import CancellationToken, IndexConfig, VideoSource +from vidxp.core.contracts import ( + CancellationToken, + IndexConfig, + VideoSource, +) from vidxp.core.video import FrameSample, VideoInfo +from vidxp.runtime import ModelRuntime +from vidxp.settings import VidXPSettings class CapturingStorage: def __init__(self): self.calls = [] - self.deleted_actor_clusters = [] def upsert(self, modality, records, **options): - self.calls.append((modality, list(records), options)) + records = list(records) + self.calls.append((modality, records, options)) return len(records) - def delete_records(self, modality, *, video_id, filters): - self.deleted_actor_clusters.append( - (modality, video_id, filters["cluster_id"]) - ) - class FakeEncoder: def __init__(self): self.batches = [] - def encode(self, texts, **_): + def encode_document(self, texts, **_): self.batches.append(list(texts)) - return np.asarray([[float(index), 1.0] for index, _ in enumerate(texts)]) - - -class FakeClipModel: - def __init__(self): - self.batch_sizes = [] - - def encode_image(self, images): - import torch - - self.batch_sizes.append(images.shape[0]) - return torch.ones((images.shape[0], 2), dtype=torch.float32) - - -class IndexingTests(unittest.TestCase): - def test_shared_visual_stream_accepts_a_registered_processor(self): - frame = np.zeros((2, 2, 3), dtype=np.uint8) - info = VideoInfo( - fps=10.0, - frame_count=1, - duration=0.1, - width=2, - height=2, + return np.asarray( + [[float(index), 1.0] for index, _ in enumerate(texts)] ) - processor = Mock() - processor.batch_size.return_value = 1 - processor.prepare.return_value = object() - processor.finalize.return_value = ({"ocr_frames": 1}, 1) - def stream(*_, **options): - options["stats"].frames_advanced = 1 - options["stats"].frames_materialized = 1 - return iter([[FrameSample(0, 0.0, frame)]]) - config = IndexConfig( - video_id="video-1", - enabled_modalities=("ocr",), - collection_names={"ocr": "ocr"}, - ) - with ( - patch( - "vidxp.capabilities.registry.get_capability", - return_value=Mock(index_processor=processor), - ), - patch( - "vidxp.capabilities.visual.probe_video", - return_value=info, - ), - patch( - "vidxp.capabilities.visual.iter_frame_batches", - side_effect=stream, - ), - patch( - "vidxp.capabilities.visual._rgb_samples", - side_effect=lambda samples: samples, - ), - ): - result = index_visuals( - VideoSource(path="unused.mp4"), - config=config, - storage=CapturingStorage(), - cancellation=CancellationToken(), +class IndexingTests(unittest.TestCase): + def runtime(self) -> ModelRuntime: + return ModelRuntime( + VidXPSettings( + repository_root="unused", + runtime_backend="cpu", ) + ) - processor.prepare.assert_called_once() - processor.process.assert_called_once() - processor.finalize.assert_called_once() - self.assertEqual(result.summary["ocr_frames"], 1) - self.assertEqual(result.summary["frame_operations"], 1) - - def test_timestamped_words_and_segments_keep_real_intervals(self): + def test_word_timestamps_are_grouped_without_interpolation(self): phrases = build_dialogue_phrases( [ { + "text": "ignored aggregate text", + "start": 0.0, + "end": 4.0, "words": [ - {"word": "one", "start": 0.0, "end": 0.4}, - {"word": "two", "start": 0.5, "end": 0.9}, - {"word": "three", "start": 1.0, "end": 1.4}, - ] - }, - {"text": "released ASR span", "start": 2.0, "end": 4.0}, + {"word": "one", "start": 0.1, "end": 0.5}, + {"word": "two", "start": 0.6, "end": 1.0}, + {"word": "three", "start": 1.2, "end": 1.8}, + ], + } ], words_per_phrase=2, ) self.assertEqual( [(item.text, item.start, item.end) for item in phrases], - [ - ("one two", 0.0, 0.9), - ("three", 1.0, 1.4), - ("released ASR", 2.0, 2.0 + 4.0 / 3.0), - ("span", 2.0 + 4.0 / 3.0, 4.0), - ], + [("one two", 0.1, 1.0), ("three", 1.2, 1.8)], + ) + + def test_siglip2_uses_transformers_five_pooled_image_output(self): + import torch + + processor = Mock( + return_value={"pixel_values": torch.ones((1, 3, 2, 2))} + ) + model = Mock() + model.get_image_features.return_value = SimpleNamespace( + pooler_output=torch.tensor([[3.0, 4.0]]) ) + provider = SceneModel(model=model, processor=processor, device="cpu") + sample = FrameSample( + frame_index=0, + timestamp=0.0, + frame=np.zeros((2, 2, 3), dtype=np.uint8), + ) + + vectors = encode_scene_batch([sample], provider) + + self.assertEqual(vectors, [[0.6000000238418579, 0.800000011920929]]) - def test_segment_text_is_rechunked_with_interpolated_timestamps(self): + def test_segment_text_uses_interpolated_timestamps_as_fallback(self): phrases = build_dialogue_phrases( [ { @@ -151,7 +135,7 @@ def test_segment_text_is_rechunked_with_interpolated_timestamps(self): ], ) - def test_supplied_transcript_is_batched_without_video_or_whisper(self): + def test_transcript_indexing_batches_without_transcription(self): config = IndexConfig( dataset="hirest", split="test", @@ -179,7 +163,7 @@ def test_supplied_transcript_is_batched_without_video_or_whisper(self): ), patch( "vidxp.capabilities.dialogue.indexing.transcribe_video", - side_effect=AssertionError("video/Whisper path was used"), + side_effect=AssertionError("transcription was used"), ), ): stats = index_dialogue( @@ -187,270 +171,331 @@ def test_supplied_transcript_is_batched_without_video_or_whisper(self): config=config, storage=storage, cancellation=CancellationToken(), + runtime=self.runtime(), ) self.assertEqual([len(batch) for batch in encoder.batches], [2, 1]) self.assertEqual(stats["dialogue_phrases"], 3) - records = [ - record - for _, group, _ in storage.calls - for record in group - ] - self.assertEqual(len({record.source_id for record in records}), 3) - self.assertEqual( - set(records[0].metadata), + + def test_silent_video_skips_dialogue_before_loading_whisper(self): + events = [] + fake_av = SimpleNamespace( + open=Mock( + return_value=nullcontext( + SimpleNamespace( + streams=SimpleNamespace(audio=()), + ) + ) + ) + ) + pipeline = Mock(side_effect=AssertionError("whisper was loaded")) + fake_whisper = SimpleNamespace(BatchedInferencePipeline=pipeline) + + with patch.dict( + sys.modules, { - "dataset", - "split", - "run_id", - "video_id", - "modality", - "source_id", - "phrase_id", - "text", - "start", - "end", + "av": fake_av, + "faster_whisper": fake_whisper, }, - ) + ): + segments, language = transcribe_video( + "silent.mp4", + config=IndexConfig( + video_id="video-1", + enabled_modalities=("dialogue",), + ), + cancellation=CancellationToken(), + runtime=self.runtime(), + progress=events.append, + ) - def test_scene_model_and_storage_writes_are_batched_with_full_metadata(self): - import torch + self.assertEqual(segments, []) + self.assertIsNone(language) + self.assertEqual(events[-1]["stage"], "dialogue_skipped") + pipeline.assert_not_called() + def test_visual_stream_uses_registered_processor_without_name_switches(self): + processor = Mock() + processor.batch_size.return_value = 1 + processor.prepare.return_value = object() + processor.finalize.return_value = ({"ocr_frames": 1}, 1) + definition = CapabilityDefinition( + name="ocr", + description="OCR.", + extra="ocr", + collection_name="ocr", + index_stage="visual_indexing", + execution_group="visual", + ) + registry = CapabilityRegistry( + ( + CapabilityPlugin( + definition=definition, + executor_factory=lambda: CapabilityExecutor( + indexer=index_visuals, + index_processor=processor, + ), + ), + ) + ) config = IndexConfig( - dataset="didemo", - split="test", - run_id="stride-2", video_id="video-1", - enabled_modalities=("scene",), - frame_stride=2, - capability_options={"scene": {"batch_size": 2}}, + enabled_modalities=("ocr",), + collection_names={"ocr": "ocr"}, ) - source = VideoSource(video_id="video-1", path="unused.mp4") - storage = CapturingStorage() - model = FakeClipModel() + info = VideoInfo(10.0, 1, 0.1, 2, 2) frame = np.zeros((2, 2, 3), dtype=np.uint8) - batches = [ - [ - FrameSample(0, 0.0, frame), - FrameSample(2, 0.2, frame), - ], - [FrameSample(4, 0.4, frame)], - ] - info = VideoInfo( - fps=10.0, - frame_count=5, - duration=0.5, - width=2, - height=2, - ) + + def stream(*_, **options): + options["stats"].frames_advanced = 1 + options["stats"].frames_materialized = 1 + return iter([[FrameSample(0, 0.0, frame)]]) with ( - patch("vidxp.capabilities.visual.probe_video", return_value=info), patch( - "vidxp.capabilities.visual.iter_frame_batches", - return_value=iter(batches), + "vidxp.capabilities.visual.probe_video", + return_value=info, ), patch( - "vidxp.capabilities.scene.indexing.get_clip_model", - return_value=( - model, - lambda _: torch.ones((3, 2, 2), dtype=torch.float32), - ), + "vidxp.capabilities.visual.iter_frame_batches", + side_effect=stream, ), ): - stats = index_visuals( - source, + result = index_visuals( + VideoSource(video_id="video-1", path="unused.mp4"), config=config, - storage=storage, + storage=CapturingStorage(), cancellation=CancellationToken(), - modalities=("scene",), - ).summary - - self.assertEqual(model.batch_sizes, [2, 1]) - self.assertEqual(stats["scene_frames"], 3) - records = [ - record - for _, group, _ in storage.calls - for record in group - ] - self.assertEqual( - [record.metadata["frame_index"] for record in records], - [0, 2, 4], - ) - self.assertEqual(records[-1].metadata["end"], 0.5) - self.assertEqual(records[0].metadata["fps"], 10.0) - self.assertEqual(records[0].metadata["duration"], 0.5) + registry=registry, + runtime=self.runtime(), + ) - def test_actor_only_records_have_stable_detection_metadata(self): + self.assertEqual(result.summary["ocr_frames"], 1) + processor.prepare.assert_called_once() + processor.process.assert_called_once() + + def test_scene_sampling_uses_target_fps_and_media_timestamps(self): config = IndexConfig( - dataset="sample", - split="test", - run_id="actors", video_id="video-1", - enabled_modalities=("actor",), - capability_options={"actor": {"minimum_detections": 2}}, - storage_batch_size=2, + enabled_modalities=("scene",), + capability_options={"scene": {"sample_fps": 1.0}}, ) - source = VideoSource(video_id="video-1", path="unused.mp4") - storage = CapturingStorage() - info = VideoInfo( - fps=10.0, - frame_count=4, - duration=0.4, - width=2, - height=2, + + near_below = scene_sampling( + config, + VideoInfo(1.49, 149, 100.0, 2, 2), ) - frame = np.zeros((2, 2, 3), dtype=np.uint8) - batches = [[ - FrameSample(0, 0.0, frame), - FrameSample(2, 0.2, frame), - ]] - fake_faces = types.SimpleNamespace( - face_locations=lambda _: [(1, 2, 3, 0)], - face_encodings=lambda *_args, **_kwargs: [ - np.asarray([1.0, 0.0]) - ], - face_distance=lambda known, _: np.asarray( - [0.0 for _ in known] - ), + near_above = scene_sampling( + config, + VideoInfo(1.5, 150, 100.0, 2, 2), + ) + below_indices = [ + index + for index in range(149) + if near_below.includes(index, index / 1.49) + ] + above_indices = [ + index + for index in range(150) + if near_above.includes(index, index / 1.5) + ] + + self.assertEqual(below_indices[:4], [0, 2, 3, 5]) + self.assertEqual(above_indices[:4], [0, 2, 3, 5]) + self.assertEqual(len(below_indices), 100) + self.assertEqual(len(above_indices), 100) + + low_fps = scene_sampling( + config, + VideoInfo(0.5, 5, 10.0, 2, 2), ) - with ( - patch("vidxp.capabilities.visual.probe_video", return_value=info), - patch( - "vidxp.capabilities.visual.iter_frame_batches", - return_value=iter(batches), - ), - patch.dict(sys.modules, {"face_recognition": fake_faces}), - ): - stats = index_visuals( - source, - config=config, - storage=storage, - cancellation=CancellationToken(), - modalities=("actor",), - ).summary - - self.assertEqual(stats["actor_frames"], 2) - self.assertEqual(stats["actor_detections"], 2) - modality, records, options = storage.calls[0] - self.assertEqual(modality, "actor") - self.assertEqual(options["batch_size"], 2) self.assertEqual( - [record.metadata["detection_id"] for record in records], [ - "d000000000000-0000", - "d000000000002-0000", + index + for index in range(5) + if low_fps.includes(index, index / 0.5) ], + [0, 1, 2, 3, 4], ) - self.assertEqual( - set(records[0].metadata), - { - "dataset", - "split", - "run_id", - "video_id", - "modality", - "source_id", - "detection_id", - "cluster_id", - "frame_index", - "timestamp", - "bbox_top", - "bbox_right", - "bbox_bottom", - "bbox_left", - }, + + def test_scene_record_duration_uses_scene_cadence_not_actor_stride(self): + config = IndexConfig( + video_id="video-1", + enabled_modalities=("scene",), + frame_stride=3, + capability_options={"scene": {"sample_fps": 2.0}}, ) + info = VideoInfo(30.0, 300, 10.0, 2, 2) - def test_scene_and_actor_share_one_probe_and_frame_stream(self): - import torch + record = scene_records( + [FrameSample(15, 0.5, object())], + [[0.1, 0.2]], + info, + config, + )[0] + + self.assertEqual(record.metadata["start"], 0.5) + self.assertEqual(record.metadata["end"], 1.0) + def test_scene_records_end_at_next_selected_sample_without_gaps(self): config = IndexConfig( - dataset="sample", - split="test", - run_id="shared", video_id="video-1", - enabled_modalities=("scene", "actor"), - frame_stride=2, - capability_options={ - "scene": {"batch_size": 2}, - "actor": { - "batch_size": 1, - "minimum_detections": 1, - }, - }, + enabled_modalities=("scene",), + capability_options={"scene": {"sample_fps": 1.0}}, ) - source = VideoSource(video_id="video-1", path="unused.mp4") - storage = CapturingStorage() - model = FakeClipModel() - frame = np.zeros((2, 2, 3), dtype=np.uint8) - batches = [ + info = VideoInfo(1.49, 5, 5 / 1.49, 2, 2) + + records = scene_records( [ - FrameSample(0, 0.0, frame), - FrameSample(2, 0.2, frame), + FrameSample(0, 0.0, object()), + FrameSample(2, 2 / 1.49, object()), + FrameSample(3, 3 / 1.49, object()), ], - [FrameSample(4, 0.4, frame)], - ] - info = VideoInfo( - fps=10.0, - frame_count=5, - duration=0.5, - width=2, - height=2, + [[0.1], [0.2], [0.3]], + info, + config, ) - frame_stream = Mock() + self.assertEqual(records[0].metadata["end"], 2 / 1.49) + self.assertEqual(records[1].metadata["end"], 3 / 1.49) + self.assertEqual(records[2].metadata["end"], info.duration) + + def test_scene_and_actor_share_decode_but_keep_independent_cadences(self): + registry = create_capability_registry() + scene = registry.executor("scene").index_processor + actor = registry.executor("actor").index_processor + scene.prepare = Mock(return_value=object()) + actor.prepare = Mock(return_value=object()) + scene.process = Mock() + actor.process = Mock() + scene.batch_size = Mock(return_value=2) + actor.batch_size = Mock(return_value=1) + scene.finalize = Mock(return_value=({"scene_frames": 1}, 1)) + actor.finalize = Mock( + return_value=( + {"actor_frames": 2, "actor_detections": 0, "actor_clusters": 0}, + 2, + ) + ) + info = VideoInfo(10.0, 2, 0.2, 2, 2) + frame = np.zeros((2, 2, 3), dtype=np.uint8) def stream(*_, **options): - options["stats"].frames_advanced = 5 - options["stats"].frames_materialized = 3 - return iter(batches) - - frame_stream.side_effect = stream - - def consume_actor(samples, *, state, **_): - state.processed_frames += len(samples) + options["stats"].frames_advanced = 2 + options["stats"].frames_materialized = 2 + return iter( + [[FrameSample(0, 0.0, frame), FrameSample(1, 0.1, frame)]] + ) + frame_stream = Mock(side_effect=stream) + config = IndexConfig( + video_id="video-1", + enabled_modalities=("scene", "actor"), + ) with ( patch( "vidxp.capabilities.visual.probe_video", return_value=info, - ) as probe, + ), patch( "vidxp.capabilities.visual.iter_frame_batches", frame_stream, ), - patch( - "vidxp.capabilities.scene.indexing.get_clip_model", - return_value=( - model, - lambda _: torch.ones((3, 2, 2), dtype=torch.float32), - ), - ), - patch( - "vidxp.capabilities.actor.indexing.process_actor_samples", - side_effect=consume_actor, - ) as actor_consumer, - patch( - "vidxp.capabilities.visual._rgb_samples", - wraps=indexing_visual_module._rgb_samples, - ) as rgb_conversion, ): result = index_visuals( - source, + VideoSource(video_id="video-1", path="unused.mp4"), config=config, - storage=storage, + storage=CapturingStorage(), cancellation=CancellationToken(), + registry=registry, + runtime=self.runtime(), ) - probe.assert_called_once_with("unused.mp4") frame_stream.assert_called_once() - self.assertEqual(frame_stream.call_args.kwargs["batch_size"], 2) - self.assertEqual(model.batch_sizes, [2, 1]) - self.assertEqual(actor_consumer.call_count, 2) - self.assertEqual(rgb_conversion.call_count, 2) - self.assertEqual(result.summary["source_frames_advanced"], 5) - self.assertEqual(result.summary["sampled_frames"], 3) - self.assertEqual(result.summary["frame_operations"], 6) + samplings = frame_stream.call_args.kwargs["samplings"] + self.assertEqual(samplings[0].target_fps, 1.0) + self.assertEqual(samplings[1].frame_stride, 1) + self.assertEqual( + [sample.frame_index for sample in scene.process.call_args.args[0]], + [0], + ) + self.assertEqual( + [sample.frame_index for sample in actor.process.call_args.args[0]], + [0, 1], + ) + self.assertEqual(result.summary["processed_frames"], 2) + self.assertEqual(result.summary["scene_frames"], 1) + self.assertEqual(result.summary["actor_frames"], 2) + + def test_actor_records_preserve_stable_detection_metadata(self): + config = IndexConfig( + dataset="sample", + split="test", + run_id="actors", + video_id="video-1", + generation_id="generation-1", + enabled_modalities=("actor",), + ) + cluster_id = _actor_cluster_id(config, 1) + records = _actor_records( + [ + { + "detection_id": "d000000000000-0000", + "cluster_id": cluster_id, + "frame_index": 0, + "timestamp": 0.0, + "bbox": (1, 2, 3, 0), + } + ], + config, + ) + + self.assertEqual(records[0].metadata["cluster_id"], cluster_id) + self.assertEqual( + cluster_id, + "generation-1:actors:video-1:actor-cluster:1", + ) + self.assertEqual(records[0].metadata["bbox_top"], 1) + + def test_actor_cluster_identity_is_unique_by_media_and_generation(self): + def config(video_id, generation_id): + return IndexConfig( + run_id="actors", + video_id=video_id, + generation_id=generation_id, + enabled_modalities=("actor",), + ) + + identities = { + _actor_cluster_id(config("video-1", "generation-1"), 1), + _actor_cluster_id(config("video-2", "generation-1"), 1), + _actor_cluster_id(config("video-1", "generation-2"), 1), + } + + self.assertEqual(len(identities), 3) + + def test_actor_cluster_summaries_are_materialized_for_bounded_paging(self): + config = IndexConfig( + run_id="actors", + video_id="video-1", + generation_id="generation-1", + enabled_modalities=("actor",), + ) + + records = _actor_cluster_records( + {"cluster-1": 3}, + {"cluster-1": (1.25, 4.5)}, + config, + ) + + self.assertEqual(len(records), 1) + self.assertEqual(records[0].metadata["record_kind"], "cluster_summary") + self.assertEqual( + records[0].metadata["summary_cluster_id"], + "cluster-1", + ) + self.assertEqual(records[0].metadata["detection_count"], 3) + self.assertEqual(records[0].metadata["first_timestamp"], 1.25) + self.assertEqual(records[0].metadata["last_timestamp"], 4.5) if __name__ == "__main__": diff --git a/tests/test_job_contracts.py b/tests/test_job_contracts.py new file mode 100644 index 0000000..817f05f --- /dev/null +++ b/tests/test_job_contracts.py @@ -0,0 +1,308 @@ +import json +import unittest +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import Mock + +from pydantic import ValidationError + +from vidxp.application_models import ( + ActorOverlayJobRequest, + ApplicationError, + CreateActorOverlayCommand, + CreateIndexCommand, + Job, + JobKind, + JobProgress, + JobQueue, + JobState, + InvalidRequestError, + IndexSnapshotReference, + ListJobsCommand, + QueryJobRequest, + QueryVideoCommand, + SearchCommand, + SearchJobRequest, +) +from vidxp.core.storage import BUNDLED_CHROMA_SERVER_URL +from vidxp.job_service import JobService +from vidxp.ports import InvalidJobBackendRequestError +from vidxp.execution import ExecutionContext +from vidxp.composition import _server_chroma_url +from vidxp.settings import ApplicationMode, VidXPSettings +from vidxp.workflow_runtime import ( + BUNDLED_POSTGRES_DATABASE_URL, + workflow_database_url, +) + + +MEDIA_ID = "123456781234423481234567890abcde" +JOB_ID = "223456781234423481234567890abcde" +SNAPSHOT_ID = "323456781234423481234567890abcde" +SNAPSHOT_SHA256 = "a" * 64 + + +class JobContractTests(unittest.TestCase): + def test_progress_is_typed_and_position_is_bounded(self): + progress = JobProgress( + stage="frames", + message="Indexing frames.", + current=2, + total=3, + updated_at=datetime.now(timezone.utc), + ) + + self.assertEqual(progress.schema_version, 1) + with self.assertRaises(ValidationError): + JobProgress( + stage="frames", + message="Invalid.", + current=4, + total=3, + updated_at=datetime.now(timezone.utc), + ) + + def test_public_job_contract_has_no_path_or_storage_fields(self): + job = Job( + job_id=JOB_ID, + kind=JobKind.index, + state=JobState.queued, + queue=JobQueue.cpu, + ) + schema = json.dumps(Job.model_json_schema()) + self.assertEqual(job.schema_version, 2) + self.assertNotIn("storage_key", schema) + self.assertNotIn('"path"', schema) + self.assertNotIn("model_cache", schema) + + def test_job_service_routes_model_work_without_reimplementing_it(self): + backend = Mock() + backend.submit.return_value = Job( + job_id=JOB_ID, + kind=JobKind.index, + state=JobState.queued, + queue=JobQueue.cpu, + ) + service = JobService( + settings=VidXPSettings( + repository_root=Path("repository"), + runtime_backend="cpu", + ), + backend=backend, + ) + + job = service.submit_index( + CreateIndexCommand( + media_id=MEDIA_ID, + modalities=("scene",), + ) + ) + + self.assertEqual(job.job_id, JOB_ID) + request = backend.submit.call_args.args[0] + self.assertEqual(request.kind, JobKind.index) + self.assertEqual(request.command.media_id, MEDIA_ID) + self.assertEqual( + backend.submit.call_args.kwargs["queue"], + JobQueue.cpu, + ) + + def test_job_list_cursor_is_bounded(self): + with self.assertRaises(ValidationError): + ListJobsCommand(cursor="x" * 513) + + def test_search_uses_the_same_model_worker_queue(self): + backend = Mock() + planner = Mock() + command = SearchCommand( + modalities=("scene",), + query="taxi", + top_k=2, + ) + planner.plan_search.return_value = SearchJobRequest( + command=command, + snapshot=IndexSnapshotReference( + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, + ), + ) + backend.submit.return_value = Job( + job_id=JOB_ID, + kind=JobKind.search, + state=JobState.queued, + queue=JobQueue.gpu, + ) + service = JobService( + settings=VidXPSettings( + repository_root=Path("repository"), + runtime_backend="cuda:0", + ), + backend=backend, + read_planner=planner, + ) + + service.submit_search(command) + + request = backend.submit.call_args.args[0] + self.assertEqual(request.kind, JobKind.search) + self.assertEqual(request.snapshot.snapshot_id, SNAPSHOT_ID) + planner.plan_search.assert_called_once_with(command) + self.assertEqual( + backend.submit.call_args.kwargs["queue"], + JobQueue.gpu, + ) + + def test_query_uses_the_same_pinned_model_worker_boundary(self): + backend = Mock() + planner = Mock() + command = QueryVideoCommand(question="What happens next?") + planner.plan_query.return_value = QueryJobRequest( + command=command, + snapshot=IndexSnapshotReference( + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, + ), + ) + backend.submit.return_value = Job( + job_id=JOB_ID, + kind=JobKind.query, + state=JobState.queued, + queue=JobQueue.gpu, + ) + service = JobService( + settings=VidXPSettings( + repository_root=Path("repository"), + runtime_backend="cuda:0", + ), + backend=backend, + read_planner=planner, + ) + + service.submit_query(command) + + request = backend.submit.call_args.args[0] + self.assertEqual(request.kind, JobKind.query) + self.assertEqual(request.snapshot.snapshot_id, SNAPSHOT_ID) + planner.plan_query.assert_called_once_with(command) + self.assertEqual( + backend.submit.call_args.kwargs["queue"], + JobQueue.gpu, + ) + + def test_actor_job_retains_pinned_snapshot_identity(self): + backend = Mock() + planner = Mock() + command = CreateActorOverlayCommand(cluster_id="actor-1") + planner.plan_actor_overlay.return_value = ActorOverlayJobRequest( + command=command, + snapshot=IndexSnapshotReference( + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, + ), + ) + backend.submit.return_value = Job( + job_id=JOB_ID, + kind=JobKind.actor_overlay, + state=JobState.queued, + queue=JobQueue.cpu, + ) + service = JobService( + settings=VidXPSettings( + repository_root=Path("repository"), + runtime_backend="cpu", + ), + backend=backend, + read_planner=planner, + ) + + service.submit_actor_overlay(command) + + request = backend.submit.call_args.args[0] + self.assertEqual(request.snapshot.snapshot_id, SNAPSHOT_ID) + planner.plan_actor_overlay.assert_called_once_with(command) + + def test_search_and_actor_identifiers_are_bounded(self): + command = SearchCommand( + modalities=(" scene ",), + query=" a chef prepares pizza ", + ) + self.assertEqual(command.modalities, ("scene",)) + self.assertEqual(command.query, "a chef prepares pizza") + + for values in ( + {"modalities": ["scene/video"], "query": "taxi"}, + {"modalities": ["scene"], "query": "x" * 4097}, + {"modalities": ["scene"], "query": " "}, + ): + with self.assertRaises(ValidationError): + SearchCommand(**values) + + with self.assertRaises(ValidationError): + CreateActorOverlayCommand(cluster_id="../../video") + encoded_cluster = CreateActorOverlayCommand( + cluster_id=( + "generation:run%20name:media:actor-cluster:1" + ) + ) + self.assertIn("%20", encoded_cluster.cluster_id) + + def test_canonical_dbos_job_id_has_a_hex_operation_identity(self): + execution = ExecutionContext( + job_id="22345678-1234-4234-8123-4567890abcde" + ) + + self.assertEqual(execution.operation_id, JOB_ID) + + def test_local_storage_uses_repository_services(self): + settings = VidXPSettings( + repository_root=Path("repository"), + ) + + self.assertEqual( + workflow_database_url(settings), + ( + "sqlite:///" + f"{settings.layout.workflow_database.resolve().as_posix()}" + ), + ) + self.assertIsNone(_server_chroma_url(settings)) + + def test_server_uses_bundled_storage_services(self): + settings = VidXPSettings( + mode=ApplicationMode.server, + runtime_backend="cpu", + ) + + self.assertEqual( + workflow_database_url(settings), + BUNDLED_POSTGRES_DATABASE_URL, + ) + self.assertEqual( + _server_chroma_url(settings), + BUNDLED_CHROMA_SERVER_URL, + ) + + def test_job_backend_errors_are_normalized_for_every_adapter(self): + backend = Mock() + service = JobService( + settings=VidXPSettings( + repository_root=Path("repository"), + runtime_backend="cpu", + ), + backend=backend, + ) + backend.get.side_effect = InvalidJobBackendRequestError( + "invalid workflow id" + ) + + with self.assertRaises(InvalidRequestError): + service.get("invalid") + + backend.get.side_effect = RuntimeError("database unavailable") + with self.assertRaises(ApplicationError) as raised: + service.get(JOB_ID) + self.assertEqual(raised.exception.code, "job_backend_unavailable") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_local_snapshots.py b/tests/test_local_snapshots.py new file mode 100644 index 0000000..091060c --- /dev/null +++ b/tests/test_local_snapshots.py @@ -0,0 +1,798 @@ +import json +import unittest +from dataclasses import replace +from datetime import datetime, timezone +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch +from unittest.mock import MagicMock, Mock + +from vidxp.core.contracts import ( + CancellationToken, + INDEX_SCHEMA_VERSION, + MANIFEST_SCHEMA_VERSION, + IndexConfig, + IndexCancelledError, + IndexSchemaError, + StorageRecord, +) +from vidxp.core.storage import IndexStorage +from vidxp.core.manifest import MANIFEST_FILE, write_json_atomic +from vidxp.infrastructure.local_snapshots import LocalSnapshotRepository +from vidxp.infrastructure.local_index import LocalIndexBackend + + +class LocalSnapshotRepositoryTests(unittest.TestCase): + def setUp(self): + self.temporary = TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.repository = LocalSnapshotRepository( + Path(self.temporary.name) / "indexes" + ) + self.config = IndexConfig.local( + enabled_modalities=("scene",), + collection_names={"scene": "scene"}, + storage_directory=self.repository.store, + ) + + def generation( + self, + media_id: str, + *, + input_sha: str, + store_size_bytes_at_commit: int | None = 123, + ): + generation_id = self.repository.new_generation_id() + config = replace( + self.config, + video_id=media_id, + generation_id=generation_id, + generation_directory=self.repository.generation_directory( + generation_id + ), + ) + return config, self.write_generation( + config, + media_id=media_id, + input_sha=input_sha, + store_size_bytes_at_commit=store_size_bytes_at_commit, + ) + + def write_generation( + self, + config: IndexConfig, + *, + media_id: str, + input_sha: str, + record_counts: dict[str, int] | None = None, + store_size_bytes_at_commit: int | None = 123, + ): + now = datetime.now(timezone.utc).isoformat() + manifest = { + "manifest_schema_version": MANIFEST_SCHEMA_VERSION, + "index_schema_version": INDEX_SCHEMA_VERSION, + "dataset": config.dataset, + "split": config.split, + "run_id": config.run_id, + "generation_id": config.generation_id, + "state": "complete", + "created_at": now, + "updated_at": now, + "completed_at": now, + "config_fingerprint": config.fingerprint(), + "execution_fingerprint": "e" * 64, + "configuration": config.to_dict(), + "models": {}, + "git": {}, + "environment": {}, + "inputs": { + media_id: { + "sha256": input_sha, + "checksums": {"video": input_sha}, + "size": 1, + "source_name": f"{media_id}.mp4", + "path": None, + "metadata": {}, + } + }, + "videos": { + media_id: { + "state": "complete", + "started_at": now, + "stages": {}, + "completed_at": now, + "summary": {}, + } + }, + "completed_videos": [media_id], + "failed_videos": [], + "interrupted_videos": [], + "processed_frames": 0, + "record_counts": { + modality: (record_counts or {}).get(modality, 0) + for modality in config.enabled_modalities + }, + "store_size_bytes_at_commit": store_size_bytes_at_commit, + } + write_json_atomic( + config.run_directory / MANIFEST_FILE, + manifest, + ) + return self.repository.generation_reference( + generation_id=str(config.generation_id), + media_id=media_id, + ) + + def test_unknown_store_size_round_trips_through_snapshot_metadata(self): + config, reference = self.generation( + "a", + input_sha="a" * 64, + store_size_bytes_at_commit=None, + ) + + snapshot = self.repository.publish_generation(reference, config) + + self.assertIsNone(reference.store_size_bytes_at_commit) + self.assertIsNone( + snapshot.generations["a"].store_size_bytes_at_commit + ) + + def test_add_reindex_remove_and_clear_publish_immutable_snapshots(self): + config_a1, a1 = self.generation("a", input_sha="a" * 64) + snapshot_a1 = self.repository.publish_generation(a1, config_a1) + original_a1 = ( + self.repository.snapshots / f"{snapshot_a1.snapshot_id}.json" + ).read_bytes() + + config_b1, b1 = self.generation("b", input_sha="b" * 64) + snapshot_b1 = self.repository.publish_generation(b1, config_b1) + self.assertEqual( + set(snapshot_b1.generations), + {"a", "b"}, + ) + + config_a2, a2 = self.generation("a", input_sha="c" * 64) + snapshot_a2 = self.repository.publish_generation(a2, config_a2) + self.assertEqual( + snapshot_a2.generations["a"].generation_id, + a2.generation_id, + ) + self.assertEqual( + snapshot_a2.generations["b"].generation_id, + b1.generation_id, + ) + self.assertEqual( + ( + self.repository.snapshots + / f"{snapshot_a1.snapshot_id}.json" + ).read_bytes(), + original_a1, + ) + self.assertTrue( + self.repository.generation_directory(a1.generation_id).is_dir() + ) + + self.assertTrue(self.repository.remove("a")) + self.assertEqual( + set(self.repository.read_active(required=True).generations), + {"b"}, + ) + self.assertFalse(self.repository.remove("missing")) + self.assertTrue(self.repository.clear()) + self.assertEqual( + self.repository.status()["state"], + "empty", + ) + self.assertFalse(self.repository.clear()) + + def test_pointer_failure_preserves_previous_active_snapshot(self): + config_a1, a1 = self.generation("a", input_sha="a" * 64) + previous = self.repository.publish_generation(a1, config_a1) + pointer_before = self.repository.active_pointer.read_bytes() + config_a2, a2 = self.generation("a", input_sha="b" * 64) + + real_write = write_json_atomic + + def fail_pointer(path, payload): + if path == self.repository.active_pointer: + raise OSError("injected pointer failure") + return real_write(path, payload) + + with ( + patch( + "vidxp.infrastructure.local_snapshots.write_json_atomic", + side_effect=fail_pointer, + ), + self.assertRaisesRegex(OSError, "injected"), + ): + self.repository.publish_generation(a2, config_a2) + + self.assertEqual( + self.repository.active_pointer.read_bytes(), + pointer_before, + ) + self.assertEqual( + self.repository.read_active(required=True).snapshot_id, + previous.snapshot_id, + ) + self.assertTrue( + self.repository.generation_directory(a2.generation_id).is_dir() + ) + + def test_completed_job_replay_returns_committed_generation(self): + config, reference = self.generation("a", input_sha="a" * 64) + committed = self.repository.publish_generation(reference, config) + backend = LocalIndexBackend( + Mock(), + Mock(), + self.repository.layout, + ) + storage = MagicMock() + storage.__enter__.return_value = storage + build = IndexConfig.local( + video_id="a", + enabled_modalities=("scene",), + collection_names={"scene": "scene"}, + storage_directory=self.repository.indexes, + ) + + with ( + patch.object( + backend, + "_open_committed_storage", + return_value=storage, + ), + patch.object(backend, "_validate_snapshot_storage"), + patch( + "vidxp.infrastructure.local_index.index_video" + ) as index_video, + ): + result = backend.create( + Path("video.mp4"), + config=build, + progress=None, + cancellation=None, + source_name="video.mp4", + source_checksum="a" * 64, + operation_id=reference.generation_id, + ) + + self.assertEqual(result["generation_id"], reference.generation_id) + self.assertEqual(result["snapshot_id"], committed.snapshot_id) + index_video.assert_not_called() + + def test_completed_generation_replay_publishes_without_rebuilding(self): + config, reference = self.generation("a", input_sha="a" * 64) + backend = LocalIndexBackend( + Mock(), + Mock(), + self.repository.layout, + ) + build = IndexConfig.local( + video_id="a", + enabled_modalities=("scene",), + collection_names={"scene": "scene"}, + storage_directory=self.repository.indexes, + ) + + with patch( + "vidxp.infrastructure.local_index.index_video" + ) as index_video: + result = backend.create( + Path("video.mp4"), + config=build, + progress=None, + cancellation=None, + source_name="video.mp4", + source_checksum="a" * 64, + operation_id=reference.generation_id, + ) + + self.assertEqual(result["generation_id"], reference.generation_id) + self.assertEqual( + self.repository.read_active(required=True) + .generations["a"] + .generation_id, + reference.generation_id, + ) + index_video.assert_not_called() + + def test_replay_repairs_counts_after_crash_before_generation_validation(self): + config, reference = self.generation("a", input_sha="a" * 64) + manifest_path = ( + self.repository.generation_directory(reference.generation_id) + / MANIFEST_FILE + ) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest.pop("record_counts") + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + backend = LocalIndexBackend( + Mock(), + Mock(), + self.repository.layout, + ) + build = IndexConfig.local( + video_id="a", + enabled_modalities=("scene",), + collection_names={"scene": "scene"}, + storage_directory=self.repository.indexes, + ) + storage = MagicMock() + storage.__enter__.return_value = storage + storage.records.return_value = [ + { + "generation_id": reference.generation_id, + "video_id": "a", + "modality": "scene", + } + ] + + with ( + patch( + "vidxp.infrastructure.local_index.IndexStorage", + return_value=storage, + ), + patch( + "vidxp.infrastructure.local_index.index_video" + ) as index_video, + ): + result = backend.create( + Path("video.mp4"), + config=build, + progress=None, + cancellation=None, + source_name="video.mp4", + source_checksum="a" * 64, + operation_id=reference.generation_id, + ) + + self.assertEqual(result["record_counts"], {"scene": 1}) + repaired = json.loads(manifest_path.read_text(encoding="utf-8")) + self.assertEqual(repaired["record_counts"], {"scene": 1}) + index_video.assert_not_called() + + def test_corrupt_or_missing_generation_fails_closed(self): + config, reference = self.generation("a", input_sha="a" * 64) + self.repository.publish_generation(reference, config) + manifest = ( + self.repository.generation_directory(reference.generation_id) + / MANIFEST_FILE + ) + manifest.write_text("{}", encoding="utf-8") + + with self.assertRaisesRegex(IndexSchemaError, "integrity"): + self.repository.read_active(required=True) + + def test_repository_lease_is_scoped_to_one_repository(self): + other = LocalSnapshotRepository( + Path(self.temporary.name) / "other" / "indexes" + ) + with self.repository.lease(): + self.assertTrue(self.repository.mutation_in_progress()) + self.assertFalse(other.mutation_in_progress()) + + def test_snapshot_configuration_moves_between_runtime_devices(self): + config, reference = self.generation("a", input_sha="a" * 64) + snapshot = self.repository.publish_generation(reference, config) + + active, loaded = self.repository.active_config(device="cuda") + + self.assertEqual(loaded.snapshot_id, snapshot.snapshot_id) + self.assertEqual(active.device, "cuda") + self.assertEqual(len(active.snapshot_sha256), 64) + self.assertEqual( + active.fingerprint(), + snapshot.config_fingerprint, + ) + + def test_historical_snapshot_configuration_reopens_by_checksum(self): + config_a, reference_a = self.generation("a", input_sha="a" * 64) + snapshot_a = self.repository.publish_generation(reference_a, config_a) + active_a, _ = self.repository.active_config(device="cpu") + config_b, reference_b = self.generation("b", input_sha="b" * 64) + snapshot_b = self.repository.publish_generation(reference_b, config_b) + + historical = self.repository.config_for_snapshot( + snapshot_a.snapshot_id, + snapshot_sha256=active_a.snapshot_sha256, + device="cuda", + ) + + self.assertNotEqual(snapshot_a.snapshot_id, snapshot_b.snapshot_id) + self.assertEqual(historical.snapshot_id, snapshot_a.snapshot_id) + self.assertEqual( + historical.snapshot_sha256, + active_a.snapshot_sha256, + ) + self.assertEqual(historical.device, "cuda") + self.assertEqual( + historical.fingerprint(), + snapshot_a.config_fingerprint, + ) + + def test_profile_mismatch_is_rejected_before_replacing_other_media(self): + config_a, a = self.generation("a", input_sha="a" * 64) + self.repository.publish_generation(a, config_a) + config_b, b = self.generation("b", input_sha="b" * 64) + self.repository.publish_generation(b, config_b) + + changed = replace( + config_a, + frame_stride=2, + generation_id=self.repository.new_generation_id(), + ) + changed = replace( + changed, + generation_directory=self.repository.generation_directory( + changed.generation_id + ), + ) + changed_ref = self.write_generation( + changed, + media_id="a", + input_sha="c" * 64, + ) + with self.assertRaisesRegex(IndexSchemaError, "same index profile"): + self.repository.publish_generation(changed_ref, changed) + + def test_snapshot_documents_are_canonical_json_objects(self): + config, reference = self.generation("a", input_sha="a" * 64) + snapshot = self.repository.publish_generation(reference, config) + payload = json.loads( + ( + self.repository.snapshots / f"{snapshot.snapshot_id}.json" + ).read_text(encoding="utf-8") + ) + self.assertEqual(payload["snapshot_id"], snapshot.snapshot_id) + + def test_build_failure_or_cancellation_preserves_active_snapshot(self): + config, reference = self.generation("a", input_sha="a" * 64) + active = self.repository.publish_generation(reference, config) + pointer_before = self.repository.active_pointer.read_bytes() + backend = LocalIndexBackend( + Mock(), + Mock(), + self.repository.layout, + ) + build = IndexConfig.local( + video_id="b" * 64, + enabled_modalities=("scene",), + collection_names={"scene": "scene"}, + storage_directory=self.repository.indexes, + ) + + for failure in ( + RuntimeError("injected build failure"), + IndexCancelledError("injected cancellation"), + ): + storage = MagicMock() + storage.__enter__.return_value = storage + storage.count_records.return_value = 0 + + def fail_index(*_args, **kwargs): + write_json_atomic( + kwargs["config"].run_directory / MANIFEST_FILE, + {"state": "interrupted"}, + ) + raise failure + + with ( + self.subTest(failure=type(failure).__name__), + patch( + "vidxp.infrastructure.local_index.IndexStorage", + return_value=storage, + ), + patch( + "vidxp.infrastructure.local_index.index_video", + side_effect=fail_index, + ), + self.assertRaises(type(failure)), + ): + backend.create( + Path("video.mp4"), + config=build, + progress=None, + cancellation=None, + source_name=None, + source_checksum="b" * 64, + ) + + self.assertEqual( + self.repository.active_pointer.read_bytes(), + pointer_before, + ) + self.assertEqual( + self.repository.read_active(required=True).snapshot_id, + active.snapshot_id, + ) + retained = { + path.name + for path in self.repository.generations.iterdir() + if path.is_dir() + } + self.assertEqual(retained, {reference.generation_id}) + + def test_reader_can_finish_against_snapshot_resolved_before_reindex(self): + config_a1, a1 = self.generation("a", input_sha="a" * 64) + first = self.repository.publish_generation(a1, config_a1) + old_config, _ = self.repository.active_config(device="cpu") + config_a2, a2 = self.generation("a", input_sha="b" * 64) + self.repository.publish_generation(a2, config_a2) + backend = LocalIndexBackend( + Mock(), + Mock(), + self.repository.layout, + ) + storage = MagicMock() + storage.records.return_value = [] + storage.count_records.return_value = 0 + + with patch( + "vidxp.infrastructure.local_index.IndexStorage", + return_value=storage, + ): + scoped_store = backend.open_store(old_config) + storage.reset_mock() + with scoped_store as scoped: + scoped.records("scene") + + self.assertNotEqual( + self.repository.read_active(required=True).snapshot_id, + first.snapshot_id, + ) + storage.records.assert_called_once_with( + "scene", + video_id=None, + generation_ids=(a1.generation_id,), + filters=None, + ) + + def test_reader_rejects_tampered_resolved_snapshot(self): + config, reference = self.generation("a", input_sha="a" * 64) + self.repository.publish_generation(reference, config) + resolved, _ = self.repository.active_config(device="cpu") + snapshot_path = ( + self.repository.snapshots / f"{resolved.snapshot_id}.json" + ) + snapshot_path.write_text("{}\n", encoding="utf-8") + backend = LocalIndexBackend( + Mock(), + Mock(), + self.repository.layout, + ) + + with self.assertRaisesRegex(IndexSchemaError, "integrity"): + backend.open_store(resolved) + + def test_reader_fails_closed_after_committed_record_loss(self): + generation_id = self.repository.new_generation_id() + config = replace( + self.config, + video_id="a", + generation_id=generation_id, + generation_directory=self.repository.generation_directory( + generation_id + ), + ) + with IndexStorage(config) as storage: + storage.upsert( + "scene", + [ + StorageRecord( + source_id="source-1", + embedding=[1.0, 0.0], + metadata={ + **config.record_identity("scene", "source-1"), + }, + ) + ], + batch_size=1, + cancellation=CancellationToken(), + ) + reference = self.write_generation( + config, + media_id="a", + input_sha="a" * 64, + record_counts={"scene": 1}, + ) + self.repository.publish_generation(reference, config) + active, _ = self.repository.active_config(device="cpu") + runtime = Mock() + runtime.backends.torch_device = "cpu" + backend = LocalIndexBackend( + Mock(), + runtime, + self.repository.layout, + ) + + with backend.open_store(active): + pass + with IndexStorage(config) as storage: + storage.delete_generation(generation_id) + + with backend.open_store(active): + pass + with self.assertRaisesRegex(IndexSchemaError, "record counts"): + backend.status(self.repository.indexes) + + def test_cancellation_after_validation_does_not_publish(self): + config_a, reference_a = self.generation("a", input_sha="a" * 64) + previous = self.repository.publish_generation(reference_a, config_a) + pointer_before = self.repository.active_pointer.read_bytes() + runtime = Mock() + runtime.backends.torch_device = "cpu" + backend = LocalIndexBackend( + Mock(), + runtime, + self.repository.layout, + ) + build = IndexConfig.local( + video_id="b" * 64, + enabled_modalities=("scene",), + collection_names={"scene": "scene"}, + storage_directory=self.repository.indexes, + ) + storage = MagicMock() + storage.__enter__.return_value = storage + storage.records.return_value = [] + storage.count_records.return_value = 0 + cancellation = Mock() + cancellation.raise_if_cancelled.side_effect = ( + None, + None, + IndexCancelledError("late cancellation"), + ) + + def complete_index(*_args, **kwargs): + self.write_generation( + kwargs["config"], + media_id="b" * 64, + input_sha="b" * 64, + ) + return {} + + with ( + patch( + "vidxp.infrastructure.local_index.IndexStorage", + return_value=storage, + ), + patch( + "vidxp.infrastructure.local_index.index_video", + side_effect=complete_index, + ), + self.assertRaises(IndexCancelledError), + ): + backend.create( + Path("video.mp4"), + config=build, + progress=None, + cancellation=cancellation, + source_name=None, + source_checksum="b" * 64, + ) + + self.assertEqual( + self.repository.active_pointer.read_bytes(), + pointer_before, + ) + self.assertEqual( + self.repository.read_active(required=True).snapshot_id, + previous.snapshot_id, + ) + + def test_abandoned_generation_directory_and_records_are_cleaned(self): + generation_id = self.repository.new_generation_id() + generation_directory = self.repository.generation_directory( + generation_id + ) + write_json_atomic( + generation_directory / MANIFEST_FILE, + {"state": "running", "generation_id": generation_id}, + ) + config = IndexConfig.local( + video_id="a", + generation_id=generation_id, + generation_directory=generation_directory, + enabled_modalities=("scene",), + collection_names={"scene": "scene"}, + storage_directory=self.repository.store, + ) + with IndexStorage(config) as storage: + storage.upsert( + "scene", + [ + StorageRecord( + source_id="abandoned-source", + embedding=[1.0], + metadata={ + **config.record_identity( + "scene", + "abandoned-source", + ), + }, + ) + ], + batch_size=1, + cancellation=CancellationToken(), + ) + + LocalIndexBackend._cleanup_abandoned_generations( + self.repository, + config, + ) + + self.assertFalse(generation_directory.exists()) + with IndexStorage(config, create=False) as storage: + self.assertEqual(storage.records("scene"), []) + + def test_real_chroma_reader_remains_pinned_across_reindex(self): + references = [] + for input_sha, embedding in ( + ("a" * 64, [1.0, 0.0]), + ("b" * 64, [0.0, 1.0]), + ): + generation_id = self.repository.new_generation_id() + config = replace( + self.config, + video_id="a", + generation_id=generation_id, + generation_directory=self.repository.generation_directory( + generation_id + ), + ) + with IndexStorage(config) as storage: + storage.upsert( + "scene", + [ + StorageRecord( + source_id=f"source-{generation_id}", + embedding=embedding, + metadata={ + **config.record_identity( + "scene", + f"source-{generation_id}", + ), + }, + ) + ], + batch_size=1, + cancellation=CancellationToken(), + ) + reference = self.write_generation( + config, + media_id="a", + input_sha=input_sha, + record_counts={"scene": 1}, + ) + snapshot = self.repository.publish_generation(reference, config) + active_config, _ = self.repository.active_config(device="cpu") + references.append( + (reference, snapshot, active_config) + ) + + runtime = Mock() + runtime.backends.torch_device = "cpu" + backend = LocalIndexBackend( + Mock(), + runtime, + self.repository.layout, + ) + first_reference, _, first_config = references[0] + second_reference, _, second_config = references[1] + with backend.open_store(first_config) as first_reader: + first_records = first_reader.records("scene") + with backend.open_store(second_config) as second_reader: + second_records = second_reader.records("scene") + + self.assertEqual( + {record["generation_id"] for record in first_records}, + {first_reference.generation_id}, + ) + self.assertEqual( + {record["generation_id"] for record in second_records}, + {second_reference.generation_id}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_local_worker.py b/tests/test_local_worker.py new file mode 100644 index 0000000..9971436 --- /dev/null +++ b/tests/test_local_worker.py @@ -0,0 +1,485 @@ +import os +import re +import subprocess +import sys +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from threading import Event +from time import sleep +from unittest.mock import Mock, patch + +from filelock import Timeout +from vidxp import __version__ +from vidxp.application_models import ( + ApplicationError, + JobState, + PrepareModelsCommand, +) +from vidxp.infrastructure.dbos_jobs import DBOSJobBackend +from vidxp.infrastructure.local_worker import LocalWorkerSupervisor +from vidxp.job_service import JobService +from vidxp.settings import LocalExecutionSettings, VidXPSettings +from vidxp.workflow_worker import _configure_logging, run_worker +from vidxp.workflow_runtime import ( + LOCAL_WORKER_BOOTSTRAP_ENV, + LocalWorkerBootstrap, + LocalWorkerReady, + LocalWorkerStopRequest, + local_worker_bootstrap, + local_executor_id, + server_executor_id, + workflow_application_version, + workflow_database_url, +) +from vidxp.workflow_worker import main as workflow_worker_main + + +class LocalWorkerSupervisorTests(unittest.TestCase): + def test_workflow_version_identifies_the_release_and_implementation(self): + value = workflow_application_version() + + self.assertRegex( + value, + rf"^{re.escape(__version__)}\+[0-9a-f]{{16}}$", + ) + + def test_server_executor_identity_keeps_the_single_worker_ordinal(self): + version = workflow_application_version() + + self.assertEqual(server_executor_id(role="cpu"), f"{version}-cpu-0") + self.assertEqual(server_executor_id(role="gpu"), f"{version}-gpu-0") + with self.assertRaisesRegex(ValueError, "role"): + server_executor_id(role="other") + + def test_worker_is_spawned_detached_without_job_state(self): + with TemporaryDirectory() as directory: + settings = VidXPSettings( + repository_root=Path(directory), + runtime_backend="cpu", + http_auth_mode="static", + http_static_bearer_token="s" * 32, + ) + supervisor = LocalWorkerSupervisor(settings) + captured_bootstrap = None + + def spawn(command, **options): + nonlocal captured_bootstrap + bootstrap_path = Path( + options["env"][LOCAL_WORKER_BOOTSTRAP_ENV] + ) + captured_bootstrap = LocalWorkerBootstrap.model_validate_json( + bootstrap_path.read_text(encoding="utf-8") + ) + process = Mock() + process.poll.return_value = None + return process + + with ( + patch( + "vidxp.infrastructure.local_worker.subprocess.Popen", + side_effect=spawn, + ) as popen, + patch.object(supervisor, "_wait_for_worker") as wait, + patch.dict( + os.environ, + { + "VIDXP_HTTP_AUTH_MODE": "static", + "VIDXP_HTTP_STATIC_BEARER_TOKEN": "e" * 32, + "VIDXP_DATABASE_URL": ( + "postgresql://user:password@db/vidxp" + ), + "DBOS__CLOUD": "true", + "DBOS_SYSTEM_DATABASE_URL": ( + "postgresql://other:secret@db/other" + ), + }, + ), + ): + supervisor.ensure_running() + + command = popen.call_args.args[0] + options = popen.call_args.kwargs + self.assertEqual( + wait.call_args.kwargs["timeout_seconds"], + supervisor._startup_timeout_seconds, + ) + self.assertGreaterEqual(supervisor._startup_timeout_seconds, 10) + self.assertEqual(command[:3], [sys.executable, "-m", "vidxp.workflow_worker"]) + self.assertIn("--lock-file", command) + lock_file = command[command.index("--lock-file") + 1] + self.assertIn(workflow_application_version(), lock_file) + self.assertNotIn("--database-url", command) + self.assertNotIn("password", " ".join(command)) + self.assertIs(options["stdin"], subprocess.DEVNULL) + self.assertIs(options["stdout"], subprocess.DEVNULL) + self.assertIs(options["stderr"], subprocess.DEVNULL) + self.assertFalse( + any( + key.upper().startswith(("VIDXP_", "DBOS_")) + for key in options["env"] + if key != LOCAL_WORKER_BOOTSTRAP_ENV + ) + ) + self.assertIsNotNone(captured_bootstrap) + serialized = captured_bootstrap.settings.model_dump_json() + self.assertNotIn("s" * 32, serialized) + self.assertNotIn("e" * 32, serialized) + self.assertNotIn("http_static_bearer_token", serialized) + worker_settings = captured_bootstrap.settings.application_settings() + self.assertEqual( + worker_settings.repository_root, + settings.repository_root, + ) + self.assertEqual(worker_settings.http_auth_mode, "none") + wait.assert_called_once() + if sys.platform == "win32": + self.assertEqual( + options["creationflags"], + ( + subprocess.CREATE_NEW_PROCESS_GROUP + | subprocess.CREATE_NO_WINDOW + ), + ) + else: + self.assertTrue(options["start_new_session"]) + + def test_application_settings_ignore_inherited_vidxp_environment(self): + local = LocalExecutionSettings.from_settings( + VidXPSettings(runtime_backend="cpu") + ) + with patch.dict( + os.environ, + { + "VIDXP_HTTP_AUTH_MODE": "static", + "VIDXP_HTTP_STATIC_BEARER_TOKEN": "e" * 32, + "VIDXP_DATABASE_URL": ( + "postgresql://user:password@db/vidxp" + ), + }, + ): + settings = local.application_settings() + + self.assertEqual(settings.http_auth_mode, "none") + self.assertIsNone(settings.http_static_bearer_token) + self.assertNotIn("database_url", type(settings).model_fields) + + def test_startup_wait_finishes_when_worker_owns_lock(self): + with TemporaryDirectory() as directory: + ready_path = Path(directory) / "worker.ready" + ready_path.write_text("123", encoding="utf-8") + process = Mock() + process.poll.return_value = None + worker_lock = Mock() + worker_lock.acquire.side_effect = Timeout("worker.lock") + fingerprint = "a" * 64 + ready_path.write_text( + LocalWorkerReady( + pid=123, + application_version=workflow_application_version(), + fingerprint=fingerprint, + ).model_dump_json(), + encoding="utf-8", + ) + + LocalWorkerSupervisor._wait_for_worker( + process, + worker_lock, + ready_path, + fingerprint=fingerprint, + ) + + worker_lock.acquire.assert_called_once_with(timeout=0) + + def test_failed_startup_is_backed_off(self): + with TemporaryDirectory() as directory: + supervisor = LocalWorkerSupervisor( + VidXPSettings(repository_root=Path(directory)) + ) + with patch.object( + supervisor, + "_ensure_running", + side_effect=RuntimeError("failed"), + ) as start: + with self.assertRaisesRegex(RuntimeError, "failed"): + supervisor.ensure_running() + with self.assertRaisesRegex(RuntimeError, "backoff"): + supervisor.ensure_running() + + start.assert_called_once_with() + + def test_stale_worker_configuration_is_rejected(self): + with TemporaryDirectory() as directory: + ready_path = Path(directory) / "worker.ready" + ready_path.write_text( + LocalWorkerReady( + pid=123, + application_version=workflow_application_version(), + fingerprint="a" * 64, + ).model_dump_json(), + encoding="utf-8", + ) + worker_lock = Mock() + worker_lock.acquire.side_effect = Timeout("worker.lock") + + with self.assertRaisesRegex( + RuntimeError, + "different execution settings", + ): + LocalWorkerSupervisor._wait_for_existing_worker( + worker_lock, + ready_path, + fingerprint="b" * 64, + timeout_seconds=0.1, + ) + + def test_startup_timeout_terminates_exact_spawned_process(self): + with TemporaryDirectory() as directory: + supervisor = LocalWorkerSupervisor( + VidXPSettings(repository_root=Path(directory)) + ) + process = Mock() + process.poll.return_value = None + with ( + patch( + "vidxp.infrastructure.local_worker.subprocess.Popen", + return_value=process, + ), + patch.object( + supervisor, + "_wait_for_worker", + side_effect=RuntimeError("startup timed out"), + ), + self.assertRaisesRegex(RuntimeError, "startup timed out"), + ): + supervisor.ensure_running() + + process.terminate.assert_called_once_with() + process.wait.assert_called_once_with(timeout=5) + + def test_stale_versioned_bootstrap_is_removed_before_start(self): + with TemporaryDirectory() as directory: + supervisor = LocalWorkerSupervisor( + VidXPSettings(repository_root=Path(directory)) + ) + supervisor.layout.ensure_local_directories() + stale = ( + supervisor.layout.local_workflows + / ( + f".worker-{workflow_application_version()}-" + "bootstrap-stale.json" + ) + ) + stale.write_text("database-secret", encoding="utf-8") + + supervisor._remove_stale_bootstraps( + workflow_application_version() + ) + + self.assertFalse(stale.exists()) + + def test_worker_recomputes_bootstrap_identity(self): + with TemporaryDirectory() as directory: + settings = VidXPSettings( + repository_root=Path(directory), + runtime_backend="cpu", + ) + supervisor = LocalWorkerSupervisor(settings) + supervisor.layout.ensure_local_directories() + bootstrap = local_worker_bootstrap(settings) + tampered = bootstrap.model_copy( + update={ + "settings": bootstrap.settings.model_copy( + update={"runtime_backend": "cuda"} + ) + } + ) + path = supervisor._write_bootstrap(tampered) + with ( + patch.dict( + os.environ, + {LOCAL_WORKER_BOOTSTRAP_ENV: str(path)}, + ), + patch("vidxp.workflow_worker.run_worker") as run, + self.assertRaisesRegex(ValueError, "identity is invalid"), + ): + workflow_worker_main( + ["--executor-id", "test", "--role", "all"] + ) + + run.assert_not_called() + self.assertFalse(path.exists()) + + def test_stop_terminates_only_ready_lock_owner(self): + with TemporaryDirectory() as directory: + settings = VidXPSettings(repository_root=Path(directory)) + supervisor = LocalWorkerSupervisor(settings) + supervisor.layout.ensure_local_directories() + ready_path = ( + supervisor.layout.local_workflows + / f"worker-{workflow_application_version()}.ready" + ) + ready_path.write_text( + LocalWorkerReady( + pid=123, + application_version=workflow_application_version(), + fingerprint="a" * 64, + ).model_dump_json(), + encoding="utf-8", + ) + worker_lock = Mock() + worker_lock.acquire.side_effect = [ + Timeout("worker.lock"), + None, + ] + with ( + patch( + "vidxp.infrastructure.local_worker.FileLock", + return_value=worker_lock, + ), + patch( + "vidxp.infrastructure.local_worker.write_json_atomic" + ) as write_stop, + ): + stopped = supervisor.stop() + + self.assertTrue(stopped) + write_stop.assert_called_once() + self.assertEqual(write_stop.call_args.args[1]["pid"], 123) + self.assertEqual( + write_stop.call_args.args[1]["application_version"], + workflow_application_version(), + ) + self.assertFalse(ready_path.exists()) + + def test_worker_destroys_dbos_after_stop_request(self): + stop_event = Event() + stop_event.set() + + with ( + patch("vidxp.workflow_worker.DBOS") as dbos, + patch("vidxp.workflow_worker.VidXPWorkerWorkflows"), + patch("vidxp.workflow_worker.create_application"), + ): + run_worker( + settings=VidXPSettings(runtime_backend="cpu"), + database_url="sqlite:///test.db", + executor_id="test", + role="cpu", + stop_event=stop_event, + ) + + dbos.destroy.assert_called_once_with( + workflow_completion_timeout_sec=30 + ) + + def test_worker_uses_live_rotating_log_handler(self): + with TemporaryDirectory() as directory: + log_path = Path(directory) / "worker.log" + with ( + patch( + "vidxp.workflow_worker.RotatingFileHandler" + ) as handler, + patch("vidxp.workflow_worker.logging.basicConfig") as config, + ): + _configure_logging(log_path) + + handler.assert_called_once_with( + log_path, + maxBytes=5 * 1024 * 1024, + backupCount=2, + encoding="utf-8", + ) + config.assert_called_once() + + def test_fresh_database_runs_a_job_in_a_separate_worker_process(self): + with TemporaryDirectory(ignore_cleanup_errors=True) as directory: + settings = VidXPSettings( + repository_root=Path(directory), + runtime_backend="cpu", + allow_model_downloads=False, + workflow_poll_interval_seconds=0.01, + http_auth_mode="static", + http_static_bearer_token="s" * 32, + ) + settings.layout.ensure_local_directories() + database_url = workflow_database_url(settings) + ready_path = settings.layout.local_workflows / "test.ready" + lock_path = settings.layout.local_workflows / "test.lock" + stop_path = settings.layout.local_workflows / "test.stop" + supervisor = LocalWorkerSupervisor(settings) + bootstrap = local_worker_bootstrap(settings) + bootstrap_path = supervisor._write_bootstrap(bootstrap) + environment = { + key: value + for key, value in os.environ.items() + if not key.upper().startswith("VIDXP_") + } + environment[LOCAL_WORKER_BOOTSTRAP_ENV] = str(bootstrap_path) + process = subprocess.Popen( + [ + sys.executable, + "-m", + "vidxp.workflow_worker", + "--executor-id", + local_executor_id(settings), + "--role", + "all", + "--lock-file", + str(lock_path), + "--ready-file", + str(ready_path), + "--stop-file", + str(stop_path), + "--log-file", + str(settings.layout.local_workflows / "test.log"), + ], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=environment, + ) + backend = None + try: + for _ in range(200): + if ready_path.is_file(): + break + if process.poll() is not None: + self.fail("The separate worker exited during startup.") + sleep(0.05) + else: + self.fail("The separate worker did not become ready.") + + backend = DBOSJobBackend( + system_database_url=database_url, + application_version=workflow_application_version(), + ) + jobs = JobService(settings=settings, backend=backend) + submitted = jobs.submit_prepare_models( + PrepareModelsCommand(modalities=()) + ) + with self.assertRaises(ApplicationError): + jobs.wait(submitted.job_id) + completed = jobs.get(submitted.job_id) + + self.assertEqual(completed.state, JobState.failed) + self.assertEqual(completed.error.code, "invalid_request") + finally: + if backend is not None: + backend.close() + stop_path.write_text( + LocalWorkerStopRequest( + pid=process.pid, + application_version=workflow_application_version(), + ).model_dump_json(), + encoding="utf-8", + ) + try: + process.wait(timeout=35) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 6db7d5f..6313443 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -2,17 +2,50 @@ import unittest from pathlib import Path from tempfile import TemporaryDirectory -from unittest.mock import patch +from unittest.mock import Mock, patch from vidxp.core.contracts import IndexConfig, VideoSource from vidxp.core.manifest import ( ManifestStore, source_checksums, + sync_parent_directory, write_json_atomic, ) +from vidxp.capabilities.registry import create_capability_registry +from vidxp.runtime import ModelRuntime +from vidxp.settings import VidXPSettings class ManifestIdentityTests(unittest.TestCase): + def test_unsupported_directory_fsync_does_not_false_fail_commit(self): + with ( + patch("vidxp.core.manifest.os.name", "posix"), + patch("vidxp.core.manifest.os.open", return_value=17), + patch( + "vidxp.core.manifest.os.fsync", + side_effect=OSError("unsupported"), + ), + patch("vidxp.core.manifest.os.close") as close, + ): + sync_parent_directory(Path("repository")) + + close.assert_called_once_with(17) + + def test_atomic_write_leaves_no_temporary_file(self): + with TemporaryDirectory() as directory: + path = Path(directory) / "manifest.json" + + write_json_atomic(path, {"state": "ready"}) + + self.assertEqual( + path.read_text(encoding="utf-8"), + '{\n "state": "ready"\n}\n', + ) + self.assertEqual( + list(path.parent.glob(f".{path.name}.*.tmp")), + [], + ) + def test_atomic_write_retries_transient_windows_reader_lock(self): with TemporaryDirectory() as directory: path = Path(directory) / "manifest.json" @@ -33,6 +66,32 @@ def test_atomic_write_retries_transient_windows_reader_lock(self): self.assertEqual(replace.call_count, 3) self.assertEqual(sleep.call_count, 2) + def test_atomic_write_failure_preserves_target_and_cleans_temporary_file( + self, + ): + with TemporaryDirectory() as directory: + path = Path(directory) / "manifest.json" + original = '{\n "state": "ready"\n}\n' + path.write_text(original, encoding="utf-8") + + with ( + patch.object( + Path, + "replace", + side_effect=OSError("replace failed"), + ) as replace, + patch("vidxp.core.manifest.time.sleep"), + self.assertRaisesRegex(OSError, "replace failed"), + ): + write_json_atomic(path, {"state": "running"}) + + self.assertEqual(replace.call_count, 5) + self.assertEqual(path.read_text(encoding="utf-8"), original) + self.assertEqual( + list(path.parent.glob(f".{path.name}.*.tmp")), + [], + ) + def test_declared_video_checksum_does_not_suppress_transcript_hash(self): transcript = ({"text": "hello", "start": 0.0, "end": 1.0},) checksums = source_checksums( @@ -55,7 +114,16 @@ def test_checkpoint_filenames_do_not_embed_dataset_video_ids(self): output_root=directory, enabled_modalities=("scene",), ) - store = ManifestStore(config) + store = ManifestStore( + config, + registry=create_capability_registry(), + runtime=ModelRuntime( + VidXPSettings( + repository_root=config.run_directory, + runtime_backend="cpu", + ) + ), + ) video_id = "folder/name:video" expected = hashlib.sha256(video_id.encode("utf-8")).hexdigest() @@ -68,6 +136,75 @@ def test_checkpoint_filenames_do_not_embed_dataset_video_ids(self): / f"{expected}.json", ) + def test_terminal_manifests_refresh_resolved_runtime_identity(self): + for terminal in ("fail_video", "interrupt_video"): + with self.subTest(terminal=terminal): + with TemporaryDirectory() as directory: + config = IndexConfig( + dataset="sample", + split="test", + run_id="run-1", + output_root=directory, + enabled_modalities=("scene",), + ) + runtime = Mock() + runtime.describe.return_value = { + "resolved_models": {}, + } + store = ManifestStore( + config, + registry=create_capability_registry(), + runtime=runtime, + ) + source = VideoSource( + video_id="video-1", + transcript=( + {"text": "hello", "start": 0.0, "end": 1.0}, + ), + ) + with patch( + "vidxp.core.manifest.execution_state", + return_value={ + "git": {"commit": None, "dirty": None}, + "implementation_sha256": "test", + "package_version": "test", + "python": "test", + "platform": "test", + "dependencies": {}, + }, + ): + store.initialize( + [ + ( + "video-1", + source, + "a" * 64, + {"declared": "a" * 64}, + ) + ] + ) + store.start_video("video-1") + runtime.describe.return_value = { + "resolved_models": {"scene": {"cached": True}}, + "compute_precision": {"scene": "float32"}, + } + + if terminal == "fail_video": + store.fail_video("video-1", "scene", "failed") + else: + store.interrupt_video("video-1", "scene") + + self.assertEqual( + store.read()["models"]["runtime"]["resolved_models"], + {"scene": {"cached": True}}, + ) + self.assertEqual( + store.read()["models"]["runtime"][ + "compute_precision" + ], + {"scene": "float32"}, + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_mcp.py b/tests/test_mcp.py new file mode 100644 index 0000000..eda1d88 --- /dev/null +++ b/tests/test_mcp.py @@ -0,0 +1,733 @@ +import asyncio +import base64 +import contextlib +import io +import json +import socket +import sys +import unittest +from datetime import datetime, timezone +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import Mock + +import httpx2 +import uvicorn +from fastapi.testclient import TestClient +from mcp.client import Client +from mcp.client.stdio import StdioServerParameters, stdio_client +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.exceptions import MCPError +from mcp.types import ResourceLink + +from vidxp.application_models import ( + ApplicationError, + Artifact, + ErrorCategory, + IndexStatus, + Job, + JobKind, + JobPage, + JobQueue, + JobState, + MediaPage, + Principal, + QueryVideoCommand, +) +from vidxp.authentication import ( + AuthenticatedBearer, + OIDCBearerAuthenticator, + create_authenticator, +) +from vidxp.api import create_app +from vidxp.authorization import AuthorizationPolicy +from vidxp.branding import ( + ICON_MIME_TYPE, + ICON_SIZE, + PROJECT_URL, + icon_bytes, +) +from vidxp.composition import HttpApplicationContext +from vidxp.control_plane import ControlPlaneApplication +from vidxp.core.artifacts import ArtifactKind, ArtifactState +from vidxp.job_service import JobService +from vidxp.mcp import VidXPTokenVerifier, create_mcp_server +from vidxp.mcp_cli import main as mcp_main +from vidxp.mcp_cli import stdio_client_config +from vidxp.ports import LocalFileResource +from vidxp.settings import VidXPSettings + + +MEDIA_ID = "123456781234423481234567890abcde" +JOB_ID = "223456781234423481234567890abcde" +ARTIFACT_ID = "323456781234423481234567890abcde" + + +def queued_job() -> Job: + return Job( + job_id=JOB_ID, + kind=JobKind.index, + state=JobState.queued, + queue=JobQueue.cpu, + ) + + +class MCPTests(unittest.IsolatedAsyncioTestCase): + def context( + self, + root: Path, + *, + static_token: str | None = None, + http_trusted_hosts: tuple[str, ...] = ( + "127.0.0.1", + "testserver", + ), + mcp_allowed_hosts: tuple[str, ...] = ("127.0.0.1:*",), + mcp_allowed_origins: tuple[str, ...] = (), + ) -> HttpApplicationContext: + settings = VidXPSettings( + repository_root=root, + runtime_backend="cpu", + http_auth_mode="static" if static_token is not None else "none", + http_static_bearer_token=static_token, + http_trusted_hosts=http_trusted_hosts, + mcp_allowed_hosts=mcp_allowed_hosts, + mcp_allowed_origins=mcp_allowed_origins, + ) + application = Mock(spec=ControlPlaneApplication) + application.list_capabilities.return_value = () + application.index_status.return_value = IndexStatus( + schema_version=2, + state="missing", + stage="status", + message="No index.", + ) + jobs = Mock(spec=JobService) + readiness = Mock() + readiness.ready.return_value = True + return HttpApplicationContext( + application=application, + jobs=jobs, + readiness=readiness, + authenticator=create_authenticator(settings), + authorization=AuthorizationPolicy(), + settings=settings, + ) + + async def test_curated_tools_have_structured_output_schemas(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + server = create_mcp_server( + context, + default_principal=Principal( + subject="local", + scopes=frozenset({"*"}), + ), + ) + async with Client(server) as client: + discovered = await client.list_tools() + result = await client.call_tool("list_capabilities", {}) + + self.assertEqual( + [tool.name for tool in discovered.tools], + [ + "list_capabilities", + "get_capability", + "get_runtime_readiness", + "list_media", + "get_media", + "get_index_status", + "start_indexing", + "prepare_models", + "search_moments", + "query_video", + "create_clip", + "get_artifact_download", + "list_jobs", + "get_job", + "retry_job", + "cancel_job", + ], + ) + self.assertTrue( + all(tool.output_schema is not None for tool in discovered.tools) + ) + tools = {tool.name: tool for tool in discovered.tools} + for name in ("search_moments", "query_video"): + schema = tools[name].input_schema + command_ref = schema["properties"]["command"]["$ref"] + command_name = command_ref.rsplit("/", 1)[-1] + media_description = schema["$defs"][command_name]["properties"][ + "media_id" + ]["description"] + self.assertIn("omit it", media_description) + self.assertIn("active index snapshot", media_description) + self.assertEqual(result.structured_content, {"items": []}) + self.assertFalse(result.is_error) + + def test_stdio_help_and_config_are_ready_to_copy(self): + config = stdio_client_config( + command=r"C:\VidXP\vidxp-mcp.exe", + repository="library", + ) + self.assertEqual( + config, + { + "mcpServers": { + "vidxp": { + "command": r"C:\VidXP\vidxp-mcp.exe", + "args": ["--repository", "library"], + } + } + }, + ) + + output = io.StringIO() + with contextlib.redirect_stdout(output): + with self.assertRaises(SystemExit) as raised: + mcp_main(["--help"]) + self.assertEqual(raised.exception.code, 0) + self.assertIn("COPY/PASTE MCP CLIENT CONFIG", output.getvalue()) + self.assertIn('"mcpServers"', output.getvalue()) + + output = io.StringIO() + with contextlib.redirect_stdout(output): + mcp_main(["--print-config", "--repository", "library"]) + rendered = json.loads(output.getvalue()) + self.assertEqual( + rendered["mcpServers"]["vidxp"]["args"], + ["--repository", "library"], + ) + + def test_stdio_check_performs_handshake_and_tool_probe(self): + output = io.StringIO() + with TemporaryDirectory() as directory: + with contextlib.redirect_stdout(output): + mcp_main( + [ + "--check", + "--data-dir", + directory, + "--device", + "cpu", + ] + ) + + rendered = output.getvalue() + self.assertIn("OK VidXP MCP", rendered) + self.assertIn("Index state: missing", rendered) + self.assertIn("Tools: 16", rendered) + self.assertIn("get_index_status", rendered) + + async def test_server_info_exposes_vidxp_branding(self): + with TemporaryDirectory() as directory: + server = create_mcp_server( + self.context(Path(directory)), + default_principal=Principal( + subject="local", + scopes=frozenset({"*"}), + ), + ) + async with Client(server) as client: + server_info = client.server_info + + self.assertIsNotNone(server_info) + self.assertEqual(server_info.title, "VidXP") + self.assertEqual(server_info.website_url, PROJECT_URL) + self.assertEqual(len(server_info.icons or ()), 1) + icon = server_info.icons[0] + self.assertEqual(icon.mime_type, ICON_MIME_TYPE) + self.assertEqual(icon.sizes, [ICON_SIZE]) + prefix = f"data:{ICON_MIME_TYPE};base64," + self.assertTrue(icon.src.startswith(prefix)) + self.assertEqual( + base64.b64decode(icon.src.removeprefix(prefix)), + icon_bytes(), + ) + + async def test_index_submission_uses_shared_stable_idempotency(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.jobs.submit_index.return_value = queued_job() + server = create_mcp_server( + context, + default_principal=Principal( + subject="agent", + scopes=frozenset({"vidxp.write"}), + ), + ) + arguments = { + "command": { + "media_id": MEDIA_ID, + "modalities": ["scene"], + "scene_sample_fps": 2.0, + }, + "idempotency_key": "agent-request-0001", + } + async with Client(server) as client: + first = await client.call_tool("start_indexing", arguments) + second = await client.call_tool("start_indexing", arguments) + + self.assertEqual( + first.structured_content, + second.structured_content, + ) + calls = context.jobs.submit_index.call_args_list + self.assertEqual(len(calls), 2) + self.assertEqual(calls[0].args[0].scene_sample_fps, 2.0) + self.assertEqual( + calls[0].kwargs["job_id"], + calls[1].kwargs["job_id"], + ) + self.assertEqual( + context.application.require_models.call_args.args[0], + ("scene",), + ) + + async def test_missing_models_fail_before_index_submission(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.application.require_models.side_effect = ApplicationError( + "model_unavailable", + ErrorCategory.unavailable, + "Run vidxp prepare --modalities scene.", + details={ + "capability": "scene", + "remediation": "vidxp prepare --modalities scene", + }, + ) + server = create_mcp_server( + context, + default_principal=Principal( + subject="agent", + scopes=frozenset({"vidxp.write"}), + ), + ) + async with Client(server) as client: + result = await client.call_tool( + "start_indexing", + { + "command": { + "media_id": MEDIA_ID, + "modalities": ["scene"], + }, + "idempotency_key": "agent-request-0002", + }, + ) + + self.assertTrue(result.is_error) + self.assertIn( + '"remediation":"vidxp prepare --modalities scene"', + result.content[0].text, + ) + context.jobs.submit_index.assert_not_called() + + async def test_query_video_submits_the_shared_durable_command(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.jobs.submit_query.return_value = queued_job().model_copy( + update={"kind": JobKind.query} + ) + server = create_mcp_server( + context, + default_principal=Principal( + subject="agent", + scopes=frozenset({"vidxp.read"}), + ), + ) + async with Client(server) as client: + result = await client.call_tool( + "query_video", + { + "command": { + "question": "What happens after the taxi arrives?", + "media_id": MEDIA_ID, + "modalities": ["scene", "dialogue"], + }, + "idempotency_key": "agent-query-0001", + }, + ) + + self.assertFalse(result.is_error) + command = context.jobs.submit_query.call_args.args[0] + self.assertEqual( + command, + QueryVideoCommand( + question="What happens after the taxi arrives?", + media_id=MEDIA_ID, + modalities=("scene", "dialogue"), + ), + ) + self.assertIn( + "job_id", + context.jobs.submit_query.call_args.kwargs, + ) + + async def test_discovery_tools_use_shared_media_and_job_pages(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.application.list_media.return_value = MediaPage(total=0) + context.jobs.list.return_value = JobPage() + server = create_mcp_server( + context, + default_principal=Principal( + subject="agent", + scopes=frozenset({"vidxp.read"}), + ), + ) + async with Client(server) as client: + media = await client.call_tool( + "list_media", + {"page_size": 7}, + ) + jobs = await client.call_tool( + "list_jobs", + {"page_size": 9}, + ) + + self.assertFalse(media.is_error) + self.assertFalse(jobs.is_error) + self.assertEqual( + media.structured_content, + {"items": [], "total": 0, "next_cursor": None}, + ) + self.assertEqual( + jobs.structured_content, + {"items": [], "next_cursor": None}, + ) + self.assertEqual( + context.application.list_media.call_args.args[0].page_size, + 7, + ) + self.assertEqual(context.jobs.list.call_args.args[0].page_size, 9) + + async def test_retry_job_uses_shared_stable_idempotency(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.jobs.retry.return_value = queued_job() + server = create_mcp_server( + context, + default_principal=Principal( + subject="agent", + scopes=frozenset({"vidxp.write"}), + ), + ) + arguments = { + "job_id": JOB_ID, + "idempotency_key": "agent-retry-0001", + } + async with Client(server) as client: + first = await client.call_tool("retry_job", arguments) + second = await client.call_tool("retry_job", arguments) + + self.assertFalse(first.is_error) + self.assertEqual( + first.structured_content, + second.structured_content, + ) + calls = context.jobs.retry.call_args_list + self.assertEqual(len(calls), 2) + self.assertEqual( + calls[0].kwargs["retry_id"], + calls[1].kwargs["retry_id"], + ) + + async def test_clip_submission_and_lazy_artifact_download(self): + with TemporaryDirectory() as directory: + root = Path(directory) + clip = root / "clip.mp4" + clip.write_bytes(b"clip-content") + context = self.context(root) + context.jobs.submit_snippet.return_value = ( + queued_job().model_copy(update={"kind": JobKind.snippet}) + ) + context.application.get_artifact.return_value = Artifact( + artifact_id=ARTIFACT_ID, + media_id=MEDIA_ID, + kind=ArtifactKind.snippet, + profile="compatible_mp4", + mime_type="video/mp4", + byte_size=12, + sha256="1" * 64, + state=ArtifactState.ready, + created_at=datetime.now(timezone.utc), + ) + context.application.open_artifact_content.return_value = ( + LocalFileResource( + path=clip, + filename=f"snippet-{ARTIFACT_ID}.mp4", + mime_type="video/mp4", + byte_size=12, + etag="1" * 64, + ) + ) + server = create_mcp_server( + context, + default_principal=Principal( + subject="agent", + scopes=frozenset({"*"}), + ), + ) + async with Client(server) as client: + submitted = await client.call_tool( + "create_clip", + { + "command": { + "media_id": MEDIA_ID, + "start_seconds": 9, + "end_seconds": 17, + }, + "idempotency_key": "agent-clip-0001", + }, + ) + linked = await client.call_tool( + "get_artifact_download", + {"artifact_id": ARTIFACT_ID}, + ) + link = linked.content[0] + downloaded = await client.read_resource(str(link.uri)) + + self.assertFalse(submitted.is_error) + submitted_command = context.jobs.submit_snippet.call_args.args[0] + self.assertEqual(submitted_command.media_id, MEDIA_ID) + self.assertEqual(submitted_command.start_seconds, 9) + self.assertEqual(submitted_command.end_seconds, 17) + self.assertIsInstance(link, ResourceLink) + self.assertEqual(link.mime_type, "video/mp4") + self.assertEqual(link.size, 12) + self.assertEqual(downloaded.contents[0].blob, "Y2xpcC1jb250ZW50") + + async def test_application_errors_are_machine_readable_and_safe(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.application.index_status.side_effect = ApplicationError( + "index_busy", + ErrorCategory.conflict, + "The index is busy.", + retryable=True, + ) + server = create_mcp_server( + context, + default_principal=Principal( + subject="agent", + scopes=frozenset({"vidxp.read"}), + ), + ) + async with Client(server) as client: + result = await client.call_tool("get_index_status", {}) + + self.assertTrue(result.is_error) + error_text = result.content[0].text + self.assertIn('"code":"index_busy"', error_text) + self.assertIn('"protocol_code":-32009', error_text) + self.assertIn('"retryable":true', error_text) + + async def test_unexpected_errors_do_not_leak_exception_text(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + context.application.index_status.side_effect = RuntimeError( + "database-password" + ) + server = create_mcp_server( + context, + default_principal=Principal( + subject="agent", + scopes=frozenset({"vidxp.read"}), + ), + ) + async with Client(server) as client: + with self.assertRaises(MCPError) as caught: + await client.call_tool("get_index_status", {}) + + self.assertEqual(caught.exception.code, -32603) + self.assertNotIn("database-password", caught.exception.message) + self.assertEqual(caught.exception.data["code"], "internal_error") + + async def test_write_tools_enforce_repository_scope(self): + with TemporaryDirectory() as directory: + context = self.context(Path(directory)) + server = create_mcp_server( + context, + default_principal=Principal( + subject="reader", + scopes=frozenset({"vidxp.read"}), + ), + ) + async with Client(server) as client: + result = await client.call_tool( + "start_indexing", + { + "command": { + "media_id": MEDIA_ID, + "modalities": ["scene"], + }, + "idempotency_key": "agent-request-0001", + }, + ) + + self.assertTrue(result.is_error) + self.assertIn('"protocol_code":-32003', result.content[0].text) + self.assertIn('"required_scope":"vidxp.write"', result.content[0].text) + context.jobs.submit_index.assert_not_called() + + async def test_stdio_entrypoint_serves_the_same_curated_surface(self): + with TemporaryDirectory() as directory: + parameters = StdioServerParameters( + command=sys.executable, + args=[ + "-m", + "vidxp.mcp_cli", + "--index-directory", + directory, + "--device", + "cpu", + ], + cwd=Path(__file__).parents[1], + ) + async with Client(stdio_client(parameters)) as client: + discovered = await client.list_tools() + result = await client.call_tool("list_capabilities", {}) + + self.assertEqual( + [tool.name for tool in discovered.tools], + [ + "list_capabilities", + "get_capability", + "get_runtime_readiness", + "list_media", + "get_media", + "get_index_status", + "start_indexing", + "prepare_models", + "search_moments", + "query_video", + "create_clip", + "get_artifact_download", + "list_jobs", + "get_job", + "retry_job", + "cancel_job", + ], + ) + self.assertEqual( + [item["name"] for item in result.structured_content["items"]], + ["dialogue", "scene", "actor"], + ) + + async def test_streamable_http_works_with_the_official_remote_client(self): + token = "s" * 32 + listener = socket.socket() + listener.bind(("127.0.0.1", 0)) + port = listener.getsockname()[1] + listener.close() + with TemporaryDirectory() as directory: + context = self.context( + Path(directory), + static_token=token, + ) + server = uvicorn.Server( + uvicorn.Config( + create_app(context=context), + host="127.0.0.1", + port=port, + log_level="critical", + ) + ) + serving = asyncio.create_task(server.serve()) + try: + for _attempt in range(200): + if server.started: + break + if serving.done(): + await serving + await asyncio.sleep(0.01) + else: + self.fail("The MCP HTTP fixture did not start.") + async with httpx2.AsyncClient( + headers={"Authorization": f"Bearer {token}"} + ) as http_client: + transport = streamable_http_client( + f"http://127.0.0.1:{port}/mcp", + http_client=http_client, + ) + async with Client(transport) as client: + discovered = await client.list_tools() + result = await client.call_tool( + "list_capabilities", + {}, + ) + finally: + server.should_exit = True + await serving + + self.assertEqual(len(discovered.tools), 16) + self.assertEqual(result.structured_content, {"items": []}) + + async def test_oidc_verifier_projects_the_shared_validated_token(self): + authenticator = Mock(spec=OIDCBearerAuthenticator) + authenticator.authenticate_bearer.return_value = AuthenticatedBearer( + principal=Principal( + subject="user-1", + client_id="client-1", + scopes=frozenset({"vidxp.read"}), + ), + expires_at=1_800_000_000, + resource="https://api.example/mcp", + claims={"iss": "https://issuer.example"}, + ) + + token = await VidXPTokenVerifier(authenticator).verify_token("token") + + self.assertEqual(token.subject, "user-1") + self.assertEqual(token.client_id, "client-1") + self.assertEqual(token.scopes, ["vidxp.read"]) + self.assertEqual(token.expires_at, 1_800_000_000) + self.assertEqual(token.resource, "https://api.example/mcp") + + def test_sdk_transport_security_rejects_host_and_origin(self): + token = "s" * 32 + with TemporaryDirectory() as directory: + context = self.context( + Path(directory), + static_token=token, + http_trusted_hosts=("*",), + mcp_allowed_hosts=("mcp.example",), + mcp_allowed_origins=("https://client.example",), + ) + with TestClient(create_app(context=context)) as client: + bad_host = client.post( + "/mcp", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Host": "other.example", + }, + json={}, + ) + bad_origin = client.post( + "/mcp", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Host": "mcp.example", + "Origin": "https://other.example", + }, + json={}, + ) + unauthenticated_bad_origin = client.post( + "/mcp", + headers={ + "Content-Type": "application/json", + "Host": "mcp.example", + "Origin": "https://other.example", + }, + json={}, + ) + + self.assertEqual(bad_host.status_code, 421) + self.assertEqual(bad_origin.status_code, 403) + self.assertEqual(unauthenticated_bad_origin.status_code, 403) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_media_catalog.py b/tests/test_media_catalog.py new file mode 100644 index 0000000..9718a1d --- /dev/null +++ b/tests/test_media_catalog.py @@ -0,0 +1,451 @@ +import json +import os +import subprocess +import unittest +from datetime import datetime, timezone +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from vidxp.core.artifacts import ArtifactKind, ArtifactRecord +from vidxp.core.media import ( + MediaImportLimitError, + MediaRecord, + MediaState, + MediaStream, +) +from vidxp.infrastructure.local_catalog import LocalCatalog +from vidxp.infrastructure.local_artifacts import LocalArtifactStore +from vidxp.infrastructure.local_media import ( + FFprobeMediaProbe, + InvalidMediaError, + LocalMediaStore, +) +from vidxp.infrastructure.local_files import prepare_managed_destination +from vidxp.infrastructure.local_objects import LocalObjectStore + + +MEDIA_ID = "123456781234423481234567890abcde" +OTHER_MEDIA_ID = "223456781234423481234567890abcde" +ARTIFACT_ID = "323456781234423481234567890abcde" + + +def media_record( + media_id: str = MEDIA_ID, + *, + checksum: str = "1" * 64, +) -> MediaRecord: + return MediaRecord( + media_id=media_id, + video_id=media_id, + sha256=checksum, + original_filename="video.mp4", + byte_size=5, + detected_mime_type="video/mp4", + container="mp4", + duration_seconds=2, + streams=( + MediaStream( + index=0, + kind="video", + codec="h264", + width=1, + height=1, + ), + ), + storage_key=f"objects/{checksum[:2]}/{checksum}.mp4", + state=MediaState.ready, + created_at=datetime.now(timezone.utc), + ) + + +class LocalCatalogTests(unittest.TestCase): + def test_catalog_enforces_sqlite_integrity_and_schema_version(self): + with TemporaryDirectory() as directory: + database = Path(directory) / "catalog.sqlite3" + catalog = LocalCatalog(database) + with catalog.engine.connect() as connection: + self.assertEqual( + set( + connection.exec_driver_sql( + "SELECT name FROM sqlite_master " + "WHERE type = 'table'" + ).scalars() + ), + { + "artifact_requests", + "artifacts", + "catalog_metadata", + "media", + "media_import_requests", + }, + ) + self.assertEqual( + connection.exec_driver_sql( + "PRAGMA foreign_keys" + ).scalar_one(), + 1, + ) + self.assertEqual( + connection.exec_driver_sql( + "PRAGMA busy_timeout" + ).scalar_one(), + 30_000, + ) + with catalog.transaction() as connection: + connection.exec_driver_sql( + "UPDATE catalog_metadata SET schema_version = 999" + ) + catalog.close() + + with self.assertRaisesRegex(RuntimeError, "incompatible"): + LocalCatalog(database) + + def test_catalog_persists_and_deduplicates_media_by_checksum(self): + with TemporaryDirectory() as directory: + database = Path(directory) / "catalog.sqlite3" + first = LocalCatalog(database) + record = media_record() + self.assertEqual(first.put_media(record), record) + + reopened = LocalCatalog(database) + self.assertEqual(reopened.get_media(MEDIA_ID), record) + duplicate = media_record(OTHER_MEDIA_ID) + self.assertEqual(reopened.put_media(duplicate), record) + self.assertEqual(reopened.list_media(limit=10), (record,)) + + def test_artifact_requires_cataloged_media_and_survives_reopen(self): + with TemporaryDirectory() as directory: + database = Path(directory) / "catalog.sqlite3" + catalog = LocalCatalog(database) + catalog.put_media(media_record()) + artifact = ArtifactRecord( + artifact_id=ARTIFACT_ID, + media_id=MEDIA_ID, + request_key="3" * 64, + kind=ArtifactKind.actor_overlay, + profile="default", + mime_type="video/mp4", + byte_size=10, + sha256="2" * 64, + storage_key=f"objects/32/{ARTIFACT_ID}.mp4", + created_at=datetime.now(timezone.utc), + ) + + catalog.put_artifact(artifact) + + self.assertEqual( + LocalCatalog(database).get_artifact(ARTIFACT_ID), + artifact, + ) + self.assertEqual( + LocalCatalog(database).get_artifact_by_request("3" * 64), + artifact, + ) + + +class LocalMediaStoreTests(unittest.TestCase): + def test_import_is_staged_hashed_once_and_atomically_published(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source.mp4" + source.write_bytes(b"video") + store = LocalMediaStore(root / "media", max_bytes=100) + + staged = store.stage_local(source) + self.assertEqual(staged.byte_size, 5) + self.assertEqual( + staged.sha256, + "0cab1c9617404faf2b24e221e189ca5945813e14" + "d3f766345b09ca13bbe28ffc", + ) + self.assertTrue(staged.path.is_file()) + + with store.publication_lock(staged.sha256): + stored = store.publish(staged) + + self.assertFalse(staged.path.exists()) + self.assertEqual(store.resolve(stored.storage_key), stored.local_path) + self.assertEqual(stored.local_path.read_bytes(), b"video") + + def test_size_limit_and_unsafe_storage_keys_fail_closed(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source.mp4" + source.write_bytes(b"too-large") + store = LocalMediaStore(root / "media", max_bytes=3) + + with self.assertRaisesRegex(MediaImportLimitError, "import limit"): + store.stage_local(source) + with self.assertRaises(ValueError): + store.resolve("../outside.mp4") + + def test_managed_storage_rejects_symlink_escapes(self): + with TemporaryDirectory() as directory: + root = Path(directory) + media_root = root / "media" + (media_root / "objects").mkdir(parents=True) + outside = root / "outside" + outside.mkdir() + (outside / "secret.mp4").write_bytes(b"secret") + link = media_root / "objects" / "escape" + try: + link.symlink_to(outside, target_is_directory=True) + except OSError as exc: + self.skipTest(f"Symlink creation is unavailable: {exc}") + + store = LocalMediaStore(media_root, max_bytes=100) + with self.assertRaises((FileNotFoundError, PermissionError)): + store.resolve("objects/escape/secret.mp4") + + def test_destination_rejects_junction_parent(self): + with TemporaryDirectory() as directory: + root = Path(directory) + junction = root / "objects" / "junction" + junction.mkdir(parents=True) + original = getattr(Path, "is_junction", lambda _item: False) + + with patch.object( + Path, + "is_junction", + lambda item: item == junction or original(item), + create=True, + ): + with self.assertRaises(PermissionError): + prepare_managed_destination( + root, + "objects/junction/video.mp4", + ) + + def test_destination_syncs_each_new_directory_entry(self): + with TemporaryDirectory() as directory: + root = Path(directory) / "media" + with patch( + "vidxp.infrastructure.local_files.sync_parent_directory" + ) as sync: + prepare_managed_destination( + root, + "objects/aa/video.mp4", + ) + + self.assertEqual( + [call.args[0] for call in sync.call_args_list], + [ + root.parent, + root, + root / "objects", + ], + ) + + def test_failed_publication_rollback_syncs_parent(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "staged.mp4" + source.write_bytes(b"video") + store = LocalObjectStore(root / "objects") + destination = store.root / "aa" / "video.mp4" + destination.parent.mkdir(parents=True) + original_resolve = Path.resolve + + def resolve(path, *args, **kwargs): + if path == destination: + raise OSError("post-publication validation failed") + return original_resolve(path, *args, **kwargs) + + with ( + patch.object(Path, "resolve", resolve), + patch( + "vidxp.infrastructure.local_files." + "sync_parent_directory" + ) as sync, + self.assertRaisesRegex( + OSError, + "post-publication validation failed", + ), + ): + store.publish( + source, + "aa/video.mp4", + expected_sha256=None, + replace_corrupt=False, + ) + + self.assertFalse(destination.exists()) + self.assertEqual( + [call.args[0] for call in sync.call_args_list], + [destination.parent, root, destination.parent], + ) + + def test_publication_rejects_symlinked_parent(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source.mp4" + source.write_bytes(b"video") + store = LocalMediaStore(root / "media", max_bytes=100) + staged = store.stage_local(source) + first_parent = store.root / "objects" / staged.sha256[:2] + outside = root / "outside" + outside.mkdir() + first_parent.parent.mkdir(parents=True) + try: + first_parent.symlink_to(outside, target_is_directory=True) + except OSError as exc: + store.discard(staged) + self.skipTest(f"Symlink creation is unavailable: {exc}") + + with self.assertRaises(PermissionError): + store.publish(staged) + self.assertEqual(tuple(outside.iterdir()), ()) + store.discard(staged) + + def test_reimport_repairs_same_size_corruption(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source.mp4" + source.write_bytes(b"video") + store = LocalMediaStore(root / "media", max_bytes=100) + first = store.publish(store.stage_local(source)) + first.local_path.write_bytes(b"bideo") + + repaired = store.publish(store.stage_local(source)) + + self.assertEqual(repaired.local_path.read_bytes(), b"video") + self.assertEqual( + store.verify( + repaired.storage_key, + sha256=repaired.sha256, + byte_size=repaired.byte_size, + ), + repaired.local_path, + ) + + @unittest.skipUnless(os.name == "nt", "Windows junction behavior") + def test_publication_rejects_windows_junction_parent(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source.mp4" + source.write_bytes(b"video") + store = LocalMediaStore(root / "media", max_bytes=100) + staged = store.stage_local(source) + parent = store.objects / staged.sha256[:2] + parent.parent.mkdir(parents=True) + outside = root / "outside" + outside.mkdir() + result = subprocess.run( + [ + "cmd", + "/c", + "mklink", + "/J", + str(parent), + str(outside), + ], + check=False, + capture_output=True, + text=True, + ) + if result.returncode: + store.discard(staged) + self.skipTest("Junction creation is unavailable.") + + with self.assertRaises(PermissionError): + store.publish(staged) + self.assertEqual(tuple(outside.iterdir()), ()) + store.discard(staged) + + +class FFprobeTests(unittest.TestCase): + def test_probe_normalizes_stream_and_container_metadata(self): + payload = { + "format": {"format_name": "mov,mp4", "duration": "2.5"}, + "streams": [ + { + "index": 0, + "codec_type": "video", + "codec_name": "h264", + "width": 1920, + "height": 1080, + }, + { + "index": 1, + "codec_type": "audio", + "codec_name": "aac", + "channels": 2, + "sample_rate": "48000", + }, + ], + } + with patch.object( + FFprobeMediaProbe, + "_output", + return_value=json.dumps(payload).encode(), + ): + result = FFprobeMediaProbe().probe(Path("video.mp4")) + + self.assertEqual(result.container, "mp4") + self.assertEqual(result.detected_mime_type, "video/mp4") + self.assertEqual(result.streams[0].codec, "h264") + self.assertEqual(result.streams[1].sample_rate, 48000) + + def test_probe_rejects_malformed_or_non_video_results(self): + with patch.object( + FFprobeMediaProbe, + "_output", + return_value=b'{"format":{"format_name":"mp4","duration":"nan"}}', + ): + with self.assertRaises(InvalidMediaError): + FFprobeMediaProbe().probe(Path("bad.mp4")) + + def test_probe_distinguishes_matroska_from_webm_by_extension(self): + payload = { + "format": { + "format_name": "matroska,webm", + "duration": "2.5", + }, + "streams": [ + { + "index": 0, + "codec_type": "video", + "codec_name": "h264", + "width": 1920, + "height": 1080, + } + ], + } + with patch.object( + FFprobeMediaProbe, + "_output", + return_value=json.dumps(payload).encode(), + ): + matroska = FFprobeMediaProbe().probe(Path("video.mkv")) + webm = FFprobeMediaProbe().probe(Path("video.webm")) + + self.assertEqual(matroska.detected_mime_type, "video/x-matroska") + self.assertEqual(webm.detected_mime_type, "video/webm") + + +class LocalArtifactStoreTests(unittest.TestCase): + def test_publication_rejects_symlinked_parent(self): + with TemporaryDirectory() as directory: + root = Path(directory) + store = LocalArtifactStore(root / "artifacts") + staged = store.stage(ARTIFACT_ID, suffix=".mp4") + staged.path.write_bytes(b"video") + outside = root / "outside" + outside.mkdir() + parent = store.objects / ARTIFACT_ID[:2] + parent.parent.mkdir(parents=True) + try: + parent.symlink_to(outside, target_is_directory=True) + except OSError as exc: + store.discard(staged) + self.skipTest(f"Symlink creation is unavailable: {exc}") + + with self.assertRaises(PermissionError): + store.publish(staged) + self.assertEqual(tuple(outside.iterdir()), ()) + store.discard(staged) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_media_runtime.py b/tests/test_media_runtime.py new file mode 100644 index 0000000..5f8c477 --- /dev/null +++ b/tests/test_media_runtime.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import os +import sys +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from vidxp.media_runtime import ( + MediaRuntimeStatus, + inspect_media_runtime, + load_media_runtime_configuration, + media_runtime_is_initialized, + save_media_runtime_configuration, + system_install_plan, +) + + +class MediaRuntimeTests(unittest.TestCase): + def test_inspection_verifies_both_executables_and_required_encoders(self): + ffmpeg = Path("resolved/ffmpeg").resolve() + ffprobe = Path("resolved/ffprobe").resolve() + + def resolve(value): + return ffmpeg if str(value) == "ffmpeg" else ffprobe + + def output(arguments, **_kwargs): + if "-encoders" in arguments: + return " V....D libx264\n A....D aac\n" + return "version" + + with ( + patch("vidxp.media_runtime._resolve_executable", side_effect=resolve), + patch("vidxp.media_runtime._command_output", side_effect=output), + patch("vidxp.media_runtime.system_install_plan"), + ): + status = inspect_media_runtime() + + self.assertTrue(status.ready) + self.assertEqual(status.ffmpeg_executable, ffmpeg) + self.assertEqual(status.ffprobe_executable, ffprobe) + self.assertEqual(status.errors, ()) + + def test_inspection_rejects_an_ffmpeg_build_without_required_codecs(self): + executable = Path("resolved/tool").resolve() + + def output(arguments, **_kwargs): + return " V....D libx264\n" if "-encoders" in arguments else "version" + + with ( + patch( + "vidxp.media_runtime._resolve_executable", + return_value=executable, + ), + patch("vidxp.media_runtime._command_output", side_effect=output), + patch("vidxp.media_runtime.system_install_plan", return_value=None), + ): + status = inspect_media_runtime() + + self.assertFalse(status.ready) + self.assertIn("aac", " ".join(status.errors)) + + def test_verified_absolute_paths_are_persisted_and_reloaded(self): + with TemporaryDirectory() as directory: + root = Path(directory) + ffmpeg = root / "ffmpeg.exe" + ffprobe = root / "ffprobe.exe" + ffmpeg.touch() + ffprobe.touch() + status = MediaRuntimeStatus( + ready=True, + initialized=False, + ffmpeg_executable=ffmpeg.resolve(), + ffprobe_executable=ffprobe.resolve(), + ) + + saved = save_media_runtime_configuration( + status, + config_directory=root / "config", + ) + loaded = load_media_runtime_configuration(root / "config") + + self.assertEqual(loaded, saved) + self.assertTrue(saved.ffmpeg_executable.is_absolute()) + self.assertTrue(saved.ffprobe_executable.is_absolute()) + + def test_initialized_accepts_saved_config_environment_or_path(self): + with TemporaryDirectory() as directory: + root = Path(directory) + ffmpeg = root / "ffmpeg" + ffprobe = root / "ffprobe" + ffmpeg.touch() + ffprobe.touch() + save_media_runtime_configuration( + MediaRuntimeStatus( + ready=True, + initialized=False, + ffmpeg_executable=ffmpeg.resolve(), + ffprobe_executable=ffprobe.resolve(), + ), + config_directory=root, + ) + self.assertTrue(media_runtime_is_initialized(root)) + + with patch.dict( + os.environ, + { + "VIDXP_FFMPEG_EXECUTABLE": "custom-ffmpeg", + "VIDXP_FFPROBE_EXECUTABLE": "custom-ffprobe", + }, + clear=False, + ): + self.assertTrue(media_runtime_is_initialized(Path("missing"))) + + with ( + patch.dict(os.environ, {}, clear=True), + patch( + "vidxp.media_runtime.load_media_runtime_configuration", + return_value=None, + ), + patch("vidxp.media_runtime.shutil.which", return_value=None), + ): + self.assertFalse(media_runtime_is_initialized()) + + @unittest.skipUnless(sys.platform == "win32", "Windows package contract") + def test_windows_plan_uses_the_approved_exact_winget_package(self): + with patch("vidxp.media_runtime.shutil.which", return_value="winget"): + plan = system_install_plan() + + self.assertIsNotNone(plan) + assert plan is not None + self.assertEqual(plan.manager, "Windows Package Manager") + self.assertTrue(plan.automatic) + self.assertIn("Gyan.FFmpeg", plan.command) + self.assertIn("--exact", plan.command) + self.assertIn("--source", plan.command) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_media_services.py b/tests/test_media_services.py new file mode 100644 index 0000000..5121994 --- /dev/null +++ b/tests/test_media_services.py @@ -0,0 +1,827 @@ +import unittest +from contextlib import nullcontext +from datetime import datetime, timezone +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import Mock, patch + +from vidxp.application_models import ( + ActorOverlayProfile, + CreateSnippetCommand, + ImportMediaCommand, + ListMediaCommand, + SnippetProfile, +) +from vidxp.artifact_service import ( + ArtifactService, + ArtifactUnavailableError, + InvalidArtifactError, +) +from vidxp.core.artifacts import ArtifactState +from vidxp.core.contracts import CancellationToken, IndexCancelledError +from vidxp.core.media import ( + MediaProbe, + QuarantinedMedia, + MediaRecord, + MediaState, + MediaStream, + StagedMedia, + StoredMedia, +) +from vidxp.execution import ExecutionContext +from vidxp.infrastructure.local_artifacts import ( + FFmpegSnippetRenderer, + LocalArtifactStore, +) +from vidxp.infrastructure.local_catalog import LocalCatalog +from vidxp.infrastructure.local_media import InvalidMediaError +from vidxp.media_service import ( + MediaIdempotencyConflictError, + MediaService, +) +from vidxp.ports import LocalFileResource +from vidxp.settings import VidXPSettings + + +MEDIA_ID = "123456781234423481234567890abcde" +JOB_ID = "223456781234423481234567890abcde" +ARTIFACT_ID = "323456781234423481234567890abcde" + + +def record() -> MediaRecord: + return MediaRecord( + media_id=MEDIA_ID, + video_id=MEDIA_ID, + sha256="1" * 64, + original_filename="video.mp4", + byte_size=5, + detected_mime_type="video/mp4", + container="mp4", + duration_seconds=2, + streams=( + MediaStream( + index=0, + kind="video", + codec="h264", + width=1, + height=1, + ), + ), + storage_key="objects/11/video.mp4", + state=MediaState.ready, + created_at=datetime.now(timezone.utc), + ) + + +class MediaServiceTests(unittest.TestCase): + def service(self, root: Path): + catalog = Mock() + store = Mock() + store.publication_lock.return_value = nullcontext() + probe = Mock() + service = MediaService( + settings=VidXPSettings( + repository_root=root, + runtime_backend="cpu", + ), + catalog=catalog, + store=store, + probe=probe, + ) + return service, catalog, store, probe + + def test_import_probes_staging_before_publishing_and_cataloging(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "video.mp4" + source.write_bytes(b"video") + service, catalog, store, probe = self.service(root) + staged = StagedMedia( + sha256="1" * 64, + byte_size=5, + storage_key="objects/11/video.mp4", + path=root / "staged.tmp", + ) + stored = StoredMedia( + sha256=staged.sha256, + byte_size=5, + storage_key=staged.storage_key, + local_path=root / "managed.mp4", + ) + store.stage_local.return_value = staged + store.publish.return_value = stored + probe.probe.return_value = MediaProbe( + detected_mime_type="video/mp4", + container="mp4", + duration_seconds=2, + streams=( + MediaStream( + index=0, + kind="video", + codec="h264", + width=1, + height=1, + ), + ), + ) + catalog.get_media_by_checksum.return_value = None + catalog.put_media.side_effect = lambda item: item + with patch("vidxp.media_service.uuid4") as identifier: + identifier.return_value.hex = MEDIA_ID + result = service.import_local(ImportMediaCommand(path=source)) + + self.assertEqual(result.media_id, MEDIA_ID) + store.publication_lock.assert_called_once_with(staged.sha256) + probe.probe.assert_called_once_with(staged.path) + store.publish.assert_called_once_with(staged) + catalog.put_media.assert_called_once() + store.discard.assert_called_once_with(staged) + + def test_invalid_probe_never_publishes_or_catalogs_media(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "video.mp4" + source.write_bytes(b"video") + service, catalog, store, probe = self.service(root) + staged = StagedMedia( + sha256="1" * 64, + byte_size=5, + storage_key="objects/11/video.mp4", + path=root / "staged.tmp", + ) + store.stage_local.return_value = staged + catalog.get_media_by_checksum.return_value = None + probe.probe.side_effect = InvalidMediaError("invalid") + + with self.assertRaises(InvalidMediaError): + service.import_local(ImportMediaCommand(path=source)) + + store.publish.assert_not_called() + catalog.put_media.assert_not_called() + store.discard.assert_called_once_with(staged) + + def test_quarantined_import_reuses_the_same_ingestion_pipeline(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "upload.mp4" + source.write_bytes(b"video") + service, catalog, store, probe = self.service(root) + staged = StagedMedia( + sha256="1" * 64, + byte_size=5, + storage_key="objects/11/video.mp4", + path=root / "staged.tmp", + ) + stored = StoredMedia( + sha256=staged.sha256, + byte_size=5, + storage_key=staged.storage_key, + local_path=root / "managed.mp4", + ) + store.stage_local.return_value = staged + store.publish.return_value = stored + probe.probe.return_value = MediaProbe( + detected_mime_type="video/mp4", + container="mp4", + duration_seconds=2, + streams=( + MediaStream( + index=0, + kind="video", + codec="h264", + width=1, + height=1, + ), + ), + ) + catalog.get_media_by_checksum.return_value = None + catalog.put_media.side_effect = lambda item: item + with patch("vidxp.media_service.uuid4") as identifier: + identifier.return_value.hex = MEDIA_ID + result = service.import_quarantined( + QuarantinedMedia( + path=source, + original_filename="client-name.mp4", + declared_mime_type="video/mp4", + ) + ) + + self.assertEqual(result.original_filename, "client-name.mp4") + store.stage_local.assert_called_once_with(source.resolve()) + probe.probe.assert_called_once_with(staged.path) + store.publish.assert_called_once_with(staged) + store.discard.assert_called_once_with(staged) + + def test_quarantined_import_completes_durable_idempotency_record(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "upload.mp4" + source.write_bytes(b"video") + service, catalog, store, probe = self.service(root) + staged = StagedMedia( + sha256="1" * 64, + byte_size=5, + storage_key="objects/11/video.mp4", + path=root / "staged.tmp", + ) + stored = StoredMedia( + sha256=staged.sha256, + byte_size=5, + storage_key=staged.storage_key, + local_path=root / "managed.mp4", + ) + store.stage_local.return_value = staged + store.publish.return_value = stored + probe.probe.return_value = MediaProbe( + detected_mime_type="video/mp4", + container="mp4", + duration_seconds=2, + streams=( + MediaStream( + index=0, + kind="video", + codec="h264", + width=1, + height=1, + ), + ), + ) + catalog.reserve_media_import.return_value = None + catalog.get_media_by_checksum.return_value = None + imported = record() + catalog.put_media.return_value = imported + catalog.get_media.return_value = imported + + result = service.import_quarantined( + QuarantinedMedia( + path=source, + original_filename="upload.mp4", + ), + request_key="request-key", + ) + + self.assertEqual(result.media_id, MEDIA_ID) + fingerprint = catalog.reserve_media_import.call_args.args[1] + self.assertEqual(len(fingerprint), 64) + catalog.complete_media_import.assert_called_once_with( + "request-key", + fingerprint, + imported, + ) + + def test_quarantined_import_rejects_reused_key_for_other_content(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "upload.mp4" + source.write_bytes(b"video") + service, catalog, store, _probe = self.service(root) + staged = StagedMedia( + sha256="2" * 64, + byte_size=5, + storage_key="objects/22/video.mp4", + path=root / "staged.tmp", + ) + store.stage_local.return_value = staged + catalog.reserve_media_import.side_effect = FileExistsError + + with self.assertRaises(MediaIdempotencyConflictError): + service.import_quarantined( + QuarantinedMedia( + path=source, + original_filename="upload.mp4", + ), + request_key="request-key", + ) + + store.publish.assert_not_called() + + def test_local_catalog_persists_media_import_idempotency(self): + with TemporaryDirectory() as directory: + catalog = LocalCatalog(Path(directory) / "catalog.sqlite3") + item = record() + catalog.put_media(item) + + self.assertIsNone( + catalog.reserve_media_import( + "request-key", + "f" * 64, + ) + ) + catalog.complete_media_import( + "request-key", + "f" * 64, + item, + ) + replay = catalog.reserve_media_import( + "request-key", + "f" * 64, + ) + + self.assertEqual(replay, item) + with self.assertRaises(FileExistsError): + catalog.reserve_media_import( + "request-key", + "e" * 64, + ) + + def test_existing_checksum_is_reused_without_reprobe(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "video.mp4" + source.write_bytes(b"video") + service, catalog, store, probe = self.service(root) + staged = StagedMedia( + sha256="1" * 64, + byte_size=5, + storage_key="objects/11/video.mp4", + path=root / "staged.tmp", + ) + store.stage_local.return_value = staged + catalog.get_media_by_checksum.return_value = record() + + result = service.import_local(ImportMediaCommand(path=source)) + + self.assertEqual(result.media_id, MEDIA_ID) + probe.probe.assert_not_called() + store.publish.assert_called_once_with(staged) + store.discard.assert_called_once_with(staged) + + def test_catalog_failure_rolls_back_new_managed_content(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "video.mp4" + source.write_bytes(b"video") + service, catalog, store, probe = self.service(root) + staged = StagedMedia( + sha256="1" * 64, + byte_size=5, + storage_key="objects/11/video.mp4", + path=root / "staged.tmp", + ) + stored = StoredMedia( + sha256=staged.sha256, + byte_size=5, + storage_key=staged.storage_key, + local_path=root / "managed.mp4", + ) + store.stage_local.return_value = staged + store.publish.return_value = stored + probe.probe.return_value = MediaProbe( + detected_mime_type="video/mp4", + container="mp4", + duration_seconds=2, + streams=( + MediaStream( + index=0, + kind="video", + codec="h264", + width=1, + height=1, + ), + ), + ) + catalog.get_media_by_checksum.return_value = None + catalog.put_media.side_effect = RuntimeError("catalog failed") + + with self.assertRaisesRegex(RuntimeError, "catalog failed"): + service.import_local(ImportMediaCommand(path=source)) + + store.delete.assert_called_once_with(stored.storage_key) + + def test_media_pages_are_bounded_and_cursor_scoped(self): + with TemporaryDirectory() as directory: + service, catalog, _store, _probe = self.service(Path(directory)) + catalog.count_media.return_value = 3 + catalog.list_media.side_effect = [ + (record(), record().model_copy( + update={ + "media_id": "223456781234423481234567890abcde", + "video_id": "223456781234423481234567890abcde", + "sha256": "2" * 64, + } + )), + (record().model_copy( + update={ + "media_id": "323456781234423481234567890abcde", + "video_id": "323456781234423481234567890abcde", + "sha256": "3" * 64, + } + ),), + ] + + first = service.list(ListMediaCommand(page_size=2)) + second = service.list( + ListMediaCommand(page_size=2, cursor=first.next_cursor) + ) + + self.assertEqual(first.total, 3) + self.assertEqual(len(first.items), 2) + self.assertIsNotNone(first.next_cursor) + self.assertEqual(len(second.items), 1) + self.assertIsNone(second.next_cursor) + self.assertEqual( + catalog.list_media.call_args_list[1].kwargs["offset"], + 2, + ) + + +class ArtifactServiceTests(unittest.TestCase): + def test_actor_overlay_uses_catalog_media_and_store_owned_destination(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source.mp4" + source.write_bytes(b"source") + media = Mock() + media.content.return_value = LocalFileResource( + path=source, + filename="source.mp4", + mime_type="video/mp4", + byte_size=6, + etag="1" * 64, + ) + catalog = Mock() + catalog.get_artifact_by_request.return_value = None + catalog.put_artifact.side_effect = lambda item: item + renderer = Mock() + renderer.render.side_effect = ( + lambda _source, destination, _cluster, _detections, **_kwargs: + destination.write_bytes(b"rendered") + ) + probe = Mock() + probe.probe.return_value = MediaProbe( + detected_mime_type="video/mp4", + container="mp4", + duration_seconds=1, + streams=( + MediaStream( + index=0, + kind="video", + codec="h264", + width=1, + height=1, + ), + ), + ) + store = LocalArtifactStore(root / "artifacts") + service = ArtifactService( + catalog=catalog, + store=store, + media=media, + probe=probe, + actor_renderer=renderer, + snippet_renderer=Mock(), + max_snippet_duration_seconds=300, + ) + with patch("vidxp.artifact_service.uuid4") as identifier: + identifier.return_value.hex = ARTIFACT_ID + result = service.create_actor_overlay( + media_id=MEDIA_ID, + generation_id="223456781234423481234567890abcde", + cluster_id="cluster", + detections=[{"frame_index": 1}], + profile=ActorOverlayProfile.default, + ) + + self.assertEqual(result.artifact_id, ARTIFACT_ID) + self.assertEqual(result.media_id, MEDIA_ID) + self.assertEqual(result.byte_size, len(b"rendered")) + renderer.render.assert_called_once() + self.assertNotIn("path", result.model_dump(mode="json")) + catalog.put_artifact.assert_called_once() + + def test_progress_failure_after_catalog_commit_keeps_artifact_content(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source.mp4" + source.write_bytes(b"source") + media = Mock() + media.content.return_value = LocalFileResource( + path=source, + filename="source.mp4", + mime_type="video/mp4", + byte_size=6, + etag="1" * 64, + ) + catalog = Mock() + catalog.get_artifact_by_request.return_value = None + catalog.put_artifact.side_effect = lambda item: item + renderer = Mock() + renderer.render.side_effect = ( + lambda _source, destination, _cluster, _detections, **_kwargs: + destination.write_bytes(b"rendered") + ) + probe = Mock() + probe.probe.return_value = MediaProbe( + detected_mime_type="video/mp4", + container="mp4", + duration_seconds=1, + streams=( + MediaStream( + index=0, + kind="video", + codec="h264", + width=1, + height=1, + ), + ), + ) + store = LocalArtifactStore(root / "artifacts") + service = ArtifactService( + catalog=catalog, + store=store, + media=media, + probe=probe, + actor_renderer=renderer, + snippet_renderer=Mock(), + max_snippet_duration_seconds=300, + ) + + def fail_final_progress(event): + if event["stage"] == "complete": + raise RuntimeError("progress unavailable") + + with ( + patch("vidxp.artifact_service.uuid4") as identifier, + self.assertRaisesRegex(RuntimeError, "progress unavailable"), + ): + identifier.return_value.hex = ARTIFACT_ID + service.create_actor_overlay( + media_id=MEDIA_ID, + generation_id="223456781234423481234567890abcde", + cluster_id="cluster", + detections=[], + profile=ActorOverlayProfile.default, + execution=ExecutionContext(progress=fail_final_progress), + ) + + committed = catalog.put_artifact.call_args.args[0] + self.assertTrue(store.resolve(committed.storage_key).is_file()) + + def test_durable_replay_recovers_object_published_before_catalog_commit(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source.mp4" + source.write_bytes(b"source") + media = Mock() + media.content.return_value = LocalFileResource( + path=source, + filename="source.mp4", + mime_type="video/mp4", + byte_size=6, + etag="1" * 64, + ) + catalog = Mock() + catalog.get_artifact_by_request.return_value = None + catalog.put_artifact.side_effect = lambda item: item + renderer = Mock() + probe = Mock() + probe.probe.return_value = MediaProbe( + detected_mime_type="video/mp4", + container="mp4", + duration_seconds=1, + streams=( + MediaStream( + index=0, + kind="video", + codec="h264", + width=1, + height=1, + ), + ), + ) + store = LocalArtifactStore(root / "artifacts") + orphan = store.stage(JOB_ID, suffix=".mp4") + orphan.path.write_bytes(b"rendered-before-crash") + store.publish(orphan) + service = ArtifactService( + catalog=catalog, + store=store, + media=media, + probe=probe, + actor_renderer=renderer, + snippet_renderer=Mock(), + max_snippet_duration_seconds=300, + ) + + result = service.create_actor_overlay( + media_id=MEDIA_ID, + generation_id="423456781234423481234567890abcde", + cluster_id="cluster", + detections=[], + profile=ActorOverlayProfile.default, + job_id=JOB_ID, + execution=ExecutionContext(job_id=JOB_ID), + ) + + self.assertEqual(result.artifact_id, JOB_ID) + renderer.render.assert_not_called() + catalog.put_artifact.assert_called_once() + + def test_ffmpeg_snippet_renderer_terminates_on_cancellation(self): + cancellation = CancellationToken() + cancellation.cancel() + process = Mock() + process.poll.return_value = None + process.wait.return_value = 0 + renderer = FFmpegSnippetRenderer() + + with ( + patch( + "vidxp.infrastructure.local_artifacts.subprocess.Popen", + return_value=process, + ), + self.assertRaises(IndexCancelledError), + ): + renderer.render( + Path("source.mp4"), + Path("snippet.mp4"), + start_seconds=0, + end_seconds=1, + compatible_mp4=True, + cancellation=cancellation, + progress=None, + ) + + process.terminate.assert_called_once() + + def test_actor_renderer_failure_leaves_no_staged_or_ready_artifact(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source.mp4" + source.write_bytes(b"source") + media = Mock() + media.content.return_value = LocalFileResource( + path=source, + filename="source.mp4", + mime_type="video/mp4", + byte_size=6, + etag="1" * 64, + ) + catalog = Mock() + catalog.get_artifact_by_request.return_value = None + renderer = Mock() + renderer.render.side_effect = RuntimeError("render failed") + store = LocalArtifactStore(root / "artifacts") + service = ArtifactService( + catalog=catalog, + store=store, + media=media, + probe=Mock(), + actor_renderer=renderer, + snippet_renderer=Mock(), + max_snippet_duration_seconds=300, + ) + with ( + patch("vidxp.artifact_service.uuid4") as identifier, + self.assertRaisesRegex(RuntimeError, "render failed"), + ): + identifier.return_value.hex = ARTIFACT_ID + service.create_actor_overlay( + media_id=MEDIA_ID, + generation_id="223456781234423481234567890abcde", + cluster_id="cluster", + detections=[], + profile=ActorOverlayProfile.default, + ) + + self.assertFalse(any(store.staging.glob("*"))) + catalog.put_artifact.assert_not_called() + + def test_invalid_rendered_media_is_not_published_or_cataloged(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source.mp4" + source.write_bytes(b"source") + media = Mock() + media.content.return_value = LocalFileResource( + path=source, + filename="source.mp4", + mime_type="video/mp4", + byte_size=6, + etag="1" * 64, + ) + catalog = Mock() + catalog.get_artifact_by_request.return_value = None + renderer = Mock() + renderer.render.side_effect = ( + lambda _source, destination, _cluster, _detections, **_kwargs: + destination.write_bytes(b"not-video") + ) + probe = Mock() + probe.probe.side_effect = InvalidMediaError("invalid") + store = LocalArtifactStore(root / "artifacts") + service = ArtifactService( + catalog=catalog, + store=store, + media=media, + probe=probe, + actor_renderer=renderer, + snippet_renderer=Mock(), + max_snippet_duration_seconds=300, + ) + + with self.assertRaises(InvalidArtifactError): + service.create_actor_overlay( + media_id=MEDIA_ID, + generation_id="223456781234423481234567890abcde", + cluster_id="cluster", + detections=[], + profile=ActorOverlayProfile.default, + ) + + self.assertFalse(any(store.staging.glob("*"))) + catalog.put_artifact.assert_not_called() + + def test_snippet_is_bounded_and_reuses_ready_request(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source.mp4" + source.write_bytes(b"source") + media = Mock() + media.require_record.return_value = record() + media.content.return_value = LocalFileResource( + path=source, + filename="source.mp4", + mime_type="video/mp4", + byte_size=6, + etag="1" * 64, + ) + catalog = Mock() + catalog.get_artifact_by_request.return_value = None + catalog.put_artifact.side_effect = lambda item: item + renderer = Mock() + renderer.render.side_effect = ( + lambda _source, destination, **_kwargs: + destination.write_bytes(b"rendered") + ) + probe = Mock() + probe.probe.return_value = MediaProbe( + detected_mime_type="video/mp4", + container="mp4", + duration_seconds=1, + streams=( + MediaStream( + index=0, + kind="video", + codec="h264", + width=1, + height=1, + ), + ), + ) + store = LocalArtifactStore(root / "artifacts") + service = ArtifactService( + catalog=catalog, + store=store, + media=media, + probe=probe, + actor_renderer=Mock(), + snippet_renderer=renderer, + max_snippet_duration_seconds=1, + ) + command = CreateSnippetCommand( + media_id=MEDIA_ID, + start_seconds=0, + end_seconds=1, + profile=SnippetProfile.compatible_mp4, + ) + + created = service.create_snippet(command) + catalog.get_artifact_by_request.return_value = ( + catalog.put_artifact.call_args.args[0] + ) + reused = service.create_snippet(command) + cached_record = catalog.put_artifact.call_args.args[0] + store.delete(cached_record.storage_key) + regenerated = service.create_snippet(command) + + self.assertEqual(reused.artifact_id, created.artifact_id) + self.assertNotEqual(regenerated.artifact_id, created.artifact_id) + self.assertEqual(renderer.render.call_count, 2) + catalog.invalidate_artifact_request.assert_called_once_with( + cached_record.request_key, + cached_record.artifact_id, + ) + + def test_expired_artifact_is_unavailable(self): + catalog = Mock() + expired = Mock() + catalog.get_artifact.return_value = expired + expired.state = ArtifactState.ready + expired.expires_at = datetime(2000, 1, 1, tzinfo=timezone.utc) + service = ArtifactService( + catalog=catalog, + store=Mock(), + media=Mock(), + probe=Mock(), + actor_renderer=Mock(), + snippet_renderer=Mock(), + max_snippet_duration_seconds=1, + ) + + with self.assertRaises(ArtifactUnavailableError): + service.require_record(ARTIFACT_ID) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_models.py b/tests/test_models.py index 50c144a..0d0a56d 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,110 +1,492 @@ +import hashlib +import os import sys import types import unittest +from concurrent.futures import ThreadPoolExecutor +from dataclasses import replace from importlib.metadata import PackageNotFoundError -from unittest.mock import Mock, patch +from pathlib import Path +from tempfile import TemporaryDirectory +from threading import Barrier +from time import sleep +from unittest.mock import Mock, call, patch + +from packaging.requirements import Requirement +from pydantic import ValidationError from vidxp.capabilities.contracts import CapabilityDefinition from vidxp.capabilities.dialogue import models as dialogue_models -from vidxp.capabilities.registry import ( - dependency_checks, - requirements_for, - runtime_checks_for, - runtime_distributions, +from vidxp.capabilities.dialogue.specs import ( + FASTER_WHISPER_MODEL, + QWEN3_EMBEDDING_MODEL, ) -from vidxp.core.contracts import VideoSource -from vidxp.dependencies import inspect_requirement -from packaging.requirements import Requirement +from vidxp.capabilities.actor import models as actor_models +from vidxp.capabilities.actor.definition import model_manifest as actor_manifest +from vidxp.capabilities.actor.specs import SFACE_MODEL, YUNET_MODEL +from vidxp.capabilities.registry import create_capability_registry +from vidxp.application_models import DependencyKind +from vidxp.core.contracts import IndexConfig, VideoSource +from vidxp.dependencies import ( + active_requirements, + inspect_requirement, + packaged_requirements, +) +from vidxp.infrastructure.local_index import ( + LOCAL_INDEX_RUNTIME_CHECKS, + SERVER_INDEX_RUNTIME_CHECKS, +) +from vidxp.model_contracts import ( + ModelArtifactUnavailableError, + ModelKey, + model_artifact_path, +) +from vidxp.runtime import ModelRuntime, resolve_backends +from vidxp.settings import VidXPSettings class ModelTests(unittest.TestCase): - def tearDown(self): - dialogue_models.clear_model_cache() + def runtime( + self, + root: str, + *, + allowed_specs=None, + allow_model_downloads: bool | None = None, + ) -> ModelRuntime: + settings = { + "repository_root": root, + "model_cache": Path(root) / "models", + "runtime_backend": "cpu", + } + if allow_model_downloads is not None: + settings["allow_model_downloads"] = allow_model_downloads + return ModelRuntime( + VidXPSettings(**settings), + allowed_specs=( + create_capability_registry().model_specs() + if allowed_specs is None + else tuple(allowed_specs) + ), + ) - def test_dialogue_model_is_reused_across_videos(self): - constructor = Mock(return_value=object()) - fake_module = types.SimpleNamespace(SentenceTransformer=constructor) - with patch.dict(sys.modules, {"sentence_transformers": fake_module}): - first = dialogue_models.get_embedder("model-id", "cpu") - second = dialogue_models.get_embedder("model-id", "cpu") + def test_model_runtime_reuses_one_provider_instance(self): + with TemporaryDirectory() as directory: + runtime = self.runtime(directory) + constructor = Mock(return_value=object()) + fake_module = types.SimpleNamespace( + SentenceTransformer=constructor + ) + snapshot = Path(directory) / "snapshot" + runtime.resolve_model = Mock(return_value=snapshot) + with patch.dict( + sys.modules, + {"sentence_transformers": fake_module}, + ): + first = dialogue_models.get_embedder(runtime) + second = dialogue_models.get_embedder(runtime) self.assertIs(first, second) - constructor.assert_called_once_with("model-id", device="cpu") + constructor.assert_called_once_with( + str(snapshot), + device="cpu", + cache_folder=str(Path(directory) / "models"), + local_files_only=True, + ) + self.assertEqual( + runtime.describe()["compute_precision"]["dialogue.embedding"], + "bfloat16", + ) + + def test_unrelated_model_loads_do_not_hold_the_global_cache_lock(self): + runtime = self.runtime("unused") + barrier = Barrier(2) + + def load(value): + barrier.wait(timeout=2) + return value + + keys = ( + ModelKey("scene", "one", "one", "1", "cpu"), + ModelKey("dialogue", "two", "two", "1", "cpu"), + ) + with ThreadPoolExecutor(max_workers=2) as pool: + futures = [ + pool.submit(runtime.get_or_load, key, lambda v=value: load(v)) + for value, key in enumerate(keys) + ] + self.assertEqual([future.result() for future in futures], [0, 1]) + + def test_actor_download_uses_pinned_media_object_not_lfs_pointer(self): + with TemporaryDirectory() as directory: + content = b"verified model" + downloaded = Path(directory) / "model.onnx" + downloaded.write_bytes(content) + spec = replace( + YUNET_MODEL, + filename=downloaded.name, + sha256=hashlib.sha256(content).hexdigest(), + ) + runtime = self.runtime(directory, allowed_specs=(spec,)) + retrieve = Mock(return_value=str(downloaded)) + fake_pooch = types.SimpleNamespace(retrieve=retrieve) + with patch.dict(sys.modules, {"pooch": fake_pooch}): + result = runtime.resolve_artifact(spec, download=True) + + self.assertEqual(result, downloaded) + url = retrieve.call_args.kwargs["url"] + self.assertIn("media.githubusercontent.com/media/", url) + self.assertIn(spec.revision, url) + self.assertNotIn("raw.githubusercontent.com", url) + self.assertEqual( + retrieve.call_args.kwargs["known_hash"], + f"sha256:{spec.sha256}", + ) + self.assertEqual( + runtime.describe()["resolved_models"]["actor.detector"]["model"], + "yunet", + ) + + def test_normal_model_resolution_never_downloads_implicitly(self): + with TemporaryDirectory() as directory: + spec = replace(YUNET_MODEL, filename="missing.onnx") + runtime = self.runtime( + directory, + allowed_specs=(spec,), + allow_model_downloads=True, + ) + retrieve = Mock(side_effect=AssertionError("downloaded")) + with ( + patch.dict( + sys.modules, + {"pooch": types.SimpleNamespace(retrieve=retrieve)}, + ), + self.assertRaises(ModelArtifactUnavailableError), + ): + runtime.resolve_artifact(spec) + + retrieve.assert_not_called() + + def test_model_readiness_checks_the_pinned_cache_without_loading(self): + with TemporaryDirectory() as directory: + cache = Path(directory) / "models" + path = model_artifact_path(cache, YUNET_MODEL) + path.parent.mkdir(parents=True) + path.write_bytes(b"present") + checks = create_capability_registry().model_checks( + ("actor",), + cache=cache, + ) + + self.assertEqual( + [ + (check.name, check.download_size_bytes, check.ok) + for check in checks + ], + [ + ( + YUNET_MODEL.model_id, + YUNET_MODEL.download_size_bytes, + True, + ), + ( + SFACE_MODEL.model_id, + SFACE_MODEL.download_size_bytes, + False, + ), + ], + ) + + def test_explicit_snapshot_download_reports_bytes(self): + with TemporaryDirectory() as directory: + snapshot = Path(directory) / "snapshot" + events = [] + + def download(**options): + progress = options["tqdm_class"]( + desc="Downloading bytes", + total=1024, + unit="B", + ) + progress.update(512) + sleep(0.6) + progress.update(512) + sleep(0.6) + progress.close() + return str(snapshot) + + with patch( + "huggingface_hub.snapshot_download", + side_effect=download, + ), patch( + "huggingface_hub.constants.HF_HUB_DISABLE_XET", + False, + ): + resolved = ModelRuntime._download_snapshot( + FASTER_WHISPER_MODEL, + cache=Path(directory), + progress=events.append, + ) + self.assertTrue( + __import__("huggingface_hub").constants.HF_HUB_DISABLE_XET + ) + + self.assertEqual(resolved, snapshot) + self.assertTrue( + any( + event["stage"] == "downloading_model" + and event["current"] == 1024 + and event["total"] == 1024 + for event in events + ) + ) + + def test_runtime_rejects_specs_not_declared_by_enabled_capabilities(self): + with TemporaryDirectory() as directory: + runtime = self.runtime(directory) + untrusted = replace(YUNET_MODEL, model_id="untrusted-detector") + + with self.assertRaises(ModelArtifactUnavailableError): + runtime.resolve_artifact(untrusted) + + self.assertEqual(runtime.describe()["resolved_models"], {}) + + def test_actor_artifact_resolves_verified_offline_cache(self): + with TemporaryDirectory() as directory: + content = b"verified cached model" + spec = replace( + YUNET_MODEL, + filename="cached.onnx", + sha256=hashlib.sha256(content).hexdigest(), + ) + cache = Path(directory) / "models" + path = cache / spec.provider / spec.filename + path.parent.mkdir(parents=True) + path.write_bytes(content) + runtime = ModelRuntime( + VidXPSettings( + repository_root=directory, + model_cache=cache, + runtime_backend="cpu", + allow_model_downloads=False, + ), + allowed_specs=(spec,), + ) + fake_pooch = types.SimpleNamespace( + retrieve=Mock(side_effect=AssertionError("downloaded")) + ) + with patch.dict(sys.modules, {"pooch": fake_pooch}): + resolved = runtime.resolve_artifact(spec) + + self.assertEqual(resolved, path) + self.assertTrue( + runtime.describe()["resolved_models"]["actor.detector"]["cached"] + ) + + def test_bad_or_missing_offline_artifact_is_not_recorded(self): + for content in (None, b"corrupt"): + with self.subTest(content=content): + with TemporaryDirectory() as directory: + cache = Path(directory) / "models" + spec = replace( + YUNET_MODEL, + filename="cached.onnx", + sha256=hashlib.sha256(b"expected").hexdigest(), + ) + if content is not None: + path = cache / spec.provider / spec.filename + path.parent.mkdir(parents=True) + path.write_bytes(content) + runtime = ModelRuntime( + VidXPSettings( + repository_root=directory, + model_cache=cache, + runtime_backend="cpu", + allow_model_downloads=False, + ), + allowed_specs=(spec,), + ) + with self.assertRaises(ModelArtifactUnavailableError): + runtime.resolve_artifact(spec) + + self.assertNotIn( + "actor.detector", + runtime.describe()["resolved_models"], + ) + + def test_actor_models_resolve_both_specs_through_runtime(self): + runtime = Mock() + runtime.device_for.return_value = "cpu" + runtime.get_or_load.side_effect = lambda _key, loader: loader() + runtime.resolve_artifact.side_effect = ( + Path("detector.onnx"), + Path("recognizer.onnx"), + ) + detector = Mock() + recognizer = Mock() + fake_cv2 = types.SimpleNamespace( + FaceDetectorYN=types.SimpleNamespace(create=detector), + FaceRecognizerSF=types.SimpleNamespace(create=recognizer), + ) + + with patch.dict(sys.modules, {"cv2": fake_cv2}): + actor_models.get_actor_models(runtime) - def test_scene_dependency_check_does_not_touch_other_capabilities(self): + self.assertEqual( + [call.args[0] for call in runtime.resolve_artifact.call_args_list], + [YUNET_MODEL, SFACE_MODEL], + ) + detector.assert_called_once() + recognizer.assert_called_once() + self.assertEqual( + runtime.record_compute_precision.call_args_list, + [ + call("actor.detector", "float32"), + call("actor.recognizer", "float32"), + ], + ) + + def test_actor_manifest_derives_provider_identity_from_specs(self): + manifest = actor_manifest(IndexConfig.local(), ()) + actor = manifest["actor"] + + self.assertEqual( + actor["models"], + { + "detector": YUNET_MODEL.identity(), + "recognizer": SFACE_MODEL.identity(), + }, + ) + for repeated in ("provider", "revision", "license", "precision"): + self.assertNotIn(repeated, actor) + + def test_model_spec_metadata_is_canonical_and_unambiguous(self): + self.assertEqual(YUNET_MODEL.license, "MIT") + self.assertEqual(SFACE_MODEL.license, "Apache-2.0") + self.assertEqual(QWEN3_EMBEDDING_MODEL.weights_precision, "bfloat16") + self.assertEqual( + FASTER_WHISPER_MODEL.model_id, + "dropbox-dash/faster-whisper-large-v3-turbo", + ) + self.assertEqual( + FASTER_WHISPER_MODEL.weights_precision, + "float16", + ) + + def test_scene_dependency_check_is_capability_scoped(self): + registry = create_capability_registry() inspected = [] - versions = { - "chromadb": "1.0", - "clip-anytorch": "2.6.0", - "numpy": "2.1", - "opencv-python": "4.10", - "Pillow": "10.0", - "setuptools": "80.10.2", - "torch": "2.5", - } + def installed(name): + inspected.append(name) + return { + "chromadb": "1.5.9", + "psutil": "7.2.2", + "numpy": "2.5.1", + "opencv-python-headless": "5.0.0.93", + "Pillow": "12.3.0", + "huggingface-hub": "1.25.1", + "torch": "2.13.0", + "transformers": "5.14.1", + }[name] - with patch( - "vidxp.dependencies.version", - side_effect=lambda name: ( - inspected.append(name), - versions[name], - )[1], - ): - checks = dependency_checks(("scene",)) + with patch("vidxp.dependencies.version", side_effect=installed): + checks = registry.dependency_checks(("scene",)) - self.assertTrue(all(check["ok"] for check in checks)) - self.assertNotIn("whisperx", inspected) - self.assertNotIn("face-recognition", inspected) + self.assertTrue( + all( + check.ok + for check in checks + if check.kind == DependencyKind.distribution + ) + ) + self.assertNotIn("faster-whisper", inspected) self.assertNotIn("sentence-transformers", inspected) + self.assertNotIn("pooch", inspected) + + def test_platform_runtime_checks_are_storage_owned_and_not_duplicated(self): + registry = create_capability_registry( + platform_runtime_checks=LOCAL_INDEX_RUNTIME_CHECKS + ) + + checks = registry.dependency_checks(registry.names()) + storage_checks = [ + check + for check in checks + if check.kind == DependencyKind.runtime + and check.capability == "storage" + ] + + self.assertEqual( + [check.name for check in storage_checks], + [ + "Chroma storage import", + "Host resource monitor import", + ], + ) - def test_supplied_transcript_only_requires_dialogue_search_dependencies(self): + def test_server_storage_checks_match_the_remote_chroma_client(self): + registry = create_capability_registry( + platform_runtime_checks=SERVER_INDEX_RUNTIME_CHECKS, + storage_requirements=active_requirements( + packaged_requirements( + "vidxp", + "requirements/server-storage.txt", + ) + ), + ) + + requirements = { + requirement.name + for requirement in registry.requirements_for(("scene",)) + } + + self.assertIn("chromadb-client", requirements) + self.assertNotIn("chromadb", requirements) + + def test_transcript_only_excludes_transcription_provider(self): + registry = create_capability_registry() source = VideoSource( transcript=({"text": "hello", "start": 0, "end": 1},) ) distributions = { requirement.name - for requirement in requirements_for( + for requirement in registry.requirements_for( ("dialogue",), source=source, ) } self.assertIn("sentence-transformers", distributions) - self.assertNotIn("whisperx", distributions) - self.assertNotIn("moviepy", distributions) - self.assertNotIn("opencv-python", distributions) + self.assertNotIn("faster-whisper", distributions) - def test_runtime_distributions_come_from_capability_registry(self): + def test_runtime_distributions_come_from_registry(self): + registry = create_capability_registry() with patch( "vidxp.capabilities.registry.installed_base_requirements", return_value=(), ): - distributions = runtime_distributions() + distributions = registry.runtime_distributions() - self.assertIn("clip-anytorch", distributions) - self.assertIn("face-recognition", distributions) + self.assertIn("transformers", distributions) + self.assertIn("faster-whisper", distributions) + self.assertIn("opencv-python-headless", distributions) + self.assertNotIn("clip-anytorch", distributions) + self.assertNotIn("face-recognition", distributions) self.assertEqual(len(distributions), len(set(distributions))) - def test_requirement_files_are_the_only_python_dependency_contract(self): + def test_requirement_files_are_dependency_contract(self): self.assertNotIn("dependencies", CapabilityDefinition.model_fields) - checks = runtime_checks_for( - ("dialogue",), - source=VideoSource( - transcript=({"text": "hello", "start": 0, "end": 1},) - ), - ) - self.assertEqual(checks, ()) - self.assertEqual( - [check.label for check in runtime_checks_for( - ("dialogue",), - source=VideoSource(path="video.mp4"), - )], - ["FFmpeg"], + registry = create_capability_registry() + self.assertIn( + "faster-whisper import", + { + check.label + for check in registry.runtime_checks_for(("dialogue",)) + }, ) - def test_requirement_check_uses_distribution_metadata_and_specifier(self): + def test_requirement_check_uses_distribution_metadata(self): requirement = Requirement("example>=2,<3") with patch("vidxp.dependencies.version", return_value="2.5"): self.assertTrue(inspect_requirement(requirement)["ok"]) @@ -116,6 +498,74 @@ def test_requirement_check_uses_distribution_metadata_and_specifier(self): ): self.assertFalse(inspect_requirement(requirement)["ok"]) + def test_server_runtime_and_external_allowlist_are_explicit(self): + with self.assertRaises(ValidationError): + VidXPSettings(mode="server", runtime_backend="mps") + with self.assertRaises(ValidationError): + VidXPSettings(capability_allowlist=("distribution-only",)) + settings = VidXPSettings( + mode="server", + runtime_backend="cuda:0", + capability_allowlist=("acme-capabilities:ocr",), + ) + self.assertEqual(settings.runtime_backend, "cuda:0") + self.assertNotIn("database_url", VidXPSettings.model_fields) + self.assertNotIn("chroma_server_url", VidXPSettings.model_fields) + with self.assertRaises(ValidationError): + VidXPSettings(slm_base_url="http://localhost:11434/v1") + with self.assertRaises(ValidationError): + VidXPSettings( + slm_base_url="https://ollama.com/v1", + slm_model="qwen3", + ) + with self.assertRaises(ValidationError): + VidXPSettings( + slm_base_url="http://localhost:11434/v1", + slm_model="qwen3-cloud", + ) + slm = VidXPSettings( + slm_base_url="http://localhost:11434/v1", + slm_model="evaluated-model", + ) + self.assertEqual(slm.slm_model, "evaluated-model") + + def test_only_optional_slm_environment_values_ignore_empty_strings(self): + with patch.dict( + os.environ, + { + "VIDXP_SLM_BASE_URL": "", + "VIDXP_SLM_MODEL": "", + }, + clear=True, + ): + settings = VidXPSettings(_env_file=None) + + self.assertIsNone(settings.slm_base_url) + self.assertIsNone(settings.slm_model) + + with ( + patch.dict( + os.environ, + { + "VIDXP_HTTP_AUTH_MODE": "static", + "VIDXP_HTTP_STATIC_BEARER_TOKEN": "", + }, + clear=True, + ), + self.assertRaises(ValidationError), + ): + VidXPSettings(_env_file=None) + + def test_auto_runtime_remains_cpu_until_acceleration_parity_is_enabled(self): + with patch( + "vidxp.runtime._torch_accelerators", + return_value=(True, True), + ): + profile = resolve_backends("auto") + + self.assertEqual(profile.torch_device, "cpu") + self.assertEqual(profile.transcription_device, "cpu") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_ollama_query.py b/tests/test_ollama_query.py new file mode 100644 index 0000000..61cf331 --- /dev/null +++ b/tests/test_ollama_query.py @@ -0,0 +1,136 @@ +import asyncio +import json +import unittest + +import httpx + +from vidxp.application_models import ( + DraftAnswer, + MomentEvidence, + QueryPlanningRequest, + QuerySynthesisRequest, + SearchHit, +) +from vidxp.infrastructure.ollama_query import OllamaQueryModel + + +MEDIA_ID = "123456781234423481234567890abcde" +GENERATION_ID = "223456781234423481234567890abcde" +SNAPSHOT_ID = "323456781234423481234567890abcde" +EVIDENCE_ID = "a" * 64 + + +class OllamaQueryModelTests(unittest.TestCase): + def test_structured_planning_and_synthesis_use_the_provider_contract(self): + outputs = [ + { + "steps": [ + { + "kind": "search_moments", + "modality": "dialogue", + "query": "taxi arrival", + } + ] + }, + { + "claims": [ + { + "text": "The taxi arrived.", + "evidence_ids": [EVIDENCE_ID], + } + ] + }, + ] + requests: list[dict[str, object]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(json.loads(request.content)) + return httpx.Response( + 200, + request=request, + json={ + "id": f"chatcmpl-{len(requests)}", + "object": "chat.completion", + "created": 1, + "model": "contract-model", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": json.dumps(outputs.pop(0)), + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 10, + "total_tokens": 20, + }, + }, + ) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + model = OllamaQueryModel( + base_url="http://ollama.test/v1", + model_name="contract-model", + timeout_seconds=2, + output_retries=0, + http_client=client, + ) + hit = SearchHit( + rank=1, + media_id=MEDIA_ID, + video_id=MEDIA_ID, + generation_id=GENERATION_ID, + start=1, + end=2, + score=-0.1, + raw_distance=0.1, + modality="dialogue", + source_id="dialogue:1", + metadata={"text": "the taxi arrived"}, + ) + evidence = MomentEvidence( + evidence_id=EVIDENCE_ID, + snapshot_id=SNAPSHOT_ID, + media_id=MEDIA_ID, + generation_id=GENERATION_ID, + modality="dialogue", + source_id="dialogue:1", + start=1, + end=2, + display_text="the taxi arrived", + hit=hit, + ) + try: + plan = model.plan( + QueryPlanningRequest( + question="When did the taxi arrive?", + allowed_modalities=("dialogue",), + ) + ) + answer = model.synthesize( + QuerySynthesisRequest( + question="When did the taxi arrive?", + evidence=(evidence,), + ) + ) + finally: + asyncio.run(client.aclose()) + + self.assertEqual(plan.steps[0].query, "taxi arrival") + self.assertIsInstance(answer, DraftAnswer) + self.assertEqual(answer.claims[0].evidence_ids, (EVIDENCE_ID,)) + self.assertEqual(len(requests), 2) + for request in requests: + self.assertEqual( + request["response_format"]["type"], + "json_schema", + ) + self.assertEqual(request["model"], "contract-model") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 5338ddc..75b12be 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -1,13 +1,101 @@ +import json import unittest from pathlib import Path +import tomllib -from vidxp.capabilities.registry import CAPABILITIES +from packaging.requirements import Requirement +from packaging.version import Version + +from vidxp.capabilities.registry import create_capability_registry ROOT = Path(__file__).resolve().parents[1] class PackagingTests(unittest.TestCase): + def test_canonical_icon_is_packaged_and_desktop_derivatives_are_wired(self): + icon = ROOT / "docs" / "images" / "logo.png" + self.assertTrue(icon.is_file()) + self.assertTrue(icon.read_bytes().startswith(b"\x89PNG\r\n\x1a\n")) + + self.assertIn( + "include docs/images/logo.png", + (ROOT / "MANIFEST.in").read_text(encoding="utf-8"), + ) + project = tomllib.loads( + (ROOT / "pyproject.toml").read_text(encoding="utf-8") + ) + self.assertEqual( + project["project"]["urls"]["Homepage"], + "https://github.com/grayhatdevelopers/vidxp", + ) + self.assertIn( + "./docs/images/logo.png", + (ROOT / "README.md").read_text(encoding="utf-8"), + ) + build_hook = (ROOT / "setup.py").read_text(encoding="utf-8") + self.assertIn('"docs" / "images" / "logo.png"', build_hook) + self.assertIn('"vidxp" / "assets" / "icon.png"', build_hook) + + package = json.loads( + (ROOT / "desktop" / "package.json").read_text(encoding="utf-8") + ) + self.assertEqual( + package["scripts"]["sync:branding"], + "node scripts/sync-branding.mjs", + ) + self.assertEqual( + package["scripts"]["predesktop:dev"], + "npm run sync:branding", + ) + self.assertEqual( + package["scripts"]["predesktop:build"], + "npm run icons", + ) + self.assertEqual( + package["scripts"]["icons"], + ( + "npm run sync:branding && " + "tauri icon ../docs/images/logo.png " + "--output src-tauri/icons" + ), + ) + sync_script = ( + ROOT / "desktop" / "scripts" / "sync-branding.mjs" + ).read_text(encoding="utf-8") + self.assertIn("../docs/images/logo.png", sync_script) + self.assertIn("web/icon.png", sync_script) + self.assertIn( + 'href="icon.png"', + (ROOT / "desktop" / "web" / "index.html").read_text( + encoding="utf-8" + ), + ) + + def test_cpu_lock_uses_explicit_pytorch_index_without_cuda_packages(self): + lock = (ROOT / "uv.lock").read_text(encoding="utf-8") + project = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + + self.assertIn("https://download.pytorch.org/whl/cpu", lock) + self.assertNotIn('name = "nvidia-', lock) + self.assertIn('explicit = true', project) + self.assertIn("sys_platform == 'linux'", project) + self.assertIn("sys_platform == 'win32'", project) + + def test_publishable_metadata_contains_no_direct_url_requirements(self): + project = tomllib.loads( + (ROOT / "pyproject.toml").read_text(encoding="utf-8") + )["project"] + requirements = list(project["dependencies"]) + requirements.extend( + line.strip() + for path in (ROOT / "src" / "vidxp").rglob("requirements.txt") + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") + ) + for value in requirements: + self.assertIsNone(Requirement(value).url, value) + def test_capability_extras_read_capability_owned_requirements(self): pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8") storage = "src/vidxp/requirements/storage.txt" @@ -16,7 +104,7 @@ def test_capability_extras_read_capability_owned_requirements(self): 1, )[0] - for capability in CAPABILITIES.values(): + for capability in create_capability_registry().definitions.values(): extra_block = pyproject.split( f"{capability.extra} = {{ file = [", 1, @@ -36,13 +124,26 @@ def test_capability_extras_read_capability_owned_requirements(self): self.assertIn(f'"{relative}"', all_block) self.assertIn(f'storage = {{ file = ["{storage}"] }}', pyproject) - self.assertEqual( - sum( - line.strip() == "chromadb" - for path in (ROOT / "src" / "vidxp").rglob("*.txt") + chroma_contracts = { + path.relative_to(ROOT).as_posix() + for path in (ROOT / "src" / "vidxp").rglob("*.txt") + if any( + line.strip().startswith("chromadb") for line in path.read_text(encoding="utf-8").splitlines() - ), - 1, + ) + } + self.assertEqual( + chroma_contracts, + { + "src/vidxp/requirements/storage.txt", + "src/vidxp/requirements/server-storage.txt", + }, + ) + self.assertIn( + "chromadb-client", + ( + ROOT / "src" / "vidxp" / "requirements" / "server-storage.txt" + ).read_text(encoding="utf-8"), ) self.assertNotIn("benchmarks/requirements.txt", all_block) self.assertNotIn("requirements/frontend.txt", all_block) @@ -56,19 +157,188 @@ def test_base_dependencies_exclude_capability_runtimes(self): for distribution in ( "chromadb", - "face-recognition", - "moviepy", + "faster-whisper", "numpy", - "opencv-python", + "opencv-python-headless", "sentence-transformers", "torch", - "whisperx", - "clip-anytorch", + "transformers", + "pooch", "streamlit", "srt", ): self.assertNotIn(distribution, base_dependencies) + def test_install_profiles_keep_server_free_of_ml_dependencies(self): + pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + self.assertIn("local-worker = { file = [", pyproject) + self.assertIn("mcp = { file = [", pyproject) + self.assertIn("server = { file = [", pyproject) + self.assertIn( + '"src/vidxp/requirements/mcp.txt"', + pyproject, + ) + server = ( + ROOT / "src" / "vidxp" / "requirements" / "server.txt" + ).read_text(encoding="utf-8") + for distribution in ( + "chromadb", + "faster-whisper", + "opencv-python-headless", + "sentence-transformers", + "torch", + "transformers", + ): + self.assertNotIn(distribution, server) + for requirement in ( + "asgi-correlation-id>=5.0.1,<6", + "fastapi>=0.140.13,<0.141", + "pyjwt[crypto]>=2.13,<3", + "python-multipart>=0.0.32,<0.1", + "uvicorn[standard]>=0.51,<0.52", + ): + self.assertIn(requirement, server) + self.assertNotIn("fastapi-mcp", server) + self.assertNotIn("\nmcp", server) + self.assertNotIn("httpx2", server) + mcp_requirements = ( + ROOT / "src" / "vidxp" / "requirements" / "mcp.txt" + ).read_text(encoding="utf-8") + self.assertIn("mcp>=2.0,<3", mcp_requirements) + self.assertNotIn("fastmcp", mcp_requirements) + self.assertNotIn("fastapi-mcp", mcp_requirements) + slm_requirements = ( + ROOT / "src" / "vidxp" / "requirements" / "slm.txt" + ).read_text(encoding="utf-8") + self.assertIn( + "pydantic-ai-slim[openai]>=2.13,<3", + slm_requirements, + ) + self.assertNotIn("pydantic-ai", server) + test_requirements = ( + ROOT / "src" / "vidxp" / "requirements" / "test.txt" + ).read_text(encoding="utf-8") + self.assertIn("httpx>=0.28.1,<0.29", test_requirements) + self.assertNotIn("httpx2", test_requirements) + + def test_optional_ollama_profile_never_pulls_a_model_implicitly(self): + compose = (ROOT / "compose.coolify.yaml").read_text( + encoding="utf-8" + ) + + self.assertIn( + "ollama/ollama:0.32.5@sha256:" + "4dea9fb511947e24a84237bb636b0203abcb2ff0d3fbc7b4ff865deb91362131", + compose, + ) + self.assertIn('profiles: ["slm"]', compose) + self.assertIn( + "VIDXP_SLM_BASE_URL: ${VIDXP_SLM_BASE_URL:-}", + compose, + ) + self.assertIn( + "VIDXP_SLM_MODEL: ${VIDXP_SLM_MODEL:-}", + compose, + ) + self.assertNotIn("ollama pull", compose.lower()) + + def test_desktop_manifest_matches_published_package_contract(self): + project = tomllib.loads( + (ROOT / "pyproject.toml").read_text(encoding="utf-8") + ) + self.assertEqual( + project["project"]["scripts"]["vidxp"], + "vidxp.entrypoint:main", + ) + manifest = json.loads( + (ROOT / "desktop" / "runtime-manifest.json").read_text( + encoding="utf-8" + ) + ) + package = json.loads( + (ROOT / "desktop" / "package.json").read_text(encoding="utf-8") + ) + tauri = json.loads( + ( + ROOT + / "desktop" + / "src-tauri" + / "tauri.conf.json" + ).read_text(encoding="utf-8") + ) + cargo = tomllib.loads( + ( + ROOT / "desktop" / "src-tauri" / "Cargo.toml" + ).read_text(encoding="utf-8") + ) + + self.assertEqual(manifest["package_name"], project["project"]["name"]) + self.assertEqual( + Version(manifest["package_version"]), + Version(project["project"]["version"]), + ) + self.assertEqual(package["version"], manifest["desktop_version"]) + self.assertEqual(tauri["version"], manifest["desktop_version"]) + self.assertFalse(tauri["app"]["windows"][0]["visible"]) + self.assertEqual( + tauri["bundle"]["windows"]["nsis"]["installerHooks"], + "nsis-hooks.nsh", + ) + nsis_hooks = ( + ROOT / "desktop" / "src-tauri" / "nsis-hooks.nsh" + ).read_text(encoding="utf-8") + self.assertIn( + r"$LOCALAPPDATA\Programs\${PRODUCTNAME}", + nsis_hooks, + ) + self.assertIn( + "tray-icon", + cargo["dependencies"]["tauri"]["features"], + ) + self.assertEqual( + Version(manifest["desktop_version"]), + Version(manifest["package_version"]), + ) + self.assertEqual(manifest["python_version"], "3.14.6") + self.assertEqual(manifest["uv_version"], "0.12.0") + sidecars = json.loads( + (ROOT / "desktop" / "sidecars.json").read_text(encoding="utf-8") + ) + self.assertEqual(sidecars["uv_version"], manifest["uv_version"]) + self.assertEqual( + set(sidecars["targets"]), + { + "x86_64-pc-windows-msvc", + "aarch64-apple-darwin", + "x86_64-unknown-linux-gnu", + }, + ) + for target in sidecars["targets"].values(): + self.assertRegex(target["sha256"], r"^[a-f0-9]{64}$") + dynamic_extras = project["tool"]["setuptools"]["dynamic"][ + "optional-dependencies" + ] + self.assertEqual(set(manifest["surfaces"]), {"browser"}) + self.assertTrue(manifest["surfaces"]["browser"]["default"]) + for surface in manifest["surfaces"].values(): + self.assertIn(surface["extra"], dynamic_extras) + for capability in manifest["capabilities"].values(): + self.assertIn(capability["extra"], dynamic_extras) + self.assertEqual( + manifest["media_runtime"]["strategy"], + "system", + ) + + def test_windows_release_binary_uses_the_gui_subsystem(self): + main = ( + ROOT / "desktop" / "src-tauri" / "src" / "main.rs" + ).read_text(encoding="utf-8") + + self.assertIn( + '#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]', + main, + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_public_path_contracts.py b/tests/test_public_path_contracts.py new file mode 100644 index 0000000..6623473 --- /dev/null +++ b/tests/test_public_path_contracts.py @@ -0,0 +1,197 @@ +import json +import unittest +from dataclasses import is_dataclass +from pathlib import Path + +from pydantic import BaseModel, ValidationError + +from vidxp.application_models import ( + Artifact, + CreateActorOverlayCommand, + CreateIndexCommand, + CreateSnippetCommand, + ImportMediaCommand, + IndexResult, + IndexStatus, + MediaAsset, + MediaPage, + PrepareModelsResult, +) +from vidxp.capabilities.registry import create_capability_registry +from vidxp.capabilities.contracts import OperationDefinition +from vidxp.capabilities.schemas import SearchHit +from vidxp.ports import LocalFileResource + + +MEDIA_ID = "123456781234423481234567890abcde" +GENERATION_ID = "223456781234423481234567890abcde" + + +def schema_fields(schema): + fields = set() + if isinstance(schema, dict): + fields.update(schema.get("properties", ())) + for value in schema.values(): + fields.update(schema_fields(value)) + elif isinstance(schema, list): + for value in schema: + fields.update(schema_fields(value)) + return fields + + +class PublicPathContractTests(unittest.TestCase): + def test_shared_contracts_do_not_expose_paths_or_storage_keys(self): + models = [ + Artifact, + CreateActorOverlayCommand, + CreateIndexCommand, + CreateSnippetCommand, + IndexResult, + IndexStatus, + MediaAsset, + MediaPage, + PrepareModelsResult, + SearchHit, + ] + registry = create_capability_registry() + for definition in registry.definitions.values(): + for operation in definition.operations.values(): + models.extend((operation.input_model, operation.output_model)) + + forbidden = { + "path", + "input_path", + "output_path", + "storage_key", + "repository_root", + "index_directory", + } + for model in models: + with self.subTest(model=model.__name__): + schema = model.model_json_schema() + self.assertTrue(forbidden.isdisjoint(schema_fields(schema))) + self.assertNotIn('"format": "path"', json.dumps(schema)) + + def test_only_local_import_accepts_a_path(self): + command = ImportMediaCommand(path=Path("video.mp4")) + self.assertEqual(command.path, Path("video.mp4")) + with self.assertRaises(ValidationError): + CreateIndexCommand.model_validate( + { + "path": "video.mp4", + "media_id": MEDIA_ID, + "modalities": ["scene"], + } + ) + + def test_media_and_artifact_ids_are_not_content_hashes_or_names(self): + with self.assertRaises(ValidationError): + CreateIndexCommand(media_id="episode-1", modalities=("scene",)) + with self.assertRaises(ValidationError): + CreateIndexCommand(media_id="1" * 64, modalities=("scene",)) + + def test_scene_sampling_requires_a_positive_scene_request(self): + with self.assertRaises(ValidationError): + CreateIndexCommand( + media_id=MEDIA_ID, + modalities=("dialogue",), + scene_sample_fps=1.0, + ) + with self.assertRaises(ValidationError): + CreateIndexCommand( + media_id=MEDIA_ID, + modalities=("scene",), + scene_sample_fps=0, + ) + + def test_scene_sampling_has_one_canonical_command_field(self): + nested = CreateIndexCommand( + media_id=MEDIA_ID, + modalities=("scene",), + capability_options={ + "scene": {"sample_fps": 2.0, "batch_size": 4}, + }, + ) + explicit = CreateIndexCommand( + media_id=MEDIA_ID, + modalities=("scene",), + scene_sample_fps=2.0, + capability_options={"scene": {"batch_size": 4}}, + ) + + self.assertEqual(nested, explicit) + self.assertEqual(nested.scene_sample_fps, 2.0) + self.assertEqual( + nested.capability_options, + {"scene": {"batch_size": 4}}, + ) + self.assertNotIn( + "sample_fps", + nested.model_dump(mode="json")["capability_options"]["scene"], + ) + with self.assertRaisesRegex(ValidationError, "conflicts"): + CreateIndexCommand( + media_id=MEDIA_ID, + modalities=("scene",), + scene_sample_fps=1.0, + capability_options={"scene": {"sample_fps": 2.0}}, + ) + + def test_search_metadata_rejects_internal_locations(self): + with self.assertRaises(ValidationError): + SearchHit( + rank=1, + media_id=MEDIA_ID, + video_id=MEDIA_ID, + generation_id=GENERATION_ID, + start=1, + end=2, + score=0, + raw_distance=0, + modality="scene", + source_id="source", + metadata={"source_path": "C:/secret/video.mp4"}, + ) + + with self.assertRaises(ValidationError): + SearchHit( + rank=1, + media_id=MEDIA_ID, + video_id=MEDIA_ID, + generation_id=GENERATION_ID, + start=1, + end=2, + score=0, + raw_distance=0, + modality="scene", + source_id="source", + metadata={"source": {"storage_key": "secret"}}, + ) + + def test_external_public_operation_cannot_expose_paths(self): + class UnsafeInput(BaseModel): + path: Path + + class SafeOutput(BaseModel): + ok: bool + + with self.assertRaises(ValidationError): + OperationDefinition( + input_model=UnsafeInput, + output_model=SafeOutput, + ) + + def test_local_file_handle_is_not_dataclass_serializable(self): + handle = LocalFileResource( + path=Path("internal"), + filename="video.mp4", + mime_type="video/mp4", + byte_size=1, + etag="1" * 64, + ) + self.assertFalse(is_dataclass(handle)) + self.assertFalse(hasattr(handle, "__dict__")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_query_service.py b/tests/test_query_service.py new file mode 100644 index 0000000..a57029d --- /dev/null +++ b/tests/test_query_service.py @@ -0,0 +1,377 @@ +import unittest + +from vidxp.application_models import ( + DraftAnswer, + DraftClaim, + FusedSearchResult, + FusionProvenance, + IndexSnapshotReference, + QueryAnswerMode, + QueryModelIdentity, + QueryPlan, + QueryVideoCommand, + SearchHit, + SearchMomentsPlanStep, + SearchResult, +) +from vidxp.ports import QueryProviderError +from vidxp.query_service import GroundedQueryService +from vidxp.search_fusion import fuse_search_results + + +MEDIA_ID = "123456781234423481234567890abcde" +GENERATION_ID = "223456781234423481234567890abcde" +SNAPSHOT_ID = "323456781234423481234567890abcde" +SNAPSHOT_SHA256 = "a" * 64 + + +class FakeQueryModel: + identity = QueryModelIdentity(provider="ollama", model="test") + + def __init__(self, plan, answer=None): + self.proposed_plan = plan + self.proposed_answer = answer + + def plan(self, _request): + if isinstance(self.proposed_plan, Exception): + raise self.proposed_plan + return self.proposed_plan + + def synthesize(self, _request): + if isinstance(self.proposed_answer, Exception): + raise self.proposed_answer + return self.proposed_answer + + +def result(*, text: str | None = None, modality: str = "scene"): + metadata = {"text": text} if text is not None else {} + return SearchResult( + query_id=f"{modality}:q", + query="taxi", + modality=modality, + hits=( + SearchHit( + rank=1, + media_id=MEDIA_ID, + video_id=MEDIA_ID, + generation_id=GENERATION_ID, + start=1, + end=2, + score=-0.1, + raw_distance=0.1, + modality=modality, + source_id=f"{modality}:1", + metadata=metadata, + ), + ), + ) + + +def fused( + modality: str = "scene", + atomic: SearchResult | None = None, +): + if atomic is not None: + return fuse_search_results( + query=atomic.query, + requested_modalities=(modality,), + results=(atomic,), + ) + return FusedSearchResult( + query_id="fused:q", + query="taxi", + modalities=(modality,), + fusion=FusionProvenance( + requested_modalities=(modality,), + searched_modalities=(modality,), + ), + ) + + +class GroundedQueryServiceTests(unittest.TestCase): + def setUp(self): + self.command = QueryVideoCommand(question="Where is the taxi?") + self.snapshot = IndexSnapshotReference( + snapshot_id=SNAPSHOT_ID, + snapshot_sha256=SNAPSHOT_SHA256, + ) + + def test_invalid_model_plan_falls_back_to_complete_closed_plan(self): + model = FakeQueryModel( + QueryPlan( + steps=( + SearchMomentsPlanStep( + modality="scene", + query="taxi", + ), + ) + ) + ) + service = GroundedQueryService(model) + + plan, reason = service.plan( + self.command, + search_modalities=("scene", "dialogue"), + actor_overview=False, + ) + + self.assertEqual(reason, "query_plan_rejected") + self.assertEqual( + [step.modality for step in plan.steps], + ["scene", "dialogue"], + ) + + def test_provider_failure_uses_deterministic_retrieval_plan(self): + service = GroundedQueryService( + FakeQueryModel(QueryProviderError("offline")) + ) + + plan, reason = service.plan( + self.command, + search_modalities=("scene",), + actor_overview=False, + ) + + self.assertEqual(reason, "query_model_unavailable") + self.assertEqual(plan.steps[0].query, self.command.question) + + def test_scene_only_evidence_never_becomes_a_generated_claim(self): + service = GroundedQueryService( + FakeQueryModel( + QueryPlan( + steps=( + SearchMomentsPlanStep( + modality="scene", + query="taxi", + ), + ) + ), + DraftAnswer( + claims=( + DraftClaim( + text="A taxi is visible.", + evidence_ids=("b" * 64,), + ), + ) + ), + ) + ) + atomic = result() + fused_result = fused(atomic=atomic) + evidence = service.evidence( + snapshot=self.snapshot, + fused=fused_result, + actors=(), + ) + + answer = service.answer( + self.command, + plan=service.plan( + self.command, + search_modalities=("scene",), + actor_overview=False, + )[0], + planning_fallback=None, + evidence=evidence, + fused=fused_result, + ) + + self.assertEqual(answer.mode, QueryAnswerMode.evidence_only) + self.assertEqual(answer.fallback_reason, "no_textual_evidence") + self.assertFalse(answer.claims) + + def test_unknown_citation_is_rejected(self): + plan = QueryPlan( + steps=( + SearchMomentsPlanStep( + modality="dialogue", + query="taxi", + ), + ) + ) + service = GroundedQueryService( + FakeQueryModel( + plan, + DraftAnswer( + claims=( + DraftClaim( + text="Someone mentions a taxi.", + evidence_ids=("b" * 64,), + ), + ) + ), + ) + ) + atomic = result(text="the taxi arrived", modality="dialogue") + fused_result = fused("dialogue", atomic) + evidence = service.evidence( + snapshot=self.snapshot, + fused=fused_result, + actors=(), + ) + + answer = service.answer( + self.command, + plan=plan, + planning_fallback=None, + evidence=evidence, + fused=fused_result, + ) + + self.assertEqual(answer.mode, QueryAnswerMode.evidence_only) + self.assertEqual(answer.fallback_reason, "query_citations_rejected") + + def test_valid_textual_citation_is_reconstructed_by_the_application(self): + plan = QueryPlan( + steps=( + SearchMomentsPlanStep( + modality="dialogue", + query="taxi", + ), + ) + ) + atomic = result(text="the taxi arrived", modality="dialogue") + service = GroundedQueryService() + fused_result = fused("dialogue", atomic) + evidence = service.evidence( + snapshot=self.snapshot, + fused=fused_result, + actors=(), + ) + service.model = FakeQueryModel( + plan, + DraftAnswer( + claims=( + DraftClaim( + text="The taxi arrived.", + evidence_ids=(evidence[0].evidence_id,), + ), + ) + ), + ) + + answer = service.answer( + self.command, + plan=plan, + planning_fallback=None, + evidence=evidence, + fused=fused_result, + ) + + self.assertEqual(answer.mode, QueryAnswerMode.generated) + self.assertEqual( + answer.claims[0].evidence_ids, + (evidence[0].evidence_id,), + ) + + def test_generated_answer_preserves_rejected_plan_provenance(self): + atomic = result(text="the taxi arrived", modality="dialogue") + fused_result = fused("dialogue", atomic) + service = GroundedQueryService( + FakeQueryModel( + QueryPlan( + steps=( + SearchMomentsPlanStep( + modality="dialogue", + query="taxi", + ), + ) + ) + ) + ) + evidence = service.evidence( + snapshot=self.snapshot, + fused=fused_result, + actors=(), + ) + service.model.proposed_answer = DraftAnswer( + claims=( + DraftClaim( + text="The taxi arrived.", + evidence_ids=(evidence[0].evidence_id,), + ), + ) + ) + + answer = service.answer( + self.command, + plan=service.model.proposed_plan, + planning_fallback="query_plan_rejected", + evidence=evidence, + fused=fused_result, + ) + + self.assertEqual(answer.mode, QueryAnswerMode.generated) + self.assertEqual(answer.fallback_reason, "query_plan_rejected") + self.assertEqual(answer.model, service.model.identity) + + def test_configured_model_identity_is_retained_without_evidence(self): + plan = QueryPlan( + steps=( + SearchMomentsPlanStep( + modality="scene", + query="taxi", + ), + ) + ) + service = GroundedQueryService(FakeQueryModel(plan)) + + answer = service.answer( + self.command, + plan=plan, + planning_fallback=None, + evidence=(), + fused=fused(), + ) + + self.assertEqual(answer.mode, QueryAnswerMode.no_evidence) + self.assertEqual(answer.model, service.model.identity) + + def test_evidence_is_bounded_to_retained_fused_moments(self): + atomic = SearchResult( + query_id="dialogue:many", + query="taxi", + modality="dialogue", + hits=tuple( + SearchHit( + rank=index + 1, + media_id=MEDIA_ID, + video_id=MEDIA_ID, + generation_id=GENERATION_ID, + start=float(index * 2), + end=float(index * 2 + 1), + score=-float(index + 1), + raw_distance=float(index + 1), + modality="dialogue", + source_id=f"dialogue:{index}", + metadata={"text": f"line {index}"}, + ) + for index in range(201) + ), + ) + fused_result = fuse_search_results( + query="taxi", + requested_modalities=("dialogue",), + results=(atomic,), + top_k=201, + ) + + evidence = GroundedQueryService.evidence( + snapshot=self.snapshot, + fused=fused_result, + actors=(), + ) + + self.assertEqual(len(evidence), 200) + retained_sources = { + hit.source_id + for moment in fused_result.moments + for hit in moment.hits + } + self.assertTrue( + all(item.source_id in retained_sources for item in evidence) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_read_job_planner.py b/tests/test_read_job_planner.py new file mode 100644 index 0000000..bb12274 --- /dev/null +++ b/tests/test_read_job_planner.py @@ -0,0 +1,73 @@ +import unittest +from unittest.mock import Mock + +from vidxp.application_models import ( + CreateActorOverlayCommand, + QueryVideoCommand, + SearchCommand, +) +from vidxp.core.contracts import IndexConfig +from vidxp.read_job_planner import LocalReadJobPlanner +from vidxp.repository_layout import RepositoryLayout + + +MEDIA_ID = "123456781234423481234567890abcde" +GENERATION_ID = "223456781234423481234567890abcde" +SNAPSHOT_ID = "323456781234423481234567890abcde" + + +class LocalReadJobPlannerTests(unittest.TestCase): + def setUp(self): + self.layout = RepositoryLayout(root="unused") + self.config = IndexConfig.local( + video_id=MEDIA_ID, + enabled_modalities=("scene", "actor"), + snapshot_id=SNAPSHOT_ID, + snapshot_sha256="a" * 64, + storage_directory=self.layout.index_store, + collection_names={"scene": "scene", "actor": "actor"}, + ) + self.index = Mock() + self.index.active_config.return_value = self.config + self.planner = LocalReadJobPlanner( + layout=self.layout, + index=self.index, + ) + + def test_search_job_carries_only_the_logical_snapshot_reference(self): + request = self.planner.plan_search( + SearchCommand(modalities=("scene",), query="a taxi"), + ) + + self.assertEqual(request.snapshot.snapshot_id, SNAPSHOT_ID) + self.assertEqual(request.snapshot.snapshot_sha256, "a" * 64) + self.assertNotIn( + "storage_directory", + request.model_dump(mode="json"), + ) + self.index.open_store.assert_not_called() + + def test_actor_job_pins_snapshot_without_opening_vector_storage(self): + request = self.planner.plan_actor_overlay( + CreateActorOverlayCommand(cluster_id="actor-1"), + ) + + self.assertEqual(request.snapshot.snapshot_id, SNAPSHOT_ID) + self.index.open_store.assert_not_called() + + def test_query_job_carries_the_same_logical_snapshot_reference(self): + request = self.planner.plan_query( + QueryVideoCommand( + question="What happens?", + modalities=("scene",), + ) + ) + + self.assertEqual(request.command.modalities, ("scene",)) + self.assertEqual(request.snapshot.snapshot_id, SNAPSHOT_ID) + self.assertEqual(request.snapshot.snapshot_sha256, "a" * 64) + self.index.open_store.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_repositories.py b/tests/test_repositories.py index cbf80e7..1a24b72 100644 --- a/tests/test_repositories.py +++ b/tests/test_repositories.py @@ -5,6 +5,7 @@ from tempfile import TemporaryDirectory from unittest.mock import patch +from vidxp.app_paths import default_repository_directory from vidxp.repositories import ( DEFAULT_REPOSITORY_NAME, RepositoryConfigError, @@ -23,7 +24,37 @@ def test_missing_registry_exposes_non_persistent_default(self): self.assertEqual(repositories[0].name, DEFAULT_REPOSITORY_NAME) self.assertFalse(repositories[0].configured) - self.assertEqual(selected.index_directory, Path("chroma_data")) + self.assertEqual( + selected.index_directory, + default_repository_directory(), + ) + self.assertTrue(selected.index_directory.is_absolute()) + + def test_data_directory_controls_the_implicit_default_repository(self): + with TemporaryDirectory() as directory: + root = Path(directory) + registry_path = root / "repos.json" + data_directory = root / "application-data" + unrelated_working_directory = root / "working-directory" + unrelated_working_directory.mkdir() + original_working_directory = Path.cwd() + try: + os.chdir(unrelated_working_directory) + with patch.dict( + os.environ, + {"VIDXP_DATA_DIR": str(data_directory)}, + clear=False, + ): + _, selected = resolve_repository( + registry_path=registry_path, + ) + finally: + os.chdir(original_working_directory) + + self.assertEqual( + selected.index_directory, + data_directory / "repositories" / "default", + ) def test_add_use_replace_and_remove_round_trip(self): with TemporaryDirectory() as directory: @@ -109,6 +140,26 @@ def test_explicit_and_environment_overrides_have_stable_precedence(self): self.assertEqual(selected.index_directory, Path("explicit-index")) self.assertEqual(selected.device, "cuda") + def test_explicit_data_directory_precedes_environment_default(self): + with TemporaryDirectory() as directory: + root = Path(directory) + environment_root = root / "environment-data" + explicit_root = root / "explicit-data" + with patch.dict( + os.environ, + {"VIDXP_DATA_DIR": str(environment_root)}, + clear=False, + ): + _, selected = resolve_repository( + registry_path=root / "repos.json", + data_directory=explicit_root, + ) + + self.assertEqual( + selected.index_directory, + explicit_root / "repositories" / "default", + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_runner.py b/tests/test_runner.py index 00db29a..852d67e 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -5,23 +5,23 @@ from unittest.mock import Mock, patch from vidxp.core.contracts import ( - INDEX_SCHEMA_VERSION, IndexCancelledError, IndexConfig, - IndexSchemaError, VideoSource, ) -from vidxp.core.manifest import COMPLETION_FILE +from vidxp.core.manifest import COMPLETION_FILE, ManifestStore from vidxp.capabilities.contracts import CapabilityIndexResult -from vidxp.capabilities.dialogue.config import dialogue_config +from vidxp.capabilities.dialogue.specs import FASTER_WHISPER_MODEL from vidxp.core.runner import ( _RunLock, - index_video, + index_video as _index_video, indexing_in_progress, - local_config_from_status, - run_index, + run_index as _run_index, ) +from vidxp.capabilities.registry import create_capability_registry from vidxp.index_state import IndexingInProgressError +from vidxp.runtime import ModelRuntime +from vidxp.settings import VidXPSettings EXECUTION_STATE = { @@ -34,6 +34,50 @@ } +def _dependencies(config): + return { + "registry": create_capability_registry(), + "runtime": ModelRuntime( + VidXPSettings( + repository_root=config.run_directory, + runtime_backend=config.device, + ) + ), + } + + +def run_index(sources, config, **options): + dependencies = _dependencies(config) + options.setdefault("registry", dependencies["registry"]) + options.setdefault("runtime", dependencies["runtime"]) + options.setdefault("storage", FakeStorage()) + options.setdefault( + "manifest_store", + ManifestStore( + config, + registry=options["registry"], + runtime=options["runtime"], + ), + ) + return _run_index(sources, config, **options) + + +def index_video(path, *args, config, **options): + dependencies = _dependencies(config) + options.setdefault("registry", dependencies["registry"]) + options.setdefault("runtime", dependencies["runtime"]) + options.setdefault("storage", FakeStorage()) + options.setdefault( + "manifest_store", + ManifestStore( + config, + registry=options["registry"], + runtime=options["runtime"], + ), + ) + return _index_video(path, *args, config=config, **options) + + def visual_result(summary, timings=None): normalized = dict(summary) scene_frames = int(normalized.get("scene_frames", 0)) @@ -63,6 +107,17 @@ def clear(self, modalities=None): def delete_video(self, modality, video_id): self.deleted.append((modality, video_id)) + def delete_records( + self, + modality, + *, + video_id, + filters=None, + ): + self.deleted.append( + (modality, video_id, dict(filters or {})) + ) + def size_bytes(self): return self.size @@ -77,16 +132,6 @@ def _config(self, root, modalities=("scene",)): output_root=root, ) - def test_local_config_rejects_an_older_index_schema(self): - status = { - "summary": { - "index_schema_version": INDEX_SCHEMA_VERSION - 1, - } - } - - with self.assertRaisesRegex(IndexSchemaError, "Re-index"): - local_config_from_status(status) - def test_two_videos_complete_one_isolated_resumable_run(self): with TemporaryDirectory() as directory: root = Path(directory) @@ -134,7 +179,10 @@ def scene_indexer(source, *, config, **_): ) self.assertEqual(resumed["state"], "complete") self.assertEqual(manifest["git"]["commit"], "abc123") - self.assertEqual(manifest["index_size_bytes"], 321) + self.assertEqual( + manifest["store_size_bytes_at_commit"], + 321, + ) self.assertEqual(manifest["processed_frames"], 4) self.assertTrue((config.run_directory / COMPLETION_FILE).is_file()) self.assertEqual( @@ -142,6 +190,66 @@ def scene_indexer(source, *, config, **_): [("scene", "video-1"), ("scene", "video-2")], ) + def test_generation_cleanup_is_scoped_to_each_video(self): + with TemporaryDirectory() as directory: + root = Path(directory) + first = root / "first.mp4" + second = root / "second.mp4" + first.write_bytes(b"first") + second.write_bytes(b"second") + config = IndexConfig( + dataset="sample", + split="test", + run_id="run-1", + enabled_modalities=("scene",), + output_root=root, + generation_id="generation-1", + ) + storage = FakeStorage() + + with ( + patch("vidxp.core.runner.require_dependencies"), + patch( + "vidxp.capabilities.visual.index_visuals", + return_value=visual_result( + { + "scene_frames": 1, + "decoded_frames": 1, + "duration": 1.0, + "fps": 1.0, + } + ), + ), + patch( + "vidxp.core.manifest.execution_state", + return_value=EXECUTION_STATE, + ), + ): + run_index( + [ + VideoSource(video_id="video-1", path=first), + VideoSource(video_id="video-2", path=second), + ], + config, + storage=storage, + ) + + self.assertEqual( + storage.deleted, + [ + ( + "scene", + "video-1", + {"generation_id": "generation-1"}, + ), + ( + "scene", + "video-2", + {"generation_id": "generation-1"}, + ), + ], + ) + def test_scene_and_actor_are_dispatched_as_one_visual_pipeline(self): with TemporaryDirectory() as directory: path = Path(directory) / "video.mp4" @@ -281,10 +389,12 @@ def test_transcript_only_run_does_not_request_transcription_dependencies(self): ): run_index([source], config, storage=FakeStorage()) - dependency_check.assert_called_once_with( - ("dialogue",), - source=source, + dependency_check.assert_called_once() + self.assertEqual( + dependency_check.call_args.args, + (("dialogue",),), ) + self.assertIs(dependency_check.call_args.kwargs["source"], source) def test_manifest_adds_transcription_model_when_run_later_needs_it(self): with TemporaryDirectory() as directory: @@ -321,10 +431,10 @@ def test_manifest_adds_transcription_model_when_run_later_needs_it(self): ) self.assertNotIn("transcription", first["models"]) - self.assertEqual( - second["models"]["transcription"], - dialogue_config(config).whisper_model, - ) + self.assertEqual( + second["models"]["transcription"]["model"], + FASTER_WHISPER_MODEL.model_id, + ) def test_changed_input_is_not_silently_accepted_by_checkpoint(self): with TemporaryDirectory() as directory: @@ -571,22 +681,12 @@ def test_local_index_hash_is_reused_by_run_index(self): "vidxp.core.runner.run_index", return_value=manifest, ) as run, - patch( - "vidxp.core.runner.write_index_status", - ) as write_status, ): index_video(str(path), config=config) hash_source.assert_called_once() indexed_source = run.call_args.args[0][0] self.assertEqual(indexed_source.checksum, checksum) - self.assertTrue( - all( - call.kwargs["index_directory"] - == config.index_directory - for call in write_status.call_args_list - ) - ) def test_manifest_and_timing_files_are_valid_json(self): with TemporaryDirectory() as directory: diff --git a/tests/test_search.py b/tests/test_search.py index 3b10003..be7c58a 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -3,8 +3,11 @@ from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import patch +from unittest.mock import Mock +import numpy as np from vidxp.capabilities.dialogue.operations import search_dialogue +from vidxp.capabilities.dialogue.operations import dialogue_embedding from vidxp.capabilities.schemas import SearchResult from vidxp.capabilities.search import ( distance_to_score, @@ -12,6 +15,12 @@ stable_query_id, ) from vidxp.core.contracts import IndexConfig, IndexSchemaError +from vidxp.runtime import ModelRuntime +from vidxp.settings import VidXPSettings + + +MEDIA_ID = "123456781234423481234567890abcde" +GENERATION_ID = "223456781234423481234567890abcde" class FakeStorage: @@ -24,7 +33,7 @@ def query(self, modality, embedding, **options): return list(self.rows) -def dialogue_row(source_id, distance, video_id="video-1"): +def dialogue_row(source_id, distance, video_id=MEDIA_ID): return { "source_id": source_id, "raw_distance": distance, @@ -33,6 +42,7 @@ def dialogue_row(source_id, distance, video_id="video-1"): "split": "test", "run_id": "run-1", "video_id": video_id, + "generation_id": GENERATION_ID, "source_id": source_id, "start": 1.0, "end": 2.0, @@ -51,6 +61,12 @@ def setUp(self): run_id="run-1", enabled_modalities=("dialogue",), ) + self.runtime = ModelRuntime( + VidXPSettings( + repository_root="unused", + runtime_backend="cpu", + ) + ) def test_top_k_filter_order_distance_and_score_are_preserved(self): storage = FakeStorage( @@ -67,8 +83,9 @@ def test_top_k_filter_order_distance_and_score_are_preserved(self): result = search_dialogue( "fresh bread", config=self.config, + runtime=self.runtime, top_k=3, - video_id="video-1", + video_id=MEDIA_ID, query_id="query-7", storage=storage, ) @@ -84,13 +101,33 @@ def test_top_k_filter_order_distance_and_score_are_preserved(self): self.assertEqual([hit.rank for hit in result.hits], [1, 2, 3]) self.assertEqual(result.hits[0].raw_distance, 0.1) self.assertEqual(result.hits[0].score, -0.1) + self.assertEqual( + result.hits[0].metadata, + {"text": "fresh bread", "phrase_id": 3}, + ) self.assertEqual(storage.calls[0][2]["top_k"], 3) - self.assertEqual(storage.calls[0][2]["video_id"], "video-1") + self.assertEqual(storage.calls[0][2]["video_id"], MEDIA_ID) def test_score_is_strictly_monotonic_and_not_a_probability(self): self.assertGreater(distance_to_score(0.1), distance_to_score(0.2)) self.assertEqual(distance_to_score(2.5), -2.5) + def test_dialogue_query_uses_model_owned_query_prompt(self): + encoder = Mock() + encoder.encode_query.return_value = np.asarray([[0.5, 0.25]]) + with patch( + "vidxp.capabilities.dialogue.operations.get_embedder", + return_value=encoder, + ): + vector = dialogue_embedding("fresh bread", self.config, self.runtime) + + self.assertEqual(vector, [0.5, 0.25]) + encoder.encode_query.assert_called_once_with( + ["fresh bread"], + convert_to_numpy=True, + normalize_embeddings=True, + ) + def test_generated_query_ids_are_scoped_to_the_benchmark_run(self): first = stable_query_id("fresh bread", "dialogue", self.config) second = stable_query_id( @@ -111,6 +148,7 @@ def test_nonpositive_top_k_is_rejected_before_querying(self): search_dialogue( "query", config=self.config, + runtime=self.runtime, top_k=0, storage=FakeStorage([]), ) @@ -135,6 +173,7 @@ def test_old_metadata_requires_an_explicit_reindex(self): search_dialogue( "query", config=self.config, + runtime=self.runtime, storage=storage, ) diff --git a/tests/test_search_fusion.py b/tests/test_search_fusion.py new file mode 100644 index 0000000..6d83e53 --- /dev/null +++ b/tests/test_search_fusion.py @@ -0,0 +1,114 @@ +import unittest + +from vidxp.application_models import SearchHit, SearchResult +from vidxp.search_fusion import RRF_RANK_CONSTANT, fuse_search_results + + +MEDIA_ID = "123456781234423481234567890abcde" +GENERATION_ID = "223456781234423481234567890abcde" + + +def hit( + modality: str, + rank: int, + start: float, + end: float, + source_id: str, +) -> SearchHit: + return SearchHit( + rank=rank, + media_id=MEDIA_ID, + video_id=MEDIA_ID, + generation_id=GENERATION_ID, + start=start, + end=end, + score=-float(rank), + raw_distance=float(rank), + modality=modality, + source_id=source_id, + ) + + +class SearchFusionTests(unittest.TestCase): + def test_rrf_counts_only_the_best_rank_per_modality_in_a_moment(self): + scene = SearchResult( + query_id="scene:q", + query="taxi", + modality="scene", + hits=( + hit("scene", 1, 1, 3, "scene:1"), + hit("scene", 2, 2, 4, "scene:2"), + ), + ) + dialogue = SearchResult( + query_id="dialogue:q", + query="taxi", + modality="dialogue", + hits=(hit("dialogue", 1, 2.5, 3.5, "dialogue:1"),), + ) + + result = fuse_search_results( + query="taxi", + requested_modalities=("scene", "dialogue"), + results=(scene, dialogue), + ) + + self.assertEqual(len(result.moments), 1) + moment = result.moments[0] + self.assertAlmostEqual(moment.score, 2 / (RRF_RANK_CONSTANT + 1)) + self.assertEqual(len(moment.hits), 3) + self.assertEqual(moment.start, 1) + self.assertEqual(moment.end, 4) + + def test_result_order_does_not_change_fusion_identity_or_output(self): + scene = SearchResult( + query_id="scene:q", + query="taxi", + modality="scene", + hits=(hit("scene", 1, 1, 2, "scene:1"),), + ) + dialogue = SearchResult( + query_id="dialogue:q", + query="taxi", + modality="dialogue", + hits=(hit("dialogue", 1, 1, 2, "dialogue:1"),), + ) + arguments = { + "query": "taxi", + "requested_modalities": ("scene", "dialogue"), + } + + forward = fuse_search_results( + results=(scene, dialogue), + **arguments, + ) + reverse = fuse_search_results( + results=(dialogue, scene), + **arguments, + ) + + self.assertEqual(forward, reverse) + + def test_rewritten_atomic_query_identity_changes_fused_identity(self): + original = SearchResult( + query_id="scene:original", + query="taxi", + modality="scene", + hits=(hit("scene", 1, 1, 2, "scene:1"),), + ) + rewritten = original.model_copy( + update={"query_id": "scene:rewritten"} + ) + arguments = { + "query": "Where is the taxi?", + "requested_modalities": ("scene",), + } + + first = fuse_search_results(results=(original,), **arguments) + second = fuse_search_results(results=(rewritten,), **arguments) + + self.assertNotEqual(first.query_id, second.query_id) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..c089a17 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,121 @@ +import os +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from pydantic import ValidationError + +from vidxp.app_paths import ( + default_data_directory, + default_model_directory, + default_repository_directory, +) +from vidxp.settings import ( + ApplicationMode, + LocalExecutionSettings, + VidXPSettings, +) + + +class SettingsTests(unittest.TestCase): + def test_default_storage_is_under_the_per_user_data_directory(self): + with TemporaryDirectory() as directory: + unrelated_working_directory = Path(directory) + original_working_directory = Path.cwd() + try: + os.chdir(unrelated_working_directory) + settings = VidXPSettings() + finally: + os.chdir(original_working_directory) + + self.assertEqual(settings.data_dir, default_data_directory()) + self.assertEqual( + settings.repository_root, + default_repository_directory(settings.data_dir), + ) + self.assertEqual( + settings.model_cache, + default_model_directory(settings.data_dir), + ) + self.assertTrue(settings.data_dir.is_absolute()) + + def test_data_directory_environment_derives_storage_defaults(self): + with TemporaryDirectory() as directory: + data_directory = Path(directory) / "vidxp-data" + with patch.dict( + os.environ, + {"VIDXP_DATA_DIR": str(data_directory)}, + clear=False, + ): + settings = VidXPSettings() + + self.assertEqual(settings.data_dir, data_directory) + self.assertEqual( + settings.repository_root, + data_directory / "repositories" / "default", + ) + self.assertEqual(settings.model_cache, data_directory / "models") + + def test_explicit_storage_paths_override_derived_defaults(self): + with TemporaryDirectory() as directory: + root = Path(directory) + data_directory = root / "data" + repository_root = root / "repository" + model_cache = root / "model-cache" + with patch.dict( + os.environ, + {"VIDXP_DATA_DIR": str(root / "environment-data")}, + clear=False, + ): + settings = VidXPSettings( + data_dir=data_directory, + repository_root=repository_root, + model_cache=model_cache, + ) + + self.assertEqual(settings.data_dir, data_directory) + self.assertEqual(settings.repository_root, repository_root) + self.assertEqual(settings.model_cache, model_cache) + + def test_saved_media_runtime_paths_become_settings_defaults(self): + ffmpeg = Path("tools/ffmpeg").resolve() + ffprobe = Path("tools/ffprobe").resolve() + + def executable(name): + return str(ffmpeg if name == "ffmpeg" else ffprobe) + + with patch( + "vidxp.settings.default_media_executable", + side_effect=executable, + ): + settings = VidXPSettings() + + self.assertEqual(settings.ffmpeg_executable, str(ffmpeg)) + self.assertEqual(settings.ffprobe_executable, str(ffprobe)) + + def test_local_execution_settings_preserve_the_data_directory(self): + with TemporaryDirectory() as directory: + expected = VidXPSettings(data_dir=Path(directory) / "data") + + reconstructed = LocalExecutionSettings.from_settings( + expected + ).application_settings() + + self.assertEqual(reconstructed.data_dir, expected.data_dir) + self.assertEqual( + reconstructed.repository_root, + expected.repository_root, + ) + self.assertEqual(reconstructed.model_cache, expected.model_cache) + + def test_unimplemented_remote_mode_is_rejected_explicitly(self): + with self.assertRaisesRegex( + ValidationError, + "Remote client mode is not available", + ): + VidXPSettings(mode=ApplicationMode.remote) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_snapshots.py b/tests/test_snapshots.py new file mode 100644 index 0000000..1057dd9 --- /dev/null +++ b/tests/test_snapshots.py @@ -0,0 +1,168 @@ +import json +import unittest +from datetime import datetime, timezone +from uuid import uuid1, uuid4 + +from pydantic import ValidationError + +from vidxp.core.snapshots import ( + ACTIVE_SNAPSHOT_POINTER_SCHEMA_VERSION, + INDEX_SNAPSHOT_SCHEMA_VERSION, + ActiveSnapshotPointer, + GenerationReference, + IndexSnapshot, +) + + +SHA256 = "a" * 64 +OTHER_SHA256 = "b" * 64 + + +class SnapshotModelTests(unittest.TestCase): + def generation_reference(self, **changes): + values = { + "generation_id": uuid4().hex, + "media_id": "media-1", + "manifest_sha256": SHA256, + "input_sha256": OTHER_SHA256, + "config_fingerprint": SHA256, + "modalities": ("dialogue", "scene"), + "record_counts": {"dialogue": 3, "scene": 4}, + "store_size_bytes_at_commit": 2048, + } + values.update(changes) + return GenerationReference(**values) + + def index_snapshot(self, **changes): + reference = changes.pop("reference", self.generation_reference()) + values = { + "snapshot_id": uuid4().hex, + "created_at": datetime(2026, 7, 29, tzinfo=timezone.utc), + "config_fingerprint": SHA256, + "configuration": { + "device": "cpu", + "modalities": ["dialogue", "scene"], + "options": {"batch_size": 8}, + }, + "generations": {reference.media_id: reference}, + } + values.update(changes) + return IndexSnapshot(**values) + + def test_models_round_trip_through_json(self): + snapshot = self.index_snapshot() + pointer = ActiveSnapshotPointer( + snapshot_id=snapshot.snapshot_id, + snapshot_sha256=OTHER_SHA256, + updated_at=datetime(2026, 7, 29, 1, 2, tzinfo=timezone.utc), + ) + + reference = next(iter(snapshot.generations.values())) + self.assertEqual( + GenerationReference.model_validate_json( + reference.model_dump_json() + ), + reference, + ) + self.assertEqual( + IndexSnapshot.model_validate_json(snapshot.model_dump_json()), + snapshot, + ) + self.assertEqual( + ActiveSnapshotPointer.model_validate_json( + pointer.model_dump_json() + ), + pointer, + ) + self.assertEqual( + json.loads(snapshot.model_dump_json())["schema_version"], + INDEX_SNAPSHOT_SCHEMA_VERSION, + ) + self.assertEqual( + json.loads(pointer.model_dump_json())["schema_version"], + ACTIVE_SNAPSHOT_POINTER_SCHEMA_VERSION, + ) + + def test_models_are_strict_frozen_and_forbid_extra_fields(self): + reference = self.generation_reference() + with self.assertRaises(ValidationError): + reference.store_size_bytes_at_commit = 1 + with self.assertRaises(ValidationError): + self.generation_reference(store_size_bytes_at_commit="2048") + with self.assertRaises(ValidationError): + GenerationReference( + **reference.model_dump(), + unexpected=True, + ) + + def test_ids_must_be_lowercase_uuid4_hex(self): + invalid_ids = ( + str(uuid4()), + uuid4().hex.upper(), + uuid1().hex, + "0" * 32, + ) + for invalid_id in invalid_ids: + with self.subTest(invalid_id=invalid_id): + with self.assertRaises(ValidationError): + self.generation_reference(generation_id=invalid_id) + with self.assertRaises(ValidationError): + self.index_snapshot(snapshot_id=invalid_id) + + def test_sha256_fields_require_lowercase_64_character_hex(self): + for invalid_hash in ("a" * 63, "A" * 64, "g" * 64): + with self.subTest(invalid_hash=invalid_hash): + with self.assertRaises(ValidationError): + self.generation_reference( + manifest_sha256=invalid_hash + ) + with self.assertRaises(ValidationError): + ActiveSnapshotPointer( + snapshot_id=uuid4().hex, + snapshot_sha256=invalid_hash, + updated_at=datetime.now(timezone.utc), + ) + + def test_snapshot_rejects_mismatched_media_mapping(self): + reference = self.generation_reference(media_id="media-1") + with self.assertRaisesRegex( + ValidationError, + "mapping keys must match", + ): + self.index_snapshot( + reference=reference, + generations={"media-2": reference}, + ) + + def test_generation_counts_must_match_modalities(self): + with self.assertRaisesRegex(ValidationError, "exactly match"): + self.generation_reference(record_counts={"scene": 1}) + with self.assertRaises(ValidationError): + self.generation_reference( + record_counts={"dialogue": -1, "scene": 1} + ) + + def test_generation_store_size_may_be_unknown(self): + reference = self.generation_reference( + store_size_bytes_at_commit=None + ) + + self.assertIsNone(reference.store_size_bytes_at_commit) + + def test_snapshot_configuration_must_be_json_safe(self): + with self.assertRaises(ValidationError): + self.index_snapshot(configuration={"runtime": object()}) + + def test_timestamps_must_be_timezone_aware(self): + with self.assertRaises(ValidationError): + self.index_snapshot(created_at=datetime(2026, 7, 29)) + with self.assertRaises(ValidationError): + ActiveSnapshotPointer( + snapshot_id=uuid4().hex, + snapshot_sha256=SHA256, + updated_at=datetime(2026, 7, 29), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_storage.py b/tests/test_storage.py index b52d322..3f3b8ec 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -1,13 +1,28 @@ import unittest +from dataclasses import replace from pathlib import Path from tempfile import TemporaryDirectory +from unittest.mock import MagicMock + +import httpx +from chromadb.errors import ( + InvalidArgumentError, + InvalidDimensionException, + VersionMismatchError, +) from vidxp.core.contracts import ( CancellationToken, IndexConfig, StorageRecord, ) -from vidxp.core.storage import IndexStorage, directory_size, metadata_filter +from vidxp.core.storage import ( + IndexStorage, + IndexStorageUnavailableError, + SnapshotScopedIndexStore, + directory_size, + metadata_filter, +) class FakeCollection: @@ -34,6 +49,7 @@ def query(self, **options): def get(self, **options): self.get_options = options return { + "ids": ["source-3", "source-1"], "metadatas": [ { "frame_index": 3, @@ -66,6 +82,8 @@ def fake_storage(config, collection): storage.config = config storage.path = config.index_directory storage.client = FakeClient(collection) + storage._client_factory = MagicMock(remote=False) + storage._create = True storage._collections = {} storage._names = { "dialogue": "dialogue", @@ -84,6 +102,51 @@ def setUp(self): enabled_modalities=("scene",), ) + def test_remote_transport_failures_are_retryable_storage_failures(self): + storage = object.__new__(IndexStorage) + storage._client_factory = MagicMock(remote=True) + + with self.assertRaises(IndexStorageUnavailableError): + storage._call( + MagicMock(side_effect=httpx.ConnectError("offline")) + ) + + def test_remote_validation_failures_retain_their_original_type(self): + storage = object.__new__(IndexStorage) + storage._client_factory = MagicMock(remote=True) + + for failure in ( + InvalidArgumentError("invalid filter"), + InvalidDimensionException("wrong embedding dimensions"), + VersionMismatchError("incompatible client and server"), + ValueError("invalid schema"), + ): + with self.subTest(failure=type(failure).__name__): + with self.assertRaises(type(failure)): + storage._call(MagicMock(side_effect=failure)) + + def test_read_only_storage_uses_the_public_collection_api(self): + with TemporaryDirectory() as directory: + client = MagicMock() + client.list_collections.return_value = ["scene"] + factory = MagicMock(remote=False) + factory.create.return_value = client + + with IndexStorage( + replace(self.config, storage_directory=directory), + create=False, + client_factory=factory, + ): + pass + + client.list_collections.assert_called_once_with() + + def test_remote_store_size_is_unknown(self): + storage = object.__new__(IndexStorage) + storage._client_factory = MagicMock(remote=True) + + self.assertIsNone(storage.size_bytes()) + def test_upserts_are_split_into_declared_write_batches(self): collection = FakeCollection() storage = fake_storage(self.config, collection) @@ -142,6 +205,61 @@ def test_query_requests_distances_and_applies_run_and_video_filter(self): self.assertIn({"video_id": "video-1"}, clauses) self.assertIn({"run_id": "run-1"}, clauses) + def test_query_and_records_scope_to_generation_ids(self): + collection = FakeCollection() + storage = fake_storage(self.config, collection) + generation_ids = ("generation-1", "generation-2") + + storage.query( + "scene", + [0.1, 0.2], + top_k=7, + generation_ids=generation_ids, + ) + query_clauses = collection.query_options["where"]["$and"] + self.assertIn( + { + "generation_id": { + "$in": ["generation-1", "generation-2"] + } + }, + query_clauses, + ) + + storage.records( + "scene", + generation_ids=generation_ids, + ) + record_clauses = collection.get_options["where"]["$and"] + self.assertIn( + { + "generation_id": { + "$in": ["generation-1", "generation-2"] + } + }, + record_clauses, + ) + + def test_empty_generation_scope_returns_no_records(self): + collection = FakeCollection() + storage = fake_storage(self.config, collection) + + self.assertEqual( + storage.query( + "scene", + [0.1, 0.2], + top_k=7, + generation_ids=(), + ), + [], + ) + self.assertEqual( + storage.records("scene", generation_ids=()), + [], + ) + self.assertIsNone(collection.query_options) + self.assertIsNone(collection.get_options) + def test_records_apply_capability_filters(self): collection = FakeCollection() storage = fake_storage(self.config, collection) @@ -161,6 +279,19 @@ def test_records_apply_capability_filters(self): self.assertIn({"video_id": "video-1"}, clauses) self.assertIn({"cluster_id": "1"}, clauses) + def test_count_records_uses_ids_without_loading_metadata(self): + collection = FakeCollection() + storage = fake_storage(self.config, collection) + + count = storage.count_records( + "scene", + video_id="video-1", + generation_ids=("generation-1",), + ) + + self.assertEqual(count, 2) + self.assertEqual(collection.get_options["include"], []) + def test_record_cleanup_remains_scoped_to_capability_and_run(self): collection = FakeCollection() storage = fake_storage(self.config, collection) @@ -176,6 +307,19 @@ def test_record_cleanup_remains_scoped_to_capability_and_run(self): self.assertIn({"video_id": "video-1"}, clauses) self.assertIn({"cluster_id": "3"}, clauses) + def test_generation_cleanup_uses_generation_filter(self): + collection = FakeCollection() + storage = fake_storage(self.config, collection) + + storage.delete_generation("generation-1", modalities=("scene",)) + + clauses = collection.deletes[0]["where"]["$and"] + self.assertIn( + {"generation_id": {"$in": ["generation-1"]}}, + clauses, + ) + self.assertIn({"run_id": "run-1"}, clauses) + def test_directory_size_only_counts_files_under_requested_path(self): with TemporaryDirectory() as directory: root = Path(directory) @@ -189,6 +333,70 @@ def test_metadata_filter_keeps_run_identity_without_video_filter(self): where = metadata_filter(self.config) self.assertNotIn({"video_id": None}, where["$and"]) + def test_snapshot_scope_delegates_reads_and_lifecycle(self): + store = MagicMock(spec=IndexStorage) + store.query.return_value = [{"source_id": "source-1"}] + store.records.return_value = [{"source_id": "source-1"}] + store.size_bytes.return_value = 42 + scoped = SnapshotScopedIndexStore( + store, + ("generation-1", "generation-2", "generation-1"), + ) + + with scoped as entered: + self.assertIs(entered, scoped) + self.assertEqual( + scoped.query("scene", [0.1], top_k=2), + [{"source_id": "source-1"}], + ) + self.assertEqual( + scoped.records("scene"), + [{"source_id": "source-1"}], + ) + self.assertEqual(scoped.size_bytes(), 42) + + store.__enter__.assert_called_once_with() + store.__exit__.assert_called_once() + store.query.assert_called_once_with( + "scene", + [0.1], + top_k=2, + video_id=None, + generation_ids=("generation-1", "generation-2"), + filters=None, + ) + store.records.assert_called_once_with( + "scene", + video_id=None, + generation_ids=("generation-1", "generation-2"), + filters=None, + ) + + scoped.close() + store.close.assert_called_once_with() + + def test_snapshot_scope_exposes_no_mutation_api(self): + scoped = SnapshotScopedIndexStore( + MagicMock(spec=IndexStorage), + ("generation-1",), + ) + for method in ( + "clear", + "delete_video", + "delete_records", + "delete_generation", + "upsert", + ): + self.assertFalse(hasattr(scoped, method)) + + def test_explicit_empty_generation_cleanup_selects_nothing(self): + collection = FakeCollection() + storage = fake_storage(self.config, collection) + + storage.delete_generation("generation-1", modalities=()) + + self.assertEqual(collection.deletes, []) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_storage_integration.py b/tests/test_storage_integration.py index c51b45b..6bdc7a3 100644 --- a/tests/test_storage_integration.py +++ b/tests/test_storage_integration.py @@ -1,4 +1,5 @@ import unittest +from pathlib import Path from tempfile import TemporaryDirectory from vidxp.core.contracts import ( @@ -72,6 +73,87 @@ def test_two_videos_can_coexist_query_and_delete_by_video(self): ["video-2"], ) + def test_generation_scope_and_cleanup_use_chroma_in_filter(self): + with TemporaryDirectory() as directory: + config = IndexConfig( + dataset="sample", + split="test", + run_id="run-1", + enabled_modalities=("scene",), + storage_directory=directory, + ) + with IndexStorage(config) as storage: + storage.upsert( + "scene", + [ + StorageRecord( + source_id=f"source-{index}", + embedding=embedding, + metadata={ + "dataset": "sample", + "split": "test", + "run_id": "run-1", + "video_id": f"video-{index}", + "generation_id": generation_id, + }, + ) + for index, generation_id, embedding in ( + (1, "generation-1", [1.0, 0.0]), + (2, "generation-2", [0.0, 1.0]), + ) + ], + batch_size=2, + cancellation=CancellationToken(), + ) + + scoped = storage.query( + "scene", + [1.0, 0.0], + top_k=2, + generation_ids=("generation-1",), + ) + self.assertEqual( + [ + row["metadata"]["generation_id"] + for row in scoped + ], + ["generation-1"], + ) + + storage.delete_generation( + "generation-1", + modalities=("scene",), + ) + remaining = storage.records("scene") + self.assertEqual( + [item["generation_id"] for item in remaining], + ["generation-2"], + ) + + def test_read_only_store_fails_closed_without_database_or_collection(self): + with TemporaryDirectory() as directory: + path = Path(directory) + config = IndexConfig( + dataset="sample", + split="test", + run_id="run-1", + enabled_modalities=("scene",), + storage_directory=path / "missing", + ) + with self.assertRaises(FileNotFoundError): + IndexStorage(config, create=False) + self.assertFalse(config.index_directory.exists()) + + with IndexStorage(config) as storage: + storage.collection("scene") + read_only = IndexStorage(config, create=False) + read_only.client.delete_collection("scene") + with ( + self.assertRaises(FileNotFoundError), + read_only, + ): + read_only.records("scene") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_tusd_hooks.py b/tests/test_tusd_hooks.py new file mode 100644 index 0000000..3915851 --- /dev/null +++ b/tests/test_tusd_hooks.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from pathlib import Path + +from vidxp.application_models import ( + CreateUploadIntentCommand, + Principal, +) +from vidxp.authentication import StaticBearerAuthenticator +from vidxp.authorization import AuthorizationPolicy +from vidxp.infrastructure.sql_catalog import SQLCatalog +from vidxp.infrastructure.tusd_contracts import TusdHookRequest +from vidxp.settings import VidXPSettings +from vidxp.tusd_hooks import TusdHookService +from vidxp.upload_service import RemoteUploadService + + +class _Jobs: + def enqueue_media_import_in_transaction( + self, + upload_id: str, + *, + connection, + job_id: str, + ) -> str: + del upload_id, connection + return job_id + + +def _hooks(tmp_path: Path): + token = "t" * 32 + catalog = SQLCatalog( + f"sqlite:///{(tmp_path / 'server.sqlite3').resolve().as_posix()}", + initialize=True, + ) + settings = VidXPSettings( + repository_root=tmp_path, + upload_public_endpoint="http://localhost:8080/uploads/", + upload_internal_endpoint="http://localhost:8080/uploads/", + upload_cleanup_token="c" * 32, + ) + uploads = RemoteUploadService( + settings=settings, + catalog=catalog, + media=object(), + jobs=_Jobs(), + ) + intent = uploads.create_intent( + CreateUploadIntentCommand( + original_filename="sample.mp4", + byte_size=20, + declared_mime_type="video/mp4", + ), + principal=Principal( + subject="static", + scopes=frozenset({"*"}), + ), + request_key="a" * 64, + ) + return ( + TusdHookService( + uploads=uploads, + authenticator=StaticBearerAuthenticator(token), + authorization=AuthorizationPolicy(), + ), + catalog, + intent, + token, + ) + + +def _pre_create(intent_id: str, token: str) -> TusdHookRequest: + return TusdHookRequest.model_validate( + { + "Type": "pre-create", + "Event": { + "Upload": { + "ID": "", + "Size": 20, + "Offset": 0, + "MetaData": {"intent_id": intent_id}, + }, + "HTTPRequest": { + "Method": "POST", + "URI": "/uploads/", + "Header": {"Authorization": [f"Bearer {token}"]}, + }, + }, + } + ) + + +def test_pre_create_authenticates_and_assigns_stored_id( + tmp_path: Path, +) -> None: + hooks, catalog, intent, token = _hooks(tmp_path) + response = hooks.handle(_pre_create(intent.intent_id, token)) + + assert not response.reject_upload + assert response.change_file_info is not None + stored = catalog.get_upload_intent(intent.intent_id) + assert stored is not None + assert response.change_file_info.upload_id == stored.upload_id + catalog.close() + + +def test_pre_create_rejects_unsupported_tus_modes(tmp_path: Path) -> None: + hooks, catalog, intent, token = _hooks(tmp_path) + request = _pre_create(intent.intent_id, token).model_copy( + update={ + "event": _pre_create(intent.intent_id, token).event.model_copy( + update={ + "upload": _pre_create( + intent.intent_id, + token, + ).event.upload.model_copy( + update={"size_is_deferred": True} + ) + } + ) + } + ) + + response = hooks.handle(request) + + assert response.reject_upload + assert response.http_response is not None + assert response.http_response.status_code == 400 + catalog.close() + + +def test_pre_create_retry_reuses_id_then_rejects_new_materialized_upload( + tmp_path: Path, +) -> None: + hooks, catalog, intent, token = _hooks(tmp_path) + first = hooks.handle(_pre_create(intent.intent_id, token)) + replay = hooks.handle(_pre_create(intent.intent_id, token)) + assert first.change_file_info == replay.change_file_info + assert first.change_file_info is not None + + upload_id = first.change_file_info.upload_id + stored = catalog.get_upload_intent(intent.intent_id) + assert stored is not None + quarantine = tmp_path / "upload-quarantine" + quarantine.mkdir(parents=True, exist_ok=True) + (quarantine / f"{upload_id}.info").write_text("{}", encoding="utf-8") + + duplicate = hooks.handle(_pre_create(intent.intent_id, token)) + assert duplicate.reject_upload + assert duplicate.http_response is not None + assert duplicate.http_response.status_code == 409 + catalog.close() diff --git a/tests/test_upload_service.py b/tests/test_upload_service.py new file mode 100644 index 0000000..fc82b59 --- /dev/null +++ b/tests/test_upload_service.py @@ -0,0 +1,424 @@ +from __future__ import annotations + +from pathlib import Path +from datetime import timedelta +from types import SimpleNamespace +from unittest.mock import patch +from urllib.error import HTTPError +from uuid import uuid4 + +import pytest +from sqlalchemy.exc import IntegrityError + +from vidxp.application_models import ( + ApplicationError, + CreateUploadIntentCommand, + JobState, + Principal, +) +from vidxp.core.media import utc_now +from vidxp.core.uploads import UploadIntentRecord, UploadState +from vidxp.infrastructure.sql_catalog import SQLCatalog +from vidxp.settings import VidXPSettings +from vidxp.upload_service import RemoteUploadService + + +class _Media: + pass + + +class _Jobs: + def __init__(self) -> None: + self.calls: list[tuple[str, str]] = [] + self.states: dict[str, JobState] = {} + + def enqueue_media_import_in_transaction( + self, + upload_id: str, + *, + connection, + job_id: str, + ) -> str: + del connection + self.calls.append((upload_id, job_id)) + return job_id + + def get(self, job_id: str): + return SimpleNamespace(state=self.states[job_id]) + + +def _service( + root: Path, + *, + quota: int = 100, +) -> tuple[RemoteUploadService, SQLCatalog, _Jobs]: + catalog = SQLCatalog( + f"sqlite:///{(root / 'server.sqlite3').resolve().as_posix()}", + initialize=True, + ) + jobs = _Jobs() + settings = VidXPSettings( + repository_root=root, + upload_public_endpoint="http://localhost:8080/uploads/", + upload_internal_endpoint="http://localhost:8080/uploads/", + upload_cleanup_token="x" * 32, + upload_max_bytes=quota, + upload_quota_bytes=quota, + ) + return ( + RemoteUploadService( + settings=settings, + catalog=catalog, + media=_Media(), + jobs=jobs, + ), + catalog, + jobs, + ) + + +def _command(size: int = 60) -> CreateUploadIntentCommand: + return CreateUploadIntentCommand( + original_filename="sample.mp4", + byte_size=size, + declared_mime_type="video/mp4", + ) + + +def test_upload_intent_is_idempotent_and_repository_shared( + tmp_path: Path, +) -> None: + service, catalog, _ = _service(tmp_path) + owner = Principal(subject="owner") + created = service.create_intent( + _command(), + principal=owner, + request_key="a" * 64, + ) + + assert ( + service.create_intent( + _command(), + principal=owner, + request_key="a" * 64, + ) + == created + ) + assert ( + service.get_intent( + created.intent_id, + principal=Principal(subject="other"), + ) + == created + ) + catalog.close() + + +def test_upload_quota_is_reserved_and_released_atomically( + tmp_path: Path, +) -> None: + service, catalog, _ = _service(tmp_path) + owner = Principal(subject="owner") + first = service.create_intent( + _command(), + principal=owner, + request_key="a" * 64, + ) + + with pytest.raises(ApplicationError) as exceeded: + service.create_intent( + _command(), + principal=owner, + request_key="b" * 64, + ) + assert exceeded.value.detail.code == "upload_quota_exceeded" + + service.accept_creation( + first.intent_id, + principal=owner, + byte_size=60, + ) + service.record_terminated( + catalog.get_upload_intent(first.intent_id).upload_id # type: ignore[union-attr] + ) + second = service.create_intent( + _command(), + principal=owner, + request_key="b" * 64, + ) + assert second.byte_size == 60 + catalog.close() + + +def test_duplicate_finish_enqueues_one_import_job(tmp_path: Path) -> None: + service, catalog, jobs = _service(tmp_path) + owner = Principal(subject="owner") + intent = service.create_intent( + _command(), + principal=owner, + request_key="a" * 64, + ) + accepted = service.accept_creation( + intent.intent_id, + principal=owner, + byte_size=60, + ) + assert accepted.upload_id is not None + + first = service.complete_upload( + intent_id=intent.intent_id, + upload_id=accepted.upload_id, + byte_size=60, + offset=60, + ) + second = service.complete_upload( + intent_id=intent.intent_id, + upload_id=accepted.upload_id, + byte_size=60, + offset=60, + ) + + assert first == second == accepted.upload_id + assert jobs.calls == [(accepted.upload_id, accepted.upload_id)] + catalog.close() + + +def test_creation_hook_retry_replays_only_before_tusd_materializes_upload( + tmp_path: Path, +) -> None: + service, catalog, _ = _service(tmp_path) + owner = Principal(subject="owner") + intent = service.create_intent( + _command(), + principal=owner, + request_key="a" * 64, + ) + + first = service.accept_creation( + intent.intent_id, + principal=owner, + byte_size=60, + ) + replay = service.accept_creation( + intent.intent_id, + principal=owner, + byte_size=60, + ) + + assert first.upload_id == replay.upload_id + assert first.upload_id is not None + service.settings.quarantine_root.mkdir(parents=True, exist_ok=True) + (service.settings.quarantine_root / f"{first.upload_id}.info").write_text( + "{}", + encoding="utf-8", + ) + with pytest.raises(ApplicationError) as duplicate: + service.accept_creation( + intent.intent_id, + principal=owner, + byte_size=60, + ) + assert duplicate.value.detail.code == "upload_already_created" + catalog.close() + + +def test_termination_reserves_state_before_finish_can_enqueue( + tmp_path: Path, +) -> None: + service, catalog, jobs = _service(tmp_path) + owner = Principal(subject="owner") + intent = service.create_intent( + _command(), + principal=owner, + request_key="a" * 64, + ) + accepted = service.accept_creation( + intent.intent_id, + principal=owner, + byte_size=60, + ) + assert accepted.upload_id is not None + + service.authorize_termination( + accepted.upload_id, + cleanup_token=None, + ) + + with pytest.raises(ApplicationError) as rejected: + service.complete_upload( + intent_id=intent.intent_id, + upload_id=accepted.upload_id, + byte_size=60, + offset=60, + ) + assert rejected.value.detail.code == "upload_completion_invalid" + assert jobs.calls == [] + stored = catalog.get_upload_intent(intent.intent_id) + assert stored is not None and stored.state == UploadState.expired + catalog.close() + + +def test_expired_creation_persists_state_and_releases_quota( + tmp_path: Path, +) -> None: + service, catalog, _ = _service(tmp_path) + now = utc_now() + record = UploadIntentRecord( + intent_id=uuid4().hex, + request_key="a" * 64, + original_filename="sample.mp4", + byte_size=60, + declared_mime_type="video/mp4", + state=UploadState.pending, + created_at=now - timedelta(minutes=10), + expires_at=now - timedelta(minutes=5), + ) + catalog.create_upload_intent(record, quota_limit=100) + + with pytest.raises(ApplicationError) as expired: + service.accept_creation( + record.intent_id, + principal=Principal(subject="owner"), + byte_size=60, + ) + + assert expired.value.detail.code == "upload_intent_expired" + stored = catalog.get_upload_intent(record.intent_id) + assert stored is not None and stored.state == UploadState.expired + service.create_intent( + _command(), + principal=Principal(subject="owner"), + request_key="b" * 64, + ) + catalog.close() + + +def test_active_resumable_upload_is_not_expired(tmp_path: Path) -> None: + service, catalog, _ = _service(tmp_path) + now = utc_now() + upload_id = "2" * 32 + record = UploadIntentRecord( + intent_id=uuid4().hex, + request_key="a" * 64, + original_filename="sample.mp4", + byte_size=60, + declared_mime_type="video/mp4", + state=UploadState.accepted, + created_at=now - timedelta(days=2), + expires_at=now - timedelta(days=1), + upload_id=upload_id, + ) + catalog.create_upload_intent(record, quota_limit=100) + service.settings.quarantine_root.mkdir(parents=True, exist_ok=True) + (service.settings.quarantine_root / f"{upload_id}.info").write_text( + '{"MetaData":{"intent_id":"' + + record.intent_id + + '"},"Size":60,"Offset":10}', + encoding="utf-8", + ) + + result = service.reconcile() + + assert result["expired"] == 0 + stored = catalog.get_upload_intent(record.intent_id) + assert stored is not None and stored.state == UploadState.accepted + catalog.close() + + +def test_concurrent_idempotency_winner_must_match_request( + tmp_path: Path, +) -> None: + service, catalog, _ = _service(tmp_path) + original_create = catalog.create_upload_intent + + def lose_race(record, *, quota_limit): + winner = record.model_copy( + update={ + "intent_id": uuid4().hex, + "original_filename": "other.mp4", + } + ) + original_create(winner, quota_limit=quota_limit) + raise IntegrityError("insert upload", {}, RuntimeError("duplicate")) + + with patch.object(catalog, "create_upload_intent", side_effect=lose_race): + with pytest.raises(ApplicationError) as conflict: + service.create_intent( + _command(), + principal=Principal(subject="owner"), + request_key="a" * 64, + ) + + assert conflict.value.detail.code == "idempotency_key_reused" + catalog.close() + + +def test_terminal_import_job_releases_processing_upload( + tmp_path: Path, +) -> None: + service, catalog, jobs = _service(tmp_path) + owner = Principal(subject="owner") + intent = service.create_intent( + _command(), + principal=owner, + request_key="a" * 64, + ) + accepted = service.accept_creation( + intent.intent_id, + principal=owner, + byte_size=60, + ) + assert accepted.upload_id is not None + job_id = service.complete_upload( + intent_id=intent.intent_id, + upload_id=accepted.upload_id, + byte_size=60, + offset=60, + ) + jobs.states[job_id] = JobState.cancelled + + result = service.reconcile() + + assert result["failed"] == 1 + stored = catalog.get_upload_intent(intent.intent_id) + assert stored is not None and stored.state == UploadState.failed + catalog.close() + + +def test_cleanup_reconciles_tusd_not_found_and_clears_capability_url( + tmp_path: Path, +) -> None: + service, catalog, _ = _service(tmp_path) + owner = Principal(subject="owner") + intent = service.create_intent( + _command(), + principal=owner, + request_key="a" * 64, + ) + accepted = service.accept_creation( + intent.intent_id, + principal=owner, + byte_size=60, + ) + assert accepted.upload_id is not None + service.authorize_termination( + accepted.upload_id, + cleanup_token=None, + ) + missing = HTTPError( + url="http://localhost/uploads/missing", + code=404, + msg="not found", + hdrs=None, + fp=None, + ) + + with patch("vidxp.upload_service.urlopen", side_effect=missing): + result = service.reconcile() + + assert result["cleaned"] == 1 + stored = catalog.get_upload_intent(intent.intent_id) + assert stored is not None + assert stored.state == UploadState.expired + assert stored.upload_id is None + catalog.close() diff --git a/tests/test_video.py b/tests/test_video.py index ac35371..282b4b4 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -7,6 +7,7 @@ from vidxp.core.contracts import CancellationToken from vidxp.core.video import ( + FrameSampling, FrameStreamStats, iter_frame_batches, render_actor_video, @@ -14,15 +15,16 @@ class FakeCapture: - def __init__(self, frames): + def __init__(self, frames, *, fps=10.0): self.frames = list(frames) + self.fps = fps self.position = 0 self.read_calls = 0 self.grab_calls = 0 self.released = False def get(self, _): - return 10.0 + return self.fps def isOpened(self): return True @@ -75,6 +77,58 @@ def test_stride_advances_skipped_frames_without_materializing_them(self): self.assertEqual(stats.frames_materialized, 2) self.assertTrue(capture.released) + def test_multiple_cadences_materialize_their_union_once(self): + capture = FakeCapture([f"f{index}" for index in range(8)]) + fake_cv2 = types.SimpleNamespace( + CAP_PROP_FPS=5, + VideoCapture=lambda _: capture, + ) + + with patch.dict(sys.modules, {"cv2": fake_cv2}): + batches = list( + iter_frame_batches( + "unused.mp4", + frame_strides=(3, 4), + batch_size=8, + cancellation=CancellationToken(), + ) + ) + + self.assertEqual( + [sample.frame_index for sample in batches[0]], + [0, 3, 4, 6], + ) + self.assertEqual(capture.read_calls, 5) + self.assertEqual(capture.grab_calls, 4) + + def test_time_and_frame_cadences_share_one_materialized_union(self): + capture = FakeCapture( + [f"f{index}" for index in range(6)], + fps=1.5, + ) + fake_cv2 = types.SimpleNamespace( + CAP_PROP_FPS=5, + VideoCapture=lambda _: capture, + ) + + with patch.dict(sys.modules, {"cv2": fake_cv2}): + batches = list( + iter_frame_batches( + "unused.mp4", + samplings=( + FrameSampling(source_fps=1.5, target_fps=1.0), + FrameSampling(frame_stride=2), + ), + batch_size=8, + cancellation=CancellationToken(), + ) + ) + + self.assertEqual( + [sample.frame_index for sample in batches[0]], + [0, 2, 3, 4, 5], + ) + def test_actor_renderer_creates_output_directory_and_falls_back_codec(self): with TemporaryDirectory() as directory: input_path = Path(directory) / "input.mp4" @@ -105,6 +159,7 @@ def writer(path, codec, *_): CAP_PROP_FPS=1, CAP_PROP_FRAME_WIDTH=2, CAP_PROP_FRAME_HEIGHT=3, + CAP_PROP_FRAME_COUNT=5, FONT_HERSHEY_SIMPLEX=4, VideoCapture=lambda _: capture, VideoWriter=writer, diff --git a/utils/build_package.sh b/utils/build_package.sh index 70a8495..86c173f 100644 --- a/utils/build_package.sh +++ b/utils/build_package.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +python utils/sync_desktop_lock_versions.py "${NEW_VERSION:?NEW_VERSION is required}" + BASE_URL="https://github.com/grayhatdevelopers/vidxp/blob/main" README="README.md" README_BAK="$README.bak" diff --git a/utils/sync_desktop_lock_versions.py b/utils/sync_desktop_lock_versions.py new file mode 100644 index 0000000..12b1dee --- /dev/null +++ b/utils/sync_desktop_lock_versions.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import argparse +import json +import re +import tomllib +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def _read_json(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def _require_release_versions(version: str) -> None: + manifest = _read_json(ROOT / "desktop" / "runtime-manifest.json") + expected = { + "pyproject.toml": tomllib.loads( + (ROOT / "pyproject.toml").read_text(encoding="utf-8") + )["project"]["version"], + "desktop/package.json": _read_json( + ROOT / "desktop" / "package.json" + )["version"], + "desktop/runtime-manifest.json:desktop_version": manifest[ + "desktop_version" + ], + "desktop/runtime-manifest.json:package_version": manifest[ + "package_version" + ], + "desktop/src-tauri/Cargo.toml": tomllib.loads( + (ROOT / "desktop" / "src-tauri" / "Cargo.toml").read_text( + encoding="utf-8" + ) + )["package"]["version"], + "desktop/src-tauri/tauri.conf.json": _read_json( + ROOT / "desktop" / "src-tauri" / "tauri.conf.json" + )["version"], + } + stale = [path for path, value in expected.items() if value != version] + if stale: + raise RuntimeError( + "python-semantic-release did not stamp the requested version in: " + + ", ".join(stale) + ) + + +def update(version: str) -> None: + _require_release_versions(version) + + package_lock_path = ROOT / "desktop" / "package-lock.json" + package_lock = _read_json(package_lock_path) + package_lock["version"] = version + package_lock["packages"][""]["version"] = version + package_lock_path.write_text( + json.dumps(package_lock, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + + cargo_lock_path = ROOT / "desktop" / "src-tauri" / "Cargo.lock" + cargo_lock = cargo_lock_path.read_text(encoding="utf-8") + updated, count = re.subn( + ( + r'(?m)(^\[\[package\]\]\r?\nname = "vidxp-desktop"\r?\n' + r'version = ")[^"]+(")' + ), + rf"\g<1>{version}\g<2>", + cargo_lock, + count=1, + ) + if count != 1: + raise RuntimeError("Could not find vidxp-desktop in Cargo.lock.") + cargo_lock_path.write_text(updated, encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("version") + args = parser.parse_args() + update(args.version) + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..57ecd7e --- /dev/null +++ b/uv.lock @@ -0,0 +1,4668 @@ +version = 1 +revision = 3 +requires-python = ">=3.11, <3.15" +resolution-markers = [ + "(python_full_version >= '3.14' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform == 'win32')", + "(python_full_version == '3.13.*' and sys_platform == 'linux') or (python_full_version == '3.13.*' and sys_platform == 'win32')", + "(python_full_version == '3.12.*' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform == 'win32')", + "(python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')", + "python_full_version >= '3.14' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version < '3.12' and sys_platform != 'linux' and sys_platform != 'win32'", +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" }, + { url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" }, + { url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" }, + { url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" }, + { url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" }, + { url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" }, + { url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" }, + { url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" }, + { url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" }, + { url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, + { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, + { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, + { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, + { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, + { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, + { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, + { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, + { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "alembic" +version = "1.18.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/cc/ac0bed8e562e7407fe55c3ba85a4dce86e6dbd8730887bd1e406a6c5c18a/alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e", size = 2060480, upload-time = "2026-06-25T15:20:54.888Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/78/5fe6dc3a3a5b2f5a2a4faef8bfe336d5fa049a38884ab3172e0098160c01/alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc", size = 264664, upload-time = "2026-06-25T15:20:56.673Z" }, +] + +[[package]] +name = "altair" +version = "6.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "narwhals" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/a1/5e6cc638a66da48cfc89a79c2f4810dfec00b63385f9b009ab1f069779bb/altair-6.2.2.tar.gz", hash = "sha256:a1ff9d9cfe81c75414641826312b9471780e19d39293ba0b012933f6b6cba0fe", size = 766606, upload-time = "2026-06-23T12:47:13.384Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/99/d6031f4f146298951c46b1bf1cc160c2a63f6e44b3c13a30054add100d5f/altair-6.2.2-py3-none-any.whl", hash = "sha256:94014f8ad8617c3cb163d1137359cd6db5ba134b9b46d93cfd8b609fd245a583", size = 797613, upload-time = "2026-06-23T12:47:11.451Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "asgi-correlation-id" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/25/df29145ef02ca69548e82b50ed15ac5c438ab5caf7b8e717d2b1258f5bea/asgi_correlation_id-5.0.1.tar.gz", hash = "sha256:ad54374a300f000b8bef3b39738768842394de0a3c528a6f77ef3ca053eb89a1", size = 10642, upload-time = "2026-06-09T15:09:49.112Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/fe/3f5a304ddc2b625a6a2fda913b29bc4675c434016557592791cf1aa39a60/asgi_correlation_id-5.0.1-py3-none-any.whl", hash = "sha256:dfea97c1fc41e8eadc3e518a311427cba6e7e79d9ddb6a6c7ea8879f2408d91d", size = 12026, upload-time = "2026-06-09T15:09:48.155Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "av" +version = "18.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/a4/570a5a35c8638aba01e739925846c35fdd6b0756a15526766d0a4dd3b7df/av-18.0.0.tar.gz", hash = "sha256:4ef7e72c3d3a872584a1215173b16e0226811037f40dcdbf75992631098df1ba", size = 4340222, upload-time = "2026-07-02T06:37:58.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/4a/9e3463df030e063d757fa12f0f39be6541b45b06b5bad48c2ce361b924bf/av-18.0.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:149289d40e732a6e49c9530bc245b49d9964cfd1c8c9e06778703b7d5bba6b25", size = 22499354, upload-time = "2026-07-02T06:36:58.751Z" }, + { url = "https://files.pythonhosted.org/packages/77/b3/2576a44b4f39c7462ced4c17fec04c756f7b0f3c5cb940d124173e417d6a/av-18.0.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:35274c20d2ad3b4774fe632bcef2e34af79858ddf899352339cc3babbc13a484", size = 18175248, upload-time = "2026-07-02T06:37:01.741Z" }, + { url = "https://files.pythonhosted.org/packages/84/74/6732f17b96dc23fd23b876b2805435855abdc8a3b397142be4e581165de8/av-18.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4d683b7747a0ba9222b8a5f81e41db5f796e7f64473454ec4fe2548e083c2fa0", size = 33387843, upload-time = "2026-07-02T06:37:05.097Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b9/7708c43fed7ae28b4a1bad060b4221e3334cd827cec24f7165902a6ac1f4/av-18.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ae56b40b6f8b067a8ad2dac664fbfbabac7f7a55b9a7bb031eb99289252bc017", size = 35536910, upload-time = "2026-07-02T06:37:08.806Z" }, + { url = "https://files.pythonhosted.org/packages/5a/94/eba99691d184f6a395a242d54dc370e2fd2265e95bbc98e2963a0fdbdd6c/av-18.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ea2e8ebbce521f21b55df9400e00d721623c9020ef158f5a188a96130be0743f", size = 38984619, upload-time = "2026-07-02T06:37:11.861Z" }, + { url = "https://files.pythonhosted.org/packages/c9/cf/0d7aee07fe16aa9ffdf96043c14bed5485a52c0dea4259de87aa306ecab4/av-18.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef96dabb3e50dac249913145dff5424b302b257fd95dcb64be3c7b7a8aef16d1", size = 34451176, upload-time = "2026-07-02T06:37:15.154Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/810da80b12680d4c4fe235bd1b4003289be9213ac7f114b77b8ecf0e3b3e/av-18.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0f65518a184613e41536f29e8758c8e3d8293e46bf5bef108f04f925bbfa3f44", size = 36619869, upload-time = "2026-07-02T06:37:18.495Z" }, + { url = "https://files.pythonhosted.org/packages/11/85/0f121ff43dc5a70696676c98a8f1674e2fa787614c2abaacb15fa1a9bc99/av-18.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:aaf4d354d2beaa6651e4f92e54409a578bde64f79c0beef9a30b388d06f7c629", size = 27556236, upload-time = "2026-07-02T06:37:21.388Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f6/2509754d4d2356abc6fc0ea3d57c12ade29bac23a1fb7fc215a53ca518fb/av-18.0.0-cp311-abi3-win_arm64.whl", hash = "sha256:adac2b3833b6cb9bd6cb52664a522b94db453615b3675b1dbb26e13fe1c80da6", size = 20221133, upload-time = "2026-07-02T06:37:23.88Z" }, + { url = "https://files.pythonhosted.org/packages/e2/25/4ee23a7f1609adf9b2f140c7a8ffade64a1449d89ab431d922a809eebf19/av-18.0.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:88dd8e35e9242662b409a6a05fd24a6775d949eb05da0ba31cab4f250eacbab5", size = 22740741, upload-time = "2026-07-02T06:37:26.659Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f0/b9f8363d07aa4521913e483f6a30c7c164973ef01de62769bf9b97049cd8/av-18.0.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f8f454349c402e2c8d6fa80b54eb2a3f86c00f414d2b399f01ae6dab075c6fd8", size = 18384189, upload-time = "2026-07-02T06:37:29.518Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e5/69397019aed280a72a43e97a252dee4295df1a9e608848452e5300ec4dab/av-18.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:88ce194c2201c6a6d40336adee8a5ddde46ed743eacb500e3ae9368d1c6d889e", size = 36749881, upload-time = "2026-07-02T06:37:33.096Z" }, + { url = "https://files.pythonhosted.org/packages/37/3a/1614d74f0d676ea6745eb59553c9ad01ca25db523cba808d522e838f4f5b/av-18.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:aa15e567a018cc94a26b0ab45da676dee70c4146ace6e92e47d30cc9689cbfbe", size = 38645927, upload-time = "2026-07-02T06:37:37.086Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3c/5f54710d69b0ea93634134f92b49c7a2a7fd27da5486a8a7e6251ac1cfb4/av-18.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:613153e48cefc91700746dde0ad0282d4677b194cba22cc771de14c78411cf8b", size = 40454783, upload-time = "2026-07-02T06:37:40.904Z" }, + { url = "https://files.pythonhosted.org/packages/26/92/8293e6a267e0591b543abd96ae01e7e8ed228509bdb4e4644a8a8395d90f/av-18.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:30404f53ca1ea7f350ac86ff22a2c04f903014758e9b33f398c5a62de34bd84f", size = 37573117, upload-time = "2026-07-02T06:37:44.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/0c/38ed7601277ae57dfe857d040be4762530fd728efff45c2fb8f035fef96a/av-18.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6882a48f7aec2863c96cddee3256ff2da98f7fb6cbed83cee9d7e70a8f186a6b", size = 39669026, upload-time = "2026-07-02T06:37:48.761Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/0636ca04d5d89d01c49bd366d2b660cc85d1f8117c476b2be62eb0c70855/av-18.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:55a646e9afce9fdc5de5224205a8a12c7ed1ba9803145dcc876c40bfc03a109b", size = 28448336, upload-time = "2026-07-02T06:37:52.477Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/1e24450ea981c44ed328691496fd2774dfa9fa3c3b00fd07f72fd5614abe/av-18.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:96f594ff506a09475e5549359352332049a25d37a08f00b4623f7f6e92e45b9c", size = 21377289, upload-time = "2026-07-02T06:37:55.935Z" }, +] + +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +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/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" }, + { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" }, + { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" }, + { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" }, + { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" }, + { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" }, + { 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/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, + { 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/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, + { 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/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, + { 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/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { 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/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { 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/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { 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/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { 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/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, + { url = "https://files.pythonhosted.org/packages/8a/75/4aa9f5a4d40d762892066ba1046000b329c7cd58e888a6db878019b282dc/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", size = 271180, upload-time = "2025-09-25T19:50:38.575Z" }, + { url = "https://files.pythonhosted.org/packages/54/79/875f9558179573d40a9cc743038ac2bf67dfb79cecb1e8b5d70e88c94c3d/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", size = 273791, upload-time = "2025-09-25T19:50:39.913Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fe/975adb8c216174bf70fc17535f75e85ac06ed5252ea077be10d9cff5ce24/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", size = 270746, upload-time = "2025-09-25T19:50:43.306Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "build" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/e0/df5e171f685f82f37b12e1f208064e24244911079d7b767447d1af7e0d70/build-1.5.0.tar.gz", hash = "sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647", size = 89796, upload-time = "2026-04-30T03:18:25.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl", hash = "sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f", size = 26018, upload-time = "2026-04-30T03:18:23.644Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" }, + { url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" }, + { url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" }, + { url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" }, + { url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" }, + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "chromadb" +version = "1.5.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bcrypt" }, + { name = "build" }, + { name = "grpcio" }, + { name = "httpx" }, + { name = "importlib-resources" }, + { name = "jsonschema" }, + { name = "kubernetes" }, + { name = "mmh3" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "onnxruntime" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-sdk" }, + { name = "orjson" }, + { name = "overrides" }, + { name = "pybase64" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pypika" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "tenacity" }, + { name = "tokenizers" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "typing-extensions" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/d1/5e33b26985f0c7046a0be1cee2158ada1748ee700d2545057fde1468d74d/chromadb-1.5.9.tar.gz", hash = "sha256:5c20e62a455c28bacac927f26116a73fd8e1799e0d908be8e8a4f02197a54731", size = 2595635, upload-time = "2026-05-05T05:54:51.713Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/5b/3cced915244f43ed14b53fe9f63a37f05f865064f4e4fe7d9448d3f2a352/chromadb-1.5.9-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:60701011b5e6409647fa40d12c7c5a66b2b0bfcf33a52db2ad53a30a2abc4957", size = 22564540, upload-time = "2026-05-05T05:54:48.906Z" }, + { url = "https://files.pythonhosted.org/packages/34/4c/adcef1f4e82a2ef69ccd3711d55fc289193d54c4c0ff7a0292a3631db46f/chromadb-1.5.9-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:814b9c95617377f6501e5757d63dfddb554a283a7739c87b9fa573850174e6f3", size = 21699698, upload-time = "2026-05-05T05:54:45.078Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/937bc4d2e6f8ab9664ec79931fbbd69efff47e513ec2924b071e4b0ff774/chromadb-1.5.9-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9192d111bd662241625867962333d99369a00769a50f8b2f58cb388731274d7e", size = 22680924, upload-time = "2026-05-05T05:54:36.25Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ec/0c42039e80b9acc534f67b73b7a42471948042859b3a64867b50a4a77fa3/chromadb-1.5.9-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc09b3df76e5a5cb386aed2715a2eea152e3949f9e1ba93c7119505377749929", size = 23316203, upload-time = "2026-05-05T05:54:41.157Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ce/0f7be6e5d0feafa2cda54b12e6542afeea7dea89d2d411e14da90f8abb96/chromadb-1.5.9-cp39-abi3-win_amd64.whl", hash = "sha256:4fd0b560e56761b7f3cb4d5c6205fd5f20814484b4a3e4e9af9038c2b428fc6c", size = 23542454, upload-time = "2026-05-05T05:54:54.942Z" }, +] + +[[package]] +name = "chromadb-client" +version = "1.5.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "jsonschema" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-sdk" }, + { name = "orjson" }, + { name = "overrides" }, + { name = "pybase64" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/3b/47c02df4d56883f1dee3548d03a52e75ab1d1c282030ffaf32031bb46569/chromadb_client-1.5.9.tar.gz", hash = "sha256:5e481d51ce6634fe54230b0411bf1ff257607ace222fc64c669e27874c12175b", size = 22112301, upload-time = "2026-05-05T05:36:19.069Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/0c/4ae7985586638bcaf5459f6cad5e81704317df1946419c8a16fb8f9c1fbc/chromadb_client-1.5.9-py3-none-any.whl", hash = "sha256:3200b9f79389974f9a35bbcdd0657289f5352da3a7d0d37c63194283e02d0605", size = 798976, upload-time = "2026-05-05T05:36:15.966Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +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 = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +] + +[[package]] +name = "ctranslate2" +version = "4.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pyyaml" }, + { name = "setuptools" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/c4/0e450796f90e54f3325697fc67db4f4ecd397aef96d7b3924e26fb8bd04b/ctranslate2-4.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c2db633a06e3b34bbfb72fd26eee58053d9df1f9c1610ac4df3a6a1e25af7d7", size = 1270559, upload-time = "2026-07-03T12:39:01.154Z" }, + { url = "https://files.pythonhosted.org/packages/b7/54/7b6db16470d0788fb8ab43a99e3e18ba9d41a9b50b7fef7dec353eafbe20/ctranslate2-4.8.1-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:079976cbce3a68de04bf9948d08c96beb86df44e5cd2974e4187bc9c9bb388f3", size = 11928069, upload-time = "2026-07-03T12:39:02.6Z" }, + { url = "https://files.pythonhosted.org/packages/37/66/8fee1366631d224bf26b34db9063a0c88ce358d58331c2393689b0ea27ff/ctranslate2-4.8.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74bae0a8dc9f98c5a6100bf1c17a91782b384ea53b83e2606030ebf9f25318fe", size = 16707971, upload-time = "2026-07-03T12:39:05.09Z" }, + { url = "https://files.pythonhosted.org/packages/30/84/f610e90bb419707632b9b668476b9fd4cdb090c9b53c119ce017699b58ca/ctranslate2-4.8.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0a584c17f21779eb9035bcbc1ec280998f90b36725b70a5ff911f33e343199a", size = 39351971, upload-time = "2026-07-03T12:39:08.555Z" }, + { url = "https://files.pythonhosted.org/packages/76/6c/7230ecbdd23ab867715e1b6ffe99211c39c11cae8ec2d6c3ec9208c38ee2/ctranslate2-4.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:82982f07a7d615d2248d17d6ec4c43cd50e534b094aa27cda62125a5e3a6e3fc", size = 19219248, upload-time = "2026-07-03T12:39:11.329Z" }, + { url = "https://files.pythonhosted.org/packages/6d/09/9a50eeab00db68aeac08f6ab7f98b5c36abd26b89cbd707ea39e70656500/ctranslate2-4.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9de0dddd91ae68da0a7323441e90708d14b31d31cd443004dda0e1198b5bf11e", size = 1270522, upload-time = "2026-07-03T12:39:13.368Z" }, + { url = "https://files.pythonhosted.org/packages/2f/97/6c41c4d3ae539ec76b1943c362184677befd7c1d5290d2ec361182cdb1e0/ctranslate2-4.8.1-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:82e0e6eb7d4301fd79a714495c8faf34242e09542cef04c9e9794c3fe90014a1", size = 11930367, upload-time = "2026-07-03T12:39:14.896Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d4/03428106134a0a58922461074f8942f92c5ed0bb3a8d018677ad64a9c476/ctranslate2-4.8.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5ca144b93035b9f53e6d67b7cdf5802c3fffca9aa0247940eecbd4592c68ce2f", size = 16882768, upload-time = "2026-07-03T12:39:17.425Z" }, + { url = "https://files.pythonhosted.org/packages/47/c9/976a565398a03fb2973cbe5edd5ca03c4332d86b634799e0ee562420d3bc/ctranslate2-4.8.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dacc408f716ebc73b3b3c6ddd937700e776c4c68b6d9c81862990150ff0f6af6", size = 39529060, upload-time = "2026-07-03T12:39:20.468Z" }, + { url = "https://files.pythonhosted.org/packages/c0/82/0a5f7f2b03b4e10aacb3146715724e1b96bb993cc7d199be28c9825aa120/ctranslate2-4.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:49f96e861b57301f0b76a082109bde2cac8204a6b4fedc870883008271e82251", size = 19220789, upload-time = "2026-07-03T12:39:23.356Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a7/3101c3a0785253a8ef386f39744ad19c28c75b7f227e7c232aee7a5c416a/ctranslate2-4.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ba628835e6ad4ad399261ab6cb51bf152de563e6b122a9e8eb0c61e69f925931", size = 1270478, upload-time = "2026-07-03T12:39:25.401Z" }, + { url = "https://files.pythonhosted.org/packages/89/b9/e50c7558e96a054d6b1e6a6c5e729dda4a4f05584e065f2902aa5f1bc4c8/ctranslate2-4.8.1-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:85ef15ce0b2172ec471975b8a30d5c5bc71e7cffcd163ad6c07ea32f1943d940", size = 11930241, upload-time = "2026-07-03T12:39:26.927Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2f/ea7a19c6d7e949b731fb034664633184bbfc7882846d107f4d790693fb76/ctranslate2-4.8.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0030670278a73cae09dff9bca72cdd248af61f9367257f18db9b3b94fbb3a50d", size = 16883512, upload-time = "2026-07-03T12:39:29.302Z" }, + { url = "https://files.pythonhosted.org/packages/99/4a/21f325a9d0925d8ad24b04249adf29bf9909442967603634f7f6d4acbb79/ctranslate2-4.8.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4242a7f8e285f922525f4cffd5b1fb43cbacc61d0611cf54832e9c447d030840", size = 39529085, upload-time = "2026-07-03T12:39:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e7/37da1a7500b57496a5269318c4f57962ea0c26dcac06b85222d7831acf00/ctranslate2-4.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:d52499f05a60a791aeadee28d609efa130142f376d1ea76b2b1c593bb01f8827", size = 19220784, upload-time = "2026-07-03T12:39:35.74Z" }, + { url = "https://files.pythonhosted.org/packages/c6/66/39111224e418400d97fd79fbc9e72329c51f91a3e7a9c9a1a182e4f88022/ctranslate2-4.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b4c3246aa4a7f309109a841ca743a72cc4abad4f93c0bf7da691023323215621", size = 1271321, upload-time = "2026-07-03T12:39:37.907Z" }, + { url = "https://files.pythonhosted.org/packages/ef/89/13f827fae226eea51315729c00111f716813d7736ebb827fecb8f361fe0d/ctranslate2-4.8.1-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:c989f747789e8619cbc2e06443b3674c31bc71bad0369652485bd894b627360a", size = 11930735, upload-time = "2026-07-03T12:39:39.534Z" }, + { url = "https://files.pythonhosted.org/packages/c9/94/4b73f9bbaba29df4227cc65114f11d83fe6d696ef3705cb1ade79eb118fd/ctranslate2-4.8.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90eb0bd67b6bb183712cc3fd14bf01ec4f622cd625c5b33cc6c56be7d1c9c34", size = 16872460, upload-time = "2026-07-03T12:39:42.272Z" }, + { url = "https://files.pythonhosted.org/packages/ee/d0/9816494d5ff0745bdf9abe5af04e57a103a416444e604cbe83a6eb0aed7b/ctranslate2-4.8.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e3e3aef4670a6c8dcea367401675f82b49b02c18f5837221bcd7cca90b1707a8", size = 39494736, upload-time = "2026-07-03T12:39:45.733Z" }, + { url = "https://files.pythonhosted.org/packages/6c/dc/22a2c874ca8bb6caa7018dfefdff92dddd487db31cf169891c4c6d408091/ctranslate2-4.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:a2dcce0a57beee984a691d9daa8fc3fd389f5b6cada2644c34571011833bd5b1", size = 19477164, upload-time = "2026-07-03T12:39:48.952Z" }, + { url = "https://files.pythonhosted.org/packages/77/39/7b8d47bf49748ba73182742683eef74b46608beb879765d9d4efc46bc345/ctranslate2-4.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a28c5889585cd17ee3649dfd46d9002ddf50204173f8bff476b9f76d6585795", size = 1293935, upload-time = "2026-07-03T12:39:50.924Z" }, + { url = "https://files.pythonhosted.org/packages/c1/20/434e30c752c433eaef5deccd4de54775bc1f205a6fe6c9e756b737018209/ctranslate2-4.8.1-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:911a5cdef8a405c1804330613a1865f616eb9c092a0e932ee4648128eb20b627", size = 11951789, upload-time = "2026-07-03T12:39:52.886Z" }, + { url = "https://files.pythonhosted.org/packages/85/f2/d716426220b462bbb5bb354b9c6c8d9a41285f067203c860cc79f9f19917/ctranslate2-4.8.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84723cae6f802551bbf2438e5e4810722631a2183b89a82c31df26566b54821d", size = 16860414, upload-time = "2026-07-03T12:39:55.54Z" }, + { url = "https://files.pythonhosted.org/packages/69/11/cdab0e7e2ad4e547f15ab227c09207569f1272abae05816900ecebb0797a/ctranslate2-4.8.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1910752ec541980644191fa3b407bc61dee00e88070b0aed29b4cef75010b3ea", size = 39465200, upload-time = "2026-07-03T12:39:59.017Z" }, + { url = "https://files.pythonhosted.org/packages/c0/03/126e963fc3237a416f3085b8a663ebd8ab449ed6c37195b4e0b49597ba0c/ctranslate2-4.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:dc9f1abef55579cc02cdc74b3a55df38491ec56d177d6e6039609d61d09ed30e", size = 19499597, upload-time = "2026-07-03T12:40:01.68Z" }, +] + +[[package]] +name = "dbos" +version = "2.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "psycopg", extra = ["binary"] }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "typer-slim" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/b1/8a6c8ace5764e19850f2fd3f010251c3135daf1d682f43566c18dd0d432f/dbos-2.28.0.tar.gz", hash = "sha256:e738d62baf3953639d3e70988912b533ffd9de047d8b90d12755c10c03cac6fe", size = 592187, upload-time = "2026-07-21T15:24:08.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/d4/17f83581209153dff0bb7f6369c83d98d06303f8674746a7d47875add155/dbos-2.28.0-py3-none-any.whl", hash = "sha256:ba1bec5fcb2505b1bdd99251056cecf79669337699a81d5ef2b7598f701f31b6", size = 236036, upload-time = "2026-07-21T15:24:06.354Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "durationpy" +version = "0.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/a4/e44218c2b394e31a6dd0d6b095c4e1f32d0be54c2a4b250032d717647bab/durationpy-0.10.tar.gz", hash = "sha256:1fa6893409a6e739c9c72334fc65cca1f355dbdd93405d30f726deb5bde42fba", size = 3335, upload-time = "2025-05-17T13:52:37.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922, upload-time = "2025-05-17T13:52:36.463Z" }, +] + +[[package]] +name = "fastapi" +version = "0.140.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/cb/7a4d2c2eb5a5d8a91763c05b7383d72917862e32f780daa0e27ffbb34cc6/fastapi-0.140.13.tar.gz", hash = "sha256:500172a08cf1459901f90b05c37d93060dada3b573fec8f0862445db52ba6b4b", size = 424843, upload-time = "2026-07-28T15:37:00.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/4e/f9e8c762ef5e05c40482131e3d5e8b36bca13fa127578261f1d6b35a25d4/fastapi-0.140.13-py3-none-any.whl", hash = "sha256:8b017110e1e9f30a95e8bdb8f71fbe2f0fe3af5717109e5b14f9e069df54f6d4", size = 131222, upload-time = "2026-07-28T15:37:02.124Z" }, +] + +[[package]] +name = "faster-whisper" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "av" }, + { name = "ctranslate2" }, + { name = "huggingface-hub" }, + { name = "onnxruntime" }, + { name = "tokenizers" }, + { name = "tqdm" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/99/49ee85903dee060d9f08297b4a342e5e0bcfca2f027a07b4ee0a38ab13f9/faster_whisper-1.2.1-py3-none-any.whl", hash = "sha256:79a66ad50688c0b794dd501dc340a736992a6342f7f95e5811be60b5224a26a7", size = 1118909, upload-time = "2025-10-31T11:35:47.794Z" }, +] + +[[package]] +name = "filelock" +version = "3.32.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/80/8232b582c4b318b817cf1274ba74976b07b34d35ef439b3eb948f98645a1/filelock-3.32.0.tar.gz", hash = "sha256:7be2ad23a14607ccc71808e68fe30848aeace7058ace17852f68e2a68e310402", size = 213757, upload-time = "2026-07-21T13:17:42.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/79/b4c714bef36bc4ec2beeae1e0c124f0223888cd8c6feb1cdc56038116920/filelock-3.32.0-py3-none-any.whl", hash = "sha256:d396bea984af47333ef05e50eae7eff88c84256de6112aea0ec48a233c064fe3", size = 97732, upload-time = "2026-07-21T13:17:41.55Z" }, +] + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/78/f34251dadb8f3921264a1d9b8946f5e542014ee2614b285261b4e40e6775/fsspec-2026.7.0.tar.gz", hash = "sha256:c803c40f4cf860b49dea58ee3e1c33cb9c790520e233537e1340049f89b82a88", size = 317040, upload-time = "2026-07-28T16:34:51.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/3c/6a2bf344106328fd04963664a60b9bb6496fc25df8e962fcdc1367285fb9/fsspec-2026.7.0-py3-none-any.whl", hash = "sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279", size = 206583, upload-time = "2026-07-28T16:34:49.538Z" }, +] + +[[package]] +name = "genai-prices" +version = "0.0.72" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx2" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/c8/2549fa8ceaaf0bd61114cf392250a107be657233723bc4e65cb91dba934f/genai_prices-0.0.72.tar.gz", hash = "sha256:a7e481d0ea85922fcf48df6864f2491fc81a4f03dcf0ecbd9aa8c2c6d9fcdbe8", size = 82753, upload-time = "2026-07-22T21:11:03.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/2e/1e2c31666f7e4e2d0327a80f1c920fd8764ccd27672c4bc389d79219ece6/genai_prices-0.0.72-py3-none-any.whl", hash = "sha256:21281d8df34d9bfbb736e0763a60ceda02226938cd47379ae437a44fcf8ba23d", size = 85238, upload-time = "2026-07-22T21:11:02.911Z" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.57" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/0d/132ed135c871b6bf91adf16a0e43797cd535b81d4973b5d09291c54fc5ee/gitpython-3.1.57.tar.gz", hash = "sha256:c493ec57c0ef6b19743798b6a5af859c71814b524e7e6f97baa2f8e658961488", size = 225898, upload-time = "2026-07-26T07:33:26.351Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/6e/2139de986d9c7c3ac86f1f8be43858ce90bdfe2f7175e6c80c650ba15242/gitpython-3.1.57-py3-none-any.whl", hash = "sha256:4ccf7d73c10f5c9e76043fbb2675ac5a1b3ff5b41e648f56bcbed5f63792ecaf", size = 217151, upload-time = "2026-07-26T07:33:24.838Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.75.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/74/b13368064b09053253555d3f2839cc2684d22d5aed0d2ccffbf7a6736558/greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20", size = 206538, upload-time = "2026-07-22T12:47:14.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/16/71eefcf68267bbf06a9b6bff57d0b222e49432326e85d74348b67694b8d4/greenlet-3.5.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e883de250e299654b1f1680f72a1a9f9ba62c9bd1bce84099c90657349a8dfbb", size = 294266, upload-time = "2026-07-22T11:37:56.142Z" }, + { url = "https://files.pythonhosted.org/packages/36/ea/a0b19adfc35d07e10acb626e9d22a3893b95f1309c42c4a20161dec16800/greenlet-3.5.4-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32802705c2c1ff25e8237b3bdacf2594fa02be80af8a66703eb7853ea7e68686", size = 613712, upload-time = "2026-07-22T12:26:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a121978b3337407d05a1ce5f79b4aa5998a43a9d8422f9726029b90b4471/greenlet-3.5.4-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57aa201b351f7c7c75627c60d29e4d5b97a07d37efeb62b903466fca42c097d7", size = 625582, upload-time = "2026-07-22T12:29:00.814Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4a/f301f1d85c69a86b90b5d581a73e8927bba4e79450037e6e2cbca05eb4fd/greenlet-3.5.4-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9667862a2e38ad379f11b845daeda22c8989186def44f06962c9c4c05e556da7", size = 633429, upload-time = "2026-07-22T12:43:42.073Z" }, + { url = "https://files.pythonhosted.org/packages/34/c2/080f16cf870e929e592f55767f01d6c98d2ee83bfdc36c3b892f2d0459ab/greenlet-3.5.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3fe76c2cac86b4f7a1e92865ac0a54384deb05c92986287c1a7110d9bd53071", size = 624663, upload-time = "2026-07-22T11:51:08.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2e/26884072b0eb343a4d5fee903341bfe5171b32b7f14553886e2b6349135a/greenlet-3.5.4-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:ae53534b5dec0f4c2ec26f898f538dc8ea1ca3ef2927d597a9439e40a09da937", size = 428238, upload-time = "2026-07-22T12:39:49.973Z" }, + { url = "https://files.pythonhosted.org/packages/9e/bb/8f3ca88370b817369008faeceeee85970adc16c92a70a3e5fe5fea495a57/greenlet-3.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1e1a4a684b16c45ba324e60b32a4386a87722bcb815d2a149d2182f9b401ca72", size = 1585010, upload-time = "2026-07-22T12:25:02.539Z" }, + { url = "https://files.pythonhosted.org/packages/51/c2/45877154689709ebce9a0b83c2235e6ca0f31577889b02af308c8cc5f8fb/greenlet-3.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e849e6e139b9671adeac505f72fc05f4af7fd1921faef40295e214fc3b361b59", size = 1651283, upload-time = "2026-07-22T11:51:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7d/8711a75cb61d85246277c07ff6e1a6504621ba473d808c11ad225ffca43f/greenlet-3.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:dc418cf4c873357964d6624445ed09472e50def990c65dd4e76fc3ba8cd9cef6", size = 246434, upload-time = "2026-07-22T11:43:15.557Z" }, + { url = "https://files.pythonhosted.org/packages/00/62/e290b3bce433da8f0324ac02da0b128d683482229f1a8b789fa47818a4cd/greenlet-3.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:c38c902a0986eba1f6e7ba1ab39ad5195926abde90f3fe080e08212db62176da", size = 244990, upload-time = "2026-07-22T11:39:22.626Z" }, + { url = "https://files.pythonhosted.org/packages/f3/04/81bd731d6d1e3a469d9a4c36f5eb069bcf0cbb2d5d342c9fec22245b91fc/greenlet-3.5.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3d66250e8b09f182ede05490998c818b5961f7a3640332d44c4927caec7bbfe4", size = 295909, upload-time = "2026-07-22T11:38:09.261Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dd/f5f22903a6ae70f5ea328ed0beaec92ad903f0e3b7d2845133b354abc4b8/greenlet-3.5.4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90e930c9c192e5b3ee9fb8bcd920ea3926155e2e3ded39fc697323addecee17", size = 612011, upload-time = "2026-07-22T12:26:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/8e/10/92a4a88d12b915d74ea5b6d288e4afefda4771647caa34442c156f7a454f/greenlet-3.5.4-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:791fdfeeb9c6e0c7b10fa151bf110d2a6974866f13dcb5b1c7efae698245893a", size = 624299, upload-time = "2026-07-22T12:29:02.089Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f9/03e26be3487c5238e81f2b84714959a86ea8515a869828cf41f4fc54b34e/greenlet-3.5.4-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b7c895310363f310361e0fe2072af85269d2a2a285cd04c0c59e79a5e3670dcf", size = 629603, upload-time = "2026-07-22T12:43:43.456Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/0b14bb9db2989f32cd9fe7f76afedea01ee8bee3f87c07e69f24adfe7e63/greenlet-3.5.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f88193799d43dbf8c8a806d6405c9c52fe2af40bf75072a606357b33cc336c7f", size = 621541, upload-time = "2026-07-22T11:51:09.464Z" }, + { url = "https://files.pythonhosted.org/packages/57/6b/7c55ca72ef80d57c16c4a55210f82582622462dc4485799a30f4ec6f3372/greenlet-3.5.4-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:13b980043cb1b3134e81ea469da1250ddcc6bfe6d245bbaa59168d9cdc8f228f", size = 432554, upload-time = "2026-07-22T12:39:51.379Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/25e9a2d9eb6b2e8b7ca4e80a3a26cb887cce6c8e0a87c921164f11bc5574/greenlet-3.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7a5f095767c4493afcd06067f2bb3b8716e3f3f9e92b99c88e7e99f885b3d4d", size = 1581444, upload-time = "2026-07-22T12:25:03.818Z" }, + { url = "https://files.pythonhosted.org/packages/b9/96/4c9bf2e2c408dcc0556edce69efa9f802e82223573c53240136a086821f1/greenlet-3.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:42afdc1ab5f66da8c586c32af9224a74a706b4f0ea0dc3a4188a0860a09c65c9", size = 1645842, upload-time = "2026-07-22T11:51:12.295Z" }, + { url = "https://files.pythonhosted.org/packages/b5/41/303ecb26a3a56122c0f4d4073ee078881847bd6b6f463ae0ec57ec20223b/greenlet-3.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:60149df8f462d1b230038e6590c23c3b4768bb5d6c022b3b6e82532b34b0b8a3", size = 247169, upload-time = "2026-07-22T11:38:19.893Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e3/ef56864b4c35fcb3eb3b41b869f6cc46f4cd3f5e2c68e74acde8ac433951/greenlet-3.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:77d6ce04fed0d9aeed42e0f37923cc43eba9b027bdd9c34546bb4ccd143d0fe0", size = 245565, upload-time = "2026-07-22T11:38:27.061Z" }, + { url = "https://files.pythonhosted.org/packages/c0/9a/e51225dcd58713f16ccbdcc501a8da21098ea14515b7870f1f94459e5ff5/greenlet-3.5.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02", size = 294831, upload-time = "2026-07-22T11:38:53.389Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ea/de50a50fadf979713ab18b46f22ad5ff5f2dcfc637a3ebdecf669801e1a5/greenlet-3.5.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356", size = 614619, upload-time = "2026-07-22T12:26:42.282Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/2aae27fea41205b8650294c301f042a2a4bb6155eea48c995b890a92f2c1/greenlet-3.5.4-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef", size = 627021, upload-time = "2026-07-22T12:29:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/1b/80/fb4d4788bbc8e54761f1fc88533af9523a6e86299fa113d6e8a8503ed9fc/greenlet-3.5.4-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07bd44616608d873d06735b63ef1a88191d6ca57c8d291d6559c71bc14c0893c", size = 632845, upload-time = "2026-07-22T12:43:45.19Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/79fd826f9ccaae0b84e1b4ef68dabba5e105bb044ffcd448a0b782fcba9a/greenlet-3.5.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0", size = 624002, upload-time = "2026-07-22T11:51:11.391Z" }, + { url = "https://files.pythonhosted.org/packages/42/e3/6086fa578ebb72772722cdc4bcd628459814b42e0c2db1e3cbd6552b3271/greenlet-3.5.4-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:3529a8a933582ad19e224792cac7372489526576b75b4c124e8e4f29948f4861", size = 435053, upload-time = "2026-07-22T12:39:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/27319f97e731298513dcba1a2e91b63e9d8811d9de22130f960b129b1bf1/greenlet-3.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd", size = 1581533, upload-time = "2026-07-22T12:25:05.322Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6d/24240bf562e9786dd2799ee0a4a4dadb4ded22510f41b20245099159ac8c/greenlet-3.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f", size = 1645781, upload-time = "2026-07-22T11:51:14.805Z" }, + { url = "https://files.pythonhosted.org/packages/c1/5a/442ab1a9ef7ca6bf7210e5397a95972206a91a31033a03c8900866a10039/greenlet-3.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c", size = 247133, upload-time = "2026-07-22T11:39:20.661Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/9160210222386b1a378ff94db846b9508ca24a121cf684991561fdb69280/greenlet-3.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:7c1303791d603080cac6fc3b34df51c3b75b723739c282c8029e48a0d241672f", size = 245500, upload-time = "2026-07-22T11:40:22.185Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/6ab1d4f9cd548d15ab90da29947f2076100130bb179b0bde59f795a459e3/greenlet-3.5.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22", size = 295410, upload-time = "2026-07-22T11:40:35.747Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7a/422f63b4715cbc0b24385305407adf38b48f6bb68b3e6b04090e994d0f5a/greenlet-3.5.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf", size = 661286, upload-time = "2026-07-22T12:26:43.8Z" }, + { url = "https://files.pythonhosted.org/packages/d0/31/5a1cac663bf5582190c5a714ef81364f03cde232227f39748f8ae4c11da5/greenlet-3.5.4-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9", size = 673517, upload-time = "2026-07-22T12:29:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/9c/bf/250c2921c7b585dde12f5239e313ca2dcbc464d161ecca36e4e6ef21762d/greenlet-3.5.4-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cef589bc65fae02d10bca2ac341191c5b33acc2967892ebf4fcbd10eabb7a74c", size = 677968, upload-time = "2026-07-22T12:43:46.788Z" }, + { url = "https://files.pythonhosted.org/packages/15/4a/2a82a1e3f8aaca020853ac8d12211280ca2b231aa08ea39f636f1060c319/greenlet-3.5.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8", size = 670917, upload-time = "2026-07-22T11:51:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/18/40/10bfcf6513558d82f7b95dd728001c63bd388259fe27d3e30ae01f103430/greenlet-3.5.4-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:dfc41ae893d9ceaf22c824f2153a88b30651b20e8758c2cd9ac143f23640563c", size = 480643, upload-time = "2026-07-22T12:39:54.149Z" }, + { url = "https://files.pythonhosted.org/packages/68/b0/e379a152b17bfdfa95795af4049e37c0fd1b4d81f020d426db104ed07c77/greenlet-3.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3", size = 1628478, upload-time = "2026-07-22T12:25:06.678Z" }, + { url = "https://files.pythonhosted.org/packages/5e/43/bffdfa64f7317f954c5c1230b5dd5922676ce198689a68c1ac1ed4b1b1a5/greenlet-3.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec", size = 1692021, upload-time = "2026-07-22T11:51:17.008Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/f799f9637e2c6e9b0b716015e339040598b058cf7654dfc0d67468b177ed/greenlet-3.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c", size = 248031, upload-time = "2026-07-22T11:40:11.007Z" }, + { url = "https://files.pythonhosted.org/packages/05/75/625bcdd74d5e6b2dca1ecba3c3ac77bcf8a026c21a649a46cef23e421f97/greenlet-3.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:f260930bbbbcf9caee661211235a5111c86dfe5832fdf6ae4570da1e0995320f", size = 246892, upload-time = "2026-07-22T11:40:27.357Z" }, + { url = "https://files.pythonhosted.org/packages/ec/69/35c62ed49c320cb4d98e14698ccca5467d3bfe683984172be9cb564d9ce3/greenlet-3.5.4-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3", size = 305571, upload-time = "2026-07-22T11:40:31.659Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/64d60216b3640dcb0b62d913dd9e0d80030c09115bb2e4ba70c95d10ca45/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867", size = 672568, upload-time = "2026-07-22T12:26:45.298Z" }, + { url = "https://files.pythonhosted.org/packages/5c/de/ba3ab0a96292e53039530333b0d2ae18d9e508f3a325cd7bf15f8172944c/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c", size = 680076, upload-time = "2026-07-22T12:29:06.125Z" }, + { url = "https://files.pythonhosted.org/packages/ae/db/24a10af12bf8e639cec46c38b9ce1a282543ba42ff4fb0b31a970f1ab603/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39169a11d87a6a263afda3e9a27d1df16d0f919d40a4837cc73986c9884c0dd8", size = 681690, upload-time = "2026-07-22T12:43:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/aeada79083c6f1c15f45d77a332f9c441af263ee298e3eb17522cd337d22/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66", size = 676733, upload-time = "2026-07-22T11:51:16.027Z" }, + { url = "https://files.pythonhosted.org/packages/f4/60/44a2eca7b9fd71ae0fae7ff184da1cd3169d176652b97aa1cffcbb0ef961/greenlet-3.5.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:bd3d1145f603b2db19feb9078c2e6855eb7c67e15580c010ed815cee519b86fd", size = 510263, upload-time = "2026-07-22T12:39:55.678Z" }, + { url = "https://files.pythonhosted.org/packages/e9/10/2392fc3a98948652ef5fd1e7275c04f861dd13f74b78a2b4309f4ee4d090/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7", size = 1637327, upload-time = "2026-07-22T12:25:07.879Z" }, + { url = "https://files.pythonhosted.org/packages/55/c6/e7237a3dfa1f205ed0d9ea1e46d70bd2811b32d516266399fb59d28ab90a/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e", size = 1697493, upload-time = "2026-07-22T11:51:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/55/e3/4ba8154ba2a3d43729e499f72471b4b5c993f3826d3e24da81d5f06d6572/greenlet-3.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132", size = 251637, upload-time = "2026-07-22T11:40:37.44Z" }, +] + +[[package]] +name = "griffelib" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/e4/8d187ea29c2e30b3a09505c567513077d6117861bde1fbd997a167f262ec/griffelib-2.1.0.tar.gz", hash = "sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813", size = 216234, upload-time = "2026-06-19T12:05:42.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" }, +] + +[[package]] +name = "grpcio" +version = "1.83.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/98/304898ac4e04e2d5e4e4c2eadc178b1f2a16d5f4bc2f91306c87d64680b9/grpcio-1.83.0.tar.gz", hash = "sha256:7674587248fbbb2ac6e4eecf83a8a0f3d91a928f941de571acfd3a2f007fbc24", size = 13428824, upload-time = "2026-07-23T15:20:37.759Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/f6/3b781cd07a715ea5f5125ae264226e7fc4d87603d6d3955022cabfdc5da2/grpcio-1.83.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:8ff0b8767ddd62704e0d9571c1890af08d84a3a689ebba1807e62519d0b3277f", size = 6338720, upload-time = "2026-07-23T15:19:13.177Z" }, + { url = "https://files.pythonhosted.org/packages/21/cc/d14833d15d5984e366f1b027fa78bd038c9b028c66880bffb0f5a4d25ee2/grpcio-1.83.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:4772402f43517b4824980be4b3b2274a81eec0004a70009473c31b340d43e223", size = 12178773, upload-time = "2026-07-23T15:19:15.401Z" }, + { url = "https://files.pythonhosted.org/packages/6b/98/8acbb416544e7871132d8e42a07ed70c802d70e6a16c6009e505a34d32a4/grpcio-1.83.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f4cee5fc86e84a0cf7ad1574b454c3320e087c07f55b7df5dc0ac6a873fb90c0", size = 6921203, upload-time = "2026-07-23T15:19:17.824Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/0fdbfaf4fc54e5c88f6bce4008a065092fe7fbc4460eb5617ae8b20fd505/grpcio-1.83.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f5e822a7e7d03282f6ad225e710493c48b9057a353358344a5f7c42b2b37618d", size = 7648508, upload-time = "2026-07-23T15:19:19.685Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ea/107b9dbb2ed3ad14dd774fd3dde7d29ff9938a6c198654becb2c3a0e9a6a/grpcio-1.83.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5f410d7c2903eabb34789dfd6342eef04af1ad459943936b7e09a9f5bd417b9", size = 7079466, upload-time = "2026-07-23T15:19:21.478Z" }, + { url = "https://files.pythonhosted.org/packages/3b/06/9fa9941089e6fae83b060b6ce61c1e81053e52decae43197245f45e07d36/grpcio-1.83.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee94a4016fdf8699fb1fd8a38652475ff677f1c72074cee44deeeb9a7e95e745", size = 7605583, upload-time = "2026-07-23T15:19:23.74Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/f10fb56062dc2771c630827a82d9ad0ecd05cad572ea3b08d49f6631680a/grpcio-1.83.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c6444666317338e903093c7c756e6cc88eee59f798cb8dd41e87725bf54e1617", size = 8637810, upload-time = "2026-07-23T15:19:25.536Z" }, + { url = "https://files.pythonhosted.org/packages/99/55/f84927258f6a1b6ea6dea661fdc6de859b35e560c96f3012d15ccd39f85e/grpcio-1.83.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aa074041231f03959cb097dd5517b0677b8ea49215bae01d5710a7b69dd59969", size = 8008021, upload-time = "2026-07-23T15:19:27.863Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6b/cdf72161397ccd29d4ca2192f641524536c9cf54ad948c9dd0e0e01138fa/grpcio-1.83.0-cp311-cp311-win32.whl", hash = "sha256:cb056f6e171c42639a50460b2929c82241fda51f71cf3dcdd68090fe45095a45", size = 4404376, upload-time = "2026-07-23T15:19:30.137Z" }, + { url = "https://files.pythonhosted.org/packages/df/ed/e0ffeb4c848699c194dc9fb6a29ab29bcb2b6aac8c416bf18c51bfe8242c/grpcio-1.83.0-cp311-cp311-win_amd64.whl", hash = "sha256:7416952ca770477990257206276999056f8316d79196f2f25942393e58a20b49", size = 5164469, upload-time = "2026-07-23T15:19:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/15/2b/51e32514a4e9b715375c99721aadff0f24164cc2049b8269eda4de82a814/grpcio-1.83.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:28f6c35ac8fcf10e4594f138e468f194360089dde40d126a7033e863fc479930", size = 6303167, upload-time = "2026-07-23T15:19:33.78Z" }, + { url = "https://files.pythonhosted.org/packages/39/33/b5b50fc2c6fbe350e04814047bb2d409feec7b36ef8b170254c050e06bc0/grpcio-1.83.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:33898e6a28e4ae598f1577cb1c4fec2a15c033d0ec52b9b45a09610dd045b9da", size = 12160538, upload-time = "2026-07-23T15:19:35.958Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5f/734e72e7b9f79bcf0b2c270b8d3bca0e4ebb97a27a50d06240b145f6d41e/grpcio-1.83.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6fb8a1dd0c6f0f931e69e9d0dc6d1c406ed2a44fa963414eafba07b7fb685d16", size = 6869310, upload-time = "2026-07-23T15:19:38.607Z" }, + { url = "https://files.pythonhosted.org/packages/a4/17/a1735f215b2a5cd43c38b79eac072ad197e61be9829905b6b29550abd0db/grpcio-1.83.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2b5e75c34842cd9c1b95285ca395c6a569664b81e3ffa6b714125922942abaaf", size = 7613472, upload-time = "2026-07-23T15:19:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/b2/78/c9e81f806ac704b6b145cb01628db398985b1f8dfdc10e23b55fb0902b3d/grpcio-1.83.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeb339838db07600481ef869507279b75326c75eac6d10f7afa62a0da1d2bcdd", size = 7040616, upload-time = "2026-07-23T15:19:42.349Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ba/94cd5af859876049d340480acbb61a959096c84b567f215534faa78d0424/grpcio-1.83.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f47d62808b4c0a97b78bff88a6d4ca283a2a492b9a04a87d814af95ca3b9c19c", size = 7570491, upload-time = "2026-07-23T15:19:44.357Z" }, + { url = "https://files.pythonhosted.org/packages/3e/15/108d30d5a5c964312ae8b9cb0e8cc5b3c1cc68d8f757cca52b3565534d26/grpcio-1.83.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62003babc444a606dcd1f009cd16391ce23669ae4ad6ec267a873da7937a69f5", size = 8605036, upload-time = "2026-07-23T15:19:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/ea/23/3828ae13c3db8233d123ad612747665817b952d8a954f32390230b582336/grpcio-1.83.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1aa567f8c3f19850ffd5d2858c9a8ea7c80f0db6c01186b71eb31e923ec984f5", size = 7981587, upload-time = "2026-07-23T15:19:48.913Z" }, + { url = "https://files.pythonhosted.org/packages/17/5b/77af31228f55f55a2a5112bb0077ad0a1c4d23dbb0c2853a62475bbdcc14/grpcio-1.83.0-cp312-cp312-win32.whl", hash = "sha256:cb2906c61db4f9c64cc360054b5df70eeb81846228e9e56a4944bd415a63dadc", size = 4394004, upload-time = "2026-07-23T15:19:50.618Z" }, + { url = "https://files.pythonhosted.org/packages/c0/da/f706e39550e7a3732ce2b9c5926107a93d74a802775b19b642a6df27dc96/grpcio-1.83.0-cp312-cp312-win_amd64.whl", hash = "sha256:1c699bbb20f143c8f2bff219de578aa2dc1f919399d67dc702b038b986ee62df", size = 5158525, upload-time = "2026-07-23T15:19:52.246Z" }, + { url = "https://files.pythonhosted.org/packages/56/eb/135daaa713f32d33b8f99b4153b3f8dc3b2a124996ac15581bf9ebdad3c3/grpcio-1.83.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:6662f3b1e07cc7493d437351860dc867bddc6a93c83ecf33bbfdaf0c217ab2d0", size = 6304480, upload-time = "2026-07-23T15:19:53.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a1/121806ce69f23138dabe06aa595b0e5f1ae051a37e4c1954eed7d692c800/grpcio-1.83.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:74fe6f9e8a35c7dbf32255ee154d15e3e5338a81ed39173d079d594d2e544cd1", size = 12154419, upload-time = "2026-07-23T15:19:56.3Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e8/d0389e09cd6b4c4d3089b92967ae4e3ffd64795bd349bf2f85cd6656d3da/grpcio-1.83.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10b3fa0475eb572c9a81a6fe37fa16a9c500c0c91cfc148cac15692b7e3c2867", size = 6873200, upload-time = "2026-07-23T15:19:58.701Z" }, + { url = "https://files.pythonhosted.org/packages/f8/51/f464c1d211fa50d5adbabe1b2e519948d99c13757052bfc9ea7afa28e284/grpcio-1.83.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5f20a988480b0f28207f057f7f7ae1313393c3cef0adcfeae8248f9947eaf881", size = 7618811, upload-time = "2026-07-23T15:20:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c0/539fe0832f2dd6500a28f5263071623fb34e8d4867aec632ccf81bd21156/grpcio-1.83.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bd82671b39065ba18cd536e9cd45b27ff649053f81ddd2c6a966d595067080f", size = 7042310, upload-time = "2026-07-23T15:20:02.675Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ca/ccf617d37ffa72567fa8e005ec7090c99da922799be2fb9847c8b21ca18c/grpcio-1.83.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc60215b5cb9fc8ca72942c498b551ac2305bd08f6ef8d4e3f0d21b64fbecd61", size = 7575412, upload-time = "2026-07-23T15:20:04.712Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b9/fd8d5245f823a8e0fd35d90e20ea3aa4acd47f8d5318fa8df307df52dec6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f1c3e5689d4b90987b1d72022bcfe866a9a3dc66197484cf856d96b6150e7f45", size = 8604248, upload-time = "2026-07-23T15:20:06.77Z" }, + { url = "https://files.pythonhosted.org/packages/14/1e/f37632fc11db72dfa4bba86c3a43e54358e53030df111ecae5e91a733ad6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a21cb4eeeba124443f399be2e8b624943cde864dcbe588cb42e5c483a52a906c", size = 7977458, upload-time = "2026-07-23T15:20:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/93/b6/d70b69ae5c0cfc341b9ba474980e4ed99cbf05c0e4a14e9eee8cb73db0a5/grpcio-1.83.0-cp313-cp313-win32.whl", hash = "sha256:8fe04f1050a59f875601eb55d42b4f66946fe89817f967e34db1462ccd07dadf", size = 4393993, upload-time = "2026-07-23T15:20:11.017Z" }, + { url = "https://files.pythonhosted.org/packages/0f/13/45d4cccb555cf4c476226979bf3d2fd0b0254216f7564c3a053e35117efc/grpcio-1.83.0-cp313-cp313-win_amd64.whl", hash = "sha256:6e01ecd9d8ef280abe1365138a4dc318f9a5287f4cb1b41d07816f796653f735", size = 5159650, upload-time = "2026-07-23T15:20:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/9c/60/f2cca8147ea213d3e43ae9158d03ad04e020fdf32ff027253e1fe93f921d/grpcio-1.83.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3f351629f6ae16ecc0ec3553e586a6763ffd9f6114044286d0cbec3e09241bfa", size = 6305607, upload-time = "2026-07-23T15:20:15.353Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ab/d3874931d123a95e83a3ebf8aa04537988fb62425cedb8bf3cefc5ad41b2/grpcio-1.83.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d05ff664100d429335b93c91b8b34ddf9e94a112205e7fa06dede309e44a4e4c", size = 12166617, upload-time = "2026-07-23T15:20:17.435Z" }, + { url = "https://files.pythonhosted.org/packages/92/ff/6f18f9426b69306f4e00a9add3b0ee2748da8aad53836ef80cab0d62d04f/grpcio-1.83.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7936f2a56cf04f6514705c0fedf400971de01b6aa1719327e4718f410a765e2b", size = 6880213, upload-time = "2026-07-23T15:20:19.98Z" }, + { url = "https://files.pythonhosted.org/packages/70/21/706d1147c6b93b98f179240c13991fbcc56880eba0c868abb1ad40d8a0a6/grpcio-1.83.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b0a0be840e51b6b7ee9df9269770faf77bdf4b771053c257c21d12bad607714c", size = 7618335, upload-time = "2026-07-23T15:20:22.161Z" }, + { url = "https://files.pythonhosted.org/packages/74/04/1a8443c889115ec9e213a213e86bc93a71ee9088027e5befa09aaa0edd9d/grpcio-1.83.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:009667eaf3dcd5224c713589cdc98e7ca4ed0ff0b61132c6b276e930eb83a2df", size = 7043416, upload-time = "2026-07-23T15:20:24.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/c6/94e0fee5b12bc1da1370185b680988db6f739d19b42d9959db01a7ea50bf/grpcio-1.83.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb669918fd88936b15599caff4160a77ab74bdeb25f2231f6e45b61282d6107b", size = 7583253, upload-time = "2026-07-23T15:20:26.313Z" }, + { url = "https://files.pythonhosted.org/packages/a0/97/de1ccb671fb85575bc5192faedf9ecdbdf5b390d2e6584dcf552bcbd370e/grpcio-1.83.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c19b454d3d3f28db81f2c7c4dbaee96e7f6fd149721733ffe79d6bc530f17404", size = 8605102, upload-time = "2026-07-23T15:20:28.437Z" }, + { url = "https://files.pythonhosted.org/packages/17/0f/0e0ec749a7034ffcbaa050e39779872950ead90c22e7e0116be3f28b2b46/grpcio-1.83.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:61007cd08640abc5c54547ee32505474c482cd733a53cb87551ea81faa6350af", size = 7979826, upload-time = "2026-07-23T15:20:31.182Z" }, + { url = "https://files.pythonhosted.org/packages/83/fa/c3fda157287f64bc65acee6c5aa90c41acf9e0d3a8e69a265eecff6d00a1/grpcio-1.83.0-cp314-cp314-win32.whl", hash = "sha256:32e11c37f5285b0c6fa3042c05fe06903696689749833fc64e67dec71b9bbe33", size = 4471765, upload-time = "2026-07-23T15:20:33.195Z" }, + { url = "https://files.pythonhosted.org/packages/a1/00/b1b26431c9d54eee11724fd6e5585473a2ed47fbc1fb95e5204906a642ce/grpcio-1.83.0-cp314-cp314-win_amd64.whl", hash = "sha256:2bb48cb5e6dd005ca12b89ce4b6ac0b48ff3112c747542ee7986ef611a8ca6d9", size = 5298932, upload-time = "2026-07-23T15:20:35.48Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hf-xet" +version = "1.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/39/67be8d71f900d9a55761b6022821d6679fb56c64f1b6063d5af2c2606727/hf_xet-1.5.2.tar.gz", hash = "sha256:73044bd31bae33c984af832d19c752a0dffb67518fee9ddbd91d616e1101cf47", size = 903674, upload-time = "2026-07-16T17:29:56.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/be/525eabac5d1736b679c39e342ecd4292534012546a2d18f0043c8e3b6021/hf_xet-1.5.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:4a5ecb9cda8512ba2aa8ee5d37c87a1422992165892d653098c7b90247481c3b", size = 4064284, upload-time = "2026-07-16T17:29:29.907Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3f/699749dd78442480eda4e4fca494284b0e3542e4063cc37654d5fdc929e6/hf_xet-1.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8764488197c1d7b1378c8438c18d2eea902e150dbca0b0f0d2d32603fb9b5576", size = 3828537, upload-time = "2026-07-16T17:29:31.549Z" }, + { url = "https://files.pythonhosted.org/packages/22/d7/2658ac0a5b9f4664ca27ce31bd015044fe9dea50ed455fb5197aba819c11/hf_xet-1.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d7446f72abbf7e01ca5ff131786bc2e74a56393462c17a6bf1e303fbab81db4", size = 4417133, upload-time = "2026-07-16T17:29:33.391Z" }, + { url = "https://files.pythonhosted.org/packages/d9/58/8343f3cb63c8fa058d576136df3871550f7d5214a8f048a7ea2eab6ac906/hf_xet-1.5.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:580e59e29bf37aece1f2b68537de1e3fb04f43a23d910dcf6f128280b5bfbba4", size = 4212613, upload-time = "2026-07-16T17:29:34.989Z" }, + { url = "https://files.pythonhosted.org/packages/0c/33/a968f4e4535037b36941ec00714625fb60e026302407e7e26ca9f3e65f4e/hf_xet-1.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bee28c619622d36968056532fd49cf2b35ca75099b1d616c31a618a893491380", size = 4412710, upload-time = "2026-07-16T17:29:36.646Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/9e33981173dbaf194ba0015202b02d467b624d44d4eba89e1bf06c0d2995/hf_xet-1.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e396ab0faf6298199ad7a95305c3ca8498cb825978a6485be6d00587ee4ec577", size = 4628455, upload-time = "2026-07-16T17:29:38.352Z" }, + { url = "https://files.pythonhosted.org/packages/e9/4b/cc682832de4264a03880a2d1b5ec3e1fab3bf307f508817250baafdb9996/hf_xet-1.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fd3add255549e8ef58fa35b2e42dc016961c050600444e7d77d030ba6b57120e", size = 3979044, upload-time = "2026-07-16T17:29:40.329Z" }, + { url = "https://files.pythonhosted.org/packages/ea/09/b2cdf2a0fb39a08af3222b96092a36bd3b40c54123eef07de4422e870971/hf_xet-1.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d6f9c58549407b84b9a5383afd68db0acc42345326a3159990b36a5ca8a20e4e", size = 3808037, upload-time = "2026-07-16T17:29:42.357Z" }, + { url = "https://files.pythonhosted.org/packages/de/ba/2b70603c7552db82baeb2623e2336898304a17328845151be4fe1f48d420/hf_xet-1.5.2-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f922b8f5fb84f1dd3d7ab7a1316354a1bca9b1c73ecfc19c76e51a2a49d29799", size = 4033760, upload-time = "2026-07-16T17:29:43.884Z" }, + { url = "https://files.pythonhosted.org/packages/60/ac/b097a86a1e4a6098f3a79382643ab09d5733d87ccc864877ad1e12b49b70/hf_xet-1.5.2-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:045f84440c55cdeb659cf1a1dd48c77bcd0d2e93632e2fea8f2c3bdee79f38ed", size = 3841438, upload-time = "2026-07-16T17:29:45.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/35/db860aa3a0780660324a506ad4b3d322ddc6ecbba4b9340aed0942cbf21c/hf_xet-1.5.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db78c39c83d6279daddc98e2238f373ab8980685556d42472b4ec51abcf03e8c", size = 4428006, upload-time = "2026-07-16T17:29:46.996Z" }, + { url = "https://files.pythonhosted.org/packages/af/6b/832dd980af4b0c3ae0660e309285f2ffcdff2faa38129390dbb47aa4a3f9/hf_xet-1.5.2-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7db73c810500c54c6760be8c39d4b2e476974de85424c50063efc22fdda13025", size = 4221099, upload-time = "2026-07-16T17:29:48.525Z" }, + { url = "https://files.pythonhosted.org/packages/9e/05/ae50f0d34e3254e6c3e208beb2519f6b8673016fc4b3643badaf6450d186/hf_xet-1.5.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6395cfe3c9cbead4f16b31808b0e67eac428b66c656f856e99636adaddea878f", size = 4420766, upload-time = "2026-07-16T17:29:50.092Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/c050bc2743a2bcd68928bfee157b08681667a164a24ec95fbfcfcd717e08/hf_xet-1.5.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cde8cd167126bb6109b2ceb19b844433a4988643e8f3e01dd9dd0e4a34535097", size = 4636716, upload-time = "2026-07-16T17:29:51.62Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f8/68b01c5c2edb56ac9a67b3d076ffddcb90867abaee923923eb34e7a14e76/hf_xet-1.5.2-cp38-abi3-win_amd64.whl", hash = "sha256:ecf63d1cb69a9a7319910f8f83fcf9b46e7a32dfcf4b8f8eeddb55f647306e65", size = 3988373, upload-time = "2026-07-16T17:29:53.395Z" }, + { url = "https://files.pythonhosted.org/packages/39/c6/988383e9dc17294d536fcbcd6fd16eed882e411ad16c954984a53e47b09c/hf_xet-1.5.2-cp38-abi3-win_arm64.whl", hash = "sha256:1da28519496eb7c8094c11e4d25509b4a468457a0302d58136099db2fd9a671d", size = 3816957, upload-time = "2026-07-16T17:29:54.991Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089, upload-time = "2026-07-24T09:21:03.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809, upload-time = "2026-07-24T09:21:01.178Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" }, + { url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" }, + { url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx2" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458, upload-time = "2026-07-24T09:21:04.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191, upload-time = "2026-07-24T09:21:02.6Z" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/50/db3771a6e4fad4bd28fb055d4363b51cb0ae98c1aa504b79d41fdcab5483/huggingface_hub-1.25.1.tar.gz", hash = "sha256:21129595ca7a753be479b319913e22cc8808361ac118bd76cc413db831b28a99", size = 928426, upload-time = "2026-07-27T09:24:10.117Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/3f/21e816831c6d16f88a6c784974413fa0421ce8a5d04380c2666ed5b503e5/huggingface_hub-1.25.1-py3-none-any.whl", hash = "sha256:004d4e70350517e24c68a7dbb7dc5e40b2b6aefef8f94bf7a85f6f9835102ea5", size = 774909, upload-time = "2026-07-27T09:24:08.079Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "importlib-resources" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/06/b56dfa750b44e86157093bc8fca0ab81dccbf5260510de4eaf1cb69b5b99/importlib_resources-7.1.0.tar.gz", hash = "sha256:0722d4c6212489c530f2a145a34c0a7a3b4721bc96a15fada5930e2a0b760708", size = 44985, upload-time = "2026-04-12T16:36:09.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/db/55a262f3606bebcae07cc14095338471ad7c0bbcaa37707e6f0ee49725b7/importlib_resources-7.1.0-py3-none-any.whl", hash = "sha256:1bd7b48b4088eddb2cd16382150bb515af0bd2c70128194392725f82ad2c96a1", size = 37232, upload-time = "2026-04-12T16:36:08.219Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jiter" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c", size = 176431, upload-time = "2026-06-29T13:05:13.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/3f/fae6cc967d120ec89e31c5418a51176d8278b3087fbb384a9176754f353c/jiter-0.16.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:67fddeda1688f0cce2d2ae83ccf8a80f79936f2d2997d6cc2261f82fdb54a4d3", size = 309289, upload-time = "2026-06-29T13:02:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/97c6c3562c077f6247d6e6ce5c82562500b6316c0d928e97e106b7a1321a/jiter-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c90c0f63df322be920eda6ce622e3083d8906ba267f8220fe7873213b8b4430e", size = 315181, upload-time = "2026-06-29T13:02:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/7b/89/d8d073f8aa2667e46c6c0873f86fe4a512bba4293cc730f626a076211a62/jiter-0.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64c0203212098470032aabcde9356fc168f377aade3e43def61dfe17e92f2037", size = 340939, upload-time = "2026-06-29T13:02:55.412Z" }, + { url = "https://files.pythonhosted.org/packages/87/c9/db4fda3ed73fb864139305e935e5b8b38a5a24692a5a9dd356c22f1b9c8d/jiter-0.16.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12288303c9844e61e1651d02a9a6f6633e47d39f897d6991d1427161ce6b746e", size = 364932, upload-time = "2026-06-29T13:02:57.28Z" }, + { url = "https://files.pythonhosted.org/packages/a2/74/52b5e86241057f52ddd7c9a580f90effb51f9d06239f6fc612279b91a838/jiter-0.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cf109d010b4b05a105afb3d43be36a21322d345ad3111e13d15f680afef0e5b", size = 461132, upload-time = "2026-06-29T13:02:58.994Z" }, + { url = "https://files.pythonhosted.org/packages/a9/87/544a700f7447c1f31c5d7833821a4daa5683165c2d5a094fbf5b5800c3dc/jiter-0.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62c1b7fe1f77925acf5af68b6140b8810fa87dfd4dc0a9c8568ec2fa2a10429c", size = 374857, upload-time = "2026-06-29T13:03:00.455Z" }, + { url = "https://files.pythonhosted.org/packages/40/cd/0fcc3f7d39183674d5bfa9ec640faaeb506c60be7c8f94625dfba366e37c/jiter-0.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8597d23c87f59294f83bcb6229b9ed1fccee13dbba967b46930d2f1759466fee", size = 347053, upload-time = "2026-06-29T13:03:02.045Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ae/c7e64e7932ad597fa395b61440b249ada6366716e25c6e08dd2afbd021e6/jiter-0.16.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3126a5dbad56401989ac769aca0cb56005bfb3e2366eea0ca99d1a91c3c1ee03", size = 356153, upload-time = "2026-06-29T13:03:03.706Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/1c719044f14da814e1a060191ab19b96f3e99207bc5b4bfc6d6be34b3f80/jiter-0.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c4b4717bdb35ae456f831a6b08d01880fff399887a6bbc526a583a406e484eea", size = 393956, upload-time = "2026-06-29T13:03:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/3b/dc/7b2f303a2847207e265503853a2d964a55354cffd62a5f2936c155486798/jiter-0.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:adff21bc78edfe086c15eb495b900306076de378dc2337c132401fc39bd79c91", size = 521081, upload-time = "2026-06-29T13:03:06.886Z" }, + { url = "https://files.pythonhosted.org/packages/c2/5f/501cf6e1e09caeb420195179ffc6f62aca603f1220ec53fd80d0d70b3e56/jiter-0.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dab907db06fc593645e73109acf4581ba5b548897d28b9348dc41ddc8343b2d3", size = 552085, upload-time = "2026-06-29T13:03:08.339Z" }, + { url = "https://files.pythonhosted.org/packages/79/54/aa5be86520113b79455c3877f3d1f07a348098df4083ba3688e9537e52dd/jiter-0.16.0-cp311-cp311-win32.whl", hash = "sha256:560b2cf3fb03240cd34f27409a238547488708f05b7c3924f571a60422251ec7", size = 206755, upload-time = "2026-06-29T13:03:09.653Z" }, + { url = "https://files.pythonhosted.org/packages/64/ec/2feb893eb330bd69b413866f4d5daada33c3962f1c6f270c91ca2d87fdf9/jiter-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:e431cfc9caf44c1d5459ff77d4e64cbf85fddb6a35dad836a15c6a9ec23087c1", size = 199155, upload-time = "2026-06-29T13:03:10.979Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9c/ca040d94415048a3666fc237774df8151c96f8d2b661cbe3b184acc95876/jiter-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:2a8e9e39cf083016137aa5cadafe3188adc2ba6ba1fbf1e5d18889ad3e9ad056", size = 194403, upload-time = "2026-06-29T13:03:12.341Z" }, + { url = "https://files.pythonhosted.org/packages/83/2b/52ace16ed031354f0539749a49e4bf33797d82bea5137910835fa4b09793/jiter-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a", size = 306943, upload-time = "2026-06-29T13:03:14.035Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/34957c2c1b661c252ba9bcc60ae0bddc27e0f7202c6073326a13c5390eec/jiter-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9", size = 307779, upload-time = "2026-06-29T13:03:15.418Z" }, + { url = "https://files.pythonhosted.org/packages/88/6c/59bd309cab4460c54cf1079f3eb7fe7af6a4c895c5c957a53378693bad2b/jiter-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72", size = 335826, upload-time = "2026-06-29T13:03:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8c/f5ef7b65f0df47afa16596969defb281ebb86e96df346d62be6fd853d620/jiter-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e", size = 362573, upload-time = "2026-06-29T13:03:18.781Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0b/ace4354da061ee38844a0c27dc2c21eecd27aea119e8da324bea987522d0/jiter-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290", size = 457979, upload-time = "2026-06-29T13:03:20.293Z" }, + { url = "https://files.pythonhosted.org/packages/55/40/c0253d3772eb9dcd8e6606ee9b2d53ec8e5b814589c47f140aa585f21eaa/jiter-0.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01", size = 372302, upload-time = "2026-06-29T13:03:21.739Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d2/4839422241aa12860ce597b20068727094ba0bc480723c74924ca5bad483/jiter-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48", size = 343805, upload-time = "2026-06-29T13:03:23.384Z" }, + { url = "https://files.pythonhosted.org/packages/e2/59/e196888a05befdda7dbe299b722d56f2f6eec65402bc34c0a3306d595feb/jiter-0.16.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9", size = 351107, upload-time = "2026-06-29T13:03:24.815Z" }, + { url = "https://files.pythonhosted.org/packages/ec/74/4cd9e0fca65232136400354b630fbfcd2de634e22ccbb96567725981b548/jiter-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5", size = 388441, upload-time = "2026-06-29T13:03:26.266Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8c/554691e48bc711299c0a293dd8a6179e24b2d66a54dc295421fcf64569c0/jiter-0.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730", size = 516354, upload-time = "2026-06-29T13:03:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/a4/cb/01e9d69dc2cc6759d4f91e230b34489c4fdb2518992650633f9e20bece89/jiter-0.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f", size = 547880, upload-time = "2026-06-29T13:03:29.534Z" }, + { url = "https://files.pythonhosted.org/packages/79/70/2953195f1c6ad00f49fa67e13df7e60acb3dd4f387101bc15abccddd905e/jiter-0.16.0-cp312-cp312-win32.whl", hash = "sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274", size = 203473, upload-time = "2026-06-29T13:03:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/05/2909a8b10699a4d560f8c502b6b2c5f3991b682b1922c1eedda242b225bd/jiter-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7", size = 196905, upload-time = "2026-06-29T13:03:32.472Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a9/6b82bb1c8d7790d602489b967b982a909e5d092875a6c2ade96444c8dfc5/jiter-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331", size = 190618, upload-time = "2026-06-29T13:03:34.672Z" }, + { url = "https://files.pythonhosted.org/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195", size = 306203, upload-time = "2026-06-29T13:03:36.243Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09", size = 306489, upload-time = "2026-06-29T13:03:37.846Z" }, + { url = "https://files.pythonhosted.org/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536", size = 335453, upload-time = "2026-06-29T13:03:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450", size = 361625, upload-time = "2026-06-29T13:03:40.597Z" }, + { url = "https://files.pythonhosted.org/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e", size = 456958, upload-time = "2026-06-29T13:03:42.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8", size = 372017, upload-time = "2026-06-29T13:03:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026", size = 343320, upload-time = "2026-06-29T13:03:45.226Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053", size = 350520, upload-time = "2026-06-29T13:03:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e", size = 387550, upload-time = "2026-06-29T13:03:48.361Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb", size = 515424, upload-time = "2026-06-29T13:03:49.881Z" }, + { url = "https://files.pythonhosted.org/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84", size = 546981, upload-time = "2026-06-29T13:03:51.363Z" }, + { url = "https://files.pythonhosted.org/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e", size = 202853, upload-time = "2026-06-29T13:03:53.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd", size = 196160, upload-time = "2026-06-29T13:03:54.447Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a", size = 189862, upload-time = "2026-06-29T13:03:55.754Z" }, + { url = "https://files.pythonhosted.org/packages/a7/89/bc4f1b57d5da938fd344a466396541e586d161320d70bffd929aaafcd8f4/jiter-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee", size = 308239, upload-time = "2026-06-29T13:03:57.205Z" }, + { url = "https://files.pythonhosted.org/packages/65/7a/c415453e5213001bf3b411ff65dec3d303b0e76a4a2cfea9768cd4960994/jiter-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2", size = 308928, upload-time = "2026-06-29T13:03:58.643Z" }, + { url = "https://files.pythonhosted.org/packages/11/fc/1f4fb7ebf9a724c7741994f4aae18fba1e2f3133df14521a79194952c34a/jiter-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883", size = 336998, upload-time = "2026-06-29T13:04:00.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8d/72cadaac05ccfa7cc3a0a2232862e6c72443ca40cf300ba8b57f9f18b69b/jiter-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898", size = 362112, upload-time = "2026-06-29T13:04:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/58/4a/c4b0d5f651fda90a24ffce9f8d56cde462a2e09d31ae3de3c68cef34c04e/jiter-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5", size = 459807, upload-time = "2026-06-29T13:04:03.214Z" }, + { url = "https://files.pythonhosted.org/packages/80/58/ef77879ea9aa56b50824edc5a445e226422c7a8d211f3fd2a56bcb9493cf/jiter-0.16.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567", size = 373181, upload-time = "2026-06-29T13:04:04.629Z" }, + { url = "https://files.pythonhosted.org/packages/49/2e/ffbc3f254e4d8a66da3062c624a7df4b7c2b2cf9e1fe43cf394b3e104041/jiter-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69", size = 344927, upload-time = "2026-06-29T13:04:06.067Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f6/0be5dc6d64a89f80aa8fec984f94dedb2973e251edcae55841d60786d578/jiter-0.16.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29", size = 352754, upload-time = "2026-06-29T13:04:07.477Z" }, + { url = "https://files.pythonhosted.org/packages/da/6e/7d31243b3b91cd261dd19e9d3557fc3251a80883d3d8049c86174e7ab7af/jiter-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93", size = 390553, upload-time = "2026-06-29T13:04:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/25/33/51ae371fde3c88897520f62b4d5f8b27ad7103e2bb10812ff52195609853/jiter-0.16.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a", size = 516900, upload-time = "2026-06-29T13:04:10.407Z" }, + { url = "https://files.pythonhosted.org/packages/a0/45/6449b3d123ea439ba79507c657288f461d55049e7bcbdc2cf8eb8210f491/jiter-0.16.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00", size = 548754, upload-time = "2026-06-29T13:04:12.046Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e7/fd2fb11ae3e2649333da3aa170d04d7b3000bbdc3b270f6513382fdf4e04/jiter-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe", size = 122381, upload-time = "2026-06-29T13:04:13.413Z" }, + { url = "https://files.pythonhosted.org/packages/26/80/f0b147a62c315a164ed2168908286ca302310824c218d3aae52b06c0c9a9/jiter-0.16.0-cp314-cp314-win32.whl", hash = "sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106", size = 204578, upload-time = "2026-06-29T13:04:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/4758a14304b4523a6f5adb2419340086aa3593bd4327c2b25b5948a90548/jiter-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8", size = 198154, upload-time = "2026-06-29T13:04:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/26/be/41fa54a2e7ea41d6c99f1dc5b1f0fd4cb474680304b5d268dd518e81da3a/jiter-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585", size = 191458, upload-time = "2026-06-29T13:04:17.707Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/59127338b86d9fe4d99418f5a15118bea778103ee0fe9d9dd7e0af174e95/jiter-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207", size = 316739, upload-time = "2026-06-29T13:04:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/2d/95/49461034d5388196d3dabf98748935f017b7785d8f3f5349f834bcc4ed0d/jiter-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818", size = 340911, upload-time = "2026-06-29T13:04:21.257Z" }, + { url = "https://files.pythonhosted.org/packages/cd/97/a4369f2fb82cb3dda13b98622f31249b2e014b223fe64ee534413ad72294/jiter-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3", size = 361747, upload-time = "2026-06-29T13:04:22.677Z" }, + { url = "https://files.pythonhosted.org/packages/28/51/49b6ed456261646e1906016a6760367a28aacd3c24805e4e5fe64116c1db/jiter-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284", size = 460225, upload-time = "2026-06-29T13:04:24.441Z" }, + { url = "https://files.pythonhosted.org/packages/33/b5/5689aff4f66c5b60be63106e591dbfcba2190df97d2c9c7cf052361ddb98/jiter-0.16.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929", size = 373169, upload-time = "2026-06-29T13:04:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/a2/96/3ae1b85ee0d6d6cab254fb7f8da018272b932bbf2d69b07e98aa2a96c746/jiter-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620", size = 350332, upload-time = "2026-06-29T13:04:27.302Z" }, + { url = "https://files.pythonhosted.org/packages/15/32/c99d7bafd78986556c95bf60ce84c6cc98786eac56066c12d7f828bb6747/jiter-0.16.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af", size = 353377, upload-time = "2026-06-29T13:04:28.731Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/f99a8e571287c3dec766bcc18528bbe8e8fb5365522ab5e6d64c93e87066/jiter-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e", size = 387746, upload-time = "2026-06-29T13:04:30.319Z" }, + { url = "https://files.pythonhosted.org/packages/75/69/c78a5b3f71040e34eb5917df26fb7ae9a2174cad1ccbf277512507c53a6e/jiter-0.16.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077", size = 517292, upload-time = "2026-06-29T13:04:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f7/095b38eda4c70d03651c403f29a5590f16d12ddc5d544aac9f9cddf72277/jiter-0.16.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734", size = 549259, upload-time = "2026-06-29T13:04:33.721Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c5/6a0207d90e5f656d95af98ebd0934f382d37674416f215aeda2ff8063e51/jiter-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf", size = 206523, upload-time = "2026-06-29T13:04:35.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/31/c757d5f30a8980fd945ce7b98be10be9e4ff59c7c42f5fd86804c2e87db8/jiter-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db", size = 200366, upload-time = "2026-06-29T13:04:36.61Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce", size = 190516, upload-time = "2026-06-29T13:04:38.004Z" }, + { url = "https://files.pythonhosted.org/packages/06/d3/8e278946d43eeca2585b4dd0834a887cd71136329b837f3a16ed86a8b4b0/jiter-0.16.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:850ccb1d7eedb4200f4014b1c0e8a577de114fc3cd88faad646dcc9bc4bb12ad", size = 304518, upload-time = "2026-06-29T13:05:00.172Z" }, + { url = "https://files.pythonhosted.org/packages/72/43/28d4ef495028bf0506a413d4db3f4eb3e7288a382e0f065f306a17bbeb5e/jiter-0.16.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:e34e97bda77eb63242a410243c071e28ac7e0d8c0948c5ee658498690a4b2f2f", size = 310207, upload-time = "2026-06-29T13:05:02.123Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ca/c366b1012da1d640de975d9683acd44e4d150d9068845d0ca2610435253f/jiter-0.16.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7dc85ea77d4abbae8bad0d3538678aedee75bceec4e2f6c8dfb1c74772e5aa5", size = 342771, upload-time = "2026-06-29T13:05:03.55Z" }, + { url = "https://files.pythonhosted.org/packages/16/52/50cc4056fc1ae02e7154704e7ecc89df0afb8300222cfe8a52d3f67e4730/jiter-0.16.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17ca7fae79f6d99cd9a042b75f917eaada7b895cfc7dd2ee3a16089dcaec7a85", size = 346468, upload-time = "2026-06-29T13:05:05.452Z" }, + { url = "https://files.pythonhosted.org/packages/98/ab/664fd8c4be028b2bedd3d2ff08769c4ede23d0dbc87a77c62384a0515b5d/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127", size = 303106, upload-time = "2026-06-29T13:05:07.118Z" }, + { url = "https://files.pythonhosted.org/packages/1a/07/421f1d5b65493a76e16027b848aba6a7d28073ae75944fa4289cc914d39f/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3", size = 304658, upload-time = "2026-06-29T13:05:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/0a/db/bba1155f01a01c3c37a89425d571da751bbedf5c54247b831a04cb971798/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702", size = 339719, upload-time = "2026-06-29T13:05:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "kubernetes" +version = "36.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "certifi" }, + { name = "durationpy" }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "requests-oauthlib" }, + { name = "six" }, + { name = "urllib3" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/57/b07b96353f902aa1bdbe00e878e3a12a137977d03a962479785576aa8ec9/kubernetes-36.0.3.tar.gz", hash = "sha256:36993ed25ce59b789c9341473a228fcf268504a2fec7c2b2b1531d73072e5ce7", size = 2337528, upload-time = "2026-07-13T20:38:12.128Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/30/a96d47df739689ac0001ade0afefc16e3b477fc2fb426b568515fdc8afce/kubernetes-36.0.3-py2.py3-none-any.whl", hash = "sha256:8fde9241c4b298e6374a069dcf728359b4e72c2fb29489a975ba4e1c047cf10f", size = 4618066, upload-time = "2026-07-13T20:38:10.172Z" }, +] + +[[package]] +name = "logfire-api" +version = "4.39.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/f4/41f8647f6091fb9b9aac5a4b6d164bddb11d55b8369bf38de154ef91b8f4/logfire_api-4.39.0.tar.gz", hash = "sha256:1e885f95c37d58cdb927bbc6baea4f4a7c13066f6b3019758627d4dc442643d0", size = 90619, upload-time = "2026-07-24T18:31:36.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/d4/87747d12eaf2d852676fd6535df76df945ef62681f1eb5391b63d1fc05e2/logfire_api-4.39.0-py3-none-any.whl", hash = "sha256:20057bbd2898dec2eed02e2559bd73f4e10bc4b108987821df55e9c762da3ba8", size = 140413, upload-time = "2026-07-24T18:31:32.818Z" }, +] + +[[package]] +name = "mako" +version = "1.3.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mcp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, + { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/33/32d4dff2c95bb5d897c3ef4c83649a08996b17b58f0a326d2495d4c81179/mcp-2.0.0.tar.gz", hash = "sha256:0f440e735c13ece8bb19bc62cf0b86f4313448432fbb77d35e14034f4e050728", size = 1662284, upload-time = "2026-07-28T13:45:32.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/72/7d7897418912c1d12e87556630dfb7bf0eac71160e9bef8b447960804ee3/mcp-2.0.0-py3-none-any.whl", hash = "sha256:1cb4c75d2d2c7b8c1d756355e5d82a39f2822cc7f13e22a2051d7ca3592349d6", size = 349980, upload-time = "2026-07-28T13:45:28.853Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/56/9b8e1c152f61f6c6b07c4b5896c88c7d0ae90bac6ee6306f852fcc5c1eb0/mcp_types-2.0.0.tar.gz", hash = "sha256:d7d939b9285c9961ae8866ba75ef85da34d12bafe276efbf4eb6a131786d8379", size = 66632, upload-time = "2026-07-28T13:45:33.804Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/4c/c78d78c3d52b0ac594ad7cc8ef5972adfe070e3597a8a4c6ce0cd39196ea/mcp_types-2.0.0-py3-none-any.whl", hash = "sha256:6b2de797ca2797f568b79529e1b25948e34de511bcc0bd82fef1039a6d1b8eb0", size = 69649, upload-time = "2026-07-28T13:45:30.713Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mmh3" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/1a/edb23803a168f070ded7a3014c6d706f63b90c84ccc024f89d794a3b7a6d/mmh3-5.2.1.tar.gz", hash = "sha256:bbea5b775f0ac84945191fb83f845a6fd9a21a03ea7f2e187defac7e401616ad", size = 33775, upload-time = "2026-03-05T15:55:57.716Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/d7/3312a59df3c1cdd783f4cf0c4ee8e9decff9c5466937182e4cc7dbbfe6c5/mmh3-5.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dae0f0bd7d30c0ad61b9a504e8e272cb8391eed3f1587edf933f4f6b33437450", size = 56082, upload-time = "2026-03-05T15:53:59.702Z" }, + { url = "https://files.pythonhosted.org/packages/61/96/6f617baa098ca0d2989bfec6d28b5719532cd8d8848782662f5b755f657f/mmh3-5.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9aeaf53eaa075dd63e81512522fd180097312fb2c9f476333309184285c49ce0", size = 40458, upload-time = "2026-03-05T15:54:01.548Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b4/9cd284bd6062d711e13d26c04d4778ab3f690c1c38a4563e3c767ec8802e/mmh3-5.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0634581290e6714c068f4aa24020acf7880927d1f0084fa753d9799ae9610082", size = 40079, upload-time = "2026-03-05T15:54:02.743Z" }, + { url = "https://files.pythonhosted.org/packages/f6/09/a806334ce1d3d50bf782b95fcee8b3648e1e170327d4bb7b4bad2ad7d956/mmh3-5.2.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080c0637aea036f35507e803a4778f119a9b436617694ae1c5c366805f1e997", size = 97242, upload-time = "2026-03-05T15:54:04.536Z" }, + { url = "https://files.pythonhosted.org/packages/ee/93/723e317dd9e041c4dc4566a2eb53b01ad94de31750e0b834f1643905e97c/mmh3-5.2.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db0562c5f71d18596dcd45e854cf2eeba27d7543e1a3acdafb7eef728f7fe85d", size = 103082, upload-time = "2026-03-05T15:54:06.387Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/f96121e69cc48696075071531cf574f112e1ffd08059f4bffb41210e6fc5/mmh3-5.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d9f9a3ce559a5267014b04b82956993270f63ec91765e13e9fd73daf2d2738e", size = 106054, upload-time = "2026-03-05T15:54:07.506Z" }, + { url = "https://files.pythonhosted.org/packages/82/49/192b987ec48d0b2aecf8ac285a9b11fbc00030f6b9c694664ae923458dde/mmh3-5.2.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:960b1b3efa39872ac8b6cc3a556edd6fb90ed74f08c9c45e028f1005b26aa55d", size = 112910, upload-time = "2026-03-05T15:54:09.403Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a1/03e91fd334ed0144b83343a76eb11f17434cd08f746401488cfeafb2d241/mmh3-5.2.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d30b650595fdbe32366b94cb14f30bb2b625e512bd4e1df00611f99dc5c27fd4", size = 120551, upload-time = "2026-03-05T15:54:10.587Z" }, + { url = "https://files.pythonhosted.org/packages/93/b9/b89a71d2ff35c3a764d1c066c7313fc62c7cc48fa48a4b3b0304a4a0146f/mmh3-5.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:82f3802bfc4751f420d591c5c864de538b71cea117fce67e4595c2afede08a15", size = 99096, upload-time = "2026-03-05T15:54:11.76Z" }, + { url = "https://files.pythonhosted.org/packages/36/b5/613772c1c6ed5f7b63df55eb131e887cc43720fec392777b95a79d34e640/mmh3-5.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:915e7a2418f10bd1151b1953df06d896db9783c9cfdb9a8ee1f9b3a4331ab503", size = 98524, upload-time = "2026-03-05T15:54:13.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/1524566fe8eaf871e4f7bc44095929fcd2620488f402822d848df19d679c/mmh3-5.2.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fc78739b5ec6e4fb02301984a3d442a91406e7700efbe305071e7fd1c78278f2", size = 106239, upload-time = "2026-03-05T15:54:14.601Z" }, + { url = "https://files.pythonhosted.org/packages/04/94/21adfa7d90a7a697137ad6de33eeff6445420ca55e433a5d4919c79bc3b5/mmh3-5.2.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:41aac7002a749f08727cb91babff1daf8deac317c0b1f317adc69be0e6c375d1", size = 109797, upload-time = "2026-03-05T15:54:15.819Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e6/1aacc3a219e1aa62fa65669995d4a3562b35be5200ec03680c7e4bec9676/mmh3-5.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9d8089d853c7963a8ce87fff93e2a67075c0bc08684a08ea6ad13577c38ffc38", size = 97228, upload-time = "2026-03-05T15:54:16.992Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b9/5e4cca8dcccf298add0a27f3c357bc8cf8baf821d35cdc6165e4bd5a48b0/mmh3-5.2.1-cp311-cp311-win32.whl", hash = "sha256:baeb47635cb33375dee4924cd93d7f5dcaa786c740b08423b0209b824a1ee728", size = 40751, upload-time = "2026-03-05T15:54:18.714Z" }, + { url = "https://files.pythonhosted.org/packages/72/fc/5b11d49247f499bcda591171e9cf3b6ee422b19e70aa2cef2e0ae65ca3b9/mmh3-5.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:1e4ecee40ba19e6975e1120829796770325841c2f153c0e9aecca927194c6a2a", size = 41517, upload-time = "2026-03-05T15:54:19.764Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5f/2a511ee8a1c2a527c77726d5231685b72312c5a1a1b7639ad66a9652aa84/mmh3-5.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:c302245fd6c33d96bd169c7ccf2513c20f4c1e417c07ce9dce107c8bc3f8411f", size = 39287, upload-time = "2026-03-05T15:54:20.904Z" }, + { url = "https://files.pythonhosted.org/packages/92/94/bc5c3b573b40a328c4d141c20e399039ada95e5e2a661df3425c5165fd84/mmh3-5.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0cc21533878e5586b80d74c281d7f8da7932bc8ace50b8d5f6dbf7e3935f63f1", size = 56087, upload-time = "2026-03-05T15:54:21.92Z" }, + { url = "https://files.pythonhosted.org/packages/f6/80/64a02cc3e95c3af0aaa2590849d9ed24a9f14bb93537addde688e039b7c3/mmh3-5.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4eda76074cfca2787c8cf1bec603eaebdddd8b061ad5502f85cddae998d54f00", size = 40500, upload-time = "2026-03-05T15:54:22.953Z" }, + { url = "https://files.pythonhosted.org/packages/8b/72/e6d6602ce18adf4ddcd0e48f2e13590cc92a536199e52109f46f259d3c46/mmh3-5.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eee884572b06bbe8a2b54f424dbd996139442cf83c76478e1ec162512e0dd2c7", size = 40034, upload-time = "2026-03-05T15:54:23.943Z" }, + { url = "https://files.pythonhosted.org/packages/59/c2/bf4537a8e58e21886ef16477041238cab5095c836496e19fafc34b7445d2/mmh3-5.2.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d0b7e803191db5f714d264044e06189c8ccd3219e936cc184f07106bd17fd7b", size = 97292, upload-time = "2026-03-05T15:54:25.335Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e2/51ed62063b44d10b06d975ac87af287729eeb5e3ed9772f7584a17983e90/mmh3-5.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e6c219e375f6341d0959af814296372d265a8ca1af63825f65e2e87c618f006", size = 103274, upload-time = "2026-03-05T15:54:26.44Z" }, + { url = "https://files.pythonhosted.org/packages/75/ce/12a7524dca59eec92e5b31fdb13ede1e98eda277cf2b786cf73bfbc24e81/mmh3-5.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26fb5b9c3946bf7f1daed7b37e0c03898a6f062149127570f8ede346390a0825", size = 106158, upload-time = "2026-03-05T15:54:28.578Z" }, + { url = "https://files.pythonhosted.org/packages/86/1f/d3ba6dd322d01ab5d44c46c8f0c38ab6bbbf9b5e20e666dfc05bf4a23604/mmh3-5.2.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c38d142c706201db5b2345166eeef1e7740e3e2422b470b8ba5c8727a9b4c7a", size = 113005, upload-time = "2026-03-05T15:54:29.767Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a9/15d6b6f913294ea41b44d901741298e3718e1cb89ee626b3694625826a43/mmh3-5.2.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50885073e2909251d4718634a191c49ae5f527e5e1736d738e365c3e8be8f22b", size = 120744, upload-time = "2026-03-05T15:54:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/76/b3/70b73923fd0284c439860ff5c871b20210dfdbe9a6b9dd0ee6496d77f174/mmh3-5.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3f99e1756fc48ad507b95e5d86f2fb21b3d495012ff13e6592ebac14033f166", size = 99111, upload-time = "2026-03-05T15:54:32.353Z" }, + { url = "https://files.pythonhosted.org/packages/dd/38/99f7f75cd27d10d8b899a1caafb9d531f3903e4d54d572220e3d8ac35e89/mmh3-5.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62815d2c67f2dd1be76a253d88af4e1da19aeaa1820146dec52cf8bee2958b16", size = 98623, upload-time = "2026-03-05T15:54:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/fd/68/6e292c0853e204c44d2f03ea5f090be3317a0e2d9417ecb62c9eb27687df/mmh3-5.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8f767ba0911602ddef289404e33835a61168314ebd3c729833db2ed685824211", size = 106437, upload-time = "2026-03-05T15:54:35.177Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c6/fedd7284c459cfb58721d461fcf5607a4c1f5d9ab195d113d51d10164d16/mmh3-5.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:67e41a497bac88cc1de96eeba56eeb933c39d54bc227352f8455aa87c4ca4000", size = 110002, upload-time = "2026-03-05T15:54:36.673Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ac/ca8e0c19a34f5b71390171d2ff0b9f7f187550d66801a731bb68925126a4/mmh3-5.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d74a03fb57757ece25aa4b3c1c60157a1cece37a020542785f942e2f827eed5", size = 97507, upload-time = "2026-03-05T15:54:37.804Z" }, + { url = "https://files.pythonhosted.org/packages/df/94/6ebb9094cfc7ac5e7950776b9d13a66bb4a34f83814f32ba2abc9494fc68/mmh3-5.2.1-cp312-cp312-win32.whl", hash = "sha256:7374d6e3ef72afe49697ecd683f3da12f4fc06af2d75433d0580c6746d2fa025", size = 40773, upload-time = "2026-03-05T15:54:40.077Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/cd3527198cf159495966551c84a5f36805a10ac17b294f41f67b83f6a4d6/mmh3-5.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:3a9fed49c6ce4ed7e73f13182760c65c816da006debe67f37635580dfb0fae00", size = 41560, upload-time = "2026-03-05T15:54:41.148Z" }, + { url = "https://files.pythonhosted.org/packages/15/96/6fe5ebd0f970a076e3ed5512871ce7569447b962e96c125528a2f9724470/mmh3-5.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfcb95d9a744e6e2827dfc66ad10e1020e0cac255eb7f85652832d5a264c2fc", size = 39313, upload-time = "2026-03-05T15:54:42.171Z" }, + { url = "https://files.pythonhosted.org/packages/25/a5/9daa0508a1569a54130f6198d5462a92deda870043624aa3ea72721aa765/mmh3-5.2.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:723b2681ed4cc07d3401bbea9c201ad4f2a4ca6ba8cddaff6789f715dd2b391e", size = 40832, upload-time = "2026-03-05T15:54:43.212Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6b/3230c6d80c1f4b766dedf280a92c2241e99f87c1504ff74205ec8cebe451/mmh3-5.2.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:3619473a0e0d329fd4aec8075628f8f616be2da41605300696206d6f36920c3d", size = 41964, upload-time = "2026-03-05T15:54:44.204Z" }, + { url = "https://files.pythonhosted.org/packages/62/fb/648bfddb74a872004b6ee751551bfdda783fe6d70d2e9723bad84dbe5311/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e48d4dbe0f88e53081da605ae68644e5182752803bbc2beb228cca7f1c4454d6", size = 39114, upload-time = "2026-03-05T15:54:45.205Z" }, + { url = "https://files.pythonhosted.org/packages/95/c2/ab7901f87af438468b496728d11264cb397b3574d41506e71b92128e0373/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a482ac121de6973897c92c2f31defc6bafb11c83825109275cffce54bb64933f", size = 39819, upload-time = "2026-03-05T15:54:46.509Z" }, + { url = "https://files.pythonhosted.org/packages/2f/ed/6f88dda0df67de1612f2e130ffea34cf84aaee5bff5b0aff4dbff2babe34/mmh3-5.2.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:17fbb47f0885ace8327ce1235d0416dc86a211dcd8cc1e703f41523be32cfec8", size = 40330, upload-time = "2026-03-05T15:54:47.864Z" }, + { url = "https://files.pythonhosted.org/packages/3d/66/7516d23f53cdf90f43fce24ab80c28f45e6851d78b46bef8c02084edf583/mmh3-5.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d51fde50a77f81330523562e3c2734ffdca9c4c9e9d355478117905e1cfe16c6", size = 56078, upload-time = "2026-03-05T15:54:48.9Z" }, + { url = "https://files.pythonhosted.org/packages/bc/34/4d152fdf4a91a132cb226b671f11c6b796eada9ab78080fb5ce1e95adaab/mmh3-5.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19bbd3b841174ae6ed588536ab5e1b1fe83d046e668602c20266547298d939a9", size = 40498, upload-time = "2026-03-05T15:54:49.942Z" }, + { url = "https://files.pythonhosted.org/packages/d4/4c/8e3af1b6d85a299767ec97bd923f12b06267089c1472c27c1696870d1175/mmh3-5.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be77c402d5e882b6fbacfd90823f13da8e0a69658405a39a569c6b58fdb17b03", size = 40033, upload-time = "2026-03-05T15:54:50.994Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/966ea560e32578d453c9e9db53d602cbb1d0da27317e232afa7c38ceba11/mmh3-5.2.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fd96476f04db5ceba1cfa0f21228f67c1f7402296f0e73fee3513aa680ad237b", size = 97320, upload-time = "2026-03-05T15:54:52.072Z" }, + { url = "https://files.pythonhosted.org/packages/bb/0d/2c5f9893b38aeb6b034d1a44ecd55a010148054f6a516abe53b5e4057297/mmh3-5.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:707151644085dd0f20fe4f4b573d28e5130c4aaa5f587e95b60989c5926653b5", size = 103299, upload-time = "2026-03-05T15:54:53.569Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fc/2ebaef4a4d4376f89761274dc274035ffd96006ab496b4ee5af9b08f21a9/mmh3-5.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3737303ca9ea0f7cb83028781148fcda4f1dac7821db0c47672971dabcf63593", size = 106222, upload-time = "2026-03-05T15:54:55.092Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/ea7ffe126d0ba0406622602a2d05e1e1a6841cc92fc322eb576c95b27fad/mmh3-5.2.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2778fed822d7db23ac5008b181441af0c869455b2e7d001f4019636ac31b6fe4", size = 113048, upload-time = "2026-03-05T15:54:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/85/57/9447032edf93a64aa9bef4d9aa596400b1756f40411890f77a284f6293ca/mmh3-5.2.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d57dea657357230cc780e13920d7fa7db059d58fe721c80020f94476da4ca0a1", size = 120742, upload-time = "2026-03-05T15:54:57.453Z" }, + { url = "https://files.pythonhosted.org/packages/53/82/a86cc87cc88c92e9e1a598fee509f0409435b57879a6129bf3b3e40513c7/mmh3-5.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:169e0d178cb59314456ab30772429a802b25d13227088085b0d49b9fe1533104", size = 99132, upload-time = "2026-03-05T15:54:58.583Z" }, + { url = "https://files.pythonhosted.org/packages/54/f7/6b16eb1b40ee89bb740698735574536bc20d6cdafc65ae702ea235578e05/mmh3-5.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7e4e1f580033335c6f76d1e0d6b56baf009d1a64d6a4816347e4271ba951f46d", size = 98686, upload-time = "2026-03-05T15:55:00.078Z" }, + { url = "https://files.pythonhosted.org/packages/e8/88/a601e9f32ad1410f438a6d0544298ea621f989bd34a0731a7190f7dec799/mmh3-5.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2bd9f19f7f1fcebd74e830f4af0f28adad4975d40d80620be19ffb2b2af56c9f", size = 106479, upload-time = "2026-03-05T15:55:01.532Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/ce29ae3dfc4feec4007a437a1b7435fb9507532a25147602cd5b52be86db/mmh3-5.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c88653877aeb514c089d1b3d473451677b8b9a6d1497dbddf1ae7934518b06d2", size = 110030, upload-time = "2026-03-05T15:55:02.934Z" }, + { url = "https://files.pythonhosted.org/packages/13/30/ae444ef2ff87c805d525da4fa63d27cda4fe8a48e77003a036b8461cfd5c/mmh3-5.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fceef7fe67c81e1585198215e42ad3fdba3a25644beda8fbdaf85f4d7b93175a", size = 97536, upload-time = "2026-03-05T15:55:04.135Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f9/dc3787ee5c813cc27fe79f45ad4500d9b5437f23a7402435cc34e07c7718/mmh3-5.2.1-cp313-cp313-win32.whl", hash = "sha256:54b64fb2433bc71488e7a449603bf8bd31fbcf9cb56fbe1eb6d459e90b86c37b", size = 40769, upload-time = "2026-03-05T15:55:05.277Z" }, + { url = "https://files.pythonhosted.org/packages/43/67/850e0b5a1e97799822ebfc4ca0e8c6ece3ed8baf7dcdf64de817dfdda2ca/mmh3-5.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:cae6383181f1e345317742d2ddd88f9e7d2682fa4c9432e3a74e47d92dce0229", size = 41563, upload-time = "2026-03-05T15:55:06.283Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/98c90b28e1da5458e19fbfaf4adb5289208d3bfccd45dd14eab216a2f0bb/mmh3-5.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:022aa1a528604e6c83d0a7705fdef0b5355d897a9e0fa3a8d26709ceaa06965d", size = 39310, upload-time = "2026-03-05T15:55:07.323Z" }, + { url = "https://files.pythonhosted.org/packages/63/b4/65bc1fb2bb7f83e91c30865023b1847cf89a5f237165575e8c83aa536584/mmh3-5.2.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:d771f085fcdf4035786adfb1d8db026df1eb4b41dac1c3d070d1e49512843227", size = 40794, upload-time = "2026-03-05T15:55:09.773Z" }, + { url = "https://files.pythonhosted.org/packages/c4/86/7168b3d83be8eb553897b1fac9da8bbb06568e5cfe555ffc329ebb46f59d/mmh3-5.2.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:7f196cd7910d71e9d9860da0ff7a77f64d22c1ad931f1dd18559a06e03109fc0", size = 41923, upload-time = "2026-03-05T15:55:10.924Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9b/b653ab611c9060ce8ff0ba25c0226757755725e789292f3ca138a58082cd/mmh3-5.2.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b1f12bd684887a0a5d55e6363ca87056f361e45451105012d329b86ec19dbe0b", size = 39131, upload-time = "2026-03-05T15:55:11.961Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b4/5a2e0d34ab4d33543f01121e832395ea510132ea8e52cdf63926d9d81754/mmh3-5.2.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d106493a60dcb4aef35a0fac85105e150a11cf8bc2b0d388f5a33272d756c966", size = 39825, upload-time = "2026-03-05T15:55:13.013Z" }, + { url = "https://files.pythonhosted.org/packages/bd/69/81699a8f39a3f8d368bec6443435c0c392df0d200ad915bf0d222b588e03/mmh3-5.2.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:44983e45310ee5b9f73397350251cdf6e63a466406a105f1d16cb5baa659270b", size = 40344, upload-time = "2026-03-05T15:55:14.026Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b3/71c8c775807606e8fd8acc5c69016e1caf3200d50b50b6dd4b40ce10b76c/mmh3-5.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:368625fb01666655985391dbad3860dc0ba7c0d6b9125819f3121ee7292b4ac8", size = 56291, upload-time = "2026-03-05T15:55:15.137Z" }, + { url = "https://files.pythonhosted.org/packages/6f/75/2c24517d4b2ce9e4917362d24f274d3d541346af764430249ddcc4cb3a08/mmh3-5.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:72d1cc63bcc91e14933f77d51b3df899d6a07d184ec515ea7f56bff659e124d7", size = 40575, upload-time = "2026-03-05T15:55:16.518Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b9/e4a360164365ac9f07a25f0f7928e3a66eb9ecc989384060747aa170e6aa/mmh3-5.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e8b4b5580280b9265af3e0409974fb79c64cf7523632d03fbf11df18f8b0181e", size = 40052, upload-time = "2026-03-05T15:55:17.735Z" }, + { url = "https://files.pythonhosted.org/packages/97/ca/120d92223a7546131bbbc31c9174168ee7a73b1366f5463ffe69d9e691fe/mmh3-5.2.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4cbbde66f1183db040daede83dd86c06d663c5bb2af6de1142b7c8c37923dd74", size = 97311, upload-time = "2026-03-05T15:55:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/b6/71/c1a60c1652b8813ef9de6d289784847355417ee0f2980bca002fe87f4ae5/mmh3-5.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ff038d52ef6aa0f309feeba00c5095c9118d0abf787e8e8454d6048db2037fc", size = 103279, upload-time = "2026-03-05T15:55:20.448Z" }, + { url = "https://files.pythonhosted.org/packages/48/29/ad97f4be1509cdcb28ae32c15593ce7c415db47ace37f8fad35b493faa9a/mmh3-5.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4130d0b9ce5fad6af07421b1aecc7e079519f70d6c05729ab871794eded8617", size = 106290, upload-time = "2026-03-05T15:55:21.6Z" }, + { url = "https://files.pythonhosted.org/packages/77/29/1f86d22e281bd8827ba373600a4a8b0c0eae5ca6aa55b9a8c26d2a34decc/mmh3-5.2.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e0bfe77d238308839699944164b96a2eeccaf55f2af400f54dc20669d8d5f2", size = 113116, upload-time = "2026-03-05T15:55:22.826Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7c/339971ea7ed4c12d98f421f13db3ea576a9114082ccb59d2d1a0f00ccac1/mmh3-5.2.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f963eafc0a77a6c0562397da004f5876a9bcf7265a7bcc3205e29636bc4a1312", size = 120740, upload-time = "2026-03-05T15:55:24.3Z" }, + { url = "https://files.pythonhosted.org/packages/e4/92/3c7c4bdb8e926bb3c972d1e2907d77960c1c4b250b41e8366cf20c6e4373/mmh3-5.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:92883836caf50d5255be03d988d75bc93e3f86ba247b7ca137347c323f731deb", size = 99143, upload-time = "2026-03-05T15:55:25.456Z" }, + { url = "https://files.pythonhosted.org/packages/df/0a/33dd8706e732458c8375eae63c981292de07a406bad4ec03e5269654aa2c/mmh3-5.2.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:57b52603e89355ff318025dd55158f6e71396c0f1f609d548e9ea9c94cc6ce0a", size = 98703, upload-time = "2026-03-05T15:55:26.723Z" }, + { url = "https://files.pythonhosted.org/packages/51/04/76bbce05df76cbc3d396f13b2ea5b1578ef02b6a5187e132c6c33f99d596/mmh3-5.2.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f40a95186a72fa0b67d15fef0f157bfcda00b4f59c8a07cbe5530d41ac35d105", size = 106484, upload-time = "2026-03-05T15:55:28.214Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8f/c6e204a2c70b719c1f62ffd9da27aef2dddcba875ea9c31ca0e87b975a46/mmh3-5.2.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:58370d05d033ee97224c81263af123dea3d931025030fd34b61227a768a8858a", size = 110012, upload-time = "2026-03-05T15:55:29.532Z" }, + { url = "https://files.pythonhosted.org/packages/e3/37/7181efd8e39db386c1ebc3e6b7d1f702a09d7c1197a6f2742ed6b5c16597/mmh3-5.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7be6dfb49e48fd0a7d91ff758a2b51336f1cd21f9d44b20f6801f072bd080cdd", size = 97508, upload-time = "2026-03-05T15:55:31.01Z" }, + { url = "https://files.pythonhosted.org/packages/42/0f/afa7ca2615fd85e1469474bb860e381443d0b868c083b62b41cb1d7ca32f/mmh3-5.2.1-cp314-cp314-win32.whl", hash = "sha256:54fe8518abe06a4c3852754bfd498b30cc58e667f376c513eac89a244ce781a4", size = 41387, upload-time = "2026-03-05T15:55:32.403Z" }, + { url = "https://files.pythonhosted.org/packages/71/0d/46d42a260ee1357db3d486e6c7a692e303c017968e14865e00efa10d09fc/mmh3-5.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3f796b535008708846044c43302719c6956f39ca2d93f2edda5319e79a29efbb", size = 42101, upload-time = "2026-03-05T15:55:33.646Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7b/848a8378059d96501a41159fca90d6a99e89736b0afbe8e8edffeac8c74b/mmh3-5.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:cd471ede0d802dd936b6fab28188302b2d497f68436025857ca72cd3810423fe", size = 39836, upload-time = "2026-03-05T15:55:35.026Z" }, + { url = "https://files.pythonhosted.org/packages/27/61/1dabea76c011ba8547c25d30c91c0ec22544487a8750997a27a0c9e1180b/mmh3-5.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5174a697ce042fa77c407e05efe41e03aa56dae9ec67388055820fb48cf4c3ba", size = 57727, upload-time = "2026-03-05T15:55:36.162Z" }, + { url = "https://files.pythonhosted.org/packages/b7/32/731185950d1cf2d5e28979cc8593016ba1619a295faba10dda664a4931b5/mmh3-5.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0a3984146e414684a6be2862d84fcb1035f4984851cb81b26d933bab6119bf00", size = 41308, upload-time = "2026-03-05T15:55:37.254Z" }, + { url = "https://files.pythonhosted.org/packages/76/aa/66c76801c24b8c9418b4edde9b5e57c75e72c94e29c48f707e3962534f18/mmh3-5.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bd6e7d363aa93bd3421b30b6af97064daf47bc96005bddba67c5ffbc6df426b8", size = 40758, upload-time = "2026-03-05T15:55:38.61Z" }, + { url = "https://files.pythonhosted.org/packages/9e/bb/79a1f638a02f0ae389f706d13891e2fbf7d8c0a22ecde67ba828951bb60a/mmh3-5.2.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:113f78e7463a36dbbcea05bfe688efd7fa759d0f0c56e73c974d60dcfec3dfcc", size = 109670, upload-time = "2026-03-05T15:55:40.13Z" }, + { url = "https://files.pythonhosted.org/packages/26/94/8cd0e187a288985bcfc79bf5144d1d712df9dee74365f59d26e3a1865be6/mmh3-5.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e8ec5f606e0809426d2440e0683509fb605a8820a21ebd120dcdba61b74ef7f", size = 117399, upload-time = "2026-03-05T15:55:42.076Z" }, + { url = "https://files.pythonhosted.org/packages/42/94/dfea6059bd5c5beda565f58a4096e43f4858fb6d2862806b8bbd12cbb284/mmh3-5.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22b0f9971ec4e07e8223f2beebe96a6cfc779d940b6f27d26604040dd74d3a44", size = 120386, upload-time = "2026-03-05T15:55:43.481Z" }, + { url = "https://files.pythonhosted.org/packages/47/cb/f9c45e62aaa67220179f487772461d891bb582bb2f9783c944832c60efd9/mmh3-5.2.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:85ffc9920ffc39c5eee1e3ac9100c913a0973996fbad5111f939bbda49204bb7", size = 125924, upload-time = "2026-03-05T15:55:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/a5/83/fe54a4a7c11bc9f623dfc1707decd034245602b076dfc1dcc771a4163170/mmh3-5.2.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7aec798c2b01aaa65a55f1124f3405804184373abb318a3091325aece235f67c", size = 135280, upload-time = "2026-03-05T15:55:45.866Z" }, + { url = "https://files.pythonhosted.org/packages/97/67/fe7e9e9c143daddd210cd22aef89cbc425d58ecf238d2b7d9eb0da974105/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55dbbd8ffbc40d1697d5e2d0375b08599dae8746b0b08dea05eee4ce81648fac", size = 110050, upload-time = "2026-03-05T15:55:47.074Z" }, + { url = "https://files.pythonhosted.org/packages/43/c4/6d4b09fcbef80794de447c9378e39eefc047156b290fa3dd2d5257ca8227/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6c85c38a279ca9295a69b9b088a2e48aa49737bb1b34e6a9dc6297c110e8d912", size = 111158, upload-time = "2026-03-05T15:55:48.239Z" }, + { url = "https://files.pythonhosted.org/packages/81/a6/ca51c864bdb30524beb055a6d8826db3906af0834ec8c41d097a6e8573d5/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6290289fa5fb4c70fd7f72016e03633d60388185483ff3b162912c81205ae2cf", size = 116890, upload-time = "2026-03-05T15:55:49.405Z" }, + { url = "https://files.pythonhosted.org/packages/cc/04/5a1fe2e2ad843d03e89af25238cbc4f6840a8bb6c4329a98ab694c71deda/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4fc6cd65dc4d2fdb2625e288939a3566e36127a84811a4913f02f3d5931da52d", size = 123121, upload-time = "2026-03-05T15:55:50.61Z" }, + { url = "https://files.pythonhosted.org/packages/af/4d/3c820c6f4897afd25905270a9f2330a23f77a207ea7356f7aadace7273c0/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:623f938f6a039536cc02b7582a07a080f13fdfd48f87e63201d92d7e34d09a18", size = 110187, upload-time = "2026-03-05T15:55:52.143Z" }, + { url = "https://files.pythonhosted.org/packages/21/54/1d71cd143752361c0aebef16ad3f55926a6faf7b112d355745c1f8a25f7f/mmh3-5.2.1-cp314-cp314t-win32.whl", hash = "sha256:29bc3973676ae334412efdd367fcd11d036b7be3efc1ce2407ef8676dabfeb82", size = 41934, upload-time = "2026-03-05T15:55:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e4/63a2a88f31d93dea03947cccc2a076946857e799ea4f7acdecbf43b324aa/mmh3-5.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:28cfab66577000b9505a0d068c731aee7ca85cd26d4d63881fab17857e0fe1fb", size = 43036, upload-time = "2026-03-05T15:55:55.252Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0f/59204bf136d1201f8d7884cfbaf7498c5b4674e87a4c693f9bde63741ce1/mmh3-5.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dfd51b4c56b673dfbc43d7d27ef857dd91124801e2806c69bb45585ce0fa019b", size = 40391, upload-time = "2026-03-05T15:55:56.697Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "narwhals" +version = "2.24.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/1d/58946e5aab18393e793bd4add6985b95d0e01c3a2d832f38f54468b10dcd/narwhals-2.24.0.tar.gz", hash = "sha256:b5c0f684ccd9d7475b564111e319a4964abcf2baf79d3cf6b1003d06ac9b828d", size = 661143, upload-time = "2026-07-13T10:49:19.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl", hash = "sha256:42fdedf44e5b2ca7505630d45b4ac3058f38d8485cba9fe1652ca23152df7489", size = 461030, upload-time = "2026-07-13T10:49:17.571Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "(python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')", + "python_full_version < '3.12' and sys_platform != 'linux' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "(python_full_version >= '3.14' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform == 'win32')", + "(python_full_version == '3.13.*' and sys_platform == 'linux') or (python_full_version == '3.13.*' and sys_platform == 'win32')", + "(python_full_version == '3.12.*' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform == 'win32')", + "python_full_version >= '3.14' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'linux' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +] + +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + +[[package]] +name = "onnxruntime" +version = "1.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flatbuffers" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, + { name = "protobuf" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/4d/5014667e2a3a77d6e1b74cc3d88948d06163b8e0a33a84c85073322b5dec/onnxruntime-1.28.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:f5c5daabd28aad610f83fdcf32acec8fb57e6adc6c6a39fe2a3c755db957b410", size = 19130506, upload-time = "2026-07-25T01:22:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/ea/97/b7ce1bc8bb6048b5fe9129f55d6506dc19499068ef2e0a0af1ae3c8aa4e7/onnxruntime-1.28.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d66f9ceb29909c70839e4e4fb3435c7b490050d8f162bd5f3aba4ca01ee517f", size = 17039880, upload-time = "2026-07-25T01:21:37.538Z" }, + { url = "https://files.pythonhosted.org/packages/f3/17/4e5ecd8764f87573c495d834ce79e61ecca47f7a01d1e444a606e570edcb/onnxruntime-1.28.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a166b78ee04f3a37fa1ef82034b6a3ce96d9684e582d4d30b296de83e9998bb5", size = 19193162, upload-time = "2026-07-25T01:21:59.151Z" }, + { url = "https://files.pythonhosted.org/packages/9f/10/3d946d5d5f2cdcc3c8da36cae63190c516d16349edaffd944bda60ca4c3e/onnxruntime-1.28.0-cp311-cp311-win_amd64.whl", hash = "sha256:0d650aeee29368414367b65529e90afe4bf1bab76254789063b8b2f7ea3013c8", size = 13752539, upload-time = "2026-07-25T01:22:24.524Z" }, + { url = "https://files.pythonhosted.org/packages/8f/74/1c440be7af1e026280b139caa1be5d11bd4dc368011ddbe8f5362b58e12f/onnxruntime-1.28.0-cp311-cp311-win_arm64.whl", hash = "sha256:0faf85fb447a663c9cdadc39bd6b19bdf7bedded6699e45731b9b36c46fd993d", size = 13449940, upload-time = "2026-07-25T01:22:14.97Z" }, + { url = "https://files.pythonhosted.org/packages/98/f8/dcbe7700dca82fa540035abd3c868fe5ad0f86af00b9a3db7c2e27d15c7d/onnxruntime-1.28.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:26ff0fdd06efb6c155bae95387a09db1a2be89c7a03e4d0bffd5a171cc2826da", size = 19141362, upload-time = "2026-07-25T01:22:36.965Z" }, + { url = "https://files.pythonhosted.org/packages/28/5b/1d77e62097fdbe07e2dc827f389b1c4c0c275f6fab0369a8f46d2461af27/onnxruntime-1.28.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e81a23df16e7acb9d51b06d30cc098e49315ef9180f97bc2221d167b4b04d9c", size = 17050628, upload-time = "2026-07-25T01:21:40.481Z" }, + { url = "https://files.pythonhosted.org/packages/95/df/5486ab03e9be288d5268867054c8b04bebcf95bfd12e801c05cc67703dab/onnxruntime-1.28.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0a83bdb70d143cede762b677789bf2a7acca54b3fb82565601d5c30695aa933c", size = 19214257, upload-time = "2026-07-25T01:22:01.695Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3b/986ca67c274932ba9ac5332fb10de56f643dfd433c74e33f8ae8f847cf24/onnxruntime-1.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:c35064f9b3c43c81c5d5d282091401d0f1ff22796d93ccade4ea2ece5e137ab8", size = 13755036, upload-time = "2026-07-25T01:22:26.89Z" }, + { url = "https://files.pythonhosted.org/packages/1d/46/059dba81d46c6ba88e0c2d1c64321ac8098847d678423300a183d42ecbd6/onnxruntime-1.28.0-cp312-cp312-win_arm64.whl", hash = "sha256:e02feeb0165c5f13b4cc954738078d59b90128516ac12b671ee24a530242bf02", size = 13454462, upload-time = "2026-07-25T01:22:17.38Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/3807e2b17d9eb71d3cb78ed2ba76869b05c637c9b9d6112e636098b0c97a/onnxruntime-1.28.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:31410f544674f534c2f27348af52ef81682ca9c8719154bf4d48f0ef23823b1e", size = 19141759, upload-time = "2026-07-25T01:21:53.765Z" }, + { url = "https://files.pythonhosted.org/packages/c0/23/b46045c3bf67a9cf54c12f5df0f018a422c65fbb9d6072b10071bebfaae2/onnxruntime-1.28.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f649dd6f6452d12a8059888aa489fe519e062e18793dac72b9efa0f9fdb64135", size = 17049339, upload-time = "2026-07-25T01:21:43.005Z" }, + { url = "https://files.pythonhosted.org/packages/78/b6/8c5396e7894e77c5a7d1e026f3acb9dd39c4b5644e412e37a0055eaa3bc5/onnxruntime-1.28.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54fa221d669282bd8f582708ce4c96010a7e9fb0661f9006b37fe2fedafb73fe", size = 19214329, upload-time = "2026-07-25T01:22:04.133Z" }, + { url = "https://files.pythonhosted.org/packages/56/f1/51225c202edba4dfc94e1ea03f3d78f1aaf307da75fd792c0ce1946b2514/onnxruntime-1.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:1a1a19175464665c9b8d50bc916f216cc0b569110045b7bbca8f9f290b186f58", size = 13755033, upload-time = "2026-07-25T01:22:29.302Z" }, + { url = "https://files.pythonhosted.org/packages/f4/db/f59f715edfdd96a051f32b5ef0e680a20a8755d4ecd75f63090e960e347a/onnxruntime-1.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:cfab507abe09d6ffeb817eee07944d452fdc0b00fdcef34cab4db10a45e378c7", size = 13454175, upload-time = "2026-07-25T01:22:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/47/28/810314fa88647af9f4cdaf438a30ad1cfebebb53ded55499232d7a0094e6/onnxruntime-1.28.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac301f53b1930402fc46c368e268acfed02f3207272aaff05070d7e09f96f031", size = 17057307, upload-time = "2026-07-25T01:21:45.492Z" }, + { url = "https://files.pythonhosted.org/packages/3d/cc/9e9f193cc0f29f263a8f09ec08487aed6c96ee856d5fd77da32a425c1949/onnxruntime-1.28.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7f022a1103cae591c75fc4565589a515f2ddd14a6ac8e8a05812dfeda142e28", size = 19222954, upload-time = "2026-07-25T01:22:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/4e/eb/952314c451d9463e5c9aed9978eec76cf32930d407d9ab8700dd0f4ea1ea/onnxruntime-1.28.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:8adff67a3f28257b37cfe945a7e952e4122666aa8c91a0380862e9fd4c2ed19f", size = 19143748, upload-time = "2026-07-25T01:21:56.297Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e9/139180b4dd810329aaa42c238b4e6383c906202d98609ae29d66eb7c32b1/onnxruntime-1.28.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc2565e487b4896fb988d6383577d875d958e071fc5f6c3550bd5d02ae98264b", size = 17051950, upload-time = "2026-07-25T01:21:48.606Z" }, + { url = "https://files.pythonhosted.org/packages/03/88/9432428273356ad3c8aa01f52c1b3e7f53c4c0192748f41ad983872b436b/onnxruntime-1.28.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6afdc83f1317c136e92fc29f5ee9f058de59d87c0b22cee3fdbfbaa0ccc2098a", size = 19214924, upload-time = "2026-07-25T01:22:09.727Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e2/6feb3a43517aaf2b1bf7e46897ba5eb81a29717f7d7901420614d5ee4653/onnxruntime-1.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:f2a3b9e30ce880d4ca54999cb313569e36da4f62eefe25f87be18f43e9a3a4d5", size = 14093738, upload-time = "2026-07-25T01:22:31.629Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8f/83974a1e201dc2e58e5e7111bcaeb1ca2413e9c41f505d26419ee9e3dddf/onnxruntime-1.28.0-cp314-cp314-win_arm64.whl", hash = "sha256:07fb3cbe990d6bf0ab3c22bfbbfb0e314151266046ea6edb4a07f556b4258c5f", size = 13821117, upload-time = "2026-07-25T01:22:22.387Z" }, + { url = "https://files.pythonhosted.org/packages/0d/83/00e606bc25c756d76a267370c39b7516ad52f9cf134d7ff2bff8b6108bc4/onnxruntime-1.28.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e562d6e36a749f6764481c0ddb0f2af3d0b5a3c164291361d08803c557f369af", size = 17055518, upload-time = "2026-07-25T01:21:51.08Z" }, + { url = "https://files.pythonhosted.org/packages/94/a9/68707e1ce345cbdbcd4df65932ebc82a673e917d63eda0007ebcff948691/onnxruntime-1.28.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f6e92367ddce1e4d33cf295024f40192be6c6171a09208f515ba169ced06c8e", size = 19222976, upload-time = "2026-07-25T01:22:12.474Z" }, +] + +[[package]] +name = "openai" +version = "2.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/f5/e7735f2af272ee179a287911a698b3cbdb59d7a4ac4874571363adf1e4de/openai-2.50.0.tar.gz", hash = "sha256:5128f7caf4a6b01aefd6e7e93efe170a2c3427b8de286b9af5cdff3aa47e02c8", size = 1081965, upload-time = "2026-07-28T22:16:31.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/ca/db315b3bb748c26c644a3f85b7d509e774354d6518d47080b1446005ee41/openai-2.50.0-py3-none-any.whl", hash = "sha256:90bdddcc5a2fa529b350fac9c5780d87e5c361dcc6090ab57b0d470b0d7af7fa", size = 1650721, upload-time = "2026-07-28T22:16:29.48Z" }, +] + +[[package]] +name = "opencv-python-headless" +version = "5.0.0.93" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/99/76b7c80252aa83c1af16393454aafd125a0287101afe8deb0a6821af0e30/opencv_python_headless-5.0.0.93.tar.gz", hash = "sha256:b82f9831daab90b725c7c1ee1b36cb5732c367096ac76d119e64e14eb70d5f3c", size = 81817738, upload-time = "2026-07-02T07:01:06.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/7c/8c8097891c509d98cd128493835c95631c80be6a8f37ed9d25716c2e16f1/opencv_python_headless-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:030ca5e0837a2963ab36ef896baa9767eb8d2b83353fb28af5a521e40dd8756f", size = 48322581, upload-time = "2026-07-02T05:50:34.207Z" }, + { url = "https://files.pythonhosted.org/packages/90/8c/eab2ad388c3cbab2a350c10c2ef19ce6bd099240afc31789032c996bab52/opencv_python_headless-5.0.0.93-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:1e55af3abfb462eeeabe5c775f12bdb36216d8a93a3583d69e6bd6e1d6ba7d00", size = 34782894, upload-time = "2026-07-02T05:51:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/ec/78/afca939f40ffe2b2380bfa86f812b2f7d4acc5a27b27dc41b49cad7ce7b4/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10818d91510e05c04568ae12b5cd120779c70c01bf897b001a6221fe430df80f", size = 36521085, upload-time = "2026-07-02T06:55:24.429Z" }, + { url = "https://files.pythonhosted.org/packages/2b/97/8170e9819764c47e436c130d3ff6cfb73b58f923eae9d3a03d8982b04aec/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09a872a157c1376ab922a69bbf22f9a95bcc7b658a9d8b436a60212b02b2eeb4", size = 56563598, upload-time = "2026-07-02T06:55:47.355Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/1a28a7101e31801042b3098871a74b76c61581d328ef40774ff4edb53a56/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:840bd717c21e5c11cadadc022a823315ea417f961213d06b4df010e019eb16f4", size = 39648433, upload-time = "2026-07-02T06:56:04.255Z" }, + { url = "https://files.pythonhosted.org/packages/9b/21/f6ef335f6e65724aa78b8d792b48d40a48c381715f1e62f5a5049e09d07e/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ed709fdf9aa0bd1f2ed8549e71d19449b03a675bb581eb292285f6861953be37", size = 61204038, upload-time = "2026-07-02T06:56:41.823Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8f/b8756467ea991449a293797f6b3fa80fcfdd29598a0a60d1cd5715b96e61/opencv_python_headless-5.0.0.93-cp37-abi3-win32.whl", hash = "sha256:c6bcd96b185975ea240d22cfdb15a1f6d080cc95264cfbe2621f21bb144d89b9", size = 35411237, upload-time = "2026-07-02T05:50:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/b8/88/763b967f7efd7226b82c9fae16d560cba049b1f0c036647e65c610fd636e/opencv_python_headless-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:829717b6a95554f273e49e357cee3b3a2a26b6f4842fbc1bed2b45bdd8f87e0e", size = 43825962, upload-time = "2026-07-02T05:50:09.627Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/09/4d717852c1cf3f854b76c7110a5d00883bc3c99288b9b0dbcbeb9e306eb6/opentelemetry_exporter_otlp_proto_common-1.44.0.tar.gz", hash = "sha256:dc87a5a5bc58f149a56d1547e4691588fa12994cdc3bc039a694ccb3375862ac", size = 20202, upload-time = "2026-07-16T15:25:37.658Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/71/65fd9d54c10b860f87c045ccee1264cab7011268895d3528818a29c1172a/opentelemetry_exporter_otlp_proto_common-1.44.0-py3-none-any.whl", hash = "sha256:9a9fe61bba73d802904bc989f1d6b4a7b1ee40f06c40e98d6f85af65aaebb694", size = 17045, upload-time = "2026-07-16T15:25:18.201Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/47/80d9e9d468dc5de3af5096f5ccdb065fa4dd1470f74495cc53e59e397f47/opentelemetry_exporter_otlp_proto_grpc-1.44.0.tar.gz", hash = "sha256:40d1ae9e03fcc36de3cbac610cc99f35894938bff9cfd90fc4ec68bd85448463", size = 27225, upload-time = "2026-07-16T15:25:38.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/29/6ae42ba32b153ae0a44ae125f0caff2188bbe62d99c82d1768da30864e72/opentelemetry_exporter_otlp_proto_grpc-1.44.0-py3-none-any.whl", hash = "sha256:6a1a645ea182a2f59440c51fa8301d309f3324a8f9d65f8395584b064b67ee4e", size = 19624, upload-time = "2026-07-16T15:25:19.096Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/01/40ac4ae9a149263cc52c2cee200ddd80cb6d8db1a4610abf8eabce0fe771/opentelemetry_proto-1.44.0.tar.gz", hash = "sha256:c547a79c2f8c0c515d31509154682e5921c7cfd5ca67b70e1f9266e2c3e103f3", size = 46488, upload-time = "2026-07-16T15:25:45.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/7c/8be563d68e93bbefa5c8affb82ddcff91b3ad858ce49957ba7b16fd3e0ab/opentelemetry_proto-1.44.0-py3-none-any.whl", hash = "sha256:898b155a0e1557afd867478fb6158e8122a46329ca0bb8dc53cc55e98f017f56", size = 72483, upload-time = "2026-07-16T15:25:28.429Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/77/a6592cbc7c8d9bcc9d6757a9df45e04a7c585e3e6e7a13456da522b21109/opentelemetry_sdk-1.44.0.tar.gz", hash = "sha256:cebe7f65dc12f26ead75c6064de12fd2a9052e5060c0272d402cfa203aae123b", size = 208624, upload-time = "2026-07-16T15:25:46.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/23/ff077e61886ee020a17ce9c8b6fa11c601c8d8345b09ea24f605445df62a/opentelemetry_sdk-1.44.0-py3-none-any.whl", hash = "sha256:df081c4c6bcfdb1211e3e86140376792643128a25f8d72d1d27675936e7e96ad", size = 137221, upload-time = "2026-07-16T15:25:29.534Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.65b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/73/0cbdebcb4cf545fdd328da14f5137e37d0770c3f26185e478b0d15d94f50/opentelemetry_semantic_conventions-0.65b0.tar.gz", hash = "sha256:f9b2b81e9d5b64f11bc952075e7e9c7fb0aab075c7fd1c46d597f1b919852d60", size = 148774, upload-time = "2026-07-16T15:25:46.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/0e/49df70d9b81fb5cbae4bbf2a49d865b09bcbcbc4eb53f5851b1027738d78/opentelemetry_semantic_conventions-0.65b0-py3-none-any.whl", hash = "sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb", size = 204645, upload-time = "2026-07-16T15:25:30.688Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/51/3fb9e65ae76ee97bd611869a503fa3fc0a6e81dd8b737cf3003f682df7ff/orjson-3.11.9-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f01c4818b3fc9b0da8e096722a84318071eaa118df35f6ed2344da0e73a5444f", size = 228522, upload-time = "2026-05-06T15:09:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/16/fa/9d54b07cb3f3b0bfd57841478e42d7a0ece4a9f49f9907eecf5a45461687/orjson-3.11.9-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3ebca4179031ee716ed076ffadc29428e900512f6fccee8614c9983157fcf19c", size = 128463, upload-time = "2026-05-06T15:09:37.063Z" }, + { url = "https://files.pythonhosted.org/packages/88/b1/6ceafc2eefd0a553e3be77ce6c49d107e772485d9568629376171c50e634/orjson-3.11.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ee05097750de0ff69ed5b7bbcf0732182fd57a24043dcc2a1da780a5ead3a5", size = 132306, upload-time = "2026-05-06T15:09:38.299Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/f11311285324a40aab1e3031385c50b635a7cd0734fdaf60c7e89a696f60/orjson-3.11.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6082706765a95a6680d812e1daf1c0cfe8adec7831b3ff3b625693f3b461b1c", size = 127988, upload-time = "2026-05-06T15:09:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/9e/85/0ef63bcf1337f44031ce9b91b1919563f62a37527b3ea4368bb15a22e5d7/orjson-3.11.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:277fefe9d76ee17eb14debf399e3533d4d63b5f677a4d3719eb763536af1f4bd", size = 135188, upload-time = "2026-05-06T15:09:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/05/94/b0d27090ea8a2095db3c2bd1b1c96f96f19bbb494d7fef33130e846e613d/orjson-3.11.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03db380e3780fa0015ed776a90f20e8e20bb11dde13b216ce19e5718e3dfba62", size = 145937, upload-time = "2026-05-06T15:09:42.249Z" }, + { url = "https://files.pythonhosted.org/packages/09/eb/75d50c29c05b8054013e221e598820a365c8e64065312e75e202ed880709/orjson-3.11.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33d7d766701847dc6729846362dc27895d2f2d2251264f9d10e7cb9878194877", size = 132758, upload-time = "2026-05-06T15:09:43.945Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/360686f39348aa88827cb6fbf7dc606fd41c831a35235e1abf1db8e3a9e6/orjson-3.11.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:147302878da387104b66bb4a8b0227d1d487e976ce41a8501916161072ed87b1", size = 133971, upload-time = "2026-05-06T15:09:45.239Z" }, + { url = "https://files.pythonhosted.org/packages/0e/30/3178eb16f3221aeef068b6f1f1ebe05f656ea5c6dffe9f6c917329fe17a3/orjson-3.11.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3513550321f8c8c811a7c3297b8a630e82dc08e4c10216d07703c997776236cd", size = 141685, upload-time = "2026-05-06T15:09:46.858Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f1/ff2f19ed0225f9680fafa42febca3570dd59444ebf190980738d376214c2/orjson-3.11.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c5d001196b89fa9cf0a4ab79766cd835b991a166e4b621ba95089edc50c429ff", size = 415167, upload-time = "2026-05-06T15:09:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/9b/61/863bddf0da6e9e586765414debd54b4e58db05f560902b6d00658cb88636/orjson-3.11.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:16969c9d369c98eb084889c6e4d2d39b77c7eb38ceccf8da2a9fff62ae908980", size = 147913, upload-time = "2026-05-06T15:09:49.733Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4081492586d75b073d60c5271a8d0f05a0955cabf1e34c8473f6fcd84235/orjson-3.11.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:63e0efbc991250c0b3143488fa57d95affcabbfc63c99c48d625dd37779aafe2", size = 136959, upload-time = "2026-05-06T15:09:51.311Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bd/70b6ab193594d7abb875320c0a7c8335e846f28968c432c31042409c3c8d/orjson-3.11.9-cp311-cp311-win32.whl", hash = "sha256:14ed654580c1ed2bc217352ec82f91b047aef82951aa71c7f64e0dcb03c0e180", size = 131533, upload-time = "2026-05-06T15:09:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/3f/17/1a1a228183d62d1b77e2c30d210f47dd4768b310ebe1607c63e3c0e3a71e/orjson-3.11.9-cp311-cp311-win_amd64.whl", hash = "sha256:57ea77fb70a448ce87d18fca050193202a3da5e54598f6501ca5476fb66cfe02", size = 127106, upload-time = "2026-05-06T15:09:54.204Z" }, + { url = "https://files.pythonhosted.org/packages/b8/95/285de5fa296d09681ee9c546cd4a8aeb773b701cf343dc125994f4d52953/orjson-3.11.9-cp311-cp311-win_arm64.whl", hash = "sha256:19b72ed11572a2ee51a67a903afbe5af504f84ed6f529c0fe44b0ab3fb5cc697", size = 126848, upload-time = "2026-05-06T15:09:55.551Z" }, + { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, + { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, + { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, +] + +[[package]] +name = "overrides" +version = "7.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/86/b585f53236dec60aba864e050778b25045f857e17f6e5ea0ae95fe80edd2/overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a", size = 22812, upload-time = "2024-01-27T21:01:33.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49", size = 17832, upload-time = "2024-01-27T21:01:31.393Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/ef/f1fd7431d635bf20015489bf0bd69c17fff1018de773540f651455a3916b/pandas-3.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2946e77e4a53cd248cbde631a12f0e51c8324ce354c3eba4d20147c1ad6f4282", size = 10397178, upload-time = "2026-07-22T22:17:48.274Z" }, + { url = "https://files.pythonhosted.org/packages/31/b4/0eafac990a431561187694126de01f9b12559549b4d86360c0c4bd870fde/pandas-3.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71ecc8fb7ed1a7aa4392316b5309a6347e8e7f832f38fd897846b3a1457a9298", size = 9990736, upload-time = "2026-07-22T22:17:52.388Z" }, + { url = "https://files.pythonhosted.org/packages/de/21/359880af3ea9b7cb23bea5b51e8e70ef3866c03be09da9a2787e18e330a8/pandas-3.0.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b173f5951ff6b8b0ec7675e20dff3c97b7e7a57dfcce387c2d7c5afe87cb7899", size = 10814438, upload-time = "2026-07-22T22:17:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/d1/50/d6cc4d7e508bbccf5d6027314a8312bc7ac73d0ec7f195f53838daafab40/pandas-3.0.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c0cf1dd9b55a22d105fc46c1b489af3bd42264fcba7c66297bf47a9a1d9c78a", size = 11323634, upload-time = "2026-07-22T22:17:56.858Z" }, + { url = "https://files.pythonhosted.org/packages/70/2b/d5f0a8c90dd0ae04e64ba53b871afb796ec026b615086d382ddc2ade729b/pandas-3.0.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0fac0010c75e4efb6b99e249c183a8993ce0dc95c240f9b120a5e67c727b7928", size = 11850860, upload-time = "2026-07-22T22:17:59.1Z" }, + { url = "https://files.pythonhosted.org/packages/5c/30/183aec2e19adf778a98d29b5729a0a68f4cc4ebf9b9c3b70d0297355bcb1/pandas-3.0.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:08d24fe11a17dc33bd6e937dc9c665f9cba08fbdc9f657f405713515febe300d", size = 12411100, upload-time = "2026-07-22T22:18:01.485Z" }, + { url = "https://files.pythonhosted.org/packages/fa/9a/31f4983f191af51ab2a8f2d0c7b33dff3a84da26533f982fff02c2f9e28b/pandas-3.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:b1261758dfb6cf12c3cff8300e21cefad30e7ec709abb4c24ac7318e6a52462a", size = 9968804, upload-time = "2026-07-22T22:18:03.903Z" }, + { url = "https://files.pythonhosted.org/packages/49/97/7886c89a39045c69ad82cbceaf3343810480c8ef49a216319ce8183860a6/pandas-3.0.5-cp311-cp311-win_arm64.whl", hash = "sha256:679f4e85b30ddb1515458ab1e788d3e260eae369b1f78da7a3aa4cac8ebf4a2a", size = 9205447, upload-time = "2026-07-22T22:18:06.134Z" }, + { url = "https://files.pythonhosted.org/packages/1c/54/1dc810ea558d1320b597aa140a514f2fdf1d2ea09c38cf556f13ea712ec9/pandas-3.0.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fa290c16964d4963fbfbc358928239cf3bd755b20e988ce944877def2f44471d", size = 10411717, upload-time = "2026-07-22T22:18:08.307Z" }, + { url = "https://files.pythonhosted.org/packages/68/56/fbe81c09195924d8b7b8d4461a20458fe80a6a5ed6b24f0314da684277e1/pandas-3.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2e26bb46934b8a2ca0c3de1d3d606fc5f6746584791b2db264d58cf370e08dc", size = 9957095, upload-time = "2026-07-22T22:18:10.6Z" }, + { url = "https://files.pythonhosted.org/packages/e0/51/fac252f4a913ed5eabf3c11b880a9e8d5a6c10f0b2129d0462212d238b4d/pandas-3.0.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73fa87b08a7ef706f8aafda39ddaccf2a99047bea62d8c88a0361bcafb2237bc", size = 10485458, upload-time = "2026-07-22T22:18:12.834Z" }, + { url = "https://files.pythonhosted.org/packages/12/98/e976540c1addf70442be7842a18cf70884a964abbf69442504f4d2939989/pandas-3.0.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d373ce03ffd84010ed9839fa73672a9c8256990532e158440c0085db7d914b34", size = 10998091, upload-time = "2026-07-22T22:18:15.209Z" }, + { url = "https://files.pythonhosted.org/packages/a4/8c/1f29b5be8d3fc47dd7567eb167fabba2085879b31e0287ce7cba6d3d2ff4/pandas-3.0.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a29c53d85ea98c5e792c59ef82ee9fbe6ca902c0d0adb6b23f45ef894cd7bf6", size = 11499501, upload-time = "2026-07-22T22:18:17.689Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e2/bd9c98ad2df7b38bde002adde4cdf353519da51881634323b126c55997f9/pandas-3.0.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a5ad3b02ed6bc7d7ae9b70804b2c6aa31827489d150f8e623ce82491b82085d7", size = 12060559, upload-time = "2026-07-22T22:18:20.147Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9a/ffbd852d58bd74a617fe2f8ee6a58a96982271ce41cf981eab22190b4a4b/pandas-3.0.5-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:b2acb4650527eec6822c3dadb2b771277b65e7dae7a267d4bccf65fd1bb3fbce", size = 7197652, upload-time = "2026-07-22T22:18:22.502Z" }, + { url = "https://files.pythonhosted.org/packages/70/b5/d2d3e9ae73362ba4229651b0ee1455cf78073a1ce585f6ff693782ce263e/pandas-3.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:80a611068e8a3ac23f7398c6c14eb46dc974e5cc9997f653e2dcfd1da74edd41", size = 9831691, upload-time = "2026-07-22T22:18:24.534Z" }, + { url = "https://files.pythonhosted.org/packages/52/51/dea1e89d6a6796b9c43f85a09b484ee03edb8a4c4842e73e200a8c11301c/pandas-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:25ff585b972a18ef1fe9ffa3ac6544d9950508aa76832e5147640b6022821e49", size = 9105796, upload-time = "2026-07-22T22:18:27.064Z" }, + { url = "https://files.pythonhosted.org/packages/bf/09/7b95c4a0025227d6f118c4039b423412ac6a982db02864166185d812fbc7/pandas-3.0.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c1c05a767fe8e5b4fe9e1c29806829c582052eaedb9120a3da83ba3f69e24a5b", size = 10385742, upload-time = "2026-07-22T22:18:29.346Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0c/dc78fd8c4da477b4b5e8ad37295af352190d21ef63a9ee1bc071753074cc/pandas-3.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b86765f268b56f7e665b93bce9d5df69dee7f99e595cf8fb839483ab315942a3", size = 9932067, upload-time = "2026-07-22T22:18:31.833Z" }, + { url = "https://files.pythonhosted.org/packages/3e/71/3592c055cf44df9808550f9368ceda80ff2b224d355ef73fe251dcda1802/pandas-3.0.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c597ecf5616b5c420372c1d4d4c00dbbfba7398bea857dcc984347e1ea48417b", size = 10466756, upload-time = "2026-07-22T22:18:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/e3/70/4363150359f95b4cb4bcbb34ca23572bb5495749a621a8f3d5a1ddfd293c/pandas-3.0.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b11c36e218331d0387cbe3a0a5f75162357a1d92d57b2b08a336ff94b19b2be", size = 10938525, upload-time = "2026-07-22T22:18:36.81Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d0/317e7a0c67c0e69fa905a0161409397a7dc2d46ff611f6ca4803352c042b/pandas-3.0.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cf52e1f61d229496da17dc7ab54acdee627357e7008fd4fecba3d0ba2937fa58", size = 11489303, upload-time = "2026-07-22T22:18:39.287Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8d/36dade89b49e4f9d5cbdbe863772581f98c0c6d78fc39ad4c557f6f2e17e/pandas-3.0.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:db172144bb56422bd157812f3b021eacc255451470b31e2c633c349490a1cfee", size = 11989004, upload-time = "2026-07-22T22:18:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ba/18c4ec8a746e177da05a9e7a7963781d8ea195780724f854601b6ebd6b78/pandas-3.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:0d298e951f23016ce4699951d044ae6418dbc91bf68cefca0f77666fcbb4e5c6", size = 9826896, upload-time = "2026-07-22T22:18:44.539Z" }, + { url = "https://files.pythonhosted.org/packages/de/ec/28a57266b753799a87b8bc79e7887ac6fd981b8c6d2978a0b7e7b6bd708c/pandas-3.0.5-cp313-cp313-win_arm64.whl", hash = "sha256:66266d3442a5e8b3c90274c2b8b230bee42dd1c286bc822cc2f9f2c7e12b883e", size = 9094790, upload-time = "2026-07-22T22:18:47.468Z" }, + { url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" }, + { url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" }, + { url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" }, + { url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" }, + { url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" }, + { url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" }, + { url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" }, + { url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" }, + { url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pooch" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "platformdirs" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/43/85ef45e8b36c6a48546af7b266592dc32d7f67837a6514d111bced6d7d75/pooch-1.9.0.tar.gz", hash = "sha256:de46729579b9857ffd3e741987a2f6d5e0e03219892c167c6578c0091fb511ed", size = 61788, upload-time = "2026-01-30T19:15:09.649Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl", hash = "sha256:f265597baa9f760d25ceb29d0beb8186c243d6607b0f60b83ecf14078dbc703b", size = 67175, upload-time = "2026-01-30T19:15:08.36Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, + { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, + { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, + { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, + { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "psycopg" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { 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] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/82/df3312c0ca083d5b43b352f27d4dd8b1e614bd334473074715d9e0000da4/psycopg_binary-3.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:612a627d733f695b1de1f9b4bd511c15f999a5d8b915d444bbd7dd71cf3370da", size = 4609813, upload-time = "2026-05-01T23:26:30.612Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b5/d74d542458d3e8ac0571d8a88f57ca369999b9a82f4fa528052d0d7d3e4c/psycopg_binary-3.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:13a7f380824c35896dcac7fe0f61440f7ca49d6dc73f3c13a9a4471e6a3b302e", size = 4676799, upload-time = "2026-05-01T23:26:38.475Z" }, + { url = "https://files.pythonhosted.org/packages/09/67/06bab9c60671999f4c6ceff1b334f3ac1f9fc5789eb467c714623ea21de9/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:276904e3452d6a23d474ef9a21eee19f20eed3d53ddd2576af033827e0ba0992", size = 5497050, upload-time = "2026-05-01T23:26:47.061Z" }, + { url = "https://files.pythonhosted.org/packages/72/9b/023433e2b20f970de1e22d29132a95281277646da0b2e2879dd4ee94b8c1/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab8cca8ef8fb1ccf5b048ae5bd78ba55b9e4b5d472e3ce5ca39ff4d2a9c249e4", size = 5172428, upload-time = "2026-05-01T23:26:56.708Z" }, + { url = "https://files.pythonhosted.org/packages/08/cd/ae16da8fde228a38b2fe9269bbc13cf89e0186173f2265600f02d6a71e64/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7465bfe6087d2d5b42d4c53b9b11ca9f218e477317a4a162a10e3c19e984ba8e", size = 6762746, upload-time = "2026-05-01T23:27:07.023Z" }, + { url = "https://files.pythonhosted.org/packages/4f/81/0ba09fa5f5f88779093a2541a8e02489825721f258ab88058b11d68b3eb5/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22cdbf5f91ef7bb91fe0c5757e1962d3127a8010256eefd9c61fcaf441802097", size = 5006033, upload-time = "2026-05-01T23:27:12.221Z" }, + { url = "https://files.pythonhosted.org/packages/73/6a/629136040cc3497adb442a305710b5913f2a754d4630fc3d3717c4c0df65/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2631da29253a98bd496e6c4813b24e09a4fe3fb2a9e88513305d6f8747cce95", size = 4534175, upload-time = "2026-05-01T23:27:18.248Z" }, + { url = "https://files.pythonhosted.org/packages/7c/32/1027f843c6dc2d5d51960ee62cc0c2cf755a4c39455aff1371173edbef7d/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7f7668f30b9dd5163197e5cbf4e0efd54e00f0a859cc566ce56cfc31f4054839", size = 4224203, upload-time = "2026-05-01T23:27:24.3Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e1/380a724d9093c74adb14d4fce920ea8327838abb61f760b1448586b14a8e/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:cffc3408d77a27973f33e5d909b624cce683db5fc25964b02fe0aae7886c1007", size = 3954509, upload-time = "2026-05-01T23:27:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/db/cd/895893ae575a09c97ccfd5def070d88993d955ef34df45a881fd5ff506d6/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0579252a1202cd73e4da137a1426e2dae993ae44e757605344282af3a082848c", size = 4259551, upload-time = "2026-05-01T23:27:38.828Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c6/2330a20794e37a3ec609ef2fd8522919ec7a4395a1abf979a8e2d1775cd5/psycopg_binary-3.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:41f2ec0fea529832982bcb6c9415de3c86264ebe562b77a467c0fbcd7efbba8d", size = 3572054, upload-time = "2026-05-01T23:27:45.455Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" }, + { url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" }, + { url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" }, + { url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" }, + { url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" }, + { url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" }, + { url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" }, + { url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" }, + { url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" }, + { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" }, + { url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, +] + +[[package]] +name = "pyarrow" +version = "24.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/c9/a47ab7ece0d86cbe6678418a0fbd1ac4bb493b9184a3891dfa0e7f287ae0/pyarrow-24.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:b0e131f880cda8d04e076cee175a46fc0e8bc8b65c99c6c09dff6669335fde74", size = 35068898, upload-time = "2026-04-21T10:46:36.599Z" }, + { url = "https://files.pythonhosted.org/packages/d1/bc/8db86617a9a58008acf8913d6fed68ea2a46acb6de928db28d724c891a68/pyarrow-24.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:1b2fe7f9a5566401a0ef2571f197eb92358925c1f0c8dba305d6e43ea0871bb3", size = 36679915, upload-time = "2026-04-21T10:46:42.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8e/fb178720400ef69db251eb4a9c3ccf4af269bc1feb5055529b8fc87170d1/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0b3537c00fb8d384f15ac1e79b6eb6db04a16514c8c1d22e59a9b95c8ba42868", size = 45697931, upload-time = "2026-04-21T10:46:48.403Z" }, + { url = "https://files.pythonhosted.org/packages/f3/27/99c42abe8e21b44f4917f62631f3aa31404882a2c41d8a4cd5c110e13d52/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:14e31a3c9e35f1ab6356c6378f6f72830e6d2d5f1791df3774a7b097d18a6a1e", size = 48837449, upload-time = "2026-04-21T10:46:55.329Z" }, + { url = "https://files.pythonhosted.org/packages/36/b6/333749e2666e9032891125bf9c691146e92901bece62030ac1430e2e7c88/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7d9a514e73bc42711e6a35aaccf3587c520024fe0a25d830a1a8a27c15f4f57", size = 49395949, upload-time = "2026-04-21T10:47:01.869Z" }, + { url = "https://files.pythonhosted.org/packages/17/25/c5201706a2dd374e8ba6ee3fd7a8c89fb7ffc16eed5217a91fd2bd7f7626/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b196eb3f931862af3fa84c2a253514d859c08e0d8fe020e07be12e75a5a9780c", size = 51912986, upload-time = "2026-04-21T10:47:09.872Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d2/4d1bbba65320b21a49678d6fbdc6ff7c649251359fdcfc03568c4136231d/pyarrow-24.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:35405aecb474e683fb36af650618fd5340ee5471fc65a21b36076a18bbc6c981", size = 27255371, upload-time = "2026-04-21T10:47:15.943Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559, upload-time = "2026-04-21T10:47:22.17Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654, upload-time = "2026-04-21T10:47:28.315Z" }, + { url = "https://files.pythonhosted.org/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394, upload-time = "2026-04-21T10:47:34.821Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122, upload-time = "2026-04-21T10:47:42.056Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" }, + { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759, upload-time = "2026-04-21T10:48:07.258Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471, upload-time = "2026-04-21T10:48:13.347Z" }, + { url = "https://files.pythonhosted.org/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981, upload-time = "2026-04-21T10:48:20.201Z" }, + { url = "https://files.pythonhosted.org/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0", size = 48859172, upload-time = "2026-04-21T10:48:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495", size = 49385733, upload-time = "2026-04-21T10:48:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f", size = 51934335, upload-time = "2026-04-21T10:48:42.099Z" }, + { url = "https://files.pythonhosted.org/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91", size = 27271748, upload-time = "2026-04-21T10:49:42.532Z" }, + { url = "https://files.pythonhosted.org/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275", size = 35238554, upload-time = "2026-04-21T10:48:48.526Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b", size = 36782301, upload-time = "2026-04-21T10:48:55.181Z" }, + { url = "https://files.pythonhosted.org/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42", size = 45721929, upload-time = "2026-04-21T10:49:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b", size = 48825365, upload-time = "2026-04-21T10:49:11.714Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37", size = 49451819, upload-time = "2026-04-21T10:49:21.474Z" }, + { url = "https://files.pythonhosted.org/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca", size = 51909252, upload-time = "2026-04-21T10:49:31.164Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", size = 27388127, upload-time = "2026-04-21T10:49:37.334Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/d022a34ff05d2cbedd8ccf841fc1f532ecfa9eb5ed1711b56d0e0ea71fc9/pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838", size = 35007997, upload-time = "2026-04-21T10:49:48.796Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ff/f01485fda6f4e5d441afb8dd5e7681e4db18826c1e271852f5d3957d6a80/pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b", size = 36678720, upload-time = "2026-04-21T10:49:55.858Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/2d2d5fea814237923f71b36495211f20b43a1576f9a4d6da7e751a64ec6f/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795", size = 45741852, upload-time = "2026-04-21T10:50:04.624Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3a/28ba9c1c1ebdbb5f1b94dfebb46f207e52e6a554b7fe4132540fde29a3a0/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26", size = 48889852, upload-time = "2026-04-21T10:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/df/51/4a389acfd31dca009f8fb82d7f510bb4130f2b3a8e18cf00194d0687d8ac/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde", size = 49445207, upload-time = "2026-04-21T10:50:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/0bab2b23d2ae901b1b9a03c0efd4b2d070256f8ce3fc43f6e58c167b2081/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76", size = 51954117, upload-time = "2026-04-21T10:50:29.14Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/f4e9145da0417b3d2c12035a8492b35ff4a3dbc653e614fcfb51d9dedb38/pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e", size = 28001155, upload-time = "2026-04-21T10:51:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/79/4f/46a49a63f43526da895b1a45bbb51d5baf8e4d77159f8528fc3e5490007f/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05", size = 35250387, upload-time = "2026-04-21T10:50:35.552Z" }, + { url = "https://files.pythonhosted.org/packages/a0/da/d5e0cd5ef00796922404806d5f00325cdadc3441ce2c13fe7115f2df9a64/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a", size = 36797102, upload-time = "2026-04-21T10:50:42.417Z" }, + { url = "https://files.pythonhosted.org/packages/34/c7/5904145b0a593a05236c882933d439b5720f0a145381179063722fbfc123/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072", size = 45745118, upload-time = "2026-04-21T10:50:49.324Z" }, + { url = "https://files.pythonhosted.org/packages/13/d3/cca42fe166d1c6e4d5b80e530b7949104d10e17508a90ae202dac205ce2a/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931", size = 48844765, upload-time = "2026-04-21T10:50:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/942c3b79878ba928324d1e17c274ed84581db8c0a749b24bcf4cbdf15bd3/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699", size = 49471890, upload-time = "2026-04-21T10:51:02.439Z" }, + { url = "https://files.pythonhosted.org/packages/76/97/ff71431000a75d84135a1ace5ca4ba11726a231a8007bbb320a4c54075d5/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136", size = 51932250, upload-time = "2026-04-21T10:51:10.576Z" }, + { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" }, +] + +[[package]] +name = "pybase64" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/b8/4ed5c7ad5ec15b08d35cc79ace6145d5c1ae426e46435f4987379439dfea/pybase64-1.4.3.tar.gz", hash = "sha256:c2ed274c9e0ba9c8f9c4083cfe265e66dd679126cd9c2027965d807352f3f053", size = 137272, upload-time = "2025-12-06T13:27:04.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/63/21e981e9d3f1f123e0b0ee2130112b1956cad9752309f574862c7ae77c08/pybase64-1.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:70b0d4a4d54e216ce42c2655315378b8903933ecfa32fced453989a92b4317b2", size = 38237, upload-time = "2025-12-06T13:22:52.159Z" }, + { url = "https://files.pythonhosted.org/packages/92/fb/3f448e139516404d2a3963915cc10dc9dde7d3a67de4edba2f827adfef17/pybase64-1.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8127f110cdee7a70e576c5c9c1d4e17e92e76c191869085efbc50419f4ae3c72", size = 31673, upload-time = "2025-12-06T13:22:53.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/bb06a5b9885e7d853ac1e801c4d8abfdb4c8506deee33e53d55aa6690e67/pybase64-1.4.3-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f9ef0388878bc15a084bd9bf73ec1b2b4ee513d11009b1506375e10a7aae5032", size = 68331, upload-time = "2025-12-06T13:22:54.197Z" }, + { url = "https://files.pythonhosted.org/packages/64/15/8d60b9ec5e658185fc2ee3333e01a6e30d717cf677b24f47cbb3a859d13c/pybase64-1.4.3-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95a57cccf106352a72ed8bc8198f6820b16cc7d55aa3867a16dea7011ae7c218", size = 71370, upload-time = "2025-12-06T13:22:55.517Z" }, + { url = "https://files.pythonhosted.org/packages/ac/29/a3e5c1667cc8c38d025a4636855de0fc117fc62e2afeb033a3c6f12c6a22/pybase64-1.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cd1c47dfceb9c7bd3de210fb4e65904053ed2d7c9dce6d107f041ff6fbd7e21", size = 59834, upload-time = "2025-12-06T13:22:56.682Z" }, + { url = "https://files.pythonhosted.org/packages/a9/00/8ffcf9810bd23f3984698be161cf7edba656fd639b818039a7be1d6405d4/pybase64-1.4.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:9fe9922698f3e2f72874b26890d53a051c431d942701bb3a37aae94da0b12107", size = 56652, upload-time = "2025-12-06T13:22:57.724Z" }, + { url = "https://files.pythonhosted.org/packages/81/62/379e347797cdea4ab686375945bc77ad8d039c688c0d4d0cfb09d247beb9/pybase64-1.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:af5f4bd29c86b59bb4375e0491d16ec8a67548fa99c54763aaedaf0b4b5a6632", size = 59382, upload-time = "2025-12-06T13:22:58.758Z" }, + { url = "https://files.pythonhosted.org/packages/c6/f2/9338ffe2f487086f26a2c8ca175acb3baa86fce0a756ff5670a0822bb877/pybase64-1.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c302f6ca7465262908131411226e02100f488f531bb5e64cb901aa3f439bccd9", size = 59990, upload-time = "2025-12-06T13:23:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a4/85a6142b65b4df8625b337727aa81dc199642de3d09677804141df6ee312/pybase64-1.4.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:2f3f439fa4d7fde164ebbbb41968db7d66b064450ab6017c6c95cef0afa2b349", size = 54923, upload-time = "2025-12-06T13:23:02.369Z" }, + { url = "https://files.pythonhosted.org/packages/ac/00/e40215d25624012bf5b7416ca37f168cb75f6dd15acdb91ea1f2ea4dc4e7/pybase64-1.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a23c6866551043f8b681a5e1e0d59469148b2920a3b4fc42b1275f25ea4217a", size = 58664, upload-time = "2025-12-06T13:23:03.378Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/d7e19a63e795c13837f2356268d95dc79d1180e756f57ced742a1e52fdeb/pybase64-1.4.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:56e6526f8565642abc5f84338cc131ce298a8ccab696b19bdf76fa6d7dc592ef", size = 52338, upload-time = "2025-12-06T13:23:04.458Z" }, + { url = "https://files.pythonhosted.org/packages/f2/32/3c746d7a310b69bdd9df77ffc85c41b80bce00a774717596f869b0d4a20e/pybase64-1.4.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6a792a8b9d866ffa413c9687d9b611553203753987a3a582d68cbc51cf23da45", size = 68993, upload-time = "2025-12-06T13:23:05.526Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b3/63cec68f9d6f6e4c0b438d14e5f1ef536a5fe63ce14b70733ac5e31d7ab8/pybase64-1.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:62ad29a5026bb22cfcd1ca484ec34b0a5ced56ddba38ceecd9359b2818c9c4f9", size = 58055, upload-time = "2025-12-06T13:23:06.931Z" }, + { url = "https://files.pythonhosted.org/packages/d5/cb/7acf7c3c06f9692093c07f109668725dc37fb9a3df0fa912b50add645195/pybase64-1.4.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11b9d1d2d32ec358c02214363b8fc3651f6be7dd84d880ecd597a6206a80e121", size = 54430, upload-time = "2025-12-06T13:23:07.936Z" }, + { url = "https://files.pythonhosted.org/packages/33/39/4eb33ff35d173bfff4002e184ce8907f5d0a42d958d61cd9058ef3570179/pybase64-1.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0aebaa7f238caa0a0d373616016e2040c6c879ebce3ba7ab3c59029920f13640", size = 56272, upload-time = "2025-12-06T13:23:09.253Z" }, + { url = "https://files.pythonhosted.org/packages/19/97/a76d65c375a254e65b730c6f56bf528feca91305da32eceab8bcc08591e6/pybase64-1.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e504682b20c63c2b0c000e5f98a80ea867f8d97642e042a5a39818e44ba4d599", size = 70904, upload-time = "2025-12-06T13:23:10.336Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/8338b6d3da3c265002839e92af0a80d6db88385c313c73f103dfb800c857/pybase64-1.4.3-cp311-cp311-win32.whl", hash = "sha256:e9a8b81984e3c6fb1db9e1614341b0a2d98c0033d693d90c726677db1ffa3a4c", size = 33639, upload-time = "2025-12-06T13:23:11.9Z" }, + { url = "https://files.pythonhosted.org/packages/39/dc/32efdf2f5927e5449cc341c266a1bbc5fecd5319a8807d9c5405f76e6d02/pybase64-1.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:a90a8fa16a901fabf20de824d7acce07586e6127dc2333f1de05f73b1f848319", size = 35797, upload-time = "2025-12-06T13:23:13.174Z" }, + { url = "https://files.pythonhosted.org/packages/da/59/eda4f9cb0cbce5a45f0cd06131e710674f8123a4d570772c5b9694f88559/pybase64-1.4.3-cp311-cp311-win_arm64.whl", hash = "sha256:61d87de5bc94d143622e94390ec3e11b9c1d4644fe9be3a81068ab0f91056f59", size = 31160, upload-time = "2025-12-06T13:23:15.696Z" }, + { url = "https://files.pythonhosted.org/packages/86/a7/efcaa564f091a2af7f18a83c1c4875b1437db56ba39540451dc85d56f653/pybase64-1.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:18d85e5ab8b986bb32d8446aca6258ed80d1bafe3603c437690b352c648f5967", size = 38167, upload-time = "2025-12-06T13:23:16.821Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/c7ad35adff2d272bf2930132db2b3eea8c44bb1b1f64eb9b2b8e57cde7b4/pybase64-1.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3f5791a3491d116d0deaf4d83268f48792998519698f8751efb191eac84320e9", size = 31673, upload-time = "2025-12-06T13:23:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/43/1b/9a8cab0042b464e9a876d5c65fe5127445a2436da36fda64899b119b1a1b/pybase64-1.4.3-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f0b3f200c3e06316f6bebabd458b4e4bcd4c2ca26af7c0c766614d91968dee27", size = 68210, upload-time = "2025-12-06T13:23:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/62/f7/965b79ff391ad208b50e412b5d3205ccce372a2d27b7218ae86d5295b105/pybase64-1.4.3-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb632edfd132b3eaf90c39c89aa314beec4e946e210099b57d40311f704e11d4", size = 71599, upload-time = "2025-12-06T13:23:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/03/4b/a3b5175130b3810bbb8ccfa1edaadbd3afddb9992d877c8a1e2f274b476e/pybase64-1.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:356ef1d74648ce997f5a777cf8f1aefecc1c0b4fe6201e0ef3ec8a08170e1b54", size = 59922, upload-time = "2025-12-06T13:23:21.487Z" }, + { url = "https://files.pythonhosted.org/packages/da/5d/c38d1572027fc601b62d7a407721688b04b4d065d60ca489912d6893e6cf/pybase64-1.4.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:c48361f90db32bacaa5518419d4eb9066ba558013aaf0c7781620279ecddaeb9", size = 56712, upload-time = "2025-12-06T13:23:22.77Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d4/4e04472fef485caa8f561d904d4d69210a8f8fc1608ea15ebd9012b92655/pybase64-1.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:702bcaa16ae02139d881aeaef5b1c8ffb4a3fae062fe601d1e3835e10310a517", size = 59300, upload-time = "2025-12-06T13:23:24.543Z" }, + { url = "https://files.pythonhosted.org/packages/86/e7/16e29721b86734b881d09b7e23dfd7c8408ad01a4f4c7525f3b1088e25ec/pybase64-1.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:53d0ffe1847b16b647c6413d34d1de08942b7724273dd57e67dcbdb10c574045", size = 60278, upload-time = "2025-12-06T13:23:25.608Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/18515f211d7c046be32070709a8efeeef8a0203de4fd7521e6b56404731b/pybase64-1.4.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9a1792e8b830a92736dae58f0c386062eb038dfe8004fb03ba33b6083d89cd43", size = 54817, upload-time = "2025-12-06T13:23:26.633Z" }, + { url = "https://files.pythonhosted.org/packages/e7/be/14e29d8e1a481dbff151324c96dd7b5d2688194bb65dc8a00ca0e1ad1e86/pybase64-1.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d468b1b1ac5ad84875a46eaa458663c3721e8be5f155ade356406848d3701f6", size = 58611, upload-time = "2025-12-06T13:23:27.684Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8a/a2588dfe24e1bbd742a554553778ab0d65fdf3d1c9a06d10b77047d142aa/pybase64-1.4.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e97b7bdbd62e71898cd542a6a9e320d9da754ff3ebd02cb802d69087ee94d468", size = 52404, upload-time = "2025-12-06T13:23:28.714Z" }, + { url = "https://files.pythonhosted.org/packages/27/fc/afcda7445bebe0cbc38cafdd7813234cdd4fc5573ff067f1abf317bb0cec/pybase64-1.4.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b33aeaa780caaa08ffda87fc584d5eab61e3d3bbb5d86ead02161dc0c20d04bc", size = 68817, upload-time = "2025-12-06T13:23:30.079Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3a/87c3201e555ed71f73e961a787241a2438c2bbb2ca8809c29ddf938a3157/pybase64-1.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c0efcf78f11cf866bed49caa7b97552bc4855a892f9cc2372abcd3ed0056f0d", size = 57854, upload-time = "2025-12-06T13:23:31.17Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7d/931c2539b31a7b375e7d595b88401eeb5bd6c5ce1059c9123f9b608aaa14/pybase64-1.4.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:66e3791f2ed725a46593f8bd2761ff37d01e2cdad065b1dceb89066f476e50c6", size = 54333, upload-time = "2025-12-06T13:23:32.422Z" }, + { url = "https://files.pythonhosted.org/packages/de/5e/537601e02cc01f27e9d75f440f1a6095b8df44fc28b1eef2cd739aea8cec/pybase64-1.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:72bb0b6bddadab26e1b069bb78e83092711a111a80a0d6b9edcb08199ad7299b", size = 56492, upload-time = "2025-12-06T13:23:33.515Z" }, + { url = "https://files.pythonhosted.org/packages/96/97/2a2e57acf8f5c9258d22aba52e71f8050e167b29ed2ee1113677c1b600c1/pybase64-1.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5b3365dbcbcdb0a294f0f50af0c0a16b27a232eddeeb0bceeefd844ef30d2a23", size = 70974, upload-time = "2025-12-06T13:23:36.27Z" }, + { url = "https://files.pythonhosted.org/packages/75/2e/a9e28941c6dab6f06e6d3f6783d3373044be9b0f9a9d3492c3d8d2260ac0/pybase64-1.4.3-cp312-cp312-win32.whl", hash = "sha256:7bca1ed3a5df53305c629ca94276966272eda33c0d71f862d2d3d043f1e1b91a", size = 33686, upload-time = "2025-12-06T13:23:37.848Z" }, + { url = "https://files.pythonhosted.org/packages/83/e3/507ab649d8c3512c258819c51d25c45d6e29d9ca33992593059e7b646a33/pybase64-1.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:9f2da8f56d9b891b18b4daf463a0640eae45a80af548ce435be86aa6eff3603b", size = 35833, upload-time = "2025-12-06T13:23:38.877Z" }, + { url = "https://files.pythonhosted.org/packages/bc/8a/6eba66cd549a2fc74bb4425fd61b839ba0ab3022d3c401b8a8dc2cc00c7a/pybase64-1.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:0631d8a2d035de03aa9bded029b9513e1fee8ed80b7ddef6b8e9389ffc445da0", size = 31185, upload-time = "2025-12-06T13:23:39.908Z" }, + { url = "https://files.pythonhosted.org/packages/3a/50/b7170cb2c631944388fe2519507fe3835a4054a6a12a43f43781dae82be1/pybase64-1.4.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:ea4b785b0607d11950b66ce7c328f452614aefc9c6d3c9c28bae795dc7f072e1", size = 33901, upload-time = "2025-12-06T13:23:40.951Z" }, + { url = "https://files.pythonhosted.org/packages/48/8b/69f50578e49c25e0a26e3ee72c39884ff56363344b79fc3967f5af420ed6/pybase64-1.4.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:6a10b6330188c3026a8b9c10e6b9b3f2e445779cf16a4c453d51a072241c65a2", size = 40807, upload-time = "2025-12-06T13:23:42.006Z" }, + { url = "https://files.pythonhosted.org/packages/5c/8d/20b68f11adfc4c22230e034b65c71392e3e338b413bf713c8945bd2ccfb3/pybase64-1.4.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:27fdff227a0c0e182e0ba37a99109645188978b920dfb20d8b9c17eeee370d0d", size = 30932, upload-time = "2025-12-06T13:23:43.348Z" }, + { url = "https://files.pythonhosted.org/packages/f7/79/b1b550ac6bff51a4880bf6e089008b2e1ca16f2c98db5e039a08ac3ad157/pybase64-1.4.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2a8204f1fdfec5aa4184249b51296c0de95445869920c88123978304aad42df1", size = 31394, upload-time = "2025-12-06T13:23:44.317Z" }, + { url = "https://files.pythonhosted.org/packages/82/70/b5d7c5932bf64ee1ec5da859fbac981930b6a55d432a603986c7f509c838/pybase64-1.4.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:874fc2a3777de6baf6aa921a7aa73b3be98295794bea31bd80568a963be30767", size = 38078, upload-time = "2025-12-06T13:23:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/e66fe373bce717c6858427670736d54297938dad61c5907517ab4106bd90/pybase64-1.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2dc64a94a9d936b8e3449c66afabbaa521d3cc1a563d6bbaaa6ffa4535222e4b", size = 38158, upload-time = "2025-12-06T13:23:46.872Z" }, + { url = "https://files.pythonhosted.org/packages/80/a9/b806ed1dcc7aed2ea3dd4952286319e6f3a8b48615c8118f453948e01999/pybase64-1.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e48f86de1c145116ccf369a6e11720ce696c2ec02d285f440dfb57ceaa0a6cb4", size = 31672, upload-time = "2025-12-06T13:23:47.88Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c9/24b3b905cf75e23a9a4deaf203b35ffcb9f473ac0e6d8257f91a05dfce62/pybase64-1.4.3-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:1d45c8fe8fe82b65c36b227bb4a2cf623d9ada16bed602ce2d3e18c35285b72a", size = 68244, upload-time = "2025-12-06T13:23:49.026Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cd/d15b0c3e25e5859fab0416dc5b96d34d6bd2603c1c96a07bb2202b68ab92/pybase64-1.4.3-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ad70c26ba091d8f5167e9d4e1e86a0483a5414805cdb598a813db635bd3be8b8", size = 71620, upload-time = "2025-12-06T13:23:50.081Z" }, + { url = "https://files.pythonhosted.org/packages/0d/31/4ca953cc3dcde2b3711d6bfd70a6f4ad2ca95a483c9698076ba605f1520f/pybase64-1.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e98310b7c43145221e7194ac9fa7fffc84763c87bfc5e2f59f9f92363475bdc1", size = 59930, upload-time = "2025-12-06T13:23:51.68Z" }, + { url = "https://files.pythonhosted.org/packages/60/55/e7f7bdcd0fd66e61dda08db158ffda5c89a306bbdaaf5a062fbe4e48f4a1/pybase64-1.4.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:398685a76034e91485a28aeebcb49e64cd663212fd697b2497ac6dfc1df5e671", size = 56425, upload-time = "2025-12-06T13:23:52.732Z" }, + { url = "https://files.pythonhosted.org/packages/cb/65/b592c7f921e51ca1aca3af5b0d201a98666d0a36b930ebb67e7c2ed27395/pybase64-1.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7e46400a6461187ccb52ed75b0045d937529e801a53a9cd770b350509f9e4d50", size = 59327, upload-time = "2025-12-06T13:23:53.856Z" }, + { url = "https://files.pythonhosted.org/packages/23/95/1613d2fb82dbb1548595ad4179f04e9a8451bfa18635efce18b631eabe3f/pybase64-1.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:1b62b9f2f291d94f5e0b76ab499790b7dcc78a009d4ceea0b0428770267484b6", size = 60294, upload-time = "2025-12-06T13:23:54.937Z" }, + { url = "https://files.pythonhosted.org/packages/9d/73/40431f37f7d1b3eab4673e7946ff1e8f5d6bd425ec257e834dae8a6fc7b0/pybase64-1.4.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:f30ceb5fa4327809dede614be586efcbc55404406d71e1f902a6fdcf322b93b2", size = 54858, upload-time = "2025-12-06T13:23:56.031Z" }, + { url = "https://files.pythonhosted.org/packages/a7/84/f6368bcaf9f743732e002a9858646fd7a54f428490d427dd6847c5cfe89e/pybase64-1.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0d5f18ed53dfa1d4cf8b39ee542fdda8e66d365940e11f1710989b3cf4a2ed66", size = 58629, upload-time = "2025-12-06T13:23:57.12Z" }, + { url = "https://files.pythonhosted.org/packages/43/75/359532f9adb49c6b546cafc65c46ed75e2ccc220d514ba81c686fbd83965/pybase64-1.4.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:119d31aa4b58b85a8ebd12b63c07681a138c08dfc2fe5383459d42238665d3eb", size = 52448, upload-time = "2025-12-06T13:23:58.298Z" }, + { url = "https://files.pythonhosted.org/packages/92/6c/ade2ba244c3f33ed920a7ed572ad772eb0b5f14480b72d629d0c9e739a40/pybase64-1.4.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3cf0218b0e2f7988cf7d738a73b6a1d14f3be6ce249d7c0f606e768366df2cce", size = 68841, upload-time = "2025-12-06T13:23:59.886Z" }, + { url = "https://files.pythonhosted.org/packages/a0/51/b345139cd236be382f2d4d4453c21ee6299e14d2f759b668e23080f8663f/pybase64-1.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:12f4ee5e988bc5c0c1106b0d8fc37fb0508f12dab76bac1b098cb500d148da9d", size = 57910, upload-time = "2025-12-06T13:24:00.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b8/9f84bdc4f1c4f0052489396403c04be2f9266a66b70c776001eaf0d78c1f/pybase64-1.4.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:937826bc7b6b95b594a45180e81dd4d99bd4dd4814a443170e399163f7ff3fb6", size = 54335, upload-time = "2025-12-06T13:24:02.046Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c7/be63b617d284de46578a366da77ede39c8f8e815ed0d82c7c2acca560fab/pybase64-1.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:88995d1460971ef80b13e3e007afbe4b27c62db0508bc7250a2ab0a0b4b91362", size = 56486, upload-time = "2025-12-06T13:24:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/5e/96/f252c8f9abd6ded3ef1ccd3cdbb8393a33798007f761b23df8de1a2480e6/pybase64-1.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:72326fe163385ed3e1e806dd579d47fde5d8a59e51297a60fc4e6cbc1b4fc4ed", size = 70978, upload-time = "2025-12-06T13:24:04.221Z" }, + { url = "https://files.pythonhosted.org/packages/af/51/0f5714af7aeef96e30f968e4371d75ad60558aaed3579d7c6c8f1c43c18a/pybase64-1.4.3-cp313-cp313-win32.whl", hash = "sha256:b1623730c7892cf5ed0d6355e375416be6ef8d53ab9b284f50890443175c0ac3", size = 33684, upload-time = "2025-12-06T13:24:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ad/0cea830a654eb08563fb8214150ef57546ece1cc421c09035f0e6b0b5ea9/pybase64-1.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:8369887590f1646a5182ca2fb29252509da7ae31d4923dbb55d3e09da8cc4749", size = 35832, upload-time = "2025-12-06T13:24:06.35Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/eec2a8214989c751bc7b4cad1860eb2c6abf466e76b77508c0f488c96a37/pybase64-1.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:860b86bca71e5f0237e2ab8b2d9c4c56681f3513b1bf3e2117290c1963488390", size = 31175, upload-time = "2025-12-06T13:24:07.419Z" }, + { url = "https://files.pythonhosted.org/packages/db/c9/e23463c1a2913686803ef76b1a5ae7e6fac868249a66e48253d17ad7232c/pybase64-1.4.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:eb51db4a9c93215135dccd1895dca078e8785c357fabd983c9f9a769f08989a9", size = 38497, upload-time = "2025-12-06T13:24:08.873Z" }, + { url = "https://files.pythonhosted.org/packages/71/83/343f446b4b7a7579bf6937d2d013d82f1a63057cf05558e391ab6039d7db/pybase64-1.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a03ef3f529d85fd46b89971dfb00c634d53598d20ad8908fb7482955c710329d", size = 32076, upload-time = "2025-12-06T13:24:09.975Z" }, + { url = "https://files.pythonhosted.org/packages/46/fc/cb64964c3b29b432f54d1bce5e7691d693e33bbf780555151969ffd95178/pybase64-1.4.3-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:2e745f2ce760c6cf04d8a72198ef892015ddb89f6ceba489e383518ecbdb13ab", size = 72317, upload-time = "2025-12-06T13:24:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b7/fab2240da6f4e1ad46f71fa56ec577613cf5df9dce2d5b4cfaa4edd0e365/pybase64-1.4.3-cp313-cp313t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fac217cd9de8581a854b0ac734c50fd1fa4b8d912396c1fc2fce7c230efe3a7", size = 75534, upload-time = "2025-12-06T13:24:12.433Z" }, + { url = "https://files.pythonhosted.org/packages/91/3b/3e2f2b6e68e3d83ddb9fa799f3548fb7449765daec9bbd005a9fbe296d7f/pybase64-1.4.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:da1ee8fa04b283873de2d6e8fa5653e827f55b86bdf1a929c5367aaeb8d26f8a", size = 65399, upload-time = "2025-12-06T13:24:13.928Z" }, + { url = "https://files.pythonhosted.org/packages/6b/08/476ac5914c3b32e0274a2524fc74f01cbf4f4af4513d054e41574eb018f6/pybase64-1.4.3-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:b0bf8e884ee822ca7b1448eeb97fa131628fe0ff42f60cae9962789bd562727f", size = 60487, upload-time = "2025-12-06T13:24:15.177Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b8/618a92915330cc9cba7880299b546a1d9dab1a21fd6c0292ee44a4fe608c/pybase64-1.4.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1bf749300382a6fd1f4f255b183146ef58f8e9cb2f44a077b3a9200dfb473a77", size = 63959, upload-time = "2025-12-06T13:24:16.854Z" }, + { url = "https://files.pythonhosted.org/packages/a5/52/af9d8d051652c3051862c442ec3861259c5cdb3fc69774bc701470bd2a59/pybase64-1.4.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:153a0e42329b92337664cfc356f2065248e6c9a1bd651bbcd6dcaf15145d3f06", size = 64874, upload-time = "2025-12-06T13:24:18.328Z" }, + { url = "https://files.pythonhosted.org/packages/e4/51/5381a7adf1f381bd184d33203692d3c57cf8ae9f250f380c3fecbdbe554b/pybase64-1.4.3-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:86ee56ac7f2184ca10217ed1c655c1a060273e233e692e9086da29d1ae1768db", size = 58572, upload-time = "2025-12-06T13:24:19.417Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f0/578ee4ffce5818017de4fdf544e066c225bc435e73eb4793cde28a689d0b/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0e71a4db76726bf830b47477e7d830a75c01b2e9b01842e787a0836b0ba741e3", size = 63636, upload-time = "2025-12-06T13:24:20.497Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ad/8ae94814bf20159ea06310b742433e53d5820aa564c9fdf65bf2d79f8799/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2ba7799ec88540acd9861b10551d24656ca3c2888ecf4dba2ee0a71544a8923f", size = 56193, upload-time = "2025-12-06T13:24:21.559Z" }, + { url = "https://files.pythonhosted.org/packages/d1/31/6438cfcc3d3f0fa84d229fa125c243d5094e72628e525dfefadf3bcc6761/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2860299e4c74315f5951f0cf3e72ba0f201c3356c8a68f95a3ab4e620baf44e9", size = 72655, upload-time = "2025-12-06T13:24:22.673Z" }, + { url = "https://files.pythonhosted.org/packages/a3/0d/2bbc9e9c3fc12ba8a6e261482f03a544aca524f92eae0b4908c0a10ba481/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:bb06015db9151f0c66c10aae8e3603adab6b6cd7d1f7335a858161d92fc29618", size = 62471, upload-time = "2025-12-06T13:24:23.8Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0b/34d491e7f49c1dbdb322ea8da6adecda7c7cd70b6644557c6e4ca5c6f7c7/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:242512a070817272865d37c8909059f43003b81da31f616bb0c391ceadffe067", size = 58119, upload-time = "2025-12-06T13:24:24.994Z" }, + { url = "https://files.pythonhosted.org/packages/ce/17/c21d0cde2a6c766923ae388fc1f78291e1564b0d38c814b5ea8a0e5e081c/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5d8277554a12d3e3eed6180ebda62786bf9fc8d7bb1ee00244258f4a87ca8d20", size = 60791, upload-time = "2025-12-06T13:24:26.046Z" }, + { url = "https://files.pythonhosted.org/packages/92/b2/eaa67038916a48de12b16f4c384bcc1b84b7ec731b23613cb05f27673294/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f40b7ddd698fc1e13a4b64fbe405e4e0e1279e8197e37050e24154655f5f7c4e", size = 74701, upload-time = "2025-12-06T13:24:27.466Z" }, + { url = "https://files.pythonhosted.org/packages/42/10/abb7757c330bb869ebb95dab0c57edf5961ffbd6c095c8209cbbf75d117d/pybase64-1.4.3-cp313-cp313t-win32.whl", hash = "sha256:46d75c9387f354c5172582a9eaae153b53a53afeb9c19fcf764ea7038be3bd8b", size = 33965, upload-time = "2025-12-06T13:24:28.548Z" }, + { url = "https://files.pythonhosted.org/packages/63/a0/2d4e5a59188e9e6aed0903d580541aaea72dcbbab7bf50fb8b83b490b6c3/pybase64-1.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:d7344625591d281bec54e85cbfdab9e970f6219cac1570f2aa140b8c942ccb81", size = 36207, upload-time = "2025-12-06T13:24:29.646Z" }, + { url = "https://files.pythonhosted.org/packages/1f/05/95b902e8f567b4d4b41df768ccc438af618f8d111e54deaf57d2df46bd76/pybase64-1.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:28a3c60c55138e0028313f2eccd321fec3c4a0be75e57a8d3eb883730b1b0880", size = 31505, upload-time = "2025-12-06T13:24:30.687Z" }, + { url = "https://files.pythonhosted.org/packages/e4/80/4bd3dff423e5a91f667ca41982dc0b79495b90ec0c0f5d59aca513e50f8c/pybase64-1.4.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:015bb586a1ea1467f69d57427abe587469392215f59db14f1f5c39b52fdafaf5", size = 33835, upload-time = "2025-12-06T13:24:31.767Z" }, + { url = "https://files.pythonhosted.org/packages/45/60/a94d94cc1e3057f602e0b483c9ebdaef40911d84a232647a2fe593ab77bb/pybase64-1.4.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:d101e3a516f837c3dcc0e5a0b7db09582ebf99ed670865223123fb2e5839c6c0", size = 40673, upload-time = "2025-12-06T13:24:32.82Z" }, + { url = "https://files.pythonhosted.org/packages/e3/71/cf62b261d431857e8e054537a5c3c24caafa331de30daede7b2c6c558501/pybase64-1.4.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8f183ac925a48046abe047360fe3a1b28327afb35309892132fe1915d62fb282", size = 30939, upload-time = "2025-12-06T13:24:34.001Z" }, + { url = "https://files.pythonhosted.org/packages/24/3e/d12f92a3c1f7c6ab5d53c155bff9f1084ba997a37a39a4f781ccba9455f3/pybase64-1.4.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30bf3558e24dcce4da5248dcf6d73792adfcf4f504246967e9db155be4c439ad", size = 31401, upload-time = "2025-12-06T13:24:35.11Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3d/9c27440031fea0d05146f8b70a460feb95d8b4e3d9ca8f45c972efb4c3d3/pybase64-1.4.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a674b419de318d2ce54387dd62646731efa32b4b590907800f0bd40675c1771d", size = 38075, upload-time = "2025-12-06T13:24:36.53Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d4/6c0e0cf0efd53c254173fbcd84a3d8fcbf5e0f66622473da425becec32a5/pybase64-1.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:720104fd7303d07bac302be0ff8f7f9f126f2f45c1edb4f48fdb0ff267e69fe1", size = 38257, upload-time = "2025-12-06T13:24:38.049Z" }, + { url = "https://files.pythonhosted.org/packages/50/eb/27cb0b610d5cd70f5ad0d66c14ad21c04b8db930f7139818e8fbdc14df4d/pybase64-1.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:83f1067f73fa5afbc3efc0565cecc6ed53260eccddef2ebe43a8ce2b99ea0e0a", size = 31685, upload-time = "2025-12-06T13:24:40.327Z" }, + { url = "https://files.pythonhosted.org/packages/db/26/b136a4b65e5c94ff06217f7726478df3f31ab1c777c2c02cf698e748183f/pybase64-1.4.3-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:b51204d349a4b208287a8aa5b5422be3baa88abf6cc8ff97ccbda34919bbc857", size = 68460, upload-time = "2025-12-06T13:24:41.735Z" }, + { url = "https://files.pythonhosted.org/packages/68/6d/84ce50e7ee1ae79984d689e05a9937b2460d4efa1e5b202b46762fb9036c/pybase64-1.4.3-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30f2fd53efecbdde4bdca73a872a68dcb0d1bf8a4560c70a3e7746df973e1ef3", size = 71688, upload-time = "2025-12-06T13:24:42.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/57/6743e420416c3ff1b004041c85eb0ebd9c50e9cf05624664bfa1dc8b5625/pybase64-1.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0932b0c5cfa617091fd74f17d24549ce5de3628791998c94ba57be808078eeaf", size = 60040, upload-time = "2025-12-06T13:24:44.37Z" }, + { url = "https://files.pythonhosted.org/packages/3b/68/733324e28068a89119af2921ce548e1c607cc5c17d354690fc51c302e326/pybase64-1.4.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:acb61f5ab72bec808eb0d4ce8b87ec9f38d7d750cb89b1371c35eb8052a29f11", size = 56478, upload-time = "2025-12-06T13:24:45.815Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9e/f3f4aa8cfe3357a3cdb0535b78eb032b671519d3ecc08c58c4c6b72b5a91/pybase64-1.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:2bc2d5bc15168f5c04c53bdfe5a1e543b2155f456ed1e16d7edce9ce73842021", size = 59463, upload-time = "2025-12-06T13:24:46.938Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d1/53286038e1f0df1cf58abcf4a4a91b0f74ab44539c2547b6c31001ddd054/pybase64-1.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:8a7bc3cd23880bdca59758bcdd6f4ef0674f2393782763910a7466fab35ccb98", size = 60360, upload-time = "2025-12-06T13:24:48.039Z" }, + { url = "https://files.pythonhosted.org/packages/00/9a/5cc6ce95db2383d27ff4d790b8f8b46704d360d701ab77c4f655bcfaa6a7/pybase64-1.4.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ad15acf618880d99792d71e3905b0e2508e6e331b76a1b34212fa0f11e01ad28", size = 54999, upload-time = "2025-12-06T13:24:49.547Z" }, + { url = "https://files.pythonhosted.org/packages/64/e7/c3c1d09c3d7ae79e3aa1358c6d912d6b85f29281e47aa94fc0122a415a2f/pybase64-1.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448158d417139cb4851200e5fee62677ae51f56a865d50cda9e0d61bda91b116", size = 58736, upload-time = "2025-12-06T13:24:50.641Z" }, + { url = "https://files.pythonhosted.org/packages/db/d5/0baa08e3d8119b15b588c39f0d39fd10472f0372e3c54ca44649cbefa256/pybase64-1.4.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9058c49b5a2f3e691b9db21d37eb349e62540f9f5fc4beabf8cbe3c732bead86", size = 52298, upload-time = "2025-12-06T13:24:51.791Z" }, + { url = "https://files.pythonhosted.org/packages/00/87/fc6f11474a1de7e27cd2acbb8d0d7508bda3efa73dfe91c63f968728b2a3/pybase64-1.4.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ce561724f6522907a66303aca27dce252d363fcd85884972d348f4403ba3011a", size = 69049, upload-time = "2025-12-06T13:24:53.253Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/7fb5566f669ac18b40aa5fc1c438e24df52b843c1bdc5da47d46d4c1c630/pybase64-1.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:63316560a94ac449fe86cb8b9e0a13714c659417e92e26a5cbf085cd0a0c838d", size = 57952, upload-time = "2025-12-06T13:24:54.342Z" }, + { url = "https://files.pythonhosted.org/packages/de/cc/ceb949232dbbd3ec4ee0190d1df4361296beceee9840390a63df8bc31784/pybase64-1.4.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7ecd796f2ac0be7b73e7e4e232b8c16422014de3295d43e71d2b19fd4a4f5368", size = 54484, upload-time = "2025-12-06T13:24:55.774Z" }, + { url = "https://files.pythonhosted.org/packages/a7/69/659f3c8e6a5d7b753b9c42a4bd9c42892a0f10044e9c7351a4148d413a33/pybase64-1.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d01e102a12fb2e1ed3dc11611c2818448626637857ec3994a9cf4809dfd23477", size = 56542, upload-time = "2025-12-06T13:24:57Z" }, + { url = "https://files.pythonhosted.org/packages/85/2c/29c9e6c9c82b72025f9676f9e82eb1fd2339ad038cbcbf8b9e2ac02798fc/pybase64-1.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ebff797a93c2345f22183f454fd8607a34d75eca5a3a4a969c1c75b304cee39d", size = 71045, upload-time = "2025-12-06T13:24:58.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/84/5a3dce8d7a0040a5c0c14f0fe1311cd8db872913fa04438071b26b0dac04/pybase64-1.4.3-cp314-cp314-win32.whl", hash = "sha256:28b2a1bb0828c0595dc1ea3336305cd97ff85b01c00d81cfce4f92a95fb88f56", size = 34200, upload-time = "2025-12-06T13:24:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/57/bc/ce7427c12384adee115b347b287f8f3cf65860b824d74fe2c43e37e81c1f/pybase64-1.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:33338d3888700ff68c3dedfcd49f99bfc3b887570206130926791e26b316b029", size = 36323, upload-time = "2025-12-06T13:25:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1b/2b8ffbe9a96eef7e3f6a5a7be75995eebfb6faaedc85b6da6b233e50c778/pybase64-1.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:62725669feb5acb186458da2f9353e88ae28ef66bb9c4c8d1568b12a790dfa94", size = 31584, upload-time = "2025-12-06T13:25:02.801Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/6824c2e6fb45b8fa4e7d92e3c6805432d5edc7b855e3e8e1eedaaf6efb7c/pybase64-1.4.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:153fe29be038948d9372c3e77ae7d1cab44e4ba7d9aaf6f064dbeea36e45b092", size = 38601, upload-time = "2025-12-06T13:25:04.222Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e5/10d2b3a4ad3a4850be2704a2f70cd9c0cf55725c8885679872d3bc846c67/pybase64-1.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f7fe3decaa7c4a9e162327ec7bd81ce183d2b16f23c6d53b606649c6e0203e9e", size = 32078, upload-time = "2025-12-06T13:25:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/43/04/8b15c34d3c2282f1c1b0850f1113a249401b618a382646a895170bc9b5e7/pybase64-1.4.3-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:a5ae04ea114c86eb1da1f6e18d75f19e3b5ae39cb1d8d3cd87c29751a6a22780", size = 72474, upload-time = "2025-12-06T13:25:06.434Z" }, + { url = "https://files.pythonhosted.org/packages/42/00/f34b4d11278f8fdc68bc38f694a91492aa318f7c6f1bd7396197ac0f8b12/pybase64-1.4.3-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1755b3dce3a2a5c7d17ff6d4115e8bee4a1d5aeae74469db02e47c8f477147da", size = 75706, upload-time = "2025-12-06T13:25:07.636Z" }, + { url = "https://files.pythonhosted.org/packages/bb/5d/71747d4ad7fe16df4c4c852bdbdeb1f2cf35677b48d7c34d3011a7a6ad3a/pybase64-1.4.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb852f900e27ffc4ec1896817535a0fa19610ef8875a096b59f21d0aa42ff172", size = 65589, upload-time = "2025-12-06T13:25:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/49/b1/d1e82bd58805bb5a3a662864800bab83a83a36ba56e7e3b1706c708002a5/pybase64-1.4.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:9cf21ea8c70c61eddab3421fbfce061fac4f2fb21f7031383005a1efdb13d0b9", size = 60670, upload-time = "2025-12-06T13:25:10.04Z" }, + { url = "https://files.pythonhosted.org/packages/15/67/16c609b7a13d1d9fc87eca12ba2dce5e67f949eeaab61a41bddff843cbb0/pybase64-1.4.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:afff11b331fdc27692fc75e85ae083340a35105cea1a3c4552139e2f0e0d174f", size = 64194, upload-time = "2025-12-06T13:25:11.48Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/37bc724e42960f0106c2d33dc957dcec8f760c91a908cc6c0df7718bc1a8/pybase64-1.4.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9a5143df542c1ce5c1f423874b948c4d689b3f05ec571f8792286197a39ba02", size = 64984, upload-time = "2025-12-06T13:25:12.645Z" }, + { url = "https://files.pythonhosted.org/packages/6e/66/b2b962a6a480dd5dae3029becf03ea1a650d326e39bf1c44ea3db78bb010/pybase64-1.4.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:d62e9861019ad63624b4a7914dff155af1cc5d6d79df3be14edcaedb5fdad6f9", size = 58750, upload-time = "2025-12-06T13:25:13.848Z" }, + { url = "https://files.pythonhosted.org/packages/2b/15/9b6d711035e29b18b2e1c03d47f41396d803d06ef15b6c97f45b75f73f04/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:84cfd4d92668ef5766cc42a9c9474b88960ac2b860767e6e7be255c6fddbd34a", size = 63816, upload-time = "2025-12-06T13:25:15.356Z" }, + { url = "https://files.pythonhosted.org/packages/b4/21/e2901381ed0df62e2308380f30d9c4d87d6b74e33a84faed3478d33a7197/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:60fc025437f9a7c2cc45e0c19ed68ed08ba672be2c5575fd9d98bdd8f01dd61f", size = 56348, upload-time = "2025-12-06T13:25:16.559Z" }, + { url = "https://files.pythonhosted.org/packages/c4/16/3d788388a178a0407aa814b976fe61bfa4af6760d9aac566e59da6e4a8b4/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:edc8446196f04b71d3af76c0bd1fe0a45066ac5bffecca88adb9626ee28c266f", size = 72842, upload-time = "2025-12-06T13:25:18.055Z" }, + { url = "https://files.pythonhosted.org/packages/a6/63/c15b1f8bd47ea48a5a2d52a4ec61f037062932ea6434ab916107b58e861e/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e99f6fa6509c037794da57f906ade271f52276c956d00f748e5b118462021d48", size = 62651, upload-time = "2025-12-06T13:25:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b8/f544a2e37c778d59208966d4ef19742a0be37c12fc8149ff34483c176616/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d94020ef09f624d841aa9a3a6029df8cf65d60d7a6d5c8687579fa68bd679b65", size = 58295, upload-time = "2025-12-06T13:25:20.822Z" }, + { url = "https://files.pythonhosted.org/packages/03/99/1fae8a3b7ac181e36f6e7864a62d42d5b1f4fa7edf408c6711e28fba6b4d/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f64ce70d89942a23602dee910dec9b48e5edf94351e1b378186b74fcc00d7f66", size = 60960, upload-time = "2025-12-06T13:25:22.099Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9e/cd4c727742345ad8384569a4466f1a1428f4e5cc94d9c2ab2f53d30be3fe/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8ea99f56e45c469818b9781903be86ba4153769f007ba0655fa3b46dc332803d", size = 74863, upload-time = "2025-12-06T13:25:23.442Z" }, + { url = "https://files.pythonhosted.org/packages/28/86/a236ecfc5b494e1e922da149689f690abc84248c7c1358f5605b8c9fdd60/pybase64-1.4.3-cp314-cp314t-win32.whl", hash = "sha256:343b1901103cc72362fd1f842524e3bb24978e31aea7ff11e033af7f373f66ab", size = 34513, upload-time = "2025-12-06T13:25:24.592Z" }, + { url = "https://files.pythonhosted.org/packages/56/ce/ca8675f8d1352e245eb012bfc75429ee9cf1f21c3256b98d9a329d44bf0f/pybase64-1.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:57aff6f7f9dea6705afac9d706432049642de5b01080d3718acc23af87c5af76", size = 36702, upload-time = "2025-12-06T13:25:25.72Z" }, + { url = "https://files.pythonhosted.org/packages/3b/30/4a675864877397179b09b720ee5fcb1cf772cf7bebc831989aff0a5f79c1/pybase64-1.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:e906aa08d4331e799400829e0f5e4177e76a3281e8a4bc82ba114c6b30e405c9", size = 31904, upload-time = "2025-12-06T13:25:26.826Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7c/545fd4935a0e1ddd7147f557bf8157c73eecec9cffd523382fa7af2557de/pybase64-1.4.3-graalpy311-graalpy242_311_native-macosx_10_9_x86_64.whl", hash = "sha256:d27c1dfdb0c59a5e758e7a98bd78eaca5983c22f4a811a36f4f980d245df4611", size = 38393, upload-time = "2025-12-06T13:26:19.535Z" }, + { url = "https://files.pythonhosted.org/packages/c3/ca/ae7a96be9ddc96030d4e9dffc43635d4e136b12058b387fd47eb8301b60f/pybase64-1.4.3-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0f1a0c51d6f159511e3431b73c25db31095ee36c394e26a4349e067c62f434e5", size = 32109, upload-time = "2025-12-06T13:26:20.72Z" }, + { url = "https://files.pythonhosted.org/packages/bf/44/d4b7adc7bf4fd5b52d8d099121760c450a52c390223806b873f0b6a2d551/pybase64-1.4.3-graalpy311-graalpy242_311_native-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a492518f3078a4e3faaef310697d21df9c6bc71908cebc8c2f6fbfa16d7d6b1f", size = 43227, upload-time = "2025-12-06T13:26:21.845Z" }, + { url = "https://files.pythonhosted.org/packages/08/86/2ba2d8734ef7939debeb52cf9952e457ba7aa226cae5c0e6dd631f9b851f/pybase64-1.4.3-graalpy311-graalpy242_311_native-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae1a0f47784fd16df90d8acc32011c8d5fcdd9ab392c9ec49543e5f6a9c43a4", size = 35804, upload-time = "2025-12-06T13:26:23.149Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5b/19c725dc3aaa6281f2ce3ea4c1628d154a40dd99657d1381995f8096768b/pybase64-1.4.3-graalpy311-graalpy242_311_native-win_amd64.whl", hash = "sha256:03cea70676ffbd39a1ab7930a2d24c625b416cacc9d401599b1d29415a43ab6a", size = 35880, upload-time = "2025-12-06T13:26:24.663Z" }, + { url = "https://files.pythonhosted.org/packages/17/45/92322aec1b6979e789b5710f73c59f2172bc37c8ce835305434796824b7b/pybase64-1.4.3-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:2baaa092f3475f3a9c87ac5198023918ea8b6c125f4c930752ab2cbe3cd1d520", size = 38746, upload-time = "2025-12-06T13:26:25.869Z" }, + { url = "https://files.pythonhosted.org/packages/11/94/f1a07402870388fdfc2ecec0c718111189732f7d0f2d7fe1386e19e8fad0/pybase64-1.4.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:cde13c0764b1af07a631729f26df019070dad759981d6975527b7e8ecb465b6c", size = 32573, upload-time = "2025-12-06T13:26:27.792Z" }, + { url = "https://files.pythonhosted.org/packages/fa/8f/43c3bb11ca9bacf81cb0b7a71500bb65b2eda6d5fe07433c09b543de97f3/pybase64-1.4.3-graalpy312-graalpy250_312_native-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5c29a582b0ea3936d02bd6fe9bf674ab6059e6e45ab71c78404ab2c913224414", size = 43461, upload-time = "2025-12-06T13:26:28.906Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4c/2a5258329200be57497d3972b5308558c6de42e3749c6cc2aa1cbe34b25a/pybase64-1.4.3-graalpy312-graalpy250_312_native-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6b664758c804fa919b4f1257aa8cf68e95db76fc331de5f70bfc3a34655afe1", size = 36058, upload-time = "2025-12-06T13:26:30.092Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6d/41faa414cde66ec023b0ca8402a8f11cb61731c3dc27c082909cbbd1f929/pybase64-1.4.3-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:f7537fa22ae56a0bf51e4b0ffc075926ad91c618e1416330939f7ef366b58e3b", size = 36231, upload-time = "2025-12-06T13:26:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/160dded493c00d3376d4ad0f38a2119c5345de4a6693419ad39c3565959b/pybase64-1.4.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:277de6e03cc9090fb359365c686a2a3036d23aee6cd20d45d22b8c89d1247f17", size = 37939, upload-time = "2025-12-06T13:26:41.014Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b8/a0f10be8d648d6f8f26e560d6e6955efa7df0ff1e009155717454d76f601/pybase64-1.4.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ab1dd8b1ed2d1d750260ed58ab40defaa5ba83f76a30e18b9ebd5646f6247ae5", size = 31466, upload-time = "2025-12-06T13:26:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/22/832a2f9e76cdf39b52e01e40d8feeb6a04cf105494f2c3e3126d0149717f/pybase64-1.4.3-pp311-pypy311_pp73-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:bd4d2293de9fd212e294c136cec85892460b17d24e8c18a6ba18750928037750", size = 40681, upload-time = "2025-12-06T13:26:43.782Z" }, + { url = "https://files.pythonhosted.org/packages/12/d7/6610f34a8972415fab3bb4704c174a1cc477bffbc3c36e526428d0f3957d/pybase64-1.4.3-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af6d0d3a691911cc4c9a625f3ddcd3af720738c21be3d5c72de05629139d393", size = 41294, upload-time = "2025-12-06T13:26:44.936Z" }, + { url = "https://files.pythonhosted.org/packages/64/25/ed24400948a6c974ab1374a233cb7e8af0a5373cea0dd8a944627d17c34a/pybase64-1.4.3-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5cfc8c49a28322d82242088378f8542ce97459866ba73150b062a7073e82629d", size = 35447, upload-time = "2025-12-06T13:26:46.098Z" }, + { url = "https://files.pythonhosted.org/packages/ee/2b/e18ee7c5ee508a82897f021c1981533eca2940b5f072fc6ed0906c03a7a7/pybase64-1.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:debf737e09b8bf832ba86f5ecc3d3dbd0e3021d6cd86ba4abe962d6a5a77adb3", size = 36134, upload-time = "2025-12-06T13:26:47.35Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-ai-slim" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "genai-prices" }, + { name = "griffelib" }, + { name = "httpx" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "pydantic-graph" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/53/7bfa293ed6d2eb3c400c14d442867bc22cba3cf5f1551b829f54f18ff693/pydantic_ai_slim-2.20.0.tar.gz", hash = "sha256:d70eea3c82a5976d992542ebe2bdbd1af63cd41d9d896b0d59f061fadf42cd2b", size = 912253, upload-time = "2026-07-29T03:37:50.063Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/3e/d5377980b29e6b18ee334f31cdbdb55dbfe437358251406489e683bde631/pydantic_ai_slim-2.20.0-py3-none-any.whl", hash = "sha256:c61f1e9a19cb85028638e4909f64b8331ed754e918d8bf892434fd92b1f93f59", size = 1101608, upload-time = "2026-07-29T03:37:41.463Z" }, +] + +[package.optional-dependencies] +openai = [ + { name = "openai" }, + { name = "tiktoken" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydantic-graph" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "logfire-api" }, + { name = "pydantic" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/b2/63e33bec7f5d95a40af8503c83d66cda151315a16d334975209875766d4d/pydantic_graph-2.20.0.tar.gz", hash = "sha256:d009dc97689ede3d20e2d99384f93b867ccd5b408dcc394551b8dd26cfcde80f", size = 44150, upload-time = "2026-07-29T03:37:52.248Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/24/1dd8d79a01ec0278ce9dc0933fe3a5c5bb8cde34e717294b9b028c539f6e/pydantic_graph-2.20.0-py3-none-any.whl", hash = "sha256:ceee415418a8cc6f8ef6968c8343e6be00c1971157dc552fc1fbb24790b5250a", size = 51658, upload-time = "2026-07-29T03:37:45.131Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + +[[package]] +name = "pydeck" +version = "0.9.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/c9/f71032fca47ecc09d30904d9a610234b07139f89eabc2f054b141edcc30f/pydeck-0.9.3.tar.gz", hash = "sha256:695775cbfe51f5fdffbd9735ba469987fdc5efc96bc40a0ee4808170509c78b2", size = 5900912, upload-time = "2026-07-02T23:27:08.704Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/34/3998411437aff304a9ed4fa37a6fe1ef3132bcd2b5eac59851b80c86123c/pydeck-0.9.3-py2.py3-none-any.whl", hash = "sha256:d8a47c11c81fb12d51b1feb42427ff4f0e13cb599e48931021b2cba98b6849a6", size = 11428091, upload-time = "2026-07-02T23:27:06.399Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pypika" +version = "0.51.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/78/cbaebba88e05e2dcda13ca203131b38d3640219f20ebb49676d26714861b/pypika-0.51.1.tar.gz", hash = "sha256:c30c7c1048fbf056fd3920c5a2b88b0c29dd190a9b2bee971fd17e4abe4d0ebe", size = 80919, upload-time = "2026-02-04T11:27:48.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/83/c77dfeed04022e8930b08eedca2b6e5efed256ab3321396fde90066efb65/pypika-0.51.1-py2.py3-none-any.whl", hash = "sha256:77985b4d7ce71b9905255bf12468cf598349e98837c037541cfc240e528aec46", size = 60585, upload-time = "2026-02-04T11:27:46.251Z" }, +] + +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "regex" +version = "2026.7.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/98/04b13f1ddfb63158025291c02e03eb42fbb7acb51d091d541050eb4e35e8/regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5", size = 416440, upload-time = "2026-07-19T00:19:48.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/e5/cef4de2bac939280b68d32adc659478845238a8274f2f79c465063f590ad/regex-2026.7.19-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ac777001cdfc28b72477d93c8564bb7583081ea8fb45cdca3d568e0a4f87183c", size = 494012, upload-time = "2026-07-19T00:16:39.927Z" }, + { url = "https://files.pythonhosted.org/packages/ff/87/e86f51eb117457bb7803132ffe5cb6e2841e2b5bea4cc85d397f3c6e257d/regex-2026.7.19-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:59787bd5f8c70aa339084e961d2996b53fbdeab4d5393bba5c1fe1fc32e02bae", size = 295281, upload-time = "2026-07-19T00:16:41.433Z" }, + { url = "https://files.pythonhosted.org/packages/41/2e/2360c41d8080a3d9ec7e5c90fad6eab3b50192869d10e9a5609e48c8177b/regex-2026.7.19-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:90c633e7e8d6bf4e992b8b36ce69e018f834b641dd6de8cea6d78c06ffa119c5", size = 290615, upload-time = "2026-07-19T00:16:43.058Z" }, + { url = "https://files.pythonhosted.org/packages/cf/69/b65ba4344efbc771b28fe5dde84cbbb6c8f9551165952fe78def5b9dde6a/regex-2026.7.19-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87ccab0db8d5f4fbb0272642113c1adb2ffc698c16d3a0944580222331fa7a20", size = 791804, upload-time = "2026-07-19T00:16:44.662Z" }, + { url = "https://files.pythonhosted.org/packages/81/b6/a40dfa0dc6224b36f620c00296eacc830489cbf8c2837b6750dfe6170375/regex-2026.7.19-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e50d748a32da622f256e8d505867f5d3c43a837c6a9f0efb149655fadd1042a", size = 861723, upload-time = "2026-07-19T00:16:46.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/02/735991dee71abd83196a7962f7ed8bf5aa05720ff06e2d3ff896a85e2bbb/regex-2026.7.19-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf1516fe58fc104f39b2d1dbe2d5e27d0cd45c4be2e42ba6ee0cc763701ec3c7", size = 905932, upload-time = "2026-07-19T00:16:47.956Z" }, + { url = "https://files.pythonhosted.org/packages/45/6c/e7098d8b846ccdbf431d8c081b61e496526a27a28094ed09e0dce21b3f54/regex-2026.7.19-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09f3e5287f94f17b709dc9a9e70865855feee835c861613be144218ce4ca82cc", size = 801407, upload-time = "2026-07-19T00:16:49.43Z" }, + { url = "https://files.pythonhosted.org/packages/8a/18/34b69274e2649bcc7d9b089c2b2983fb2632d8ecf667e359593be9072e79/regex-2026.7.19-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6383cd2ed53a646c659ba1fe65727db76437fdaa069e697a0b44a51d5843d864", size = 774448, upload-time = "2026-07-19T00:16:51.352Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e6/0a72247d025585fd3800b98e040b84d562a88af6303347100484849f4f01/regex-2026.7.19-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:09d3007fc76249a83cdd33de160d50e6cb77f54e09d8fa9e7148e10607ce24af", size = 783297, upload-time = "2026-07-19T00:16:53.071Z" }, + { url = "https://files.pythonhosted.org/packages/b1/aa/c4f65ae7dd02a36b323a70c4cff326e1f3442361aaebc9311100a130d54f/regex-2026.7.19-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6f8c6e7a1cfa3dc9d0ee2de0e65e834537fa29992cc3976ffec914afc35c5dd5", size = 854736, upload-time = "2026-07-19T00:16:54.607Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/668082bcc817b9e694189b84997aeba7385b7779faa6711788679c482e35/regex-2026.7.19-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b2ea4a3e8357be8849e833beeae757ac3c7a6b3fc055c03c808a53c91ad30d82", size = 763298, upload-time = "2026-07-19T00:16:56.289Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fb/2d07ad555e7af88aa5f867fdafa47a8d945ee237c20af3ebceb46a820835/regex-2026.7.19-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:80115dd39481fd3a4b4080220799dbcacb921a844de4b827264ececacbe17c78", size = 844430, upload-time = "2026-07-19T00:16:57.933Z" }, + { url = "https://files.pythonhosted.org/packages/51/15/c82a471fe3dce56f03745635b43aa456c40dc0db089e07ef148b331507d1/regex-2026.7.19-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6ce43a0269d68cee79a7d1ade7def53c20f8f2a047b92d7b5d5bcc73ae88327", size = 789683, upload-time = "2026-07-19T00:16:59.583Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f4/7532a2c59d56f5398902c20de60f0c9a5d1cd364e42a051b48e1b210be7b/regex-2026.7.19-cp311-cp311-win32.whl", hash = "sha256:9be2a6647740dd3cca6acb24e87f03d7632cd280dbce9bbe40c26353a215a45d", size = 266778, upload-time = "2026-07-19T00:17:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/83/2b/cf1bc631db154eb95520d9d5dbc2371ff77a0f014bbf7d748fed8496aa63/regex-2026.7.19-cp311-cp311-win_amd64.whl", hash = "sha256:8d3469c91dd92ee41b7c95280edbd975ef1ba9195086686623a1c6e8935ce965", size = 277983, upload-time = "2026-07-19T00:17:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/8d/bd/56ceaf170e875d5a6761bf2bfd0d040f1cacc896850d5e40cb29b11bbd06/regex-2026.7.19-cp311-cp311-win_arm64.whl", hash = "sha256:36aacfb15faaff3ced55afbf35ec72f50d4aee22082c4f7fe0573a33e2fca92e", size = 276961, upload-time = "2026-07-19T00:17:04.135Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/d11d7e501ac8fd7d617684423ebb9561e0b998481c1e4cbc0cb212c5d74a/regex-2026.7.19-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2cc3460cedf7579948486eab03bc9ad7089df4d7281c0f47f4afe03e8d13f02d", size = 496778, upload-time = "2026-07-19T00:17:05.677Z" }, + { url = "https://files.pythonhosted.org/packages/3f/a9/a5ab6f312f24318019170dc485d5421fe4f89e43a98640da50d95a8a7041/regex-2026.7.19-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0e9554c8785eac5cffe6300f69a91f58ba72bc88a5f8d661235ad7c6aa5b8ccd", size = 297122, upload-time = "2026-07-19T00:17:07.59Z" }, + { url = "https://files.pythonhosted.org/packages/b3/63/4cab4d7f2d384a144d420b763d97674cb70619c878ea6fcd7640d0e62143/regex-2026.7.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d7da47a0f248977f08e2cb659ff3c17ddc13a4d39b3a7baa0a81bf5b415430f6", size = 292009, upload-time = "2026-07-19T00:17:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/22/85/102a81b218298957d4ea7d2f084fae537a71add9d6ff93c8e67284c5f45e/regex-2026.7.19-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93db40c8de0815baab96a06e08a984bac71f989d13bab789e382158c5d426797", size = 796708, upload-time = "2026-07-19T00:17:11.542Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/dc136af5629938a037cd2b304c12240e132ec92f38be8ff9cc89af2a1f2d/regex-2026.7.19-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:66bd62c59a5427746e8c44becae1d9b99d22fb13f30f492083dfb9ad7c45cc18", size = 865651, upload-time = "2026-07-19T00:17:13.312Z" }, + { url = "https://files.pythonhosted.org/packages/e0/75/67402ae3cd9c8c988a4c805d15ee3eef015e7ca4cb112cf3e640fc1f4153/regex-2026.7.19-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1649eb39fcc9ea80c4d2f110fde2b8ab2aef3877b98f02ab9b14e961f418c511", size = 911756, upload-time = "2026-07-19T00:17:15.015Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8e/096d00c7c480ef2ff4265349b14e2261d4ab787ba1f74e2e80d1c58079c3/regex-2026.7.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9dce8ec9695f531a1b8a6f314fd4b393adcccf2ea861db480cdf97a301d01a68", size = 801798, upload-time = "2026-07-19T00:17:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f0/41/e7ecac6edb5722417f85cc67eaf386322fbe8acf6918ec2fdc37c20dd9d0/regex-2026.7.19-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3080a7fd38ef049bd489e01c970c97dd84ff446a885b0f1f6b26d9b1ad13ce11", size = 776933, upload-time = "2026-07-19T00:17:19.347Z" }, + { url = "https://files.pythonhosted.org/packages/6f/69/03c9b3f058d66403e0ca2c938696e81d51cd4c6d47ec5265f02f96948d9a/regex-2026.7.19-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d793a7988e04fcb1e2e135567443d82173225d657419ec09414a9b5a145b986", size = 784338, upload-time = "2026-07-19T00:17:21.057Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f7/b38ab3d43f284afbb618fcd15d0e77eb786ae461ce1f6bc7494619ddc0f2/regex-2026.7.19-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e8b0abe7d870f53ca5143895fef7d1041a0c831a140d3dc2c760dd7ba25d4a8b", size = 860452, upload-time = "2026-07-19T00:17:23.119Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/ff60ef0571121714f3cf9920bc183071e384a10b556d042e0fdb06cc07a5/regex-2026.7.19-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4e5413bd5f13d3a4e3539ca98f70f75e7fca92518dd7f117f030ebedd10b60cb", size = 765958, upload-time = "2026-07-19T00:17:24.81Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0f/bd34021162c0ab47f9a315bd56cd5642e920c8e5668a75ef6c6a6fca590d/regex-2026.7.19-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:73b133a9e6fb512858e7f065e96f1180aa46646bc74a83aea62f1d314f3dd035", size = 851765, upload-time = "2026-07-19T00:17:26.993Z" }, + { url = "https://files.pythonhosted.org/packages/2a/20/a2ca43edade0595cccfdc98636739f536d9e26898e7dbddc2b9e98898953/regex-2026.7.19-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dbe6493fbd27321b1d1f2dd4f5c7e5bd4d8b1d7cab7f32fd67db3d0b2ed8248a", size = 789714, upload-time = "2026-07-19T00:17:28.699Z" }, + { url = "https://files.pythonhosted.org/packages/5d/47/e02db4015d424fc83c00ea0ac8c5e5ec14397943de9abf909d5ce3a25931/regex-2026.7.19-cp312-cp312-win32.whl", hash = "sha256:ddd67571c10869f65a5d7dde536d1e066e306cc90de57d7de4d5f34802428bb5", size = 267157, upload-time = "2026-07-19T00:17:31.051Z" }, + { url = "https://files.pythonhosted.org/packages/08/8e/c780c131f79b42ed22d1bd7da4096c2c35f813e835acd02ef0f018bd892c/regex-2026.7.19-cp312-cp312-win_amd64.whl", hash = "sha256:e30d40268a28d54ce0437031750497004c22602b8e3ab891f759b795a003b312", size = 277777, upload-time = "2026-07-19T00:17:32.848Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4c/e4d7e086449bdf379d89774bf1f89dc4a41943f3c5a6125a03905b34b5fb/regex-2026.7.19-cp312-cp312-win_arm64.whl", hash = "sha256:de9208bb427130c82a5dbfd104f92c8876fc9559278c880b3002755bbbe9c83d", size = 277136, upload-time = "2026-07-19T00:17:34.803Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3d/84165e4299ff76f3a40fe1f2abf939e976f693383a08d2beea6af62bd2c1/regex-2026.7.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f035d9dc1d25eff9d361456572231c7d27b5ccd473ca7dc0adfce732bd006d40", size = 496552, upload-time = "2026-07-19T00:17:36.808Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/a65293e6e4cf28eb7ee1be5335a5386c40d6742e9f47fafc8fec785e16c7/regex-2026.7.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c42572142ed0b9d5d261ba727157c426510da78e20828b66bbb855098b8a4e38", size = 296983, upload-time = "2026-07-19T00:17:38.816Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/2d0564e93d87bc48618360ddca232a2ca612bbdf53ce8465d45ca5ce14ee/regex-2026.7.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40b34dd88658e4fedd2fddbf0275ac970d00614b731357f425722a3ed1983d11", size = 291832, upload-time = "2026-07-19T00:17:40.726Z" }, + { url = "https://files.pythonhosted.org/packages/07/cd/42dfbabff3dfc9603c501c0e2e2c5adbb09d127b267bf5348de0af338c15/regex-2026.7.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c41c63992bf1874cebb6e7f56fd7d3c007924659a604ae3d90e427d40d4fd13", size = 796775, upload-time = "2026-07-19T00:17:42.382Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/f6a4839f2b934e3eed5973fd07f5929ee97d4c98939fb275ea23c274ee16/regex-2026.7.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d3372064506b94dd2c67c845f2db8062e9e9ba84d04e33cb96d7d33c11fe1ae", size = 865687, upload-time = "2026-07-19T00:17:44.185Z" }, + { url = "https://files.pythonhosted.org/packages/14/b0/b47d6c36049bc59806a50bd4c86ced70bbe058d787f80281b1d7a9b0e024/regex-2026.7.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fce7760bf283405b2c7999cab3da4e72f7deca6396013115e3f7a955db9760da", size = 911962, upload-time = "2026-07-19T00:17:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/2a/be/ff61f28f9273658cfe23acbbac5217221f6519960ed401e61dfdab12bc35/regex-2026.7.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0d702548d89d572b2929879bc883bb7a4c4709efafe4512cadee56c55c9bd15", size = 801817, upload-time = "2026-07-19T00:17:48.25Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bb/8b4f7f26b333f9f79e1b453613c39bb4776f51d38ae66dd0ba31d6b354ca/regex-2026.7.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d446c6ac40bb6e05025ccee55b84d80fe9bf8e93010ffc4bb9484f13d498835f", size = 776908, upload-time = "2026-07-19T00:17:50.183Z" }, + { url = "https://files.pythonhosted.org/packages/09/13/610110fc5921d380516d03c26b652555f08aa0d23ea78a771231873c3638/regex-2026.7.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c3501bfa814ab07b5580741f9bf78dfdfe146a04057f82df9e2402d2a975939", size = 784426, upload-time = "2026-07-19T00:17:52.454Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f5/1ef9e2a83a5947c57ebff0b377cb5727c3d5ec1992317a320d035cd0dbb6/regex-2026.7.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c4585c3e64b4f9e583b4d2683f18f5d5d872b3d71dcf24594b74ecc23602fa96", size = 860600, upload-time = "2026-07-19T00:17:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/a0/02/073af33a3ec149241d11c80acea91e722aa0adbf05addd50f251c4fe89c3/regex-2026.7.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:571fde9741eb0ccde23dd4e0c1d50fbae910e901fa7e629faf39b2dda740d220", size = 765950, upload-time = "2026-07-19T00:17:56.041Z" }, + { url = "https://files.pythonhosted.org/packages/81/a9/d1e9f819dc394a568ef370cd56cf25394e957a2235f8370f23b576e5a475/regex-2026.7.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:15b364b9b98d6d2fe1a85034c23a3180ff913f46caddc3895f6fd65186255ccc", size = 851794, upload-time = "2026-07-19T00:17:57.897Z" }, + { url = "https://files.pythonhosted.org/packages/03/3a/8ae83eda7579feacdf984e71fb9e70635fb6f832eeddca58427ec4fca926/regex-2026.7.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffd8893ccc1c2fce6e0d6ca402d716fe1b29db70c7132609a05955e31b2aa8f2", size = 789845, upload-time = "2026-07-19T00:17:59.97Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/c195cbfe5a75fdec64d8f6554fd15237b837919d2c61bdc141d7c807b08b/regex-2026.7.19-cp313-cp313-win32.whl", hash = "sha256:f0fa4fa9c3632d708742baf2282f2055c11d888a790362670a403cbf48a2c404", size = 267135, upload-time = "2026-07-19T00:18:01.958Z" }, + { url = "https://files.pythonhosted.org/packages/b2/80/a11de8404b7272b70acb45c1c05987cce60b45d5693da2e176f0e390d564/regex-2026.7.19-cp313-cp313-win_amd64.whl", hash = "sha256:d51ffd3427640fa2da6ade574ceba932f210ad095f65fcc450a2b0a0d454868e", size = 277747, upload-time = "2026-07-19T00:18:04.121Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/0f5c8eff1b4f1f3d83276d365fccecf666afcc7d947420943bf394d07adb/regex-2026.7.19-cp313-cp313-win_arm64.whl", hash = "sha256:c670fe7be5b6020b76bc6e8d2196074657e1327595bca93a389e1a76ab130ad8", size = 277129, upload-time = "2026-07-19T00:18:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4c/44b74742052cedda40f9ae469532a037112f7311a36669a891fba8984bb0/regex-2026.7.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db47b561c9afd884baa1f96f797c9ca369872c4b65912bc691cfa99e68340af2", size = 501134, upload-time = "2026-07-19T00:18:07.567Z" }, + { url = "https://files.pythonhosted.org/packages/f0/45/bbd038b5e39ee5613a5a689290145b40058cc152c41de9cc23639d2b9734/regex-2026.7.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:65dcd28d3eba2ab7c2fd906485cc301392b47cc2234790d27d4e4814e02cdfda", size = 299418, upload-time = "2026-07-19T00:18:09.38Z" }, + { url = "https://files.pythonhosted.org/packages/65/38/c5bde94b4cedfd5850d64c3f08222d8e1600e84f6ee71d9b44b4b8163f74/regex-2026.7.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f2e7f8e2ab6c2922be02c7ec45185aa5bd771e2e57b95455ee343a44d8130dff", size = 294486, upload-time = "2026-07-19T00:18:11.188Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6a/2f5e107cb26c960b781967178899daf2787a7ab151844ed3c01d6fc95474/regex-2026.7.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe31f28c94402043161876a258a9c6f757cb485905c7614ce8d6cd40e6b7bdc1", size = 811643, upload-time = "2026-07-19T00:18:12.975Z" }, + { url = "https://files.pythonhosted.org/packages/37/d4/a2f963406d7d73a62eed84ba05a258afb6cad1b21aa4517443ce40506b78/regex-2026.7.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8f6fa298bb4f7f58a33334406218ba74716e68feddf5e4e54cd5d8082705abf", size = 871081, upload-time = "2026-07-19T00:18:14.733Z" }, + { url = "https://files.pythonhosted.org/packages/45/a3/44be546340bedb15f13063f5e7fe16793ea4d9ea2e805d09bd174ac27724/regex-2026.7.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cc1b2440423a851fad781309dd87843868f4f66a6bcd1ddb9225cf4ec2c84732", size = 917372, upload-time = "2026-07-19T00:18:16.724Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f6/e0870b0fd2a40dba0074e4b76e514b21313d37946c9248453e34ec43923e/regex-2026.7.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ac59a0900474a52b7c04af8196affc22bd9842acb0950df12f7b813e983609a", size = 816089, upload-time = "2026-07-19T00:18:18.617Z" }, + { url = "https://files.pythonhosted.org/packages/ae/27/957e8e22690ad6634572b39b71f130a6105f4d0718bb16849eac00fff147/regex-2026.7.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4896db1f4ce0576765b8272aa922df324e0f5b9bb2c3d03044ff32a7234a9aba", size = 785206, upload-time = "2026-07-19T00:18:20.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/a4/186e410941e731037c01166069ab86da9f65e8f8110c18009ccf4bd623ee/regex-2026.7.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4e6883a021db30511d9fb8cfb0f222ce1f2c369f7d4d8b0448f449a93ba0bdfc", size = 800431, upload-time = "2026-07-19T00:18:22.716Z" }, + { url = "https://files.pythonhosted.org/packages/73/9f/e4e10e023d291d64a33e246610b724493bf1ce98e0e59c9b7c837e5acfb7/regex-2026.7.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:09523a592938aa9f587fb74467c63ff0cf88fc3df14c82ab0f0517dcf76aaa62", size = 864906, upload-time = "2026-07-19T00:18:24.772Z" }, + { url = "https://files.pythonhosted.org/packages/24/57/ccb20b6be5f1f52a053d1ba2a8f7a077edb9d918248b8490d7506c6832b3/regex-2026.7.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1ebac3474b8589fce2f9b225b650afd61448f7c73a5d0255a10cc6366471aed1", size = 773559, upload-time = "2026-07-19T00:18:27.008Z" }, + { url = "https://files.pythonhosted.org/packages/a3/82/f3b263cf8fad927dc102891da8502e718b7ff9d19af7a2a07c03865d7188/regex-2026.7.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4a0530bb1b8c1c985e7e2122e2b4d3aedd8a3c21c6bfddae6767c4405668b56e", size = 857739, upload-time = "2026-07-19T00:18:29.107Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/1687bd1b6c2aed5e672ccf845fc11557821fe7366d921b50889ea5ce57bf/regex-2026.7.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef7eeb108c47ce7bcc9513e51bcb1bf57e8f483d52fce68a8642e3527141ae0", size = 804522, upload-time = "2026-07-19T00:18:31.362Z" }, + { url = "https://files.pythonhosted.org/packages/76/7c/cc4e7655181b2d9235b704f2c5e19d8eff002bbc437bae59baee0e381aca/regex-2026.7.19-cp313-cp313t-win32.whl", hash = "sha256:64b6ca7391a1395c2638dd5c7456d67bea44fc6c5e8e92c5dc8aa6a8f23292b4", size = 269141, upload-time = "2026-07-19T00:18:33.479Z" }, + { url = "https://files.pythonhosted.org/packages/bb/14/961b4c7b05a2391c32dbc85e27773076671ef8f97f36cec70fe414734c02/regex-2026.7.19-cp313-cp313t-win_amd64.whl", hash = "sha256:f04b9f56b0e0614c0126be12c2c2d9f8850c1e57af302bd0a63bed379d4af974", size = 280036, upload-time = "2026-07-19T00:18:35.419Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/795644550d788ddbb6dc458c95895f8009978ea6d6ea76b005eb3f45e8c9/regex-2026.7.19-cp313-cp313t-win_arm64.whl", hash = "sha256:fcee38cd8e5089d6d4f048ba1233b3ad76e5954f545382180889112ff5cb712d", size = 279394, upload-time = "2026-07-19T00:18:37.454Z" }, + { url = "https://files.pythonhosted.org/packages/d2/25/0c4c452f8ef3efe456745b2f33195f5904b573fb4c2ff3f0cb9ec188461e/regex-2026.7.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a81758ed242b861b72e778ba34d41366441a2e10b16b472784c88da2dea7e2dd", size = 496750, upload-time = "2026-07-19T00:18:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/24/9e/b70ca6c1704f6c7cd32a9e143c86cc5968d10981eca284bad670c245ea7d/regex-2026.7.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4aa5435cdb3eb6f55fe98a171b05e3fbcd95fadaa4aa32acf62afd9b0cfdbcac", size = 297093, upload-time = "2026-07-19T00:18:41.583Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/0b692da2520d51fbff19c88b83d97e4c702909dd02386c585998b7e2dbed/regex-2026.7.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:60be8693a1dadc210bbcbc0db3e26da5f7d01d1d5a3da594e99b4fa42df404f5", size = 292043, upload-time = "2026-07-19T00:18:43.347Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a7/1d478e614016045a33feae57446215f9fd65b665a5ceb2f891fb3183bc52/regex-2026.7.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19662dbedbe783d323196312d38f5ba53cf56296378252171985da6899887d3", size = 797214, upload-time = "2026-07-19T00:18:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ae/11b9c9411d92c30e3d2db32df5a31133e4a99a8fc397a604fd08f6c4bffb/regex-2026.7.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d15df07081d91b76ff20d43f94592ee110330152d617b730fdbe5ef9fb680053", size = 866433, upload-time = "2026-07-19T00:18:47.315Z" }, + { url = "https://files.pythonhosted.org/packages/b1/62/2b2efc4992f91d6d204b24c647c9f9412e85379d92b7c0ab9fdae622327e/regex-2026.7.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:56ad4d9f77df871a99e25c37091052a02528ec0eb059de928ee33956b854b45b", size = 911360, upload-time = "2026-07-19T00:18:49.588Z" }, + { url = "https://files.pythonhosted.org/packages/14/71/986ceea9aa3da548bf1357cad89b63915ec6d21ec957c8113b29ece567df/regex-2026.7.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7322ec6cc9fba9d49ab888bb82d67ac5625627aa168f0165139b17018df3fb8a", size = 801275, upload-time = "2026-07-19T00:18:51.767Z" }, + { url = "https://files.pythonhosted.org/packages/15/be/ce9d9534b2cda96eab32c548261224b9b4e220a4126f098f60f42ae7b4cd/regex-2026.7.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c7472192ebfad53a6be7c4a8bfb2d64b81c0e93a1fc8c57e1dd0b638297b5d1", size = 777131, upload-time = "2026-07-19T00:18:54.053Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/58b5c710f2c3929515a25f3a1ca0dad0dcd4518d4fff3cf23bc7adb8dcd2/regex-2026.7.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c10b82c2634df08dfb13b1f04e38fe310d086ee092f4f69c0c8da234251e556e", size = 785020, upload-time = "2026-07-19T00:18:56.579Z" }, + { url = "https://files.pythonhosted.org/packages/84/03/5fe091935b74f15fe0f97998c215cae418d1c0413f6258c7d4d2e83aa37f/regex-2026.7.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:17ed5692f6acc4183e98331101a5f9e4f64d72fe58b753da4d444a2c77d05b12", size = 861263, upload-time = "2026-07-19T00:18:58.64Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fa/d60bf82e10841eef62a9e32aac401468f05fddfbcb2942e342b1ba3d2433/regex-2026.7.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:22a992de9a0d91bda927bf02b94351d737a0302905432c88a53de7c4b9ce62e2", size = 766199, upload-time = "2026-07-19T00:19:00.705Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5d/11e64d151b0662b81d6bf644c74dc118d461df85bdf2577fadbbf751788a/regex-2026.7.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:618a0aed532be87294c4477b0481f3aa0f1520f4014a4374dd4cf789b4cd2c97", size = 851317, upload-time = "2026-07-19T00:19:03.015Z" }, + { url = "https://files.pythonhosted.org/packages/7c/34/532efb87488d90807bae6a443d357ee5e2728a478c597619c8aaa17cc0bd/regex-2026.7.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ce9e679f776649746729b6c86382da519ef649c8e34cc41df0d2e5e0f6c36d4", size = 789557, upload-time = "2026-07-19T00:19:05.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/90/3a8d5ca977171ec3ae21a71207d2228b2663bde14d7f7ef0e6363ecf9290/regex-2026.7.19-cp314-cp314-win32.whl", hash = "sha256:73f272fba87b8ccfe70a137d02a54af386f6d27aa509fbffdd978f5947aae1aa", size = 272531, upload-time = "2026-07-19T00:19:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/96/e1/8862885e70409de70e8c005f57fb2e7be8d9ef0317250d60f4c9660a300d/regex-2026.7.19-cp314-cp314-win_amd64.whl", hash = "sha256:d721e53758b2cca74990185eb0671dd466d7a388a1a45d0c6f4c13cef41a68ac", size = 280831, upload-time = "2026-07-19T00:19:09.46Z" }, + { url = "https://files.pythonhosted.org/packages/08/82/2693e53e29f9104d9de95d37ce4dd826bd32d5f9c0085d3aa6ac042675c4/regex-2026.7.19-cp314-cp314-win_arm64.whl", hash = "sha256:65fa6cb38ed5e9c3637e68e544f598b39c3b86b808ed0627a67b68320384b459", size = 281099, upload-time = "2026-07-19T00:19:11.398Z" }, + { url = "https://files.pythonhosted.org/packages/92/b7/9a01aa16461a18cde9d7b9c3ab21e501db2ce33725f53014342b91df2b0a/regex-2026.7.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5a2721c8720e2cb3c209925dfb9200199b4b07361c9e01d321719404b21458b3", size = 501121, upload-time = "2026-07-19T00:19:13.425Z" }, + { url = "https://files.pythonhosted.org/packages/f3/5e/bbaeca815dc9191c424c94a4fdc5c87c75748a64a6271821212ebdd4e1a3/regex-2026.7.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:199535629f25caf89698039af3d1ad5fcae7f933e2112c73f1cdf49165c99518", size = 299415, upload-time = "2026-07-19T00:19:15.43Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d6/0dd1a321afaab95eb7ff44aa0f637301786f1dc71c6b797b9ed236ed8890/regex-2026.7.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9b60d7814174f059e5de4ab98271cc5ba9259cfea55273a81544dceea32dc8d9", size = 294483, upload-time = "2026-07-19T00:19:17.879Z" }, + { url = "https://files.pythonhosted.org/packages/92/5f/40bacf91d0904f812e13bbbab3864604c463eced8afdc54aeaa50492ea95/regex-2026.7.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbece16025afda5e3031af0c4059207e61dcf73ef13af844964f57f387d1c435", size = 811833, upload-time = "2026-07-19T00:19:20.102Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/4902744261f775aeede8b5627314b38482da29cf49a57b66a6fb753246c5/regex-2026.7.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d24ecb4f5e009ea0bd275ee37ad9953b32005e2e5e60f8bbae16da0dbbf0d3a0", size = 871270, upload-time = "2026-07-19T00:19:22.365Z" }, + { url = "https://files.pythonhosted.org/packages/16/70/6980c9be6bf21c0a60ed3e0aea39cf419ecf3b08d1d9947bc56e196ef186/regex-2026.7.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8cae6fd77a5b72dae505084b1a2ee0360139faf72fedbab667cd7cc65aae7a6a", size = 917534, upload-time = "2026-07-19T00:19:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/52/92/8b2bd872782ce8c42691e39acb38eb8efe014e5ddb78ad7d943d6f197ce9/regex-2026.7.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9724e6cb5e478cd7d8cabf027826178739cb18cf0e117d0e32814d479fa02276", size = 816135, upload-time = "2026-07-19T00:19:26.919Z" }, + { url = "https://files.pythonhosted.org/packages/de/2d/33a602f657bdc4041f17d79f92ab18261d255d91a06117a6e29df023e5e2/regex-2026.7.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:572fc57b0009c735ee56c175ea021b637a15551a312f56734277f923d6fd0f6c", size = 785492, upload-time = "2026-07-19T00:19:29.192Z" }, + { url = "https://files.pythonhosted.org/packages/9e/36/0987cf4cb271680064a70d24a475873775a151d0b7058698a006cb0cae4a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:20568e182eb82d39a6bf7cff3fd58566f14c75c6f74b2c8c96537eecf9010e3a", size = 800658, upload-time = "2026-07-19T00:19:31.392Z" }, + { url = "https://files.pythonhosted.org/packages/a8/24/c14f31c135e1ba55fa4f9a58ca98d0842512bf6188230763c31c8f449e3b/regex-2026.7.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1d58561843f0ff7dc78b4c28b5e2dc388f3eff94ebc8a232a3adba961fc00009", size = 865073, upload-time = "2026-07-19T00:19:33.485Z" }, + { url = "https://files.pythonhosted.org/packages/14/85/181a12211f22469f24d2de1ebddfe397d2396e2c29013b9a58134a91069a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:61bb1bd45520aacd56dd80943bd34991fb5350afdd1f36f2282230fd5154a218", size = 773684, upload-time = "2026-07-19T00:19:35.599Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/bd1a0c1a62251366f8d21f41b1ea3c76994962071b8b6ea42f72d505c0f0/regex-2026.7.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:cd3584591ea4429026cdb931b054342c2bcf189b44ff367f8d5c15bc092a2966", size = 857769, upload-time = "2026-07-19T00:19:37.738Z" }, + { url = "https://files.pythonhosted.org/packages/e4/4f/f7e2dad6756b2fe1fe75dd90a628c3b45f249d39f948dd90cd2476325417/regex-2026.7.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cc26a66e212fa5d6c6170c3a40d99d888db3020c6fdab1523250d4341382e44", size = 804546, upload-time = "2026-07-19T00:19:40.229Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d7/01d31d5bdb09bc026fab77f59a371fdf8f9b292e4810546c56182ca70498/regex-2026.7.19-cp314-cp314t-win32.whl", hash = "sha256:2c4e61e2e1be56f63ec3cc618aa9e0de81ef6f43d177205451840022e24f5b78", size = 274526, upload-time = "2026-07-19T00:19:42.398Z" }, + { url = "https://files.pythonhosted.org/packages/52/0e/cea4ce73bc0a8247a0748228ae6669984c7e1f8134b6fa66e59c0572e0ea/regex-2026.7.19-cp314-cp314t-win_amd64.whl", hash = "sha256:c639ea314df70a7b2811e8020448c75af8c9445f5a60f8a4ced81c306a9380c2", size = 283763, upload-time = "2026-07-19T00:19:44.644Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b6/26e41975febae63b7a6e3e02f32cff6cff2e4f10d19c929082f56aebf7c6/regex-2026.7.19-cp314-cp314t-win_arm64.whl", hash = "sha256:9a15e785f244f3e07847b984ce8773fc3da10a9f3c131cc49a4c5b4d672b4547", size = 283451, upload-time = "2026-07-19T00:19:46.639Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + +[[package]] +name = "safetensors" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/06/f955dbbb1859e3bd23c8ac6141af5106e7ad5fedec4a3a6e3d60f94b7001/safetensors-0.8.0.tar.gz", hash = "sha256:fabaf3e0f18a6618d9b36560682562157f77c2b71fcffc7b432be2baed9d753d", size = 325846, upload-time = "2026-06-09T07:52:25.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/a0/f718cda65b05407d228f97602cf60dca269c979867aa5beb25410de26cd3/safetensors-0.8.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c554f85858e05226d3c2828e32395e677434685d6d94594a41643361c5e837f0", size = 473568, upload-time = "2026-06-09T07:52:18.829Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b1/fa7c600e7dceae12e9606c7578cbc9ff1e1ed55844883ee5c92205e86226/safetensors-0.8.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c80201d22cbf405b80647a60ada77bba06c8fba2da2743ba1e89cdcc39a81f25", size = 484562, upload-time = "2026-06-09T07:52:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/65a7de0af421317bb36a067241e4235fff194eed60b961ed6d3f59a3fc60/safetensors-0.8.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a46e5ff292c356d6991e60942ba7f79817682d3a2cef0702136448cb9c4d235", size = 502844, upload-time = "2026-06-09T07:52:07.624Z" }, + { url = "https://files.pythonhosted.org/packages/91/4f/3175c9d75634e0e0dda0082794193521035edd7c70a6f212bf33ca06ddf4/safetensors-0.8.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4124502b78f03534117c848f87a39b8f31e577b15eff423bf8bfb95f2a8c30d0", size = 511823, upload-time = "2026-06-09T07:52:09.565Z" }, + { url = "https://files.pythonhosted.org/packages/20/87/846c289e7aa2299eff406335717cf43ce8777194ece8aad75772e0411615/safetensors-0.8.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7bc0a787ba8a35be368ee3574edfa2b1ad389eebd0a72e482ae275490e3f6c98", size = 633461, upload-time = "2026-06-09T07:52:11.128Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/8d64d9df2c45d5ded401df889d0ad90882804ca172d79ec4f0df8f727fe0/safetensors-0.8.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:040070828e36dc8e122178bbbd5830ff9e97920affb84cbe0f46442497bed358", size = 545148, upload-time = "2026-06-09T07:52:13.603Z" }, + { url = "https://files.pythonhosted.org/packages/28/50/f203ff3a3ddfe19308efc83c5a3a29ed02bf786732ec35e68bf9162f3365/safetensors-0.8.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd6f3f93c9a0a7cc2788ee63fb763353d4bd2e89b0751bc78fcf7dda00bea774", size = 516040, upload-time = "2026-06-09T07:52:16.29Z" }, + { url = "https://files.pythonhosted.org/packages/46/fb/cdaed17ceb2948784fd9c36b6fd3e951b608547cea81a48e8ee6f8cfdfcb/safetensors-0.8.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:fcdd41ec4628fee5799f807c73c353629130fbd942aa23d83c623dd6c9d52d78", size = 513832, upload-time = "2026-06-09T07:52:12.37Z" }, + { url = "https://files.pythonhosted.org/packages/0d/49/1e15de264dcc3b77943d2d0c56a95809956883b1c2d6d585c792523f180b/safetensors-0.8.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e9f537aa183a38ace122d27303dcd986b26bd2a7591f9181d7f0c396f4677ca", size = 559930, upload-time = "2026-06-09T07:52:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/2a/43/bf38443278eab4b1be1fce2931e2b012ad9cb7df52ada751d0aab8f7659a/safetensors-0.8.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:87eec7ffed2b809f05a398a8becb7d013f19f7837cd15d9748580d6cf30dbaf4", size = 678670, upload-time = "2026-06-09T07:52:20.032Z" }, + { url = "https://files.pythonhosted.org/packages/72/e3/68cd3fa5b48488e84add63e04cb12f3bc28ae4638c06d4508c6e88823d0e/safetensors-0.8.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4a95ae2b05d7726d751da4ebf626a2ca782b706e101bd894c95bc2450b1cffcc", size = 786679, upload-time = "2026-06-09T07:52:21.322Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/1c19c509d56e01f4fbb3d0a2e597450f6cc04d1d56cf52defb0a62dfd715/safetensors-0.8.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae091f16662658bdc019a4ff6cb4c085bb7d725eb5978b183ffd265863b6d2d", size = 765683, upload-time = "2026-06-09T07:52:22.594Z" }, + { url = "https://files.pythonhosted.org/packages/27/43/41c1621732edd934d868a00d1b891584c892a7b62a9aab82ea5a0a5623ee/safetensors-0.8.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8e080062fcde23be189565e1c3305d16751a218ecf9412c8601e64204eb6f846", size = 722361, upload-time = "2026-06-09T07:52:23.924Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3f/73ccf82579412b4a71c4ca673f10b5f1f888d7cf5af7fe24f27d30307be4/safetensors-0.8.0-cp310-abi3-win32.whl", hash = "sha256:2ddf52eac562eda224f99acfa7889d02968c1fd59a5b011ae7d8137c37e9c02d", size = 342401, upload-time = "2026-06-09T07:52:28.895Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6d/3fba214c1e5e0f69991677ec3bc17023f0421776975e1de0c682dca475e2/safetensors-0.8.0-cp310-abi3-win_amd64.whl", hash = "sha256:096ec1a98435df7beb08853bb5aa9081a84f23d0adc67ed1a0a10550f608373f", size = 355540, upload-time = "2026-06-09T07:52:27.832Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fc/7eedc3510d97878876e32774eebbeb61c43f148a96e915c84229a3e967aa/safetensors-0.8.0-cp310-abi3-win_arm64.whl", hash = "sha256:f7838e5135a406ad3e02efdcb8cf2e5397d368b0154537c4fec682dbc544d452", size = 340500, upload-time = "2026-06-09T07:52:26.745Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "narwhals" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/be/e844fd9586e66540a15b71924d17a6cbc1bb749e81ddd0a796bcdba4c055/scikit_learn-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9db6f4d34e68c8899e4cab27fdf8eafe6ed21f2ba52ceb25ea250cd237f8e47b", size = 8789686, upload-time = "2026-06-02T11:53:05.439Z" }, + { url = "https://files.pythonhosted.org/packages/42/e2/ff880f62677a17d035817d543cb0fc8727d01eccbee81c5f7fc733a9d856/scikit_learn-1.9.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f401448645a3e7bc115aa3c094097865155b34bff1cba8101857d9104e99074c", size = 8256782, upload-time = "2026-06-02T11:53:08.904Z" }, + { url = "https://files.pythonhosted.org/packages/25/64/eb40435e1a508ab1b4e284ce43ae80f6a162e5be5e38ed5a6fab467a9ea4/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd3a8ef0c758555a3b23c03adaa858af32f7736785ded50ad5991f59c4ed03fa", size = 8992419, upload-time = "2026-06-02T11:53:11.551Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/4810a28e473185429e45a57eebcc91fc991b33d889cc0676063e671db03d/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7e254636164090da847715a27f8e5478feb98c40a9e0ee90cbd277de9e5ceb8", size = 9281411, upload-time = "2026-06-02T11:53:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/be3d369f40d8178ba3bd86635d132e08cb5329b023e4669d9426d84bc007/scikit_learn-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:5dc1818c77575d149e25fce9ef82dd7b7263ae372f03494158668ad632a69759", size = 8272736, upload-time = "2026-06-02T11:53:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/37/79/a733f02dc2118da7e77a134b34f39f40201a353311b011d20859d2db3556/scikit_learn-1.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:366652351f092b219c248f1e72821e841960a63d8f358f1dcfd54dc1cbdbbc28", size = 7919564, upload-time = "2026-06-02T11:53:21.2Z" }, + { url = "https://files.pythonhosted.org/packages/ac/20/75f915ff375d6249e6550ac740fdbbd66159a068fd3af1400ff62036b07a/scikit_learn-1.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2bd41b0d201bc81575531b96b713d3eb5e5f50fb0b82101ff0f92294fdc236ac", size = 8741122, upload-time = "2026-06-02T11:53:24.08Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d5/2b5148f2279196775e1db2aeb85d14b70ac80e7e32b3b28e7ebeafb0901d/scikit_learn-1.9.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5be45aa4a42a68a533913a6ed736cf309de2226411c79ef8d609a5456f1939b1", size = 8261512, upload-time = "2026-06-02T11:53:27.183Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ee/5adbc77656b71f9456a2f5a7a9fdb4bcf9207a6b962889f1c2f9323afa4e/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e50ed4da51974e86e940690e9a3d82e729b62b5a49f7c9bac534d515d39d86f", size = 8837603, upload-time = "2026-06-02T11:53:30.328Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c2/63fdda36c56437eeb44aaf9493c8bcd62ce230ab1598924fc626ffbfa943/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:056c92bb67ad4c28463c2f2653d9701449201e7e7a9e94e321be0f71c4fef2b8", size = 9132097, upload-time = "2026-06-02T11:53:33.456Z" }, + { url = "https://files.pythonhosted.org/packages/83/a4/c8e67227c680e2259c8864ae72ff48b06e16a6f51253a22167aa02a8aa4e/scikit_learn-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:4306775fad04cc4b472a1b15af1ae9cede1540fbfcc17fbce3767cd8dc7ae283", size = 8211173, upload-time = "2026-06-02T11:53:36.602Z" }, + { url = "https://files.pythonhosted.org/packages/cf/fd/3c0863792e98e67e9184aa4029288a175935eb65443afcd30d4f143450cf/scikit_learn-1.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:26e22435f63bcdcf396b574273f29f13dd531f5ea035801f5be10ba1540a4e60", size = 7867451, upload-time = "2026-06-02T11:53:39.075Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/cf3310626b6d48d3e9be69a1223f9180360b5e6edb045f50fade723ce494/scikit_learn-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:80746d63bd4b6eaca54d36fe5feaf4d28bb38dc6f9470f81c7cad7c40155f119", size = 8705188, upload-time = "2026-06-02T11:53:41.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5b934c45c252844a91d69fda3a34cff5e7307e1db10d77cb10a3980312c74713", size = 8228299, upload-time = "2026-06-02T11:53:44.817Z" }, + { url = "https://files.pythonhosted.org/packages/0c/39/ffe829a5b8ecb40a518724a997794657fdc354ada5e8fe8e64d998c0bac9/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:38c3dcb9a1ffb85505ec53d54c7b4aea0cff70050425a7760c2af661ac85df05", size = 8789690, upload-time = "2026-06-02T11:53:47.461Z" }, + { url = "https://files.pythonhosted.org/packages/1f/88/8dab5de10c638c083772a6be83a3d8106ced492f74a928c8693638e5bb50/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da76d09304a4706db7cc1e3ebaa3b6b98a67365cc11d2996c4f1e58ba47df714", size = 9087723, upload-time = "2026-06-02T11:53:50.702Z" }, + { url = "https://files.pythonhosted.org/packages/20/3f/7917ca72464038f6240ec70c29f94862d08a34a74291ae4d4ec5eb8186a0/scikit_learn-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5808d98f15c6bf6d9d96d2348c1997392a5888ce7097e664105f930c4bca1277", size = 8184330, upload-time = "2026-06-02T11:53:53.396Z" }, + { url = "https://files.pythonhosted.org/packages/78/c7/15739eb2f61fda3c54639e9942414e5a19ad8a8d1f5a3266afad7cb7df80/scikit_learn-1.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:d77f54c017633791bc0225a43e2f8d03745fdcfe4880268fcc4df15f505dec2e", size = 7840653, upload-time = "2026-06-02T11:53:56.035Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/c9a35cf59b20a86fec24d306f1547b78dec194b08d367ce2a3e4854169d9/scikit_learn-1.9.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9656acd4e93f74e0b66c8a36c88830a99252dfa900044d36bc2212ae89a47162", size = 8713289, upload-time = "2026-06-02T11:53:58.788Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a7/552a7821597c632b907f7bfe8f36f9f572777af8ef8a48353041cf8e091a/scikit_learn-1.9.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:24360002ae845e7866522b0a5bbf690802e7bc388cac8663502e78aa98598aa2", size = 8245141, upload-time = "2026-06-02T11:54:01.694Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/f4a0c4fe9711154cddabf913471153af79056382ddc612cfe5ee0ff4b72e/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5162ad10a418c8a282dde04c9aa06965de3e9a65f33c1440c0ae69bb1a09d913", size = 8847671, upload-time = "2026-06-02T11:54:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/f0/af/4d72d9e475ac83719160c662619e4bf7b95c19507cd582e7d0167a3c3dae/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fea2cc5677ab49d6f5bade978c866da44957b712d92e9635e8b4f723013c3cb", size = 9118104, upload-time = "2026-06-02T11:54:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d5/6a58eea2cb9abbb9b3f2bb8b2cfb3243d1152d69f442d256c7af71304769/scikit_learn-1.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:64fa347efc1c839c487433e40c5144d38c336e8a2b59c81aa8660373945c2673", size = 8290674, upload-time = "2026-06-02T11:54:10.087Z" }, + { url = "https://files.pythonhosted.org/packages/65/5b/d4c879cf358f1187141cf90ced473f087183489090244f50c124a2ee478b/scikit_learn-1.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:1b944b6db288f6b926e3650026ddafb988929de95d11fc2cc5fa117773c9ba42", size = 7978807, upload-time = "2026-06-02T11:54:12.769Z" }, + { url = "https://files.pythonhosted.org/packages/8a/43/bfae3121ec67ae09150d453c442c7c1cc166e9aefe056e6ab3b7728a5cfc/scikit_learn-1.9.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4ccacf04ca5f4b492158a5f28afe0ace43f81b2571e4b9a66d34848b46128949", size = 9031941, upload-time = "2026-06-02T11:54:15.436Z" }, + { url = "https://files.pythonhosted.org/packages/75/b0/20a4546eb17f3b25d3c66df15810411c14ed5065bcfab50b53c96fb627b2/scikit_learn-1.9.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ee1a8db2c18c08e34c7412d4b10be1cac214cd4ea7dc9715a6a327eb49a37c96", size = 8613528, upload-time = "2026-06-02T11:54:18.842Z" }, + { url = "https://files.pythonhosted.org/packages/18/3c/e440e039bb82cd19004edaaad00acbde0fb9b461083c3ecf37941c557312/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:147e9329ef0e39f75d4cffa02b2aa48d827832684926cd5210d9a2cb5c57246b", size = 8855050, upload-time = "2026-06-02T11:54:21.699Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/b341b8dab5998da6270a3a42c2152c578501354d36f944b5856757035ef8/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bad8f8b9950321b54c965fdcbac6c6c55e79e16646b49977bcf3668d3870a1a", size = 9097190, upload-time = "2026-06-02T11:54:24.454Z" }, + { url = "https://files.pythonhosted.org/packages/fb/de/b650b4d69b84468cfa2e28a3ff7b8103743029e6446ce1a97fe060ef688c/scikit_learn-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:78fc56eafd4edb9575d2d8950d1dd152061abb573341a1cb7e099fc40f6c6666", size = 8963204, upload-time = "2026-06-02T11:54:27.428Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/ff83d76d7418112e5a61326443cdda87be3545dd8d6599c95b2481a4419e/scikit_learn-1.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:051075bda8b7aab87b1906ab3d4740a1e1224a19d7b3781a576736edc94e76aa", size = 8222661, upload-time = "2026-06-02T11:54:30.192Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "(python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')", + "python_full_version < '3.12' and sys_platform != 'linux' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "(python_full_version >= '3.14' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform == 'win32')", + "(python_full_version == '3.13.*' and sys_platform == 'linux') or (python_full_version == '3.13.*' and sys_platform == 'win32')", + "(python_full_version == '3.12.*' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform == 'win32')", + "python_full_version >= '3.14' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'linux' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" }, + { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578", size = 31036253, upload-time = "2026-06-19T15:00:43.216Z" }, + { url = "https://files.pythonhosted.org/packages/d7/88/b72def7262e150d16be13fca37a96481138d624e700340bc3362a7588929/scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8", size = 28673758, upload-time = "2026-06-19T15:00:46.663Z" }, + { url = "https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d", size = 20415514, upload-time = "2026-06-19T15:00:49.399Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/2135974442f6aba159d9d39d774a1c8cb19947016725d69fecc685df45bf/scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6", size = 23034398, upload-time = "2026-06-19T15:00:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/ba89ec5abf6ee9257c0d1ec985573f3ae32742c24bc03e016388a40b1b15/scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690", size = 33998032, upload-time = "2026-06-19T15:00:54.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0", size = 35283333, upload-time = "2026-06-19T15:00:58.152Z" }, + { url = "https://files.pythonhosted.org/packages/53/a4/cbdeef6eb3830a8462a9d4ada814de5fc984345cc9ecf17cbec51a036f1e/scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867", size = 35610216, upload-time = "2026-06-19T15:01:01.155Z" }, + { url = "https://files.pythonhosted.org/packages/80/4d/b2b82502b65f661d1b789c1665dcdf315d5f12194e06fc0b37946294ebae/scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709", size = 37418960, upload-time = "2026-06-19T15:01:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61", size = 37288845, upload-time = "2026-06-19T15:01:07.822Z" }, + { url = "https://files.pythonhosted.org/packages/b6/43/8d73b337a3bdb14daa0314f0434210747c02d79d729ce1777574a817dcf6/scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b", size = 24988971, upload-time = "2026-06-19T15:01:11.076Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b4/f11918b0508a2787031a0499a03fbe3546f3bb5ca05d01038c45b278c09a/scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707", size = 31399325, upload-time = "2026-06-19T15:01:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d1/1f287b57c0ff0ee5185dff3946d92c8017d39b0e431f0ae79a3ff1859512/scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677", size = 29092110, upload-time = "2026-06-19T15:01:16.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1a/7b74eb6c392fdcb27d414c0e7558a6d0231eb3b6d73571f479bb81ea8794/scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4", size = 20833811, upload-time = "2026-06-19T15:01:20.488Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ad/f3941716320a7b9cb4d68734a903b45fe16eff5fb7da7e16f2e619304979/scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce", size = 23396644, upload-time = "2026-06-19T15:01:23.364Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/1446b62ffe07f9719b7d9b1b6a4e05a772833ae8f441fe4c22c34c9b250f/scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0", size = 34079318, upload-time = "2026-06-19T15:01:26.002Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/b87da667098bb470fa30c7011b0ba351ee976dd395c78798c66e941665a3/scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58", size = 35324320, upload-time = "2026-06-19T15:01:28.881Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a1/c7932f91909759b0267f75fdea34e91309f96b895757534b76a90b6b4344/scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553", size = 35699541, upload-time = "2026-06-19T15:01:31.968Z" }, + { url = "https://files.pythonhosted.org/packages/f7/86/5185061a1fcc41d18c5dc2463969b3a3964b31d9ac67b2fb05d4c7ff7670/scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76", size = 37472480, upload-time = "2026-06-19T15:01:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/31/8e/f04c68e39919a010d34f2ee1367fd705b0a25a02f609d755f0bfbc0a15fc/scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f", size = 37365390, upload-time = "2026-06-19T15:01:38.091Z" }, + { url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661, upload-time = "2026-06-19T15:01:40.817Z" }, +] + +[[package]] +name = "sentence-transformers" +version = "5.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "scikit-learn" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "torch", version = "2.13.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tqdm" }, + { name = "transformers" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/80/573ab31b77bdfa8f18051188adff3405e928386287cd6f756eff5777dd82/sentence_transformers-5.6.1.tar.gz", hash = "sha256:16af5d682ef66672b076d58599a23905800e850ec2bfb1865938306bf684ad72", size = 452185, upload-time = "2026-07-23T14:40:41.589Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ad/8f73f512dc7ad4031d2b64cbb67f70bdfb355756afbe0db610a5146415c1/sentence_transformers-5.6.1-py3-none-any.whl", hash = "sha256:cefbb17b6325a982a4732c8c49fb013375392687049d1de3d435c4b04060680b", size = 596677, upload-time = "2026-07-23T14:40:40.312Z" }, +] + +[[package]] +name = "setuptools" +version = "83.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "smmap" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/69/a67c69e5f28fc9c99d6f7bd60bd50e91f2fed2423e3b30fb228fa00e51f3/sqlalchemy-2.0.51-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1aa10c0daee6705294d181daadaa793221e1a59ed55000a3fab1d42b088ce4ba", size = 2161838, upload-time = "2026-06-15T16:05:17.144Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/c8c22b8438bddc0a030157c6ec0f6ef97b3c38effa444bdab2a27af04090/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5b2ed6d828f1f09bd812861f4f59ca3bc3803f9df871f4555187f0faf018604", size = 3319402, upload-time = "2026-06-15T16:10:40.002Z" }, + { url = "https://files.pythonhosted.org/packages/90/54/44012d32fd77d991256d2ff793ba3807c51d40cb27a85b4796224f6744df/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:436728ce18a80f6951a1e11cc6112c2ede9faf20766f1a26195a7c441ca12dbd", size = 3319675, upload-time = "2026-06-15T16:12:25.658Z" }, + { url = "https://files.pythonhosted.org/packages/29/a5/de0592acaf5906cd7430874392d6f7e8b4a7c8437610953ee2d1501c0b44/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dc261707bf5739aea8a541593f3cc1d463c2701fb05fbcbba0ce031b69a21260", size = 3270777, upload-time = "2026-06-15T16:10:42.125Z" }, + { url = "https://files.pythonhosted.org/packages/cb/14/a44c90739c780b362238e4ac3cb19dd0ca40d13e6ddc5daa112166ddab4f/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a6d26094615306d116dd5e4a51b0304c99dd2356fc569eed6922a80a6bd3b265", size = 3293940, upload-time = "2026-06-15T16:12:27.156Z" }, + { url = "https://files.pythonhosted.org/packages/65/eb/fbd0f206a330e66f8c602a99c37c4e731f107faed62954b41b01f16dd9d9/sqlalchemy-2.0.51-cp311-cp311-win32.whl", hash = "sha256:ca8435d13829b92f4a97362d91975154a4015db3a2634154e1754e9a915e6b86", size = 2121183, upload-time = "2026-06-15T16:13:29.905Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fd/005bf80f3cf6e5c62b5dd68616280f51cd012c60840fa74781b3ed7b1623/sqlalchemy-2.0.51-cp311-cp311-win_amd64.whl", hash = "sha256:4a011ea4510683319ce4ed274b56ee05194b39b6da9d09ca7a39388f0fa84dcc", size = 2145796, upload-time = "2026-06-15T16:13:31.283Z" }, + { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, + { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, + { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, +] + +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, +] + +[[package]] +name = "srt" +version = "3.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/b7/4a1bc231e0681ebf339337b0cd05b91dc6a0d701fa852bb812e244b7a030/srt-3.5.3.tar.gz", hash = "sha256:4884315043a4f0740fd1f878ed6caa376ac06d70e135f306a6dc44632eed0cc0", size = 28296, upload-time = "2023-03-28T02:35:44.007Z" } + +[[package]] +name = "sse-starlette" +version = "3.4.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/10/a34c656829ffc1c4b22ef36d70d9ebb6b99c020e2aeb17cee5485099f028/sse_starlette-3.4.6.tar.gz", hash = "sha256:725f8a1bd6d26ae1b2c9610c0ef5065dfdd496f3988d28adcf8c4b49dc25c627", size = 32542, upload-time = "2026-07-20T14:16:32.201Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/36/e10c1d1b7ca881d2625db2ec28508578499187bb1c389952c398474e1834/sse_starlette-3.4.6-py3-none-any.whl", hash = "sha256:56217ab4c9a9f9c5db7b21e08732d3e7c2b807f45231ad23de0551a24c4a41f6", size = 16516, upload-time = "2026-07-20T14:16:30.978Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "streamlit" +version = "1.60.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altair" }, + { name = "anyio" }, + { name = "blinker" }, + { name = "click" }, + { name = "gitpython" }, + { name = "httptools" }, + { name = "itsdangerous" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "pillow" }, + { name = "protobuf" }, + { name = "pyarrow" }, + { name = "pydeck" }, + { name = "python-multipart" }, + { name = "requests" }, + { name = "starlette" }, + { name = "tenacity" }, + { name = "toml" }, + { name = "typing-extensions" }, + { name = "uvicorn" }, + { name = "watchdog", marker = "sys_platform != 'darwin'" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/46/fa437e9651af1dbe7e5b28a754fd9c36df656e4a14a2b5ecc1bcd1d90587/streamlit-1.60.0.tar.gz", hash = "sha256:09f2fdd2aabbd3b3560953795681dd675d5479ac82c21fc2dceadaf9bd484ea3", size = 9819884, upload-time = "2026-07-21T21:13:58.04Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/69/7a1984df015d01875c9ac79bfda7c31492cc94f81a2625303ce4707188e5/streamlit-1.60.0-py3-none-any.whl", hash = "sha256:d167d67cf94537600a6e378d8aa0c31eabab26beb243e2a51de9842e2872e393", size = 10419153, upload-time = "2026-07-21T21:13:55.113Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + +[[package]] +name = "tiktoken" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/e5/5f3cb2159769d0f4324c0e9e87f9de3c4b1cd45848a96b2eb3566ad5ca77/tiktoken-0.13.0.tar.gz", hash = "sha256:c9435714c3a84c2319499de9a300c0e604449dd0799ff246458b3bb6a7f433c1", size = 38986, upload-time = "2026-05-15T04:51:27.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/4c/1bc81f4cd53e827c4ee67ca951b5935724716049452d8dfa09b8b82372bb/tiktoken-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7bfe1849caa65d1e1d9871817170ec497bbb7984e182012e1bdce72f66608cdb", size = 1036353, upload-time = "2026-05-15T04:50:21.757Z" }, + { url = "https://files.pythonhosted.org/packages/75/91/10b9c7076bc02c246c853201fdbbe300a4b8c5ed7b84c25f7403f4e32655/tiktoken-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:91c180fe255bd5a86d8316210d2833a1d4d33d026cd86a67812f4773743c8d26", size = 984644, upload-time = "2026-05-15T04:50:23.256Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e4/fceae98015fab47fcd49b8bd7f46145bcd187a47e0add1e5378ed67ef980/tiktoken-0.13.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:059c8ecf554eb5b41e6e054ba467b871b03277d267dee7244380aca4359747d4", size = 1119261, upload-time = "2026-05-15T04:50:24.348Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/fe42ad00de01a8c4a49ad8649a2c8a316835a9cad5961b11d21eac0020a5/tiktoken-0.13.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:36217497eaffc158607a3b26f065300db2aefd43b115263f3b9688ce38146173", size = 1138253, upload-time = "2026-05-15T04:50:25.505Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/ccee1ecccca107e9a16efcecdeeb964c325305038554d466ece65b42338f/tiktoken-0.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:303f7d91b4fce3baddbcde05c139091d4caa5026ac7214c1dc7ff7a71ee429ff", size = 1185747, upload-time = "2026-05-15T04:50:27.02Z" }, + { url = "https://files.pythonhosted.org/packages/9d/03/cd0cba295522b91eb55c6b2704f1df895f8226cfe60ab10d4d51d0cc9e69/tiktoken-0.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5d48843bee149630eb735a99e1f4a85b47308d21868ea63163f6e87768d3cfed", size = 1241265, upload-time = "2026-05-15T04:50:28.815Z" }, + { url = "https://files.pythonhosted.org/packages/7e/25/a10efd564402d82c2ff50d12057353ace447aa8007deceaa48641f63d35c/tiktoken-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:fc1c44cd37b43fc46bae593129164f4f281e82ea116b57a85aa81bda57eafc94", size = 876509, upload-time = "2026-05-15T04:50:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/85/8e/144bde4e01df66b34bb865557c7cd754ed08b036217ebd79c9db5e9048a9/tiktoken-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:32ac870a806cfb260a02d0cb70426aef02e038297f8ad50df5040bb5af360791", size = 1034888, upload-time = "2026-05-15T04:50:31.579Z" }, + { url = "https://files.pythonhosted.org/packages/36/18/d4ac9d20956cdebca04841316660ed584c2fecdc2b81722a28bc7ad3b1e4/tiktoken-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4d9980f11429ed2d737c463bb1fb78cf330caa026adf002f714aced7849a687b", size = 982970, upload-time = "2026-05-15T04:50:32.961Z" }, + { url = "https://files.pythonhosted.org/packages/74/ed/6bb8d05b9f731f749fee5c6f5ca63e981143c826a5985877330507bd13b7/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3f277ebea5edd7b8bf03c6f9431e1d67d517530115572b2dc1d465326e8f88c7", size = 1115741, upload-time = "2026-05-15T04:50:34.475Z" }, + { url = "https://files.pythonhosted.org/packages/34/de/2ca96b07a82d972b74fe4b46de055b79c904e45c7eab699354a0bfa697dc/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:a116178fa7e1b4065bff05214360373a65cac22f965be7b3f73d00a0dbfe7649", size = 1136523, upload-time = "2026-05-15T04:50:35.782Z" }, + { url = "https://files.pythonhosted.org/packages/ee/dc/9dafec002c2d4424378563cf4cf5c7fb93631d2a55013c8b87554ee4012c/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c397ddda233208345b01bd30f2fca79ff730e55731d0108a603f9bc57f6af3b", size = 1181954, upload-time = "2026-05-15T04:50:36.99Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d0/1f8578c45b2f24759b46f0b50d31878c63c73e6bf0f2227e10ec5c5408dc/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:95097e4f89b06403976e498abf61a0ee73a7497e73fb599cb211d8197a054d91", size = 1240069, upload-time = "2026-05-15T04:50:38.221Z" }, + { url = "https://files.pythonhosted.org/packages/aa/90/28d7f154888610aa9237e541986beb62b479df29d193a5a0617dbb1514d0/tiktoken-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:8f2d16e7a7c783ad81f36e457d046d1f1c8af70b22aec8a13238efe531977c41", size = 874748, upload-time = "2026-05-15T04:50:39.587Z" }, + { url = "https://files.pythonhosted.org/packages/9c/83/b096c859c2a47c11731bf2f5885f4028b809dfe2396582883eed9cae372f/tiktoken-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5df5d1507bd245f1ccad4a074698240021239e455eb0bb4ced4e3d7181872154", size = 1034228, upload-time = "2026-05-15T04:50:40.988Z" }, + { url = "https://files.pythonhosted.org/packages/53/61/c68e123b6d753e3fc2751e9b18e732c9d8bf1e1926762e736eee935d931c/tiktoken-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fe806a50664e83a6ffd56cbd1e4f5dcc6cd32a3e7538f70dc38b1a271384545", size = 982978, upload-time = "2026-05-15T04:50:42.195Z" }, + { url = "https://files.pythonhosted.org/packages/ef/8b/96cc178cc584e65d363134500f297790b06cd48cdeb1e8fcf7bbe60f4715/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:125bc05005e747f993a83dc67934249932d6e4209854452cd4c0b1d53fba3ba2", size = 1116355, upload-time = "2026-05-15T04:50:43.564Z" }, + { url = "https://files.pythonhosted.org/packages/86/f5/bab735d2c72ea55404b295d02d092644eb5f7cc6205e34d35eb9abfb9ab2/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5e6358911cab4adee6712da27d65573496a4f68cf8a2b5fca6a4ad10fc5748cf", size = 1135772, upload-time = "2026-05-15T04:50:44.782Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b9/6de04ebdf904edfaad87788011b3735087a0c9ea671b9027e1e4e965e8c8/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:975cbd78d085d75d26b59660e262736dcaed1e35f8f142cd6291025c01d25486", size = 1182415, upload-time = "2026-05-15T04:50:46.422Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9c/470a05f3b1caf038f44880e334d47ab674e0c80d514c66b375d14d5afa10/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ab9bc99fa020a4c283424590ecd7f3afd70c1c281cb3fa3192a6c3af9f9615", size = 1239879, upload-time = "2026-05-15T04:50:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/42/a6/c1936d16055436cb32e6c6128d68629622e00f4768562f55653752d34768/tiktoken-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:6b1615f0ff71953d19729ceb18865429c185b0a23c5353f1bbca34a394bf60f7", size = 874829, upload-time = "2026-05-15T04:50:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/d6/07/acb5992c3772b5a36284f742cfb7a5895aa4471d1848ac31464ad50d7fdf/tiktoken-0.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6eb4a5bfbc6426938026b1a334e898ac53541360d62d8c689870160cc80abd67", size = 1033600, upload-time = "2026-05-15T04:50:50.4Z" }, + { url = "https://files.pythonhosted.org/packages/14/e9/742e9aec30f59b9f161f7ff7cd072e02ea836c9e1c0854a8076dfcd40d5c/tiktoken-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:43cee3e5400573b2046fbf092cc7a5bc30164f9e4c95ce20714da929df48737a", size = 982516, upload-time = "2026-05-15T04:50:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/72/74/ca1541b053e7648254d2e4b42a253e1bb4359f2c91a0a8d49228c794e1a0/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:7de52e3f566d19b3b11bd37eea552c6c305ad74081f736882bd44d148ed4c48d", size = 1115518, upload-time = "2026-05-15T04:50:53.543Z" }, + { url = "https://files.pythonhosted.org/packages/46/e3/93825eaf5a4a504795b787e5d5dea07fbeb3dabf97aa7b450be8bde59c89/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:51384448aa508e4df84c0f7c1dc3211c7f7b8096325660ee5fc82f3e11b381ce", size = 1136867, upload-time = "2026-05-15T04:50:55.191Z" }, + { url = "https://files.pythonhosted.org/packages/8c/46/002b68de6827091d5ae90b048f326e8aad8d953520950e5ce1508879414f/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e28157350f7ebf35008dd8e9e0fdb621f976e4230c881099c85e8cf07eaa50e2", size = 1181826, upload-time = "2026-05-15T04:50:56.296Z" }, + { url = "https://files.pythonhosted.org/packages/db/c6/d393e3185a276505182f7abd93fe714f3c444a2be9180798fa052347504e/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:165cf1820ea4a354985c2490a5205d4cc74661c934aca79dd0368232fff94e0f", size = 1239489, upload-time = "2026-05-15T04:50:57.918Z" }, + { url = "https://files.pythonhosted.org/packages/b7/4d/bc07d1f1635d4897a202acc0ae11c2886eaa7325c359ba4741b47bf8e225/tiktoken-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6c43a675ca14f6f2749ba7f12075d37456015a24b859f2517b9beb4ef30807ec", size = 873820, upload-time = "2026-05-15T04:50:59.528Z" }, + { url = "https://files.pythonhosted.org/packages/8c/93/0dd6adca026a616c3a92974566b43381eea4b475ce1f36c062b8271a9ac5/tiktoken-0.13.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaaaef47c2406277181d2086484c317bf7fc433e2d5d03ff94f56b0dcec87471", size = 1034977, upload-time = "2026-05-15T04:51:00.957Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5ec6e6bc5b30bed6d93f7f2162d8f6b32437b3ba27cb527cfe004f6109c9/tiktoken-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ca8b310bd93b3772cb1b7922d915446864860f562bdfe4825c63a0aed3fb28cd", size = 983635, upload-time = "2026-05-15T04:51:02.629Z" }, + { url = "https://files.pythonhosted.org/packages/94/b0/c8ae9aff00d625c50659b4513e707a0462c4bf5d4d6cc1b802103225c02e/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:32e0c12305105002c047b3bb1070b0dd9a73b0cb3b2856a8972b810e7a4f5881", size = 1116036, upload-time = "2026-05-15T04:51:04.082Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/6a5dddd1d0a6018ecb389bd0353e6b4a515eb4d2286611bd0ace1937b9e1/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5ba5fd62507a932d1241346179e3b39bc7bf7408f03c272652d93b3bedf5db24", size = 1135544, upload-time = "2026-05-15T04:51:05.229Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b8/585032b4384b2f7dcdaddcb52865c83a701a420d09e3c2b4a2be1c450c57/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d108bc2d470fc53c8ecd24f2c0fd2b5f98c33e87cdb6aa2e9b8c5dced703d273", size = 1182217, upload-time = "2026-05-15T04:51:06.517Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b6/993ff1ded3958215fd341a847b8e5ffeb5de473f435296870d314fc91ac4/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cb99cb5127449f58d0a2d5f5ccfb390d8dbdfd919c221246caaee29d8725ed51", size = 1239404, upload-time = "2026-05-15T04:51:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3d/fef7e06e3b33e7538db0ced734cf9fe23b6832d2ac4990c119c377aec55e/tiktoken-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:115c4f26ffa11caac8b54eea35c2ad38c612c20a48d35dd15d70a02ac6f51f58", size = 918686, upload-time = "2026-05-15T04:51:08.925Z" }, + { url = "https://files.pythonhosted.org/packages/c1/82/a7fc44582bc32ab00de988a2299bf77c077f59068b233109e34b7d6ca7e6/tiktoken-0.13.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:472527e9132952f2fbf77cd290658bacf003d4d5a3fabc18e5fbd407cbae4d9b", size = 1034454, upload-time = "2026-05-15T04:51:10.035Z" }, + { url = "https://files.pythonhosted.org/packages/37/d0/24d8a890c14f432a05cea669c17bebeaa99f96a7c79523b590f564246411/tiktoken-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e2f67d27c9626cdd25fe33d9313c5cdb3d8d82da646b68d6eb8e7e9c20e6448", size = 982976, upload-time = "2026-05-15T04:51:11.23Z" }, + { url = "https://files.pythonhosted.org/packages/49/b7/2ab43f62788a9266187a9bfc1d3af99ad83e5eaa25fbef168a69cd5ad14f/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2b920b35805cd64585a37c3dc7ce65fba4d2d36016be01e1d7942482ca29093a", size = 1115526, upload-time = "2026-05-15T04:51:12.608Z" }, + { url = "https://files.pythonhosted.org/packages/64/39/1494321ed323ce7a14d88e3cd6cb9058625977df1c6961ddc492bd10a9f3/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:493af3aa28a4aaf2e3d2600a2ee717252c9bf5ab38fff94eb5a02db5ab77e5ad", size = 1136466, upload-time = "2026-05-15T04:51:13.926Z" }, + { url = "https://files.pythonhosted.org/packages/96/d9/dfd086aa2d918c563a140720e0ce296cada1634efd2783d5cf51e05f984e/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6644c9c2b5cf3916f5a3641d7d12fdb3f006a7b3d9ff6acdaec44e29ab1ff91e", size = 1181863, upload-time = "2026-05-15T04:51:15.025Z" }, + { url = "https://files.pythonhosted.org/packages/2f/68/a18b4f307086954fdae32714cb4f85562e34f9d34ab206e61f1816aa6018/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cb65b60b9408563676d874a3a4ee573370066f0dc4e29d84e82e989c6517424", size = 1239218, upload-time = "2026-05-15T04:51:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/16/5b/f2aa703a4fc5d2dff73460a7d46cc2f3f44aa0f3dd8eeb20d2a0ecf68862/tiktoken-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:85b78cc3a2c3d48723ca751fa981f1fedccd54194ca0471b957364353a898b07", size = 918110, upload-time = "2026-05-15T04:51:17.237Z" }, +] + +[[package]] +name = "tokenizers" +version = "0.22.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, + { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, + { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, + { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, + { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, + { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, + { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, +] + +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + +[[package]] +name = "torch" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version < '3.12' and sys_platform != 'linux' and sys_platform != 'win32'", +] +dependencies = [ + { name = "filelock", marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "fsspec", marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "jinja2", marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "networkx", marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "setuptools", marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "sympy", marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "typing-extensions", marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/fe/cba54dc58523434919b66f13a667e36e436deddd77ca519e96553617d4ec/torch-2.13.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:e76f9bcecc52b8ff711239a2f7547d5353df95878ab232f0773c1d95928b92f8", size = 111187938, upload-time = "2026-07-08T16:05:17.065Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3a/ed0f4d4d1dcde03bced7aac9a28e800abcdc0cbd06b6775044c9fbd877b7/torch-2.13.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2fe228aba290d14b9f31b049be550dbd469c3fd3013d7a19705b30454da97027", size = 111213045, upload-time = "2026-07-08T16:05:22.997Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fa/c1c10b7aff4a9a3e8956d4f0a5f468fa6db7abc3208805719076772b4833/torch-2.13.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:33449899ce5496c1b84b4853179d94fd102028ae1407314d9fb956bb79e70d09", size = 111213743, upload-time = "2026-07-08T16:03:28.579Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/6dcc7f0c07052102dd36f83cbc5800842a909c8c3fbf1a7f8a5844954de9/torch-2.13.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d849b390e07d8d333ce8ecaf91b273c656c598379a19c9acf1318a883f6b391c", size = 111227066, upload-time = "2026-07-08T16:03:33.6Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ce/aa8b7f9949d32e0f2f624f342bc3b48112c1b8a130288465938bc83bcbf9/torch-2.13.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:c28def70706c2f9ecc752574766e8ae4da9b810ab6676b611166761a78a9f1e1", size = 111537025, upload-time = "2026-07-08T16:02:44.28Z" }, +] + +[[package]] +name = "torch" +version = "2.13.0+cpu" +source = { registry = "https://download.pytorch.org/whl/cpu" } +resolution-markers = [ + "(python_full_version >= '3.14' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform == 'win32')", + "(python_full_version == '3.13.*' and sys_platform == 'linux') or (python_full_version == '3.13.*' and sys_platform == 'win32')", + "(python_full_version == '3.12.*' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform == 'win32')", + "(python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')", +] +dependencies = [ + { name = "filelock", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "fsspec", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "jinja2", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "networkx", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "setuptools", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "sympy", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp311-cp311-linux_s390x.whl", hash = "sha256:6e9817dbdf5ea76789babd46e457eac5bf14ff566cf85f8addbfdff2d56601ce", upload-time = "2026-07-08T19:27:52Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:84453b69508ec79902f899c5ed9495acb9e2bbe9fda5f1d5d6f19e3c3842e1a7", upload-time = "2026-07-08T19:28:03Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:6746dbcbeb526eb61330b76b41ff1b4eb848951103a892eeb080dfa2b264667b", upload-time = "2026-07-08T19:28:16Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp311-cp311-win_amd64.whl", hash = "sha256:10717d8b3b67c45a4788bf7ffc0bab1ea1e5ebbedd24466be6100102d141fac1", upload-time = "2026-07-08T19:28:27Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp311-cp311-win_arm64.whl", hash = "sha256:2b3d093abd919ad934c43d47e73ba63ceba7cbd7269fc2e9c1e4fc29e8fe45fa", upload-time = "2026-07-08T19:28:33Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp312-cp312-linux_s390x.whl", hash = "sha256:ffadde149901c8afa138daa38d898264003cfcf1a3336ca5cd964b5af227d867", upload-time = "2026-07-08T19:28:41Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:6f307c2c32d764ffc6ff6893b801fad6d4752f3e67966cb8abf1843427c02604", upload-time = "2026-07-08T19:28:51Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4ca4a9394b0c771238a4f73590fdbbc4debad85ed0fa63d026ae1b085da7d6e2", upload-time = "2026-07-08T19:29:03Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:a8b450c1e58e5800e5b4691dac412f8d2d65a1dc3298166f91596603a3531e6f", upload-time = "2026-07-08T19:29:15Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp312-cp312-win_arm64.whl", hash = "sha256:fa0762705b933624d59f6823db9ce7ec2e35b3e1e9c319c9db51fbeecfc3e319", upload-time = "2026-07-08T19:29:21Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp313-cp313-linux_s390x.whl", hash = "sha256:966d020354f465672dc7dd10d3a5c6cd17d7eb48620aa1d265b48a1f78f06898", upload-time = "2026-07-08T19:29:30Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:0b8f7d0423027ae8b90c7977c627f3379f325363a08224dffad9b4b2d684a83d", upload-time = "2026-07-08T19:29:40Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:3fbf9c9d1f3c10c2d59d04aca426dee9ccc6ceb32d255c61e93acc3b4f75fae6", upload-time = "2026-07-08T19:29:54Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:a17ff48608634db245e17e8bb00a9558554a49aeb1e4f5fe6cd039af2a10515b", upload-time = "2026-07-08T19:30:05Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp313-cp313-win_arm64.whl", hash = "sha256:ac7aaf322be4777765a53bed7264a214dd81b3a1d276b93150515a3c5f75e4b0", upload-time = "2026-07-08T19:30:12Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314-linux_s390x.whl", hash = "sha256:dec241fef3984c0d1edadd1f58708e218d4eae881ceef7bc10cf9964d41b68b9", upload-time = "2026-07-08T19:30:20Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:ca021f9eb2f8345c83fa03e3a04587308afb8df71bd472670b3ece00df58621c", upload-time = "2026-07-08T19:30:32Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d20fa53ee744502fa4c69818a720b05ca0d37abd055d4f6e66cae155114bc691", upload-time = "2026-07-08T19:30:45Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314-win_amd64.whl", hash = "sha256:e2e5134decf00e218da62318f3dc5df156231d367871918e91eba95ab0ad43ab", upload-time = "2026-07-08T19:30:58Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314t-linux_s390x.whl", hash = "sha256:991cc14b39e751122c01f017be6448533989868731cb5eecd1006893d26787c2", upload-time = "2026-07-08T19:31:09Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:7b8d26e29bceafbdaa8d63bfe7612f23875b5af2cc07e13f809c3ed890bbe1d8", upload-time = "2026-07-08T19:31:21Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:b222c15a0fc2ce207d1c1a59700b46c8fa6748df1f447ad11e5c870dde0933d9", upload-time = "2026-07-08T19:31:35Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314t-win_amd64.whl", hash = "sha256:a43376bd094124ef626bfdd3d4c2c62eacb0b5ddc99776f4a32d4fd16f1f3420", upload-time = "2026-07-08T19:31:48Z" }, +] + +[[package]] +name = "tqdm" +version = "4.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, +] + +[[package]] +name = "transformers" +version = "5.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "safetensors" }, + { name = "tokenizers" }, + { name = "tqdm" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/fb/2a2ba88f325e68a921d8b69ff63b477830b2e73ade9a3c8c8cab2f06d741/transformers-5.14.1.tar.gz", hash = "sha256:60d196c27781eacf8637e2b533f517582907ad6f9ae142046d6b69431a5b2173", size = 9295927, upload-time = "2026-07-16T09:41:57.773Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/67/8d85ca2323233ae3c0365a659c4e52ee1f587b440e4bc577e7d8e4416d0f/transformers-5.14.1-py3-none-any.whl", hash = "sha256:9db974c4079ede2d1a3ea7ca5a240df33f2cc26fc2b36ba64c5f2a4f43b6e725", size = 11625234, upload-time = "2026-07-16T09:41:54.143Z" }, +] + +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + +[[package]] +name = "typer" +version = "0.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/78/fda3361b56efc27944f24225f6ecd13d96d6fcfe37bd0eb34e2f4c63f9fc/typer-0.27.0.tar.gz", hash = "sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5", size = 203430, upload-time = "2026-07-15T19:21:07.007Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" }, +] + +[[package]] +name = "typer-slim" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/a7/e6aecc4b4eb59598829a3b5076a93aff291b4fdaa2ded25efc4e1f4d219c/typer_slim-0.24.0.tar.gz", hash = "sha256:f0ed36127183f52ae6ced2ecb2521789995992c521a46083bfcdbb652d22ad34", size = 4776, upload-time = "2026-02-16T22:08:51.2Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/24/5480c20380dfd18cf33d14784096dca45a24eae6102e91d49a718d3b6855/typer_slim-0.24.0-py3-none-any.whl", hash = "sha256:d5d7ee1ee2834d5020c7c616ed5e0d0f29b9a4b1dd283bdebae198ec09778d0e", size = 3394, upload-time = "2026-02-16T22:08:49.92Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.51.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "vidxp" +version = "0.2.1b1" +source = { editable = "." } +dependencies = [ + { name = "dbos" }, + { name = "filelock" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "rich" }, + { name = "sqlalchemy" }, + { name = "typer" }, +] + +[package.optional-dependencies] +actor = [ + { name = "chromadb" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "opencv-python-headless" }, + { name = "pooch" }, + { name = "psutil" }, +] +all = [ + { name = "chromadb" }, + { name = "faster-whisper" }, + { name = "huggingface-hub" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "opencv-python-headless" }, + { name = "pillow" }, + { name = "pooch" }, + { name = "psutil" }, + { name = "sentence-transformers" }, + { name = "torch", version = "2.13.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "transformers" }, +] +benchmarks = [ + { name = "srt" }, +] +dialogue = [ + { name = "chromadb" }, + { name = "faster-whisper" }, + { name = "huggingface-hub" }, + { name = "psutil" }, + { name = "sentence-transformers" }, +] +frontend = [ + { name = "streamlit" }, +] +local-worker = [ + { name = "chromadb" }, + { name = "faster-whisper" }, + { name = "huggingface-hub" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "opencv-python-headless" }, + { name = "pillow" }, + { name = "pooch" }, + { name = "psutil" }, + { name = "pydantic-ai-slim", extra = ["openai"] }, + { name = "sentence-transformers" }, + { name = "torch", version = "2.13.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "transformers" }, +] +mcp = [ + { name = "mcp" }, +] +scene = [ + { name = "chromadb" }, + { name = "huggingface-hub" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "opencv-python-headless" }, + { name = "pillow" }, + { name = "psutil" }, + { name = "torch", version = "2.13.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "transformers" }, +] +server = [ + { name = "alembic" }, + { name = "asgi-correlation-id" }, + { name = "fastapi" }, + { name = "mcp" }, + { name = "psutil" }, + { name = "psycopg", extra = ["binary"] }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "uvicorn", extra = ["standard"] }, +] +server-worker = [ + { name = "alembic" }, + { name = "asgi-correlation-id" }, + { name = "chromadb-client" }, + { name = "fastapi" }, + { name = "faster-whisper" }, + { name = "huggingface-hub" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "opencv-python-headless" }, + { name = "pillow" }, + { name = "pooch" }, + { name = "psutil" }, + { name = "psycopg", extra = ["binary"] }, + { name = "pydantic-ai-slim", extra = ["openai"] }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "sentence-transformers" }, + { name = "torch", version = "2.13.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "transformers" }, + { name = "uvicorn", extra = ["standard"] }, +] +slm = [ + { name = "pydantic-ai-slim", extra = ["openai"] }, +] +storage = [ + { name = "chromadb" }, + { name = "psutil" }, +] +test = [ + { name = "httpx" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", marker = "extra == 'server'", specifier = ">=1.18.5,<2" }, + { name = "alembic", marker = "extra == 'server-worker'", specifier = ">=1.18.5,<2" }, + { name = "asgi-correlation-id", marker = "extra == 'server'", specifier = ">=5.0.1,<6" }, + { name = "asgi-correlation-id", marker = "extra == 'server-worker'", specifier = ">=5.0.1,<6" }, + { name = "chromadb", marker = "extra == 'actor'", specifier = ">=1.5.9,<2" }, + { name = "chromadb", marker = "extra == 'all'", specifier = ">=1.5.9,<2" }, + { name = "chromadb", marker = "extra == 'dialogue'", specifier = ">=1.5.9,<2" }, + { name = "chromadb", marker = "extra == 'local-worker'", specifier = ">=1.5.9,<2" }, + { name = "chromadb", marker = "extra == 'scene'", specifier = ">=1.5.9,<2" }, + { name = "chromadb", marker = "extra == 'storage'", specifier = ">=1.5.9,<2" }, + { name = "chromadb-client", marker = "extra == 'server-worker'", specifier = ">=1.5.9,<2" }, + { name = "dbos", specifier = ">=2.28,<3" }, + { name = "fastapi", marker = "extra == 'server'", specifier = ">=0.140.13,<0.141" }, + { name = "fastapi", marker = "extra == 'server-worker'", specifier = ">=0.140.13,<0.141" }, + { name = "faster-whisper", marker = "extra == 'all'", specifier = ">=1.2.1,<2" }, + { name = "faster-whisper", marker = "extra == 'dialogue'", specifier = ">=1.2.1,<2" }, + { name = "faster-whisper", marker = "extra == 'local-worker'", specifier = ">=1.2.1,<2" }, + { name = "faster-whisper", marker = "extra == 'server-worker'", specifier = ">=1.2.1,<2" }, + { name = "filelock", specifier = ">=3.32,<4" }, + { name = "httpx", marker = "extra == 'test'", specifier = ">=0.28.1,<0.29" }, + { name = "huggingface-hub", marker = "extra == 'all'", specifier = ">=1.25.1,<2" }, + { name = "huggingface-hub", marker = "extra == 'dialogue'", specifier = ">=1.25.1,<2" }, + { name = "huggingface-hub", marker = "extra == 'local-worker'", specifier = ">=1.25.1,<2" }, + { name = "huggingface-hub", marker = "extra == 'scene'", specifier = ">=1.25.1,<2" }, + { name = "huggingface-hub", marker = "extra == 'server-worker'", specifier = ">=1.25.1,<2" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = ">=2.0,<3" }, + { name = "mcp", marker = "extra == 'server'", specifier = ">=2.0,<3" }, + { name = "numpy", marker = "extra == 'actor'", specifier = ">=2.3,<3" }, + { name = "numpy", marker = "extra == 'all'", specifier = ">=2.3,<3" }, + { name = "numpy", marker = "extra == 'local-worker'", specifier = ">=2.3,<3" }, + { name = "numpy", marker = "extra == 'scene'", specifier = ">=2.3,<3" }, + { name = "numpy", marker = "extra == 'server-worker'", specifier = ">=2.3,<3" }, + { name = "opencv-python-headless", marker = "extra == 'actor'", specifier = ">=5.0.0.93,<6" }, + { name = "opencv-python-headless", marker = "extra == 'all'", specifier = ">=5.0.0.93,<6" }, + { name = "opencv-python-headless", marker = "extra == 'local-worker'", specifier = ">=5.0.0.93,<6" }, + { name = "opencv-python-headless", marker = "extra == 'scene'", specifier = ">=5.0.0.93,<6" }, + { name = "opencv-python-headless", marker = "extra == 'server-worker'", specifier = ">=5.0.0.93,<6" }, + { name = "packaging", specifier = ">=26.2,<27" }, + { name = "pillow", marker = "extra == 'all'", specifier = ">=12.3,<13" }, + { name = "pillow", marker = "extra == 'local-worker'", specifier = ">=12.3,<13" }, + { name = "pillow", marker = "extra == 'scene'", specifier = ">=12.3,<13" }, + { name = "pillow", marker = "extra == 'server-worker'", specifier = ">=12.3,<13" }, + { name = "platformdirs", specifier = ">=4.11,<5" }, + { name = "pooch", marker = "extra == 'actor'", specifier = ">=1.9,<2" }, + { name = "pooch", marker = "extra == 'all'", specifier = ">=1.9,<2" }, + { name = "pooch", marker = "extra == 'local-worker'", specifier = ">=1.9,<2" }, + { name = "pooch", marker = "extra == 'server-worker'", specifier = ">=1.9,<2" }, + { name = "psutil", marker = "extra == 'actor'", specifier = ">=7.2.2,<8" }, + { name = "psutil", marker = "extra == 'all'", specifier = ">=7.2.2,<8" }, + { name = "psutil", marker = "extra == 'dialogue'", specifier = ">=7.2.2,<8" }, + { name = "psutil", marker = "extra == 'local-worker'", specifier = ">=7.2.2,<8" }, + { name = "psutil", marker = "extra == 'scene'", specifier = ">=7.2.2,<8" }, + { name = "psutil", marker = "extra == 'server'", specifier = ">=7.2.2,<8" }, + { name = "psutil", marker = "extra == 'server-worker'", specifier = ">=7.2.2,<8" }, + { name = "psutil", marker = "extra == 'storage'", specifier = ">=7.2.2,<8" }, + { name = "psycopg", extras = ["binary"], marker = "extra == 'server'", specifier = ">=3.3.4,<4" }, + { name = "psycopg", extras = ["binary"], marker = "extra == 'server-worker'", specifier = ">=3.3.4,<4" }, + { name = "pydantic", specifier = ">=2.13.4,<3" }, + { name = "pydantic-ai-slim", extras = ["openai"], marker = "extra == 'local-worker'", specifier = ">=2.13,<3" }, + { name = "pydantic-ai-slim", extras = ["openai"], marker = "extra == 'server-worker'", specifier = ">=2.13,<3" }, + { name = "pydantic-ai-slim", extras = ["openai"], marker = "extra == 'slm'", specifier = ">=2.13,<3" }, + { name = "pydantic-settings", specifier = ">=2.14.2,<3" }, + { name = "pyjwt", extras = ["crypto"], marker = "extra == 'server'", specifier = ">=2.13,<3" }, + { name = "pyjwt", extras = ["crypto"], marker = "extra == 'server-worker'", specifier = ">=2.13,<3" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=9.1.1,<10" }, + { name = "python-multipart", marker = "extra == 'server'", specifier = ">=0.0.32,<0.1" }, + { name = "python-multipart", marker = "extra == 'server-worker'", specifier = ">=0.0.32,<0.1" }, + { name = "rich", specifier = ">=15,<16" }, + { name = "sentence-transformers", marker = "extra == 'all'", specifier = ">=5.6.1,<6" }, + { name = "sentence-transformers", marker = "extra == 'dialogue'", specifier = ">=5.6.1,<6" }, + { name = "sentence-transformers", marker = "extra == 'local-worker'", specifier = ">=5.6.1,<6" }, + { name = "sentence-transformers", marker = "extra == 'server-worker'", specifier = ">=5.6.1,<6" }, + { name = "sqlalchemy", specifier = ">=2.0.51,<2.1" }, + { name = "srt", marker = "extra == 'benchmarks'", specifier = ">=3.5,<4" }, + { name = "streamlit", marker = "extra == 'frontend'", specifier = ">=1.60,<2" }, + { name = "torch", marker = "(sys_platform == 'linux' and extra == 'all') or (sys_platform == 'win32' and extra == 'all')", specifier = ">=2.13,<3", index = "https://download.pytorch.org/whl/cpu" }, + { name = "torch", marker = "(sys_platform == 'linux' and extra == 'local-worker') or (sys_platform == 'win32' and extra == 'local-worker')", specifier = ">=2.13,<3", index = "https://download.pytorch.org/whl/cpu" }, + { name = "torch", marker = "(sys_platform == 'linux' and extra == 'scene') or (sys_platform == 'win32' and extra == 'scene')", specifier = ">=2.13,<3", index = "https://download.pytorch.org/whl/cpu" }, + { name = "torch", marker = "(sys_platform == 'linux' and extra == 'server-worker') or (sys_platform == 'win32' and extra == 'server-worker')", specifier = ">=2.13,<3", index = "https://download.pytorch.org/whl/cpu" }, + { name = "torch", marker = "sys_platform != 'linux' and sys_platform != 'win32' and extra == 'all'", specifier = ">=2.13,<3" }, + { name = "torch", marker = "sys_platform != 'linux' and sys_platform != 'win32' and extra == 'local-worker'", specifier = ">=2.13,<3" }, + { name = "torch", marker = "sys_platform != 'linux' and sys_platform != 'win32' and extra == 'scene'", specifier = ">=2.13,<3" }, + { name = "torch", marker = "sys_platform != 'linux' and sys_platform != 'win32' and extra == 'server-worker'", specifier = ">=2.13,<3" }, + { name = "transformers", marker = "extra == 'all'", specifier = ">=5.14.1,<6" }, + { name = "transformers", marker = "extra == 'local-worker'", specifier = ">=5.14.1,<6" }, + { name = "transformers", marker = "extra == 'scene'", specifier = ">=5.14.1,<6" }, + { name = "transformers", marker = "extra == 'server-worker'", specifier = ">=5.14.1,<6" }, + { name = "typer", specifier = ">=0.27,<1" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = ">=0.51,<0.52" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'server-worker'", specifier = ">=0.51,<0.52" }, +] +provides-extras = ["storage", "dialogue", "scene", "actor", "all", "local-worker", "mcp", "slm", "server", "server-worker", "test", "frontend", "benchmarks"] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[package]] +name = "websockets" +version = "16.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/03/47debfe28e9d6d354be5d777b67fd44c359b9eb299a5d103500bd7cc3e37/websockets-16.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d0fcf657e9f13ff4b177960ab2200237b12994232dfb6df16f1cfe1d4339f93c", size = 179566, upload-time = "2026-07-17T22:48:49.596Z" }, + { url = "https://files.pythonhosted.org/packages/72/93/31efa1ed78c17e5cfc229fd449e3966e1b9cc15753204cd585cc8dd01f4a/websockets-16.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b852788aa51764e2d8e4cf5493d559326bcae5e38d16ba25ffa322b034df272a", size = 177250, upload-time = "2026-07-17T22:48:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/542378ab3972b0c1cf1df3df3eff9591cea0d30c58c3aa3c4ddbc244e787/websockets-16.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1427fb4cf0d72f66333e2cacc3ff5f575bf2d7008166ce991a4a470b21d51a22", size = 177528, upload-time = "2026-07-17T22:48:52.59Z" }, + { url = "https://files.pythonhosted.org/packages/33/d9/162321f63c7eed558e9e1798ed7a1e34a4f6dab51f35419e4ed7a4907979/websockets-16.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:da4ca1a9d72f9030b3146b8d7022719a9f3d478f61efe6f7dd51d243f61c51b2", size = 186859, upload-time = "2026-07-17T22:48:53.915Z" }, + { url = "https://files.pythonhosted.org/packages/de/09/87df740f7430ce564bd52402e9c9458d4d0459cc7d2ee29e530c8204851b/websockets-16.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d7f0f8bdb25d2c632b72527325e4776430fd5bc61b9118de4e2b8ddb5f5b01", size = 188095, upload-time = "2026-07-17T22:48:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/d2/12/3d2703af7cc095f3c81904c92208cc1ae79affbc67376944b50ee9301f73/websockets-16.1.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7dfcad78ea1492ee3a9ec765cb7f51bbc17d477107aaf6b22abf7b2558d1c5a0", size = 191385, upload-time = "2026-07-17T22:48:56.742Z" }, + { url = "https://files.pythonhosted.org/packages/1d/69/986aa0234a964a00f5149cfc46e136e96c8faad1c783474550f40d31aef4/websockets-16.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb9a0a6dc3d1b3986cb88091b6899f0396651e0f74e2c9766ab8d6ffc3842e29", size = 188653, upload-time = "2026-07-17T22:48:58.134Z" }, + { url = "https://files.pythonhosted.org/packages/35/6b/10f9d03e3970a69ba67bd3b46b87a929b586d0300fadbfe14f57c1f85490/websockets-16.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29dfa8114c4a620c69591c5973860f768eac29d3fd6904f37f34266cb219c512", size = 187426, upload-time = "2026-07-17T22:48:59.515Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/bb3aad62bf63d8bb3f0634b2eabffcfb3677a34bd19492110ff6869cf703/websockets-16.1.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ff9417c0ada4d0f7d212f928303e5579bdf3ace4c802fa4afabb30995da58c3", size = 184882, upload-time = "2026-07-17T22:49:00.916Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4c/c09a2ea9bfbeccce52fdc383e5f28af4bc8843338aabac28c81489af6120/websockets-16.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fe0b50da2d84535fb4f7b4bfa951280f97ce3d558a0443b541166d609e67b57", size = 187584, upload-time = "2026-07-17T22:49:02.283Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8b/31bb4eb4d9eaacf1fdd39d115772a8aeaedfc19b5dc262e57ffbc8a9d42c/websockets-16.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:34420aaa64440ebd51ac72ca8a45ef4626429438c9b02e633ae412ed43f925d3", size = 186174, upload-time = "2026-07-17T22:49:03.973Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e4/dc02d725610a1ad49e193ef91a548194d71bdc6cdf27da83067dd1f73995/websockets-16.1.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a6a61aff018180c9c50b7b0da33bfd29d378af3497429c95006c589a23a11648", size = 187986, upload-time = "2026-07-17T22:49:05.553Z" }, + { url = "https://files.pythonhosted.org/packages/e0/73/30ed84c8bfd14c73d4af29d5ed9323c3073b48e0b7b23b67070f4e7fd59b/websockets-16.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:04fd29a0e2fe9414a95b00e92c67ae51bf900c50c0f8a4b2dafdad621f49ea1d", size = 185565, upload-time = "2026-07-17T22:49:06.959Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d3/4be8d4959f51e31b4f8fc0ece12b45bd3b6c0d15ea23b9990d9c11fc805f/websockets-16.1.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5c31aa7e39ee3e8a358573257f1c0bb5c52430d1b637030dd9c8cc2c282926be", size = 186598, upload-time = "2026-07-17T22:49:08.293Z" }, + { url = "https://files.pythonhosted.org/packages/26/fa/abb38597a52d84ed9cfacadc7a0c6f2db282c0ab23cdf72b58a666a21227/websockets-16.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d14bfb217eb4701e850f1525c9d29d79c44794cdf1c299ead25f39f8c78dea81", size = 186834, upload-time = "2026-07-17T22:49:09.766Z" }, + { url = "https://files.pythonhosted.org/packages/59/80/1119ad08a228b90c4eb77fbe48df7836731a605f5f881ba701ca826a4a65/websockets-16.1.1-cp311-cp311-win32.whl", hash = "sha256:2e28e602bb13da44fbe518c1781a88e3b9d4c3d48d02c9bad83e546164336f57", size = 179940, upload-time = "2026-07-17T22:49:11.196Z" }, + { url = "https://files.pythonhosted.org/packages/71/b2/e511c1c6f64a95c2f3fc54bffda0e14eaa7e9442be605c29270f7589b918/websockets-16.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:7421fad442de870a8cbf2287d1cad7e706ece0dbfeba5e911df132cbdc1cb56a", size = 180239, upload-time = "2026-07-17T22:49:12.519Z" }, + { url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600, upload-time = "2026-07-17T22:49:13.92Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272, upload-time = "2026-07-17T22:49:15.453Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542, upload-time = "2026-07-17T22:49:16.875Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137, upload-time = "2026-07-17T22:49:18.323Z" }, + { url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374, upload-time = "2026-07-17T22:49:19.65Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155, upload-time = "2026-07-17T22:49:21.331Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011, upload-time = "2026-07-17T22:49:22.889Z" }, + { url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766, upload-time = "2026-07-17T22:49:24.339Z" }, + { url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173, upload-time = "2026-07-17T22:49:25.743Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809, upload-time = "2026-07-17T22:49:27.147Z" }, + { url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412, upload-time = "2026-07-17T22:49:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290, upload-time = "2026-07-17T22:49:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844, upload-time = "2026-07-17T22:49:31.447Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823, upload-time = "2026-07-17T22:49:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102, upload-time = "2026-07-17T22:49:34.616Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943, upload-time = "2026-07-17T22:49:36.213Z" }, + { url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243, upload-time = "2026-07-17T22:49:37.626Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" }, + { url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" }, + { url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" }, + { url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" }, + { url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" }, + { url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" }, + { url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" }, + { url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" }, + { url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" }, + { url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" }, + { url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" }, + { url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" }, + { url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" }, + { url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" }, + { url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" }, + { url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" }, + { url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" }, + { url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" }, + { url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" }, + { url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" }, + { url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" }, + { url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" }, + { url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" }, + { url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" }, + { url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ed/71fea6e141590cafc40b14dc5943b0845606bee87bdb52a21b6a73eb4311/websockets-16.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:820fb8450edddae3812fd58cbc08e2bf22812cb248ecb5f06dbb82119a56e869", size = 177185, upload-time = "2026-07-17T22:50:56.665Z" }, + { url = "https://files.pythonhosted.org/packages/01/ec/00e7eeca200facf9266a83e4cbbf1bed0e67fba1d4d45031d3e5b3d81b5c/websockets-16.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:125f22dbefaf1554fea66fc83851490edb284ce4f501d37ffed2752f418332d9", size = 177459, upload-time = "2026-07-17T22:50:58.197Z" }, + { url = "https://files.pythonhosted.org/packages/75/fd/5774c4b33f7c0d8f0c51809c8b3a93456c48e3543579262cfa64eb5f522e/websockets-16.1.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30bbe120437b5648a77d3519b7024ea09530e0b5b18d3698c5a0ae536fe0cc2e", size = 178294, upload-time = "2026-07-17T22:50:59.641Z" }, + { url = "https://files.pythonhosted.org/packages/37/c3/48e2c03d2bd79bb45948841c592d24156312dd5f58cdf8f549febe652fb6/websockets-16.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b6b9dadbef0cccd9f4c4ee96b08898afa73e26803bbe0f6aeb5bb12b0074206d", size = 179190, upload-time = "2026-07-17T22:51:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3f/73e511ecf2496ceac57dd4ed8388efe2bcf0769338a2dbf242c8366ae87e/websockets-16.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56cd5fc4f10a9ea8aa0804bddb7b42506cf9e136046f3b4c27de8fec9e2ecba5", size = 180330, upload-time = "2026-07-17T22:51:02.603Z" }, + { url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" }, +] + +[[package]] +name = "yarl" +version = "1.24.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/db/3cb5df059756a45761cc3dee8fd25ec82b83a6585ea3542b969fda850f99/yarl-1.24.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2c1fe720934a16ea8e7146175cba2126f87f54912c8c5435e7f7c7a51ef808d3", size = 135043, upload-time = "2026-07-20T02:04:52.39Z" }, + { url = "https://files.pythonhosted.org/packages/44/f8/767d6bd5a03db63bc467df2fb56d6fafeae9667d74aea92cd6af399f828b/yarl-1.24.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c687ed078e145f5fd53a14854beff320e1d2ab76df03e2009c98f39a0f68f39a", size = 96942, upload-time = "2026-07-20T02:04:54.26Z" }, + { url = "https://files.pythonhosted.org/packages/ce/97/10b939c44d7b28d1dbc389cfc7012306d1ea8dba01eaef44b39fffaee52a/yarl-1.24.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:709f1efed56c4a145793c046cd4939f9959bcd818979a787b77d8e09c57a0840", size = 97046, upload-time = "2026-07-20T02:04:56.638Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7a/b410dbe39b6255c55fb2a2bcee96eb844d0789235ddc381a889a90dc72d6/yarl-1.24.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:874019bd513008b009f58657134e5d0c5e030b3559bd0553976837adf52fe966", size = 110512, upload-time = "2026-07-20T02:04:58.955Z" }, + { url = "https://files.pythonhosted.org/packages/83/c7/da591971f78a5617e1f21f5699858ebccd836fe181a6493788ffc91ba69b/yarl-1.24.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a4582acf7ef76482f6f511ebaf1946dae7f2e85ec4728b81a678c01df63bd723", size = 102454, upload-time = "2026-07-20T02:05:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8e/73b0ed4de47289a78a96045d76d1cfe5e41848bf0da59ce25b2ec87ee05d/yarl-1.24.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2cabe6546e41dabe439999a23fcb5246e0c3b595b4315b96ef755252be90caeb", size = 117617, upload-time = "2026-07-20T02:05:02.325Z" }, + { url = "https://files.pythonhosted.org/packages/cf/14/b744747bc4f57a8d55bd744df463457524583e1e9f7538b5ace0346ab92e/yarl-1.24.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:17f57620f5475b3c69109376cc87e42a7af5db13c9398e4292772a706ff10780", size = 116135, upload-time = "2026-07-20T02:05:04.05Z" }, + { url = "https://files.pythonhosted.org/packages/66/ca/95aa4d0e5b7ea4f20e4d577c42d001ed9df207569fdb063cc5ed4ebb496b/yarl-1.24.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:570fec8fbd22b032733625f03f10b7ff023bc399213db15e72a7acaef28c2f4e", size = 111935, upload-time = "2026-07-20T02:05:05.738Z" }, + { url = "https://files.pythonhosted.org/packages/72/0d/d2ad8d6b147832d177a4e720ba1962fe686eb0913b74503b3eca094b8bba/yarl-1.24.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5fede79c6f73ff2c3ef822864cb1ada23196e62756df53bc6231d351a49516a2", size = 110010, upload-time = "2026-07-20T02:05:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/50/18/eb335e4120903903f4865041355ae46256a2406eb2865bc24827f4f27b61/yarl-1.24.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ccf9aca873b767977c73df497a85dbedee4ee086ae9ae49dc461333b9b79f58", size = 110058, upload-time = "2026-07-20T02:05:09.246Z" }, + { url = "https://files.pythonhosted.org/packages/44/70/97353add32c62ad6f206d948ac5a5ee84398225e534dc6ed6433d1b335b6/yarl-1.24.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ad5d8201d310b031e6cd839d9bac2d4e5a01533ce5d3d5b50b7de1ef3af1de61", size = 103308, upload-time = "2026-07-20T02:05:11.31Z" }, + { url = "https://files.pythonhosted.org/packages/68/39/5e7398d4b6f6b3c9062823ebc60802df5b272e3fe9e788f9734c6ee46c85/yarl-1.24.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:841f0852f48fefea3b12c9dfec00704dfa3aef5215d0e3ce564bb3d7cd8d57c6", size = 116898, upload-time = "2026-07-20T02:05:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/09e52f2239e8b96357eccca05915382e4ba5405ebfb623b6036040d99654/yarl-1.24.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:9baafc71b04f8f4bb0703b21d6fc9f0c30b346c636a532ff16ec8491a5ea4b1f", size = 109400, upload-time = "2026-07-20T02:05:14.821Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6a/e94133d4c2d1a14d2384310bf3e79d9cf32c9d1eae1c6f034fb80d098fa1/yarl-1.24.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d897129df1a22b12aeed2c2c98df0785a2e8e6e0bde87b389491d0025c187077", size = 115934, upload-time = "2026-07-20T02:05:17.78Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3c/34955ed967b976fc38edcbb6d538dee79dbda4cb7fc7f72a0907a7c78e0f/yarl-1.24.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dd625535328fd9882374356269227670189adfcc6a2d90284f323c05862eecbd", size = 112178, upload-time = "2026-07-20T02:05:19.675Z" }, + { url = "https://files.pythonhosted.org/packages/f5/46/d7bd3a8859d47dcfaffd7127af7076032a7da278a9a02e17b5f37bfb6712/yarl-1.24.5-cp311-cp311-win_amd64.whl", hash = "sha256:f4239bbec5a3577ddb49e4b50aeb32d8e5792098262ae2f63723f916a29b1a25", size = 97544, upload-time = "2026-07-20T02:05:21.523Z" }, + { url = "https://files.pythonhosted.org/packages/01/69/c1bfd21e32c638974ea2c542a0b8c53ef1fa9eff336020f5d014f9503ff2/yarl-1.24.5-cp311-cp311-win_arm64.whl", hash = "sha256:3ac6aff147deb9c09461b2d4bbdf6256831198f5d8a23f5d37138213090b6d8a", size = 93359, upload-time = "2026-07-20T02:05:23.493Z" }, + { url = "https://files.pythonhosted.org/packages/1b/84/71d051c850b5af41d168c679d9eb67eb7c55283ac4ee131673edf134bc4e/yarl-1.24.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d", size = 136035, upload-time = "2026-07-20T02:05:25.489Z" }, + { url = "https://files.pythonhosted.org/packages/03/4d/8ad27f9a1b7e69313cca5d695b925b48efe51208d3490e0844bae97cabc0/yarl-1.24.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec", size = 97642, upload-time = "2026-07-20T02:05:27.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/05b4131c407006cd1e410e9c6539f16a0945724677e5364447313c15ea3e/yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c", size = 97323, upload-time = "2026-07-20T02:05:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/20/16/e618c875c73e0e39611f20a581b3d5e8d59b8857bf001bee3263044c6deb/yarl-1.24.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54", size = 107741, upload-time = "2026-07-20T02:05:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c4defeaf3ed33fcb346aacf9c6e971a8d4e2bde04a0310e79abb208e7965/yarl-1.24.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12", size = 103570, upload-time = "2026-07-20T02:05:33.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e7/0e0e0de5865ebd5914537ef486f36c727a59865c3ac0cf5ff1b32aececbf/yarl-1.24.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d", size = 115815, upload-time = "2026-07-20T02:05:35.292Z" }, + { url = "https://files.pythonhosted.org/packages/2b/27/ca56b700cb170aba25a3893b75355b213935657dc5714d2383354a270e62/yarl-1.24.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1", size = 116025, upload-time = "2026-07-20T02:05:37.503Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d0/d56c859b8222116f5d68459199f48359e0bf121b6f65a69bf329b3602ba0/yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9", size = 109835, upload-time = "2026-07-20T02:05:39.506Z" }, + { url = "https://files.pythonhosted.org/packages/70/a2/3a35557e4d1a79425040eba202ccaf08bdc8717680fc77e2498a1ad2e0a5/yarl-1.24.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027", size = 108884, upload-time = "2026-07-20T02:05:41.584Z" }, + { url = "https://files.pythonhosted.org/packages/e4/35/ef4c26356b7913c68983bac2d72a4212b3347af551cb8d250b99b5ed7b7f/yarl-1.24.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b", size = 107308, upload-time = "2026-07-20T02:05:43.697Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/ff0dc66c2ccf3e0153ab97ff61eabab4400e6a5264af427ab30cd69f1857/yarl-1.24.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293", size = 103646, upload-time = "2026-07-20T02:05:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/74/f0/33b9271c7f881766359d58266fa0811d2e5210ed860e28da7dc6d7786344/yarl-1.24.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e", size = 115305, upload-time = "2026-07-20T02:05:47.832Z" }, + { url = "https://files.pythonhosted.org/packages/ef/65/fd79fb1868c4a80db8661091de525bf430f63c3bea1b20e8b6a84fc7d359/yarl-1.24.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b", size = 108404, upload-time = "2026-07-20T02:05:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ba/dbabe6b262f17a816c70cfc09558dbf03ece3ec76684d02f911a3d3a189c/yarl-1.24.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce", size = 115940, upload-time = "2026-07-20T02:05:51.741Z" }, + { url = "https://files.pythonhosted.org/packages/a5/43/fab2d1dad9d340a268cdde63756a123d069723efff6a372d123fa74a9517/yarl-1.24.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba", size = 110006, upload-time = "2026-07-20T02:05:53.554Z" }, + { url = "https://files.pythonhosted.org/packages/c4/27/41eb51bbd1b8d89546b83897cfb0164f1e109304fd408dbb151b639eec0f/yarl-1.24.5-cp312-cp312-win_amd64.whl", hash = "sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b", size = 97618, upload-time = "2026-07-20T02:05:55.57Z" }, + { url = "https://files.pythonhosted.org/packages/3c/25/b2553764b3d65db711d8f45416351ec4f420847558eb669edcbcaadf5780/yarl-1.24.5-cp312-cp312-win_arm64.whl", hash = "sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c", size = 93018, upload-time = "2026-07-20T02:05:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" }, + { url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" }, + { url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" }, + { url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" }, + { url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" }, + { url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" }, + { url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" }, + { url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" }, + { url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" }, + { url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" }, + { url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" }, + { url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" }, + { url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" }, + { url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" }, + { url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" }, + { url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" }, + { url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" }, + { url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" }, + { url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" }, + { url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" }, + { url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" }, + { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, +]