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" },
+]