diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index 21573b7ce..535eb26d6 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -6,13 +6,13 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: docker/setup-buildx-action@v3 + - uses: docker/setup-buildx-action@v4 id: setup - name: Cache Build - uses: actions/cache@v4 + uses: actions/cache@v5 id: cache with: # if the list or anything in these folders expected to change, then cache needs to be cleared and rebuilt, because it is keyed only by pyproject.toml hash @@ -42,7 +42,7 @@ jobs: } - name: Build Doc - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: push: false platforms: linux/arm64 @@ -57,7 +57,7 @@ jobs: uses: actions/configure-pages@v5 - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v4 with: path: docs/_build/html/ diff --git a/.github/workflows/build-package.yml b/.github/workflows/build-package.yml index a7adba43f..73c7549c5 100644 --- a/.github/workflows/build-package.yml +++ b/.github/workflows/build-package.yml @@ -11,23 +11,23 @@ on: jobs: build_src: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 defaults: run: shell: bash steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Download version info - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-version-info path: | src/foundation - name: Set up Python 3.13 - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.13' @@ -41,7 +41,7 @@ jobs: scripts/build - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-src if-no-files-found: error @@ -49,23 +49,23 @@ jobs: dist/icloudpd*.whl get_version_thumbprint: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 defaults: run: shell: bash steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Download version info - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-version-info path: | src/foundation - name: Set up Python 3.13 - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.13' @@ -82,7 +82,7 @@ jobs: icloudpd --version | tee dist/icloudpd-version-thumbprint.txt - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-version-thumbprint if-no-files-found: error @@ -90,23 +90,23 @@ jobs: dist/icloudpd-version-thumbprint.txt get_expected_version_linux_apt: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 defaults: run: shell: bash steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Download version info - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-version-info path: | src/foundation - name: Set up Python 3.13 - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.13' @@ -129,16 +129,16 @@ jobs: expected_version: ${{steps.get_version.outputs.expected_version}} get_expected_version_linux_apk: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 defaults: run: shell: bash steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Download version info - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-version-info path: | @@ -178,17 +178,17 @@ jobs: shell: bash steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Download version info - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-version-info path: | src/foundation - name: Set up Python 3.13 - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.13' @@ -210,7 +210,7 @@ jobs: build_linux_apt: # 24.02 gives futex error during apt: https://github.com/actions/runner-images/issues/9977 - runs-on: ${{ (matrix.platform[1] == 'arm64' || matrix.platform[1] == 'arm32v7') && 'ubuntu-22.04-arm' || 'ubuntu-22.04' }} + runs-on: ${{ (matrix.platform[1] == 'arm64' || matrix.platform[1] == 'arm32v7') && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} name: Build Linux ${{ matrix.platform[1] }} Debian strategy: fail-fast: false @@ -229,10 +229,10 @@ jobs: "arm32v7", ] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Download version info - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-version-info path: | @@ -240,11 +240,11 @@ jobs: # ARM32v7 runs natively on ARM64 runners - no QEMU needed - - uses: docker/setup-buildx-action@v3 + - uses: docker/setup-buildx-action@v4 id: setup - name: Cache Build - uses: actions/cache@v4 + uses: actions/cache@v5 id: cache with: # if the list or anything in these folders expected to change, then cache needs to be cleared and rebuilt, because it is keyed only by pyproject.toml hash @@ -274,7 +274,7 @@ jobs: } - name: Build - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: push: false platforms: ${{ matrix.platform[0] }} @@ -291,7 +291,7 @@ jobs: mv dist/icloudpd dist/icloudpd-${{inputs.icloudpd_version}}-linux-${{ matrix.platform[1] }} - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-bin-linux-${{ matrix.platform[1] }}-apt if-no-files-found: error @@ -299,7 +299,7 @@ jobs: dist/icloud* clone_src_whl: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 needs: - build_src defaults: @@ -307,17 +307,17 @@ jobs: shell: bash steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Download src - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-src path: | dist - name: Set up Python 3.13 - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.13' @@ -331,7 +331,7 @@ jobs: scripts/clone_whl_version ${{inputs.icloudpd_version}} 0.0.1234567890 - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-dummywhl-src if-no-files-found: error @@ -339,7 +339,7 @@ jobs: dist/icloudpd-0.0.1234567890-*.whl clone_linux_whl: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 needs: - build_linux_apt - build_linux_apk @@ -348,10 +348,10 @@ jobs: shell: bash steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Download bin - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: pattern: icloudpd-bin-linux-* merge-multiple: true @@ -359,7 +359,7 @@ jobs: dist - name: Set up Python 3.13 - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.13' @@ -373,7 +373,7 @@ jobs: scripts/clone_whl_version ${{inputs.icloudpd_version}} 0.0.1234567890 - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-dummywhl-linux if-no-files-found: error @@ -381,7 +381,7 @@ jobs: dist/icloudpd-0.0.1234567890-*.whl clone_macos_whl: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 needs: - build_macos defaults: @@ -389,10 +389,10 @@ jobs: shell: bash steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Download bin - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: pattern: icloudpd-bin-macos-* merge-multiple: true @@ -400,7 +400,7 @@ jobs: dist - name: Set up Python 3.13 - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.13' @@ -414,7 +414,7 @@ jobs: scripts/clone_whl_version ${{inputs.icloudpd_version}} 0.0.1234567890 - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-dummywhl-macos if-no-files-found: error @@ -422,7 +422,7 @@ jobs: dist/icloudpd-0.0.1234567890-*.whl clone_windows_whl: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 needs: - build_windows defaults: @@ -430,10 +430,10 @@ jobs: shell: bash steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Download bin - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: pattern: icloudpd-bin-windows-* merge-multiple: true @@ -441,7 +441,7 @@ jobs: dist - name: Set up Python 3.13 - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.13' @@ -455,7 +455,7 @@ jobs: scripts/clone_whl_version ${{inputs.icloudpd_version}} 0.0.1234567890 - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-dummywhl-windows if-no-files-found: error @@ -464,7 +464,7 @@ jobs: build_linux_apk: # 24.02 gives futex error during apt: https://github.com/actions/runner-images/issues/9977 - runs-on: ${{ (matrix.platform[1] == 'arm64' || matrix.platform[1] == 'arm32v7') && 'ubuntu-22.04-arm' || 'ubuntu-22.04' }} + runs-on: ${{ (matrix.platform[1] == 'arm64' || matrix.platform[1] == 'arm32v7') && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} name: Build Linux ${{ matrix.platform[1] }} Alpine strategy: fail-fast: false @@ -483,10 +483,10 @@ jobs: "arm32v7", ] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Download version info - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-version-info path: | @@ -494,11 +494,11 @@ jobs: # ARM32v7 runs natively on ARM64 runners - no QEMU needed - - uses: docker/setup-buildx-action@v3 + - uses: docker/setup-buildx-action@v4 id: setup - name: Cache Folders Build - uses: actions/cache@v4 + uses: actions/cache@v5 id: cache with: # if the list or anything in these folders expected to change, then cache needs to be cleared and rebuilt, because it is keyed only by pyproject.toml hash @@ -522,7 +522,7 @@ jobs: } - name: Build - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: push: false platforms: ${{ matrix.platform[0] }} @@ -539,7 +539,7 @@ jobs: mv dist/icloudpd dist/icloudpd-${{inputs.icloudpd_version}}-linux-musl-${{ matrix.platform[1] }} - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-bin-linux-${{ matrix.platform[1] }}-apk if-no-files-found: error @@ -554,17 +554,17 @@ jobs: shell: bash steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Download version info - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-version-info path: | src/foundation - name: Set up Python 3.13 - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.13' @@ -585,7 +585,7 @@ jobs: scripts/build_binary_dist_macos ${{inputs.icloudpd_version}} - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-bin-macos-amd64 if-no-files-found: error @@ -600,17 +600,17 @@ jobs: shell: bash steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Download version info - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-version-info path: | src/foundation - name: Set up Python 3.13 - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.13' @@ -631,7 +631,7 @@ jobs: scripts/build_binary_dist_windows ${{inputs.icloudpd_version}} - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-bin-windows-amd64 if-no-files-found: error @@ -639,16 +639,16 @@ jobs: dist/icloud* build_docker: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 needs: # - build_linux_apt - build_linux_apk # docker is musl only steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Download artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: pattern: icloudpd-bin-linux-* merge-multiple: true @@ -656,10 +656,10 @@ jobs: dist - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 with: version: v0.12.0 @@ -678,7 +678,7 @@ jobs: # icloudpd/icloudpd:commit-${{ github.sha }} - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-oci if-no-files-found: error @@ -686,7 +686,7 @@ jobs: dist/icloud*.tar build_npm: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 needs: - build_linux_apt # - build_linux_apk npm is glibc only @@ -694,16 +694,16 @@ jobs: - build_windows steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20.x' registry-url: 'https://registry.npmjs.org' - name: Download artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: pattern: icloudpd-bin-* merge-multiple: true @@ -715,7 +715,7 @@ jobs: scripts/build_npm ${{inputs.icloudpd_version}} - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-npm if-no-files-found: error @@ -810,7 +810,7 @@ jobs: "arm/v7", # platform spec "arm32v7/", # image prefix ] - runs-on: ${{ (matrix.prop[1] == 'arm64' || matrix.prop[1] == 'arm/v7') && 'ubuntu-22.04-arm' || 'ubuntu-22.04' }} + runs-on: ${{ (matrix.prop[1] == 'arm64' || matrix.prop[1] == 'arm/v7') && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} needs: - clone_linux_whl - clone_src_whl @@ -837,7 +837,7 @@ jobs: - name: Download artifacts (src) if: steps.get_image.outputs.digest != '' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-dummywhl-src path: | @@ -845,7 +845,7 @@ jobs: - name: Download artifacts (whl) if: steps.get_image.outputs.digest != '' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-dummywhl-linux path: | @@ -854,9 +854,9 @@ jobs: # fails with "icloud: Failed to stat /proc/self/exe: Bad file descriptor" in bookwork arm64 # - name: Set up QEMU # if: matrix.prop[1] != 'amd64' - # uses: docker/setup-qemu-action@v3 + # uses: docker/setup-qemu-action@v4 - # ARM runs natively on ubuntu-22.04-arm runners - no QEMU needed + # ARM runs natively on ubuntu-24.04-arm runners - no QEMU needed - name: Run test for ${{ matrix.prop[2] }}${{ matrix.image[1] }} on ${{ matrix.prop[1] }} if: steps.get_image.outputs.digest != '' @@ -892,7 +892,7 @@ jobs: - name: Upload compatibility result if: steps.get_image.outputs.digest != '' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-compatibility-linux-pip-${{ matrix.image[0] }}-${{ matrix.prop[0] }}-apt if-no-files-found: error @@ -931,7 +931,7 @@ jobs: - name: Upload tzlc result if: steps.get_image.outputs.digest != '' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-tzlc-linux-pip-${{ matrix.image[0] }}-${{ matrix.prop[0] }}-apt if-no-files-found: error @@ -1016,7 +1016,7 @@ jobs: "arm/v7", # platform spec "arm32v7/", # image prefix ] - runs-on: ${{ (matrix.prop[1] == 'arm64' || matrix.prop[1] == 'arm/v7') && 'ubuntu-22.04-arm' || 'ubuntu-22.04' }} + runs-on: ${{ (matrix.prop[1] == 'arm64' || matrix.prop[1] == 'arm/v7') && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} needs: - clone_linux_whl - clone_src_whl @@ -1043,7 +1043,7 @@ jobs: - name: Download artifacts (src) if: steps.get_image.outputs.digest != '' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-dummywhl-src path: | @@ -1051,7 +1051,7 @@ jobs: - name: Download artifacts (linux) if: steps.get_image.outputs.digest != '' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-dummywhl-linux path: | @@ -1059,9 +1059,9 @@ jobs: # - name: Set up QEMU # if: matrix.prop[1] != 'amd64' - # uses: docker/setup-qemu-action@v3 + # uses: docker/setup-qemu-action@v4 - # ARM runs natively on ubuntu-22.04-arm runners - no QEMU needed + # ARM runs natively on ubuntu-24.04-arm runners - no QEMU needed - name: Run test for ${{ matrix.prop[2] }}${{ matrix.image[1] }} on ${{ matrix.prop[1] }} if: steps.get_image.outputs.digest != '' @@ -1096,7 +1096,7 @@ jobs: - name: Upload compatibility result if: steps.get_image.outputs.digest != '' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-compatibility-linux-pip-${{ matrix.image[0] }}-${{ matrix.prop[0] }}-apk if-no-files-found: error @@ -1134,7 +1134,7 @@ jobs: - name: Upload tzlc result if: steps.get_image.outputs.digest != '' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-tzlc-linux-pip-${{ matrix.image[0] }}-${{ matrix.prop[0] }}-apk if-no-files-found: error @@ -1171,14 +1171,14 @@ jobs: mkdir tzlc - name: Download artifacts (src) - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-dummywhl-src path: | dist - name: Download artifacts (macos) - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-dummywhl-macos path: | @@ -1206,7 +1206,7 @@ jobs: touch compatibility/pip.${{ matrix.prop[0] }}.${{ matrix.prop[1] }}.fail - name: Upload compatibility result - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-compatibility-macos-pip-${{ matrix.prop[0] }}-${{ matrix.prop[1] }} if-no-files-found: error @@ -1235,7 +1235,7 @@ jobs: touch tzlc/pip.${{ matrix.prop[0] }}.${{ matrix.prop[1] }}.fail - name: Upload tzlc result - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-tzlc-macos-pip-${{ matrix.prop[0] }}-${{ matrix.prop[1] }} if-no-files-found: error @@ -1260,14 +1260,14 @@ jobs: mkdir compatibility - name: Download artifacts (src) - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-dummywhl-src path: | dist - name: Download artifacts (windows) - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-dummywhl-windows path: | @@ -1294,7 +1294,7 @@ jobs: touch compatibility/pip.${{ matrix.os }}.amd64.fail - name: Upload compatibility result - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-compatibility-windows-pip-${{ matrix.os }}-amd64 if-no-files-found: error @@ -1379,7 +1379,7 @@ jobs: "arm/v7", # platform spec "arm32v7/", # image prefix ] - runs-on: ${{ (matrix.prop[1] == 'arm64' || matrix.prop[1] == 'arm/v7') && 'ubuntu-22.04-arm' || 'ubuntu-22.04' }} + runs-on: ${{ (matrix.prop[1] == 'arm64' || matrix.prop[1] == 'arm/v7') && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} needs: - build_linux_apt # - build_linux_apk @@ -1406,7 +1406,7 @@ jobs: - name: Download artifacts if: steps.get_image.outputs.digest != '' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: pattern: icloudpd-bin-linux-* merge-multiple: true @@ -1416,9 +1416,9 @@ jobs: # fails with "icloud: Failed to stat /proc/self/exe: Bad file descriptor" in bookwork arm64 # - name: Set up QEMU # if: matrix.prop[1] != 'amd64' - # uses: docker/setup-qemu-action@v3 + # uses: docker/setup-qemu-action@v4 - # ARM runs natively on ubuntu-22.04-arm runners - no QEMU needed + # ARM runs natively on ubuntu-24.04-arm runners - no QEMU needed - name: Run test for ${{ matrix.prop[2] }}${{ matrix.image[1] }} on ${{ matrix.prop[1] }} if: steps.get_image.outputs.digest != '' @@ -1447,7 +1447,7 @@ jobs: - name: Upload compatibility result if: steps.get_image.outputs.digest != '' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-compatibility-linux-bin-${{ matrix.image[0] }}-${{ matrix.prop[0] }}-apt if-no-files-found: error @@ -1482,7 +1482,7 @@ jobs: - name: Upload tzlc result if: steps.get_image.outputs.digest != '' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-tzlc-linux-bin-${{ matrix.image[0] }}-${{ matrix.prop[0] }}-apt if-no-files-found: error @@ -1567,7 +1567,7 @@ jobs: "arm/v7", # platform spec "arm32v7/", # image prefix ] - runs-on: ${{ (matrix.prop[1] == 'arm64' || matrix.prop[1] == 'arm/v7') && 'ubuntu-22.04-arm' || 'ubuntu-22.04' }} + runs-on: ${{ (matrix.prop[1] == 'arm64' || matrix.prop[1] == 'arm/v7') && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} needs: # - build_linux_apt - build_linux_apk @@ -1594,7 +1594,7 @@ jobs: - name: Download artifacts if: steps.get_image.outputs.digest != '' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: pattern: icloudpd-bin-linux-* merge-multiple: true @@ -1604,9 +1604,9 @@ jobs: # fails with "icloud: Failed to stat /proc/self/exe: Bad file descriptor" in bookwork arm64 # - name: Set up QEMU # if: matrix.prop[1] != 'amd64' - # uses: docker/setup-qemu-action@v3 + # uses: docker/setup-qemu-action@v4 - # ARM runs natively on ubuntu-22.04-arm runners - no QEMU needed + # ARM runs natively on ubuntu-24.04-arm runners - no QEMU needed - name: Run test for ${{ matrix.prop[2] }}${{ matrix.image[1] }} on ${{ matrix.prop[1] }} if: steps.get_image.outputs.digest != '' @@ -1635,7 +1635,7 @@ jobs: - name: Upload compatibility result if: steps.get_image.outputs.digest != '' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-compatibility-linux-bin-musl-${{ matrix.image[0] }}-${{ matrix.prop[0] }}-apt if-no-files-found: error @@ -1670,7 +1670,7 @@ jobs: - name: Upload tzlc result if: steps.get_image.outputs.digest != '' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-tzlc-linux-bin-musl-${{ matrix.image[0] }}-${{ matrix.prop[0] }}-apt if-no-files-found: error @@ -1752,7 +1752,7 @@ jobs: "arm/v7", # platform spec "arm32v7/", # image prefix ] - runs-on: ${{ (matrix.prop[1] == 'arm64' || matrix.prop[1] == 'arm/v7') && 'ubuntu-22.04-arm' || 'ubuntu-22.04' }} + runs-on: ${{ (matrix.prop[1] == 'arm64' || matrix.prop[1] == 'arm/v7') && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} needs: - build_linux_apt # - build_linux_apk @@ -1779,7 +1779,7 @@ jobs: - name: Download artifacts if: steps.get_image.outputs.digest != '' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: pattern: icloudpd-bin-linux-* merge-multiple: true @@ -1789,9 +1789,9 @@ jobs: # fails with "icloud: Failed to stat /proc/self/exe: Bad file descriptor" in bookwork arm64 # - name: Set up QEMU # if: matrix.prop[1] != 'amd64' - # uses: docker/setup-qemu-action@v3 + # uses: docker/setup-qemu-action@v4 - # ARM runs natively on ubuntu-22.04-arm runners - no QEMU needed + # ARM runs natively on ubuntu-24.04-arm runners - no QEMU needed - name: Run test for ${{ matrix.prop[2] }}${{ matrix.image[1] }} on ${{ matrix.prop[1] }} if: steps.get_image.outputs.digest != '' @@ -1820,7 +1820,7 @@ jobs: - name: Upload compatibility result if: steps.get_image.outputs.digest != '' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-compatibility-linux-bin-${{ matrix.image[0] }}-${{ matrix.prop[0] }}-apk if-no-files-found: error @@ -1856,7 +1856,7 @@ jobs: - name: Upload tzlc result if: steps.get_image.outputs.digest != '' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-tzlc-linux-bin-${{ matrix.image[0] }}-${{ matrix.prop[0] }}-apk if-no-files-found: error @@ -1938,7 +1938,7 @@ jobs: "arm/v7", # platform spec "arm32v7/", # image prefix ] - runs-on: ${{ (matrix.prop[1] == 'arm64' || matrix.prop[1] == 'arm/v7') && 'ubuntu-22.04-arm' || 'ubuntu-22.04' }} + runs-on: ${{ (matrix.prop[1] == 'arm64' || matrix.prop[1] == 'arm/v7') && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} needs: # - build_linux_apt - build_linux_apk @@ -1965,7 +1965,7 @@ jobs: - name: Download artifacts if: steps.get_image.outputs.digest != '' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: pattern: icloudpd-bin-linux-* merge-multiple: true @@ -1975,9 +1975,9 @@ jobs: # fails with "icloud: Failed to stat /proc/self/exe: Bad file descriptor" in bookwork arm64 # - name: Set up QEMU # if: matrix.prop[1] != 'amd64' - # uses: docker/setup-qemu-action@v3 + # uses: docker/setup-qemu-action@v4 - # ARM runs natively on ubuntu-22.04-arm runners - no QEMU needed + # ARM runs natively on ubuntu-24.04-arm runners - no QEMU needed - name: Run test for ${{ matrix.prop[2] }}${{ matrix.image[1] }} on ${{ matrix.prop[1] }} if: steps.get_image.outputs.digest != '' @@ -2006,7 +2006,7 @@ jobs: - name: Upload compatibility result if: steps.get_image.outputs.digest != '' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-compatibility-linux-bin-musl-${{ matrix.image[0] }}-${{ matrix.prop[0] }}-apk if-no-files-found: error @@ -2042,7 +2042,7 @@ jobs: - name: Upload tzlc result if: steps.get_image.outputs.digest != '' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-tzlc-linux-bin-musl-${{ matrix.image[0] }}-${{ matrix.prop[0] }}-apk if-no-files-found: error @@ -2078,7 +2078,7 @@ jobs: mkdir tzlc - name: Download artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-bin-macos-amd64 path: | @@ -2103,7 +2103,7 @@ jobs: touch compatibility/bin.${{ matrix.prop[0] }}.${{ matrix.prop[1] }}.fail - name: Upload compatibility result - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-compatibility-macos-bin-${{ matrix.prop[0] }}-${{ matrix.prop[1] }} if-no-files-found: error @@ -2128,7 +2128,7 @@ jobs: touch tzlc/bin.${{ matrix.prop[0] }}.${{ matrix.prop[1] }}.fail - name: Upload tzlc result - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-tzlc-macos-bin-${{ matrix.prop[0] }}-${{ matrix.prop[1] }} if-no-files-found: error @@ -2160,7 +2160,7 @@ jobs: mkdir compatibility - name: Download artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-bin-windows-amd64 path: | @@ -2183,7 +2183,7 @@ jobs: touch compatibility/bin.${{ matrix.prop[0] }}.amd64.fail - name: Upload compatibility result - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-compatibility-windows-bin-${{ matrix.prop[0] }}-amd64 if-no-files-found: error @@ -2216,7 +2216,7 @@ jobs: "arm/v7", # platform spec "arm32v7/", # image prefix ] - runs-on: ${{ (matrix.prop[1] == 'arm64' || matrix.prop[1] == 'arm/v7') && 'ubuntu-22.04-arm' || 'ubuntu-22.04' }} + runs-on: ${{ (matrix.prop[1] == 'arm64' || matrix.prop[1] == 'arm/v7') && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} needs: [ build_docker ] defaults: run: @@ -2254,7 +2254,7 @@ jobs: - name: Download artifacts if: steps.reload_docker.outcome != 'failure' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-oci path: | @@ -2283,9 +2283,9 @@ jobs: # - name: Set up QEMU # if: matrix.prop[1] != 'amd64' - # uses: docker/setup-qemu-action@v3 + # uses: docker/setup-qemu-action@v4 - # ARM runs natively on ubuntu-22.04-arm runners - no QEMU needed + # ARM runs natively on ubuntu-24.04-arm runners - no QEMU needed - name: Run test on ${{ matrix.prop[1] }} if: steps.reload_docker.outcome != 'failure' @@ -2307,7 +2307,7 @@ jobs: - name: Upload compatibility result if: steps.reload_docker.outcome != 'failure' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-compatibility-docker-${{ matrix.image[0] }}-${{ matrix.prop[0] }} if-no-files-found: error @@ -2334,7 +2334,7 @@ jobs: - name: Upload tzlc result if: steps.reload_docker.outcome != 'failure' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-tzlc-docker-${{ matrix.image[0] }}-${{ matrix.prop[0] }} if-no-files-found: error @@ -2424,7 +2424,7 @@ jobs: "arm/v7", # platform spec "arm32v7/", # image prefix ] - runs-on: ${{ (matrix.prop[1] == 'arm64' || matrix.prop[1] == 'arm/v7') && 'ubuntu-22.04-arm' || 'ubuntu-22.04' }} + runs-on: ${{ (matrix.prop[1] == 'arm64' || matrix.prop[1] == 'arm/v7') && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} needs: [ build_npm ] defaults: run: @@ -2439,7 +2439,7 @@ jobs: - name: Setup Node for Registry Server if: steps.get_image.outputs.digest != '' - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20.x' @@ -2482,7 +2482,7 @@ jobs: - name: Download artifacts if: steps.get_image.outputs.digest != '' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-npm path: | @@ -2490,7 +2490,7 @@ jobs: - name: Setup Node for Test if: steps.get_image.outputs.digest != '' - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20.x' registry-url: 'http://localhost:4873' @@ -2510,7 +2510,7 @@ jobs: env: NODE_AUTH_TOKEN: "fake" - # ARM runs natively on ubuntu-22.04-arm runners - no QEMU needed + # ARM runs natively on ubuntu-24.04-arm runners - no QEMU needed - name: Run test for ${{ matrix.prop[2] }}${{ matrix.image[1] }} on ${{ matrix.prop[1] }} if: steps.get_image.outputs.digest != '' @@ -2536,7 +2536,7 @@ jobs: - name: Upload compatibility result if: steps.get_image.outputs.digest != '' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-compatibility-linux-npm-${{ matrix.image[0] }}-${{ matrix.prop[0] }}-apt if-no-files-found: error @@ -2568,7 +2568,7 @@ jobs: - name: Upload tzlc result if: steps.get_image.outputs.digest != '' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-tzlc-linux-npm-${{ matrix.image[0] }}-${{ matrix.prop[0] }}-apt if-no-files-found: error @@ -2657,7 +2657,7 @@ jobs: "arm/v7", # platform spec "arm32v7/", # image prefix ] - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 needs: [ build_npm ] defaults: run: @@ -2672,11 +2672,11 @@ jobs: - name: Checkout code if: steps.get_image.outputs.digest != '' - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node for Registry Server if: steps.get_image.outputs.digest != '' - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20.x' @@ -2719,7 +2719,7 @@ jobs: - name: Download artifacts if: steps.get_image.outputs.digest != '' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-npm path: | @@ -2727,7 +2727,7 @@ jobs: - name: Setup Node for Test if: steps.get_image.outputs.digest != '' - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20.x' registry-url: 'http://localhost:4873' @@ -2747,7 +2747,7 @@ jobs: env: NODE_AUTH_TOKEN: "fake" - # ARM runs natively on ubuntu-22.04-arm runners - no QEMU needed + # ARM runs natively on ubuntu-24.04-arm runners - no QEMU needed - name: Run test for ${{ matrix.prop[2] }}${{ matrix.image[1] }} on ${{ matrix.prop[1] }} if: steps.get_image.outputs.digest != '' @@ -2770,7 +2770,7 @@ jobs: - name: Upload compatibility result if: steps.get_image.outputs.digest != '' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-compatibility-linux-npx-${{ matrix.image[0] }}-${{ matrix.prop[0] }}-apt if-no-files-found: error @@ -2798,7 +2798,7 @@ jobs: - name: Upload tzlc result if: steps.get_image.outputs.digest != '' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-tzlc-linux-npx-${{ matrix.image[0] }}-${{ matrix.prop[0] }}-apt if-no-files-found: error @@ -2869,7 +2869,7 @@ jobs: # "arm/v7", # platform spec # "arm32v7/", # image prefix # ] - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 needs: [ build_npm ] defaults: run: @@ -2884,7 +2884,7 @@ jobs: - name: Setup Node for Registry Server if: steps.get_image.outputs.digest != '' - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20.x' @@ -2927,7 +2927,7 @@ jobs: - name: Download artifacts if: steps.get_image.outputs.digest != '' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-npm path: | @@ -2935,7 +2935,7 @@ jobs: - name: Setup Node for Test if: steps.get_image.outputs.digest != '' - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20.x' registry-url: 'http://localhost:4873' @@ -2955,7 +2955,7 @@ jobs: env: NODE_AUTH_TOKEN: "fake" - # ARM runs natively on ubuntu-22.04-arm runners - no QEMU needed + # ARM runs natively on ubuntu-24.04-arm runners - no QEMU needed - name: Run test for ${{ matrix.prop[2] }}${{ matrix.image[1] }} on ${{ matrix.prop[1] }} if: steps.get_image.outputs.digest != '' @@ -2981,7 +2981,7 @@ jobs: - name: Upload compatibility result if: steps.get_image.outputs.digest != '' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-compatibility-linux-npm-${{ matrix.image[0] }}-${{ matrix.prop[0] }}-apk if-no-files-found: error @@ -3014,7 +3014,7 @@ jobs: - name: Upload tzlc result if: steps.get_image.outputs.digest != '' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-tzlc-linux-npm-${{ matrix.image[0] }}-${{ matrix.prop[0] }}-apt if-no-files-found: error @@ -3084,7 +3084,7 @@ jobs: # "arm/v7", # platform spec # "arm32v7/", # image prefix # ] - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 needs: [ build_npm ] defaults: run: @@ -3099,7 +3099,7 @@ jobs: - name: Setup Node for Registry Server if: steps.get_image.outputs.digest != '' - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20.x' @@ -3142,7 +3142,7 @@ jobs: - name: Download artifacts if: steps.get_image.outputs.digest != '' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-npm path: | @@ -3150,7 +3150,7 @@ jobs: - name: Setup Node for Test if: steps.get_image.outputs.digest != '' - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20.x' registry-url: 'http://localhost:4873' @@ -3170,7 +3170,7 @@ jobs: env: NODE_AUTH_TOKEN: "fake" - # ARM runs natively on ubuntu-22.04-arm runners - no QEMU needed + # ARM runs natively on ubuntu-24.04-arm runners - no QEMU needed - name: Run test for ${{ matrix.prop[2] }}${{ matrix.image[1] }} on ${{ matrix.prop[1] }} if: steps.get_image.outputs.digest != '' @@ -3195,7 +3195,7 @@ jobs: - name: Upload compatibility result if: steps.get_image.outputs.digest != '' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-compatibility-linux-npx-${{ matrix.image[0] }}-${{ matrix.prop[0] }}-apk if-no-files-found: error @@ -3227,7 +3227,7 @@ jobs: - name: Upload tzlc result if: steps.get_image.outputs.digest != '' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-tzlc-linux-npx-${{ matrix.image[0] }}-${{ matrix.prop[0] }}-apk if-no-files-found: error @@ -3299,7 +3299,7 @@ jobs: "arm/v7", # platform spec "arm32v7/", # image prefix ] - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 defaults: run: shell: bash @@ -3320,7 +3320,7 @@ jobs: touch compatibility/npx.${{ matrix.image[0] }}.${{ matrix.prop[0] }}.fail - name: Upload compatibility result - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-compatibility-linux-npm-${{ matrix.image[0] }}-${{ matrix.prop[0] }}-apk-fail if-no-files-found: error @@ -3333,7 +3333,7 @@ jobs: touch tzlc/npx.${{ matrix.image[0] }}.${{ matrix.prop[0] }}.fail - name: Upload tzlc result - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-tzlc-linux-npm-${{ matrix.image[0] }}-${{ matrix.prop[0] }}-apk-fail if-no-files-found: error @@ -3363,7 +3363,7 @@ jobs: steps: - name: Setup Node for Registry Server - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20.x' @@ -3400,14 +3400,14 @@ jobs: mkdir tzlc - name: Download artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-npm path: | dist/npm - name: Setup Node for Test - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20.x' registry-url: 'http://localhost:4873' @@ -3440,7 +3440,7 @@ jobs: touch compatibility/npm.${{ matrix.prop[0] }}.${{ matrix.prop[1] }}.fail - name: Upload compatibility result - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-compatibility-macos-npm-${{ matrix.prop[0] }}-${{ matrix.prop[1] }} if-no-files-found: error @@ -3462,7 +3462,7 @@ jobs: touch tzlc/npm.${{ matrix.prop[0] }}.${{ matrix.prop[1] }}.fail - name: Upload tzlc result - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-tzlc-macos-npm-${{ matrix.prop[0] }}-${{ matrix.prop[1] }} if-no-files-found: error @@ -3492,7 +3492,7 @@ jobs: steps: - name: Setup Node for Registry Server - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20.x' @@ -3529,14 +3529,14 @@ jobs: mkdir tzlc - name: Download artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-npm path: | dist/npm - name: Setup Node for Test - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20.x' registry-url: 'http://localhost:4873' @@ -3568,7 +3568,7 @@ jobs: touch compatibility/npx.${{ matrix.prop[0] }}.${{ matrix.prop[1] }}.fail - name: Upload compatibility result - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-compatibility-macos-npx-${{ matrix.prop[0] }}-${{ matrix.prop[1] }} if-no-files-found: error @@ -3589,7 +3589,7 @@ jobs: touch tzlc/npx.${{ matrix.prop[0] }}.${{ matrix.prop[1] }}.fail - name: Upload tzlc result - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-tzlc-macos-npx-${{ matrix.prop[0] }}-${{ matrix.prop[1] }} if-no-files-found: error @@ -3613,7 +3613,7 @@ jobs: steps: - name: Setup Node for Registry Server - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20.x' @@ -3646,14 +3646,14 @@ jobs: mkdir compatibility - name: Download artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-npm path: | dist/npm - name: Setup Node for Test - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20.x' registry-url: 'http://localhost:4873' @@ -3687,7 +3687,7 @@ jobs: touch compatibility/npm.${{ matrix.os }}.amd64.fail - name: Upload compatibility result - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-compatibility-windows-npm-${{ matrix.os }}-amd64 if-no-files-found: error @@ -3711,7 +3711,7 @@ jobs: steps: - name: Setup Node for Registry Server - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20.x' @@ -3744,14 +3744,14 @@ jobs: mkdir compatibility - name: Download artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-npm path: | dist/npm - name: Setup Node for Test - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20.x' registry-url: 'http://localhost:4873' @@ -3784,7 +3784,7 @@ jobs: touch compatibility/npx.${{ matrix.os }}.amd64.fail - name: Upload compatibility result - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-compatibility-windows-npx-${{ matrix.os }}-amd64 if-no-files-found: error @@ -3794,7 +3794,7 @@ jobs: compatibility_report: name: "Build Compatibility Report" - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 needs: - compatibility_macos_pip - compatibility_windows_pip @@ -3827,10 +3827,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Python 3.13 - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.13' @@ -3839,7 +3839,7 @@ jobs: mkdir dist - name: Download Compatibility Results - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: pattern: icloudpd-compatibility-* merge-multiple: true @@ -3847,7 +3847,7 @@ jobs: compatibility - name: Download tzlc Results - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: pattern: icloudpd-tzlc-* merge-multiple: true @@ -3855,7 +3855,7 @@ jobs: tzlc - name: Download Version - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: pattern: icloudpd-version-thumbprint merge-multiple: true @@ -3868,7 +3868,7 @@ jobs: scripts/compile_compatibility.py dist/icloudpd-version-thumbprint.txt compatibility | tee dist/compatibility-${{inputs.icloudpd_version}}.md - name: Upload compatibility report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-compatibility if-no-files-found: error @@ -3881,7 +3881,7 @@ jobs: scripts/compile_tzlc.py dist/icloudpd-version-thumbprint.txt tzlc "${{needs.get_expected_version_linux_apt.outputs.expected_version}}" "${{needs.get_expected_version_linux_apk.outputs.expected_version}}" "${{needs.get_expected_version_macos.outputs.expected_version}}" | tee dist/tzlc-${{inputs.icloudpd_version}}.md - name: Upload tzlc report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-tzlc if-no-files-found: error diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index 5acb67bb0..000000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,68 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# ******** NOTE ******** - -name: "CodeQL" - -on: - push: - branches: [ master ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ master ] - schedule: - - cron: '40 3 * * 1' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-22.04 - - strategy: - fail-fast: false - matrix: - language: [ 'python' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] - # Learn more... - # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 - - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..028c6e4df --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,30 @@ +name: CodeQL + +on: + push: + branches: [master] + pull_request: + branches: [master] + schedule: + - cron: "0 6 * * 1" + +permissions: + contents: read + security-events: write + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: python + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:python" diff --git a/.github/workflows/compile-notes.yml b/.github/workflows/compile-notes.yml index 4a72ca1d8..f1a0ee34e 100644 --- a/.github/workflows/compile-notes.yml +++ b/.github/workflows/compile-notes.yml @@ -11,18 +11,18 @@ on: jobs: build_notes: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - name: Download artifacts (compatibility) - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-compatibility path: | dist - name: Download artifacts (changelog) - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-changelog path: | @@ -34,7 +34,7 @@ jobs: cat dist/compatibility-*.md >> dist/notes-${{inputs.icloudpd_version}}.md - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-notes if-no-files-found: error diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 5b7da5ed5..2cf751a42 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -8,9 +8,9 @@ name: Create Release jobs: get_version: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Retrieve version id: get_version diff --git a/.github/workflows/extract-changelog.yml b/.github/workflows/extract-changelog.yml index fc183d708..a9280853c 100644 --- a/.github/workflows/extract-changelog.yml +++ b/.github/workflows/extract-changelog.yml @@ -11,9 +11,9 @@ on: jobs: extract_changelog: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Make dist folder run: | @@ -29,7 +29,7 @@ jobs: cat dist/changelog-${{inputs.icloudpd_version}}.md - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-changelog if-no-files-found: error diff --git a/.github/workflows/patch-version.yml b/.github/workflows/patch-version.yml index ef098e4e0..19eaada80 100644 --- a/.github/workflows/patch-version.yml +++ b/.github/workflows/patch-version.yml @@ -7,17 +7,17 @@ on: jobs: patch_version: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Patch Version run: | scripts/patch_version - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: icloudpd-version-info if-no-files-found: error diff --git a/.github/workflows/produce-artifacts.yml b/.github/workflows/produce-artifacts.yml index b133feedd..1c459cb23 100644 --- a/.github/workflows/produce-artifacts.yml +++ b/.github/workflows/produce-artifacts.yml @@ -14,7 +14,7 @@ on: jobs: skip_check: # continue-on-error: true # Uncomment once integration is finished - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 # Map a step output to a job output outputs: should_skip: ${{ steps.skip_check.outputs.should_skip }} @@ -30,9 +30,9 @@ jobs: get_version: needs: skip_check if: needs.skip_check.outputs.should_skip != 'true' - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Retrieve version id: get_version diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 92e3e24be..22e694ddc 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -24,14 +24,14 @@ name: Publish Release jobs: docker: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Download artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-oci path: | @@ -48,7 +48,7 @@ jobs: (skopeo copy --preserve-digests --src-creds=${{ secrets.DOCKERHUB_USERNAME }}:${{ secrets.DOCKERHUB_PASSWORD }} --dest-creds=${{ secrets.DOCKERHUB_USERNAME }}:${{ secrets.DOCKERHUB_PASSWORD }} --all docker://docker.io/icloudpd/icloudpd:${{inputs.icloudpd_version}} docker://docker.io/icloudpd/icloudpd:latest) - name: Update repo description - uses: peter-evans/dockerhub-description@v3 + uses: peter-evans/dockerhub-description@v5 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} @@ -57,16 +57,16 @@ jobs: short-description: ${{ github.event.repository.description }} npm: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20.x' registry-url: 'https://registry.npmjs.org' - name: Download artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-npm path: | @@ -85,29 +85,28 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} pypi: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - - name: Set up Python 3.13 - uses: actions/setup-python@v5 + - name: Set up uv + uses: astral-sh/setup-uv@v5 with: - python-version: '3.13' - + enable-cache: true + - name: Install Dev dependencies - run: | - pip3 install --group dev + run: uv sync --python 3.13 --group dev - name: Download artifacts (src) - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-src path: | dist - name: Download artifacts (bin) - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: pattern: icloudpd-bin-* merge-multiple: true @@ -120,21 +119,21 @@ jobs: TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | - python3 -m twine upload --non-interactive --disable-progress-bar dist/*.whl + uv run python3 -m twine upload --non-interactive --disable-progress-bar dist/*.whl gh_release: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - name: Download artifacts (src) - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-src path: | dist - name: Download artifacts (bin) - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: pattern: icloudpd-bin-* merge-multiple: true @@ -142,28 +141,28 @@ jobs: dist - name: Download artifacts (docker) - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-oci path: | dist - name: Download artifacts (compatibility) - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-compatibility path: | dist - name: Download artifacts (tzlc compatibility) - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-tzlc path: | dist - name: Download artifacts (notes) - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-notes path: | diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index d76376b99..42dfbf79e 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -1,6 +1,3 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python -# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions - name: Quality Checks on: @@ -8,14 +5,11 @@ on: branches: - '**' pull_request: - # branches: [ master ] workflow_dispatch: jobs: skip_check: - # continue-on-error: true # Uncomment once integration is finished - runs-on: ubuntu-22.04 - # Map a step output to a job output + runs-on: ubuntu-24.04 outputs: should_skip: ${{ steps.skip_check.outputs.should_skip }} steps: @@ -30,146 +24,128 @@ jobs: lint: needs: skip_check if: needs.skip_check.outputs.should_skip != 'true' - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 strategy: matrix: python-version: ['3.10', '3.11', '3.12', '3.13'] + env: + UV_PYTHON: ${{ matrix.python-version }} steps: - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v5 with: - python-version: ${{ matrix.python-version }} - - name: Install Test dependencies - run: > - python3 -m pip install --disable-pip-version-check -r requirements-pip.txt && - pip3 install --disable-pip-version-check . --group test - + enable-cache: true + - name: Install dependencies + run: uv sync --group test - name: Lint - run: | - scripts/lint + run: uv run ruff check --ignore "E402" - type_check: + type_check: needs: skip_check if: needs.skip_check.outputs.should_skip != 'true' - runs-on: ubuntu-22.04 - strategy: - matrix: + runs-on: ubuntu-24.04 + strategy: + matrix: python-version: ['3.10', '3.11', '3.12', '3.13'] - steps: - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - cache: 'pip' - - name: Install Test dependencies - run: > - python3 -m pip install --disable-pip-version-check -r requirements-pip.txt && - pip3 install --disable-pip-version-check . --group test - - name: Type Check - run: | - scripts/type_check + env: + UV_PYTHON: ${{ matrix.python-version }} + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Install dependencies + run: uv sync --group test + - name: Type Check + run: uv run python -m mypy src tests --strict --python-version 3.10 patch_version: needs: skip_check if: needs.skip_check.outputs.should_skip != 'true' uses: ./.github/workflows/patch-version.yml - test: + test: needs: [skip_check, patch_version] if: needs.skip_check.outputs.should_skip != 'true' - runs-on: ubuntu-22.04 - strategy: - matrix: + runs-on: ubuntu-24.04 + strategy: + matrix: python-version: ['3.10', '3.11', '3.12', '3.13'] - steps: - - name: Install Locales for Tests - run: | - sudo apt-get update && sudo apt-get -y install locales locales-all - - - uses: actions/checkout@v4 - + env: + UV_PYTHON: ${{ matrix.python-version }} + steps: + - name: Install system dependencies + run: | + sudo apt-get update && sudo apt-get -y install locales locales-all libimage-exiftool-perl + - uses: actions/checkout@v6 - name: Download version info - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-version-info path: | src/foundation - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - cache: 'pip' - - - name: Install Test dependencies - run: > - python3 -m pip install --disable-pip-version-check -r requirements-pip.txt && - pip3 install --disable-pip-version-check . --group test - + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Install dependencies + run: uv sync --group test - name: Test - run: | - scripts/test + run: uv run python -m pytest --cov=icloudpd --cov-report html --cov-report term-missing --numprocesses auto - test_non_linux: + test_non_linux: needs: [skip_check, patch_version] if: needs.skip_check.outputs.should_skip != 'true' - runs-on: ${{ matrix.os }} + runs-on: ${{ matrix.os }} defaults: run: shell: bash - strategy: + strategy: fail-fast: false - matrix: + matrix: python-version: [3.13] - os: - - "macos-13" + os: + - "macos-15" - "macos-14" - "windows-2025" - - "windows-2022" - steps: - - - uses: actions/checkout@v4 - + - "windows-2025" + env: + UV_PYTHON: ${{ matrix.python-version }} + steps: + - uses: actions/checkout@v6 - name: Download version info - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: icloudpd-version-info path: | src/foundation - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - cache: 'pip' - - - name: Install Test dependencies - run: > - python3 -m pip install --disable-pip-version-check -r requirements-pip.txt && - pip3 install --disable-pip-version-check . --group test - - - name: Test + - name: Install exiftool run: | - scripts/test + if [ "$RUNNER_OS" = "macOS" ]; then + brew install exiftool + elif [ "$RUNNER_OS" = "Windows" ]; then + choco install exiftool -y + fi + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Install dependencies + run: uv sync --group test + - name: Test + run: uv run python -m pytest --cov=icloudpd --cov-report html --cov-report term-missing --numprocesses auto get_version: needs: skip_check if: needs.skip_check.outputs.should_skip != 'true' - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 - + - uses: actions/checkout@v6 - name: Retrieve version id: get_version run: | echo icloudpd_version=$(scripts/get_version) >> $GITHUB_OUTPUT - - name: Log version run: | echo "icloudpd_version=${{steps.get_version.outputs.icloudpd_version}}" - outputs: icloudpd_version: ${{steps.get_version.outputs.icloudpd_version}} @@ -179,5 +155,3 @@ jobs: uses: ./.github/workflows/extract-changelog.yml with: icloudpd_version: ${{needs.get_version.outputs.icloudpd_version}} - - \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 000000000..24ee5b1be --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/pyproject.toml b/pyproject.toml index 409e2b3e5..58596ee0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [build-system] requires = [ - "setuptools==80.9.0", - "wheel==0.45.1", + "setuptools==82.0.1", + "wheel==0.46.3", ] build-backend = "setuptools.build_meta" @@ -23,57 +23,57 @@ classifiers = [ "License :: OSI Approved :: MIT License", ] dependencies = [ - "requests==2.32.3", - "schema==0.7.7", - "tqdm==4.67.1", + "requests==2.33.1", + "schema==0.7.8", + "tqdm==4.67.3", "piexif==1.1.3", "urllib3==1.26.20", - "typing_extensions==4.14.0", - "Flask==3.1.1", + "typing_extensions==4.15.0", + "Flask==3.1.3", "waitress==3.0.2", # from pyicloud_ipd "tzlocal==5.3.1", - "pytz==2025.2", - "certifi==2025.4.26", - "keyring==25.6.0", + "pytz==2026.1.post1", + "certifi==2026.2.25", + "keyring==25.7.0", "keyrings-alt==5.0.2", "srp==1.0.22", ] [dependency-groups] doc = [ - "furo==2024.8.6", - "Sphinx==7.4.7", + "furo==2025.12.19", + "Sphinx==8.1.3", "sphinx-autobuild==2024.10.3", - "myst-parser==3.0.1" + "myst-parser==4.0.1" ] dev = [ - "twine==6.1.0", - "pyinstaller==6.14.0", - "wheel==0.45.1", + "twine==6.2.0", + "pyinstaller==6.19.0", + "wheel==0.46.3", ] devlinux = [ - "auditwheel==6.4.0", + "auditwheel==6.6.0", #"staticx==0.14.1", - "scons==4.9.1" + "scons==4.10.1" ] test = [ - "pytest==8.4.0", + "pytest==9.0.2", "mock==5.2.0", - "freezegun==1.5.2", - "vcrpy==7.0.0", - "pytest-cov==6.2.1", - "ruff==0.11.13", + "freezegun==1.5.5", + "vcrpy==8.1.1", + "pytest-cov==7.1.0", + "ruff==0.15.8", "pytest-timeout==2.4.0", - "pytest-xdist==3.7.0", - "mypy==1.16.0", - "types-pytz==2025.2.0.20250516", + "pytest-xdist==3.8.0", + "mypy==1.19.1", + "types-pytz==2026.1.1.20260304", "types-tzlocal==5.1.0.1", "types-requests==2.31.0.2", "types-urllib3==1.26.25.14", - "types-tqdm==4.67.0.20250516", - "types-mock==5.2.0.20250516", - "types-waitress==3.0.1.20241117", + "types-tqdm==4.67.3.20260303", + "types-mock==5.2.0.20250924", + "types-waitress==3.0.1.20260316", ] [project.urls] diff --git a/src/icloudpd/autodelete.py b/src/icloudpd/autodelete.py index fab222624..b3690f3a2 100644 --- a/src/icloudpd/autodelete.py +++ b/src/icloudpd/autodelete.py @@ -9,6 +9,7 @@ from tzlocal import get_localzone +from icloudpd.manifest import ManifestDB from icloudpd.paths import local_download_path from pyicloud_ipd.asset_version import calculate_version_filename from pyicloud_ipd.raw_policy import RawTreatmentPolicy @@ -39,6 +40,7 @@ def autodelete_photos( _sizes: Sequence[AssetVersionSize], lp_filename_generator: Callable[[str], str], raw_policy: RawTreatmentPolicy, + manifest: ManifestDB | None = None, ) -> None: """ Scans the "Recently Deleted" folder and deletes any matching files @@ -72,7 +74,7 @@ def autodelete_photos( # e.g. ValueError: year=5 is before 1900 # (https://github.com/icloud-photos-downloader/icloud_photos_downloader/issues/122) # Just use the Unix epoch - created_date = datetime.datetime.fromtimestamp(0) + created_date = datetime.datetime.fromtimestamp(0, tz=datetime.timezone.utc) date_path = folder_structure.format(created_date) download_dir = os.path.join(directory, date_path) @@ -111,6 +113,17 @@ def autodelete_photos( ) for path in paths: if os.path.exists(path): + if manifest is not None and not path.endswith(".xmp"): + rel_path = os.path.relpath(path, directory) + # Guard: don't delete files shared by multiple assets + if manifest.count_by_path(rel_path) > 1: + logger.debug( + "Keeping %s — other assets still reference it", + path, + ) + continue logger.debug("Deleting %s...", path) delete_local = delete_file_dry_run if dry_run else delete_file - delete_local(logger, path) + if delete_local(logger, path) and manifest is not None and not path.endswith(".xmp"): + rel_path = os.path.relpath(path, directory) + manifest.remove_by_path(rel_path) diff --git a/src/icloudpd/base.py b/src/icloudpd/base.py index 2c9fe24dc..954b463ca 100644 --- a/src/icloudpd/base.py +++ b/src/icloudpd/base.py @@ -1,6 +1,8 @@ #!/usr/bin/env python """Main script that uses Click to parse command-line arguments""" +import base64 +import contextlib import datetime import getpass import itertools @@ -43,17 +45,24 @@ from icloudpd.autodelete import autodelete_photos from icloudpd.config import GlobalConfig, UserConfig from icloudpd.counter import Counter +from icloudpd.dir_cache import DirCache from icloudpd.email_notifications import send_2sa_notification from icloudpd.filename_policies import build_filename_with_policies, create_filename_builder from icloudpd.log_level import LogLevel +from icloudpd.manifest import ManifestDB, ManifestRow +from icloudpd.metadata_writer import MetadataUpdate from icloudpd.mfa_provider import MFAProvider from icloudpd.password_provider import PasswordProvider from icloudpd.paths import local_download_path, remove_unicode_chars from icloudpd.server import serve_app from icloudpd.status import Status, StatusExchange from icloudpd.string_helpers import parse_timestamp_or_timedelta, truncate_middle -from icloudpd.xmp_sidecar import generate_xmp_file -from pyicloud_ipd.asset_version import add_suffix_to_filename, calculate_version_filename +from icloudpd.xmp_sidecar import build_metadata, generate_xmp_file +from pyicloud_ipd.asset_version import ( + AssetVersion, + add_suffix_to_filename, + calculate_version_filename, +) from pyicloud_ipd.base import PyiCloudService from pyicloud_ipd.exceptions import ( PyiCloudAPIResponseException, @@ -78,7 +87,7 @@ size_to_suffix, store_password_in_keyring, ) -from pyicloud_ipd.version_size import AssetVersionSize, LivePhotoVersionSize +from pyicloud_ipd.version_size import AssetVersionSize, LivePhotoVersionSize, version_to_resource freeze_support() # fmt: skip # fixing tqdm on macos @@ -101,6 +110,45 @@ def build_filename_cleaner(keep_unicode: bool) -> Callable[[str], str]: return remove_unicode_chars +def _generate_dedup_suffix(asset_id: str) -> str: + """Generate a deterministic dedup suffix from an asset ID using URL-safe base64.""" + return base64.urlsafe_b64encode(asset_id.encode("utf-8")).decode("ascii")[:7] + + +def _check_collision( + manifest: "ManifestDB", + photo_id: str, + rel_path: str, + download_path: str, + directory: str, + dir_cache: "DirCache", + logger: Logger, + asset_resource: str = "resOriginal", +) -> tuple[str, str, bool, str | None]: + """Check if another asset owns this path and self-heal if needed. + + Returns (rel_path, download_path, file_exists, dedup_suffix). + If no collision, returns the inputs unchanged with dedup_suffix=None. + """ + path_owner = manifest.lookup_by_path(rel_path) + if path_owner is not None and path_owner.asset_id != photo_id: + suffix = _generate_dedup_suffix(photo_id) + dedup_suffix = f"_{suffix}" + new_download_path = add_suffix_to_filename(dedup_suffix, download_path) + new_rel_path = os.path.relpath(new_download_path, directory) + manifest.update_path(photo_id, manifest.zone_id, asset_resource, new_rel_path) + file_exists = dir_cache.isfile(new_download_path) + logger.debug( + "Collision on %s (owned by %s), deduping %s to %s", + rel_path, + path_owner.asset_id[:12], + photo_id[:12], + new_rel_path, + ) + return new_rel_path, new_download_path, file_exists, dedup_suffix + return rel_path, download_path, True, None + + def lp_filename_concatinator(filename: str) -> str: """Generate concatenator-style live photo filename, adding HEVC suffix for HEIC files""" import os @@ -239,6 +287,16 @@ def run_with_configs(global_config: GlobalConfig, user_configs: Sequence[UserCon # Create shared logger logger = create_logger(global_config) + # Check exiftool availability if any user has --write-metadata-exif + if any(uc.write_metadata_exif for uc in user_configs): + from icloudpd.metadata_writer import ExiftoolNotFoundError, check_exiftool + try: + ver = check_exiftool() + logger.info("exiftool %s found for --write-metadata", ver) + except ExiftoolNotFoundError as e: + logger.error(str(e)) + return 1 + # Create shared status exchange for web server and progress tracking shared_status_exchange = StatusExchange() @@ -396,6 +454,11 @@ def password_provider(_username: str) -> str | None: filename_builder, ) + dir_cache = DirCache() + manifest: ManifestDB | None = None + if user_config.directory is not None and os.path.isdir(user_config.directory): + manifest = ManifestDB(user_config.directory) + manifest.open() downloader = ( partial( download_builder, @@ -406,14 +469,18 @@ def password_provider(_username: str) -> str | None: user_config.force_size, global_config.only_print_filenames, user_config.set_exif_datetime, + user_config.write_metadata_exif, user_config.skip_live_photos, user_config.live_photo_size, user_config.dry_run, user_config.file_match_policy, - user_config.xmp_sidecar, + user_config.write_metadata_xmp, lp_filename_generator, filename_builder, user_config.align_raw, + dir_cache, + manifest, + user_config.accept_apple_changes, ) if user_config.directory is not None else (lambda _s, _c, _p: False) @@ -435,17 +502,22 @@ def password_provider(_username: str) -> str | None: # Use core_single_run since we've disabled watch at this level logger.info(f"Processing user: {user_config.username}") - result = core_single_run( - logger, - status_exchange, - global_config, - user_config, - password_providers_dict, - passer, - downloader, - notificator, - lp_filename_generator, - ) + try: + result = core_single_run( + logger, + status_exchange, + global_config, + user_config, + password_providers_dict, + passer, + downloader, + notificator, + lp_filename_generator, + manifest, + ) + finally: + if manifest is not None: + manifest.close() # If any user config fails and we're not in watch mode, return the error code if result != 0: @@ -561,6 +633,252 @@ def skip_created_after_message( return f"Skipping {filename}, as it was created {photo.created}, after {target_created_date}." +def _extract_manifest_metadata(photo: PhotoAsset, version: AssetVersion) -> Dict[str, Any]: + """Extract all metadata fields from a PhotoAsset for manifest.upsert().""" + import json as _json # noqa: I001 + + from icloudpd.xmp_sidecar import build_metadata + + fields = photo._asset_record.get("fields", {}) + + # Status flags + is_favorite = int(fields.get("isFavorite", {}).get("value", 0)) + is_hidden = int(fields.get("isHidden", {}).get("value", 0)) + is_deleted = int(fields.get("isDeleted", {}).get("value", 0)) + + # Dates + asset_date_ms = fields.get("assetDate", {}).get("value") + asset_date = None + if asset_date_ms is not None: + with contextlib.suppress(ValueError, OSError): + asset_date = datetime.datetime.fromtimestamp( + int(asset_date_ms) / 1000, tz=datetime.timezone.utc + ).isoformat() + + added_date_ms = fields.get("addedDate", {}).get("value") + added_date = None + if added_date_ms is not None: + with contextlib.suppress(ValueError, OSError): + added_date = datetime.datetime.fromtimestamp( + int(added_date_ms) / 1000, tz=datetime.timezone.utc + ).isoformat() + + # Dimensions + original_width = None + original_height = None + try: + master_fields = photo._master_record.get("fields", {}) + original_width = int(master_fields["resOriginalWidth"]["value"]) + original_height = int(master_fields["resOriginalHeight"]["value"]) + except (KeyError, TypeError, ValueError): + pass + + # Duration (video) + duration = None + duration_val = fields.get("duration", {}).get("value") + if duration_val is not None: + with contextlib.suppress(ValueError, TypeError): + duration = int(duration_val) + + # Item type + item_type = None + try: + item_type_val = photo._master_record.get("fields", {}).get("itemType", {}).get("value") + if item_type_val: + item_type = str(item_type_val) + except (KeyError, TypeError): + pass + + # Filename + filename = None + with contextlib.suppress(KeyError, TypeError, AttributeError): + filename = photo.filename + + # Content metadata — reuse XMP's build_metadata for decoded values + _xmp_logger = logging.getLogger("icloudpd.manifest.xmp") + try: + xmp = build_metadata(_xmp_logger, photo._asset_record) + title = xmp.Title + description = xmp.Description + keywords = _json.dumps(xmp.Keywords) if xmp.Keywords else None + gps_latitude = xmp.GPSLatitude + gps_longitude = xmp.GPSLongitude + gps_altitude = xmp.GPSAltitude + gps_speed = xmp.GPSSpeed + gps_timestamp = xmp.GPSTimeStamp.isoformat() if xmp.GPSTimeStamp else None + orientation = xmp.Orientation + except Exception: + title = description = keywords = None + gps_latitude = gps_longitude = gps_altitude = None + gps_speed = None + gps_timestamp = None + orientation = None + + # Timezone offset + timezone_offset = fields.get("timeZoneOffset", {}).get("value") + if timezone_offset is not None: + with contextlib.suppress(ValueError, TypeError): + timezone_offset = int(timezone_offset) + + # Asset subtype / HDR / burst metadata + asset_subtype = fields.get("assetSubtypeV2", {}).get("value") + if asset_subtype is not None: + with contextlib.suppress(ValueError, TypeError): + asset_subtype = int(asset_subtype) + + hdr_type = fields.get("assetHDRType", {}).get("value") + if hdr_type is not None: + with contextlib.suppress(ValueError, TypeError): + hdr_type = int(hdr_type) + + burst_flags = fields.get("burstFlags", {}).get("value") + if burst_flags is not None: + with contextlib.suppress(ValueError, TypeError): + burst_flags = int(burst_flags) + + burst_flags_ext = fields.get("burstFlagsExt", {}).get("value") + if burst_flags_ext is not None: + with contextlib.suppress(ValueError, TypeError): + burst_flags_ext = int(burst_flags_ext) + + burst_id = fields.get("burstId", {}).get("value") + if burst_id is not None: + burst_id = str(burst_id) + + # Original orientation from master record + original_orientation = None + try: + master_fields = photo._master_record.get("fields", {}) + oo_val = master_fields.get("originalOrientation", {}).get("value") + if oo_val is not None: + original_orientation = int(oo_val) + except (KeyError, TypeError, ValueError): + pass + + # Full asset record fields as JSON blob + raw_fields = _json.dumps(photo._asset_record.get("fields", {}), default=str) + + return { + "version_checksum": version.checksum, + "change_tag": photo._asset_record.get("recordChangeTag"), + "item_type": item_type, + "filename": filename, + "asset_date": asset_date, + "added_date": added_date, + "is_favorite": is_favorite, + "is_hidden": is_hidden, + "is_deleted": is_deleted, + "original_width": original_width, + "original_height": original_height, + "duration": duration, + "orientation": orientation, + "title": title, + "description": description, + "keywords": keywords, + "gps_latitude": gps_latitude, + "gps_longitude": gps_longitude, + "gps_altitude": gps_altitude, + "gps_speed": gps_speed, + "gps_timestamp": gps_timestamp, + "timezone_offset": timezone_offset, + "asset_subtype": asset_subtype, + "hdr_type": hdr_type, + "burst_flags": burst_flags, + "burst_flags_ext": burst_flags_ext, + "burst_id": burst_id, + "original_orientation": original_orientation, + "raw_fields": raw_fields, + } + + +def _metadata_matches_manifest( + manifest_row: ManifestRow, + update: MetadataUpdate, +) -> bool: + """Check if API metadata matches what's stored in the manifest. + + Used for the optimistic mtime skip: if the API values haven't changed + since the last run, and the file hasn't been modified, we can skip + the exiftool read+write entirely. + """ + if update.rating is not None and update.rating != (5 if manifest_row.is_favorite else 0): + return False + if update.title is not None and update.title != (manifest_row.title or ""): + return False + if update.description is not None and update.description != (manifest_row.description or ""): + return False + if update.orientation is not None and update.orientation != manifest_row.orientation: + return False + if update.gps_latitude is not None and manifest_row.gps_latitude is not None: + if abs(update.gps_latitude - manifest_row.gps_latitude) > 1e-5: + return False + elif update.gps_latitude is not None or manifest_row.gps_latitude is not None: + return False + if update.gps_longitude is not None and manifest_row.gps_longitude is not None: + if abs(update.gps_longitude - manifest_row.gps_longitude) > 1e-5: + return False + elif update.gps_longitude is not None or manifest_row.gps_longitude is not None: + return False + if update.gps_altitude is not None and manifest_row.gps_altitude is not None: + if abs(update.gps_altitude - manifest_row.gps_altitude) > 0.1: + return False + elif update.gps_altitude is not None or manifest_row.gps_altitude is not None: + return False + if update.keywords is not None: + import json + manifest_kw = json.loads(manifest_row.keywords) if manifest_row.keywords else [] + if sorted(update.keywords) != sorted(manifest_kw): + return False + return True + + +def _write_exif_with_mtime_check( + logger: logging.Logger, + manifest: "ManifestDB | None", + photo: "PhotoAsset", + file_path: str, + write_metadata_exif: frozenset[str], + dry_run: bool, + asset_resource: str, +) -> None: + """Write EXIF metadata with optimistic mtime-based skip. + + If the file's mtime matches the stored value and API metadata hasn't + changed, skip the exiftool invocation entirely. + """ + from icloudpd.metadata_writer import ( + extract_metadata_update, + ) + from icloudpd.metadata_writer import ( + write_metadata as write_file_metadata, + ) + + xmp_meta = build_metadata(logger, photo._asset_record) + update = extract_metadata_update(photo._asset_record, xmp_meta) + + # Optimistic skip: if we've previously written metadata and nothing changed + if manifest is not None: + manifest_row = manifest.lookup(photo.id, manifest.zone_id, asset_resource) + if manifest_row is not None and manifest_row.file_mtime is not None: + try: + current_mtime = os.stat(file_path).st_mtime + except OSError: + current_mtime = None + if current_mtime is not None and current_mtime == manifest_row.file_mtime and _metadata_matches_manifest(manifest_row, update): + return # Skip — file untouched and metadata unchanged + + write_file_metadata(file_path, update, set(write_metadata_exif), dry_run) + + # Record mtime so we can skip exiftool on subsequent runs. + # Set after both successful writes AND confirmed no-ops (metadata already correct). + if not dry_run and manifest is not None: + try: + mtime = os.stat(file_path).st_mtime + manifest.update_file_mtime(photo.id, manifest.zone_id, asset_resource, mtime) + except OSError: + pass + + def download_builder( logger: logging.Logger, folder_structure: str, @@ -569,14 +887,18 @@ def download_builder( force_size: bool, only_print_filenames: bool, set_exif_datetime: bool, + write_metadata_exif: frozenset[str], skip_live_photos: bool, live_photo_size: LivePhotoVersionSize, dry_run: bool, file_match_policy: FileMatchPolicy, - xmp_sidecar: bool, + write_metadata_xmp: frozenset[str], lp_filename_generator: Callable[[str], str], filename_builder: Callable[[PhotoAsset], str], raw_policy: RawTreatmentPolicy, + dir_cache: DirCache, + manifest: "ManifestDB | None", + accept_apple_changes: bool, icloud: PyiCloudService, counter: Counter, photo: PhotoAsset, @@ -605,7 +927,7 @@ def download_builder( # e.g. ValueError: year=5 is before 1900 # (https://github.com/icloud-photos-downloader/icloud_photos_downloader/issues/122) # Just use the Unix epoch - created_date = datetime.datetime.fromtimestamp(0) + created_date = datetime.datetime.fromtimestamp(0, tz=datetime.timezone.utc) date_path = folder_structure.format(created_date) try: @@ -660,31 +982,127 @@ def download_builder( ) download_path = local_download_path(filename, download_dir) + dedup_suffix: str | None = None original_download_path = None - file_exists = os.path.isfile(download_path) + file_exists = dir_cache.isfile(download_path) if not file_exists and download_size == AssetVersionSize.ORIGINAL: # Deprecation - We used to download files like IMG_1234-original.jpg, # so we need to check for these. # Now we match the behavior of iCloud for Windows: IMG_1234.jpg original_download_path = add_suffix_to_filename("-original", download_path) - file_exists = os.path.isfile(original_download_path) + file_exists = dir_cache.isfile(original_download_path) + + # Compute relative path for manifest — use the actual file path on disk + actual_path = original_download_path if original_download_path and file_exists else download_path + rel_path = os.path.relpath(actual_path, directory) if manifest else "" + asset_res = version_to_resource(download_size) + dedup_download = False if file_exists: - if file_match_policy == FileMatchPolicy.NAME_SIZE_DEDUP_WITH_SUFFIX: - # for later: this crashes if download-size medium is specified - file_size = os.stat(original_download_path or download_path).st_size + if manifest is not None: + # Identity-based matching: check manifest for this asset + manifest_row = manifest.lookup(photo.id, manifest.zone_id, asset_res) + if manifest_row is not None: + # Check if this asset actually owns the file + rel_path, download_path, file_exists, dedup_suffix = _check_collision( + manifest, photo.id, rel_path, download_path, directory, dir_cache, logger, + asset_resource=asset_res, + ) + dedup_download = dedup_suffix is not None and not file_exists + if dedup_suffix is None and manifest_row.version_size != version.size: + # iCloud has a different version + if accept_apple_changes: + file_exists = False + rel_path = os.path.relpath(download_path, directory) + logger.info( + "%s version changed (manifest: %d, iCloud: %d), re-downloading", + truncate_middle(download_path, 96), + manifest_row.version_size, + version.size, + ) + else: + logger.warning( + "%s version changed (manifest: %d, iCloud: %d), " + "use --accept-apple-changes to re-download", + truncate_middle(download_path, 96), + manifest_row.version_size, + version.size, + ) + elif dedup_suffix is None: + # No collision, version matches — update metadata if needed + meta = _extract_manifest_metadata(photo, version) + manifest.upsert( + asset_id=photo.id, + zone_id=manifest.zone_id, + local_path=rel_path, + version_size=version.size, + asset_resource=asset_res, + **meta, + ) + else: + # File exists on disk but not in manifest for this asset + path_owner = manifest.lookup_by_path(rel_path) + if path_owner is not None and path_owner.asset_id != photo.id: + # Another asset owns this file — generate unique filename + suffix = _generate_dedup_suffix(photo.id) + dedup_suffix = f"_{suffix}" + download_path = add_suffix_to_filename(dedup_suffix, download_path) + rel_path = os.path.relpath(download_path, directory) + file_exists = dir_cache.isfile(download_path) + if file_exists: + # Deduped file already exists — adopt it + meta = _extract_manifest_metadata(photo, version) + manifest.upsert( + asset_id=photo.id, + zone_id=manifest.zone_id, + local_path=rel_path, + version_size=version.size, + asset_resource=asset_res, + **meta, + ) + logger.debug( + "%s adopted into manifest (deduped)", + truncate_middle(download_path, 96), + ) + else: + dedup_download = True + else: + # No owner (orphan file) or this is the first adopter + meta = _extract_manifest_metadata(photo, version) + manifest.upsert( + asset_id=photo.id, + zone_id=manifest.zone_id, + local_path=rel_path, + version_size=version.size, + asset_resource=asset_res, + **meta, + ) + logger.debug( + "%s adopted into manifest", + truncate_middle(download_path, 96), + ) + elif file_match_policy == FileMatchPolicy.NAME_SIZE_DEDUP_WITH_SUFFIX: + # Legacy size-based dedup (no manifest available) + file_size = dir_cache.stat_size(original_download_path or download_path) photo_size = version.size if file_size != photo_size: - download_path = (f"-{photo_size}.").join(download_path.rsplit(".", 1)) - logger.debug("%s deduplicated", truncate_middle(download_path, 96)) - file_exists = os.path.isfile(download_path) + download_path = (f"-{photo_size}.").join( + download_path.rsplit(".", 1) + ) + logger.debug( + "%s deduplicated", truncate_middle(download_path, 96) + ) + file_exists = dir_cache.isfile(download_path) if file_exists: counter.increment() logger.debug("%s already exists", truncate_middle(download_path, 96)) if not file_exists: - counter.reset() + if dedup_download: + counter.increment() + else: + counter.reset() if only_print_filenames: print(download_path) else: @@ -725,14 +1143,35 @@ def download_builder( ) if not dry_run: download.set_utime(download_path, created_date) + with contextlib.suppress(OSError): + dir_cache.notify_new_file(download_path, os.stat(download_path).st_size) + if manifest is not None: + meta = _extract_manifest_metadata(photo, version) + manifest.upsert( + asset_id=photo.id, + zone_id=manifest.zone_id, + local_path=rel_path, + version_size=version.size, + asset_resource=asset_res, + **meta, + ) logger.info("Downloaded %s", truncated_path) - if xmp_sidecar: - generate_xmp_file(logger, download_path, photo._asset_record, dry_run) + if write_metadata_xmp: + generate_xmp_file(logger, download_path, photo._asset_record, dry_run, dir_cache) + + if write_metadata_exif and os.path.splitext(download_path.lower())[1] not in (".mov", ".mp4", ".m4v", ".avi"): + # Skip in-file metadata for videos — XMP sidecar is the canonical source. + # QuickTime Keys metadata is redundant when the sidecar exists. + _write_exif_with_mtime_check( + logger, manifest, photo, download_path, write_metadata_exif, + dry_run, asset_res, + ) # Also download the live photo if present if not skip_live_photos: lp_size = live_photo_size + lp_res = version_to_resource(lp_size) photo_versions_with_policy = photo.versions_with_raw_policy(raw_policy) if lp_size in photo_versions_with_policy: version = photo_versions_with_policy[lp_size] @@ -753,37 +1192,108 @@ def download_builder( else: pass lp_download_path = os.path.join(download_dir, lp_filename) + # Propagate dedup suffix from main photo to live photo + if dedup_suffix is not None: + lp_download_path = add_suffix_to_filename(dedup_suffix, lp_download_path) + lp_rel_path = os.path.relpath(lp_download_path, directory) - lp_file_exists = os.path.isfile(lp_download_path) + lp_file_exists = dir_cache.isfile(lp_download_path) if only_print_filenames: + if lp_file_exists and manifest is not None: + # Check if version changed — need to print the filename for re-download + lp_manifest_row = manifest.lookup(photo.id, manifest.zone_id, lp_res) + if lp_manifest_row is not None and lp_manifest_row.version_size != version.size: + lp_file_exists = False if not lp_file_exists: print(lp_download_path) # Handle deduplication case for only_print_filenames - if ( - lp_file_exists - and file_match_policy == FileMatchPolicy.NAME_SIZE_DEDUP_WITH_SUFFIX - ): - lp_file_size = os.stat(lp_download_path).st_size - lp_photo_size = version.size - if lp_file_size != lp_photo_size: - lp_download_path = (f"-{lp_photo_size}.").join( - lp_download_path.rsplit(".", 1) - ) - logger.debug("%s deduplicated", truncate_middle(lp_download_path, 96)) - # Print the deduplicated filename but don't download - print(lp_download_path) + if lp_file_exists and manifest is None and file_match_policy == FileMatchPolicy.NAME_SIZE_DEDUP_WITH_SUFFIX: + lp_file_size = dir_cache.stat_size(lp_download_path) + lp_photo_size = version.size + if lp_file_size != lp_photo_size: + lp_download_path = (f"-{lp_photo_size}.").join( + lp_download_path.rsplit(".", 1) + ) + logger.debug("%s deduplicated", truncate_middle(lp_download_path, 96)) + # Print the deduplicated filename but don't download + print(lp_download_path) else: if lp_file_exists: - if file_match_policy == FileMatchPolicy.NAME_SIZE_DEDUP_WITH_SUFFIX: - lp_file_size = os.stat(lp_download_path).st_size + if manifest is not None: + lp_manifest_row = manifest.lookup(photo.id, manifest.zone_id, lp_res) + if lp_manifest_row is not None: + # Check if this asset owns the LP file + lp_rel_path, lp_download_path, lp_file_exists, lp_dedup = _check_collision( + manifest, photo.id, lp_rel_path, lp_download_path, directory, dir_cache, logger, + asset_resource=lp_res, + ) + if lp_dedup is not None: + # Collision handled — propagate suffix + if dedup_suffix is None: + dedup_suffix = lp_dedup + elif lp_manifest_row.version_size != version.size: + logger.debug( + "%s version changed (manifest: %d, iCloud: %d), re-downloading", + truncate_middle(lp_download_path, 96), + lp_manifest_row.version_size, + version.size, + ) + lp_file_exists = False + else: + # Version matches — update metadata + meta = _extract_manifest_metadata(photo, version) + manifest.upsert( + asset_id=photo.id, + zone_id=manifest.zone_id, + local_path=lp_rel_path, + version_size=version.size, + asset_resource=lp_res, + **meta, + ) + else: + # File exists on disk but not in manifest for this asset + lp_path_owner = manifest.lookup_by_path(lp_rel_path) + if lp_path_owner is not None and lp_path_owner.asset_id != photo.id: + # Another asset owns this LP file — generate unique filename + lp_dedup = dedup_suffix or f"_{_generate_dedup_suffix(photo.id)}" + lp_download_path = add_suffix_to_filename(lp_dedup, lp_download_path) + lp_rel_path = os.path.relpath(lp_download_path, directory) + lp_file_exists = dir_cache.isfile(lp_download_path) + if lp_file_exists: + meta = _extract_manifest_metadata(photo, version) + manifest.upsert( + asset_id=photo.id, + zone_id=manifest.zone_id, + local_path=lp_rel_path, + version_size=version.size, + asset_resource=lp_res, + **meta, + ) + logger.debug( + "%s adopted into manifest (deduped)", + truncate_middle(lp_download_path, 96), + ) + else: + # Adopt existing file into manifest + meta = _extract_manifest_metadata(photo, version) + manifest.upsert( + asset_id=photo.id, + zone_id=manifest.zone_id, + local_path=lp_rel_path, + version_size=version.size, + asset_resource=lp_res, + **meta, + ) + elif file_match_policy == FileMatchPolicy.NAME_SIZE_DEDUP_WITH_SUFFIX: + lp_file_size = dir_cache.stat_size(lp_download_path) lp_photo_size = version.size if lp_file_size != lp_photo_size: lp_download_path = (f"-{lp_photo_size}.").join( lp_download_path.rsplit(".", 1) ) logger.debug("%s deduplicated", truncate_middle(lp_download_path, 96)) - lp_file_exists = os.path.isfile(lp_download_path) + lp_file_exists = dir_cache.isfile(lp_download_path) if lp_file_exists: logger.debug("%s already exists", truncate_middle(lp_download_path, 96)) if not lp_file_exists: @@ -801,7 +1311,28 @@ def download_builder( ) success = download_result and success if download_result: + if not dry_run: + with contextlib.suppress(OSError): + dir_cache.notify_new_file(lp_download_path, os.stat(lp_download_path).st_size) + if manifest is not None: + meta = _extract_manifest_metadata(photo, version) + manifest.upsert( + asset_id=photo.id, + zone_id=manifest.zone_id, + local_path=lp_rel_path, + version_size=version.size, + asset_resource=lp_res, + **meta, + ) logger.info("Downloaded %s", truncated_path) + + # Write metadata to the live photo MOV (mirrors main photo at lines 1077-1083) + if not only_print_filenames and dir_cache.isfile(lp_download_path) and write_metadata_xmp: + generate_xmp_file(logger, lp_download_path, photo._asset_record, dry_run, dir_cache) + + # Live photo companion MOVs: XMP sidecar is the canonical metadata + # source. In-file QuickTime Keys metadata is redundant — skip exiftool. + return success @@ -872,6 +1403,57 @@ def asset_type_skip_message( return f"Skipping {filename}, only downloading {photo_video_phrase}. (Item type was: {photo.item_type})" +def delete_orphaned( + logger: logging.Logger, + manifest: "ManifestDB", + directory: str, + seen_asset_ids: set[str], + delete: bool, + dry_run: bool, +) -> None: + """Check for orphaned manifest entries and optionally delete them.""" + orphans = manifest.find_orphaned(seen_asset_ids) + if not orphans: + return + + orphan_count = len(orphans) + unique_assets = len({o.asset_id for o in orphans}) + + if not delete: + logger.warning( + "Found %d orphaned files (%d assets) not in iCloud library. " + "Use --delete-orphaned to clean up.", + orphan_count, + unique_assets, + ) + return + + logger.info( + "Deleting %d orphaned files (%d assets) not in iCloud library...", + orphan_count, + unique_assets, + ) + deleted = 0 + for orphan in orphans: + file_path = os.path.join(directory, orphan.local_path) + xmp_path = file_path + ".xmp" + if dry_run: + logger.info("[DRY RUN] Would delete orphaned %s", file_path) + else: + for path in (file_path, xmp_path): + if os.path.exists(path): + try: + os.remove(path) + logger.info("Deleted orphaned %s", path) + except OSError as e: + logger.warning("Failed to delete %s: %s", path, e) + manifest.remove(orphan.asset_id, orphan.zone_id, orphan.asset_resource) + deleted += 1 + if not dry_run: + manifest.flush() + logger.info("Orphan cleanup complete: %d entries removed", deleted) + + def core_single_run( logger: logging.Logger, status_exchange: StatusExchange, @@ -884,6 +1466,7 @@ def core_single_run( downloader: Callable[[PyiCloudService, Counter, PhotoAsset], bool], notificator: Callable[[], None], lp_filename_generator: Callable[[str], str], + manifest: ManifestDB | None = None, ) -> int: """Download all iCloud photos to a local directory for a single execution (no watch loop)""" @@ -947,6 +1530,10 @@ def append_response(captured: List[Mapping[str, Any]], response: Mapping[str, An else: library_object = icloud.photos + # Set manifest zone_id for this library + if manifest is not None: + manifest.zone_id = library_object.zone_id.get("zoneName", "PrimarySync") + if user_config.list_albums: print("Albums:") album_titles = [str(a) for a in library_object.albums.values()] @@ -987,6 +1574,8 @@ def sum_(inp: Iterable[int]) -> int: return sum(inp) photos_count: int | None = compose(sum_, album_lengths)(albums) + seen_asset_ids: set[str] = set() + iteration_complete = False for photo_album in albums: photos_enumerator: Iterable[PhotoAsset] = photo_album @@ -1071,6 +1660,7 @@ def should_break(counter: Counter) -> bool: for item in photos_bar: try: + seen_asset_ids.add(item.id) if should_break(consecutive_files_found): logger.info( "Found %s consecutive previously downloaded photos. Exiting", @@ -1172,6 +1762,11 @@ def should_break(counter: Counter) -> bool: logger.info(message) status_exchange.get_progress().photos_last_message = message status_exchange.get_progress().reset() + # Flush manifest writes after each album + if manifest is not None: + manifest.flush() + + iteration_complete = not status_exchange.get_progress().cancel if user_config.auto_delete: autodelete_photos( @@ -1183,9 +1778,20 @@ def should_break(counter: Counter) -> bool: user_config.sizes, lp_filename_generator, user_config.align_raw, + manifest=manifest, ) else: pass + + if manifest is not None and iteration_complete: + delete_orphaned( + logger, + manifest, + directory, + seen_asset_ids, + delete=user_config.delete_orphaned, + dry_run=user_config.dry_run, + ) except PyiCloudFailedLoginException as error: logger.info(error) dump_responses(logger.debug, captured_responses) diff --git a/src/icloudpd/cli.py b/src/icloudpd/cli.py index 9c33ac1af..2c62a3990 100644 --- a/src/icloudpd/cli.py +++ b/src/icloudpd/cli.py @@ -34,6 +34,23 @@ def map_align_raw_to_enum(align_raw_str: str) -> RawTreatmentPolicy: return mapping[align_raw_str] +_VALID_WRITE_METADATA = {"all", "rating", "keywords", "title", "dates", "orientation", "location", "datetime"} + + +def _parse_write_metadata(value: str | None) -> frozenset[str]: + """Parse --write-metadata comma-separated values into a frozenset.""" + if value is None: + return frozenset() + categories = frozenset(s.strip().lower() for s in value.split(",") if s.strip()) + invalid = categories - _VALID_WRITE_METADATA + if invalid: + raise argparse.ArgumentTypeError( + f"Invalid --write-metadata categories: {', '.join(sorted(invalid))}. " + f"Valid options: {', '.join(sorted(_VALID_WRITE_METADATA))}" + ) + return categories + + def add_options_for_user(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: cloned = copy.deepcopy(parser) cloned.add_argument( @@ -130,6 +147,12 @@ def add_options_for_user(parser: argparse.ArgumentParser) -> argparse.ArgumentPa + "(If you restore the photo in iCloud, it will be downloaded again.)", action="store_true", ) + cloned.add_argument( + "--delete-orphaned", + help="Delete local files that are no longer in the iCloud library " + + "(e.g. deleted from iCloud or moved to another library).", + action="store_true", + ) cloned.add_argument( "--folder-structure", help="Folder structure. If set to `none`, all photos will be placed into the download directory. Default: %(default)s", @@ -141,6 +164,45 @@ def add_options_for_user(parser: argparse.ArgumentParser) -> argparse.ArgumentPa help="Write the DateTimeOriginal EXIF tag from file creation date, if it doesn't exist.", action="store_true", ) + cloned.add_argument( + "--write-metadata", + help=( + "Write iCloud metadata as both XMP sidecars and EXIF tags. " + "Shorthand for --write-metadata-xmp and --write-metadata-exif. " + "Comma-separated list of field categories: " + "all, rating, keywords, title, datetime, dates, orientation, location. " + "Requires exiftool to be installed. " + "Default when specified without value: all." + ), + nargs="?", + const="all", + default=None, + ) + cloned.add_argument( + "--write-metadata-xmp", + help=( + "Write iCloud metadata as XMP sidecar files. " + "Comma-separated list of field categories: " + "all, rating, keywords, title, datetime, dates, orientation, location. " + "Default when specified without value: all." + ), + nargs="?", + const="all", + default=None, + ) + cloned.add_argument( + "--write-metadata-exif", + help=( + "Write iCloud metadata into file EXIF/XMP tags using exiftool. " + "Comma-separated list of field categories: " + "all, rating, keywords, title, datetime, dates, orientation, location. " + "Requires exiftool to be installed. " + "Default when specified without value: all." + ), + nargs="?", + const="all", + default=None, + ) cloned.add_argument( "--smtp-username", @@ -256,6 +318,15 @@ def add_options_for_user(parser: argparse.ArgumentParser) -> argparse.ArgumentPa help="Don't download any photos (default: download all photos and videos)", action="store_true", ) + cloned.add_argument( + "--accept-apple-changes", + help=( + "Re-download files when iCloud reports a different version. " + "Without this flag, version changes are logged as warnings. " + "Metadata updates from iCloud are always applied regardless." + ), + action="store_true", + ) return cloned @@ -429,6 +500,37 @@ def format_help() -> str: def map_to_config(user_ns: argparse.Namespace) -> UserConfig: + # Resolve write-metadata flags + write_metadata_base = _parse_write_metadata(user_ns.write_metadata) + write_metadata_xmp = _parse_write_metadata(user_ns.write_metadata_xmp) + write_metadata_exif = _parse_write_metadata(user_ns.write_metadata_exif) + + # --write-metadata sets both if specific variants not given + if write_metadata_base: + if not write_metadata_xmp: + write_metadata_xmp = write_metadata_base + if not write_metadata_exif: + write_metadata_exif = write_metadata_base + + # --xmp-sidecar is deprecated alias for --write-metadata-xmp all + if user_ns.xmp_sidecar: + import logging + + logging.getLogger("icloudpd").warning( + "--xmp-sidecar is deprecated, use --write-metadata-xmp instead" + ) + if not write_metadata_xmp: + write_metadata_xmp = frozenset({"all"}) + + # --set-exif-datetime adds datetime to exif set + if user_ns.set_exif_datetime: + import logging + + logging.getLogger("icloudpd").warning( + "--set-exif-datetime is deprecated, use --write-metadata-exif datetime instead" + ) + write_metadata_exif = write_metadata_exif | frozenset({"datetime"}) + return UserConfig( username=user_ns.username, password=user_ns.password, @@ -447,11 +549,13 @@ def map_to_config(user_ns: argparse.Namespace) -> UserConfig: list_libraries=user_ns.list_libraries, skip_videos=user_ns.skip_videos, skip_live_photos=user_ns.skip_live_photos, - xmp_sidecar=user_ns.xmp_sidecar, force_size=user_ns.force_size, auto_delete=user_ns.auto_delete, + delete_orphaned=user_ns.delete_orphaned, folder_structure=user_ns.folder_structure, set_exif_datetime=user_ns.set_exif_datetime, + write_metadata_xmp=write_metadata_xmp, + write_metadata_exif=write_metadata_exif, smtp_username=user_ns.smtp_username, smtp_password=user_ns.smtp_password, smtp_host=user_ns.smtp_host, @@ -472,6 +576,7 @@ def map_to_config(user_ns: argparse.Namespace) -> UserConfig: skip_created_before=user_ns.skip_created_before, skip_created_after=user_ns.skip_created_after, skip_photos=user_ns.skip_photos, + accept_apple_changes=user_ns.accept_apple_changes, ) diff --git a/src/icloudpd/config.py b/src/icloudpd/config.py index 019843864..7aa823079 100644 --- a/src/icloudpd/config.py +++ b/src/icloudpd/config.py @@ -27,11 +27,13 @@ class _DefaultConfig: list_libraries: bool skip_videos: bool skip_live_photos: bool - xmp_sidecar: bool force_size: bool auto_delete: bool + delete_orphaned: bool folder_structure: str set_exif_datetime: bool + write_metadata_xmp: frozenset[str] + write_metadata_exif: frozenset[str] smtp_username: str | None smtp_password: str | None smtp_host: str @@ -50,6 +52,7 @@ class _DefaultConfig: skip_created_before: datetime.datetime | datetime.timedelta | None skip_created_after: datetime.datetime | datetime.timedelta | None skip_photos: bool + accept_apple_changes: bool @dataclass(kw_only=True) diff --git a/src/icloudpd/dir_cache.py b/src/icloudpd/dir_cache.py new file mode 100644 index 000000000..846d6c147 --- /dev/null +++ b/src/icloudpd/dir_cache.py @@ -0,0 +1,92 @@ +"""Directory listing cache to avoid per-file stat calls on network mounts. + +On network-mounted filesystems (e.g. WSL → Windows), each os.path.isfile() +and os.stat() call incurs significant latency (~5-50ms). For a no-op sync +of 27k+ files, this adds up to 80k+ round trips and ~18 minutes. + +This module pre-scans directories with os.scandir() (single round trip per +directory) and serves subsequent existence + size checks from memory. +""" + +import logging +import os +import stat +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + + +@dataclass(slots=True) +class CachedEntry: + """Cached file metadata from a directory scan.""" + size: int + is_file: bool + + +class DirCache: + """Caches directory listings to avoid per-file stat calls.""" + + def __init__(self) -> None: + self._cache: dict[str, dict[str, CachedEntry]] = {} + + def _scan_dir(self, directory: str) -> dict[str, CachedEntry]: + """Scan a directory and cache all entries.""" + entries: dict[str, CachedEntry] = {} + try: + with os.scandir(directory) as it: + for entry in it: + try: + st = entry.stat() + entries[entry.name] = CachedEntry( + size=st.st_size, + is_file=stat.S_ISREG(st.st_mode), + ) + except OSError: + pass + except OSError as e: + if os.path.isdir(directory): + logger.warning("Failed to scan directory %s: %s", directory, e) + self._cache[directory] = entries + return entries + + def _get_dir(self, directory: str) -> dict[str, CachedEntry]: + """Get cached directory listing, scanning on first access.""" + if directory not in self._cache: + return self._scan_dir(directory) + return self._cache[directory] + + def isfile(self, path: str) -> bool: + """Cached equivalent of os.path.isfile().""" + directory = os.path.dirname(path) + filename = os.path.basename(path) + entries = self._get_dir(directory) + entry = entries.get(filename) + return entry is not None and entry.is_file + + def stat_size(self, path: str) -> int: + """Cached equivalent of os.stat(path).st_size.""" + directory = os.path.dirname(path) + filename = os.path.basename(path) + entries = self._get_dir(directory) + entry = entries.get(filename) + if entry is None: + raise FileNotFoundError(path) + return entry.size + + def exists(self, path: str) -> bool: + """Cached equivalent of os.path.exists().""" + directory = os.path.dirname(path) + filename = os.path.basename(path) + entries = self._get_dir(directory) + return filename in entries + + def getsize(self, path: str) -> int: + """Cached equivalent of os.path.getsize().""" + return self.stat_size(path) + + def notify_new_file(self, path: str, size: int) -> None: + """Update cache after a new file is created (e.g. after download).""" + directory = os.path.dirname(path) + filename = os.path.basename(path) + entries = self._get_dir(directory) + entries[filename] = CachedEntry(size=size, is_file=True) diff --git a/src/icloudpd/download.py b/src/icloudpd/download.py index 78da134b5..3a283f88d 100644 --- a/src/icloudpd/download.py +++ b/src/icloudpd/download.py @@ -76,12 +76,22 @@ def download_response_to_path( append_mode: bool, download_path: str, created_date: datetime.datetime, + expected_size: int = 0, ) -> bool: """Saves response content into file with desired created date""" with open(temp_download_path, ("ab" if append_mode else "wb")) as file_obj: for chunk in response.iter_content(chunk_size=1024): if chunk: file_obj.write(chunk) + # Verify downloaded size matches expected before atomic rename + if expected_size > 0: + actual_size = os.path.getsize(temp_download_path) + if actual_size != expected_size: + logger = logging.getLogger(__name__) + logger.warning( + "Download size mismatch for %s: got %d bytes, expected %d", + download_path, actual_size, expected_size, + ) os.rename(temp_download_path, download_path) update_mtime(created_date, download_path) return True @@ -125,7 +135,8 @@ def download_media( temp_download_path = os.path.join(download_dir, checksum32) + ".part" download_local = ( - partial(download_response_to_path_dry_run, logger) if dry_run else download_response_to_path + partial(download_response_to_path_dry_run, logger) if dry_run + else partial(download_response_to_path, expected_size=version.size) ) retries = 0 diff --git a/src/icloudpd/exif_datetime.py b/src/icloudpd/exif_datetime.py index 00da033bc..fd856ab09 100644 --- a/src/icloudpd/exif_datetime.py +++ b/src/icloudpd/exif_datetime.py @@ -11,8 +11,12 @@ def get_photo_exif(logger: logging.Logger, path: str) -> str | None: """Get EXIF date for a photo, return nothing if there is an error""" try: exif_dict: piexif.ExifIFD = piexif.load(path) - return typing.cast(str | None, exif_dict.get("Exif").get(36867)) - except (ValueError, InvalidImageDataError): + exif_ifd = exif_dict.get("Exif") + value = exif_ifd.get(36867) + if isinstance(value, bytes): + return value.decode("utf-8") + return typing.cast(str | None, value) + except (ValueError, InvalidImageDataError, AttributeError, TypeError, UnicodeDecodeError): logger.debug("Error fetching EXIF data for %s", path) return None @@ -21,11 +25,11 @@ def set_photo_exif(logger: logging.Logger, path: str, date: str) -> None: """Set EXIF date on a photo, do nothing if there is an error""" try: exif_dict = piexif.load(path) - exif_dict.get("1st")[306] = date - exif_dict.get("Exif")[36867] = date - exif_dict.get("Exif")[36868] = date + exif_dict["0th"][306] = date + exif_dict["Exif"][36867] = date + exif_dict["Exif"][36868] = date exif_bytes = piexif.dump(exif_dict) piexif.insert(exif_bytes, path) - except (ValueError, InvalidImageDataError): + except (ValueError, InvalidImageDataError, AttributeError, TypeError, KeyError): logger.debug("Error setting EXIF data for %s", path) return diff --git a/src/icloudpd/manifest.py b/src/icloudpd/manifest.py new file mode 100644 index 000000000..cdf6abcd0 --- /dev/null +++ b/src/icloudpd/manifest.py @@ -0,0 +1,502 @@ +"""SQLite asset manifest for identity-based sync tracking. + +The manifest DB is the single source of truth for everything icloudpd knows +about your library. It stores identity (which iCloud asset maps to which local +file), sync state (has this asset changed?), and all metadata the API provides. + +XMP sidecars are an export format generated from the same API data. The DB and +XMP are independent — XMP generation does not read from the DB. + +The manifest lives at {download_dir}/.icloudpd.db and travels with the library. +""" + +import contextlib +import logging +import os +import sqlite3 +import time +from dataclasses import dataclass +from datetime import datetime, timezone + +logger = logging.getLogger(__name__) + +SCHEMA_VERSION = 4 + +_FRESH_SCHEMA = """\ +CREATE TABLE IF NOT EXISTS manifest ( + asset_id TEXT NOT NULL, + zone_id TEXT NOT NULL DEFAULT '', + asset_resource TEXT NOT NULL DEFAULT 'resOriginal', + local_path TEXT NOT NULL, + version_size INTEGER NOT NULL, + version_checksum TEXT, + change_tag TEXT, + downloaded_at TEXT NOT NULL, + last_updated_at TEXT NOT NULL, + item_type TEXT, + filename TEXT, + asset_date TEXT, + added_date TEXT, + is_favorite INTEGER DEFAULT 0, + is_hidden INTEGER DEFAULT 0, + is_deleted INTEGER DEFAULT 0, + original_width INTEGER, + original_height INTEGER, + duration INTEGER, + orientation INTEGER, + title TEXT, + description TEXT, + keywords TEXT, + gps_latitude REAL, + gps_longitude REAL, + gps_altitude REAL, + gps_speed REAL, + gps_timestamp TEXT, + timezone_offset INTEGER, + asset_subtype INTEGER, + hdr_type INTEGER, + burst_flags INTEGER, + burst_flags_ext INTEGER, + burst_id TEXT, + original_orientation INTEGER, + raw_fields TEXT, + file_mtime REAL, + PRIMARY KEY (asset_id, zone_id, asset_resource) +); +CREATE INDEX IF NOT EXISTS idx_manifest_path ON manifest(local_path); +""" + +# Columns added between schema versions, for migration from older DBs. +# Each entry: (version_introduced, ALTER TABLE statement) +_MIGRATIONS: list[tuple[int, str]] = [ + (2, "ALTER TABLE manifest ADD COLUMN gps_speed REAL"), + (2, "ALTER TABLE manifest ADD COLUMN gps_timestamp TEXT"), + (2, "ALTER TABLE manifest ADD COLUMN timezone_offset INTEGER"), + (2, "ALTER TABLE manifest ADD COLUMN asset_subtype INTEGER"), + (2, "ALTER TABLE manifest ADD COLUMN hdr_type INTEGER"), + (2, "ALTER TABLE manifest ADD COLUMN burst_flags INTEGER"), + (2, "ALTER TABLE manifest ADD COLUMN burst_flags_ext INTEGER"), + (2, "ALTER TABLE manifest ADD COLUMN burst_id TEXT"), + (2, "ALTER TABLE manifest ADD COLUMN original_orientation INTEGER"), + (2, "ALTER TABLE manifest ADD COLUMN raw_fields TEXT"), + (3, "ALTER TABLE manifest ADD COLUMN asset_resource TEXT NOT NULL DEFAULT 'resOriginal'"), + (4, "ALTER TABLE manifest ADD COLUMN file_mtime REAL"), +] + + +@dataclass(frozen=True) +class ManifestRow: + """A single manifest entry.""" + + asset_id: str + zone_id: str + asset_resource: str + local_path: str + version_size: int + version_checksum: str | None + change_tag: str | None + downloaded_at: str + last_updated_at: str + item_type: str | None + filename: str | None + asset_date: str | None + added_date: str | None + is_favorite: int + is_hidden: int + is_deleted: int + original_width: int | None + original_height: int | None + duration: int | None + orientation: int | None + title: str | None + description: str | None + keywords: str | None + gps_latitude: float | None + gps_longitude: float | None + gps_altitude: float | None + gps_speed: float | None + gps_timestamp: str | None + timezone_offset: int | None + asset_subtype: int | None + hdr_type: int | None + burst_flags: int | None + burst_flags_ext: int | None + burst_id: str | None + original_orientation: int | None + raw_fields: str | None + file_mtime: float | None + + +_ALL_COLUMNS = ( + "asset_id, zone_id, asset_resource, local_path, version_size, version_checksum, " + "change_tag, downloaded_at, last_updated_at, item_type, filename, " + "asset_date, added_date, is_favorite, is_hidden, is_deleted, " + "original_width, original_height, duration, orientation, " + "title, description, keywords, gps_latitude, gps_longitude, gps_altitude, " + "gps_speed, gps_timestamp, timezone_offset, asset_subtype, hdr_type, " + "burst_flags, burst_flags_ext, burst_id, original_orientation, raw_fields, " + "file_mtime" +) + + +class ManifestDB: + """SQLite-backed asset manifest for tracking downloaded files.""" + + _MAX_WRITE_RETRIES = 3 + _RETRY_BASE_DELAY = 0.1 + + def __init__(self, download_dir: str) -> None: + self._db_path = os.path.join(download_dir, ".icloudpd.db") + self._conn: sqlite3.Connection | None = None + self._dirty = False + self._pending_count = 0 + self._flush_interval = 500 + self.zone_id: str = "" + + @property + def _db(self) -> sqlite3.Connection: + if self._conn is None: + raise RuntimeError("ManifestDB is not open") + return self._conn + + def open(self) -> None: + """Open the manifest DB, creating schema or migrating if needed.""" + self._conn = sqlite3.connect(self._db_path, timeout=10) + self._conn.execute("PRAGMA journal_mode=DELETE") + self._conn.execute("PRAGMA synchronous=FULL") + self._dirty = False + self._pending_count = 0 + + current_version = self._conn.execute("PRAGMA user_version").fetchone()[0] + if current_version == 0: + # Fresh DB or pre-versioned DB — check if table exists + tables = self._conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='manifest'" + ).fetchone() + if tables is None: + # Brand new DB + self._conn.executescript(_FRESH_SCHEMA) + else: + # Pre-versioned DB (has table but no user_version) — migrate + self._migrate_from_v0() + self._conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}") + self._conn.commit() + elif current_version < SCHEMA_VERSION: + self._run_migrations(current_version) + + def _migrate_from_v0(self) -> None: + """Migrate from the original 7-column schema to the full schema.""" + existing = { + row[1] + for row in self._conn.execute("PRAGMA table_info(manifest)").fetchall() # type: ignore[union-attr] + } + new_columns = [ + ("last_updated_at", "TEXT NOT NULL DEFAULT ''"), + ("item_type", "TEXT"), + ("filename", "TEXT"), + ("asset_date", "TEXT"), + ("added_date", "TEXT"), + ("is_favorite", "INTEGER DEFAULT 0"), + ("is_hidden", "INTEGER DEFAULT 0"), + ("is_deleted", "INTEGER DEFAULT 0"), + ("original_width", "INTEGER"), + ("original_height", "INTEGER"), + ("duration", "INTEGER"), + ("orientation", "INTEGER"), + ("title", "TEXT"), + ("description", "TEXT"), + ("keywords", "TEXT"), + ("gps_latitude", "REAL"), + ("gps_longitude", "REAL"), + ("gps_altitude", "REAL"), + ("gps_speed", "REAL"), + ("gps_timestamp", "TEXT"), + ("timezone_offset", "INTEGER"), + ("asset_subtype", "INTEGER"), + ("hdr_type", "INTEGER"), + ("burst_flags", "INTEGER"), + ("burst_flags_ext", "INTEGER"), + ("burst_id", "TEXT"), + ("original_orientation", "INTEGER"), + ("raw_fields", "TEXT"), + ("asset_resource", "TEXT NOT NULL DEFAULT 'resOriginal'"), + ("file_mtime", "REAL"), + ] + for col_name, col_def in new_columns: + if col_name not in existing: + self._conn.execute(f"ALTER TABLE manifest ADD COLUMN {col_name} {col_def}") # type: ignore[union-attr] + logger.info("Migrated manifest DB from v0 to v%d (%d columns added)", + SCHEMA_VERSION, sum(1 for c, _ in new_columns if c not in existing)) + self._rebuild_pk() + self._conn.execute( # type: ignore[union-attr] + "CREATE INDEX IF NOT EXISTS idx_manifest_path ON manifest(local_path)" + ) + + def _run_migrations(self, from_version: int) -> None: + """Run incremental migrations from from_version to SCHEMA_VERSION.""" + for version, sql in _MIGRATIONS: + if version > from_version: + with contextlib.suppress(sqlite3.OperationalError): + self._conn.execute(sql) # type: ignore[union-attr] + if from_version < 3: + self._rebuild_pk() + self._conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}") # type: ignore[union-attr] + self._conn.commit() # type: ignore[union-attr] + + def _rebuild_pk(self) -> None: + """Rebuild the manifest table with the correct PK (asset_id, zone_id, asset_resource).""" + conn = self._conn + assert conn is not None + conn.execute("ALTER TABLE manifest RENAME TO manifest_old") + conn.executescript(_FRESH_SCHEMA) + # Copy data, keeping only one row per (asset_id, zone_id, asset_resource) + cols = _ALL_COLUMNS + conn.execute( + f"INSERT OR IGNORE INTO manifest ({cols}) " + f"SELECT {cols} FROM manifest_old" + ) + conn.execute("DROP TABLE manifest_old") + conn.commit() + + def close(self) -> None: + """Close the manifest DB, committing any pending writes.""" + if self._conn: + if self._dirty: + try: + self._conn.commit() + except sqlite3.OperationalError as e: + if "locked" in str(e): + logger.warning("Manifest commit on close failed: %s", e) + else: + raise + self._dirty = False + self._pending_count = 0 + self._conn.close() + self._conn = None + + def flush(self) -> None: + """Commit pending writes without closing.""" + if self._conn and self._dirty: + try: + self._conn.commit() + self._dirty = False + self._pending_count = 0 + except sqlite3.OperationalError as e: + if "locked" in str(e): + logger.warning("Manifest flush failed: %s", e) + else: + raise + + def __enter__(self) -> "ManifestDB": + self.open() + return self + + def __exit__(self, *_: object) -> None: + self.close() + + def lookup(self, asset_id: str, zone_id: str, asset_resource: str) -> ManifestRow | None: + """Look up a manifest entry by identity.""" + row = self._db.execute( + f"SELECT {_ALL_COLUMNS} FROM manifest " + "WHERE asset_id = ? AND zone_id = ? AND asset_resource = ?", + (asset_id, zone_id, asset_resource), + ).fetchone() + if row is None: + return None + return ManifestRow(*row) + + def lookup_by_path(self, local_path: str) -> ManifestRow | None: + """Look up the earliest-downloaded manifest entry for a local path.""" + row = self._db.execute( + f"SELECT {_ALL_COLUMNS} FROM manifest " + "WHERE local_path = ? ORDER BY downloaded_at ASC LIMIT 1", + (local_path,), + ).fetchone() + if row is None: + return None + return ManifestRow(*row) + + def upsert( + self, + asset_id: str, + zone_id: str, + local_path: str, + version_size: int, + asset_resource: str = "resOriginal", + version_checksum: str | None = None, + change_tag: str | None = None, + item_type: str | None = None, + filename: str | None = None, + asset_date: str | None = None, + added_date: str | None = None, + is_favorite: int = 0, + is_hidden: int = 0, + is_deleted: int = 0, + original_width: int | None = None, + original_height: int | None = None, + duration: int | None = None, + orientation: int | None = None, + title: str | None = None, + description: str | None = None, + keywords: str | None = None, + gps_latitude: float | None = None, + gps_longitude: float | None = None, + gps_altitude: float | None = None, + gps_speed: float | None = None, + gps_timestamp: str | None = None, + timezone_offset: int | None = None, + asset_subtype: int | None = None, + hdr_type: int | None = None, + burst_flags: int | None = None, + burst_flags_ext: int | None = None, + burst_id: str | None = None, + original_orientation: int | None = None, + raw_fields: str | None = None, + ) -> None: + """Insert or update a manifest entry. Auto-flushes every 500 writes.""" + now = datetime.now(tz=timezone.utc).isoformat() + params = ( + asset_id, zone_id, asset_resource, local_path, version_size, version_checksum, + change_tag, now, now, item_type, filename, + asset_date, added_date, is_favorite, is_hidden, is_deleted, + original_width, original_height, duration, orientation, + title, description, keywords, gps_latitude, gps_longitude, gps_altitude, + gps_speed, gps_timestamp, timezone_offset, asset_subtype, hdr_type, + burst_flags, burst_flags_ext, burst_id, original_orientation, raw_fields, + None, # file_mtime: NULL for new rows, preserved on conflict + ) + sql = ( + f"INSERT INTO manifest ({_ALL_COLUMNS}) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) " + "ON CONFLICT(asset_id, zone_id, asset_resource) DO UPDATE SET " + "local_path=excluded.local_path, " + "version_size=excluded.version_size, " + "version_checksum=excluded.version_checksum, " + "change_tag=excluded.change_tag, " + "last_updated_at=excluded.last_updated_at, " + "item_type=excluded.item_type, " + "filename=excluded.filename, " + "asset_date=excluded.asset_date, " + "added_date=excluded.added_date, " + "is_favorite=excluded.is_favorite, " + "is_hidden=excluded.is_hidden, " + "is_deleted=excluded.is_deleted, " + "original_width=excluded.original_width, " + "original_height=excluded.original_height, " + "duration=excluded.duration, " + "orientation=excluded.orientation, " + "title=excluded.title, " + "description=excluded.description, " + "keywords=excluded.keywords, " + "gps_latitude=excluded.gps_latitude, " + "gps_longitude=excluded.gps_longitude, " + "gps_altitude=excluded.gps_altitude, " + "gps_speed=excluded.gps_speed, " + "gps_timestamp=excluded.gps_timestamp, " + "timezone_offset=excluded.timezone_offset, " + "asset_subtype=excluded.asset_subtype, " + "hdr_type=excluded.hdr_type, " + "burst_flags=excluded.burst_flags, " + "burst_flags_ext=excluded.burst_flags_ext, " + "burst_id=excluded.burst_id, " + "original_orientation=excluded.original_orientation, " + "raw_fields=excluded.raw_fields" + # file_mtime deliberately NOT updated — only set via update_file_mtime() + ) + for attempt in range(self._MAX_WRITE_RETRIES): + try: + self._db.execute(sql, params) + self._dirty = True + self._pending_count += 1 + if self._pending_count >= self._flush_interval: + self.flush() + return + except sqlite3.OperationalError as e: + if "locked" in str(e) and attempt < self._MAX_WRITE_RETRIES - 1: + logger.debug( + "Manifest write retry %d for %s: %s", + attempt + 1, local_path, e, + ) + time.sleep(self._RETRY_BASE_DELAY * (attempt + 1)) + else: + logger.warning("Manifest write failed for %s: %s", local_path, e) + return + except sqlite3.Error as e: + logger.warning("Manifest write failed for %s: %s", local_path, e) + return + + def update_path(self, asset_id: str, zone_id: str, asset_resource: str, new_path: str) -> None: + """Update local_path for an existing manifest entry.""" + try: + self._db.execute( + "UPDATE manifest SET local_path = ?, last_updated_at = ? " + "WHERE asset_id = ? AND zone_id = ? AND asset_resource = ?", + (new_path, datetime.now(tz=timezone.utc).isoformat(), + asset_id, zone_id, asset_resource), + ) + self._dirty = True + self._pending_count += 1 + except sqlite3.Error as e: + logger.warning( + "Manifest path update failed for %s -> %s: %s", + asset_resource, new_path, e, + ) + + def update_file_mtime( + self, asset_id: str, zone_id: str, asset_resource: str, mtime: float + ) -> None: + """Record file mtime after successful metadata write.""" + try: + self._db.execute( + "UPDATE manifest SET file_mtime = ? " + "WHERE asset_id = ? AND zone_id = ? AND asset_resource = ?", + (mtime, asset_id, zone_id, asset_resource), + ) + self._dirty = True + self._pending_count += 1 + except sqlite3.Error as e: + logger.warning("Manifest mtime update failed: %s", e) + + def count_by_path(self, local_path: str) -> int: + """Count how many manifest entries reference a given local path.""" + row = self._db.execute( + "SELECT COUNT(*) FROM manifest WHERE local_path = ?", + (local_path,), + ).fetchone() + return row[0] if row else 0 + + def remove(self, asset_id: str, zone_id: str, asset_resource: str) -> None: + """Remove a manifest entry.""" + self._db.execute( + "DELETE FROM manifest WHERE asset_id = ? AND zone_id = ? AND asset_resource = ?", + (asset_id, zone_id, asset_resource), + ) + self._dirty = True + + def remove_by_path(self, local_path: str) -> None: + """Remove all manifest entries for a local path (used by autodelete).""" + self._db.execute( + "DELETE FROM manifest WHERE local_path = ?", + (local_path,), + ) + self._dirty = True + + def count(self) -> int: + """Return the total number of manifest entries.""" + row = self._db.execute("SELECT COUNT(*) FROM manifest").fetchone() + return row[0] if row else 0 + + def find_orphaned(self, seen_asset_ids: set[str]) -> list[ManifestRow]: + """Return manifest rows whose asset_id is not in the seen set.""" + all_rows = self._db.execute( + "SELECT * FROM manifest" + ).fetchall() + col_names = [desc[0] for desc in self._db.execute( + "SELECT * FROM manifest LIMIT 0" + ).description or []] + orphans: list[ManifestRow] = [] + for row in all_rows: + row_dict = dict(zip(col_names, row, strict=False)) + if row_dict["asset_id"] not in seen_asset_ids: + orphans.append(ManifestRow(**row_dict)) + return orphans diff --git a/src/icloudpd/metadata_writer.py b/src/icloudpd/metadata_writer.py new file mode 100644 index 000000000..71e8e529a --- /dev/null +++ b/src/icloudpd/metadata_writer.py @@ -0,0 +1,516 @@ +"""Write iCloud metadata into photo/video files using exiftool. + +This module patches EXIF/XMP metadata in-place without re-encoding images. +exiftool manipulates container metadata boxes directly, so file quality is +preserved and size changes are minimal (+1-3KB typically). + +Supported formats: HEIC, JPEG, PNG, MOV, MP4. +Requires exiftool to be installed on the system. +""" + +import base64 +import json +import logging +import os +import plistlib +import subprocess +from dataclasses import dataclass +from typing import Any + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class MetadataUpdate: + """Metadata fields to write into a file.""" + + rating: int | None = None + title: str | None = None + description: str | None = None + keywords: list[str] | None = None + orientation: int | None = None + gps_latitude: float | None = None + gps_longitude: float | None = None + gps_altitude: float | None = None + gps_h_accuracy: float | None = None + created_date: str | None = None # format: "YYYY:MM:DD HH:MM:SS" + + +class ExiftoolNotFoundError(RuntimeError): + """Raised when exiftool is not installed.""" + + +def check_exiftool() -> str: + """Check exiftool is available. Returns version string or raises.""" + try: + result = subprocess.run( + ["exiftool", "-ver"], capture_output=True, text=True, timeout=5 + ) + if result.returncode == 0: + return result.stdout.strip() + except FileNotFoundError: + pass + except subprocess.TimeoutExpired: + pass + raise ExiftoolNotFoundError( + "exiftool is required for --write-metadata but was not found. " + "Install it with: apt install libimage-exiftool-perl (Linux), " + "brew install exiftool (macOS), or choco install exiftool (Windows)." + ) + + +_VIDEO_EXTENSIONS = frozenset({".mov", ".mp4", ".m4v", ".avi"}) + +_GIF_EXTENSIONS = frozenset({".gif"}) + + +def _is_video(file_path: str) -> bool: + """Check if a file is a video based on extension.""" + return os.path.splitext(file_path.lower())[1] in _VIDEO_EXTENSIONS + + +def _is_gif(file_path: str) -> bool: + """Check if a file is a GIF based on extension.""" + return os.path.splitext(file_path.lower())[1] in _GIF_EXTENSIONS + + +def build_exiftool_args( + update: MetadataUpdate, config: set[str], file_path: str = "" +) -> list[str]: + """Build exiftool command-line arguments for the given metadata update. + + Args: + update: The metadata to write. + config: Set of enabled field categories ('rating', 'keywords', + 'title', 'orientation', 'location', 'all'). + Note: 'dates' is accepted but deprecated (no-op). + + Returns: + List of exiftool arguments (e.g. ['-Rating=5', '-XPTitle=...']) + """ + args: list[str] = [] + if _is_gif(file_path): + return args # GIF doesn't support EXIF; metadata goes in XMP sidecar only + write_all = "all" in config + + if (write_all or "rating" in config) and update.rating is not None: + args.append(f"-Rating={update.rating}") + + if (write_all or "title" in config) and update.title is not None: + args.append(f"-XPTitle={update.title}") + args.append(f"-XMP:Title={update.title}") + + if (write_all or "title" in config) and update.description is not None: + args.append(f"-ImageDescription={update.description}") + args.append(f"-XMP:Description={update.description}") + + if (write_all or "keywords" in config) and update.keywords: + # Write to both Windows EXIF and XMP/IPTC for broad compatibility + xp_kw = "; ".join(update.keywords) + args.append(f"-XPKeywords={xp_kw}") + for kw in update.keywords: + args.append(f"-XMP:Subject+={kw}") + args.append(f"-IPTC:Keywords+={kw}") + + # orientation=0 is not a valid EXIF value (valid: 1-8); skip it + # Orientation is not meaningful for videos or PNG files + if ( + (write_all or "orientation" in config) + and update.orientation is not None + and update.orientation != 0 + and not _is_video(file_path) + and os.path.splitext(file_path.lower())[1] != ".png" + ): + args.append(f"-Orientation#={update.orientation}") + + if (write_all or "location" in config) and update.gps_latitude is not None and update.gps_longitude is not None: + if _is_video(file_path): + # QuickTime native GPS format (ISO 6709): "+DD.DDDDDD+DDD.DDDDDD+AAA.AAA/" + alt = update.gps_altitude if update.gps_altitude is not None else 0.0 + args.append(f"-Keys:GPSCoordinates={update.gps_latitude:+.6f}{update.gps_longitude:+.6f}{alt:+.3f}/") + if update.gps_h_accuracy is not None and update.gps_h_accuracy > 0: + args.append(f"-Keys:LocationAccuracyHorizontal={update.gps_h_accuracy}") + # Strip legacy XMP-exif GPS tags (3KB overhead, replaced by native Keys) + args.extend([ + "-XMP-exif:GPSLatitude=", + "-XMP-exif:GPSLongitude=", + "-XMP-exif:GPSAltitude=", + "-XMP-exif:GPSAltitudeRef=", + "-XMP-exif:GPSHPositioningError=", + ]) + else: + lat_ref = "S" if update.gps_latitude < 0 else "N" + lon_ref = "W" if update.gps_longitude < 0 else "E" + # Use signed values (works for XMP) plus Ref tags (needed for EXIF longitude) + args.append(f"-GPSLatitude={update.gps_latitude}") + args.append(f"-GPSLatitudeRef={lat_ref}") + args.append(f"-GPSLongitude={update.gps_longitude}") + args.append(f"-GPSLongitudeRef={lon_ref}") + if update.gps_altitude is not None: + alt_ref = 1 if update.gps_altitude < 0 else 0 + args.append(f"-GPSAltitude={update.gps_altitude}") + args.append(f"-GPSAltitudeRef#={alt_ref}") + if update.gps_h_accuracy is not None and update.gps_h_accuracy > 0: + args.append(f"-GPSHPositioningError={update.gps_h_accuracy}") + + if (write_all or "datetime" in config or "dates" in config) and update.created_date is not None: + if _is_video(file_path): + args.append(f"-QuickTime:CreateDate={update.created_date}") + else: + args.append(f"-EXIF:DateTimeOriginal={update.created_date}") + args.append(f"-EXIF:CreateDate={update.created_date}") + + if (write_all or "dates" in config) and update.created_date is not None: + if _is_video(file_path): + args.append(f"-QuickTime:ModifyDate={update.created_date}") + else: + args.append(f"-EXIF:ModifyDate={update.created_date}") + + return args + + +def _extract_h_accuracy(asset_record: dict[str, Any]) -> float | None: + """Extract GPS horizontal accuracy from locationEnc plist blob.""" + fields = asset_record.get("fields", {}) + loc_enc = fields.get("locationEnc", {}).get("value", "") + if not loc_enc: + return None + try: + location = plistlib.loads(base64.b64decode(loc_enc)) + h_acc = location.get("horzAcc") + if h_acc is not None and h_acc > 0: + return float(h_acc) + except (plistlib.InvalidFileException, ValueError, TypeError, AttributeError): + pass + return None + + +def extract_metadata_update( + asset_record: dict[str, Any], xmp_metadata: Any | None = None +) -> MetadataUpdate: + """Extract writable metadata from an iCloud asset record. + + Reuses XMPMetadata if already built (avoids double-decoding), or + extracts directly from the asset record fields. + + Args: + asset_record: Raw iCloud asset record dict. + xmp_metadata: Optional pre-built XMPMetadata namedtuple. + + Returns: + MetadataUpdate with fields to write. + """ + if xmp_metadata is not None: + rating = xmp_metadata.Rating if xmp_metadata.Rating and xmp_metadata.Rating != 0 else None + keywords = xmp_metadata.Keywords if xmp_metadata.Keywords else None + title = xmp_metadata.Title if xmp_metadata.Title else None + description = xmp_metadata.Description if xmp_metadata.Description else None + # orientation=0 from iCloud means "no override"; falsy check naturally skips it + orientation = xmp_metadata.Orientation if xmp_metadata.Orientation else None + gps_latitude = xmp_metadata.GPSLatitude if xmp_metadata.GPSLatitude is not None else None + gps_longitude = xmp_metadata.GPSLongitude if xmp_metadata.GPSLongitude is not None else None + gps_altitude = xmp_metadata.GPSAltitude if xmp_metadata.GPSAltitude is not None else None + gps_h_accuracy = xmp_metadata.GPSHPositioningError + # Extract created_date for datetime/dates categories + created_date_val = None + if xmp_metadata.CreateDate: + created_date_val = xmp_metadata.CreateDate.strftime("%Y:%m:%d %H:%M:%S") + return MetadataUpdate( + rating=rating, + title=title, + description=description, + keywords=keywords, + orientation=orientation, + gps_latitude=gps_latitude, + gps_longitude=gps_longitude, + gps_altitude=gps_altitude, + gps_h_accuracy=gps_h_accuracy, + created_date=created_date_val, + ) + + # Fallback: extract from raw asset_record (same logic as xmp_sidecar.build_metadata) + fields = asset_record.get("fields", {}) + rating = None + if fields.get("isFavorite", {}).get("value") == 1: + rating = 5 + elif fields.get("isHidden", {}).get("value") == 1 or fields.get("isDeleted", {}).get("value") == 1: + rating = -1 + + return MetadataUpdate(rating=rating, gps_h_accuracy=_extract_h_accuracy(asset_record)) + + +def _read_existing_metadata(file_path: str) -> dict[str, str]: + """Read current metadata values using exiftool for comparison. + + Uses -G to get group-prefixed keys, then normalises to unprefixed names. + For GPS, prefers XMP values (where we write for videos) over Composite + values (which read from QuickTime Keys and may differ in precision). + """ + cmd = [ + "exiftool", "-json", "-s", "-n", "-G", + "-Rating", "-XPTitle", "-XPKeywords", "-ImageDescription", + "-Orientation#", + "-GPSLatitude", "-GPSLongitude", "-GPSAltitude", + "-GPSHPositioningError", + "-XMP-exif:GPSLatitude", "-XMP-exif:GPSLongitude", + "-XMP-exif:GPSAltitude", "-XMP-exif:GPSHPositioningError", + "-Keys:GPSCoordinates", + "-Keys:LocationAccuracyHorizontal", + "-EXIF:DateTimeOriginal", + "-QuickTime:CreateDate", + file_path, + ] + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if result.returncode != 0: + logger.debug("exiftool read failed for %s: %s", file_path, result.stderr.strip()) + return {} + data = json.loads(result.stdout) + if not data: + return {} + raw = data[0] + # Normalise: strip group prefix. Priority for GPS comparison: + # XMP > Composite > EXIF. XMP has our written values for videos. + # Composite has correctly-signed values. EXIF GPS stores unsigned + # values with separate Ref tags, so is unreliable for comparison. + priority = {"EXIF": 0, "Composite": 1, "XMP": 2} + sorted_items = sorted( + raw.items(), + key=lambda kv: priority.get(kv[0].split(":")[0], 1) + ) + meta: dict[str, Any] = {} + for key, val in sorted_items: + if key == "SourceFile": + continue + parts = key.split(":", 1) + tag = parts[-1] + meta[tag] = val + # Parse Keys:GPSCoordinates into lat/lon for comparison + gps_coords = meta.pop("GPSCoordinates", None) + if gps_coords is not None and isinstance(gps_coords, str): + # Space-separated format from exiftool -n: "-33.86 151.21 50" + parts = gps_coords.replace("/", "").split() + if len(parts) >= 2: + try: + meta.setdefault("GPSLatitude", float(parts[0])) + meta.setdefault("GPSLongitude", float(parts[1])) + except ValueError: + pass + # Map LocationAccuracyHorizontal to GPSHPositioningError for uniform comparison + loc_acc = meta.pop("LocationAccuracyHorizontal", None) + if loc_acc is not None: + meta.setdefault("GPSHPositioningError", loc_acc) + # Detect stale XMP-exif GPS on videos (should use native Keys instead) + if _is_video(file_path): + for key in raw: + if key.startswith("XMP:GPS"): + meta["_has_stale_xmp_gps"] = True + break + return meta + except (subprocess.TimeoutExpired, FileNotFoundError, ValueError) as e: + logger.debug("Could not read existing metadata from %s: %s", file_path, e) + return {} + + +def _gps_close(a: float | None, b: float | None, tol: float = 1e-5) -> bool: + """Check if two GPS coordinates are close enough (~1 metre).""" + if a is None or b is None: + return a is b + return abs(a - b) < tol + + +def _needs_update( + update: MetadataUpdate, config: set[str], existing: dict[str, Any], + file_path: str = "", +) -> bool: + """Check if the file already has the correct metadata values.""" + write_all = "all" in config + + if (write_all or "rating" in config) and update.rating is not None and existing.get("Rating") != update.rating: + logger.debug("needs_update: %s: rating (existing=%s, update=%s)", file_path, existing.get("Rating"), update.rating) + return True + + if (write_all or "title" in config) and update.title is not None and existing.get("XPTitle") != update.title: + logger.debug("needs_update: %s: title (existing=%s, update=%s)", file_path, existing.get("XPTitle"), update.title) + return True + + if (write_all or "title" in config) and update.description is not None and existing.get("ImageDescription") != update.description: + logger.debug("needs_update: %s: description (existing=%s, update=%s)", file_path, existing.get("ImageDescription"), update.description) + return True + + if (write_all or "keywords" in config) and update.keywords: + xp_kw = "; ".join(update.keywords) + if existing.get("XPKeywords") != xp_kw: + logger.debug("needs_update: %s: keywords", file_path) + return True + + # Orientation is not meaningful for videos or PNG — matches build_exiftool_args + if ( + (write_all or "orientation" in config) + and update.orientation is not None + and not _is_video(file_path) + and os.path.splitext(file_path.lower())[1] != ".png" + and existing.get("Orientation") != update.orientation + ): + logger.debug("needs_update: %s: orientation (existing=%s, update=%s)", file_path, existing.get("Orientation"), update.orientation) + return True + + if (write_all or "location" in config) and update.gps_latitude is not None and update.gps_longitude is not None: + # exiftool -n returns already-signed GPS values + if not _gps_close(existing.get("GPSLatitude"), update.gps_latitude): + logger.debug("needs_update: %s: gps_latitude (existing=%s, update=%s)", file_path, existing.get("GPSLatitude"), update.gps_latitude) + return True + if not _gps_close(existing.get("GPSLongitude"), update.gps_longitude): + logger.debug("needs_update: %s: gps_longitude", file_path) + return True + if update.gps_h_accuracy is not None and update.gps_h_accuracy > 0 and not _gps_close(existing.get("GPSHPositioningError"), update.gps_h_accuracy, tol=0.1): + logger.debug("needs_update: %s: gps_h_accuracy", file_path) + return True + # Strip legacy XMP-exif GPS from videos (replaced by native Keys) + if existing.get("_has_stale_xmp_gps"): + logger.debug("needs_update: %s: stale XMP GPS", file_path) + return True + + if (write_all or "datetime" in config or "dates" in config) and update.created_date is not None and not _has_valid_date(existing): + logger.debug("needs_update: %s: datetime", file_path) + return True + + return False + + +_ZERO_DATE = "0000:00:00 00:00:00" + + +def _has_valid_date(existing: dict[str, Any]) -> bool: + """Check if the file has a valid (non-zero) date tag.""" + dt = existing.get("DateTimeOriginal") or existing.get("CreateDate") + return dt is not None and dt != _ZERO_DATE + + +def write_metadata( + file_path: str, + update: MetadataUpdate, + config: set[str], + dry_run: bool = False, +) -> bool: + """Write metadata to a file using exiftool. + + Reads existing metadata first and only writes if values differ, + ensuring true idempotency. + + Args: + file_path: Path to the image/video file. + update: Metadata fields to write. + config: Set of enabled field categories. + dry_run: If True, log what would be written but don't modify the file. + + Returns: + True if metadata was written (or would be written in dry-run), False if + no changes were needed or an error occurred. + """ + args = build_exiftool_args(update, config, file_path) + if not args: + return False + + # Check existing values to avoid unnecessary writes + existing = _read_existing_metadata(file_path) + if existing and not _needs_update(update, config, existing, file_path=file_path): + return False + + # Don't overwrite existing dates — the camera's value is ground truth; + # iCloud's assetDate may differ due to timezone/rounding. + # Check both EXIF (images) and QuickTime (videos) date tags. + if existing and _has_valid_date(existing): + args = [a for a in args if not a.startswith("-EXIF:DateTimeOriginal=") + and not a.startswith("-EXIF:CreateDate=") + and not a.startswith("-EXIF:ModifyDate=") + and not a.startswith("-QuickTime:CreateDate=") + and not a.startswith("-QuickTime:ModifyDate=")] + if not args: + return False + + if dry_run: + logger.info( + "Would write metadata to %s: %s", + file_path, + " ".join(args), + ) + return True + + cmd = ["exiftool", "-overwrite_original"] + args + [file_path] + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=1200 + ) + if result.returncode == 0 and "1 image files updated" in result.stdout: + # Check for corrupt XMP even on success — exiftool may have written + # some tags (GPS) but silently skipped others (Rating) due to XMP issues + if "Duplicate XMP property" in result.stderr: + logger.warning( + "Corrupt XMP in %s — stripping XMP and retrying", + file_path, + ) + strip_result = subprocess.run( + ["exiftool", "-overwrite_original", "-XMP:all=", file_path], + capture_output=True, text=True, timeout=1200, + ) + if "1 image files updated" in strip_result.stdout: + retry = subprocess.run( + cmd, capture_output=True, text=True, timeout=1200 + ) + if "1 image files updated" in retry.stdout: + logger.info("Wrote metadata to %s (after XMP repair)", file_path) + return True + logger.warning( + "Metadata write failed for %s even after XMP repair", file_path + ) + return False + logger.info("Wrote metadata to %s", file_path) + return True + if "0 image files updated" in result.stdout: + # Check for corrupt XMP — auto-repair by stripping and retrying + if "Duplicate XMP property" in result.stderr: + logger.warning( + "Corrupt XMP in %s — stripping XMP and retrying", + file_path, + ) + if not dry_run: + strip_result = subprocess.run( + ["exiftool", "-overwrite_original", "-XMP:all=", file_path], + capture_output=True, text=True, timeout=1200, + ) + if "1 image files updated" in strip_result.stdout: + retry = subprocess.run( + cmd, capture_output=True, text=True, timeout=1200 + ) + if "1 image files updated" in retry.stdout: + logger.info("Wrote metadata to %s (after XMP repair)", file_path) + return True + logger.warning( + "Metadata write failed for %s even after XMP repair", file_path + ) + return False + if result.returncode == 1: + logger.warning( + "Metadata write failed for %s: %s", + file_path, result.stderr.strip()[:200], + ) + else: + logger.warning( + "Metadata write had no effect on %s (file may have corrupt metadata)", + file_path, + ) + return False + logger.warning( + "exiftool returned unexpected output for %s: %s %s", + file_path, + result.stdout.strip(), + result.stderr.strip(), + ) + return False + except subprocess.TimeoutExpired: + logger.warning("exiftool timed out writing to %s", file_path) + return False + except FileNotFoundError: + logger.error("exiftool not found") + return False diff --git a/src/icloudpd/xmp_sidecar.py b/src/icloudpd/xmp_sidecar.py index b6131d7a2..161dfa3e5 100644 --- a/src/icloudpd/xmp_sidecar.py +++ b/src/icloudpd/xmp_sidecar.py @@ -13,6 +13,7 @@ from xml.etree import ElementTree from foundation import version_info +from icloudpd.dir_cache import DirCache exif_tool = None @@ -29,17 +30,30 @@ class XMPMetadata(NamedTuple): GPSLatitude: float | None GPSLongitude: float | None GPSSpeed: float | None + GPSHPositioningError: float | None GPSTimeStamp: datetime | None CreateDate: datetime | None Rating: int | None def generate_xmp_file( - logger: logging.Logger, download_path: str, asset_record: dict[str, Any], dry_run: bool + logger: logging.Logger, + download_path: str, + asset_record: dict[str, Any], + dry_run: bool, + dir_cache: DirCache | None = None, ) -> None: sidecar_path: str = download_path + ".xmp" + + # Use dir_cache for existence/size checks to avoid per-file network stat calls + sidecar_exists = dir_cache.exists(sidecar_path) if dir_cache else os.path.exists(sidecar_path) + sidecar_size = 0 + if sidecar_exists: + sidecar_size = dir_cache.getsize(sidecar_path) if dir_cache else os.path.getsize(sidecar_path) + can_write_file: bool = True - if os.path.exists(sidecar_path) and os.path.getsize(sidecar_path) != 0: + if sidecar_exists and sidecar_size != 0: + # Sidecar exists — check if it was created by icloudpd before overwriting can_write_file = False try: root = ElementTree.parse(sidecar_path).getroot() @@ -53,41 +67,56 @@ def generate_xmp_file( else: can_write_file = True except ElementTree.ParseError as e: - logger.info(f"Not overwriting XMP file {sidecar_path} due to parser error: {e}") - - # decode asset record fields - # for k in asset_record['fields']: - # if asset_record["fields"][k]['type'] == "ENCRYPTED_BYTES": - # try: - # asset_record["fields"][k]['decoded'] = plistlib.loads(base64.b64decode(asset_record['fields'][k]['value']), fmt=plistlib.FMT_BINARY) - # except plistlib.InvalidFileException: - # try: - # asset_record["fields"][k]['decoded'] = json.loads(zlib.decompress(base64.b64decode(asset_record['fields'][k]['value']),-zlib.MAX_WBITS)) - # except Exception as e: - # asset_record["fields"][k]['decoded'] = base64.b64decode(asset_record['fields'][k]['value']).decode("utf-8") - # json.dump(asset_record["fields"], open(download_path + ".ar.json", "w"), indent=4, default=str, sort_keys=True) + # If the corrupt file was created by icloudpd, regenerate it + try: + with open(sidecar_path, "rb") as f: + raw = f.read() + if b"icloudpd" in raw: + logger.info(f"Regenerating corrupt icloudpd XMP file {sidecar_path}: {e}") + can_write_file = True + else: + logger.info( + f"Not overwriting XMP file {sidecar_path} due to parser error: {e}" + ) + except OSError: + logger.info(f"Not overwriting XMP file {sidecar_path} due to parser error: {e}") if can_write_file: - xmp_metadata: XMPMetadata = build_metadata(asset_record) + xmp_metadata: XMPMetadata = build_metadata(logger, asset_record) xml_doc: ElementTree.Element = generate_xml(xmp_metadata) if not dry_run: - # Write the XML to the file - with open(sidecar_path, "wb") as f: - f.write(ElementTree.tostring(xml_doc, encoding="utf-8", xml_declaration=True)) + # Write the XML to the file atomically + new_content = ElementTree.tostring(xml_doc, encoding="utf-8", xml_declaration=True) + tmp_path = sidecar_path + ".tmp" + with open(tmp_path, "wb") as f: + f.write(new_content) + os.replace(tmp_path, sidecar_path) + if dir_cache: + dir_cache.notify_new_file(sidecar_path, len(new_content)) -def build_metadata(asset_record: dict[str, Any]) -> XMPMetadata: +def build_metadata(logger: logging.Logger, asset_record: dict[str, Any]) -> XMPMetadata: """Build XMP metadata from asset record""" title = None if "captionEnc" in asset_record["fields"]: - title = base64.b64decode(asset_record["fields"]["captionEnc"]["value"]).decode("utf-8") + caption_value = asset_record["fields"]["captionEnc"].get("value", "") + if caption_value: + try: + title = base64.b64decode(caption_value).decode("utf-8") + except (ValueError, TypeError, UnicodeDecodeError) as e: + logger.warning("Error decoding caption: %s", e) + title = None description = None if "extendedDescEnc" in asset_record["fields"]: - description = base64.b64decode(asset_record["fields"]["extendedDescEnc"]["value"]).decode( - "utf-8" - ) + desc_value = asset_record["fields"]["extendedDescEnc"].get("value", "") + if desc_value: + try: + description = base64.b64decode(desc_value).decode("utf-8") + except (ValueError, TypeError, UnicodeDecodeError) as e: + logger.warning("Error decoding description: %s", e) + description = None # adjustementSimpleDataEnc can be one of three formats: # - a binary plist - starting with 'bplist00' ( YnBsaXN0MD once encoded), seemingly used for some videos metadata (slow motion range etc) @@ -122,24 +151,43 @@ def build_metadata(asset_record: dict[str, Any]) -> XMPMetadata: digital_source_type = "screenCapture" keywords = None - if "keywordsEnc" in asset_record["fields"] and len(asset_record["fields"]["keywordsEnc"]) > 0: - keywords = plistlib.loads( - base64.b64decode(asset_record["fields"]["keywordsEnc"]["value"]), - ) + if "keywordsEnc" in asset_record["fields"]: + kw_value = asset_record["fields"]["keywordsEnc"].get("value", "") + if kw_value: + try: + keywords = plistlib.loads(base64.b64decode(kw_value)) + except (plistlib.InvalidFileException, ValueError, TypeError, AttributeError) as e: + logger.warning("Error decoding keywords: %s", e) + keywords = None if "locationEnc" in asset_record["fields"]: - location = plistlib.loads( - base64.b64decode(asset_record["fields"]["locationEnc"]["value"]), - ) - gps_altitude = location.get("alt") - gps_latitude = location.get("lat") - gps_longitude = location.get("lon") - gps_speed = location.get("speed") - gps_timestamp = ( - location.get("timestamp") if isinstance(location.get("timestamp"), datetime) else None - ) + loc_value = asset_record["fields"]["locationEnc"].get("value", "") + if loc_value: + try: + location = plistlib.loads(base64.b64decode(loc_value)) + gps_altitude = location.get("alt") + gps_latitude = location.get("lat") + gps_longitude = location.get("lon") + gps_speed = location.get("speed") + h_acc = location.get("horzAcc") + gps_h_accuracy = float(h_acc) if h_acc is not None and h_acc > 0 else None + gps_timestamp = ( + location.get("timestamp") + if isinstance(location.get("timestamp"), datetime) + else None + ) + except (plistlib.InvalidFileException, ValueError, TypeError, AttributeError) as e: + logger.warning("Error decoding location: %s", e) + gps_altitude, gps_latitude, gps_longitude, gps_speed, gps_h_accuracy, gps_timestamp = ( + None, None, None, None, None, None, + ) + else: + gps_altitude, gps_latitude, gps_longitude, gps_speed, gps_h_accuracy, gps_timestamp = ( + None, None, None, None, None, None, + ) else: - gps_altitude, gps_latitude, gps_longitude, gps_speed, gps_timestamp = ( + gps_altitude, gps_latitude, gps_longitude, gps_speed, gps_h_accuracy, gps_timestamp = ( + None, None, None, None, @@ -184,6 +232,7 @@ def build_metadata(asset_record: dict[str, Any]) -> XMPMetadata: GPSLatitude=gps_latitude, GPSLongitude=gps_longitude, GPSSpeed=gps_speed, + GPSHPositioningError=gps_h_accuracy, GPSTimeStamp=gps_timestamp, CreateDate=create_date, Rating=rating, @@ -266,20 +315,24 @@ def generate_xml(metadata: XMPMetadata) -> ElementTree.Element: for keyword in metadata.Keywords: ElementTree.SubElement(seq, "rdf:li").text = keyword - if metadata.GPSAltitude: + if metadata.GPSAltitude is not None: ElementTree.SubElement(description_exif, "exif:GPSAltitude").text = str( metadata.GPSAltitude ) - if metadata.GPSLatitude: + if metadata.GPSLatitude is not None: ElementTree.SubElement(description_exif, "exif:GPSLatitude").text = str( metadata.GPSLatitude ) - if metadata.GPSLongitude: + if metadata.GPSLongitude is not None: ElementTree.SubElement(description_exif, "exif:GPSLongitude").text = str( metadata.GPSLongitude ) - if metadata.GPSSpeed: + if metadata.GPSSpeed is not None: ElementTree.SubElement(description_exif, "exif:GPSSpeed").text = str(metadata.GPSSpeed) + if metadata.GPSHPositioningError is not None: + ElementTree.SubElement(description_exif, "exif:GPSHPositioningError").text = str( + metadata.GPSHPositioningError + ) if metadata.GPSTimeStamp: ElementTree.SubElement( description_exif, "exif:GPSTimeStamp" diff --git a/src/pyicloud_ipd/services/photos.py b/src/pyicloud_ipd/services/photos.py index 12e5c0b32..ad4e49838 100644 --- a/src/pyicloud_ipd/services/photos.py +++ b/src/pyicloud_ipd/services/photos.py @@ -863,7 +863,7 @@ def asset_date(self) -> datetime: self._asset_record["fields"]["assetDate"]["value"] / 1000.0, tz=pytz.utc ) except (KeyError, TypeError, ValueError): - dt = datetime.fromtimestamp(0) + dt = datetime.fromtimestamp(0, tz=pytz.utc) return dt @property diff --git a/src/pyicloud_ipd/session.py b/src/pyicloud_ipd/session.py index 23791c0d4..16c99241f 100644 --- a/src/pyicloud_ipd/session.py +++ b/src/pyicloud_ipd/session.py @@ -1,6 +1,7 @@ import inspect import json import logging +import os import typing from typing import Any, Callable, Dict, Mapping, NoReturn, Sequence @@ -89,12 +90,21 @@ def request(self, method: str, url, **kwargs): # type: ignore self.service.session_data.update({session_arg: response.headers.get(header)}) # Save session_data to file - with open(self.service.session_path, "w", encoding="utf-8") as outfile: + tmp_session_path = self.service.session_path + ".tmp" + with open(tmp_session_path, "w", encoding="utf-8") as outfile: json.dump(self.service.session_data, outfile) - LOGGER.debug("Saved session data to file") + os.replace(tmp_session_path, self.service.session_path) + LOGGER.debug("Saved session data to file") # Save cookies to file - self.cookies.save(ignore_discard=True, ignore_expires=True) # type: ignore[attr-defined] + tmp_cookie_path = self.service.cookiejar_path + ".tmp" + self.cookies.save( # type: ignore[attr-defined] + filename=tmp_cookie_path, + ignore_discard=True, + ignore_expires=True, + ) + if os.path.exists(tmp_cookie_path): + os.replace(tmp_cookie_path, self.service.cookiejar_path) LOGGER.debug("Cookies saved to %s", self.service.cookiejar_path) if not response.ok and ( diff --git a/src/pyicloud_ipd/version_size.py b/src/pyicloud_ipd/version_size.py index ef75eb58e..2b33bccfc 100644 --- a/src/pyicloud_ipd/version_size.py +++ b/src/pyicloud_ipd/version_size.py @@ -22,3 +22,22 @@ def __str__(self) -> str: VersionSize = AssetVersionSize | LivePhotoVersionSize + + +# Maps VersionSize enums to Apple's iCloud resource field prefixes. +# These prefixes are stable identifiers from the CPLMaster record. +_VERSION_TO_RESOURCE: dict[VersionSize, str] = { + AssetVersionSize.ORIGINAL: "resOriginal", + AssetVersionSize.ALTERNATIVE: "resOriginalAlt", + AssetVersionSize.ADJUSTED: "resJPEGFull", + AssetVersionSize.MEDIUM: "resJPEGMed", + AssetVersionSize.THUMB: "resJPEGThumb", + LivePhotoVersionSize.ORIGINAL: "resOriginalVidCompl", + LivePhotoVersionSize.MEDIUM: "resVidMed", + LivePhotoVersionSize.THUMB: "resVidSmall", +} + + +def version_to_resource(version_size: VersionSize) -> str: + """Map a VersionSize enum to its Apple resource field prefix.""" + return _VERSION_TO_RESOURCE[version_size] diff --git a/tests/data/test_image.gif b/tests/data/test_image.gif new file mode 100644 index 000000000..d2a742742 Binary files /dev/null and b/tests/data/test_image.gif differ diff --git a/tests/data/test_image.webp b/tests/data/test_image.webp new file mode 100644 index 000000000..16a0a3801 Binary files /dev/null and b/tests/data/test_image.webp differ diff --git a/tests/data/test_video.mov b/tests/data/test_video.mov new file mode 100644 index 000000000..876de181c Binary files /dev/null and b/tests/data/test_video.mov differ diff --git a/tests/data/test_video.mp4 b/tests/data/test_video.mp4 new file mode 100644 index 000000000..2add42479 Binary files /dev/null and b/tests/data/test_video.mp4 differ diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py index 5650440ff..53da8de6a 100644 --- a/tests/helpers/__init__.py +++ b/tests/helpers/__init__.py @@ -101,7 +101,10 @@ def __call__(self, __first: _T, __second: _T, __msg: str) -> None: ... def assert_files( assert_equal: AssertEquality, data_dir: str, files_to_assert: Sequence[Tuple[str, str]] ) -> None: - files_in_result = glob.glob(os.path.join(data_dir, "**/*.*"), recursive=True) + files_in_result = [ + f for f in glob.glob(os.path.join(data_dir, "**/*.*"), recursive=True) + if not os.path.basename(f).startswith(".icloudpd") + ] assert_equal(sum(1 for _ in files_in_result), len(files_to_assert), "File count does not match") diff --git a/tests/test_autodelete_photos.py b/tests/test_autodelete_photos.py index a39b34af6..a5c4b7753 100644 --- a/tests/test_autodelete_photos.py +++ b/tests/test_autodelete_photos.py @@ -911,14 +911,17 @@ def test_autodelete_photos_lp(self) -> None: shutil.copytree(cookie_master_path, cookie_dir) - files_to_create = ["2018/07/30/IMG_7407.JPG", "2018/07/30/IMG_7407-original.JPG"] + files_to_create = [ + f"{datetime.datetime.fromtimestamp(1532940539000.0 / 1000.0, tz=pytz.utc).astimezone(get_localzone()):%Y/%m/%d}/IMG_7407.JPG", + f"{datetime.datetime.fromtimestamp(1532940539000.0 / 1000.0, tz=pytz.utc).astimezone(get_localzone()):%Y/%m/%d}/IMG_7407-original.JPG", + ] files_to_delete = [ - "2018/07/30/IMG_7406.MOV", - "2018/07/26/IMG_7383.PNG", - "2018/07/12/IMG_7190.JPG", - "2018/07/12/IMG_7190-medium.JPG", - "2018/07/12/IMG_7190.MOV", # Live Photo for JPG + f"{datetime.datetime.fromtimestamp(1532940539000.0 / 1000.0, tz=pytz.utc).astimezone(get_localzone()):%Y/%m/%d}/IMG_7406.MOV", + f"{datetime.datetime.fromtimestamp(1532618424000.0 / 1000.0, tz=pytz.utc).astimezone(get_localzone()):%Y/%m/%d}/IMG_7383.PNG", + f"{datetime.datetime.fromtimestamp(1531371164630.0 / 1000.0, tz=pytz.utc).astimezone(get_localzone()):%Y/%m/%d}/IMG_7190.JPG", + f"{datetime.datetime.fromtimestamp(1531371164630.0 / 1000.0, tz=pytz.utc).astimezone(get_localzone()):%Y/%m/%d}/IMG_7190-medium.JPG", + f"{datetime.datetime.fromtimestamp(1531371164630.0 / 1000.0, tz=pytz.utc).astimezone(get_localzone()):%Y/%m/%d}/IMG_7190.MOV", # Live Photo for JPG ] # create some empty files @@ -1003,14 +1006,17 @@ def test_autodelete_photos_lp_heic(self) -> None: shutil.copytree(cookie_master_path, cookie_dir) - files_to_create = ["2018/07/30/IMG_7407.JPG", "2018/07/30/IMG_7407-original.JPG"] + files_to_create = [ + f"{datetime.datetime.fromtimestamp(1532940539000.0 / 1000.0, tz=pytz.utc).astimezone(get_localzone()):%Y/%m/%d}/IMG_7407.JPG", + f"{datetime.datetime.fromtimestamp(1532940539000.0 / 1000.0, tz=pytz.utc).astimezone(get_localzone()):%Y/%m/%d}/IMG_7407-original.JPG", + ] files_to_delete = [ - "2018/07/30/IMG_7406.MOV", - "2018/07/26/IMG_7383.PNG", - "2018/07/12/IMG_7190.HEIC", # SU1HXzcxOTAuSlBH -> SU1HXzcxOTAuSEVJQw== - "2018/07/12/IMG_7190-medium.JPG", - "2018/07/12/IMG_7190_HEVC.MOV", # Live Photo for HEIC + f"{datetime.datetime.fromtimestamp(1532940539000.0 / 1000.0, tz=pytz.utc).astimezone(get_localzone()):%Y/%m/%d}/IMG_7406.MOV", + f"{datetime.datetime.fromtimestamp(1532618424000.0 / 1000.0, tz=pytz.utc).astimezone(get_localzone()):%Y/%m/%d}/IMG_7383.PNG", + f"{datetime.datetime.fromtimestamp(1531371164630.0 / 1000.0, tz=pytz.utc).astimezone(get_localzone()):%Y/%m/%d}/IMG_7190.HEIC", # SU1HXzcxOTAuSlBH -> SU1HXzcxOTAuSEVJQw== + f"{datetime.datetime.fromtimestamp(1531371164630.0 / 1000.0, tz=pytz.utc).astimezone(get_localzone()):%Y/%m/%d}/IMG_7190-medium.JPG", + f"{datetime.datetime.fromtimestamp(1531371164630.0 / 1000.0, tz=pytz.utc).astimezone(get_localzone()):%Y/%m/%d}/IMG_7190_HEVC.MOV", # Live Photo for HEIC ] # create some empty files diff --git a/tests/test_cli.py b/tests/test_cli.py index 7bc6996b1..5c02520f9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,12 +2,12 @@ import inspect import os import shutil -import zoneinfo from argparse import ArgumentError from typing import Sequence, Tuple from unittest import TestCase import pytest +from tzlocal import get_localzone from icloudpd.cli import format_help, parse from icloudpd.config import GlobalConfig, UserConfig @@ -211,11 +211,13 @@ def test_cli_parser(self) -> None: list_libraries=False, skip_videos=False, skip_live_photos=False, - xmp_sidecar=False, force_size=False, auto_delete=False, + delete_orphaned=False, folder_structure="{:%Y/%m/%d}", set_exif_datetime=False, + write_metadata_xmp=frozenset(), + write_metadata_exif=frozenset(), smtp_username=None, smtp_password=None, smtp_host="smtp.gmail.com", @@ -234,6 +236,7 @@ def test_cli_parser(self) -> None: skip_created_before=None, skip_created_after=None, skip_photos=False, + accept_apple_changes=False, ), UserConfig( directory="def", @@ -251,11 +254,13 @@ def test_cli_parser(self) -> None: list_libraries=False, skip_videos=False, skip_live_photos=False, - xmp_sidecar=False, force_size=False, auto_delete=False, + delete_orphaned=False, folder_structure="{:%Y/%m/%d}", set_exif_datetime=False, + write_metadata_xmp=frozenset(), + write_metadata_exif=frozenset(), smtp_username=None, smtp_password=None, smtp_host="smtp.gmail.com", @@ -274,6 +279,7 @@ def test_cli_parser(self) -> None: skip_created_before=None, skip_created_after=None, skip_photos=False, + accept_apple_changes=False, ), ], ), @@ -327,11 +333,13 @@ def test_cli_parser(self) -> None: list_libraries=False, skip_videos=False, skip_live_photos=False, - xmp_sidecar=False, force_size=False, auto_delete=False, + delete_orphaned=False, folder_structure="{:%Y/%m/%d}", set_exif_datetime=False, + write_metadata_xmp=frozenset(), + write_metadata_exif=frozenset(), smtp_username=None, smtp_password=None, smtp_host="smtp.gmail.com", @@ -348,10 +356,11 @@ def test_cli_parser(self) -> None: align_raw=RawTreatmentPolicy.AS_IS, file_match_policy=FileMatchPolicy.NAME_SIZE_DEDUP_WITH_SUFFIX, skip_created_before=datetime.datetime( - year=2025, month=1, day=2, tzinfo=zoneinfo.ZoneInfo(key="Etc/UTC") - ), + year=2025, month=1, day=2 + ).astimezone(get_localzone()), skip_created_after=datetime.timedelta(days=2), skip_photos=False, + accept_apple_changes=False, ), ], ), diff --git a/tests/test_dedup_exif.py b/tests/test_dedup_exif.py new file mode 100644 index 000000000..30398dfcc --- /dev/null +++ b/tests/test_dedup_exif.py @@ -0,0 +1,191 @@ +"""Test manifest-based identity matching replaces fragile size-based dedup. + +With the asset manifest, file identity is determined by iCloud's recordName +(asset_id), not by comparing file sizes. This eliminates the EXIF re-serialisation +false dedup problem entirely. +""" + +import inspect +import logging +import os +import sqlite3 +from typing import Any, Callable, List, Tuple +from unittest import TestCase, mock + +import pytest + +from tests.helpers import ( + path_from_project_root, + run_icloudpd_test, +) + + +class ManifestDedupTest(TestCase): + """Manifest-based sync should track files by identity, not size.""" + + @pytest.fixture(autouse=True) + def inject_fixtures(self) -> None: + self.root_path = path_from_project_root(__file__) + self.fixtures_path = os.path.join(self.root_path, "fixtures") + + def test_mismatched_size_jpeg_adopted_by_manifest(self) -> None: + """A JPEG with mismatched size should be adopted by manifest, not deduped.""" + base_dir = os.path.join(self.fixtures_path, inspect.stack()[0][3]) + + files_to_create = [ + ("2018/07/31", "IMG_7409.JPG", 1884780), # +85 bytes (EXIF delta) + ("2018/07/31", "IMG_7409.MOV", 3294075), + ("2018/07/30", "IMG_7408.JPG", 1151066), + ("2018/07/30", "IMG_7408.MOV", 1606512), + ] + files_to_download: List[Tuple[str, str]] = [] + + with mock.patch("icloudpd.exif_datetime.get_photo_exif") as get_exif_patched: + get_exif_patched.return_value = "2018:07:31 07:22:24" + data_dir, result = run_icloudpd_test( + self.assertEqual, + self.root_path, + base_dir, + "listing_photos.yml", + files_to_create, + files_to_download, + [ + "--username", + "jdoe@gmail.com", + "--password", + "password1", + "--recent", + "3", + "--skip-videos", + "--set-exif-datetime", + "--no-progress-bar", + "--threads-num", + "1", + ], + ) + + self.assertNotIn("deduplicated", result.output) + self.assertIn("adopted into manifest", result.output) + dedup_files = [ + f + for f in os.listdir(os.path.join(data_dir, "2018", "07", "31")) + if "-1884695" in f + ] + self.assertEqual(dedup_files, [], "No dedup-suffix files should be created") + self.assertTrue(os.path.isfile(os.path.join(data_dir, ".icloudpd.db"))) + + def test_version_change_triggers_redownload(self) -> None: + """When manifest has a different version_size, file should be re-downloaded.""" + import shutil + from functools import partial + + from foundation.core import compose, partial_2_1 + from tests.helpers import ( + DEFAULT_ENV, + calc_cookie_dir, + calc_data_dir, + calc_vcr_dir, + create_files, + print_result_exception, + recreate_path, + run_main_env, + run_with_cassette, + ) + + base_dir = os.path.join(self.fixtures_path, inspect.stack()[0][3]) + cookie_dir = calc_cookie_dir(base_dir) + data_dir = calc_data_dir(base_dir) + vcr_path = calc_vcr_dir(self.root_path) + cookie_master_path = calc_cookie_dir(self.root_path) + + for d in [base_dir, data_dir]: + recreate_path(d) + shutil.copytree(cookie_master_path, cookie_dir) + + files_to_create = [ + ("2018/07/31", "IMG_7409.JPG", 1884695), + ("2018/07/31", "IMG_7409.MOV", 3294075), + ("2018/07/30", "IMG_7408.JPG", 1151066), + ("2018/07/30", "IMG_7408.MOV", 1606512), + ] + create_files(data_dir, files_to_create) + + # Seed manifest with WRONG version_size for IMG_7409.JPG — triggers re-download + db_path = os.path.join(data_dir, ".icloudpd.db") + conn = sqlite3.connect(db_path) + conn.executescript("""\ + CREATE TABLE manifest ( + asset_id TEXT NOT NULL, + zone_id TEXT NOT NULL DEFAULT '', + local_path TEXT NOT NULL, + version_size INTEGER NOT NULL, + version_checksum TEXT, + change_tag TEXT, + downloaded_at TEXT NOT NULL, + PRIMARY KEY (asset_id, zone_id, local_path) + ); + """) + conn.execute( + "INSERT INTO manifest VALUES (?, ?, ?, ?, ?, ?, ?)", + ("AY6c+BsE0jjaXx9tmVGJM1D2VcEO", "PrimarySync", + os.path.join("2018", "07", "31", "IMG_7409.JPG"), + 999, None, None, "2026-01-01T00:00:00+00:00"), + ) + conn.commit() + conn.close() + + combined_env = {**DEFAULT_ENV} + main_runner = compose(print_result_exception, partial(run_main_env, combined_env, input=None)) + with_cassette_main_runner = partial_2_1( + run_with_cassette, os.path.join(vcr_path, "listing_photos.yml"), main_runner + ) + + def fake_download( + logger: logging.Logger, + dry_run: bool, + icloud: Any, + photo: Any, + download_path: str, + version: Any, + size: Any, + filename_builder: Callable[..., str], + ) -> bool: + """Stub download that writes a file so the post-download manifest upsert runs. + + The real download_media relies on VCR cassette HTTP playback which + fails on Windows. This stub writes a dummy file and returns success, + allowing the manifest upsert in base.py to execute with the real + version.size from the cassette. + """ + os.makedirs(os.path.dirname(download_path), exist_ok=True) + with open(download_path, "wb") as f: + f.write(b"\x00" * version.size) + return True + + with mock.patch("icloudpd.exif_datetime.get_photo_exif") as get_exif_patched, \ + mock.patch("icloudpd.download.download_media", side_effect=fake_download): + get_exif_patched.return_value = "2018:07:31 07:22:24" + result = with_cassette_main_runner([ + "-d", data_dir, + "--cookie-directory", cookie_dir, + "--username", "jdoe@gmail.com", + "--password", "password1", + "--recent", "3", + "--skip-videos", + "--set-exif-datetime", + "--no-progress-bar", + "--threads-num", "1", + "--accept-apple-changes", + ]) + + self.assertIn("version changed", result.output) + + # Verify manifest was updated with the correct new size + conn = sqlite3.connect(db_path) + row = conn.execute( + "SELECT version_size FROM manifest WHERE asset_id = ?", + ("AY6c+BsE0jjaXx9tmVGJM1D2VcEO",), + ).fetchone() + conn.close() + assert row is not None, "Manifest row should exist after re-download" + self.assertEqual(row[0], 1884695, "version_size should match the iCloud API size") diff --git a/tests/test_delete_orphaned.py b/tests/test_delete_orphaned.py new file mode 100644 index 000000000..f73a30555 --- /dev/null +++ b/tests/test_delete_orphaned.py @@ -0,0 +1,210 @@ +"""Tests for delete_orphaned functionality.""" + +import logging +import os + +from icloudpd.base import delete_orphaned +from icloudpd.manifest import ManifestDB + + +def _setup_manifest_with_files( + tmpdir: str, + entries: list[dict[str, str]], +) -> ManifestDB: + """Create a manifest DB with entries and corresponding files on disk.""" + manifest = ManifestDB(tmpdir) + manifest.open() + for entry in entries: + asset_id = entry["asset_id"] + zone_id = entry.get("zone_id", "PrimarySync") + local_path = entry["local_path"] + asset_resource = entry.get("asset_resource", "resOriginal") + # Create the file on disk + full_path = os.path.join(tmpdir, local_path) + os.makedirs(os.path.dirname(full_path), exist_ok=True) + with open(full_path, "w") as f: + f.write("test content") + # Create XMP sidecar + with open(full_path + ".xmp", "w") as f: + f.write("test") + # Add to manifest + manifest.upsert( + asset_id=asset_id, + zone_id=zone_id, + local_path=local_path, + version_size=1, + asset_resource=asset_resource, + ) + manifest.flush() + return manifest + + +class TestFindOrphaned: + """Tests for ManifestDB.find_orphaned().""" + + def test_no_orphans(self, tmp_path: object) -> None: + tmpdir = str(tmp_path) + manifest = _setup_manifest_with_files(tmpdir, [ + {"asset_id": "A1", "local_path": "2024-01/photo1.HEIC"}, + {"asset_id": "A2", "local_path": "2024-01/photo2.HEIC"}, + ]) + seen = {"A1", "A2"} + orphans = manifest.find_orphaned(seen) + assert len(orphans) == 0 + manifest.close() + + def test_all_orphaned(self, tmp_path: object) -> None: + tmpdir = str(tmp_path) + manifest = _setup_manifest_with_files(tmpdir, [ + {"asset_id": "A1", "local_path": "2024-01/photo1.HEIC"}, + {"asset_id": "A2", "local_path": "2024-01/photo2.HEIC"}, + ]) + seen: set[str] = set() + orphans = manifest.find_orphaned(seen) + assert len(orphans) == 2 + assert {o.asset_id for o in orphans} == {"A1", "A2"} + manifest.close() + + def test_partial_orphan(self, tmp_path: object) -> None: + tmpdir = str(tmp_path) + manifest = _setup_manifest_with_files(tmpdir, [ + {"asset_id": "A1", "local_path": "2024-01/photo1.HEIC"}, + {"asset_id": "A2", "local_path": "2024-01/photo2.HEIC"}, + {"asset_id": "A3", "local_path": "2024-01/photo3.HEIC"}, + ]) + seen = {"A1", "A3"} + orphans = manifest.find_orphaned(seen) + assert len(orphans) == 1 + assert orphans[0].asset_id == "A2" + manifest.close() + + def test_multi_resource_orphan(self, tmp_path: object) -> None: + """An asset with multiple resources (HEIC + MOV) should return all rows.""" + tmpdir = str(tmp_path) + manifest = _setup_manifest_with_files(tmpdir, [ + {"asset_id": "A1", "local_path": "2024-01/IMG_001.HEIC", "asset_resource": "resOriginal"}, + {"asset_id": "A1", "local_path": "2024-01/IMG_001_HEVC.MOV", "asset_resource": "resOriginalVidCompl"}, + {"asset_id": "A2", "local_path": "2024-01/IMG_002.HEIC", "asset_resource": "resOriginal"}, + ]) + seen = {"A2"} + orphans = manifest.find_orphaned(seen) + assert len(orphans) == 2 + assert all(o.asset_id == "A1" for o in orphans) + manifest.close() + + def test_empty_manifest(self, tmp_path: object) -> None: + tmpdir = str(tmp_path) + manifest = ManifestDB(tmpdir) + manifest.open() + orphans = manifest.find_orphaned({"A1", "A2"}) + assert len(orphans) == 0 + manifest.close() + + +class TestDeleteOrphaned: + """Tests for the delete_orphaned() function.""" + + def test_warning_mode_no_flag(self, tmp_path: object) -> None: + """Without --delete-orphaned, logs warning but doesn't delete.""" + tmpdir = str(tmp_path) + manifest = _setup_manifest_with_files(tmpdir, [ + {"asset_id": "A1", "local_path": "2024-01/photo1.HEIC"}, + ]) + logger = logging.getLogger("test") + # Call without delete flag + delete_orphaned(logger, manifest, tmpdir, set(), delete=False, dry_run=False) + # File should still exist + assert os.path.exists(os.path.join(tmpdir, "2024-01/photo1.HEIC")) + assert os.path.exists(os.path.join(tmpdir, "2024-01/photo1.HEIC.xmp")) + # Manifest should still have the entry + assert manifest.count() == 1 + manifest.close() + + def test_delete_mode(self, tmp_path: object) -> None: + """With --delete-orphaned, files and manifest entries are removed.""" + tmpdir = str(tmp_path) + manifest = _setup_manifest_with_files(tmpdir, [ + {"asset_id": "A1", "local_path": "2024-01/photo1.HEIC"}, + {"asset_id": "A2", "local_path": "2024-01/photo2.HEIC"}, + ]) + logger = logging.getLogger("test") + delete_orphaned(logger, manifest, tmpdir, {"A2"}, delete=True, dry_run=False) + # A1 should be deleted + assert not os.path.exists(os.path.join(tmpdir, "2024-01/photo1.HEIC")) + assert not os.path.exists(os.path.join(tmpdir, "2024-01/photo1.HEIC.xmp")) + # A2 should remain + assert os.path.exists(os.path.join(tmpdir, "2024-01/photo2.HEIC")) + assert os.path.exists(os.path.join(tmpdir, "2024-01/photo2.HEIC.xmp")) + # Manifest should only have A2 + assert manifest.count() == 1 + manifest.close() + + def test_dry_run_skips_deletion(self, tmp_path: object) -> None: + """Dry run logs but doesn't delete files or manifest entries.""" + tmpdir = str(tmp_path) + manifest = _setup_manifest_with_files(tmpdir, [ + {"asset_id": "A1", "local_path": "2024-01/photo1.HEIC"}, + ]) + logger = logging.getLogger("test") + delete_orphaned(logger, manifest, tmpdir, set(), delete=True, dry_run=True) + # File should still exist + assert os.path.exists(os.path.join(tmpdir, "2024-01/photo1.HEIC")) + assert os.path.exists(os.path.join(tmpdir, "2024-01/photo1.HEIC.xmp")) + # Manifest should still have the entry + assert manifest.count() == 1 + manifest.close() + + def test_multi_resource_cleanup(self, tmp_path: object) -> None: + """Deleting orphaned asset removes all resources (HEIC + MOV + XMPs).""" + tmpdir = str(tmp_path) + manifest = _setup_manifest_with_files(tmpdir, [ + {"asset_id": "A1", "local_path": "2024-01/IMG.HEIC", "asset_resource": "resOriginal"}, + {"asset_id": "A1", "local_path": "2024-01/IMG_HEVC.MOV", "asset_resource": "resOriginalVidCompl"}, + ]) + logger = logging.getLogger("test") + delete_orphaned(logger, manifest, tmpdir, set(), delete=True, dry_run=False) + assert not os.path.exists(os.path.join(tmpdir, "2024-01/IMG.HEIC")) + assert not os.path.exists(os.path.join(tmpdir, "2024-01/IMG.HEIC.xmp")) + assert not os.path.exists(os.path.join(tmpdir, "2024-01/IMG_HEVC.MOV")) + assert not os.path.exists(os.path.join(tmpdir, "2024-01/IMG_HEVC.MOV.xmp")) + assert manifest.count() == 0 + manifest.close() + + def test_no_orphans_no_action(self, tmp_path: object) -> None: + """When all assets are seen, no warning or deletion.""" + tmpdir = str(tmp_path) + manifest = _setup_manifest_with_files(tmpdir, [ + {"asset_id": "A1", "local_path": "2024-01/photo1.HEIC"}, + ]) + logger = logging.getLogger("test") + delete_orphaned(logger, manifest, tmpdir, {"A1"}, delete=True, dry_run=False) + assert os.path.exists(os.path.join(tmpdir, "2024-01/photo1.HEIC")) + assert manifest.count() == 1 + manifest.close() + + def test_file_already_missing(self, tmp_path: object) -> None: + """Orphan in manifest but file already deleted from disk.""" + tmpdir = str(tmp_path) + manifest = _setup_manifest_with_files(tmpdir, [ + {"asset_id": "A1", "local_path": "2024-01/photo1.HEIC"}, + ]) + # Remove the file manually + os.remove(os.path.join(tmpdir, "2024-01/photo1.HEIC")) + os.remove(os.path.join(tmpdir, "2024-01/photo1.HEIC.xmp")) + logger = logging.getLogger("test") + # Should not crash, just remove manifest entry + delete_orphaned(logger, manifest, tmpdir, set(), delete=True, dry_run=False) + assert manifest.count() == 0 + manifest.close() + + def test_dedup_suffix_files(self, tmp_path: object) -> None: + """Orphan with dedup suffix in path is handled correctly.""" + tmpdir = str(tmp_path) + manifest = _setup_manifest_with_files(tmpdir, [ + {"asset_id": "A1", "local_path": "2024-01/IMG_001_aB3x.HEIC"}, + ]) + logger = logging.getLogger("test") + delete_orphaned(logger, manifest, tmpdir, set(), delete=True, dry_run=False) + assert not os.path.exists(os.path.join(tmpdir, "2024-01/IMG_001_aB3x.HEIC")) + assert manifest.count() == 0 + manifest.close() diff --git a/tests/test_dir_cache.py b/tests/test_dir_cache.py new file mode 100644 index 000000000..04c2584c8 --- /dev/null +++ b/tests/test_dir_cache.py @@ -0,0 +1,108 @@ +"""Unit tests for icloudpd.dir_cache — directory listing cache.""" + +import os +import shutil +import tempfile +from unittest import TestCase + +from icloudpd.dir_cache import DirCache + + +class TestDirCache(TestCase): + def setUp(self) -> None: + self._tmpdir = tempfile.mkdtemp() + + def tearDown(self) -> None: + shutil.rmtree(self._tmpdir) + + def _create_file(self, name: str, size: int) -> str: + path = os.path.join(self._tmpdir, name) + with open(path, "wb") as f: + f.write(b"\x00" * size) + return path + + def test_isfile_returns_true_for_existing_file(self) -> None: + path = self._create_file("test.jpg", 1024) + cache = DirCache() + self.assertTrue(cache.isfile(path)) + + def test_isfile_returns_false_for_missing_file(self) -> None: + cache = DirCache() + path = os.path.join(self._tmpdir, "nonexistent.jpg") + self.assertFalse(cache.isfile(path)) + + def test_isfile_returns_false_for_directory(self) -> None: + subdir = os.path.join(self._tmpdir, "subdir") + os.makedirs(subdir) + cache = DirCache() + self.assertFalse(cache.isfile(subdir)) + + def test_stat_size_returns_correct_size(self) -> None: + path = self._create_file("test.jpg", 42) + cache = DirCache() + self.assertEqual(cache.stat_size(path), 42) + + def test_stat_size_raises_for_missing_file(self) -> None: + cache = DirCache() + path = os.path.join(self._tmpdir, "nonexistent.jpg") + with self.assertRaises(FileNotFoundError): + cache.stat_size(path) + + def test_exists_returns_true_for_file(self) -> None: + path = self._create_file("test.jpg", 0) + cache = DirCache() + self.assertTrue(cache.exists(path)) + + def test_exists_returns_false_for_missing(self) -> None: + cache = DirCache() + path = os.path.join(self._tmpdir, "nonexistent.jpg") + self.assertFalse(cache.exists(path)) + + def test_notify_new_file_makes_file_visible(self) -> None: + cache = DirCache() + path = os.path.join(self._tmpdir, "new_download.jpg") + # File doesn't exist yet on disk, but we notify the cache + self.assertFalse(cache.isfile(path)) + cache.notify_new_file(path, 5000) + self.assertTrue(cache.isfile(path)) + self.assertEqual(cache.stat_size(path), 5000) + + def test_notify_updates_existing_entry(self) -> None: + path = self._create_file("test.jpg", 100) + cache = DirCache() + self.assertEqual(cache.stat_size(path), 100) + # EXIF injection changes size + cache.notify_new_file(path, 188) + self.assertEqual(cache.stat_size(path), 188) + + def test_getsize_is_alias_for_stat_size(self) -> None: + path = self._create_file("test.jpg", 777) + cache = DirCache() + self.assertEqual(cache.getsize(path), cache.stat_size(path)) + + def test_scan_caches_results(self) -> None: + """Second call to isfile should use cached result, not re-scan.""" + path = self._create_file("test.jpg", 50) + cache = DirCache() + self.assertTrue(cache.isfile(path)) + # Delete the file on disk -- cache should still report it + os.unlink(path) + self.assertTrue(cache.isfile(path)) + + def test_scan_nonexistent_directory_logs_warning(self) -> None: + cache = DirCache() + path = os.path.join("/nonexistent/dir/that/does/not/exist", "file.jpg") + # Should not raise, but return False + self.assertFalse(cache.isfile(path)) + + def test_multiple_files_in_same_directory(self) -> None: + self._create_file("a.jpg", 100) + self._create_file("b.jpg", 200) + self._create_file("c.mov", 300) + cache = DirCache() + self.assertTrue(cache.isfile(os.path.join(self._tmpdir, "a.jpg"))) + self.assertTrue(cache.isfile(os.path.join(self._tmpdir, "b.jpg"))) + self.assertTrue(cache.isfile(os.path.join(self._tmpdir, "c.mov"))) + self.assertEqual(cache.stat_size(os.path.join(self._tmpdir, "a.jpg")), 100) + self.assertEqual(cache.stat_size(os.path.join(self._tmpdir, "b.jpg")), 200) + self.assertEqual(cache.stat_size(os.path.join(self._tmpdir, "c.mov")), 300) diff --git a/tests/test_download_photos.py b/tests/test_download_photos.py index d06974798..e18fcb695 100644 --- a/tests/test_download_photos.py +++ b/tests/test_download_photos.py @@ -13,6 +13,7 @@ import pytz from piexif._exceptions import InvalidImageDataError from requests import Response +from tzlocal import get_localzone from icloudpd import constants from pyicloud_ipd.asset_version import AssetVersion @@ -1113,98 +1114,44 @@ def test_missing_item_type_value(self) -> None: assert result.exit_code == 0 def test_download_and_dedupe_existing_photos(self) -> None: + """With manifest, existing files are adopted by identity, not deduplicated by size.""" base_dir = os.path.join(self.fixtures_path, inspect.stack()[0][3]) + # Create ALL files that --recent 1 will encounter (only IMG_7409) files_to_create = [ - ("2018/07/31", "IMG_7409.JPG", 1), - ("2018/07/31", "IMG_7409.MOV", 1), - ("2018/07/30", "IMG_7408.JPG", 1151066), - ("2018/07/30", "IMG_7408.MOV", 1606512), - ] - - files_to_download = [ - ("2018/07/31", "IMG_7409-1884695.JPG"), - ("2018/07/31", "IMG_7409-3294075.MOV"), - ("2018/07/30", "IMG_7407.JPG"), - ("2018/07/30", "IMG_7407.MOV"), + ("2018/07/31", "IMG_7409.JPG", 1), # Wrong size — will be adopted + ("2018/07/31", "IMG_7409.MOV", 1), # Wrong size — will be adopted ] - # Download the first photo, but mock the video download - orig_download = PhotoAsset.download - - def mocked_download(self: PhotoAsset, session: Any, _url: str, start: int) -> Response: - if not hasattr(PhotoAsset, "already_downloaded"): - response = orig_download(self, session, _url, start) - setattr(PhotoAsset, "already_downloaded", True) # noqa: B010 - return response - return mock.MagicMock() - - with mock.patch.object(PhotoAsset, "download", new=mocked_download): - data_dir, result = run_icloudpd_test( - self.assertEqual, - self.root_path, - base_dir, - "listing_photos.yml", - files_to_create, - files_to_download, - [ - "--username", - "jdoe@gmail.com", - "--password", - "password1", - "--recent", - "5", - "--skip-videos", - # "--set-exif-datetime", - "--no-progress-bar", - "--threads-num", - "1", - ], - ) + # Nothing to download — manifest adopts existing files + files_to_download: list[tuple[str, str]] = [] - self.assertIn("Looking up all photos...", result.output) - self.assertIn( - f"Downloading 5 original photos to {data_dir} ...", - result.output, - ) - self.assertIn( - f"{os.path.join(data_dir, os.path.normpath('2018/07/31/IMG_7409-1884695.JPG'))} deduplicated", - result.output, - ) - self.assertIn( - f"{os.path.join(data_dir, os.path.normpath('2018/07/31/IMG_7409-3294075.MOV'))} deduplicated", - result.output, - ) - self.assertIn("Skipping IMG_7405.MOV, only downloading photos.", result.output) - self.assertIn("Skipping IMG_7404.MOV, only downloading photos.", result.output) - self.assertIn("All photos have been downloaded", result.output) - - # Check that mtime was updated to the photo creation date - photo_mtime = os.path.getmtime( - os.path.join(data_dir, os.path.normpath("2018/07/31/IMG_7409-1884695.JPG")) - ) - photo_modified_time = datetime.datetime.fromtimestamp( - photo_mtime, datetime.timezone.utc - ) - self.assertEqual( - "2018-07-31 07:22:24", photo_modified_time.strftime("%Y-%m-%d %H:%M:%S") - ) - self.assertTrue( - os.path.exists( - os.path.join(data_dir, os.path.normpath("2018/07/31/IMG_7409-3294075.MOV")) - ) - ) - photo_mtime = os.path.getmtime( - os.path.join(data_dir, os.path.normpath("2018/07/31/IMG_7409-3294075.MOV")) - ) - photo_modified_time = datetime.datetime.fromtimestamp( - photo_mtime, datetime.timezone.utc - ) - self.assertEqual( - "2018-07-31 07:22:24", photo_modified_time.strftime("%Y-%m-%d %H:%M:%S") - ) + data_dir, result = run_icloudpd_test( + self.assertEqual, + self.root_path, + base_dir, + "listing_photos.yml", + files_to_create, + files_to_download, + [ + "--username", + "jdoe@gmail.com", + "--password", + "password1", + "--recent", + "1", + "--no-progress-bar", + "--threads-num", + "1", + ], + ) - assert result.exit_code == 0 + self.assertIn("Looking up all photos", result.output) + # With manifest: files are adopted, not deduplicated + self.assertIn("adopted into manifest", result.output) + self.assertNotIn("deduplicated", result.output) + self.assertIn("have been downloaded", result.output) + assert result.exit_code == 0 def test_download_photos_and_set_exif_exceptions(self) -> None: base_dir = os.path.join(self.fixtures_path, inspect.stack()[0][3]) @@ -2365,12 +2312,16 @@ def test_download_and_skip_old(self) -> None: "Skipping IMG_7404.MOV, only downloading photos.", result.output, ) + _lz = get_localzone() + _img7407_created = datetime.datetime.fromtimestamp(1532951045108 / 1000.0, tz=pytz.utc).astimezone(_lz) + _img7408_created = datetime.datetime.fromtimestamp(1532951050176 / 1000.0, tz=pytz.utc).astimezone(_lz) + _threshold = datetime.datetime(2018, 7, 31).astimezone(_lz) self.assertIn( - "Skipping IMG_7407.JPG, as it was created 2018-07-30 11:44:05.108000+00:00, before 2018-07-31 00:00:00+00:00.", + f"Skipping IMG_7407.JPG, as it was created {_img7407_created}, before {_threshold}.", result.output, ) self.assertIn( - "Skipping IMG_7408.JPG, as it was created 2018-07-30 11:44:10.176000+00:00, before 2018-07-31 00:00:00+00:00.", + f"Skipping IMG_7408.JPG, as it was created {_img7408_created}, before {_threshold}.", result.output, ) self.assertIn("All photos have been downloaded", result.output) @@ -2445,8 +2396,11 @@ def test_download_and_skip_new(self) -> None: result.output, ) + _lz = get_localzone() + _img7409_created = datetime.datetime.fromtimestamp(1533021744816 / 1000.0, tz=pytz.utc).astimezone(_lz) + _threshold = datetime.datetime(2018, 7, 31).astimezone(_lz) self.assertIn( - "Skipping IMG_7409.JPG, as it was created 2018-07-31 07:22:24.816000+00:00, after 2018-07-31 00:00:00+00:00", + f"Skipping IMG_7409.JPG, as it was created {_img7409_created}, after {_threshold}.", result.output, ) self.assertIn("All photos have been downloaded", result.output) diff --git a/tests/test_download_photos_id.py b/tests/test_download_photos_id.py index 1a10fd86e..6cd322fea 100644 --- a/tests/test_download_photos_id.py +++ b/tests/test_download_photos_id.py @@ -9,8 +9,10 @@ import piexif import pytest +import pytz from piexif._exceptions import InvalidImageDataError from requests import Response +from tzlocal import get_localzone from icloudpd import constants from icloudpd.string_helpers import truncate_middle @@ -2262,12 +2264,16 @@ def test_download_and_skip_old_name_id7(self) -> None: "Skipping IMG_7404_QVI5TWx.MOV, only downloading photos.", result.output, ) + _lz = get_localzone() + _img7407_created = datetime.datetime.fromtimestamp(1532951045108 / 1000.0, tz=pytz.utc).astimezone(_lz) + _img7408_created = datetime.datetime.fromtimestamp(1532951050176 / 1000.0, tz=pytz.utc).astimezone(_lz) + _threshold = datetime.datetime(2018, 7, 31).astimezone(_lz) self.assertIn( - "Skipping IMG_7407_QVovd0F.JPG, as it was created 2018-07-30 11:44:05.108000+00:00, before 2018-07-31 00:00:00+00:00.", + f"Skipping IMG_7407_QVovd0F.JPG, as it was created {_img7407_created}, before {_threshold}.", result.output, ) self.assertIn( - "Skipping IMG_7408_QVI4T2l.JPG, as it was created 2018-07-30 11:44:10.176000+00:00, before 2018-07-31 00:00:00+00:00.", + f"Skipping IMG_7408_QVI4T2l.JPG, as it was created {_img7408_created}, before {_threshold}.", result.output, ) self.assertIn("All photos have been downloaded", result.output) @@ -2339,8 +2345,11 @@ def test_download_and_skip_new_name_id7(self) -> None: result.output, ) + _lz = get_localzone() + _img7409_created = datetime.datetime.fromtimestamp(1533021744816 / 1000.0, tz=pytz.utc).astimezone(_lz) + _threshold = datetime.datetime(2018, 7, 31).astimezone(_lz) self.assertIn( - "Skipping IMG_7409_QVk2Yyt.JPG, as it was created 2018-07-31 07:22:24.816000+00:00, after 2018-07-31 00:00:00+00:00.", + f"Skipping IMG_7409_QVk2Yyt.JPG, as it was created {_img7409_created}, after {_threshold}.", result.output, ) self.assertIn("All photos have been downloaded", result.output) diff --git a/tests/test_exif_datetime.py b/tests/test_exif_datetime.py new file mode 100644 index 000000000..104bf8f5e --- /dev/null +++ b/tests/test_exif_datetime.py @@ -0,0 +1,145 @@ +"""Unit tests for icloudpd.exif_datetime — get/set EXIF dates on real JPEGs.""" + +import logging +import os +import shutil +import tempfile +from unittest import TestCase + +import piexif + +from icloudpd.exif_datetime import get_photo_exif, set_photo_exif + +_test_logger = logging.getLogger("test_exif_datetime") + +# Minimal valid 1x1 JPEG that piexif can load/modify +_MINIMAL_JPEG = bytes( + [ + 0xFF, 0xD8, # SOI + 0xFF, 0xE0, 0x00, 0x10, # APP0 marker + length + 0x4A, 0x46, 0x49, 0x46, 0x00, # JFIF\0 + 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, # Version, density, no thumb + 0xFF, 0xDB, 0x00, 0x43, 0x00, # DQT marker + ] + + [0x01] * 64 # Quantisation table + + [ + 0xFF, 0xC0, 0x00, 0x0B, 0x08, # SOF0 + 0x00, 0x01, 0x00, 0x01, # 1x1 + 0x01, 0x01, 0x11, 0x00, # 1 component + 0xFF, 0xC4, 0x00, 0x1F, 0x00, # DHT + 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0A, 0x0B, + 0xFF, 0xDA, 0x00, 0x08, # SOS + 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, + 0x7B, 0x40, # Compressed data + 0xFF, 0xD9, # EOI + ] +) + + +def _create_jpeg(path: str, exif_date: str | None = None) -> None: + """Write a minimal JPEG, optionally with an EXIF date already set.""" + with open(path, "wb") as f: + f.write(_MINIMAL_JPEG) + if exif_date: + exif_dict = piexif.load(path) + exif_dict["Exif"][36867] = exif_date.encode() + exif_dict["Exif"][36868] = exif_date.encode() + exif_dict["0th"][306] = exif_date.encode() + exif_bytes = piexif.dump(exif_dict) + piexif.insert(exif_bytes, path) + + +class TestPiexifAssumptions(TestCase): + """Document and verify our assumptions about piexif behaviour.""" + + def test_piexif_load_always_returns_dicts_for_ifds(self) -> None: + """piexif.load() returns empty dicts for IFDs, never None. + + This validates our removal of the None guards that were dead code. + """ + with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f: + f.write(_MINIMAL_JPEG) + path = f.name + try: + exif_dict = piexif.load(path) + for ifd_name in ["0th", "Exif", "GPS", "1st", "Interop"]: + ifd = exif_dict.get(ifd_name) + self.assertIsInstance( + ifd, dict, f"piexif IFD '{ifd_name}' should be dict, got {type(ifd)}" + ) + finally: + os.unlink(path) + + +class TestGetPhotoExif(TestCase): + def setUp(self) -> None: + self._tmpdir = tempfile.mkdtemp() + + def tearDown(self) -> None: + shutil.rmtree(self._tmpdir) + + def test_returns_date_string_when_exif_present(self) -> None: + path = os.path.join(self._tmpdir, "with_exif.jpg") + _create_jpeg(path, "2025:03:15 10:30:00") + result = get_photo_exif(_test_logger, path) + self.assertEqual(result, "2025:03:15 10:30:00") + + def test_returns_none_when_no_exif_datetime(self) -> None: + path = os.path.join(self._tmpdir, "no_exif.jpg") + _create_jpeg(path) + result = get_photo_exif(_test_logger, path) + self.assertIsNone(result) + + def test_returns_none_for_corrupt_file(self) -> None: + path = os.path.join(self._tmpdir, "corrupt.jpg") + with open(path, "wb") as f: + f.write(b"not a jpeg") + result = get_photo_exif(_test_logger, path) + self.assertIsNone(result) + + +class TestSetPhotoExif(TestCase): + def setUp(self) -> None: + self._tmpdir = tempfile.mkdtemp() + + def tearDown(self) -> None: + shutil.rmtree(self._tmpdir) + + def test_sets_exif_date_on_jpeg_without_existing_exif(self) -> None: + path = os.path.join(self._tmpdir, "no_exif.jpg") + _create_jpeg(path) + set_photo_exif(_test_logger, path, "2025:06:01 12:00:00") + # Verify the date was written + result = get_photo_exif(_test_logger, path) + self.assertEqual(result, "2025:06:01 12:00:00") + + def test_overwrites_existing_exif_date(self) -> None: + path = os.path.join(self._tmpdir, "with_exif.jpg") + _create_jpeg(path, "2020:01:01 00:00:00") + set_photo_exif(_test_logger, path, "2025:12:25 18:00:00") + result = get_photo_exif(_test_logger, path) + self.assertEqual(result, "2025:12:25 18:00:00") + + def test_noop_on_corrupt_file(self) -> None: + path = os.path.join(self._tmpdir, "corrupt.jpg") + with open(path, "wb") as f: + f.write(b"not a jpeg") + with open(path, "rb") as f: + original_content = f.read() + set_photo_exif(_test_logger, path, "2025:01:01 00:00:00") + # File should be unchanged + with open(path, "rb") as f: + self.assertEqual(f.read(), original_content) + + def test_piexif_roundtrip_is_idempotent(self) -> None: + """After one set_photo_exif, subsequent round-trips don't change size.""" + path = os.path.join(self._tmpdir, "idempotent.jpg") + _create_jpeg(path) + set_photo_exif(_test_logger, path, "2025:01:01 00:00:00") + size_after_first = os.path.getsize(path) + set_photo_exif(_test_logger, path, "2025:01:01 00:00:00") + size_after_second = os.path.getsize(path) + self.assertEqual(size_after_first, size_after_second) diff --git a/tests/test_folder_structure.py b/tests/test_folder_structure.py index 874d76eb9..83a7e2700 100644 --- a/tests/test_folder_structure.py +++ b/tests/test_folder_structure.py @@ -1,3 +1,4 @@ +import datetime import inspect import os import sys @@ -5,6 +6,8 @@ from unittest import TestCase import pytest +import pytz +from tzlocal import get_localzone from tests.helpers import ( path_from_project_root, @@ -52,30 +55,37 @@ def test_default_folder_structure(self) -> None: filenames = result.output.splitlines() + _lz = get_localzone() + _d7409 = datetime.datetime.fromtimestamp(1533021744816 / 1000.0, tz=pytz.utc).astimezone(_lz).strftime("%Y/%m/%d") + _d7408 = datetime.datetime.fromtimestamp(1532951050176 / 1000.0, tz=pytz.utc).astimezone(_lz).strftime("%Y/%m/%d") + _d7407 = datetime.datetime.fromtimestamp(1532951045108 / 1000.0, tz=pytz.utc).astimezone(_lz).strftime("%Y/%m/%d") + _d7405 = datetime.datetime.fromtimestamp(1532950655469 / 1000.0, tz=pytz.utc).astimezone(_lz).strftime("%Y/%m/%d") + _d7404 = datetime.datetime.fromtimestamp(1532950654855 / 1000.0, tz=pytz.utc).astimezone(_lz).strftime("%Y/%m/%d") + self.assertEqual(len(filenames), 8) self.assertEqual( - os.path.join(data_dir, os.path.normpath("2018/07/31/IMG_7409.JPG")), filenames[0] + os.path.join(data_dir, os.path.normpath(f"{_d7409}/IMG_7409.JPG")), filenames[0] ) self.assertEqual( - os.path.join(data_dir, os.path.normpath("2018/07/31/IMG_7409.MOV")), filenames[1] + os.path.join(data_dir, os.path.normpath(f"{_d7409}/IMG_7409.MOV")), filenames[1] ) self.assertEqual( - os.path.join(data_dir, os.path.normpath("2018/07/30/IMG_7408.JPG")), filenames[2] + os.path.join(data_dir, os.path.normpath(f"{_d7408}/IMG_7408.JPG")), filenames[2] ) self.assertEqual( - os.path.join(data_dir, os.path.normpath("2018/07/30/IMG_7408.MOV")), filenames[3] + os.path.join(data_dir, os.path.normpath(f"{_d7408}/IMG_7408.MOV")), filenames[3] ) self.assertEqual( - os.path.join(data_dir, os.path.normpath("2018/07/30/IMG_7407.JPG")), filenames[4] + os.path.join(data_dir, os.path.normpath(f"{_d7407}/IMG_7407.JPG")), filenames[4] ) self.assertEqual( - os.path.join(data_dir, os.path.normpath("2018/07/30/IMG_7407.MOV")), filenames[5] + os.path.join(data_dir, os.path.normpath(f"{_d7407}/IMG_7407.MOV")), filenames[5] ) self.assertEqual( - os.path.join(data_dir, os.path.normpath("2018/07/30/IMG_7405.MOV")), filenames[6] + os.path.join(data_dir, os.path.normpath(f"{_d7405}/IMG_7405.MOV")), filenames[6] ) self.assertEqual( - os.path.join(data_dir, os.path.normpath("2018/07/30/IMG_7404.MOV")), filenames[7] + os.path.join(data_dir, os.path.normpath(f"{_d7404}/IMG_7404.MOV")), filenames[7] ) def test_folder_structure_none(self) -> None: diff --git a/tests/test_issue_1220_only_print_filenames_dedup_bug.py b/tests/test_issue_1220_only_print_filenames_dedup_bug.py index f89ad92ac..7a6c48ba4 100644 --- a/tests/test_issue_1220_only_print_filenames_dedup_bug.py +++ b/tests/test_issue_1220_only_print_filenames_dedup_bug.py @@ -85,6 +85,8 @@ def test_only_print_filenames_should_not_download_during_deduplication(self) -> actual_files = [] for root, _dirs, files in os.walk(data_dir): for file in files: + if file.startswith(".icloudpd"): + continue rel_path = os.path.relpath(os.path.join(root, file), data_dir) actual_files.append(rel_path) diff --git a/tests/test_listing_recent_photos.py b/tests/test_listing_recent_photos.py index ec59ae7f8..261b86198 100644 --- a/tests/test_listing_recent_photos.py +++ b/tests/test_listing_recent_photos.py @@ -1,9 +1,12 @@ +import datetime import inspect import json import os from unittest import TestCase, mock import pytest +import pytz +from tzlocal import get_localzone from tests.helpers import ( path_from_project_root, @@ -43,30 +46,37 @@ def test_listing_recent_photos(self) -> None: filenames = result.output.splitlines() + _lz = get_localzone() + _d7409 = datetime.datetime.fromtimestamp(1533021744816 / 1000.0, tz=pytz.utc).astimezone(_lz).strftime("%Y/%m/%d") + _d7408 = datetime.datetime.fromtimestamp(1532951050176 / 1000.0, tz=pytz.utc).astimezone(_lz).strftime("%Y/%m/%d") + _d7407 = datetime.datetime.fromtimestamp(1532951045108 / 1000.0, tz=pytz.utc).astimezone(_lz).strftime("%Y/%m/%d") + _d7405 = datetime.datetime.fromtimestamp(1532950655469 / 1000.0, tz=pytz.utc).astimezone(_lz).strftime("%Y/%m/%d") + _d7404 = datetime.datetime.fromtimestamp(1532950654855 / 1000.0, tz=pytz.utc).astimezone(_lz).strftime("%Y/%m/%d") + self.assertEqual(len(filenames), 8) self.assertIn( - os.path.join(data_dir, os.path.normpath("2018/07/31/IMG_7409.JPG")), filenames[0] + os.path.join(data_dir, os.path.normpath(f"{_d7409}/IMG_7409.JPG")), filenames[0] ) self.assertEqual( - os.path.join(data_dir, os.path.normpath("2018/07/31/IMG_7409.MOV")), filenames[1] + os.path.join(data_dir, os.path.normpath(f"{_d7409}/IMG_7409.MOV")), filenames[1] ) self.assertEqual( - os.path.join(data_dir, os.path.normpath("2018/07/30/IMG_7408.JPG")), filenames[2] + os.path.join(data_dir, os.path.normpath(f"{_d7408}/IMG_7408.JPG")), filenames[2] ) self.assertEqual( - os.path.join(data_dir, os.path.normpath("2018/07/30/IMG_7408.MOV")), filenames[3] + os.path.join(data_dir, os.path.normpath(f"{_d7408}/IMG_7408.MOV")), filenames[3] ) self.assertEqual( - os.path.join(data_dir, os.path.normpath("2018/07/30/IMG_7407.JPG")), filenames[4] + os.path.join(data_dir, os.path.normpath(f"{_d7407}/IMG_7407.JPG")), filenames[4] ) self.assertEqual( - os.path.join(data_dir, os.path.normpath("2018/07/30/IMG_7407.MOV")), filenames[5] + os.path.join(data_dir, os.path.normpath(f"{_d7407}/IMG_7407.MOV")), filenames[5] ) self.assertEqual( - os.path.join(data_dir, os.path.normpath("2018/07/30/IMG_7405.MOV")), filenames[6] + os.path.join(data_dir, os.path.normpath(f"{_d7405}/IMG_7405.MOV")), filenames[6] ) self.assertEqual( - os.path.join(data_dir, os.path.normpath("2018/07/30/IMG_7404.MOV")), filenames[7] + os.path.join(data_dir, os.path.normpath(f"{_d7404}/IMG_7404.MOV")), filenames[7] ) def test_listing_photos_does_not_create_folders(self) -> None: diff --git a/tests/test_manifest.py b/tests/test_manifest.py new file mode 100644 index 000000000..6cd12b0ee --- /dev/null +++ b/tests/test_manifest.py @@ -0,0 +1,695 @@ +"""Unit tests for icloudpd.manifest — SQLite asset manifest.""" + +import json +import os +import shutil +import sqlite3 +import tempfile +from unittest import TestCase + +from icloudpd.manifest import SCHEMA_VERSION, ManifestDB + + +class TestManifestDB(TestCase): + def setUp(self) -> None: + self._tmpdir = tempfile.mkdtemp() + self._db = ManifestDB(self._tmpdir) + self._db.open() + + def tearDown(self) -> None: + self._db.close() + shutil.rmtree(self._tmpdir) + + def test_db_created_at_expected_path(self) -> None: + self.assertTrue(os.path.isfile(os.path.join(self._tmpdir, ".icloudpd.db"))) + + def test_schema_version_set(self) -> None: + conn = sqlite3.connect(os.path.join(self._tmpdir, ".icloudpd.db")) + version = conn.execute("PRAGMA user_version").fetchone()[0] + conn.close() + self.assertEqual(version, SCHEMA_VERSION) + + def test_empty_db_has_zero_count(self) -> None: + self.assertEqual(self._db.count(), 0) + + def test_upsert_and_lookup_all_fields(self) -> None: + self._db.upsert( + asset_id="ABC123", + zone_id="PrimarySync", + local_path="2024-01/IMG_0001.JPG", + version_size=1884695, + version_checksum="chk123", + change_tag="49lb", + item_type="public.jpeg", + filename="IMG_0001.JPG", + asset_date="2024-01-15T10:30:00+11:00", + added_date="2024-01-15T12:00:00+11:00", + is_favorite=1, + is_hidden=0, + is_deleted=0, + original_width=4032, + original_height=3024, + duration=None, + orientation=6, + title="Beach sunset", + description="A lovely sunset at the beach", + keywords=json.dumps(["sunset", "beach", "travel"]), + gps_latitude=51.500729, + gps_longitude=-0.124625, + gps_altitude=12.5, + ) + row = self._db.lookup("ABC123", "PrimarySync", "resOriginal") + self.assertIsNotNone(row) + assert row is not None + self.assertEqual(row.asset_id, "ABC123") + self.assertEqual(row.version_size, 1884695) + self.assertEqual(row.item_type, "public.jpeg") + self.assertEqual(row.filename, "IMG_0001.JPG") + self.assertEqual(row.is_favorite, 1) + self.assertEqual(row.original_width, 4032) + self.assertEqual(row.original_height, 3024) + self.assertEqual(row.orientation, 6) + self.assertEqual(row.title, "Beach sunset") + self.assertEqual(row.description, "A lovely sunset at the beach") + assert row.keywords is not None + self.assertEqual(json.loads(row.keywords), ["sunset", "beach", "travel"]) + assert row.gps_latitude is not None + assert row.gps_longitude is not None + assert row.gps_altitude is not None + self.assertAlmostEqual(row.gps_latitude, 51.500729, places=5) + self.assertAlmostEqual(row.gps_longitude, -0.124625, places=4) + self.assertAlmostEqual(row.gps_altitude, 12.5, places=1) + self.assertIsNotNone(row.downloaded_at) + self.assertIsNotNone(row.last_updated_at) + + def test_lookup_missing_returns_none(self) -> None: + self.assertIsNone(self._db.lookup("MISSING", "z", "resOriginal")) + + def test_upsert_updates_existing_row(self) -> None: + self._db.upsert("ABC", "z", "a.jpg", 100, title="old") + self._db.upsert("ABC", "z", "a.jpg", 200, title="new") + self.assertEqual(self._db.count(), 1) + row = self._db.lookup("ABC", "z", "resOriginal") + assert row is not None + self.assertEqual(row.version_size, 200) + self.assertEqual(row.title, "new") + + def test_last_updated_at_changes_on_update(self) -> None: + self._db.upsert("ABC", "z", "a.jpg", 100) + row1 = self._db.lookup("ABC", "z", "resOriginal") + assert row1 is not None + import time + time.sleep(0.01) + self._db.upsert("ABC", "z", "a.jpg", 100, title="updated") + row2 = self._db.lookup("ABC", "z", "resOriginal") + assert row2 is not None + self.assertEqual(row1.downloaded_at, row1.last_updated_at) + # last_updated_at should change, downloaded_at should not + self.assertNotEqual(row1.last_updated_at, row2.last_updated_at) + + def test_same_asset_different_paths(self) -> None: + """Live photo: one asset produces JPEG + MOV with different resources.""" + self._db.upsert("LIVE1", "z", "2024-01/IMG_0001.JPG", 1000, asset_resource="resOriginal") + self._db.upsert("LIVE1", "z", "2024-01/IMG_0001.MOV", 5000, asset_resource="resOriginalVidCompl") + self.assertEqual(self._db.count(), 2) + + def test_same_asset_different_zones(self) -> None: + self._db.upsert("DUP1", "PrimarySync", "a.jpg", 100) + self._db.upsert("DUP1", "SharedSync-XYZ", "a.jpg", 100) + self.assertEqual(self._db.count(), 2) + + def test_lookup_by_path(self) -> None: + self._db.upsert("ABC", "z", "2024-01/IMG.JPG", 1000) + row = self._db.lookup_by_path("2024-01/IMG.JPG") + assert row is not None + self.assertEqual(row.asset_id, "ABC") + + def test_remove(self) -> None: + self._db.upsert("ABC", "z", "a.jpg", 100) + self._db.remove("ABC", "z", "resOriginal") + self.assertEqual(self._db.count(), 0) + + def test_remove_by_path(self) -> None: + self._db.upsert("ABC", "z", "a.jpg", 100) + self._db.upsert("DEF", "z", "a.jpg", 200) + self._db.remove_by_path("a.jpg") + self.assertEqual(self._db.count(), 0) + + def test_context_manager(self) -> None: + tmpdir2 = tempfile.mkdtemp() + try: + with ManifestDB(tmpdir2) as db: + db.upsert("X", "z", "x.jpg", 1) + self.assertEqual(db.count(), 1) + self.assertTrue(os.path.isfile(os.path.join(tmpdir2, ".icloudpd.db"))) + finally: + shutil.rmtree(tmpdir2) + + def test_persistence_across_opens(self) -> None: + self._db.upsert("PERSIST", "z", "p.jpg", 42, title="hello") + self._db.close() + self._db.open() + row = self._db.lookup("PERSIST", "z", "resOriginal") + assert row is not None + self.assertEqual(row.version_size, 42) + self.assertEqual(row.title, "hello") + + def test_nullable_metadata_fields(self) -> None: + self._db.upsert("ABC", "z", "a.jpg", 100) + row = self._db.lookup("ABC", "z", "resOriginal") + assert row is not None + self.assertIsNone(row.version_checksum) + self.assertIsNone(row.title) + self.assertIsNone(row.gps_latitude) + self.assertIsNone(row.duration) + self.assertEqual(row.is_favorite, 0) + + def test_keywords_stored_as_json(self) -> None: + kw = ["sunset", "beach"] + self._db.upsert("ABC", "z", "a.jpg", 100, keywords=json.dumps(kw)) + row = self._db.lookup("ABC", "z", "resOriginal") + assert row is not None + assert row.keywords is not None + self.assertEqual(json.loads(row.keywords), kw) + + +class TestManifestMigration(TestCase): + def test_migrate_from_v0_schema(self) -> None: + """A pre-versioned DB (7 columns) should be migrated to the full schema.""" + tmpdir = tempfile.mkdtemp() + try: + db_path = os.path.join(tmpdir, ".icloudpd.db") + # Create a v0 DB with only the original 7 columns + conn = sqlite3.connect(db_path) + conn.executescript("""\ + CREATE TABLE manifest ( + asset_id TEXT NOT NULL, + zone_id TEXT NOT NULL DEFAULT '', + local_path TEXT NOT NULL, + version_size INTEGER NOT NULL, + version_checksum TEXT, + change_tag TEXT, + downloaded_at TEXT NOT NULL, + PRIMARY KEY (asset_id, zone_id, local_path) + ); + """) + conn.execute( + "INSERT INTO manifest VALUES (?, ?, ?, ?, ?, ?, ?)", + ("OLD1", "z", "old.jpg", 999, None, "t1", "2026-01-01T00:00:00+00:00"), + ) + conn.commit() + conn.close() + + # Open with ManifestDB — should migrate + db = ManifestDB(tmpdir) + db.open() + + # Verify version was set + version = db._db.execute("PRAGMA user_version").fetchone()[0] + self.assertEqual(version, SCHEMA_VERSION) + + # Verify index was created during migration + indexes = db._db.execute( + "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='manifest'" + ).fetchall() + index_names = [row[0] for row in indexes] + self.assertIn("idx_manifest_path", index_names) + + # Verify old row survived with new columns as defaults + row = db.lookup("OLD1", "z", "resOriginal") + assert row is not None + self.assertEqual(row.asset_id, "OLD1") + self.assertEqual(row.version_size, 999) + self.assertEqual(row.last_updated_at, "") # default from migration + self.assertIsNone(row.title) + self.assertEqual(row.is_favorite, 0) + + # Verify new columns are writable + db.upsert("NEW1", "z", "new.jpg", 500, title="test", is_favorite=1) + row2 = db.lookup("NEW1", "z", "resOriginal") + assert row2 is not None + self.assertEqual(row2.title, "test") + self.assertEqual(row2.is_favorite, 1) + + db.close() + finally: + shutil.rmtree(tmpdir) + + +_V1_SCHEMA = """\ +CREATE TABLE manifest ( + asset_id TEXT NOT NULL, + zone_id TEXT NOT NULL DEFAULT '', + local_path TEXT NOT NULL, + version_size INTEGER NOT NULL, + version_checksum TEXT, + change_tag TEXT, + downloaded_at TEXT NOT NULL, + last_updated_at TEXT NOT NULL, + item_type TEXT, + filename TEXT, + asset_date TEXT, + added_date TEXT, + is_favorite INTEGER DEFAULT 0, + is_hidden INTEGER DEFAULT 0, + is_deleted INTEGER DEFAULT 0, + original_width INTEGER, + original_height INTEGER, + duration INTEGER, + orientation INTEGER, + title TEXT, + description TEXT, + keywords TEXT, + gps_latitude REAL, + gps_longitude REAL, + gps_altitude REAL, + PRIMARY KEY (asset_id, zone_id, local_path) +); +CREATE INDEX IF NOT EXISTS idx_manifest_path ON manifest(local_path); +""" + +_V2_NEW_COLUMNS = [ + "gps_speed", + "gps_timestamp", + "timezone_offset", + "asset_subtype", + "hdr_type", + "burst_flags", + "burst_flags_ext", + "burst_id", + "original_orientation", + "raw_fields", +] + + +class TestManifestV2Migration(TestCase): + """Tests for v1 -> v2 schema migration (10 new columns).""" + + def _create_v1_db(self, tmpdir: str) -> str: + db_path = os.path.join(tmpdir, ".icloudpd.db") + conn = sqlite3.connect(db_path) + conn.executescript(_V1_SCHEMA) + conn.execute("PRAGMA user_version=1") + conn.commit() + conn.close() + return db_path + + def test_v1_to_v2_migration(self) -> None: + """Opening a v1 DB should migrate it to v2, adding all 10 new columns.""" + tmpdir = tempfile.mkdtemp() + try: + db_path = self._create_v1_db(tmpdir) + + # Seed a row before migration + conn = sqlite3.connect(db_path) + conn.execute( + "INSERT INTO manifest " + "(asset_id, zone_id, local_path, version_size, downloaded_at, " + "last_updated_at, title) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + ("V1ROW", "z", "photo.jpg", 512, "2025-01-01T00:00:00+00:00", + "2025-01-01T00:00:00+00:00", "old title"), + ) + conn.commit() + conn.close() + + db = ManifestDB(tmpdir) + db.open() + + # PRAGMA user_version should now be 2 + version = db._db.execute("PRAGMA user_version").fetchone()[0] + self.assertEqual(version, SCHEMA_VERSION) + + # All 10 new columns must exist + cols = { + row[1] + for row in db._db.execute("PRAGMA table_info(manifest)").fetchall() + } + for col in _V2_NEW_COLUMNS: + self.assertIn(col, cols, f"Column '{col}' missing after migration") + + # Existing data preserved + row = db.lookup("V1ROW", "z", "resOriginal") + assert row is not None + self.assertEqual(row.asset_id, "V1ROW") + self.assertEqual(row.version_size, 512) + self.assertEqual(row.title, "old title") + + db.close() + finally: + shutil.rmtree(tmpdir) + + def test_fresh_db_has_v2_columns(self) -> None: + """A brand-new ManifestDB should contain all 35 columns.""" + tmpdir = tempfile.mkdtemp() + try: + db = ManifestDB(tmpdir) + db.open() + + cols = [ + row[1] + for row in db._db.execute("PRAGMA table_info(manifest)").fetchall() + ] + self.assertEqual(len(cols), 37) + for col in _V2_NEW_COLUMNS: + self.assertIn(col, cols) + + db.close() + finally: + shutil.rmtree(tmpdir) + + def test_upsert_with_new_columns(self) -> None: + """All v2 fields should round-trip through upsert/lookup.""" + tmpdir = tempfile.mkdtemp() + try: + db = ManifestDB(tmpdir) + db.open() + + raw = json.dumps({"customKey": 42}) + db.upsert( + asset_id="V2FULL", + zone_id="z", + local_path="v2.jpg", + version_size=1024, + gps_speed=12.5, + gps_timestamp="2025-06-01T08:30:00Z", + timezone_offset=39600, + asset_subtype=2, + hdr_type=1, + burst_flags=7, + burst_flags_ext=15, + burst_id="B-001", + original_orientation=3, + raw_fields=raw, + ) + + row = db.lookup("V2FULL", "z", "resOriginal") + assert row is not None + assert row.gps_speed is not None + self.assertAlmostEqual(row.gps_speed, 12.5, places=1) + self.assertEqual(row.gps_timestamp, "2025-06-01T08:30:00Z") + self.assertEqual(row.timezone_offset, 39600) + self.assertEqual(row.asset_subtype, 2) + self.assertEqual(row.hdr_type, 1) + self.assertEqual(row.burst_flags, 7) + self.assertEqual(row.burst_flags_ext, 15) + self.assertEqual(row.burst_id, "B-001") + self.assertEqual(row.original_orientation, 3) + assert row.raw_fields is not None + self.assertEqual(json.loads(row.raw_fields), {"customKey": 42}) + + db.close() + finally: + shutil.rmtree(tmpdir) + + def test_new_columns_default_to_none(self) -> None: + """Inserting without v2 fields should leave them as None.""" + tmpdir = tempfile.mkdtemp() + try: + db = ManifestDB(tmpdir) + db.open() + + db.upsert("MINIMAL", "z", "min.jpg", 100) + + row = db.lookup("MINIMAL", "z", "resOriginal") + assert row is not None + self.assertIsNone(row.gps_speed) + self.assertIsNone(row.gps_timestamp) + self.assertIsNone(row.timezone_offset) + self.assertIsNone(row.asset_subtype) + self.assertIsNone(row.hdr_type) + self.assertIsNone(row.burst_flags) + self.assertIsNone(row.burst_flags_ext) + self.assertIsNone(row.burst_id) + self.assertIsNone(row.original_orientation) + self.assertIsNone(row.raw_fields) + + db.close() + finally: + shutil.rmtree(tmpdir) + + def test_v1_migration_preserves_existing_gps(self) -> None: + """GPS data from v1 rows must survive migration; new GPS columns are None.""" + tmpdir = tempfile.mkdtemp() + try: + db_path = self._create_v1_db(tmpdir) + + conn = sqlite3.connect(db_path) + conn.execute( + "INSERT INTO manifest " + "(asset_id, zone_id, local_path, version_size, downloaded_at, " + "last_updated_at, gps_latitude, gps_longitude, gps_altitude) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ("GPS1", "z", "geo.jpg", 256, "2025-01-01T00:00:00+00:00", + "2025-01-01T00:00:00+00:00", -33.7, 151.2, 10.0), + ) + conn.commit() + conn.close() + + db = ManifestDB(tmpdir) + db.open() + + row = db.lookup("GPS1", "z", "resOriginal") + assert row is not None + assert row.gps_latitude is not None + assert row.gps_longitude is not None + assert row.gps_altitude is not None + self.assertAlmostEqual(row.gps_latitude, -33.7, places=1) + self.assertAlmostEqual(row.gps_longitude, 151.2, places=1) + self.assertAlmostEqual(row.gps_altitude, 10.0, places=1) + self.assertIsNone(row.gps_speed) + self.assertIsNone(row.gps_timestamp) + + db.close() + finally: + shutil.rmtree(tmpdir) + + +class TestManifestDedup(TestCase): + """Tests for multi-asset dedup support in manifest.""" + + def setUp(self) -> None: + self._tmpdir = tempfile.mkdtemp() + self._db = ManifestDB(self._tmpdir) + self._db.open() + + def tearDown(self) -> None: + self._db.close() + shutil.rmtree(self._tmpdir) + + def test_lookup_by_path_returns_earliest_downloaded(self) -> None: + # Insert two assets with same path + self._db.upsert(asset_id="asset_B", zone_id="zone", local_path="photo.jpg", version_size=100) + self._db.upsert(asset_id="asset_A", zone_id="zone", local_path="photo.jpg", version_size=200) + result = self._db.lookup_by_path("photo.jpg") + self.assertIsNotNone(result) + assert result is not None + self.assertEqual(result.asset_id, "asset_B") # First inserted = earliest downloaded + + def test_count_by_path_multiple_assets(self) -> None: + self._db.upsert(asset_id="a1", zone_id="z", local_path="p.jpg", version_size=1) + self._db.upsert(asset_id="a2", zone_id="z", local_path="p.jpg", version_size=2) + self.assertEqual(self._db.count_by_path("p.jpg"), 2) + + def test_count_by_path_no_match(self) -> None: + self.assertEqual(self._db.count_by_path("missing.jpg"), 0) + + def test_update_path_basic(self) -> None: + self._db.upsert(asset_id="a1", zone_id="z", local_path="old.jpg", version_size=100) + self._db.update_path("a1", "z", "resOriginal", "new.jpg") + result = self._db.lookup("a1", "z", "resOriginal") + self.assertIsNotNone(result) + assert result is not None + self.assertEqual(result.local_path, "new.jpg") + self.assertEqual(result.version_size, 100) + + def test_update_path_nonexistent(self) -> None: + self._db.update_path("missing", "z", "resOriginal", "new.jpg") + # No error, no effect + self.assertIsNone(self._db.lookup_by_path("new.jpg")) + + def test_lookup_by_path_detects_collision(self) -> None: + """lookup_by_path returns the earliest owner, enabling collision detection.""" + self._db.upsert(asset_id="a1", zone_id="z", local_path="shared.jpg", version_size=1) + self._db.upsert(asset_id="a2", zone_id="z", local_path="shared.jpg", version_size=2) + owner = self._db.lookup_by_path("shared.jpg") + assert owner is not None + self.assertEqual(owner.asset_id, "a1") # earliest download wins + + +class TestManifestAssetResource(TestCase): + """Tests for the asset_resource-based PK (replaces local_path in PK).""" + + def setUp(self) -> None: + self._tmpdir = tempfile.mkdtemp() + self._db = ManifestDB(self._tmpdir) + self._db.open() + + def tearDown(self) -> None: + self._db.close() + shutil.rmtree(self._tmpdir) + + def test_upsert_with_asset_resource(self) -> None: + """asset_resource is stored and retrievable via lookup.""" + self._db.upsert( + asset_id="A1", zone_id="z", asset_resource="resOriginal", + local_path="2024-01/IMG.JPG", version_size=1000, + ) + row = self._db.lookup("A1", "z", "resOriginal") + assert row is not None + self.assertEqual(row.asset_id, "A1") + self.assertEqual(row.asset_resource, "resOriginal") + self.assertEqual(row.local_path, "2024-01/IMG.JPG") + + def test_same_asset_different_resources(self) -> None: + """Live photo: one asset produces photo + video with different resources.""" + self._db.upsert( + asset_id="LIVE1", zone_id="z", asset_resource="resOriginal", + local_path="2024-01/IMG_0001.HEIC", version_size=5000, + ) + self._db.upsert( + asset_id="LIVE1", zone_id="z", asset_resource="resOriginalVidCompl", + local_path="2024-01/IMG_0001_HEVC.MOV", version_size=7000, + ) + self.assertEqual(self._db.count(), 2) + photo = self._db.lookup("LIVE1", "z", "resOriginal") + video = self._db.lookup("LIVE1", "z", "resOriginalVidCompl") + assert photo is not None + assert video is not None + self.assertEqual(photo.local_path, "2024-01/IMG_0001.HEIC") + self.assertEqual(video.local_path, "2024-01/IMG_0001_HEVC.MOV") + + def test_path_change_updates_row_not_duplicates(self) -> None: + """Upserting same (asset_id, zone_id, asset_resource) with different local_path + updates the row instead of creating a duplicate.""" + self._db.upsert( + asset_id="A1", zone_id="z", asset_resource="resOriginal", + local_path="2024-01/old-name.JPG", version_size=1000, + ) + self._db.upsert( + asset_id="A1", zone_id="z", asset_resource="resOriginal", + local_path="2024-01/new-name.JPG", version_size=1000, + ) + self.assertEqual(self._db.count(), 1) + row = self._db.lookup("A1", "z", "resOriginal") + assert row is not None + self.assertEqual(row.local_path, "2024-01/new-name.JPG") + + def test_path_change_preserves_metadata(self) -> None: + """All metadata is preserved when local_path changes via upsert.""" + self._db.upsert( + asset_id="A1", zone_id="z", asset_resource="resOriginal", + local_path="old.jpg", version_size=1000, + title="My Photo", gps_latitude=-33.8688, orientation=6, + ) + self._db.upsert( + asset_id="A1", zone_id="z", asset_resource="resOriginal", + local_path="new.jpg", version_size=1000, + title="My Photo", gps_latitude=-33.8688, orientation=6, + ) + row = self._db.lookup("A1", "z", "resOriginal") + assert row is not None + self.assertEqual(row.local_path, "new.jpg") + self.assertEqual(row.title, "My Photo") + assert row.gps_latitude is not None + self.assertAlmostEqual(row.gps_latitude, -33.8688, places=4) + self.assertEqual(row.orientation, 6) + + def test_all_resource_slot_types(self) -> None: + """All 8 Apple resource slot types can be stored and retrieved.""" + resources = [ + "resOriginal", "resOriginalAlt", "resJPEGFull", + "resJPEGMed", "resJPEGThumb", + "resOriginalVidCompl", "resVidMed", "resVidSmall", + ] + for i, res in enumerate(resources): + self._db.upsert( + asset_id="MULTI", zone_id="z", asset_resource=res, + local_path=f"2024-01/file_{i}.dat", version_size=i * 100, + ) + self.assertEqual(self._db.count(), 8) + for i, res in enumerate(resources): + row = self._db.lookup("MULTI", "z", res) + assert row is not None, f"Missing row for {res}" + self.assertEqual(row.asset_resource, res) + self.assertEqual(row.version_size, i * 100) + + def test_lookup_uses_asset_resource_not_path(self) -> None: + """lookup() uses asset_resource as identity, not local_path.""" + self._db.upsert( + asset_id="A1", zone_id="z", asset_resource="resOriginal", + local_path="photo.jpg", version_size=1000, + ) + # Looking up with wrong resource returns None even though asset_id matches + self.assertIsNone(self._db.lookup("A1", "z", "resOriginalVidCompl")) + # Looking up with correct resource works + self.assertIsNotNone(self._db.lookup("A1", "z", "resOriginal")) + + def test_remove_uses_asset_resource(self) -> None: + """remove() identifies rows by (asset_id, zone_id, asset_resource).""" + self._db.upsert( + asset_id="A1", zone_id="z", asset_resource="resOriginal", + local_path="photo.jpg", version_size=1000, + ) + self._db.upsert( + asset_id="A1", zone_id="z", asset_resource="resOriginalVidCompl", + local_path="video.mov", version_size=5000, + ) + self._db.remove("A1", "z", "resOriginal") + self.assertEqual(self._db.count(), 1) + self.assertIsNone(self._db.lookup("A1", "z", "resOriginal")) + self.assertIsNotNone(self._db.lookup("A1", "z", "resOriginalVidCompl")) + +class TestManifestJournalMode(TestCase): + """Tests for DELETE journal mode (replacing WAL).""" + + def setUp(self) -> None: + self._tmpdir = tempfile.mkdtemp() + self._db = ManifestDB(self._tmpdir) + self._db.open() + + def tearDown(self) -> None: + self._db.close() + shutil.rmtree(self._tmpdir) + + def test_journal_mode_is_delete(self) -> None: + mode = self._db._db.execute("PRAGMA journal_mode").fetchone()[0] + self.assertEqual(mode, "delete") + + def test_synchronous_is_full(self) -> None: + sync = self._db._db.execute("PRAGMA synchronous").fetchone()[0] + self.assertEqual(sync, 2) # 2 = FULL + + def test_no_wal_shm_files_created(self) -> None: + db_path = os.path.join(self._tmpdir, ".icloudpd.db") + self._db.upsert( + asset_id="A1", zone_id="z", asset_resource="resOriginal", + local_path="test.jpg", version_size=100, + ) + self._db.flush() + self.assertFalse(os.path.exists(db_path + "-wal")) + self.assertFalse(os.path.exists(db_path + "-shm")) + + def test_wal_db_converted_to_delete_on_open(self) -> None: + """Opening an existing WAL-mode DB converts it to DELETE.""" + self._db.close() + conn = sqlite3.connect(os.path.join(self._tmpdir, ".icloudpd.db")) + conn.execute("PRAGMA journal_mode=WAL") + conn.close() + self._db.open() + mode = self._db._db.execute("PRAGMA journal_mode").fetchone()[0] + self.assertEqual(mode, "delete") + + def test_data_survives_wal_to_delete_conversion(self) -> None: + self._db.upsert( + asset_id="X", zone_id="z", asset_resource="resOriginal", + local_path="x.jpg", version_size=100, title="survive", + ) + self._db.close() + conn = sqlite3.connect(os.path.join(self._tmpdir, ".icloudpd.db")) + conn.execute("PRAGMA journal_mode=WAL") + conn.close() + self._db.open() + row = self._db.lookup("X", "z", "resOriginal") + assert row is not None + self.assertEqual(row.title, "survive") diff --git a/tests/test_metadata_writer.py b/tests/test_metadata_writer.py new file mode 100644 index 000000000..6d1dc8694 --- /dev/null +++ b/tests/test_metadata_writer.py @@ -0,0 +1,1674 @@ +"""Unit tests for icloudpd.metadata_writer — exiftool-based metadata writing. + +Validates that exiftool can write and read back iCloud metadata fields +across all supported formats (HEIC, JPEG, PNG for EXIF; MOV, MP4 for XMP) +without re-encoding the media or losing existing metadata. + +Requires exiftool to be installed on the system. +""" + +import json +import logging +import os +import shutil +import subprocess +import tempfile +from typing import Any +from unittest import TestCase + +import piexif + +from icloudpd.cli import _parse_write_metadata +from icloudpd.metadata_writer import ( + MetadataUpdate, + _gps_close, + _needs_update, + build_exiftool_args, + check_exiftool, + extract_metadata_update, + write_metadata, +) + +_test_logger = logging.getLogger("test_metadata_writer") + +_DATA_DIR = os.path.join(os.path.dirname(__file__), "data") + +# Minimal valid 1x1 JPEG (same as test_exif_datetime.py) +_MINIMAL_JPEG = bytes( + [ + 0xFF, 0xD8, # SOI + 0xFF, 0xE0, 0x00, 0x10, # APP0 marker + length + 0x4A, 0x46, 0x49, 0x46, 0x00, # JFIF\0 + 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, + 0xFF, 0xDB, 0x00, 0x43, 0x00, # DQT marker + ] + + [0x01] * 64 + + [ + 0xFF, 0xC0, 0x00, 0x0B, 0x08, # SOF0 + 0x00, 0x01, 0x00, 0x01, # 1x1 + 0x01, 0x01, 0x11, 0x00, + 0xFF, 0xC4, 0x00, 0x1F, 0x00, # DHT + 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0A, 0x0B, + 0xFF, 0xDA, 0x00, 0x08, # SOS + 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, + 0x7B, 0x40, + 0xFF, 0xD9, # EOI + ] +) + +# Minimal valid 1x1 PNG with correct CRC (exiftool rejects bad CRC) +_MINIMAL_PNG = ( + b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02" + b"\x00\x00\x00\x90wS\xde" + b"\x00\x00\x00\x0cIDATx\x9cc\xf8\xcf\xc0\x00\x00\x03\x01\x01" + b"\x00\xc9\xfe\x92\xef" + b"\x00\x00\x00\x00IEND\xaeB`\x82" +) + + +def _exiftool_available() -> bool: + """Check if exiftool is installed.""" + try: + result = subprocess.run( + ["exiftool", "-ver"], capture_output=True, text=True, timeout=5 + ) + return result.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + +def _exiftool_read(path: str, tags: list[str] | None = None) -> dict[str, Any]: + """Read metadata from a file using exiftool JSON output. + + Returns dict with keys like 'EXIF:ImageDescription', 'XMP-xmp:Rating', etc. + """ + cmd = ["exiftool", "-json", "-G1"] + if tags: + cmd.extend(f"-{tag}" for tag in tags) + cmd.append(path) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + if result.returncode != 0: + return {} + data = json.loads(result.stdout) + return data[0] if data else {} + + +def _exiftool_write(path: str, tags: dict[str, str]) -> bool: + """Write metadata to a file using exiftool. Returns True on success.""" + cmd = ["exiftool", "-overwrite_original"] + for key, value in tags.items(): + cmd.append(f"-{key}={value}") + cmd.append(path) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + return result.returncode == 0 and "1 image files updated" in result.stdout + + +def _create_jpeg(path: str) -> None: + """Write a minimal JPEG with EXIF date and GPS via piexif.""" + with open(path, "wb") as f: + f.write(_MINIMAL_JPEG) + exif_dict = piexif.load(path) + exif_dict["Exif"][36867] = b"2025:06:15 10:30:00" # DateTimeOriginal + exif_dict["0th"][271] = b"Apple" # Make + exif_dict["0th"][272] = b"iPhone 16 Pro" # Model + exif_dict["GPS"][1] = b"S" # GPSLatitudeRef + exif_dict["GPS"][2] = ((33, 1), (45, 1), (3671, 100)) # GPSLatitude + exif_dict["GPS"][3] = b"E" # GPSLongitudeRef + exif_dict["GPS"][4] = ((151, 1), (13, 1), (426, 100)) # GPSLongitude + exif_bytes = piexif.dump(exif_dict) + piexif.insert(exif_bytes, path) + + +def _create_png(path: str) -> None: + """Write a minimal PNG (no EXIF).""" + with open(path, "wb") as f: + f.write(_MINIMAL_PNG) + + +HAS_EXIFTOOL = _exiftool_available() +SKIP_MSG = "exiftool not installed" + + +class TestCheckExiftool(TestCase): + """Test the check_exiftool function from the module.""" + + def test_check_exiftool_returns_version(self) -> None: + if not HAS_EXIFTOOL: + self.skipTest(SKIP_MSG) + version = check_exiftool() + self.assertIsInstance(version, str) + # Should be a version number like "13.25" + self.assertRegex(version, r"\d+\.\d+") + + +class TestBuildExiftoolArgs(TestCase): + """Test argument building from MetadataUpdate.""" + + def test_empty_update_returns_no_args(self) -> None: + update = MetadataUpdate() + args = build_exiftool_args(update, {"all"}) + self.assertEqual(args, []) + + def test_rating_only(self) -> None: + update = MetadataUpdate(rating=5) + args = build_exiftool_args(update, {"rating"}) + self.assertEqual(args, ["-Rating=5"]) + + def test_rating_not_in_config(self) -> None: + update = MetadataUpdate(rating=5) + args = build_exiftool_args(update, {"keywords"}) + self.assertEqual(args, []) + + def test_all_config_includes_everything(self) -> None: + update = MetadataUpdate( + rating=5, title="Test", description="Desc", + keywords=["a", "b"], orientation=6 + ) + args = build_exiftool_args(update, {"all"}) + self.assertIn("-Rating=5", args) + self.assertIn("-XPTitle=Test", args) + self.assertIn("-ImageDescription=Desc", args) + self.assertIn("-Orientation#=6", args) + # Keywords generate multiple args + self.assertTrue(any("XPKeywords" in a for a in args)) + self.assertTrue(any("Subject" in a for a in args)) + + def test_keywords_semicolon_separated(self) -> None: + update = MetadataUpdate(keywords=["holiday", "beach", "sunset"]) + args = build_exiftool_args(update, {"keywords"}) + xp_args = [a for a in args if "XPKeywords" in a] + self.assertEqual(len(xp_args), 1) + self.assertIn("holiday; beach; sunset", xp_args[0]) + + +class TestWriteMetadataDryRun(TestCase): + """Test dry-run mode doesn't modify files.""" + + def setUp(self) -> None: + if not HAS_EXIFTOOL: + self.skipTest(SKIP_MSG) + self.tmp_dir = tempfile.mkdtemp() + + def tearDown(self) -> None: + shutil.rmtree(self.tmp_dir, ignore_errors=True) + + def test_dry_run_does_not_modify_file(self) -> None: + path = os.path.join(self.tmp_dir, "test.jpg") + _create_jpeg(path) + orig_size = os.path.getsize(path) + update = MetadataUpdate(rating=5, title="Test") + result = write_metadata(path, update, {"all"}, dry_run=True) + self.assertTrue(result) + self.assertEqual(os.path.getsize(path), orig_size) + + def test_dry_run_returns_false_when_no_changes(self) -> None: + path = os.path.join(self.tmp_dir, "test.jpg") + _create_jpeg(path) + update = MetadataUpdate() # No fields set + result = write_metadata(path, update, {"all"}, dry_run=True) + self.assertFalse(result) + + +class TestWriteMetadataReal(TestCase): + """Test real metadata writing via the module API.""" + + def setUp(self) -> None: + if not HAS_EXIFTOOL: + self.skipTest(SKIP_MSG) + self.tmp_dir = tempfile.mkdtemp() + + def tearDown(self) -> None: + shutil.rmtree(self.tmp_dir, ignore_errors=True) + + def test_write_rating_via_module(self) -> None: + path = os.path.join(self.tmp_dir, "test.jpg") + _create_jpeg(path) + update = MetadataUpdate(rating=5) + result = write_metadata(path, update, {"all"}) + self.assertTrue(result) + meta = _exiftool_read(path, ["Rating"]) + self.assertEqual(meta.get("XMP-xmp:Rating"), 5) + + def test_write_all_fields_via_module(self) -> None: + path = os.path.join(self.tmp_dir, "test.jpg") + _create_jpeg(path) + update = MetadataUpdate( + rating=5, + title="Beach Sunset", + description="Beautiful sunset on the beach", + keywords=["holiday", "beach"], + orientation=6, + ) + result = write_metadata(path, update, {"all"}) + self.assertTrue(result) + meta = _exiftool_read(path) + self.assertEqual(meta.get("XMP-xmp:Rating"), 5) + self.assertEqual(meta.get("IFD0:XPTitle"), "Beach Sunset") + self.assertIn("holiday", meta.get("IFD0:XPKeywords", "")) + + +class TestExiftoolAvailability(TestCase): + """Verify exiftool detection works.""" + + def test_exiftool_check_returns_bool(self) -> None: + result = _exiftool_available() + self.assertIsInstance(result, bool) + + def test_exiftool_is_available(self) -> None: + self.assertTrue(HAS_EXIFTOOL, SKIP_MSG) + + +class TestExiftoolWriteRating(TestCase): + """Write and read back Rating tag.""" + + def setUp(self) -> None: + if not HAS_EXIFTOOL: + self.skipTest(SKIP_MSG) + self.tmp_dir = tempfile.mkdtemp() + + def tearDown(self) -> None: + shutil.rmtree(self.tmp_dir, ignore_errors=True) + + def test_write_rating_to_jpeg(self) -> None: + path = os.path.join(self.tmp_dir, "test.jpg") + _create_jpeg(path) + self.assertTrue(_exiftool_write(path, {"Rating": "5"})) + meta = _exiftool_read(path, ["Rating"]) + self.assertEqual(meta.get("XMP-xmp:Rating"), 5) + + def test_write_rating_to_png(self) -> None: + path = os.path.join(self.tmp_dir, "test.png") + _create_png(path) + self.assertTrue(_exiftool_write(path, {"XMP:Rating": "5"})) + meta = _exiftool_read(path, ["Rating"]) + self.assertEqual(meta.get("XMP-xmp:Rating"), 5) + + +class TestExiftoolWriteKeywords(TestCase): + """Write and read back keywords/subject tags.""" + + def setUp(self) -> None: + if not HAS_EXIFTOOL: + self.skipTest(SKIP_MSG) + self.tmp_dir = tempfile.mkdtemp() + + def tearDown(self) -> None: + shutil.rmtree(self.tmp_dir, ignore_errors=True) + + def test_write_keywords_to_jpeg(self) -> None: + path = os.path.join(self.tmp_dir, "test.jpg") + _create_jpeg(path) + self.assertTrue( + _exiftool_write(path, {"XPKeywords": "holiday; beach; sunset"}) + ) + meta = _exiftool_read(path, ["XPKeywords"]) + keywords = meta.get("IFD0:XPKeywords", "") + self.assertIn("holiday", keywords) + self.assertIn("beach", keywords) + self.assertIn("sunset", keywords) + + def test_write_iptc_keywords_to_jpeg(self) -> None: + path = os.path.join(self.tmp_dir, "test.jpg") + _create_jpeg(path) + self.assertTrue( + _exiftool_write( + path, {"IPTC:Keywords": "holiday", "XMP:Subject": "holiday"} + ) + ) + meta = _exiftool_read(path, ["Keywords", "Subject"]) + found = str(meta) + self.assertIn("holiday", found) + + +class TestExiftoolWriteTitle(TestCase): + """Write and read back title/description tags.""" + + def setUp(self) -> None: + if not HAS_EXIFTOOL: + self.skipTest(SKIP_MSG) + self.tmp_dir = tempfile.mkdtemp() + + def tearDown(self) -> None: + shutil.rmtree(self.tmp_dir, ignore_errors=True) + + def test_write_title_to_jpeg(self) -> None: + path = os.path.join(self.tmp_dir, "test.jpg") + _create_jpeg(path) + self.assertTrue( + _exiftool_write(path, {"XPTitle": "My Holiday Photo"}) + ) + meta = _exiftool_read(path, ["XPTitle"]) + self.assertEqual(meta.get("IFD0:XPTitle"), "My Holiday Photo") + + def test_write_description_to_jpeg(self) -> None: + path = os.path.join(self.tmp_dir, "test.jpg") + _create_jpeg(path) + self.assertTrue( + _exiftool_write(path, {"ImageDescription": "A sunset at the beach"}) + ) + meta = _exiftool_read(path, ["ImageDescription"]) + self.assertEqual( + meta.get("EXIF:ImageDescription") or meta.get("IFD0:ImageDescription"), + "A sunset at the beach", + ) + + def test_write_description_to_png(self) -> None: + path = os.path.join(self.tmp_dir, "test.png") + _create_png(path) + self.assertTrue( + _exiftool_write(path, {"XMP:Description": "A screenshot"}) + ) + meta = _exiftool_read(path, ["Description"]) + found = str(meta) + self.assertIn("screenshot", found.lower()) + + +class TestExiftoolPreservation(TestCase): + """Verify existing camera EXIF is preserved after writing new metadata.""" + + def setUp(self) -> None: + if not HAS_EXIFTOOL: + self.skipTest(SKIP_MSG) + self.tmp_dir = tempfile.mkdtemp() + + def tearDown(self) -> None: + shutil.rmtree(self.tmp_dir, ignore_errors=True) + + def test_gps_preserved_after_rating_write(self) -> None: + path = os.path.join(self.tmp_dir, "test.jpg") + _create_jpeg(path) + before = _exiftool_read(path, ["GPSLatitude", "GPSLongitude", "Make"]) + # Write rating + self.assertTrue(_exiftool_write(path, {"Rating": "5"})) + after = _exiftool_read(path, ["GPSLatitude", "GPSLongitude", "Make"]) + self.assertEqual( + before.get("Composite:GPSLatitude"), + after.get("Composite:GPSLatitude"), + ) + self.assertEqual( + before.get("Composite:GPSLongitude"), + after.get("Composite:GPSLongitude"), + ) + + def test_dates_preserved_after_keyword_write(self) -> None: + path = os.path.join(self.tmp_dir, "test.jpg") + _create_jpeg(path) + before = _exiftool_read(path, ["DateTimeOriginal"]) + self.assertTrue( + _exiftool_write(path, {"XPKeywords": "test"}) + ) + after = _exiftool_read(path, ["DateTimeOriginal"]) + self.assertEqual( + before.get("EXIF:DateTimeOriginal"), + after.get("EXIF:DateTimeOriginal"), + ) + + def test_make_model_preserved(self) -> None: + path = os.path.join(self.tmp_dir, "test.jpg") + _create_jpeg(path) + before = _exiftool_read(path, ["Make", "Model"]) + self.assertTrue(_exiftool_write(path, {"Rating": "5", "XPTitle": "Test"})) + after = _exiftool_read(path, ["Make", "Model"]) + self.assertEqual(before.get("EXIF:Make"), after.get("EXIF:Make")) + + +class TestExiftoolIdempotency(TestCase): + """Second write of same metadata must produce identical file.""" + + def setUp(self) -> None: + if not HAS_EXIFTOOL: + self.skipTest(SKIP_MSG) + self.tmp_dir = tempfile.mkdtemp() + + def tearDown(self) -> None: + shutil.rmtree(self.tmp_dir, ignore_errors=True) + + def test_jpeg_idempotent(self) -> None: + path = os.path.join(self.tmp_dir, "test.jpg") + _create_jpeg(path) + tags = {"Rating": "5", "XPTitle": "Test", "ImageDescription": "Desc"} + self.assertTrue(_exiftool_write(path, tags)) + size_after_first = os.path.getsize(path) + self.assertTrue(_exiftool_write(path, tags)) + size_after_second = os.path.getsize(path) + self.assertEqual(size_after_first, size_after_second) + + def test_png_idempotent(self) -> None: + path = os.path.join(self.tmp_dir, "test.png") + _create_png(path) + tags = {"XMP:Rating": "5", "XMP:Description": "Test"} + self.assertTrue(_exiftool_write(path, tags)) + size_after_first = os.path.getsize(path) + self.assertTrue(_exiftool_write(path, tags)) + size_after_second = os.path.getsize(path) + self.assertEqual(size_after_first, size_after_second) + + +class TestExiftoolInPlaceSize(TestCase): + """Verify exiftool patches in-place with minimal size change.""" + + def setUp(self) -> None: + if not HAS_EXIFTOOL: + self.skipTest(SKIP_MSG) + self.tmp_dir = tempfile.mkdtemp() + + def tearDown(self) -> None: + shutil.rmtree(self.tmp_dir, ignore_errors=True) + + def test_jpeg_size_delta_is_small(self) -> None: + path = os.path.join(self.tmp_dir, "test.jpg") + _create_jpeg(path) + orig_size = os.path.getsize(path) + tags = { + "Rating": "5", + "XPTitle": "Favourite Photo", + "XPKeywords": "holiday; beach; sunset", + "ImageDescription": "A sunset at the beach", + } + self.assertTrue(_exiftool_write(path, tags)) + new_size = os.path.getsize(path) + # Should be within 5KB of original (metadata only, no re-encoding) + self.assertLess(abs(new_size - orig_size), 5000) + + +class TestExiftoolMultipleFields(TestCase): + """Write all supported fields at once and verify.""" + + def setUp(self) -> None: + if not HAS_EXIFTOOL: + self.skipTest(SKIP_MSG) + self.tmp_dir = tempfile.mkdtemp() + + def tearDown(self) -> None: + shutil.rmtree(self.tmp_dir, ignore_errors=True) + + def test_write_all_fields_to_jpeg(self) -> None: + path = os.path.join(self.tmp_dir, "test.jpg") + _create_jpeg(path) + tags = { + "Rating": "5", + "XPTitle": "Beach Sunset", + "XPKeywords": "holiday; beach; sunset", + "ImageDescription": "Beautiful sunset on the beach", + "Orientation": "6", # Rotate 90 CW + } + self.assertTrue(_exiftool_write(path, tags)) + + meta = _exiftool_read(path) + self.assertEqual(meta.get("XMP-xmp:Rating"), 5) + self.assertEqual(meta.get("IFD0:XPTitle"), "Beach Sunset") + self.assertIn("holiday", meta.get("IFD0:XPKeywords", "")) + + def test_write_all_fields_to_png(self) -> None: + path = os.path.join(self.tmp_dir, "test.png") + _create_png(path) + tags = { + "XMP:Rating": "5", + "XMP:Description": "A screenshot", + } + self.assertTrue(_exiftool_write(path, tags)) + meta = _exiftool_read(path, ["Rating", "Description"]) + self.assertEqual(meta.get("XMP-xmp:Rating"), 5) + + +class TestExiftoolErrorHandling(TestCase): + """Handle missing exiftool and invalid files gracefully.""" + + def setUp(self) -> None: + self.tmp_dir = tempfile.mkdtemp() + + def tearDown(self) -> None: + shutil.rmtree(self.tmp_dir, ignore_errors=True) + + def test_write_to_nonexistent_file_fails(self) -> None: + if not HAS_EXIFTOOL: + self.skipTest(SKIP_MSG) + result = _exiftool_write("/tmp/nonexistent_file_xyz.jpg", {"Rating": "5"}) + self.assertFalse(result) + + def test_write_to_empty_file_fails(self) -> None: + if not HAS_EXIFTOOL: + self.skipTest(SKIP_MSG) + path = os.path.join(self.tmp_dir, "empty.jpg") + with open(path, "wb") as f: + f.write(b"") + result = _exiftool_write(path, {"Rating": "5"}) + self.assertFalse(result) + + +class TestExtractMetadataUpdate(TestCase): + """Test extract_metadata_update from XMP metadata and raw asset records.""" + + def test_from_xmp_with_favourite(self) -> None: + from collections import namedtuple + from datetime import datetime, timedelta, timezone + + XMP = namedtuple("XMP", [ + "XMPToolkit", "Title", "Description", "Orientation", "Make", + "DigitalSourceType", "Keywords", "GPSAltitude", "GPSLatitude", + "GPSLongitude", "GPSSpeed", "GPSHPositioningError", "GPSTimeStamp", "CreateDate", "Rating", + ]) + xmp = XMP( + XMPToolkit="icloudpd", Title="Beach Day", Description="Fun day", + Orientation=6, Make="Apple", DigitalSourceType=None, + Keywords=["holiday", "beach"], GPSAltitude=10.0, GPSLatitude=-33.7, + GPSLongitude=151.2, GPSSpeed=0.0, GPSHPositioningError=None, GPSTimeStamp=None, + CreateDate=datetime(2025, 6, 15, 10, 30, tzinfo=timezone(timedelta(hours=11))), + Rating=5, + ) + update = extract_metadata_update({}, xmp) + self.assertEqual(update.rating, 5) + self.assertEqual(update.title, "Beach Day") + self.assertEqual(update.description, "Fun day") + self.assertEqual(update.keywords, ["holiday", "beach"]) + self.assertEqual(update.orientation, 6) + + def test_from_xmp_no_rating_returns_none(self) -> None: + from collections import namedtuple + XMP = namedtuple("XMP", [ + "XMPToolkit", "Title", "Description", "Orientation", "Make", + "DigitalSourceType", "Keywords", "GPSAltitude", "GPSLatitude", + "GPSLongitude", "GPSSpeed", "GPSHPositioningError", "GPSTimeStamp", "CreateDate", "Rating", + ]) + xmp = XMP( + XMPToolkit="icloudpd", Title=None, Description=None, + Orientation=None, Make=None, DigitalSourceType=None, + Keywords=None, GPSAltitude=None, GPSLatitude=None, + GPSLongitude=None, GPSSpeed=None, GPSHPositioningError=None, GPSTimeStamp=None, + CreateDate=None, Rating=None, + ) + update = extract_metadata_update({}, xmp) + self.assertIsNone(update.rating) + self.assertIsNone(update.title) + + def test_from_xmp_negative_timezone_created_date_extracted(self) -> None: + """created_date should be extracted from XMP CreateDate.""" + from collections import namedtuple + from datetime import datetime, timedelta, timezone + + XMP = namedtuple("XMP", [ + "XMPToolkit", "Title", "Description", "Orientation", "Make", + "DigitalSourceType", "Keywords", "GPSAltitude", "GPSLatitude", + "GPSLongitude", "GPSSpeed", "GPSHPositioningError", "GPSTimeStamp", "CreateDate", "Rating", + ]) + xmp = XMP( + XMPToolkit="icloudpd", Title=None, Description=None, + Orientation=None, Make=None, DigitalSourceType=None, + Keywords=None, GPSAltitude=None, GPSLatitude=None, + GPSLongitude=None, GPSSpeed=None, GPSHPositioningError=None, GPSTimeStamp=None, + CreateDate=datetime(2025, 1, 1, 8, 0, tzinfo=timezone(timedelta(hours=-8))), + Rating=None, + ) + update = extract_metadata_update({}, xmp) + self.assertEqual(update.created_date, "2025:01:01 08:00:00") + + def test_from_raw_asset_record_favourite(self) -> None: + record = {"fields": {"isFavorite": {"value": 1}}} + update = extract_metadata_update(record) + self.assertEqual(update.rating, 5) + + def test_from_raw_asset_record_hidden(self) -> None: + record = {"fields": {"isHidden": {"value": 1}}} + update = extract_metadata_update(record) + self.assertEqual(update.rating, -1) + + def test_from_raw_asset_record_normal(self) -> None: + record: dict[str, Any] = {"fields": {}} + update = extract_metadata_update(record) + self.assertIsNone(update.rating) + + +class TestBuildExiftoolArgsDatetime(TestCase): + """Test datetime and dates categories produce correct args.""" + + def test_datetime_category_emits_datetimeoriginal_and_createdate(self) -> None: + update = MetadataUpdate(created_date="2025:06:15 10:30:00") + args = build_exiftool_args(update, {"datetime"}) + self.assertIn("-EXIF:DateTimeOriginal=2025:06:15 10:30:00", args) + self.assertIn("-EXIF:CreateDate=2025:06:15 10:30:00", args) + # datetime does NOT emit ModifyDate + self.assertFalse(any("ModifyDate" in a for a in args)) + + def test_dates_category_emits_all_three(self) -> None: + update = MetadataUpdate(created_date="2025:06:15 10:30:00") + args = build_exiftool_args(update, {"dates"}) + self.assertIn("-EXIF:DateTimeOriginal=2025:06:15 10:30:00", args) + self.assertIn("-EXIF:CreateDate=2025:06:15 10:30:00", args) + self.assertIn("-EXIF:ModifyDate=2025:06:15 10:30:00", args) + + def test_datetime_no_created_date_emits_nothing(self) -> None: + update = MetadataUpdate(created_date=None) + args = build_exiftool_args(update, {"datetime"}) + self.assertEqual(args, []) + + def test_dates_with_rating(self) -> None: + update = MetadataUpdate(rating=5, created_date="2025:01:01 08:00:00") + args = build_exiftool_args(update, {"dates", "rating"}) + self.assertIn("-Rating=5", args) + self.assertIn("-EXIF:DateTimeOriginal=2025:01:01 08:00:00", args) + self.assertIn("-EXIF:ModifyDate=2025:01:01 08:00:00", args) + + def test_all_config_includes_datetime_tags(self) -> None: + update = MetadataUpdate( + rating=5, title="Test", created_date="2025:06:15 10:30:00" + ) + args = build_exiftool_args(update, {"all"}) + self.assertIn("-Rating=5", args) + self.assertIn("-EXIF:DateTimeOriginal=2025:06:15 10:30:00", args) + self.assertIn("-EXIF:CreateDate=2025:06:15 10:30:00", args) + self.assertIn("-EXIF:ModifyDate=2025:06:15 10:30:00", args) + + +class TestParseWriteMetadata(TestCase): + """Test CLI --write-metadata flag parsing.""" + + def test_none_returns_empty_frozenset(self) -> None: + self.assertEqual(_parse_write_metadata(None), frozenset()) + + def test_all(self) -> None: + self.assertEqual(_parse_write_metadata("all"), frozenset({"all"})) + + def test_comma_separated(self) -> None: + result = _parse_write_metadata("rating,keywords,title") + self.assertEqual(result, frozenset({"rating", "keywords", "title"})) + + def test_whitespace_stripped(self) -> None: + result = _parse_write_metadata("rating , keywords") + self.assertEqual(result, frozenset({"rating", "keywords"})) + + def test_invalid_raises(self) -> None: + import argparse + with self.assertRaises(argparse.ArgumentTypeError): + _parse_write_metadata("invalid_category") + + def test_mixed_valid_invalid_raises(self) -> None: + import argparse + with self.assertRaises(argparse.ArgumentTypeError): + _parse_write_metadata("rating,bogus") + + def test_dates_and_orientation(self) -> None: + result = _parse_write_metadata("dates,orientation") + self.assertEqual(result, frozenset({"dates", "orientation"})) + + def test_datetime(self) -> None: + result = _parse_write_metadata("datetime") + self.assertEqual(result, frozenset({"datetime"})) + + def test_datetime_with_others(self) -> None: + result = _parse_write_metadata("datetime,rating") + self.assertEqual(result, frozenset({"datetime", "rating"})) + + def test_location(self) -> None: + result = _parse_write_metadata("location") + self.assertEqual(result, frozenset({"location"})) + + def test_location_with_others(self) -> None: + result = _parse_write_metadata("rating,location,orientation") + self.assertEqual(result, frozenset({"rating", "location", "orientation"})) + + +class TestWriteMetadataNoChanges(TestCase): + """Test that write_metadata returns False when exiftool makes no changes.""" + + def setUp(self) -> None: + if not HAS_EXIFTOOL: + self.skipTest(SKIP_MSG) + self.tmp_dir = tempfile.mkdtemp() + + def tearDown(self) -> None: + shutil.rmtree(self.tmp_dir, ignore_errors=True) + + def test_second_write_returns_false(self) -> None: + """After writing metadata once, a second identical write should return False.""" + path = os.path.join(self.tmp_dir, "test.jpg") + _create_jpeg(path) + update = MetadataUpdate(rating=5) + # First write should succeed + result1 = write_metadata(path, update, {"all"}) + self.assertTrue(result1) + # Second identical write should detect no changes needed + result2 = write_metadata(path, update, {"all"}) + self.assertFalse(result2, "Second write with same metadata should return False") + + def test_rating_negative_roundtrip(self) -> None: + """Rating=-1 (hidden/rejected) writes and reads back correctly.""" + path = os.path.join(self.tmp_dir, "test_neg.jpg") + _create_jpeg(path) + update = MetadataUpdate(rating=-1) + self.assertTrue(write_metadata(path, update, {"rating"})) + meta = _exiftool_read(path, ["Rating"]) + self.assertEqual(meta.get("XMP-xmp:Rating"), -1) + # Idempotent + self.assertFalse(write_metadata(path, update, {"rating"})) + + def test_orientation_roundtrip(self) -> None: + """Orientation values round-trip correctly through exiftool.""" + for orient in [1, 3, 6, 8]: + path = os.path.join(self.tmp_dir, f"test_o{orient}.jpg") + _create_jpeg(path) + update = MetadataUpdate(orientation=orient) + self.assertTrue(write_metadata(path, update, {"orientation"}), f"orient={orient}") + # Idempotent + self.assertFalse(write_metadata(path, update, {"orientation"}), f"orient={orient} idempotent") + + def test_empty_keywords_no_write(self) -> None: + """Empty keywords list should not trigger a write.""" + path = os.path.join(self.tmp_dir, "test_kw.jpg") + _create_jpeg(path) + update = MetadataUpdate(keywords=[]) + result = write_metadata(path, update, {"keywords"}) + self.assertFalse(result) + + +class TestBuildExiftoolArgsLocation(TestCase): + """Test GPS/location argument building.""" + + def test_build_args_location(self) -> None: + update = MetadataUpdate( + gps_latitude=-33.7, gps_longitude=151.2, gps_altitude=10.0 + ) + args = build_exiftool_args(update, {"location"}) + self.assertIn("-GPSLatitude=-33.7", args) + self.assertIn("-GPSLatitudeRef=S", args) + self.assertIn("-GPSLongitude=151.2", args) + self.assertIn("-GPSLongitudeRef=E", args) + self.assertIn("-GPSAltitude=10.0", args) + self.assertIn("-GPSAltitudeRef#=0", args) + + def test_build_args_location_negative_altitude(self) -> None: + update = MetadataUpdate( + gps_latitude=-33.7, gps_longitude=151.2, gps_altitude=-50.0 + ) + args = build_exiftool_args(update, {"location"}) + self.assertIn("-GPSAltitude=-50.0", args) + self.assertIn("-GPSAltitudeRef#=1", args) + + def test_build_args_location_western_hemisphere(self) -> None: + """Verify western hemisphere longitude gets Ref=W.""" + update = MetadataUpdate( + gps_latitude=47.58, gps_longitude=-122.38, gps_altitude=0.0 + ) + args = build_exiftool_args(update, {"location"}) + self.assertIn("-GPSLatitude=47.58", args) + self.assertIn("-GPSLatitudeRef=N", args) + self.assertIn("-GPSLongitude=-122.38", args) + self.assertIn("-GPSLongitudeRef=W", args) + + def test_build_args_location_not_in_config(self) -> None: + update = MetadataUpdate( + gps_latitude=-33.7, gps_longitude=151.2, gps_altitude=10.0 + ) + args = build_exiftool_args(update, {"rating"}) + self.assertFalse(any("GPS" in a for a in args)) + + def test_build_args_location_with_h_accuracy(self) -> None: + """GPS horizontal accuracy should be emitted when positive.""" + update = MetadataUpdate( + gps_latitude=-33.7, gps_longitude=151.2, gps_h_accuracy=8.5 + ) + args = build_exiftool_args(update, {"location"}) + self.assertIn("-GPSHPositioningError=8.5", args) + + def test_build_args_location_h_accuracy_negative_skipped(self) -> None: + """Negative horzAcc (-1 = invalid) should not be emitted.""" + update = MetadataUpdate( + gps_latitude=-33.7, gps_longitude=151.2, gps_h_accuracy=-1.0 + ) + args = build_exiftool_args(update, {"location"}) + self.assertFalse(any("HPosError" in a or "HPositioning" in a for a in args)) + + def test_build_args_location_h_accuracy_zero_skipped(self) -> None: + """Zero horzAcc should not be emitted.""" + update = MetadataUpdate( + gps_latitude=-33.7, gps_longitude=151.2, gps_h_accuracy=0.0 + ) + args = build_exiftool_args(update, {"location"}) + self.assertFalse(any("HPosError" in a or "HPositioning" in a for a in args)) + + def test_build_args_location_h_accuracy_none_skipped(self) -> None: + """None horzAcc should not be emitted.""" + update = MetadataUpdate( + gps_latitude=-33.7, gps_longitude=151.2, gps_h_accuracy=None + ) + args = build_exiftool_args(update, {"location"}) + self.assertFalse(any("HPosError" in a or "HPositioning" in a for a in args)) + + def test_build_args_h_accuracy_requires_gps(self) -> None: + """horzAcc without lat/lon should not be emitted.""" + update = MetadataUpdate(gps_h_accuracy=8.5) + args = build_exiftool_args(update, {"location"}) + self.assertFalse(any("GPS" in a for a in args)) + + def test_orientation_zero_still_produces_args(self) -> None: + """orientation=0 is invalid EXIF (valid: 1-8); should be skipped.""" + update = MetadataUpdate(orientation=0) + args = build_exiftool_args(update, {"orientation"}) + # orientation=0 is filtered out as it's not a valid EXIF value + self.assertNotIn("-Orientation#=0", args) + + +class TestGpsClose(TestCase): + """Test _gps_close tolerance function.""" + + def test_gps_close_matching(self) -> None: + self.assertTrue(_gps_close(-33.7864, -33.7864)) + + def test_gps_close_within_tolerance(self) -> None: + self.assertTrue(_gps_close(-33.78640, -33.78641)) + + def test_gps_close_beyond_tolerance(self) -> None: + self.assertFalse(_gps_close(-33.786, -33.787)) + + def test_gps_close_none_both(self) -> None: + self.assertTrue(_gps_close(None, None)) + + def test_gps_close_none_one(self) -> None: + self.assertFalse(_gps_close(None, 1.0)) + self.assertFalse(_gps_close(1.0, None)) + + +class TestNeedsUpdateGps(TestCase): + """Test _needs_update with GPS fields.""" + + def test_needs_update_gps_same(self) -> None: + update = MetadataUpdate( + gps_latitude=-33.7864, gps_longitude=151.2099 + ) + # exiftool -n returns already-signed GPS values + existing = {"GPSLatitude": -33.7864, "GPSLatitudeRef": "S", + "GPSLongitude": 151.2099, "GPSLongitudeRef": "E"} + self.assertFalse(_needs_update(update, {"location"}, existing)) + + def test_needs_update_gps_missing(self) -> None: + update = MetadataUpdate( + gps_latitude=-33.7864, gps_longitude=151.2099 + ) + existing: dict[str, Any] = {} + self.assertTrue(_needs_update(update, {"location"}, existing)) + + +class TestExtractGps(TestCase): + """Test GPS extraction from XMP metadata.""" + + def _make_xmp(self, **overrides: Any) -> Any: + from collections import namedtuple + defaults: dict[str, Any] = { + "XMPToolkit": "icloudpd", "Title": None, "Description": None, + "Orientation": None, "Make": None, "DigitalSourceType": None, + "Keywords": None, "GPSAltitude": None, "GPSLatitude": None, + "GPSLongitude": None, "GPSSpeed": None, "GPSHPositioningError": None, "GPSTimeStamp": None, + "CreateDate": None, "Rating": None, + } + defaults.update(overrides) + XMP = namedtuple("XMP", ( + "XMPToolkit", "Title", "Description", "Orientation", "Make", + "DigitalSourceType", "Keywords", "GPSAltitude", "GPSLatitude", + "GPSLongitude", "GPSSpeed", "GPSHPositioningError", "GPSTimeStamp", "CreateDate", "Rating", + )) + return XMP(**defaults) + + def test_extract_gps_from_xmp(self) -> None: + xmp = self._make_xmp( + GPSLatitude=-33.7864, GPSLongitude=151.2099, GPSAltitude=42.5 + ) + update = extract_metadata_update({}, xmp) + assert update.gps_latitude is not None + self.assertAlmostEqual(update.gps_latitude, -33.7864) + assert update.gps_longitude is not None + self.assertAlmostEqual(update.gps_longitude, 151.2099) + assert update.gps_altitude is not None + self.assertAlmostEqual(update.gps_altitude, 42.5) + + def test_extract_orientation_zero_becomes_none(self) -> None: + xmp = self._make_xmp(Orientation=0) + update = extract_metadata_update({}, xmp) + self.assertIsNone(update.orientation) + + +class TestWriteGpsReal(TestCase): + """Test real GPS writing and read-back via exiftool.""" + + def setUp(self) -> None: + if not HAS_EXIFTOOL: + self.skipTest(SKIP_MSG) + self.tmp_dir = tempfile.mkdtemp() + + def tearDown(self) -> None: + shutil.rmtree(self.tmp_dir, ignore_errors=True) + + def test_write_gps_to_jpeg(self) -> None: + path = os.path.join(self.tmp_dir, "test.jpg") + _create_jpeg(path) + update = MetadataUpdate( + gps_latitude=-33.7864, gps_longitude=151.2099, gps_altitude=42.5 + ) + result = write_metadata(path, update, {"location"}) + self.assertTrue(result) + # Read back with -n for numeric values + cmd = [ + "exiftool", "-json", "-n", + "-GPSLatitude", "-GPSLongitude", "-GPSAltitude", + path, + ] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + self.assertEqual(proc.returncode, 0) + meta = json.loads(proc.stdout)[0] + self.assertAlmostEqual(meta["GPSLatitude"], -33.7864, places=4) + self.assertAlmostEqual(meta["GPSLongitude"], 151.2099, places=4) + self.assertAlmostEqual(meta["GPSAltitude"], 42.5, places=1) + + def test_gps_sign_preserved_all_hemispheres(self) -> None: + """Regression: GPS # suffix dropped sign, placing locations in wrong hemisphere.""" + cases = [ + (-33.786, 151.209, "Sydney: S lat, E lon"), + (47.589, -122.380, "Seattle: N lat, W lon"), + (-22.906, -43.172, "Rio: S lat, W lon"), + (55.751, 37.617, "Moscow: N lat, E lon"), + ] + for lat, lon, label in cases: + path = os.path.join(self.tmp_dir, f"test_{label[:3]}.jpg") + _create_jpeg(path) + update = MetadataUpdate(gps_latitude=lat, gps_longitude=lon) + write_metadata(path, update, {"location"}) + cmd = ["exiftool", "-json", "-n", "-GPSLatitude", "-GPSLongitude", path] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + meta = json.loads(proc.stdout)[0] + self.assertAlmostEqual(meta["GPSLatitude"], lat, places=3, msg=label) + self.assertAlmostEqual(meta["GPSLongitude"], lon, places=3, msg=label) + + def test_gps_idempotent_with_existing_data(self) -> None: + """Second write with same GPS should detect no changes needed.""" + path = os.path.join(self.tmp_dir, "test_idem.jpg") + _create_jpeg(path) + update = MetadataUpdate(gps_latitude=-33.786, gps_longitude=151.209, gps_altitude=10.0) + result1 = write_metadata(path, update, {"location"}) + self.assertTrue(result1) + result2 = write_metadata(path, update, {"location"}) + self.assertFalse(result2, "Second GPS write should be idempotent") + + def test_gps_sign_preserved_mov(self) -> None: + """MOV files use XMP GPS (not EXIF) — signed values must be preserved.""" + path = os.path.join(self.tmp_dir, "test_gps.mov") + shutil.copy(os.path.join(_DATA_DIR, "test_video.mov"), path) + cases = [ + (-33.786, 151.209, "Sydney: S lat, E lon"), + (47.589, -122.380, "Seattle: N lat, W lon"), + (-22.906, -43.172, "Rio: S lat, W lon"), + (55.751, 37.617, "Moscow: N lat, E lon"), + ] + for lat, lon, label in cases: + shutil.copy(os.path.join(_DATA_DIR, "test_video.mov"), path) + update = MetadataUpdate(gps_latitude=lat, gps_longitude=lon, gps_altitude=10.0) + write_metadata(path, update, {"location"}) + cmd = ["exiftool", "-json", "-n", "-GPSLatitude", "-GPSLongitude", path] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + meta = json.loads(proc.stdout)[0] + self.assertAlmostEqual(meta["GPSLatitude"], lat, places=3, msg=label) + self.assertAlmostEqual(meta["GPSLongitude"], lon, places=3, msg=label) + + def test_gps_sign_preserved_mp4(self) -> None: + """MP4 files use XMP GPS (not EXIF) — signed values must be preserved.""" + path = os.path.join(self.tmp_dir, "test_gps.mp4") + shutil.copy(os.path.join(_DATA_DIR, "test_video.mp4"), path) + cases = [ + (-33.786, 151.209, "Sydney: S lat, E lon"), + (47.589, -122.380, "Seattle: N lat, W lon"), + ] + for lat, lon, label in cases: + shutil.copy(os.path.join(_DATA_DIR, "test_video.mp4"), path) + update = MetadataUpdate(gps_latitude=lat, gps_longitude=lon, gps_altitude=5.0) + write_metadata(path, update, {"location"}) + cmd = ["exiftool", "-json", "-n", "-GPSLatitude", "-GPSLongitude", path] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + meta = json.loads(proc.stdout)[0] + self.assertAlmostEqual(meta["GPSLatitude"], lat, places=3, msg=label) + self.assertAlmostEqual(meta["GPSLongitude"], lon, places=3, msg=label) + + def test_gps_idempotent_mov(self) -> None: + """MOV GPS write should be idempotent (XMP round-trip).""" + path = os.path.join(self.tmp_dir, "test_idem.mov") + shutil.copy(os.path.join(_DATA_DIR, "test_video.mov"), path) + update = MetadataUpdate(gps_latitude=-33.786, gps_longitude=151.209, gps_altitude=10.0) + result1 = write_metadata(path, update, {"location"}) + self.assertTrue(result1) + result2 = write_metadata(path, update, {"location"}) + self.assertFalse(result2, "Second MOV GPS write should be idempotent") + + def test_gps_idempotent_mp4(self) -> None: + """MP4 GPS write should be idempotent (XMP round-trip).""" + path = os.path.join(self.tmp_dir, "test_idem.mp4") + shutil.copy(os.path.join(_DATA_DIR, "test_video.mp4"), path) + update = MetadataUpdate(gps_latitude=-22.906, gps_longitude=-43.172, gps_altitude=5.0) + result1 = write_metadata(path, update, {"location"}) + self.assertTrue(result1) + result2 = write_metadata(path, update, {"location"}) + self.assertFalse(result2, "Second MP4 GPS write should be idempotent") + + def test_h_accuracy_round_trip_jpeg(self) -> None: + """GPSHPositioningError should round-trip on JPEG.""" + path = os.path.join(self.tmp_dir, "test_hacc.jpg") + _create_jpeg(path) + update = MetadataUpdate( + gps_latitude=-33.786, gps_longitude=151.209, gps_h_accuracy=11.5 + ) + write_metadata(path, update, {"location"}) + cmd = ["exiftool", "-json", "-s", "-n", "-GPSHPositioningError", path] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + meta = json.loads(proc.stdout)[0] + self.assertAlmostEqual(meta["GPSHPositioningError"], 11.5, places=1) + + def test_h_accuracy_round_trip_mov(self) -> None: + """LocationAccuracyHorizontal should round-trip on MOV (Keys).""" + path = os.path.join(self.tmp_dir, "test_hacc.mov") + shutil.copy(os.path.join(_DATA_DIR, "test_video.mov"), path) + update = MetadataUpdate( + gps_latitude=-33.786, gps_longitude=151.209, gps_h_accuracy=8.0 + ) + write_metadata(path, update, {"location"}) + cmd = ["exiftool", "-json", "-s", "-n", "-Keys:LocationAccuracyHorizontal", path] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + meta = json.loads(proc.stdout)[0] + self.assertAlmostEqual(meta["LocationAccuracyHorizontal"], 8.0, places=1) + + def test_h_accuracy_idempotent(self) -> None: + """Second write with same horzAcc should be idempotent.""" + path = os.path.join(self.tmp_dir, "test_hacc_idem.jpg") + _create_jpeg(path) + update = MetadataUpdate( + gps_latitude=-33.786, gps_longitude=151.209, gps_h_accuracy=11.5 + ) + result1 = write_metadata(path, update, {"location"}) + self.assertTrue(result1) + result2 = write_metadata(path, update, {"location"}) + self.assertFalse(result2, "Second horzAcc write should be idempotent") + + def test_h_accuracy_negative_not_written(self) -> None: + """Negative horzAcc (-1 = invalid) should not be written to file.""" + path = os.path.join(self.tmp_dir, "test_hacc_neg.jpg") + _create_jpeg(path) + update = MetadataUpdate( + gps_latitude=-33.786, gps_longitude=151.209, gps_h_accuracy=-1.0 + ) + write_metadata(path, update, {"location"}) + cmd = ["exiftool", "-json", "-s", "-n", "-GPSHPositioningError", path] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + meta = json.loads(proc.stdout)[0] + self.assertNotIn("GPSHPositioningError", meta) + + def test_extract_h_accuracy_from_asset_record(self) -> None: + """extract_metadata_update should pull horzAcc from locationEnc.""" + import base64 + import plistlib + + location = {"lat": -33.786, "lon": 151.209, "alt": 10.0, + "speed": 0.0, "horzAcc": 8.5, "vertAcc": 0.0, + "course": 270.0, "timestamp": "2025-06-15T10:30:00"} + loc_b64 = base64.b64encode(plistlib.dumps(location)).decode() + asset_record = { + "fields": { + "locationEnc": {"value": loc_b64, "type": "ENCRYPTED_BYTES"}, + "isFavorite": {"value": 0, "type": "INT64"}, + } + } + update = extract_metadata_update(asset_record) + assert update.gps_h_accuracy is not None + self.assertAlmostEqual(update.gps_h_accuracy, 8.5, places=1) + + def test_extract_h_accuracy_negative_returns_none(self) -> None: + """horzAcc=-1 (invalid) should return None.""" + import base64 + import plistlib + + location = {"lat": -33.786, "lon": 151.209, "alt": 10.0, + "speed": 0.0, "horzAcc": -1.0, "vertAcc": -1.0} + loc_b64 = base64.b64encode(plistlib.dumps(location)).decode() + asset_record = { + "fields": { + "locationEnc": {"value": loc_b64, "type": "ENCRYPTED_BYTES"}, + "isFavorite": {"value": 0, "type": "INT64"}, + } + } + update = extract_metadata_update(asset_record) + self.assertIsNone(update.gps_h_accuracy) + + def test_extract_h_accuracy_missing_location(self) -> None: + """No locationEnc should return None for h_accuracy.""" + asset_record = { + "fields": { + "isFavorite": {"value": 0, "type": "INT64"}, + } + } + update = extract_metadata_update(asset_record) + self.assertIsNone(update.gps_h_accuracy) + + +class TestWriteDatetimeReal(TestCase): + """Test real datetime writing and read-back via exiftool.""" + + def setUp(self) -> None: + if not HAS_EXIFTOOL: + self.skipTest(SKIP_MSG) + self.tmp_dir = tempfile.mkdtemp() + + def tearDown(self) -> None: + shutil.rmtree(self.tmp_dir, ignore_errors=True) + + def test_datetime_round_trip_jpeg(self) -> None: + """Write datetime to JPEG, read back, verify.""" + path = os.path.join(self.tmp_dir, "test_dt.jpg") + with open(path, "wb") as f: + f.write(_MINIMAL_JPEG) + update = MetadataUpdate(created_date="2025:06:15 10:30:00") + result = write_metadata(path, update, {"datetime"}) + self.assertTrue(result) + cmd = [ + "exiftool", "-json", "-s", "-n", + "-EXIF:DateTimeOriginal", "-EXIF:CreateDate", + path, + ] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + meta = json.loads(proc.stdout)[0] + self.assertEqual(meta.get("DateTimeOriginal"), "2025:06:15 10:30:00") + self.assertEqual(meta.get("CreateDate"), "2025:06:15 10:30:00") + + def test_datetime_idempotent(self) -> None: + """Second write of same datetime should detect no changes.""" + path = os.path.join(self.tmp_dir, "test_dt_idem.jpg") + with open(path, "wb") as f: + f.write(_MINIMAL_JPEG) + update = MetadataUpdate(created_date="2025:06:15 10:30:00") + result1 = write_metadata(path, update, {"datetime"}) + self.assertTrue(result1) + result2 = write_metadata(path, update, {"datetime"}) + self.assertFalse(result2, "Second datetime write should be idempotent") + + def test_dates_includes_modify_date(self) -> None: + """dates category should also write ModifyDate.""" + path = os.path.join(self.tmp_dir, "test_dates.jpg") + with open(path, "wb") as f: + f.write(_MINIMAL_JPEG) + update = MetadataUpdate(created_date="2025:06:15 10:30:00") + result = write_metadata(path, update, {"dates"}) + self.assertTrue(result) + cmd = [ + "exiftool", "-json", "-s", "-n", + "-EXIF:DateTimeOriginal", "-EXIF:CreateDate", "-EXIF:ModifyDate", + path, + ] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + meta = json.loads(proc.stdout)[0] + self.assertEqual(meta.get("DateTimeOriginal"), "2025:06:15 10:30:00") + self.assertEqual(meta.get("CreateDate"), "2025:06:15 10:30:00") + self.assertEqual(meta.get("ModifyDate"), "2025:06:15 10:30:00") + + def test_all_includes_datetime_tags(self) -> None: + """all category should include datetime tags.""" + path = os.path.join(self.tmp_dir, "test_all_dt.jpg") + with open(path, "wb") as f: + f.write(_MINIMAL_JPEG) + update = MetadataUpdate( + rating=5, created_date="2025:06:15 10:30:00" + ) + result = write_metadata(path, update, {"all"}) + self.assertTrue(result) + cmd = [ + "exiftool", "-json", "-s", "-n", + "-EXIF:DateTimeOriginal", "-Rating", + path, + ] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + meta = json.loads(proc.stdout)[0] + self.assertEqual(meta.get("DateTimeOriginal"), "2025:06:15 10:30:00") + + def test_extract_created_date_from_xmp(self) -> None: + """extract_metadata_update should populate created_date from XMP.""" + from collections import namedtuple + from datetime import datetime, timedelta, timezone + + XMP = namedtuple("XMP", [ + "XMPToolkit", "Title", "Description", "Orientation", "Make", + "DigitalSourceType", "Keywords", "GPSAltitude", "GPSLatitude", + "GPSLongitude", "GPSSpeed", "GPSHPositioningError", "GPSTimeStamp", "CreateDate", "Rating", + ]) + xmp = XMP( + XMPToolkit="icloudpd", Title="Beach", Description=None, + Orientation=None, Make=None, DigitalSourceType=None, + Keywords=None, GPSAltitude=None, GPSLatitude=None, + GPSLongitude=None, GPSSpeed=None, GPSHPositioningError=None, GPSTimeStamp=None, + CreateDate=datetime(2025, 6, 15, 10, 30, tzinfo=timezone(timedelta(hours=11))), + Rating=5, + ) + update = extract_metadata_update({}, xmp) + self.assertEqual(update.created_date, "2025:06:15 10:30:00") + + def test_extract_no_create_date_returns_none(self) -> None: + """extract_metadata_update with no CreateDate returns None.""" + from collections import namedtuple + + XMP = namedtuple("XMP", [ + "XMPToolkit", "Title", "Description", "Orientation", "Make", + "DigitalSourceType", "Keywords", "GPSAltitude", "GPSLatitude", + "GPSLongitude", "GPSSpeed", "GPSHPositioningError", "GPSTimeStamp", "CreateDate", "Rating", + ]) + xmp = XMP( + XMPToolkit="icloudpd", Title=None, Description=None, + Orientation=None, Make=None, DigitalSourceType=None, + Keywords=None, GPSAltitude=None, GPSLatitude=None, + GPSLongitude=None, GPSSpeed=None, GPSHPositioningError=None, GPSTimeStamp=None, + CreateDate=None, Rating=None, + ) + update = extract_metadata_update({}, xmp) + self.assertIsNone(update.created_date) + + +class TestVideeDatetime(TestCase): + """Test video-specific datetime tag handling.""" + + def setUp(self) -> None: + self.tmp_dir = tempfile.mkdtemp() + + def tearDown(self) -> None: + shutil.rmtree(self.tmp_dir) + + def test_build_args_video_uses_quicktime_tags(self) -> None: + """MOV/MP4 should use QuickTime:CreateDate, not EXIF:DateTimeOriginal.""" + update = MetadataUpdate(created_date="2024:07:10 10:00:00") + args = build_exiftool_args(update, {"datetime"}, "/photos/test.MOV") + self.assertIn("-QuickTime:CreateDate=2024:07:10 10:00:00", args) + self.assertNotIn("-EXIF:DateTimeOriginal=2024:07:10 10:00:00", args) + + def test_build_args_image_uses_exif_tags(self) -> None: + """JPEG/HEIC should use EXIF:DateTimeOriginal.""" + update = MetadataUpdate(created_date="2024:07:10 10:00:00") + args = build_exiftool_args(update, {"datetime"}, "/photos/test.JPG") + self.assertIn("-EXIF:DateTimeOriginal=2024:07:10 10:00:00", args) + self.assertNotIn("-QuickTime:CreateDate=2024:07:10 10:00:00", args) + + def test_build_args_dates_video_includes_modify(self) -> None: + """dates category on video should write QuickTime:ModifyDate.""" + update = MetadataUpdate(created_date="2024:07:10 10:00:00") + args = build_exiftool_args(update, {"dates"}, "/photos/test.mp4") + self.assertIn("-QuickTime:CreateDate=2024:07:10 10:00:00", args) + self.assertIn("-QuickTime:ModifyDate=2024:07:10 10:00:00", args) + + def test_build_args_no_file_path_defaults_to_exif(self) -> None: + """When file_path not provided, default to EXIF tags.""" + update = MetadataUpdate(created_date="2024:07:10 10:00:00") + args = build_exiftool_args(update, {"datetime"}) + self.assertIn("-EXIF:DateTimeOriginal=2024:07:10 10:00:00", args) + + def test_mov_datetime_round_trip(self) -> None: + """Write QuickTime:CreateDate to MOV, verify it reads back.""" + src = os.path.join(_DATA_DIR, "test_video.mov") + path = os.path.join(self.tmp_dir, "test.mov") + shutil.copy2(src, path) + update = MetadataUpdate(created_date="2024:07:10 10:30:00") + result = write_metadata(path, update, {"datetime"}) + self.assertTrue(result) + # Read back + r = subprocess.run( + ["exiftool", "-json", "-s", "-n", "-QuickTime:CreateDate", path], + capture_output=True, text=True, + ) + data = json.loads(r.stdout)[0] + self.assertEqual(data.get("CreateDate"), "2024:07:10 10:30:00") + + +class TestXmpRepair(TestCase): + """Test XMP repair for files with corrupt XMP metadata.""" + + def test_exiftool_warning_on_unwritable_file(self) -> None: + """Exiftool 'unchanged' should log warning, not silently cycle.""" + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "corrupt.jpg") + # Write garbage that looks like a file header but isn't valid + with open(path, "wb") as f: + f.write(b"this is not a jpeg file at all") + update = MetadataUpdate(rating=5) + with self.assertLogs("icloudpd.metadata_writer", level="WARNING") as cm: + result = write_metadata(path, update, {"rating"}) + self.assertFalse(result) + self.assertTrue(any("failed" in msg.lower() or "no effect" in msg.lower() for msg in cm.output)) + + +class TestVideoKeysGps(TestCase): + """Test Keys:GPSCoordinates writing for MOV/MP4 files.""" + + def setUp(self) -> None: + self.tmp_dir = tempfile.mkdtemp() + + def tearDown(self) -> None: + shutil.rmtree(self.tmp_dir) + + def test_build_args_video_uses_keys_gps(self) -> None: + """MOV should use Keys:GPSCoordinates, not EXIF GPS tags.""" + update = MetadataUpdate(gps_latitude=-33.86, gps_longitude=151.21, gps_altitude=50.0) + args = build_exiftool_args(update, {"location"}, "/photos/test.MOV") + keys_args = [a for a in args if "Keys:" in a] + # Exclude strip commands (ending with bare '=') — those delete old tags + write_args = [a for a in args if not a.endswith("=")] + exif_args = [a for a in write_args if "GPSLatitude" in a and "Keys:" not in a] + self.assertTrue(len(keys_args) > 0, "Should have Keys: GPS args for video") + self.assertEqual(len(exif_args), 0, "Should NOT have EXIF GPS write args for video") + + def test_build_args_video_keys_gps_format(self) -> None: + """Keys:GPSCoordinates should use ISO 6709 format.""" + update = MetadataUpdate(gps_latitude=-33.86, gps_longitude=151.21, gps_altitude=50.0) + args = build_exiftool_args(update, {"location"}, "/photos/test.MOV") + gps_arg = [a for a in args if "GPSCoordinates" in a][0] + # Should be ISO 6709: "-33.860000+151.210000+50.000/" + self.assertIn("-33.860000+151.210000+50.000/", gps_arg) + + def test_build_args_video_h_accuracy_uses_keys(self) -> None: + """Video should use Keys:LocationAccuracyHorizontal.""" + update = MetadataUpdate(gps_latitude=-33.86, gps_longitude=151.21, gps_h_accuracy=10.5) + args = build_exiftool_args(update, {"location"}, "/photos/test.MOV") + self.assertTrue(any("LocationAccuracyHorizontal" in a for a in args)) + # Only write args (not strip commands ending with bare '=') should be checked + write_args = [a for a in args if not a.endswith("=")] + self.assertFalse(any("GPSHPositioningError" in a for a in write_args)) + + def test_build_args_image_still_uses_exif_gps(self) -> None: + """JPEG should still use EXIF GPS tags, not Keys.""" + update = MetadataUpdate(gps_latitude=-33.86, gps_longitude=151.21, gps_altitude=50.0) + args = build_exiftool_args(update, {"location"}, "/photos/test.JPG") + self.assertTrue(any("GPSLatitude" in a for a in args)) + self.assertFalse(any("Keys:" in a for a in args)) + + def test_mov_keys_gps_round_trip(self) -> None: + """Write Keys:GPSCoordinates to MOV, verify readback.""" + if not HAS_EXIFTOOL: + self.skipTest(SKIP_MSG) + src = os.path.join(_DATA_DIR, "test_video.mov") + path = os.path.join(self.tmp_dir, "test.mov") + shutil.copy2(src, path) + update = MetadataUpdate(gps_latitude=-33.86, gps_longitude=151.21, gps_altitude=50.0, gps_h_accuracy=10.5) + result = write_metadata(path, update, {"location"}) + self.assertTrue(result) + # Second write should be idempotent + result2 = write_metadata(path, update, {"location"}) + self.assertFalse(result2, "Second write should be idempotent") + + def test_build_args_gif_returns_empty(self) -> None: + """GIF should return no exiftool args at all.""" + update = MetadataUpdate(rating=5, gps_latitude=-33.86, gps_longitude=151.21, orientation=6) + args = build_exiftool_args(update, {"all"}, "/photos/test.gif") + self.assertEqual(args, [], "GIF should have no exiftool args") + + def test_build_args_video_no_orientation(self) -> None: + """Video should not emit Orientation tag.""" + update = MetadataUpdate(orientation=6) + args = build_exiftool_args(update, {"orientation"}, "/photos/test.MOV") + self.assertFalse(any("Orientation" in a for a in args)) + + def test_build_args_png_no_orientation(self) -> None: + """PNG should not emit Orientation tag.""" + update = MetadataUpdate(orientation=6) + args = build_exiftool_args(update, {"orientation"}, "/photos/test.PNG") + self.assertFalse(any("Orientation" in a for a in args)) + + def test_build_args_orientation_zero_skipped(self) -> None: + """Orientation=0 should never be emitted (invalid EXIF value).""" + update = MetadataUpdate(orientation=0) + args = build_exiftool_args(update, {"orientation"}, "/photos/test.JPG") + self.assertFalse(any("Orientation" in a for a in args)) + + def test_build_args_video_strips_xmp_exif_gps(self) -> None: + """Video GPS write should include XMP-exif GPS strip commands.""" + update = MetadataUpdate(gps_latitude=-33.86, gps_longitude=151.21, gps_altitude=50.0) + args = build_exiftool_args(update, {"location"}, "/photos/test.MOV") + strip_args = [a for a in args if a.startswith("-XMP-exif:GPS") and a.endswith("=")] + self.assertGreaterEqual(len(strip_args), 3, "Should strip XMP-exif GPS tags") + self.assertTrue(any(a.split(":")[-1] == "GPSLatitude=" for a in strip_args)) + + def test_build_args_image_no_xmp_exif_strip(self) -> None: + """Image GPS write should NOT include XMP-exif strip commands.""" + update = MetadataUpdate(gps_latitude=-33.86, gps_longitude=151.21, gps_altitude=50.0) + args = build_exiftool_args(update, {"location"}, "/photos/test.JPG") + strip_args = [a for a in args if "XMP-exif" in a] + self.assertEqual(len(strip_args), 0, "Image should not strip XMP-exif") + + def test_needs_update_stale_xmp_gps_triggers(self) -> None: + """_needs_update should return True when stale XMP-exif GPS detected.""" + update = MetadataUpdate(gps_latitude=-33.86, gps_longitude=151.21) + existing = { + "GPSLatitude": -33.86, + "GPSLongitude": 151.21, + "_has_stale_xmp_gps": True, + } + self.assertTrue(_needs_update(update, {"location"}, existing)) + + def test_needs_update_no_stale_xmp_no_trigger(self) -> None: + """_needs_update should return False when GPS matches and no stale XMP.""" + update = MetadataUpdate(gps_latitude=-33.86, gps_longitude=151.21) + existing = {"GPSLatitude": -33.86, "GPSLongitude": 151.21} + self.assertFalse(_needs_update(update, {"location"}, existing)) + + def test_mov_xmp_exif_gps_stripped_after_write(self) -> None: + """Write to MOV should strip XMP-exif GPS and leave Keys GPS.""" + if not HAS_EXIFTOOL: + self.skipTest(SKIP_MSG) + src = os.path.join(_DATA_DIR, "test_video.mov") + path = os.path.join(self.tmp_dir, "test.mov") + shutil.copy2(src, path) + # First, write XMP-exif GPS (simulating old code behaviour) + subprocess.run( + ["exiftool", "-overwrite_original", + "-XMP-exif:GPSLatitude=-33.86", "-XMP-exif:GPSLongitude=151.21", + path], + capture_output=True, timeout=30, + ) + update = MetadataUpdate(gps_latitude=-33.86, gps_longitude=151.21, gps_altitude=50.0) + result = write_metadata(path, update, {"location"}) + self.assertTrue(result, "Should write (stale XMP-exif detected)") + # Verify XMP-exif GPS is gone + check = subprocess.run( + ["exiftool", "-s", "-XMP-exif:GPSLatitude", path], + capture_output=True, text=True, timeout=30, + ) + self.assertEqual(check.stdout.strip(), "", "XMP-exif GPS should be stripped") + # Verify Keys GPS is present + check2 = subprocess.run( + ["exiftool", "-s", "-Keys:GPSCoordinates", path], + capture_output=True, text=True, timeout=30, + ) + self.assertIn("GPSCoordinates", check2.stdout) + # Second write should be idempotent + result2 = write_metadata(path, update, {"location"}) + self.assertFalse(result2, "Second write should be idempotent") + + +class TestWebpRoundTrip(TestCase): + """Test WEBP metadata round-trip.""" + + def setUp(self) -> None: + self.tmp_dir = tempfile.mkdtemp() + + def tearDown(self) -> None: + shutil.rmtree(self.tmp_dir) + + def test_webp_rating_round_trip(self) -> None: + """WEBP should support Rating via EXIF.""" + src = os.path.join(_DATA_DIR, "test_image.webp") + path = os.path.join(self.tmp_dir, "test.webp") + shutil.copy2(src, path) + update = MetadataUpdate(rating=5) + result = write_metadata(path, update, {"rating"}) + self.assertTrue(result) + result2 = write_metadata(path, update, {"rating"}) + self.assertFalse(result2, "Second write should be idempotent") + + def test_webp_gps_round_trip(self) -> None: + """WEBP should support GPS via EXIF.""" + src = os.path.join(_DATA_DIR, "test_image.webp") + path = os.path.join(self.tmp_dir, "test.webp") + shutil.copy2(src, path) + update = MetadataUpdate(gps_latitude=-33.86, gps_longitude=151.21, gps_altitude=50.0) + result = write_metadata(path, update, {"location"}) + self.assertTrue(result) + result2 = write_metadata(path, update, {"location"}) + self.assertFalse(result2, "Second write should be idempotent") + + +class TestGifSkip(TestCase): + """Test that GIF files are skipped for EXIF writes.""" + + def test_gif_write_returns_false(self) -> None: + """GIF should not attempt any exiftool writes.""" + update = MetadataUpdate(rating=5, gps_latitude=-33.86, gps_longitude=151.21, orientation=6) + args = build_exiftool_args(update, {"all"}, "/photos/test.gif") + self.assertEqual(args, [], "GIF should produce no exiftool args") + + def test_gif_write_metadata_returns_false(self) -> None: + """write_metadata on GIF should return False (no args to write).""" + src = os.path.join(_DATA_DIR, "test_image.gif") + path = os.path.join(tempfile.mkdtemp(), "test.gif") + shutil.copy2(src, path) + update = MetadataUpdate(rating=5) + result = write_metadata(path, update, {"rating"}) + self.assertFalse(result, "GIF writes should be skipped") + shutil.rmtree(os.path.dirname(path)) + + +class TestNeedsUpdateOrientationExclusion(TestCase): + """Orientation should be skipped for videos and PNGs in _needs_update, + matching build_exiftool_args which also skips them.""" + + def test_mov_orientation_mismatch_ignored(self) -> None: + update = MetadataUpdate(orientation=1) + existing: dict[str, Any] = {} # MOVs have no EXIF Orientation + self.assertFalse( + _needs_update(update, {"orientation"}, existing, file_path="2026-01/IMG_0993_HEVC.MOV"), + "Orientation mismatch on MOV should not trigger update", + ) + + def test_mp4_orientation_mismatch_ignored(self) -> None: + update = MetadataUpdate(orientation=6) + existing: dict[str, Any] = {} + self.assertFalse( + _needs_update(update, {"orientation"}, existing, file_path="2025-10/video.mp4"), + ) + + def test_png_orientation_mismatch_ignored(self) -> None: + update = MetadataUpdate(orientation=1) + existing: dict[str, Any] = {} + self.assertFalse( + _needs_update(update, {"orientation"}, existing, file_path="2025-01/screenshot.PNG"), + ) + + def test_heic_orientation_mismatch_triggers(self) -> None: + update = MetadataUpdate(orientation=3) + existing = {"Orientation": 6} + self.assertTrue( + _needs_update(update, {"orientation"}, existing, file_path="2025-09/IMG_9951.HEIC"), + "Orientation mismatch on HEIC should trigger update", + ) + + def test_jpeg_orientation_match_skips(self) -> None: + update = MetadataUpdate(orientation=1) + existing = {"Orientation": 1} + self.assertFalse( + _needs_update(update, {"orientation"}, existing, file_path="2022-03/IMG_3412.JPG"), + ) + + def test_mov_with_other_changes_still_triggers(self) -> None: + update = MetadataUpdate(orientation=1, rating=5) + existing: dict[str, Any] = {} # No rating yet + self.assertTrue( + _needs_update(update, {"orientation", "rating"}, existing, file_path="2026-01/IMG_0993_HEVC.MOV"), + "MOV with missing rating should still trigger update", + ) + + +class TestMetadataMatchesManifest(TestCase): + """Tests for _metadata_matches_manifest — the mtime skip comparison.""" + + def _make_row(self, **overrides: Any) -> Any: + from icloudpd.manifest import ManifestRow + defaults: dict[str, Any] = dict( + asset_id="test", zone_id="zone", asset_resource="resOriginal", + local_path="2026-01/IMG_0001.HEIC", version_size=1000, + version_checksum=None, change_tag=None, downloaded_at="2026-01-01", + last_updated_at="2026-01-01", item_type="public.heic", + filename="IMG_0001.HEIC", asset_date=None, added_date=None, + is_favorite=0, is_hidden=0, is_deleted=0, + original_width=None, original_height=None, duration=None, + orientation=1, title=None, description=None, keywords=None, + gps_latitude=None, gps_longitude=None, gps_altitude=None, + gps_speed=None, gps_timestamp=None, timezone_offset=None, + asset_subtype=None, hdr_type=None, burst_flags=None, + burst_flags_ext=None, burst_id=None, original_orientation=None, + raw_fields=None, file_mtime=1234567890.0, + ) + defaults.update(overrides) + return ManifestRow(**defaults) + + def test_all_match(self) -> None: + from icloudpd.base import _metadata_matches_manifest + row = self._make_row(is_favorite=1, gps_latitude=-33.8, gps_longitude=151.2, gps_altitude=50.0) + update = MetadataUpdate(rating=5, gps_latitude=-33.8, gps_longitude=151.2, gps_altitude=50.0) + self.assertTrue(_metadata_matches_manifest(row, update)) + + def test_rating_mismatch(self) -> None: + from icloudpd.base import _metadata_matches_manifest + row = self._make_row(is_favorite=0) + update = MetadataUpdate(rating=5) + self.assertFalse(_metadata_matches_manifest(row, update)) + + def test_keywords_match(self) -> None: + from icloudpd.base import _metadata_matches_manifest + row = self._make_row(keywords='["alpha", "beta"]') + update = MetadataUpdate(keywords=["beta", "alpha"]) # order shouldn't matter + self.assertTrue(_metadata_matches_manifest(row, update)) + + def test_keywords_mismatch(self) -> None: + from icloudpd.base import _metadata_matches_manifest + row = self._make_row(keywords='["alpha"]') + update = MetadataUpdate(keywords=["alpha", "beta"]) + self.assertFalse(_metadata_matches_manifest(row, update)) + + def test_keywords_added_from_none(self) -> None: + from icloudpd.base import _metadata_matches_manifest + row = self._make_row(keywords=None) + update = MetadataUpdate(keywords=["new_keyword"]) + self.assertFalse(_metadata_matches_manifest(row, update)) + + def test_keywords_none_in_update_skips_check(self) -> None: + from icloudpd.base import _metadata_matches_manifest + row = self._make_row(keywords='["existing"]') + update = MetadataUpdate(keywords=None) + self.assertTrue(_metadata_matches_manifest(row, update), "keywords=None means not relevant, not removed") + + def test_altitude_match(self) -> None: + from icloudpd.base import _metadata_matches_manifest + row = self._make_row(gps_altitude=-6.035) + update = MetadataUpdate(gps_altitude=-6.035) + self.assertTrue(_metadata_matches_manifest(row, update)) + + def test_altitude_mismatch(self) -> None: + from icloudpd.base import _metadata_matches_manifest + row = self._make_row(gps_altitude=50.0) + update = MetadataUpdate(gps_altitude=55.0) + self.assertFalse(_metadata_matches_manifest(row, update)) + + def test_altitude_added(self) -> None: + from icloudpd.base import _metadata_matches_manifest + row = self._make_row(gps_altitude=None) + update = MetadataUpdate(gps_altitude=10.0) + self.assertFalse(_metadata_matches_manifest(row, update)) + + def test_description_mismatch(self) -> None: + from icloudpd.base import _metadata_matches_manifest + row = self._make_row(description="old") + update = MetadataUpdate(description="new") + self.assertFalse(_metadata_matches_manifest(row, update)) + + def test_empty_update_matches_anything(self) -> None: + from icloudpd.base import _metadata_matches_manifest + row = self._make_row(is_favorite=1) + update = MetadataUpdate() + self.assertTrue(_metadata_matches_manifest(row, update)) diff --git a/tests/test_xmp_sidecar.py b/tests/test_xmp_sidecar.py index a1438ee09..dd3cf99d0 100644 --- a/tests/test_xmp_sidecar.py +++ b/tests/test_xmp_sidecar.py @@ -1,3 +1,4 @@ +import logging from datetime import datetime from typing import Any, Dict from unittest import TestCase @@ -5,6 +6,8 @@ from foundation import version_info from icloudpd.xmp_sidecar import XMPMetadata, build_metadata +_test_logger = logging.getLogger("test_xmp_sidecar") + class BuildXMPMetadata(TestCase): def test_build_metadata(self) -> None: @@ -33,7 +36,7 @@ def test_build_metadata(self) -> None: } # Test full metadata record - metadata: XMPMetadata = build_metadata(assetRecordStub) + metadata: XMPMetadata = build_metadata(_test_logger, assetRecordStub) self.assertCountEqual( metadata, XMPMetadata( @@ -48,6 +51,7 @@ def test_build_metadata(self) -> None: GPSLatitude=18.82285, GPSLongitude=98.96340333333333, GPSSpeed=0.0, + GPSHPositioningError=None, GPSTimeStamp=datetime.strptime( "2001:01:01 00:00:00.000000+00:00", "%Y:%m:%d %H:%M:%S.%f%z" ).replace(tzinfo=None), @@ -64,41 +68,41 @@ def test_build_metadata(self) -> None: assetRecordStub["fields"]["adjustmentSimpleDataEnc"]["value"] = ( "YnBsaXN0MDDRAQJac2xvd01vdGlvbtIDBAUWV3JlZ2lvbnNUcmF0ZaEG0QcIWXRpbWVSYW5nZdIJCgsUVXN0YXJ0WGR1cmF0aW9u1AwNDg8QERITVWZsYWdzVXZhbHVlWXRpbWVzY2FsZVVlcG9jaBABER8cEQJYEADUDA0ODxAVEhMRBAQiPoAAAAgLFhsjKCotNzxCS1RaYGpwcnV4eoOGAAAAAAAAAQEAAAAAAAAAFwAAAAAAAAAAAAAAAAAAAIs=" ) - metadata = build_metadata(assetRecordStub) + metadata = build_metadata(_test_logger, assetRecordStub) assert metadata.Orientation is None # - a CRDT (Conflict-free Replicated Data Types) - starting with 'crdt', example is taken from a photo with a drawing on top assetRecordStub["fields"]["adjustmentSimpleDataEnc"]["value"] = ( "Y3JkdAYAAAAaFSoTEhECnYDNABO9TpuITgZaa+E95CKEARoLCgEAEgYSBAIBAgEiYCJeCgIAARIfCh0KAggBEhdCFSoTEhECgrz/kYHVT/ad17NllB+B7xI3CjUKBAgCEAISLXIrCgMAAgMSBwjgma/0qQQSAigAEhdCFSoTEhECaEf6ANgrS7mCjBkrdfAiPioTEhECiV4RTbgNT7O2wBIqFvHhkCKiCBoLCgEAEgYSBAIBAgEi/QdS+gcK9wcKCQoBAxIEEgIAOxLpBwoQ0ryRHvwLS8a0khdy8ERO+xE/kID5BwnGQRg7IAco+AcyEOgDAAAAAA86AAD/fwAAgD86sAeL70BE2qkfRAAAAADpEEtAyiY9RFx7IETUR4A96RBLQA3JO0TtzCBEidCIPekQS0CriTpEHuggRFdbkT3pEEtA2GY5RC7xIESs5pk96RBLQPlfOEQz9CBE52+iPekQS0ACvjZEi/UgRF6Csz3pEEtA8h81RLb1IEQ2ksQ96RBLQLPDM0S29SBEiKHVPekQS0ABSzJEJgghRIW05j3pEEtAlrMwRFKcIUSJJgA+6RBLQMWmL0RCCiJEZLIIPukQS0BZbS5E4XgiRIY5ET7pEEtAXUYtRH/nIkQjwBk+6RBLQKsrLESuQyNEG0kiPukQS0D15SpEvMQjRErRKj7pEEtAf1YpRFszJERbXDM+6RBLQGK5J0T+zCREEeU7PukQS0B1DiZE+lglRAluRD7pEEtACAklRMepJURXsUg+6RBLQAz8I0TV7SVEpfRMPukQS0B/5yJEBEomRAA5UT7pEEtAg8AhRA6gJkSefVU+6RBLQPKAIEQL9CZEBcNZPukQS0A8Ox9EsTknRGwIXj7pEEtAEbgdROCVJ0THTGI+Bb9LQHYiHER52SdEZJFmPsOFTEDWYRpE4hQoRDnVaj6GUE1AoogYRD1OKEQNGW8+4h5OQIlfFkTcvChEaF1zPon0TkBwNhREeispRAWidz7+BVBA6PoRRBmaKUQR5Xs+VeBQQPCsD0S3CCpELxSAPq+6UUD3Xg1ExokqRF02gj55klJAZc0KRPkQK0SsWIQ+WVlTQBkdCETiiytEt3qGPp0QVEAzKQVE0DEsROWciD4yuVRAcjsCRE7FLESYv4o+oP1UQLxV/kOnUi1EbeKMPpcGVUCDB/hDSuwtRHkEjz5JH1VAWWfxQ4jJLkSmJpE+fyxVQCVx6kPFpi9E1EiTPpAtVUBEXuNDAoQwRCNrlT5wMVVA31/cQz9hMURQjZc+fzJVQBdR1UMMLDJEfa+ZProwVUAENs5DtPAyRCTRmz4DMVVAl2DHQ1CzM0Tt8p0+bSNVQDy4wEOVZzREoBWgPp8rVUCQq7pDQlc1RFQ4oj4dKFVAQle1QxRNNkSBWqQ+GSZVQCeKsEMTIDdEr3ymPtYdVUCB+qtDidw3RLqeqD7AD1VAzjuoQ4UDOUTGwKo+6vRUQGWJpEMXQzpE8+KsPuPNVEA1tqBD2Yo7RCEFrz6ai1RA1NqcQ6nUPEROJ7E+rhtUQL+KmUPUVz5Ee0mzPpqZU0BW2JVDj8g/RCNrtT42AFNAWA2SQ9omQUTKjLc+qGRSQEABKhMSEQJoR/oA2CtLuYKMGSt18CI+ItQBGgkKAQASBBICAAQisQEirgEKBQIDBAUGEiwKKgoECAQQARIiIiAAAAAAAAAAAAAAAAAAAAAAQI+ZCloW8SxAkAAAAAAAABIsCioKBAgFEAESIiIgAAAAAAAAAAAAAAAAAAAAAECPmQpaFvEsQJAAAAAAAAASDAoKCgQIBhACEgJKABIMCgoKBAgHEAESAkoAEi0KKwoECAgQARIjSiEKH0IdKhsSGQMRAp2AzQATvU6biE4GWmvhPeQFAWRyYXcqExIRAp2AzQATvU6biE4GWmvhPeQi2QEaCwoBABIGEgQCAQECIqwBIqkBCgIHCBIqCigKAggJEiIiIH/wAAAAAAAAf/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEndKdQozCA0SCQoBDhIEEgIAASIkChdCFSoTEhEChdKPQMo2Su6q+Q5yrvvmoBoJCgEOEgQSAgABEjoaBwoCCAoiAQEaDQoCCAsQARoCCAwiAQIaCgoICAoQ/////w8iCQoBCxIEEgIAASoJCgEMEgQSAgABGgIIDyobEhkDEQKdgM0AE71Om4hOBlpr4T3kBQFkcmF3IkcaCwoBABIGEgQCAQIBIiMKIQoCCBASG2IZEhdCFSoTEhECiV4RTbgNT7O2wBIqFvHhkCoTEhEChdKPQMo2Su6q+Q5yrvvmoCJmGgsKAQASBhIEAgECASJCIkAKAQkSOwo5CgQIERABEjFKLwotIisKFA0AAAAAFQAAAAAdAAAAACUAAIA/EhFjb20uYXBwbGUuaW5rLnBlbhgDKhMSEQKCvP+RgdVP9p3Xs2WUH4HvKgkKAQASBBICAAYyggMKoALmybEv6uEDVQxTrfvgMWIVsCFjZZ2lQZ+x2yTm85hV+STyQCD9kEnknCJoYtV7NmjZU0BI6NJGdYGAjoIt0BrUxDAouVn8RpC6rJ2aHbYvnPOXuLB8O0evtafy5BPmEKqBPYFbeLtAL6sy/VaI/pGPvuaEAo2VRyyxYUYnUR0zxP7ZGFh1Mk5an7WtHgpEeGJqj4MwLEdE1JzSZIDppWyjAAAAAAAAAAAAAAAAAAAAAPktjh87HwwaNIJe5zMyP5niLsYuqUxKb51SyH3L9ng612w3UtYaSQuTMgHQuBHkgfswPjwu9wF7A3f7C5dd2jkUJK0XqfJJTpD9NImCV8gdF5qES7KYRAW6UHcF4Nk6czh+OLdJJkjYgm2s0VilJfASCWluaGVyaXRlZBIKcHJvcGVydGllcxIGYm91bmRzEgVmcmFtZRIFaW1hZ2USC2Rlc2NyaXB0aW9uEgdkcmF3aW5nEgxjYW52YXNCb3VuZHMSB3N0cm9rZXMSA2luaw==" ) - metadata = build_metadata(assetRecordStub) + metadata = build_metadata(_test_logger, assetRecordStub) assert metadata.Orientation is None # Test Screenshot Tagging assetRecordStub["fields"]["assetSubtypeV2"]["value"] = 3 - metadata = build_metadata(assetRecordStub) + metadata = build_metadata(_test_logger, assetRecordStub) assert metadata.Make == "Screenshot" assert metadata.DigitalSourceType == "screenCapture" # Test Favorites assetRecordStub["fields"]["isFavorite"]["value"] = 1 - metadata = build_metadata(assetRecordStub) + metadata = build_metadata(_test_logger, assetRecordStub) assert metadata.Rating == 5 # Test favorites not present del assetRecordStub["fields"]["isFavorite"] - metadata = build_metadata(assetRecordStub) + metadata = build_metadata(_test_logger, assetRecordStub) assert metadata.Rating is None # Test Deleted assetRecordStub["fields"]["isDeleted"]["value"] = 1 - metadata = build_metadata(assetRecordStub) + metadata = build_metadata(_test_logger, assetRecordStub) assert metadata.Rating == -1 # Test Hidden assetRecordStub["fields"]["isDeleted"]["value"] = 0 assetRecordStub["fields"]["isHidden"]["value"] = 1 - metadata = build_metadata(assetRecordStub) + metadata = build_metadata(_test_logger, assetRecordStub) assert metadata.Rating == -1 # Test locationEnc in xml format, not binary format @@ -106,7 +110,7 @@ def test_build_metadata(self) -> None: assetRecordStub["fields"]["locationEnc"]["value"] = ( "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9InllcyI/Pgo8IURPQ1RZUEUgcGxpc3QgUFVCTElDICItLy9BcHBsZS8vRFREIFBMSVNUIDEuMC8vRU4iICJodHRwOi8vd3d3LmFwcGxlLmNvbS9EVERzL1Byb3BlcnR5TGlzdC0xLjAuZHRkIj4KPHBsaXN0IHZlcnNpb249IjEuMCI+Cgk8ZGljdD4KCQk8a2V5PnZlcnRBY2M8L2tleT4KCQk8cmVhbD4wLjA8L3JlYWw+CgoJCTxrZXk+YWx0PC9rZXk+CgkJPHJlYWw+MC4wPC9yZWFsPgoKCQk8a2V5Pmxvbjwva2V5PgoJCTxyZWFsPi0xMjIuODgxNTY2NjY2NjY2NjY8L3JlYWw+CgoJCTxrZXk+bGF0PC9rZXk+CgkJPHJlYWw+NTAuMDk0MTgzMzMzMzMzMzM8L3JlYWw+CgoJCTxrZXk+dGltZXN0YW1wPC9rZXk+CgkJPGRhdGU+MjAyMC0wMi0yOVQxODozNTo0OVo8L2RhdGU+CgoJPC9kaWN0Pgo8L3BsaXN0Pg==" ) - metadata = build_metadata(assetRecordStub) + metadata = build_metadata(_test_logger, assetRecordStub) assert ( metadata.GPSAltitude, metadata.GPSLongitude, @@ -118,3 +122,183 @@ def test_build_metadata(self) -> None: 50.09418333333333, datetime.fromisoformat("2020-02-29T18:35:49"), ) + + +def _minimal_asset_record(**field_overrides: Any) -> Dict[str, Any]: + """Build a minimal asset record with only the specified encoded fields.""" + fields: Dict[str, Any] = { + "assetDate": {"value": 1532951050176, "type": "TIMESTAMP"}, + "isHidden": {"value": 0, "type": "INT64"}, + "isDeleted": {"value": 0, "type": "INT64"}, + "isFavorite": {"value": 0, "type": "INT64"}, + } + fields.update(field_overrides) + return {"fields": fields} + + +class BuildXMPMetadataErrorPaths(TestCase): + """Test error handling for corrupt/empty encoded fields.""" + + def test_empty_caption_value_returns_none_title(self) -> None: + record = _minimal_asset_record(captionEnc={"value": "", "type": "ENCRYPTED_BYTES"}) + metadata = build_metadata(_test_logger, record) + self.assertIsNone(metadata.Title) + + def test_missing_caption_value_key_returns_none_title(self) -> None: + record = _minimal_asset_record(captionEnc={"type": "ENCRYPTED_BYTES"}) + metadata = build_metadata(_test_logger, record) + self.assertIsNone(metadata.Title) + + def test_corrupt_caption_base64_returns_none_title(self) -> None: + record = _minimal_asset_record( + captionEnc={"value": "!!!not-base64!!!", "type": "ENCRYPTED_BYTES"} + ) + metadata = build_metadata(_test_logger, record) + self.assertIsNone(metadata.Title) + + def test_empty_description_value_returns_none(self) -> None: + record = _minimal_asset_record(extendedDescEnc={"value": "", "type": "ENCRYPTED_BYTES"}) + metadata = build_metadata(_test_logger, record) + self.assertIsNone(metadata.Description) + + def test_missing_description_value_key_returns_none(self) -> None: + record = _minimal_asset_record(extendedDescEnc={"type": "ENCRYPTED_BYTES"}) + metadata = build_metadata(_test_logger, record) + self.assertIsNone(metadata.Description) + + def test_corrupt_description_base64_returns_none(self) -> None: + record = _minimal_asset_record( + extendedDescEnc={"value": "!!!not-base64!!!", "type": "ENCRYPTED_BYTES"} + ) + metadata = build_metadata(_test_logger, record) + self.assertIsNone(metadata.Description) + + def test_empty_keywords_value_returns_none(self) -> None: + record = _minimal_asset_record(keywordsEnc={"value": "", "type": "ENCRYPTED_BYTES"}) + metadata = build_metadata(_test_logger, record) + self.assertIsNone(metadata.Keywords) + + def test_corrupt_keywords_base64_returns_none(self) -> None: + record = _minimal_asset_record( + keywordsEnc={"value": "!!!not-base64!!!", "type": "ENCRYPTED_BYTES"} + ) + metadata = build_metadata(_test_logger, record) + self.assertIsNone(metadata.Keywords) + + def test_empty_location_value_returns_none_gps(self) -> None: + record = _minimal_asset_record(locationEnc={"value": "", "type": "ENCRYPTED_BYTES"}) + metadata = build_metadata(_test_logger, record) + self.assertIsNone(metadata.GPSLatitude) + self.assertIsNone(metadata.GPSLongitude) + self.assertIsNone(metadata.GPSAltitude) + + def test_corrupt_location_base64_returns_none_gps(self) -> None: + record = _minimal_asset_record( + locationEnc={"value": "!!!not-base64!!!", "type": "ENCRYPTED_BYTES"} + ) + metadata = build_metadata(_test_logger, record) + self.assertIsNone(metadata.GPSLatitude) + self.assertIsNone(metadata.GPSLongitude) + + def test_location_decoding_to_list_returns_none_gps(self) -> None: + """Some locationEnc values decode to a plist array instead of dict.""" + import base64 + import plistlib + + # Encode a plist list (not dict) as the location value + list_plist = plistlib.dumps(["not", "a", "dict"]) + record = _minimal_asset_record( + locationEnc={"value": base64.b64encode(list_plist).decode(), "type": "ENCRYPTED_BYTES"} + ) + metadata = build_metadata(_test_logger, record) + # Should not crash; GPS fields should be None since list has no .get() + self.assertIsNone(metadata.GPSLatitude) + self.assertIsNone(metadata.GPSLongitude) + + +class TestGenerateXmpFile(TestCase): + """Test generate_xmp_file() with dir_cache integration.""" + + def setUp(self) -> None: + import tempfile + self._tmpdir = tempfile.mkdtemp() + + def tearDown(self) -> None: + import shutil + shutil.rmtree(self._tmpdir) + + def test_generates_xmp_sidecar_with_dir_cache(self) -> None: + import os + + from icloudpd.dir_cache import DirCache + from icloudpd.xmp_sidecar import generate_xmp_file + + photo_path = os.path.join(self._tmpdir, "IMG_0001.JPG") + with open(photo_path, "wb") as f: + f.write(b"\x00" * 100) + + asset_record: Dict[str, Any] = { + "fields": { + "assetDate": {"value": 1532951050176, "type": "TIMESTAMP"}, + "isHidden": {"value": 0, "type": "INT64"}, + "isDeleted": {"value": 0, "type": "INT64"}, + "isFavorite": {"value": 1, "type": "INT64"}, + } + } + + dir_cache = DirCache() + generate_xmp_file(_test_logger, photo_path, asset_record, False, dir_cache) + + xmp_path = photo_path + ".xmp" + self.assertTrue(os.path.isfile(xmp_path), "XMP sidecar should be created") + self.assertTrue(dir_cache.exists(xmp_path), "dir_cache should track the XMP file") + + def test_does_not_overwrite_non_icloudpd_xmp(self) -> None: + import os + + from icloudpd.dir_cache import DirCache + from icloudpd.xmp_sidecar import generate_xmp_file + + photo_path = os.path.join(self._tmpdir, "IMG_0002.JPG") + with open(photo_path, "wb") as f: + f.write(b"\x00" * 100) + + xmp_path = photo_path + ".xmp" + with open(xmp_path, "w") as f: + f.write('') + + asset_record: Dict[str, Any] = { + "fields": { + "assetDate": {"value": 1532951050176, "type": "TIMESTAMP"}, + } + } + + dir_cache = DirCache() + generate_xmp_file(_test_logger, photo_path, asset_record, False, dir_cache) + + with open(xmp_path) as f: + content = f.read() + self.assertIn("Adobe Lightroom", content) + + def test_dry_run_does_not_create_xmp(self) -> None: + import os + + from icloudpd.dir_cache import DirCache + from icloudpd.xmp_sidecar import generate_xmp_file + + photo_path = os.path.join(self._tmpdir, "IMG_0003.JPG") + with open(photo_path, "wb") as f: + f.write(b"\x00" * 100) + + asset_record: Dict[str, Any] = { + "fields": { + "assetDate": {"value": 1532951050176, "type": "TIMESTAMP"}, + } + } + + dir_cache = DirCache() + generate_xmp_file(_test_logger, photo_path, asset_record, True, dir_cache) + + xmp_path = photo_path + ".xmp" + self.assertFalse(os.path.isfile(xmp_path), "Dry run should not create XMP") diff --git a/uv.lock b/uv.lock new file mode 100644 index 000000000..ea592d4b2 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1969 @@ +version = 1 +revision = 3 +requires-python = ">=3.10, <3.14" +resolution-markers = [ + "platform_python_implementation != 'PyPy'", + "platform_python_implementation == 'PyPy'", +] + +[[package]] +name = "accessible-pygments" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872", size = 1377899, upload-time = "2024-05-10T11:23:10.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z" }, +] + +[[package]] +name = "alabaster" +version = "0.7.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/3e/13dd8e5ed9094e734ac430b5d0eb4f2bb001708a8b7856cbf8e084e001ba/alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65", size = 23776, upload-time = "2024-01-10T00:56:10.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/34/d4e1c02d3bee589efb5dfa17f88ea08bdb3e3eac12bc475462aec52ed223/alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92", size = 13511, upload-time = "2024-01-10T00:56:08.388Z" }, +] + +[[package]] +name = "altgraph" +version = "0.17.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "auditwheel" +version = "6.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pyelftools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/ed/342df5a75589103d72402dcdd88d7b8cc2df338fbc39042ce1b3ac008960/auditwheel-6.6.0.tar.gz", hash = "sha256:277f3b315ad0b04df0a2be2d126c3fd39930bc265df0f9589d78c970ff06f52b", size = 4663481, upload-time = "2026-01-04T14:34:21.577Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/c2/bb8ba1cec816fa356e19da83c59bfd4ac34dba1ae41eb7622c18f4b1d48d/auditwheel-6.6.0-py3-none-any.whl", hash = "sha256:3e8479856ca582625b5457204cbb62d1614d3bc7e760ee307c04c34c912ebcad", size = 59184, upload-time = "2026-01-04T14:34:19.932Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + +[[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 = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy' and platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/8c/2c56124c6dc53a774d435f985b5973bc592f42d437be58c0c92d65ae7296/charset_normalizer-3.4.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95", size = 298751, upload-time = "2026-03-15T18:50:00.003Z" }, + { url = "https://files.pythonhosted.org/packages/86/2a/2a7db6b314b966a3bcad8c731c0719c60b931b931de7ae9f34b2839289ee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd", size = 200027, upload-time = "2026-03-15T18:50:01.702Z" }, + { url = "https://files.pythonhosted.org/packages/68/f2/0fe775c74ae25e2a3b07b01538fc162737b3e3f795bada3bc26f4d4d495c/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4", size = 220741, upload-time = "2026-03-15T18:50:03.194Z" }, + { url = "https://files.pythonhosted.org/packages/10/98/8085596e41f00b27dd6aa1e68413d1ddda7e605f34dd546833c61fddd709/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db", size = 215802, upload-time = "2026-03-15T18:50:05.859Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ce/865e4e09b041bad659d682bbd98b47fb490b8e124f9398c9448065f64fee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89", size = 207908, upload-time = "2026-03-15T18:50:07.676Z" }, + { url = "https://files.pythonhosted.org/packages/a8/54/8c757f1f7349262898c2f169e0d562b39dcb977503f18fdf0814e923db78/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565", size = 194357, upload-time = "2026-03-15T18:50:09.327Z" }, + { url = "https://files.pythonhosted.org/packages/6f/29/e88f2fac9218907fc7a70722b393d1bbe8334c61fe9c46640dba349b6e66/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9", size = 205610, upload-time = "2026-03-15T18:50:10.732Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c5/21d7bb0cb415287178450171d130bed9d664211fdd59731ed2c34267b07d/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7", size = 203512, upload-time = "2026-03-15T18:50:12.535Z" }, + { url = "https://files.pythonhosted.org/packages/a4/be/ce52f3c7fdb35cc987ad38a53ebcef52eec498f4fb6c66ecfe62cfe57ba2/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550", size = 195398, upload-time = "2026-03-15T18:50:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/81/a0/3ab5dd39d4859a3555e5dadfc8a9fa7f8352f8c183d1a65c90264517da0e/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0", size = 221772, upload-time = "2026-03-15T18:50:15.581Z" }, + { url = "https://files.pythonhosted.org/packages/04/6e/6a4e41a97ba6b2fa87f849c41e4d229449a586be85053c4d90135fe82d26/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8", size = 205759, upload-time = "2026-03-15T18:50:17.047Z" }, + { url = "https://files.pythonhosted.org/packages/db/3b/34a712a5ee64a6957bf355b01dc17b12de457638d436fdb05d01e463cd1c/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0", size = 216938, upload-time = "2026-03-15T18:50:18.44Z" }, + { url = "https://files.pythonhosted.org/packages/cb/05/5bd1e12da9ab18790af05c61aafd01a60f489778179b621ac2a305243c62/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b", size = 210138, upload-time = "2026-03-15T18:50:19.852Z" }, + { url = "https://files.pythonhosted.org/packages/bd/8e/3cb9e2d998ff6b21c0a1860343cb7b83eba9cdb66b91410e18fc4969d6ab/charset_normalizer-3.4.6-cp310-cp310-win32.whl", hash = "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557", size = 144137, upload-time = "2026-03-15T18:50:21.505Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8f/78f5489ffadb0db3eb7aff53d31c24531d33eb545f0c6f6567c25f49a5ff/charset_normalizer-3.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6", size = 154244, upload-time = "2026-03-15T18:50:22.81Z" }, + { url = "https://files.pythonhosted.org/packages/e4/74/e472659dffb0cadb2f411282d2d76c60da1fc94076d7fffed4ae8a93ec01/charset_normalizer-3.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058", size = 143312, upload-time = "2026-03-15T18:50:24.074Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/ff6f234e628a2de61c458be2779cb182bc03f6eec12200d4a525bbfc9741/charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e", size = 293582, upload-time = "2026-03-15T18:50:25.454Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b7/b1a117e5385cbdb3205f6055403c2a2a220c5ea80b8716c324eaf75c5c95/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9", size = 197240, upload-time = "2026-03-15T18:50:27.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5f/2574f0f09f3c3bc1b2f992e20bce6546cb1f17e111c5be07308dc5427956/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d", size = 217363, upload-time = "2026-03-15T18:50:28.601Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d1/0ae20ad77bc949ddd39b51bf383b6ca932f2916074c95cad34ae465ab71f/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de", size = 212994, upload-time = "2026-03-15T18:50:30.102Z" }, + { url = "https://files.pythonhosted.org/packages/60/ac/3233d262a310c1b12633536a07cde5ddd16985e6e7e238e9f3f9423d8eb9/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73", size = 204697, upload-time = "2026-03-15T18:50:31.654Z" }, + { url = "https://files.pythonhosted.org/packages/25/3c/8a18fc411f085b82303cfb7154eed5bd49c77035eb7608d049468b53f87c/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c", size = 191673, upload-time = "2026-03-15T18:50:33.433Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a7/11cfe61d6c5c5c7438d6ba40919d0306ed83c9ab957f3d4da2277ff67836/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc", size = 201120, upload-time = "2026-03-15T18:50:35.105Z" }, + { url = "https://files.pythonhosted.org/packages/b5/10/cf491fa1abd47c02f69687046b896c950b92b6cd7337a27e6548adbec8e4/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f", size = 200911, upload-time = "2026-03-15T18:50:36.819Z" }, + { url = "https://files.pythonhosted.org/packages/28/70/039796160b48b18ed466fde0af84c1b090c4e288fae26cd674ad04a2d703/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef", size = 192516, upload-time = "2026-03-15T18:50:38.228Z" }, + { url = "https://files.pythonhosted.org/packages/ff/34/c56f3223393d6ff3124b9e78f7de738047c2d6bc40a4f16ac0c9d7a1cb3c/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398", size = 218795, upload-time = "2026-03-15T18:50:39.664Z" }, + { url = "https://files.pythonhosted.org/packages/e8/3b/ce2d4f86c5282191a041fdc5a4ce18f1c6bd40a5bd1f74cf8625f08d51c1/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e", size = 201833, upload-time = "2026-03-15T18:50:41.552Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9b/b6a9f76b0fd7c5b5ec58b228ff7e85095370282150f0bd50b3126f5506d6/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed", size = 213920, upload-time = "2026-03-15T18:50:43.33Z" }, + { url = "https://files.pythonhosted.org/packages/ae/98/7bc23513a33d8172365ed30ee3a3b3fe1ece14a395e5fc94129541fc6003/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021", size = 206951, upload-time = "2026-03-15T18:50:44.789Z" }, + { url = "https://files.pythonhosted.org/packages/32/73/c0b86f3d1458468e11aec870e6b3feac931facbe105a894b552b0e518e79/charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e", size = 143703, upload-time = "2026-03-15T18:50:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/c6/e3/76f2facfe8eddee0bbd38d2594e709033338eae44ebf1738bcefe0a06185/charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4", size = 153857, upload-time = "2026-03-15T18:50:47.563Z" }, + { url = "https://files.pythonhosted.org/packages/e2/dc/9abe19c9b27e6cd3636036b9d1b387b78c40dedbf0b47f9366737684b4b0/charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316", size = 142751, upload-time = "2026-03-15T18:50:49.234Z" }, + { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, + { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, + { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, + { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, + { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, + { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, + { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, + { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, + { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, + { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, + { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, + { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, + { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, + { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, + { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, + { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, + { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, + { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, + { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[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 = "coverage" +version = "7.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/d4/7827d9ffa34d5d4d752eec907022aa417120936282fc488306f5da08c292/coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415", size = 219152, upload-time = "2026-02-09T12:56:11.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b0/d69df26607c64043292644dbb9dc54b0856fabaa2cbb1eeee3331cc9e280/coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b", size = 219667, upload-time = "2026-02-09T12:56:13.33Z" }, + { url = "https://files.pythonhosted.org/packages/82/a4/c1523f7c9e47b2271dbf8c2a097e7a1f89ef0d66f5840bb59b7e8814157b/coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a", size = 246425, upload-time = "2026-02-09T12:56:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/f8/02/aa7ec01d1a5023c4b680ab7257f9bfde9defe8fdddfe40be096ac19e8177/coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f", size = 248229, upload-time = "2026-02-09T12:56:16.31Z" }, + { url = "https://files.pythonhosted.org/packages/35/98/85aba0aed5126d896162087ef3f0e789a225697245256fc6181b95f47207/coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012", size = 250106, upload-time = "2026-02-09T12:56:18.024Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1db59bd67494bc162e3e4cd5fbc7edba2c7026b22f7c8ef1496d58c2b94c/coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def", size = 252021, upload-time = "2026-02-09T12:56:19.272Z" }, + { url = "https://files.pythonhosted.org/packages/9d/97/72899c59c7066961de6e3daa142d459d47d104956db43e057e034f015c8a/coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256", size = 247114, upload-time = "2026-02-09T12:56:21.051Z" }, + { url = "https://files.pythonhosted.org/packages/39/1f/f1885573b5970235e908da4389176936c8933e86cb316b9620aab1585fa2/coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda", size = 248143, upload-time = "2026-02-09T12:56:22.585Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/e80390c5b7480b722fa3e994f8202807799b85bc562aa4f1dde209fbb7be/coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92", size = 246152, upload-time = "2026-02-09T12:56:23.748Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/f89a8350d85572f95412debb0fb9bb4795b1d5b5232bd652923c759e787b/coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c", size = 249959, upload-time = "2026-02-09T12:56:25.209Z" }, + { url = "https://files.pythonhosted.org/packages/f7/6e/612a02aece8178c818df273e8d1642190c4875402ca2ba74514394b27aba/coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58", size = 246416, upload-time = "2026-02-09T12:56:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/b5afc39af67c2fa6786b03c3a7091fc300947387ce8914b096db8a73d67a/coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9", size = 247025, upload-time = "2026-02-09T12:56:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/51/30/2bba8ef0682d5bd210c38fe497e12a06c9f8d663f7025e9f5c2c31ce847d/coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf", size = 221758, upload-time = "2026-02-09T12:56:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/331f94934cf6c092b8ea59ff868eb587bc8fe0893f02c55bc6c0183a192e/coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95", size = 222693, upload-time = "2026-02-09T12:56:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, + { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, + { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, + { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, + { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, + { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, + { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, + { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, + { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, + { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, + { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, + { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, + { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, + { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, +] + +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "freezegun" +version = "1.5.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" }, +] + +[[package]] +name = "furo" +version = "2025.12.19" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "accessible-pygments" }, + { name = "beautifulsoup4" }, + { name = "pygments" }, + { name = "sphinx" }, + { name = "sphinx-basic-ng" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/20/5f5ad4da6a5a27c80f2ed2ee9aee3f9e36c66e56e21c00fde467b2f8f88f/furo-2025.12.19.tar.gz", hash = "sha256:188d1f942037d8b37cd3985b955839fea62baa1730087dc29d157677c857e2a7", size = 1661473, upload-time = "2025-12-19T17:34:40.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/b2/50e9b292b5cac13e9e81272c7171301abc753a60460d21505b606e15cf21/furo-2025.12.19-py3-none-any.whl", hash = "sha256:bb0ead5309f9500130665a26bee87693c41ce4dbdff864dbfb6b0dae4673d24f", size = 339262, upload-time = "2025-12-19T17:34:38.905Z" }, +] + +[[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 = "icloudpd" +version = "1.32.2" +source = { editable = "." } +dependencies = [ + { name = "certifi" }, + { name = "flask" }, + { name = "keyring" }, + { name = "keyrings-alt" }, + { name = "piexif" }, + { name = "pytz" }, + { name = "requests" }, + { name = "schema" }, + { name = "srp" }, + { name = "tqdm" }, + { name = "typing-extensions" }, + { name = "tzlocal" }, + { name = "urllib3" }, + { name = "waitress" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyinstaller" }, + { name = "twine" }, + { name = "wheel" }, +] +devlinux = [ + { name = "auditwheel" }, + { name = "scons" }, +] +doc = [ + { name = "furo" }, + { name = "myst-parser" }, + { name = "sphinx" }, + { name = "sphinx-autobuild" }, +] +test = [ + { name = "freezegun" }, + { name = "mock" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-timeout" }, + { name = "pytest-xdist" }, + { name = "ruff" }, + { name = "types-mock" }, + { name = "types-pytz" }, + { name = "types-requests" }, + { name = "types-tqdm" }, + { name = "types-tzlocal" }, + { name = "types-urllib3" }, + { name = "types-waitress" }, + { name = "vcrpy" }, +] + +[package.metadata] +requires-dist = [ + { name = "certifi", specifier = "==2026.2.25" }, + { name = "flask", specifier = "==3.1.3" }, + { name = "keyring", specifier = "==25.7.0" }, + { name = "keyrings-alt", specifier = "==5.0.2" }, + { name = "piexif", specifier = "==1.1.3" }, + { name = "pytz", specifier = "==2026.1.post1" }, + { name = "requests", specifier = "==2.33.1" }, + { name = "schema", specifier = "==0.7.8" }, + { name = "srp", specifier = "==1.0.22" }, + { name = "tqdm", specifier = "==4.67.3" }, + { name = "typing-extensions", specifier = "==4.15.0" }, + { name = "tzlocal", specifier = "==5.3.1" }, + { name = "urllib3", specifier = "==1.26.20" }, + { name = "waitress", specifier = "==3.0.2" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyinstaller", specifier = "==6.19.0" }, + { name = "twine", specifier = "==6.2.0" }, + { name = "wheel", specifier = "==0.46.3" }, +] +devlinux = [ + { name = "auditwheel", specifier = "==6.6.0" }, + { name = "scons", specifier = "==4.10.1" }, +] +doc = [ + { name = "furo", specifier = "==2025.12.19" }, + { name = "myst-parser", specifier = "==4.0.1" }, + { name = "sphinx", specifier = "==8.1.3" }, + { name = "sphinx-autobuild", specifier = "==2024.10.3" }, +] +test = [ + { name = "freezegun", specifier = "==1.5.5" }, + { name = "mock", specifier = "==5.2.0" }, + { name = "mypy", specifier = "==1.19.1" }, + { name = "pytest", specifier = "==9.0.2" }, + { name = "pytest-cov", specifier = "==7.1.0" }, + { name = "pytest-timeout", specifier = "==2.4.0" }, + { name = "pytest-xdist", specifier = "==3.8.0" }, + { name = "ruff", specifier = "==0.15.8" }, + { name = "types-mock", specifier = "==5.2.0.20250924" }, + { name = "types-pytz", specifier = "==2026.1.1.20260304" }, + { name = "types-requests", specifier = "==2.31.0.2" }, + { name = "types-tqdm", specifier = "==4.67.3.20260303" }, + { name = "types-tzlocal", specifier = "==5.1.0.1" }, + { name = "types-urllib3", specifier = "==1.26.25.14" }, + { name = "types-waitress", specifier = "==3.0.1.20260316" }, + { name = "vcrpy", specifier = "==8.1.1" }, +] + +[[package]] +name = "id" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/11/102da08f88412d875fa2f1a9a469ff7ad4c874b0ca6fed0048fe385bdb3d/id-1.5.0.tar.gz", hash = "sha256:292cb8a49eacbbdbce97244f47a97b4c62540169c976552e497fd57df0734c1d", size = 15237, upload-time = "2024-12-04T19:53:05.575Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/cb/18326d2d89ad3b0dd143da971e77afd1e6ca6674f1b1c3df4b6bec6279fc/id-1.5.0-py3-none-any.whl", hash = "sha256:f1434e1cef91f2cbb8a4ec64663d5a23b9ed43ef44c4c957d02583d61714c658", size = 13611, upload-time = "2024-12-04T19:53:03.02Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "imagesize" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[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 = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/7b/c3081ff1af947915503121c649f26a778e1a2101fd525f74aef997d75b7e/jaraco_context-6.1.1.tar.gz", hash = "sha256:bc046b2dc94f1e5532bd02402684414575cc11f565d929b6563125deb0a6e581", size = 15832, upload-time = "2026-03-07T15:46:04.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/49/c152890d49102b280ecf86ba5f80a8c111c3a155dafa3bd24aeb64fde9e1/jaraco_context-6.1.1-py3-none-any.whl", hash = "sha256:0df6a0287258f3e364072c3e40d5411b20cafa30cb28c4839d24319cecf9f808", size = 7005, upload-time = "2026-03-07T15:46:03.515Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[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 = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + +[[package]] +name = "keyrings-alt" +version = "5.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jaraco-classes" }, + { name = "jaraco-context" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/7b/e3bf53326e0753bee11813337b1391179582ba5c6851b13e0d9502d15a50/keyrings_alt-5.0.2.tar.gz", hash = "sha256:8f097ebe9dc8b185106502b8cdb066c926d2180e13b4689fd4771a3eab7d69fb", size = 29229, upload-time = "2024-08-14T01:09:28.12Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/0d/9c59313ab43d0858a9a665e80763bd830dc78d5f379afc3815e123c486c2/keyrings.alt-5.0.2-py3-none-any.whl", hash = "sha256:6be74693192f3f37bbb752bfac9b86e6177076b17d2ac12a390f1d6abff8ac7c", size = 17930, upload-time = "2024-08-14T01:09:26.785Z" }, +] + +[[package]] +name = "librt" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/5f/63f5fa395c7a8a93558c0904ba8f1c8d1b997ca6a3de61bc7659970d66bf/librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc", size = 65697, upload-time = "2026-02-17T16:11:06.903Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e0/0472cf37267b5920eff2f292ccfaede1886288ce35b7f3203d8de00abfe6/librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7", size = 68376, upload-time = "2026-02-17T16:11:08.395Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8bd1359fdcd27ab897cd5963294fa4a7c83b20a8564678e4fd12157e56a5/librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6", size = 197084, upload-time = "2026-02-17T16:11:09.774Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fe/163e33fdd091d0c2b102f8a60cc0a61fd730ad44e32617cd161e7cd67a01/librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0", size = 207337, upload-time = "2026-02-17T16:11:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/f85130582f05dcf0c8902f3d629270231d2f4afdfc567f8305a952ac7f14/librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b", size = 219980, upload-time = "2026-02-17T16:11:12.499Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/cb5e4d03659e043a26c74e08206412ac9a3742f0477d96f9761a55313b5f/librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6", size = 212921, upload-time = "2026-02-17T16:11:14.484Z" }, + { url = "https://files.pythonhosted.org/packages/b1/81/a3a01e4240579c30f3487f6fed01eb4bc8ef0616da5b4ebac27ca19775f3/librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71", size = 221381, upload-time = "2026-02-17T16:11:17.459Z" }, + { url = "https://files.pythonhosted.org/packages/08/b0/fc2d54b4b1c6fb81e77288ff31ff25a2c1e62eaef4424a984f228839717b/librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7", size = 216714, upload-time = "2026-02-17T16:11:19.197Z" }, + { url = "https://files.pythonhosted.org/packages/96/96/85daa73ffbd87e1fb287d7af6553ada66bf25a2a6b0de4764344a05469f6/librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05", size = 214777, upload-time = "2026-02-17T16:11:20.443Z" }, + { url = "https://files.pythonhosted.org/packages/12/9c/c3aa7a2360383f4bf4f04d98195f2739a579128720c603f4807f006a4225/librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891", size = 237398, upload-time = "2026-02-17T16:11:22.083Z" }, + { url = "https://files.pythonhosted.org/packages/61/19/d350ea89e5274665185dabc4bbb9c3536c3411f862881d316c8b8e00eb66/librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7", size = 54285, upload-time = "2026-02-17T16:11:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d6/45d587d3d41c112e9543a0093d883eb57a24a03e41561c127818aa2a6bcc/librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2", size = 61352, upload-time = "2026-02-17T16:11:24.207Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" }, + { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622, upload-time = "2026-02-17T16:11:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304, upload-time = "2026-02-17T16:11:29.344Z" }, + { url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493, upload-time = "2026-02-17T16:11:30.445Z" }, + { url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129, upload-time = "2026-02-17T16:11:32.021Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113, upload-time = "2026-02-17T16:11:33.192Z" }, + { url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269, upload-time = "2026-02-17T16:11:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673, upload-time = "2026-02-17T16:11:36.063Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597, upload-time = "2026-02-17T16:11:37.544Z" }, + { url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733, upload-time = "2026-02-17T16:11:38.691Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273, upload-time = "2026-02-17T16:11:40.308Z" }, + { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, + { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, + { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, + { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, + { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, + { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, + { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, + { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, + { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, +] + +[[package]] +name = "macholib" +version = "1.16.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[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/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { 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" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, +] + +[[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 = "mock" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/8c/14c2ae915e5f9dca5a22edd68b35be94400719ccfa068a03e0fb63d0f6f6/mock-5.2.0.tar.gz", hash = "sha256:4e460e818629b4b173f32d08bf30d3af8123afbb8e04bb5707a1fd4799e503f0", size = 92796, upload-time = "2025-03-03T12:31:42.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/d9/617e6af809bf3a1d468e0d58c3997b1dc219a9a9202e650d30c2fc85d481/mock-5.2.0-py3-none-any.whl", hash = "sha256:7ba87f72ca0e915175596069dbbcc7c75af7b5e9b9bc107ad6349ede0819982f", size = 31617, upload-time = "2025-03-03T12:31:41.518Z" }, +] + +[[package]] +name = "more-itertools" +version = "10.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, +] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, + { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "myst-parser" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "jinja2" }, + { name = "markdown-it-py" }, + { name = "mdit-py-plugins" }, + { name = "pyyaml" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d", size = 84579, upload-time = "2025-02-12T10:53:02.078Z" }, +] + +[[package]] +name = "nh3" +version = "0.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/37/ab55eb2b05e334ff9a1ad52c556ace1f9c20a3f63613a165d384d5387657/nh3-0.3.3.tar.gz", hash = "sha256:185ed41b88c910b9ca8edc89ca3b4be688a12cb9de129d84befa2f74a0039fee", size = 18968, upload-time = "2026-02-14T09:35:15.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/3e/aef8cf8e0419b530c95e96ae93a5078e9b36c1e6613eeb1df03a80d5194e/nh3-0.3.3-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e8ee96156f7dfc6e30ecda650e480c5ae0a7d38f0c6fafc3c1c655e2500421d9", size = 1448640, upload-time = "2026-02-14T09:34:49.316Z" }, + { url = "https://files.pythonhosted.org/packages/ca/43/d2011a4f6c0272cb122eeff40062ee06bb2b6e57eabc3a5e057df0d582df/nh3-0.3.3-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45fe0d6a607264910daec30360c8a3b5b1500fd832d21b2da608256287bcb92d", size = 839405, upload-time = "2026-02-14T09:34:50.779Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f3/965048510c1caf2a34ed04411a46a04a06eb05563cd06f1aa57b71eb2bc8/nh3-0.3.3-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5bc1d4b30ba1ba896669d944b6003630592665974bd11a3dc2f661bde92798a7", size = 825849, upload-time = "2026-02-14T09:34:52.622Z" }, + { url = "https://files.pythonhosted.org/packages/78/99/b4bbc6ad16329d8db2c2c320423f00b549ca3b129c2b2f9136be2606dbb0/nh3-0.3.3-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f433a2dd66545aad4a720ad1b2150edcdca75bfff6f4e6f378ade1ec138d5e77", size = 1068303, upload-time = "2026-02-14T09:34:54.179Z" }, + { url = "https://files.pythonhosted.org/packages/3f/34/3420d97065aab1b35f3e93ce9c96c8ebd423ce86fe84dee3126790421a2a/nh3-0.3.3-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52e973cb742e95b9ae1b35822ce23992428750f4b46b619fe86eba4205255b30", size = 1029316, upload-time = "2026-02-14T09:34:56.186Z" }, + { url = "https://files.pythonhosted.org/packages/f1/9a/99eda757b14e596fdb2ca5f599a849d9554181aa899274d0d183faef4493/nh3-0.3.3-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c730617bdc15d7092dcc0469dc2826b914c8f874996d105b4bc3842a41c1cd9", size = 919944, upload-time = "2026-02-14T09:34:57.886Z" }, + { url = "https://files.pythonhosted.org/packages/6f/84/c0dc75c7fb596135f999e59a410d9f45bdabb989f1cb911f0016d22b747b/nh3-0.3.3-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e98fa3dbfd54e25487e36ba500bc29bca3a4cab4ffba18cfb1a35a2d02624297", size = 811461, upload-time = "2026-02-14T09:34:59.65Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ec/b1bf57cab6230eec910e4863528dc51dcf21b57aaf7c88ee9190d62c9185/nh3-0.3.3-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:3a62b8ae7c235481715055222e54c682422d0495a5c73326807d4e44c5d14691", size = 840360, upload-time = "2026-02-14T09:35:01.444Z" }, + { url = "https://files.pythonhosted.org/packages/37/5e/326ae34e904dde09af1de51219a611ae914111f0970f2f111f4f0188f57e/nh3-0.3.3-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc305a2264868ec8fa16548296f803d8fd9c1fa66cd28b88b605b1bd06667c0b", size = 859872, upload-time = "2026-02-14T09:35:03.348Z" }, + { url = "https://files.pythonhosted.org/packages/09/38/7eba529ce17ab4d3790205da37deabb4cb6edcba15f27b8562e467f2fc97/nh3-0.3.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:90126a834c18af03bfd6ff9a027bfa6bbf0e238527bc780a24de6bd7cc1041e2", size = 1023550, upload-time = "2026-02-14T09:35:04.829Z" }, + { url = "https://files.pythonhosted.org/packages/05/a2/556fdecd37c3681b1edee2cf795a6799c6ed0a5551b2822636960d7e7651/nh3-0.3.3-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:24769a428e9e971e4ccfb24628f83aaa7dc3c8b41b130c8ddc1835fa1c924489", size = 1105212, upload-time = "2026-02-14T09:35:06.821Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e3/5db0b0ad663234967d83702277094687baf7c498831a2d3ad3451c11770f/nh3-0.3.3-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:b7a18ee057761e455d58b9d31445c3e4b2594cff4ddb84d2e331c011ef46f462", size = 1069970, upload-time = "2026-02-14T09:35:08.504Z" }, + { url = "https://files.pythonhosted.org/packages/79/b2/2ea21b79c6e869581ce5f51549b6e185c4762233591455bf2a326fb07f3b/nh3-0.3.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5a4b2c1f3e6f3cbe7048e17f4fefad3f8d3e14cc0fd08fb8599e0d5653f6b181", size = 1047588, upload-time = "2026-02-14T09:35:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/e2/92/2e434619e658c806d9c096eed2cdff9a883084299b7b19a3f0824eb8e63d/nh3-0.3.3-cp38-abi3-win32.whl", hash = "sha256:e974850b131fdffa75e7ad8e0d9c7a855b96227b093417fdf1bd61656e530f37", size = 616179, upload-time = "2026-02-14T09:35:11.366Z" }, + { url = "https://files.pythonhosted.org/packages/73/88/1ce287ef8649dc51365b5094bd3713b76454838140a32ab4f8349973883c/nh3-0.3.3-cp38-abi3-win_amd64.whl", hash = "sha256:2efd17c0355d04d39e6d79122b42662277ac10a17ea48831d90b46e5ef7e4fc0", size = 631159, upload-time = "2026-02-14T09:35:12.77Z" }, + { url = "https://files.pythonhosted.org/packages/31/f1/b4835dbde4fb06f29db89db027576d6014081cd278d9b6751facc3e69e43/nh3-0.3.3-cp38-abi3-win_arm64.whl", hash = "sha256:b838e619f483531483d26d889438e53a880510e832d2aafe73f93b7b1ac2bce2", size = 616645, upload-time = "2026-02-14T09:35:14.062Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "pefile" +version = "2023.2.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/c5/3b3c62223f72e2360737fd2a57c30e5b2adecd85e70276879609a7403334/pefile-2023.2.7.tar.gz", hash = "sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc", size = 74854, upload-time = "2023-02-07T12:23:55.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/26/d0ad8b448476d0a1e8d3ea5622dc77b916db84c6aa3cb1e1c0965af948fc/pefile-2023.2.7-py3-none-any.whl", hash = "sha256:da185cd2af68c08a6cd4481f7325ed600a88f6a813bad9dea07ab3ef73d8d8d6", size = 71791, upload-time = "2023-02-07T12:28:36.678Z" }, +] + +[[package]] +name = "piexif" +version = "1.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/84/a3f25cec7d0922bf60be8000c9739d28d24b6896717f44cc4cfb843b1487/piexif-1.1.3.zip", hash = "sha256:83cb35c606bf3a1ea1a8f0a25cb42cf17e24353fd82e87ae3884e74a302a5f1b", size = 1011134, upload-time = "2019-07-01T15:29:23.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/d8/6f63147dd73373d051c5eb049ecd841207f898f50a5a1d4378594178f6cf/piexif-1.1.3-py2.py3-none-any.whl", hash = "sha256:3bc435d171720150b81b15d27e05e54b8abbde7b4242cddd81ef160d283108b6", size = 20691, upload-time = "2019-07-01T15:43:20.907Z" }, +] + +[[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 = "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 = "pyelftools" +version = "0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/ab/33968940b2deb3d92f5b146bc6d4009a5f95d1d06c148ea2f9ee965071af/pyelftools-0.32.tar.gz", hash = "sha256:6de90ee7b8263e740c8715a925382d4099b354f29ac48ea40d840cf7aa14ace5", size = 15047199, upload-time = "2025-02-19T14:20:05.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/43/700932c4f0638c3421177144a2e86448c0d75dbaee2c7936bda3f9fd0878/pyelftools-0.32-py3-none-any.whl", hash = "sha256:013df952a006db5e138b1edf6d8a68ecc50630adbd0d83a2d41e7f846163d738", size = 188525, upload-time = "2025-02-19T14:19:59.919Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyinstaller" +version = "6.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, + { name = "macholib", marker = "sys_platform == 'darwin'" }, + { name = "packaging" }, + { name = "pefile", marker = "sys_platform == 'win32'" }, + { name = "pyinstaller-hooks-contrib" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c8/63/fd62472b6371d89dc138d40c36d87a50dc2de18a035803bbdc376b4ffac4/pyinstaller-6.19.0.tar.gz", hash = "sha256:ec73aeb8bd9b7f2f1240d328a4542e90b3c6e6fbc106014778431c616592a865", size = 4036072, upload-time = "2026-02-14T18:06:28.718Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/eb/23374721fecfa72677e79800921cb6aceefa6ba48574dc404f3f6c6c3be7/pyinstaller-6.19.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:4190e76b74f0c4b5c5f11ac360928cd2e36ec8e3194d437bf6b8648c7bc0c134", size = 1040563, upload-time = "2026-02-14T18:05:22.436Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7e/dfd724b0b533f5aaec0ee5df406fe2319987ed6964480a706f85478b12ea/pyinstaller-6.19.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8bd68abd812d8a6ba33b9f1810e91fee0f325969733721b78151f0065319ca11", size = 735477, upload-time = "2026-02-14T18:05:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/88/c9/ee3a4101c31f26344e66896c73c1fd6ed8282bf871473365b7f8674af406/pyinstaller-6.19.0-py3-none-manylinux2014_i686.whl", hash = "sha256:1ec54ef967996ca61dacba676227e2b23219878ccce5ee9d6f3aada7b8ed8abf", size = 747143, upload-time = "2026-02-14T18:05:31.488Z" }, + { url = "https://files.pythonhosted.org/packages/da/0a/fc77e9f861be8cf300ac37155f59cc92aff99b29f2ddd78546f563a5b5a6/pyinstaller-6.19.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4ab2bb52e58448e14ddf9450601bdedd66800465043501c1d8f1cab87b60b122", size = 744849, upload-time = "2026-02-14T18:05:35.492Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e3/6872e020ee758afe0b821663858492c10745608b07150e5e2c824a5b3e1c/pyinstaller-6.19.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:da6d5c6391ccefe73554b9fa29b86001c8e378e0f20c2a4004f836ba537eff63", size = 741590, upload-time = "2026-02-14T18:05:39.59Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/b8db5f1a4b0fb228175f2ea0aa33f949adcc097fbe981cc524f9faf85777/pyinstaller-6.19.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a0fc5f6b3c55aa54353f0c74ffa59b1115433c1850c6f655d62b461a2ed6cbbe", size = 741448, upload-time = "2026-02-14T18:05:45.636Z" }, + { url = "https://files.pythonhosted.org/packages/6f/4d/63b0600f2694e9141b83129fbc1c488ec84d5a0770b1448ec154dcd0fee9/pyinstaller-6.19.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:e649ba6bd1b0b89b210ad92adb5fbdc8a42dd2c5ca4f72ef3a0bfec83a424b83", size = 740613, upload-time = "2026-02-14T18:05:49.726Z" }, + { url = "https://files.pythonhosted.org/packages/01/d4/e812ad36178093a0e9fd4b8127577748dd85b0cb71de912229dca21fd741/pyinstaller-6.19.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:481a909c8e60c8692fc60fcb1344d984b44b943f8bc9682f2fcdae305ad297e6", size = 740350, upload-time = "2026-02-14T18:05:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/52/03/b2c2ee41fb8e10fd2a45d21f5ec2ef25852cfb978dbf762972eed59e3d63/pyinstaller-6.19.0-py3-none-win32.whl", hash = "sha256:3c5c251054fe4cfaa04c34a363dcfbf811545438cb7198304cd444756bc2edd2", size = 1324317, upload-time = "2026-02-14T18:06:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d3/6d5e62b8270e2b53a6065e281b3a7785079b00e9019c8019952828dd1669/pyinstaller-6.19.0-py3-none-win_amd64.whl", hash = "sha256:b5bb6536c6560330d364d91522250f254b107cf69129d9cbcd0e6727c570be33", size = 1384894, upload-time = "2026-02-14T18:06:06.425Z" }, + { url = "https://files.pythonhosted.org/packages/81/65/458cd523308a101a22fd2742893405030cc24994cc74b1b767cecf137160/pyinstaller-6.19.0-py3-none-win_arm64.whl", hash = "sha256:c2d5a539b0bfe6159d5522c8c70e1c0e487f22c2badae0f97d45246223b798ea", size = 1325374, upload-time = "2026-02-14T18:06:12.804Z" }, +] + +[[package]] +name = "pyinstaller-hooks-contrib" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/17/716326f6ba18d0663f7995ae369c23e50efebc22fbb054e9710a45688f61/pyinstaller_hooks_contrib-2026.3.tar.gz", hash = "sha256:800d3a198a49a6cd0de2d7fb795005fdca7a0222ed9cb47c0691abd1c27b9310", size = 172323, upload-time = "2026-03-09T22:44:06.345Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/19/781352446af28755f16ce52b2d97f7a6f2d7974ac34c00ca5cd8c40c9098/pyinstaller_hooks_contrib-2026.3-py3-none-any.whl", hash = "sha256:5ecd1068ad262afecadf07556279d2be52ca93a88b049fae17f1a2eb2969254a", size = 454625, upload-time = "2026-03-09T22:44:04.717Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[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 = "pytz" +version = "2026.1.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[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/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { 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" }, +] + +[[package]] +name = "readme-renderer" +version = "44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "nh3" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/a9/104ec9234c8448c4379768221ea6df01260cd6c2ce13182d4eac531c8342/readme_renderer-44.0.tar.gz", hash = "sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1", size = 32056, upload-time = "2024-07-08T15:00:57.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl", hash = "sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151", size = 13310, upload-time = "2024-07-08T15:00:56.577Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "rfc3986" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", size = 49026, upload-time = "2022-01-10T00:52:30.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", size = 31326, upload-time = "2022-01-10T00:52:29.594Z" }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" }, + { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" }, + { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" }, + { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" }, + { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" }, + { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" }, + { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" }, + { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" }, + { url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" }, +] + +[[package]] +name = "schema" +version = "0.7.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/2e/8da627b65577a8f130fe9dfa88ce94fcb24b1f8b59e0fc763ee61abef8b8/schema-0.7.8.tar.gz", hash = "sha256:e86cc08edd6fe6e2522648f4e47e3a31920a76e82cce8937535422e310862ab5", size = 45540, upload-time = "2025-10-11T13:15:40.281Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/75/aad85817266ac5285c93391711d231ca63e9ae7d42cd3ca37549e24ebe52/schema-0.7.8-py2.py3-none-any.whl", hash = "sha256:00bd977fadc7d9521bf289850cd8a8aa5f4948f575476b8daaa5c1b57af2dce1", size = 19108, upload-time = "2025-10-11T17:13:07.323Z" }, +] + +[[package]] +name = "scons" +version = "4.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/c9/2f430bb39e4eccba32ce8008df4a3206df651276422204e177a09e12b30b/scons-4.10.1.tar.gz", hash = "sha256:99c0e94a42a2c1182fa6859b0be697953db07ba936ecc9817ae0d218ced20b15", size = 3258403, upload-time = "2025-11-16T22:43:39.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/bf/931fb9fbb87234c32b8b1b1c15fba23472a10777c12043336675633809a7/scons-4.10.1-py3-none-any.whl", hash = "sha256:bd9d1c52f908d874eba92a8c0c0a8dcf2ed9f3b88ab956d0fce1da479c4e7126", size = 4136069, upload-time = "2025-11-16T22:43:35.933Z" }, +] + +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + +[[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 = "snowballstemmer" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + +[[package]] +name = "sphinx" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, +] + +[[package]] +name = "sphinx-autobuild" +version = "2024.10.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, + { name = "sphinx" }, + { name = "starlette" }, + { name = "uvicorn" }, + { name = "watchfiles" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/2c/155e1de2c1ba96a72e5dba152c509a8b41e047ee5c2def9e9f0d812f8be7/sphinx_autobuild-2024.10.3.tar.gz", hash = "sha256:248150f8f333e825107b6d4b86113ab28fa51750e5f9ae63b59dc339be951fb1", size = 14023, upload-time = "2024-10-02T23:15:30.172Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/c0/eba125db38c84d3c74717008fd3cb5000b68cd7e2cbafd1349c6a38c3d3b/sphinx_autobuild-2024.10.3-py3-none-any.whl", hash = "sha256:158e16c36f9d633e613c9aaf81c19b0fc458ca78b112533b20dafcda430d60fa", size = 11908, upload-time = "2024-10-02T23:15:28.739Z" }, +] + +[[package]] +name = "sphinx-basic-ng" +version = "1.0.0b2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9", size = 20736, upload-time = "2023-07-08T18:40:54.166Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/dd/018ce05c532a22007ac58d4f45232514cd9d6dd0ee1dc374e309db830983/sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b", size = 22496, upload-time = "2023-07-08T18:40:52.659Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "srp" +version = "1.0.22" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/fb/9210875dd162d3977580407b1c5ce6e779e770b8197a0de76819144a9755/srp-1.0.22.tar.gz", hash = "sha256:f330d0ec7387e2ac8577487b164963155d4a031bca6e2024f1b0930eb92baa5d", size = 22472, upload-time = "2024-11-01T21:52:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/75/5352c3ebd26e7d119042ae8de07354435a19c77fa2b44058fa97a1416783/srp-1.0.22-py3-none-any.whl", hash = "sha256:35aa8af053285a35683eb37182dcb2e46dbd85c7075d28e139f200d6bf16ea43", size = 25347, upload-time = "2024-11-01T21:52:53.021Z" }, +] + +[[package]] +name = "starlette" +version = "0.52.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/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "twine" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "id" }, + { name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" }, + { name = "packaging" }, + { name = "readme-renderer" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "rfc3986" }, + { name = "rich" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/a8/949edebe3a82774c1ec34f637f5dd82d1cf22c25e963b7d63771083bbee5/twine-6.2.0.tar.gz", hash = "sha256:e5ed0d2fd70c9959770dce51c8f39c8945c574e18173a7b81802dab51b4b75cf", size = 172262, upload-time = "2025-09-04T15:43:17.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl", hash = "sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8", size = 42727, upload-time = "2025-09-04T15:43:15.994Z" }, +] + +[[package]] +name = "types-mock" +version = "5.2.0.20250924" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/50/c3/00cf1e62c27fd195aaf22b249884f82643141b73f151ff019aa24c99bd17/types_mock-5.2.0.20250924.tar.gz", hash = "sha256:953197543b4183f00363e8e626f6c7abea1a3f7a4dd69d199addb70b01b6bb35", size = 11319, upload-time = "2025-09-24T02:53:33.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/85/52004fb81add2b05494cbd1c0dab71f3706f19935cabb4ad220643884382/types_mock-5.2.0.20250924-py3-none-any.whl", hash = "sha256:23617ffb4cf948c085db69ec90bd474afbce634ef74995045ae0a5748afbe57d", size = 10499, upload-time = "2025-09-24T02:53:32.054Z" }, +] + +[[package]] +name = "types-pytz" +version = "2026.1.1.20260304" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/56/2f12a15ea8c5615c8fb896c4fbbb527ab1c0f776ed5860c6fc9ec26ea2c7/types_pytz-2026.1.1.20260304.tar.gz", hash = "sha256:0c3542d8e9b0160b424233440c52b83d6f58cae4b85333d54e4f961cf013e117", size = 11198, upload-time = "2026-03-04T03:57:24.445Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/b8/e77c355f179dc89d44e7ca6dbf7a46e650806df1d356a5462e5829fccea5/types_pytz-2026.1.1.20260304-py3-none-any.whl", hash = "sha256:175332c1cf7bd6b1cc56b877f70bf02def1a3f75e5adcc05385ce2c3c70e6500", size = 10126, upload-time = "2026-03-04T03:57:23.481Z" }, +] + +[[package]] +name = "types-requests" +version = "2.31.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/70/60c1e24806a2cd1b64867a15a1100e5ad6423c8ce250ebc04dd519162487/types-requests-2.31.0.2.tar.gz", hash = "sha256:6aa3f7faf0ea52d728bb18c0a0d1522d9bfd8c72d26ff6f61bfc3d06a411cf40", size = 15293, upload-time = "2023-07-20T15:21:37.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/9b/04bb62f11a6824df5d4568439cf0715118c265d0ffbebeb7cf4b8c9caa15/types_requests-2.31.0.2-py3-none-any.whl", hash = "sha256:56d181c85b5925cbc59f4489a57e72a8b2166f18273fd8ba7b6fe0c0b986f12a", size = 14437, upload-time = "2023-07-20T15:21:36.331Z" }, +] + +[[package]] +name = "types-tqdm" +version = "4.67.3.20260303" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/64/3e7cb0f40c4bf9578098b6873df33a96f7e0de90f3a039e614d22bfde40a/types_tqdm-4.67.3.20260303.tar.gz", hash = "sha256:7bfddb506a75aedb4030fabf4f05c5638c9a3bbdf900d54ec6c82be9034bfb96", size = 18117, upload-time = "2026-03-03T04:03:49.679Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/32/e4a1fce59155c74082f1a42d0ffafa59652bfb8cff35b04d56333877748e/types_tqdm-4.67.3.20260303-py3-none-any.whl", hash = "sha256:459decf677e4b05cef36f9012ef8d6e20578edefb6b78c15bd0b546247eda62d", size = 24572, upload-time = "2026-03-03T04:03:48.913Z" }, +] + +[[package]] +name = "types-tzlocal" +version = "5.1.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-pytz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/cf/e4d446e57c0b14ed1da4de180d2a4cac773b667f183e83bdad76ea6e2238/types-tzlocal-5.1.0.1.tar.gz", hash = "sha256:b84a115c0c68f0d0fa9af1c57f0645eeef0e539147806faf1f95ac3ac01ce47b", size = 3549, upload-time = "2023-10-24T02:15:07.127Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/13/caeb438290df069ddda6f055d0eb14337ada293c7d43ab89419ba4b1a778/types_tzlocal-5.1.0.1-py3-none-any.whl", hash = "sha256:0302e8067c86936de8f7e0aaedc2cfbf240080802c603df0f80312fbd4efb926", size = 3005, upload-time = "2023-10-24T02:15:05.815Z" }, +] + +[[package]] +name = "types-urllib3" +version = "1.26.25.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/de/b9d7a68ad39092368fb21dd6194b362b98a1daeea5dcfef5e1adb5031c7e/types-urllib3-1.26.25.14.tar.gz", hash = "sha256:229b7f577c951b8c1b92c1bc2b2fdb0b49847bd2af6d1cc2a2e3dd340f3bda8f", size = 11239, upload-time = "2023-07-20T15:19:31.307Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/7b/3fc711b2efea5e85a7a0bbfe269ea944aa767bbba5ec52f9ee45d362ccf3/types_urllib3-1.26.25.14-py3-none-any.whl", hash = "sha256:9683bbb7fb72e32bfe9d2be6e04875fbe1b3eeec3cbb4ea231435aa7fd6b4f0e", size = 15377, upload-time = "2023-07-20T15:19:30.379Z" }, +] + +[[package]] +name = "types-waitress" +version = "3.0.1.20260316" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/43/1cbe473cf69717fc2b3b62fd1caabb75fbd0e06da236ad373ae95cb183bb/types_waitress-3.0.1.20260316.tar.gz", hash = "sha256:2f8feb8d21e926da88923573f11d696e611f2087f2a8c62237ab53c34d20a351", size = 14410, upload-time = "2026-03-16T04:29:08.504Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/a3/7b7955e76d319fd4247f7d935e939b74f9f0fc4c09f9743e3c6a3570dcb6/types_waitress-3.0.1.20260316-py3-none-any.whl", hash = "sha256:6e02e7d69e84773ee86eb952c1ea0f858527870a459bf5ed53dcaec25aac7308", size = 17504, upload-time = "2026-03-16T04:29:07.493Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + +[[package]] +name = "urllib3" +version = "1.26.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/e8/6ff5e6bc22095cfc59b6ea711b687e2b7ed4bdb373f7eeec370a97d7392f/urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32", size = 307380, upload-time = "2024-08-29T15:43:11.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/cf/8435d5a7159e2a9c83a95896ed596f68cf798005fe107cc655b5c5c14704/urllib3-1.26.20-py2.py3-none-any.whl", hash = "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e", size = 144225, upload-time = "2024-08-29T15:43:08.921Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.42.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, +] + +[[package]] +name = "vcrpy" +version = "8.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/07/bcfd5ebd7cb308026ab78a353e091bd699593358be49197d39d004e5ad83/vcrpy-8.1.1.tar.gz", hash = "sha256:58e3053e33b423f3594031cb758c3f4d1df931307f1e67928e30cf352df7709f", size = 85770, upload-time = "2026-01-04T19:22:03.886Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/d7/f79b05a5d728f8786876a7d75dfb0c5cae27e428081b2d60152fb52f155f/vcrpy-8.1.1-py3-none-any.whl", hash = "sha256:2d16f31ad56493efb6165182dd99767207031b0da3f68b18f975545ede8ac4b9", size = 42445, upload-time = "2026-01-04T19:22:02.532Z" }, +] + +[[package]] +name = "waitress" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/cb/04ddb054f45faa306a230769e868c28b8065ea196891f09004ebace5b184/waitress-3.0.2.tar.gz", hash = "sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f", size = 179901, upload-time = "2024-11-16T20:02:35.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/57/a27182528c90ef38d82b636a11f606b0cbb0e17588ed205435f8affe3368/waitress-3.0.2-py3-none-any.whl", hash = "sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e", size = 56232, upload-time = "2024-11-16T20:02:33.858Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/1a/206e8cf2dd86fddf939165a57b4df61607a1e0add2785f170a3f616b7d9f/watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c", size = 407318, upload-time = "2025-10-14T15:04:18.753Z" }, + { url = "https://files.pythonhosted.org/packages/b3/0f/abaf5262b9c496b5dad4ed3c0e799cbecb1f8ea512ecb6ddd46646a9fca3/watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43", size = 394478, upload-time = "2025-10-14T15:04:20.297Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/9cc0ba88697b34b755371f5ace8d3a4d9a15719c07bdc7bd13d7d8c6a341/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31", size = 449894, upload-time = "2025-10-14T15:04:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/d2/9c/eda4615863cd8621e89aed4df680d8c3ec3da6a4cf1da113c17decd87c7f/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac", size = 459065, upload-time = "2025-10-14T15:04:22.795Z" }, + { url = "https://files.pythonhosted.org/packages/84/13/f28b3f340157d03cbc8197629bc109d1098764abe1e60874622a0be5c112/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d", size = 488377, upload-time = "2025-10-14T15:04:24.138Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/cfa597fa9389e122488f7ffdbd6db505b3b915ca7435ecd7542e855898c2/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d", size = 595837, upload-time = "2025-10-14T15:04:25.057Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/68c1ed5652b48d89fc24d6af905d88ee4f82fa8bc491e2666004e307ded1/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863", size = 473456, upload-time = "2025-10-14T15:04:26.497Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dc/1a680b7458ffa3b14bb64878112aefc8f2e4f73c5af763cbf0bd43100658/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab", size = 455614, upload-time = "2025-10-14T15:04:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/3d782a666512e01eaa6541a72ebac1d3aae191ff4a31274a66b8dd85760c/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82", size = 630690, upload-time = "2025-10-14T15:04:28.495Z" }, + { url = "https://files.pythonhosted.org/packages/9b/73/bb5f38590e34687b2a9c47a244aa4dd50c56a825969c92c9c5fc7387cea1/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4", size = 622459, upload-time = "2025-10-14T15:04:29.491Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ac/c9bb0ec696e07a20bd58af5399aeadaef195fb2c73d26baf55180fe4a942/watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844", size = 272663, upload-time = "2025-10-14T15:04:30.435Z" }, + { url = "https://files.pythonhosted.org/packages/11/a0/a60c5a7c2ec59fa062d9a9c61d02e3b6abd94d32aac2d8344c4bdd033326/watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e", size = 287453, upload-time = "2025-10-14T15:04:31.53Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, + { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4c/a888c91e2e326872fa4705095d64acd8aa2fb9c1f7b9bd0588f33850516c/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3", size = 409611, upload-time = "2025-10-14T15:06:05.809Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/5420d1943c8e3ce1a21c0a9330bcf7edafb6aa65d26b21dbb3267c9e8112/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2", size = 396889, upload-time = "2025-10-14T15:06:07.035Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e5/0072cef3804ce8d3aaddbfe7788aadff6b3d3f98a286fdbee9fd74ca59a7/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d", size = 451616, upload-time = "2025-10-14T15:06:08.072Z" }, + { url = "https://files.pythonhosted.org/packages/83/4e/b87b71cbdfad81ad7e83358b3e447fedd281b880a03d64a760fe0a11fc2e/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b", size = 458413, upload-time = "2025-10-14T15:06:09.209Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" }, +] + +[[package]] +name = "wheel" +version = "0.46.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/24/a2eb353a6edac9a0303977c4cb048134959dd2a51b48a269dfc9dde00c8a/wheel-0.46.3.tar.gz", hash = "sha256:e3e79874b07d776c40bd6033f8ddf76a7dad46a7b8aa1b2787a83083519a1803", size = 60605, upload-time = "2026-01-22T12:39:49.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/22/b76d483683216dde3d67cba61fb2444be8d5be289bf628c13fc0fd90e5f9/wheel-0.46.3-py3-none-any.whl", hash = "sha256:4b399d56c9d9338230118d705d9737a2a468ccca63d5e813e2a4fc7815d8bc4d", size = 30557, upload-time = "2026-01-22T12:39:48.099Z" }, +] + +[[package]] +name = "wrapt" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/d2/387594fb592d027366645f3d7cc9b4d7ca7be93845fbaba6d835a912ef3c/wrapt-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a86d99a14f76facb269dc148590c01aaf47584071809a70da30555228158c", size = 60669, upload-time = "2026-03-06T02:52:40.671Z" }, + { url = "https://files.pythonhosted.org/packages/c9/18/3f373935bc5509e7ac444c8026a56762e50c1183e7061797437ca96c12ce/wrapt-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a819e39017f95bf7aede768f75915635aa8f671f2993c036991b8d3bfe8dbb6f", size = 61603, upload-time = "2026-03-06T02:54:21.032Z" }, + { url = "https://files.pythonhosted.org/packages/c2/7a/32758ca2853b07a887a4574b74e28843919103194bb47001a304e24af62f/wrapt-2.1.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5681123e60aed0e64c7d44f72bbf8b4ce45f79d81467e2c4c728629f5baf06eb", size = 113632, upload-time = "2026-03-06T02:53:54.121Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d5/eeaa38f670d462e97d978b3b0d9ce06d5b91e54bebac6fbed867809216e7/wrapt-2.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8b28e97a44d21836259739ae76284e180b18abbb4dcfdff07a415cf1016c3e", size = 115644, upload-time = "2026-03-06T02:54:53.33Z" }, + { url = "https://files.pythonhosted.org/packages/e3/09/2a41506cb17affb0bdf9d5e2129c8c19e192b388c4c01d05e1b14db23c00/wrapt-2.1.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cef91c95a50596fcdc31397eb6955476f82ae8a3f5a8eabdc13611b60ee380ba", size = 112016, upload-time = "2026-03-06T02:54:43.274Z" }, + { url = "https://files.pythonhosted.org/packages/64/15/0e6c3f5e87caadc43db279724ee36979246d5194fa32fed489c73643ba59/wrapt-2.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dad63212b168de8569b1c512f4eac4b57f2c6934b30df32d6ee9534a79f1493f", size = 114823, upload-time = "2026-03-06T02:54:29.392Z" }, + { url = "https://files.pythonhosted.org/packages/56/b2/0ad17c8248f4e57bedf44938c26ec3ee194715f812d2dbbd9d7ff4be6c06/wrapt-2.1.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d307aa6888d5efab2c1cde09843d48c843990be13069003184b67d426d145394", size = 111244, upload-time = "2026-03-06T02:54:02.149Z" }, + { url = "https://files.pythonhosted.org/packages/ff/04/bcdba98c26f2c6522c7c09a726d5d9229120163493620205b2f76bd13c01/wrapt-2.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c87cf3f0c85e27b3ac7d9ad95da166bf8739ca215a8b171e8404a2d739897a45", size = 113307, upload-time = "2026-03-06T02:54:12.428Z" }, + { url = "https://files.pythonhosted.org/packages/0e/1b/5e2883c6bc14143924e465a6fc5a92d09eeabe35310842a481fb0581f832/wrapt-2.1.2-cp310-cp310-win32.whl", hash = "sha256:d1c5fea4f9fe3762e2b905fdd67df51e4be7a73b7674957af2d2ade71a5c075d", size = 57986, upload-time = "2026-03-06T02:54:26.823Z" }, + { url = "https://files.pythonhosted.org/packages/42/5a/4efc997bccadd3af5749c250b49412793bc41e13a83a486b2b54a33e240c/wrapt-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:d8f7740e1af13dff2684e4d56fe604a7e04d6c94e737a60568d8d4238b9a0c71", size = 60336, upload-time = "2026-03-06T02:54:18Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f5/a2bb833e20181b937e87c242645ed5d5aa9c373006b0467bfe1a35c727d0/wrapt-2.1.2-cp310-cp310-win_arm64.whl", hash = "sha256:1c6cc827c00dc839350155f316f1f8b4b0c370f52b6a19e782e2bda89600c7dc", size = 58757, upload-time = "2026-03-06T02:53:51.545Z" }, + { url = "https://files.pythonhosted.org/packages/c7/81/60c4471fce95afa5922ca09b88a25f03c93343f759aae0f31fb4412a85c7/wrapt-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96159a0ee2b0277d44201c3b5be479a9979cf154e8c82fa5df49586a8e7679bb", size = 60666, upload-time = "2026-03-06T02:52:58.934Z" }, + { url = "https://files.pythonhosted.org/packages/6b/be/80e80e39e7cb90b006a0eaf11c73ac3a62bbfb3068469aec15cc0bc795de/wrapt-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:98ba61833a77b747901e9012072f038795de7fc77849f1faa965464f3f87ff2d", size = 61601, upload-time = "2026-03-06T02:53:00.487Z" }, + { url = "https://files.pythonhosted.org/packages/b0/be/d7c88cd9293c859fc74b232abdc65a229bb953997995d6912fc85af18323/wrapt-2.1.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:767c0dbbe76cae2a60dd2b235ac0c87c9cccf4898aef8062e57bead46b5f6894", size = 114057, upload-time = "2026-03-06T02:52:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/ea/25/36c04602831a4d685d45a93b3abea61eca7fe35dab6c842d6f5d570ef94a/wrapt-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c691a6bc752c0cc4711cc0c00896fcd0f116abc253609ef64ef930032821842", size = 116099, upload-time = "2026-03-06T02:54:56.74Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4e/98a6eb417ef551dc277bec1253d5246b25003cf36fdf3913b65cb7657a56/wrapt-2.1.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f3b7d73012ea75aee5844de58c88f44cf62d0d62711e39da5a82824a7c4626a8", size = 112457, upload-time = "2026-03-06T02:53:52.842Z" }, + { url = "https://files.pythonhosted.org/packages/cb/a6/a6f7186a5297cad8ec53fd7578533b28f795fdf5372368c74bd7e6e9841c/wrapt-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:577dff354e7acd9d411eaf4bfe76b724c89c89c8fc9b7e127ee28c5f7bcb25b6", size = 115351, upload-time = "2026-03-06T02:53:32.684Z" }, + { url = "https://files.pythonhosted.org/packages/97/6f/06e66189e721dbebd5cf20e138acc4d1150288ce118462f2fcbff92d38db/wrapt-2.1.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d7b6fd105f8b24e5bd23ccf41cb1d1099796524bcc6f7fbb8fe576c44befbc9", size = 111748, upload-time = "2026-03-06T02:53:08.455Z" }, + { url = "https://files.pythonhosted.org/packages/ef/43/4808b86f499a51370fbdbdfa6cb91e9b9169e762716456471b619fca7a70/wrapt-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:866abdbf4612e0b34764922ef8b1c5668867610a718d3053d59e24a5e5fcfc15", size = 113783, upload-time = "2026-03-06T02:53:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/91/2c/a3f28b8fa7ac2cefa01cfcaca3471f9b0460608d012b693998cd61ef43df/wrapt-2.1.2-cp311-cp311-win32.whl", hash = "sha256:5a0a0a3a882393095573344075189eb2d566e0fd205a2b6414e9997b1b800a8b", size = 57977, upload-time = "2026-03-06T02:53:27.844Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c3/2b1c7bd07a27b1db885a2fab469b707bdd35bddf30a113b4917a7e2139d2/wrapt-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:64a07a71d2730ba56f11d1a4b91f7817dc79bc134c11516b75d1921a7c6fcda1", size = 60336, upload-time = "2026-03-06T02:54:28.104Z" }, + { url = "https://files.pythonhosted.org/packages/ec/5c/76ece7b401b088daa6503d6264dd80f9a727df3e6042802de9a223084ea2/wrapt-2.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:b89f095fe98bc12107f82a9f7d570dc83a0870291aeb6b1d7a7d35575f55d98a", size = 58756, upload-time = "2026-03-06T02:53:16.319Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b6/1db817582c49c7fcbb7df6809d0f515af29d7c2fbf57eb44c36e98fb1492/wrapt-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff2aad9c4cda28a8f0653fc2d487596458c2a3f475e56ba02909e950a9efa6a9", size = 61255, upload-time = "2026-03-06T02:52:45.663Z" }, + { url = "https://files.pythonhosted.org/packages/a2/16/9b02a6b99c09227c93cd4b73acc3678114154ec38da53043c0ddc1fba0dc/wrapt-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6433ea84e1cfacf32021d2a4ee909554ade7fd392caa6f7c13f1f4bf7b8e8748", size = 61848, upload-time = "2026-03-06T02:53:48.728Z" }, + { url = "https://files.pythonhosted.org/packages/af/aa/ead46a88f9ec3a432a4832dfedb84092fc35af2d0ba40cd04aea3889f247/wrapt-2.1.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c20b757c268d30d6215916a5fa8461048d023865d888e437fab451139cad6c8e", size = 121433, upload-time = "2026-03-06T02:54:40.328Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9f/742c7c7cdf58b59085a1ee4b6c37b013f66ac33673a7ef4aaed5e992bc33/wrapt-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79847b83eb38e70d93dc392c7c5b587efe65b3e7afcc167aa8abd5d60e8761c8", size = 123013, upload-time = "2026-03-06T02:53:26.58Z" }, + { url = "https://files.pythonhosted.org/packages/e8/44/2c3dd45d53236b7ed7c646fcf212251dc19e48e599debd3926b52310fafb/wrapt-2.1.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f8fba1bae256186a83d1875b2b1f4e2d1242e8fac0f58ec0d7e41b26967b965c", size = 117326, upload-time = "2026-03-06T02:53:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/74/e2/b17d66abc26bd96f89dec0ecd0ef03da4a1286e6ff793839ec431b9fae57/wrapt-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3d3b35eedcf5f7d022291ecd7533321c4775f7b9cd0050a31a68499ba45757c", size = 121444, upload-time = "2026-03-06T02:54:09.5Z" }, + { url = "https://files.pythonhosted.org/packages/3c/62/e2977843fdf9f03daf1586a0ff49060b1b2fc7ff85a7ea82b6217c1ae36e/wrapt-2.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6f2c5390460de57fa9582bc8a1b7a6c86e1a41dfad74c5225fc07044c15cc8d1", size = 116237, upload-time = "2026-03-06T02:54:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/27fc67914e68d740bce512f11734aec08696e6b17641fef8867c00c949fc/wrapt-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7dfa9f2cf65d027b951d05c662cc99ee3bd01f6e4691ed39848a7a5fffc902b2", size = 120563, upload-time = "2026-03-06T02:53:20.412Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9f/b750b3692ed2ef4705cb305bd68858e73010492b80e43d2a4faa5573cbe7/wrapt-2.1.2-cp312-cp312-win32.whl", hash = "sha256:eba8155747eb2cae4a0b913d9ebd12a1db4d860fc4c829d7578c7b989bd3f2f0", size = 58198, upload-time = "2026-03-06T02:53:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b2/feecfe29f28483d888d76a48f03c4c4d8afea944dbee2b0cd3380f9df032/wrapt-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1c51c738d7d9faa0b3601708e7e2eda9bf779e1b601dce6c77411f2a1b324a63", size = 60441, upload-time = "2026-03-06T02:52:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/44/e1/e328f605d6e208547ea9fd120804fcdec68536ac748987a68c47c606eea8/wrapt-2.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:c8e46ae8e4032792eb2f677dbd0d557170a8e5524d22acc55199f43efedd39bf", size = 58836, upload-time = "2026-03-06T02:53:22.053Z" }, + { url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" }, + { url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" }, + { url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" }, + { url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" }, + { url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" }, + { url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" }, + { url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" }, + { url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" }, + { url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" }, + { url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" }, + { url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" }, + { url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +]