From 5cf86416f0c4cad0f20986a38256390206b53ade Mon Sep 17 00:00:00 2001 From: timonrieger Date: Sun, 11 Jan 2026 22:15:59 +0100 Subject: [PATCH 01/26] Add end-to-end testing workflow to CI/CD pipeline --- .github/workflows/cd.yml | 65 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index f8b1e32..0de4684 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -46,6 +46,71 @@ jobs: - name: Run unit tests run: mise run ci:test:unit + e2e-tests: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Check-out repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + with: + persist-credentials: false + + - name: Install uv + Python 3.14 + uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7 + with: + version: "0.9.21" + python-version: "3.14" + + - name: Install the project + run: uv sync --locked --dev + + - name: Read Immich version + id: immich-version + run: echo "version=$(cat IMMICH-VERSION | tr -d 'v')" >> $GITHUB_OUTPUT + + - name: Clone Immich repository + run: | + git clone --depth 1 --branch v${{ steps.immich-version.outputs.version }} https://github.com/immich-app/immich.git immich + env: + IMMICH_VERSION: ${{ steps.immich-version.outputs.version }} + + - name: Start Immich server + working-directory: ./immich/e2e + run: | + docker compose up -d --build + echo "Waiting for Immich server to be ready..." + timeout=120 + elapsed=0 + while ! curl -f http://127.0.0.1:2285/api/server-info/ping > /dev/null 2>&1; do + if [ $elapsed -ge $timeout ]; then + echo "Server failed to start within $timeout seconds" + docker compose logs + exit 1 + fi + sleep 2 + elapsed=$((elapsed + 2)) + echo "Waiting for server... (${elapsed}s/${timeout}s)" + done + echo "Server is ready!" + + - name: Run e2e tests + env: + IMMICH_URL: http://127.0.0.1:2285 + IMMICH_API_URL: http://127.0.0.1:2285/api + run: uv run pytest tests/e2e + + - name: Show docker logs on failure + if: failure() + working-directory: ./immich/e2e + run: docker compose logs + + - name: Teardown Immich server + if: always() + working-directory: ./immich/e2e + run: docker compose down -v + release: runs-on: ubuntu-latest needs: [lint, test] From a455d9531623cad0668319da289d12ffa15cd259 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Sun, 11 Jan 2026 22:16:10 +0100 Subject: [PATCH 02/26] reorg tests --- bin/generate.py | 10 ---------- tests/{ => unit}/test_async_client.py | 0 tests/{ => unit}/test_async_custom_session.py | 0 tests/{ => unit}/test_download.py | 0 tests/{ => unit}/test_upload.py | 0 5 files changed, 10 deletions(-) rename tests/{ => unit}/test_async_client.py (100%) rename tests/{ => unit}/test_async_custom_session.py (100%) rename tests/{ => unit}/test_download.py (100%) rename tests/{ => unit}/test_upload.py (100%) diff --git a/bin/generate.py b/bin/generate.py index ca6d714..b3762cb 100644 --- a/bin/generate.py +++ b/bin/generate.py @@ -72,16 +72,6 @@ def main() -> int: print(f"Generating Immich client from ref: {args.ref}") print(f"Spec URL: {url}") - # quick sanity check that the spec is reachable - try: - resp = requests.get(url, timeout=30) - if resp.status_code != 200: - print(f"Failed to fetch OpenAPI spec (status={resp.status_code})") - return 1 - except requests.RequestException as e: - print(f"Failed to fetch OpenAPI spec: {e}") - return 1 - if client_dir.exists(): print("Deleting existing generated client folder:", client_dir) shutil.rmtree(client_dir) diff --git a/tests/test_async_client.py b/tests/unit/test_async_client.py similarity index 100% rename from tests/test_async_client.py rename to tests/unit/test_async_client.py diff --git a/tests/test_async_custom_session.py b/tests/unit/test_async_custom_session.py similarity index 100% rename from tests/test_async_custom_session.py rename to tests/unit/test_async_custom_session.py diff --git a/tests/test_download.py b/tests/unit/test_download.py similarity index 100% rename from tests/test_download.py rename to tests/unit/test_download.py diff --git a/tests/test_upload.py b/tests/unit/test_upload.py similarity index 100% rename from tests/test_upload.py rename to tests/unit/test_upload.py From d013c68ade76a74039ff55f95ef822de388aad5d Mon Sep 17 00:00:00 2001 From: timonrieger Date: Sun, 11 Jan 2026 22:42:51 +0100 Subject: [PATCH 03/26] update tests --- .github/workflows/cd.yml | 16 ++++++---------- mise.toml | 11 +++++++++++ 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 0de4684..18b4284 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -57,22 +57,18 @@ jobs: with: persist-credentials: false - - name: Install uv + Python 3.14 - uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7 + - name: Install mise + uses: jdx/mise-action@146a28175021df8ca24f8ee1828cc2a60f980bd5 # v3 with: - version: "0.9.21" - python-version: "3.14" - - - name: Install the project - run: uv sync --locked --dev + install-args: "python@3.14" - name: Read Immich version id: immich-version - run: echo "version=$(cat IMMICH-VERSION | tr -d 'v')" >> $GITHUB_OUTPUT + run: echo "version=$(cat IMMICH-VERSION | tr -d '[:space:]')" >> $GITHUB_OUTPUT - name: Clone Immich repository run: | - git clone --depth 1 --branch v${{ steps.immich-version.outputs.version }} https://github.com/immich-app/immich.git immich + git clone --depth 1 --branch $IMMICH_VERSION https://github.com/immich-app/immich.git immich env: IMMICH_VERSION: ${{ steps.immich-version.outputs.version }} @@ -99,7 +95,7 @@ jobs: env: IMMICH_URL: http://127.0.0.1:2285 IMMICH_API_URL: http://127.0.0.1:2285/api - run: uv run pytest tests/e2e + run: mise run ci:test:e2e - name: Show docker logs on failure if: failure() diff --git a/mise.toml b/mise.toml index e2decb0..c5d8c98 100644 --- a/mise.toml +++ b/mise.toml @@ -18,6 +18,10 @@ description = "Run type checking" run = "uv run pytest tests/" description = "Run tests with pytest" +[tasks."test:e2e"] +run = "uv run pytest tests/e2e" +description = "Run e2e tests" + [tasks."test:unit:cov"] run = "uv run pytest tests/ --cov=immich --cov-report=html --cov-report=term" description = "Run tests with coverage report" @@ -80,6 +84,13 @@ run = [ { task = "test:unit" }, ] +[tasks."ci:test:e2e"] +description = "Run e2e tests in CI" +run = [ + { task = "ci:install" }, + { task = "test:e2e" }, +] + [tasks.clean] run = "rm -rf build/ dist/ *.egg-info/ .pytest_cache/ htmlcov/ .coverage .ruff_cache/" description = "Clean build artifacts and cache files" From a4997f9a36f57add757c85e3e9e28186dd7747b0 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Sun, 11 Jan 2026 22:54:17 +0100 Subject: [PATCH 04/26] Add end-to-end tests for client_wrapper modules and update pytest configuration --- pyproject.toml | 4 + tests/e2e/client/__init__.py | 0 tests/e2e/client/test_wrappers.py | 221 ++++++++++++++++++++++++++++++ 3 files changed, 225 insertions(+) create mode 100644 tests/e2e/client/__init__.py create mode 100644 tests/e2e/client/test_wrappers.py diff --git a/pyproject.toml b/pyproject.toml index 4df65a8..9df6301 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,6 +84,10 @@ lint.ignore = ["E721", "F403"] [tool.pytest.ini_options] asyncio_default_fixture_loop_scope = "function" +asyncio_mode = "auto" +markers = [ + "e2e: marks tests as end-to-end tests (deselect with '-m \"not e2e\"')", +] [tool.ty] environment.root = ['immich'] diff --git a/tests/e2e/client/__init__.py b/tests/e2e/client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/client/test_wrappers.py b/tests/e2e/client/test_wrappers.py new file mode 100644 index 0000000..b58f577 --- /dev/null +++ b/tests/e2e/client/test_wrappers.py @@ -0,0 +1,221 @@ +"""E2E tests for immich.client_wrapper modules against running Immich server.""" + +from __future__ import annotations + +import base64 +import os +from pathlib import Path +from uuid import UUID + +import pytest + +from immich import AsyncClient +from immich.client.models.admin_onboarding_update_dto import AdminOnboardingUpdateDto +from immich.client.models.api_key_create_dto import APIKeyCreateDto +from immich.client.models.asset_media_size import AssetMediaSize +from immich.client.models.download_info_dto import DownloadInfoDto +from immich.client.models.login_credential_dto import LoginCredentialDto +from immich.client.models.permission import Permission +from immich.client.models.sign_up_dto import SignUpDto + +# Minimal 1x1 JPEG (base64) - valid minimal JPEG +JPEG_BASE64 = "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/wA==" + +# Minimal MP4 (base64) - minimal valid MP4 header +MP4_BASE64 = "AAAAIGZ0eXBtcDQyAAAAAG1wNDJtcDQxaXNvbWF2YzEAAAGhtZGF0AAACrgYF//+q3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDE0OCByMjA1OCBlYjc2Y2U1IC0gSC4yNjQvTVBFRy00IEFWQyBjb2RlYyAtIENvcHlsZWZ0IDIwMDMtMjAxNyAtIGh0dHA6Ly93d3cudmlkZW9sYW4ub3JnL3gyNjQuaHRtbCAtIG9wdGlvbnM6IGNhYmFjPTEgcmVmPTMgZGVibG9jaz0xOjA6MCBhbmFseXNlPTB4MzoweDExMyBtZT1oZXggc3VibWU9NyBwc3k9MSBwc3lfcmQ9MS4wMDowLjAwIG1peGVkX3JlZj0xIG1lX3JhbmdlPTE2IGNocm9tYV9tZT0xIHRyZWxsaXM9MSA4eDhkY3Q9MSBjcW09MCBkZWFkem9uZT0yMSwxMSBmYXN0X3Bza2lwPTEgY2hyb21hX3FwX29mZnNldD0tMiB0aHJlYWRzPTEgbG9va2FoZWFkX3RocmVhZHM9MSBzbGljZWRfdGhyZWFkcz0wIG5yPTAgZGVjaW1hdGU9MSBpbnRlcmxhY2VkPTAgYmx1cmF5X2NvbXBhdD0wIGNvbnN0cmFpbmVkX2ludHJhPTAgYmZyYW1lcz0zIGJfcHlyYW1pZD0yIGJfYWRhcHQ9MSBiX2JpYXM9MCBkaXJlY3Q9MSB3ZWlnaHRiPTEgb3Blbl9nb3A9MCB3ZWlnaHRwPTIga2V5aW50PTI1MCBrZXlpbnRfbWluPTI1IHNjZW5lY3V0PTQwIGludHJhX3JlZnJlc2g9MCByY19sb29rYWhlYWQ9NDAgcmM9Y3JmIG1idHJlZT0xIGNyZj0yMy4wIHFjb21wPTAuNjAgcXBtaW49MCBxcG1heD02OSBxcHN0ZXA9NCBpcF9yYXRpbz0xLjQwIGFxPTE6MS4wMAA=" + + +@pytest.fixture +async def client_with_api_key(tmp_path: Path): + """Set up admin user, create API key, and return authenticated client.""" + base_url = os.environ.get("IMMICH_API_URL", "http://127.0.0.1:2285/api") + + # Create unauthenticated client for setup + setup_client = AsyncClient(base_url=base_url) + + try: + # Sign up admin + await setup_client.authentication.sign_up_admin( + SignUpDto(email="admin@immich.cloud", name="Immich Admin", password="password") + ) + + # Login to get access token + login_response = await setup_client.authentication.login( + LoginCredentialDto(email="admin@immich.cloud", password="password") + ) + + # Mark admin as onboarded + await setup_client.system_metadata.update_admin_onboarding( + AdminOnboardingUpdateDto(is_onboarded=True), + _headers={"Authorization": f"Bearer {login_response.access_token}"}, + ) + + # Create API key with all permissions + api_key_response = await setup_client.api_keys.create_api_key( + APIKeyCreateDto(name="e2e", permissions=[Permission.ALL]), + _headers={"Authorization": f"Bearer {login_response.access_token}"}, + ) + + # Create authenticated client with API key + client = AsyncClient(base_url=base_url, api_key=api_key_response.secret) + + yield client + + await client.close() + finally: + await setup_client.close() + + +@pytest.fixture +def test_image(tmp_path: Path) -> Path: + """Create a minimal JPEG test image.""" + img_path = tmp_path / "test.jpg" + img_path.write_bytes(base64.b64decode(JPEG_BASE64)) + return img_path + + +@pytest.fixture +def test_video(tmp_path: Path) -> Path: + """Create a minimal MP4 test video.""" + vid_path = tmp_path / "test.mp4" + vid_path.write_bytes(base64.b64decode(MP4_BASE64)) + return vid_path + + +@pytest.mark.asyncio +@pytest.mark.e2e +async def test_assets_upload(client_with_api_key: AsyncClient, test_image: Path, test_video: Path, tmp_path: Path): + """Test AssetsApiWrapped.upload method.""" + result = await client_with_api_key.assets.upload( + [test_image, test_video], + check_duplicates=True, + concurrency=2, + show_progress=False, + ) + + assert result.stats.total == 2 + assert result.stats.uploaded == 2 + assert len(result.uploaded) == 2 + assert len(result.rejected) == 0 + assert len(result.failed) == 0 + + +@pytest.mark.asyncio +@pytest.mark.e2e +async def test_assets_download_asset_to_file( + client_with_api_key: AsyncClient, test_image: Path, tmp_path: Path +): + """Test AssetsApiWrapped.download_asset_to_file method.""" + # Upload an asset first + upload_result = await client_with_api_key.assets.upload( + [test_image], check_duplicates=True, show_progress=False + ) + assert len(upload_result.uploaded) == 1 + asset_id = UUID(upload_result.uploaded[0].asset.id) + + # Download the asset + out_dir = tmp_path / "downloads" + downloaded_path = await client_with_api_key.assets.download_asset_to_file( + id=asset_id, out_dir=out_dir, show_progress=False + ) + + assert downloaded_path.exists() + assert downloaded_path.is_file() + assert downloaded_path.read_bytes() == test_image.read_bytes() + + +@pytest.mark.asyncio +@pytest.mark.e2e +async def test_assets_view_asset_to_file( + client_with_api_key: AsyncClient, test_image: Path, tmp_path: Path +): + """Test AssetsApiWrapped.view_asset_to_file method.""" + # Upload an asset first + upload_result = await client_with_api_key.assets.upload( + [test_image], check_duplicates=True, show_progress=False + ) + assert len(upload_result.uploaded) == 1 + asset_id = UUID(upload_result.uploaded[0].asset.id) + + # Download thumbnail + out_dir = tmp_path / "thumbnails" + thumbnail_path = await client_with_api_key.assets.view_asset_to_file( + id=asset_id, out_dir=out_dir, size=AssetMediaSize.THUMBNAIL, show_progress=False + ) + + assert thumbnail_path.exists() + assert thumbnail_path.is_file() + + +@pytest.mark.asyncio +@pytest.mark.e2e +async def test_assets_play_asset_video_to_file( + client_with_api_key: AsyncClient, test_video: Path, tmp_path: Path +): + """Test AssetsApiWrapped.play_asset_video_to_file method.""" + # Upload a video first + upload_result = await client_with_api_key.assets.upload( + [test_video], check_duplicates=True, show_progress=False + ) + assert len(upload_result.uploaded) == 1 + asset_id = UUID(upload_result.uploaded[0].asset.id) + + # Download video stream + out_dir = tmp_path / "videos" + video_path = await client_with_api_key.assets.play_asset_video_to_file( + id=asset_id, out_dir=out_dir, show_progress=False + ) + + assert video_path.exists() + assert video_path.is_file() + + +@pytest.mark.asyncio +@pytest.mark.e2e +async def test_download_archive_to_file( + client_with_api_key: AsyncClient, test_image: Path, tmp_path: Path +): + """Test DownloadApiWrapped.download_archive_to_file method.""" + # Upload assets first + upload_result = await client_with_api_key.assets.upload( + [test_image], check_duplicates=True, show_progress=False + ) + assert len(upload_result.uploaded) == 1 + asset_id = UUID(upload_result.uploaded[0].asset.id) + + # Create download info + download_info = DownloadInfoDto(asset_ids=[asset_id]) + + # Download archive + out_dir = tmp_path / "archives" + archive_paths = await client_with_api_key.download.download_archive_to_file( + download_info=download_info, out_dir=out_dir, show_progress=False + ) + + assert len(archive_paths) == 1 + assert archive_paths[0].exists() + assert archive_paths[0].suffix == ".zip" + + +@pytest.mark.asyncio +@pytest.mark.e2e +async def test_users_get_profile_image_to_file( + client_with_api_key: AsyncClient, test_image: Path, tmp_path: Path +): + """Test UsersApiWrapped.get_profile_image_to_file method.""" + # Get current user info + my_user = await client_with_api_key.users.get_my_user() + user_id = UUID(my_user.id) + + # Upload profile image + img_bytes = test_image.read_bytes() + await client_with_api_key.users.create_profile_image(file=img_bytes) + + # Download profile image + out_dir = tmp_path / "profiles" + profile_path = await client_with_api_key.users.get_profile_image_to_file( + id=user_id, out_dir=out_dir, show_progress=False + ) + + assert profile_path.exists() + assert profile_path.is_file() From e7f365790b5a4b7312efdb34c41b1089d5034161 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Sun, 11 Jan 2026 23:17:09 +0100 Subject: [PATCH 05/26] fix test path --- mise.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mise.toml b/mise.toml index c5d8c98..e9082b5 100644 --- a/mise.toml +++ b/mise.toml @@ -15,7 +15,7 @@ run = "uv run ty check" description = "Run type checking" [tasks."test:unit"] -run = "uv run pytest tests/" +run = "uv run pytest tests/unit" description = "Run tests with pytest" [tasks."test:e2e"] @@ -23,7 +23,7 @@ run = "uv run pytest tests/e2e" description = "Run e2e tests" [tasks."test:unit:cov"] -run = "uv run pytest tests/ --cov=immich --cov-report=html --cov-report=term" +run = "uv run pytest tests/unit --cov=immich --cov-report=html --cov-report=term" description = "Run tests with coverage report" [tasks.install] From 5a4612cb89a4d6b2da31b1436957bd787767724a Mon Sep 17 00:00:00 2001 From: timonrieger Date: Sun, 11 Jan 2026 23:17:54 +0100 Subject: [PATCH 06/26] avoid name collision --- .github/workflows/cd.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 18b4284..9df5e38 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -68,12 +68,12 @@ jobs: - name: Clone Immich repository run: | - git clone --depth 1 --branch $IMMICH_VERSION https://github.com/immich-app/immich.git immich + git clone --depth 1 --branch $IMMICH_VERSION https://github.com/immich-app/immich.git immich-git-repo env: IMMICH_VERSION: ${{ steps.immich-version.outputs.version }} - name: Start Immich server - working-directory: ./immich/e2e + working-directory: ./immich-git-repo/e2e run: | docker compose up -d --build echo "Waiting for Immich server to be ready..." @@ -99,12 +99,12 @@ jobs: - name: Show docker logs on failure if: failure() - working-directory: ./immich/e2e + working-directory: ./immich-git-repo/e2e run: docker compose logs - name: Teardown Immich server if: always() - working-directory: ./immich/e2e + working-directory: ./immich-git-repo/e2e run: docker compose down -v release: From a45f4ad93e3f28545bc3a5debb86ec6ac4a5885f Mon Sep 17 00:00:00 2001 From: timonrieger Date: Mon, 12 Jan 2026 10:21:14 +0100 Subject: [PATCH 07/26] lint --- bin/generate.py | 1 - tests/e2e/client/test_wrappers.py | 50 +++++++++++++++++-------------- 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/bin/generate.py b/bin/generate.py index b3762cb..0ad47ba 100644 --- a/bin/generate.py +++ b/bin/generate.py @@ -13,7 +13,6 @@ import shutil import subprocess # nosec: B404 from pathlib import Path -import requests # type: ignore[import-untyped] def project_root() -> Path: diff --git a/tests/e2e/client/test_wrappers.py b/tests/e2e/client/test_wrappers.py index b58f577..07aaa82 100644 --- a/tests/e2e/client/test_wrappers.py +++ b/tests/e2e/client/test_wrappers.py @@ -29,38 +29,40 @@ async def client_with_api_key(tmp_path: Path): """Set up admin user, create API key, and return authenticated client.""" base_url = os.environ.get("IMMICH_API_URL", "http://127.0.0.1:2285/api") - + # Create unauthenticated client for setup setup_client = AsyncClient(base_url=base_url) - + try: # Sign up admin await setup_client.authentication.sign_up_admin( - SignUpDto(email="admin@immich.cloud", name="Immich Admin", password="password") + SignUpDto( + email="admin@immich.cloud", name="Immich Admin", password="password" + ) ) - + # Login to get access token login_response = await setup_client.authentication.login( LoginCredentialDto(email="admin@immich.cloud", password="password") ) - + # Mark admin as onboarded await setup_client.system_metadata.update_admin_onboarding( AdminOnboardingUpdateDto(is_onboarded=True), _headers={"Authorization": f"Bearer {login_response.access_token}"}, ) - + # Create API key with all permissions api_key_response = await setup_client.api_keys.create_api_key( APIKeyCreateDto(name="e2e", permissions=[Permission.ALL]), _headers={"Authorization": f"Bearer {login_response.access_token}"}, ) - + # Create authenticated client with API key client = AsyncClient(base_url=base_url, api_key=api_key_response.secret) - + yield client - + await client.close() finally: await setup_client.close() @@ -84,7 +86,9 @@ def test_video(tmp_path: Path) -> Path: @pytest.mark.asyncio @pytest.mark.e2e -async def test_assets_upload(client_with_api_key: AsyncClient, test_image: Path, test_video: Path, tmp_path: Path): +async def test_assets_upload( + client_with_api_key: AsyncClient, test_image: Path, test_video: Path, tmp_path: Path +): """Test AssetsApiWrapped.upload method.""" result = await client_with_api_key.assets.upload( [test_image, test_video], @@ -92,7 +96,7 @@ async def test_assets_upload(client_with_api_key: AsyncClient, test_image: Path, concurrency=2, show_progress=False, ) - + assert result.stats.total == 2 assert result.stats.uploaded == 2 assert len(result.uploaded) == 2 @@ -112,13 +116,13 @@ async def test_assets_download_asset_to_file( ) assert len(upload_result.uploaded) == 1 asset_id = UUID(upload_result.uploaded[0].asset.id) - + # Download the asset out_dir = tmp_path / "downloads" downloaded_path = await client_with_api_key.assets.download_asset_to_file( id=asset_id, out_dir=out_dir, show_progress=False ) - + assert downloaded_path.exists() assert downloaded_path.is_file() assert downloaded_path.read_bytes() == test_image.read_bytes() @@ -136,13 +140,13 @@ async def test_assets_view_asset_to_file( ) assert len(upload_result.uploaded) == 1 asset_id = UUID(upload_result.uploaded[0].asset.id) - + # Download thumbnail out_dir = tmp_path / "thumbnails" thumbnail_path = await client_with_api_key.assets.view_asset_to_file( id=asset_id, out_dir=out_dir, size=AssetMediaSize.THUMBNAIL, show_progress=False ) - + assert thumbnail_path.exists() assert thumbnail_path.is_file() @@ -159,13 +163,13 @@ async def test_assets_play_asset_video_to_file( ) assert len(upload_result.uploaded) == 1 asset_id = UUID(upload_result.uploaded[0].asset.id) - + # Download video stream out_dir = tmp_path / "videos" video_path = await client_with_api_key.assets.play_asset_video_to_file( id=asset_id, out_dir=out_dir, show_progress=False ) - + assert video_path.exists() assert video_path.is_file() @@ -182,16 +186,16 @@ async def test_download_archive_to_file( ) assert len(upload_result.uploaded) == 1 asset_id = UUID(upload_result.uploaded[0].asset.id) - + # Create download info download_info = DownloadInfoDto(asset_ids=[asset_id]) - + # Download archive out_dir = tmp_path / "archives" archive_paths = await client_with_api_key.download.download_archive_to_file( download_info=download_info, out_dir=out_dir, show_progress=False ) - + assert len(archive_paths) == 1 assert archive_paths[0].exists() assert archive_paths[0].suffix == ".zip" @@ -206,16 +210,16 @@ async def test_users_get_profile_image_to_file( # Get current user info my_user = await client_with_api_key.users.get_my_user() user_id = UUID(my_user.id) - + # Upload profile image img_bytes = test_image.read_bytes() await client_with_api_key.users.create_profile_image(file=img_bytes) - + # Download profile image out_dir = tmp_path / "profiles" profile_path = await client_with_api_key.users.get_profile_image_to_file( id=user_id, out_dir=out_dir, show_progress=False ) - + assert profile_path.exists() assert profile_path.is_file() From 79813622bd4ac1dbdd2a1e116fd2089aba5a452c Mon Sep 17 00:00:00 2001 From: timonrieger Date: Mon, 12 Jan 2026 10:39:22 +0100 Subject: [PATCH 08/26] Add GitHub Container Registry login step to CI/CD workflow to avoid mise rebuild causing github api rate limits --- .github/workflows/cd.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 9df5e38..cad887a 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -72,6 +72,10 @@ jobs: env: IMMICH_VERSION: ${{ steps.immich-version.outputs.version }} + - name: Login to GitHub Container Registry + run: | + echo "${{ github.token }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + - name: Start Immich server working-directory: ./immich-git-repo/e2e run: | From 85d6d7fd427aa181c7337a92eec97e82ba02d98f Mon Sep 17 00:00:00 2001 From: timonrieger Date: Mon, 12 Jan 2026 10:47:38 +0100 Subject: [PATCH 09/26] type check --- tests/e2e/client/test_wrappers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/e2e/client/test_wrappers.py b/tests/e2e/client/test_wrappers.py index 07aaa82..ed54bee 100644 --- a/tests/e2e/client/test_wrappers.py +++ b/tests/e2e/client/test_wrappers.py @@ -48,7 +48,8 @@ async def client_with_api_key(tmp_path: Path): # Mark admin as onboarded await setup_client.system_metadata.update_admin_onboarding( - AdminOnboardingUpdateDto(is_onboarded=True), + # NOTE: type ignore likely a ty issue + AdminOnboardingUpdateDto(is_onboarded=True), # type: ignore[missing-argument] _headers={"Authorization": f"Bearer {login_response.access_token}"}, ) From 91bff2dc899546ae74727fa82fb1190103af2cab Mon Sep 17 00:00:00 2001 From: timonrieger Date: Mon, 12 Jan 2026 10:52:15 +0100 Subject: [PATCH 10/26] Increase timeout duration in CI/CD workflow for Immich server readiness check from 120 to 180 seconds. --- .github/workflows/cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index cad887a..fbcf3c0 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -81,7 +81,7 @@ jobs: run: | docker compose up -d --build echo "Waiting for Immich server to be ready..." - timeout=120 + timeout=180 elapsed=0 while ! curl -f http://127.0.0.1:2285/api/server-info/ping > /dev/null 2>&1; do if [ $elapsed -ge $timeout ]; then From 1bdab8a5d79085e2b6411a700cb9b7f294bd08a9 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Mon, 12 Jan 2026 10:57:28 +0100 Subject: [PATCH 11/26] run end-to-end tests on main branch pushes and pull requests with "run-e2e" label --- .github/workflows/cd.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index fbcf3c0..504b54f 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -48,6 +48,7 @@ jobs: e2e-tests: runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-e2e') permissions: contents: read From 5fb558e63ae870edc0890c8264ef16fb6e0e882b Mon Sep 17 00:00:00 2001 From: Timon Date: Mon, 12 Jan 2026 10:58:32 +0100 Subject: [PATCH 12/26] gate release with e2e tests Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .github/workflows/cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 504b54f..7b69ac3 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -114,7 +114,7 @@ jobs: release: runs-on: ubuntu-latest - needs: [lint, test] + needs: [lint, test, e2e-tests] if: github.ref == 'refs/heads/main' && github.event_name == 'push' concurrency: group: ${{ github.workflow }}-release-${{ github.ref_name }} From a52a47d695edc797f65d05fc77ab779adac28753 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Mon, 12 Jan 2026 11:01:00 +0100 Subject: [PATCH 13/26] trigger e2e tests --- .github/workflows/cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 7b69ac3..23a6d05 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -48,7 +48,7 @@ jobs: e2e-tests: runs-on: ubuntu-latest - if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-e2e') + if: github.ref == 'refs/heads/main' && github.event_name == 'push' || github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-e2e') permissions: contents: read From d27aca282c7b2ec97aa80905d8e0c147237cc983 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Mon, 12 Jan 2026 11:18:16 +0100 Subject: [PATCH 14/26] match upstream test --- .github/workflows/cd.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 23a6d05..4bf7824 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -80,14 +80,19 @@ jobs: - name: Start Immich server working-directory: ./immich-git-repo/e2e run: | - docker compose up -d --build + docker compose up --build --renew-anon-volumes --force-recreate --remove-orphans > /tmp/docker-compose.log 2>&1 & + COMPOSE_PID=$! echo "Waiting for Immich server to be ready..." - timeout=180 + timeout=60 elapsed=0 - while ! curl -f http://127.0.0.1:2285/api/server-info/ping > /dev/null 2>&1; do + while ! grep -q "Immich Microservices is running" /tmp/docker-compose.log 2>/dev/null; do if [ $elapsed -ge $timeout ]; then echo "Server failed to start within $timeout seconds" + echo "=== Docker Compose Logs ===" + cat /tmp/docker-compose.log + echo "=== Docker Compose Status ===" docker compose logs + kill $COMPOSE_PID 2>/dev/null || true exit 1 fi sleep 2 From f09dc78ca7fcf3a3b717ae809db117923a2cf93e Mon Sep 17 00:00:00 2001 From: timonrieger Date: Mon, 12 Jan 2026 11:29:16 +0100 Subject: [PATCH 15/26] Refactor Immich server startup in CI/CD workflow to align with upstream e2e setup, improving readiness check and simplifying logs handling. --- .github/workflows/cd.yml | 27 ++++++--------------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 4bf7824..2da7e8b 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -80,26 +80,11 @@ jobs: - name: Start Immich server working-directory: ./immich-git-repo/e2e run: | - docker compose up --build --renew-anon-volumes --force-recreate --remove-orphans > /tmp/docker-compose.log 2>&1 & - COMPOSE_PID=$! - echo "Waiting for Immich server to be ready..." - timeout=60 - elapsed=0 - while ! grep -q "Immich Microservices is running" /tmp/docker-compose.log 2>/dev/null; do - if [ $elapsed -ge $timeout ]; then - echo "Server failed to start within $timeout seconds" - echo "=== Docker Compose Logs ===" - cat /tmp/docker-compose.log - echo "=== Docker Compose Status ===" - docker compose logs - kill $COMPOSE_PID 2>/dev/null || true - exit 1 - fi - sleep 2 - elapsed=$((elapsed + 2)) - echo "Waiting for server... (${elapsed}s/${timeout}s)" - done - echo "Server is ready!" + # matching with upstream Immich e2e setup (`e2e/src/setup/docker-compose.ts`) + docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans + echo "Waiting for Immich e2e environment to be ready..." + timeout 60s bash -lc 'docker compose logs -f --no-color --tail=0 | grep -m 1 "Immich Microservices is running"' + echo "Immich e2e environment is ready!" - name: Run e2e tests env: @@ -115,7 +100,7 @@ jobs: - name: Teardown Immich server if: always() working-directory: ./immich-git-repo/e2e - run: docker compose down -v + run: docker compose down release: runs-on: ubuntu-latest From 1d307e2c13ecaa2bf3ea1cbc2fdae8a01abfd2c0 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Mon, 12 Jan 2026 12:00:54 +0100 Subject: [PATCH 16/26] fix ci --- .github/workflows/cd.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 2da7e8b..535a21a 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -80,10 +80,10 @@ jobs: - name: Start Immich server working-directory: ./immich-git-repo/e2e run: | - # matching with upstream Immich e2e setup (`e2e/src/setup/docker-compose.ts`) + # Match upstream Immich e2e setup (`e2e/src/setup/docker-compose.ts`): docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans echo "Waiting for Immich e2e environment to be ready..." - timeout 60s bash -lc 'docker compose logs -f --no-color --tail=0 | grep -m 1 "Immich Microservices is running"' + timeout 60s bash -lc 'until docker logs immich-e2e-server 2>&1 | grep -q "Immich Microservices is running"; do sleep 1; done' echo "Immich e2e environment is ready!" - name: Run e2e tests From aad797997bb0d945e3b5fbb252a9476c25cdbe71 Mon Sep 17 00:00:00 2001 From: Timon Date: Mon, 12 Jan 2026 12:06:31 +0100 Subject: [PATCH 17/26] fix skip-ci Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .github/workflows/cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 535a21a..867e6c9 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -48,7 +48,7 @@ jobs: e2e-tests: runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' && github.event_name == 'push' || github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-e2e') + if: (github.ref == 'refs/heads/main' && github.event_name == 'push') || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-e2e')) permissions: contents: read From 24190a490374d62e258e48c633a365d4683d3586 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Mon, 12 Jan 2026 20:21:57 +0100 Subject: [PATCH 18/26] make signup idempotent --- tests/e2e/client/test_wrappers.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/e2e/client/test_wrappers.py b/tests/e2e/client/test_wrappers.py index ed54bee..d9bae88 100644 --- a/tests/e2e/client/test_wrappers.py +++ b/tests/e2e/client/test_wrappers.py @@ -10,6 +10,7 @@ import pytest from immich import AsyncClient +from immich.client.exceptions import BadRequestException from immich.client.models.admin_onboarding_update_dto import AdminOnboardingUpdateDto from immich.client.models.api_key_create_dto import APIKeyCreateDto from immich.client.models.asset_media_size import AssetMediaSize @@ -28,18 +29,22 @@ @pytest.fixture async def client_with_api_key(tmp_path: Path): """Set up admin user, create API key, and return authenticated client.""" - base_url = os.environ.get("IMMICH_API_URL", "http://127.0.0.1:2285/api") + base_url = os.environ.get("IMMICH_API_URL", "http://127.0.0.1:2283/api") # Create unauthenticated client for setup setup_client = AsyncClient(base_url=base_url) try: - # Sign up admin - await setup_client.authentication.sign_up_admin( - SignUpDto( - email="admin@immich.cloud", name="Immich Admin", password="password" + # Sign up admin (idempotent: subsequent tests will hit "already has an admin") + try: + await setup_client.authentication.sign_up_admin( + SignUpDto( + email="admin@immich.cloud", name="Immich Admin", password="password" + ) ) - ) + except BadRequestException as e: + if not (e.status == 400 and e.body and "already has an admin" in e.body): + raise # Login to get access token login_response = await setup_client.authentication.login( From 0cb460db5ad49b46469446f0cc90a47253b424dd Mon Sep 17 00:00:00 2001 From: timonrieger Date: Mon, 12 Jan 2026 20:34:50 +0100 Subject: [PATCH 19/26] fix tests --- .github/workflows/cd.yml | 5 +- pyproject.toml | 1 + tests/e2e/client/generators.py | 79 +++++++++++++++++++++++ tests/e2e/client/test_wrappers.py | 101 +++++++++++++++++++++++------- uv.lock | 61 ++++++++++++++++++ 5 files changed, 225 insertions(+), 22 deletions(-) create mode 100644 tests/e2e/client/generators.py diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 867e6c9..2d8ab39 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -80,11 +80,14 @@ jobs: - name: Start Immich server working-directory: ./immich-git-repo/e2e run: | - # Match upstream Immich e2e setup (`e2e/src/setup/docker-compose.ts`): docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans echo "Waiting for Immich e2e environment to be ready..." timeout 60s bash -lc 'until docker logs immich-e2e-server 2>&1 | grep -q "Immich Microservices is running"; do sleep 1; done' echo "Immich e2e environment is ready!" + # Note: This startup process is identical to the one in Immich's e2e setup. + # We are depending on the same log message to be printed. For reference, see: + # - server/src/workers/microservices.ts + # - e2e/src/setup/docker-compose.ts - name: Run e2e tests env: diff --git a/pyproject.toml b/pyproject.toml index 9df6301..1eefbdc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ dev = [ "pytest-asyncio>=1.2.0,<2.0.0", "pytest-cov>=7.0.0,<8.0.0", "ty>=0.0.10,<0.0.11", + "pillow>=10.0.0,<11.0.0", ] [tool.semantic_release] diff --git a/tests/e2e/client/generators.py b/tests/e2e/client/generators.py new file mode 100644 index 0000000..06b8823 --- /dev/null +++ b/tests/e2e/client/generators.py @@ -0,0 +1,79 @@ +"""Image and video generators for E2E tests.""" + +from __future__ import annotations + +import io +import time +from typing import Iterator + +from PIL import Image + + +def _create_jpeg(r: int, g: int, b: int) -> bytes: + """Create a minimal 1x1 JPEG with the specified RGB color.""" + img = Image.new("RGB", (1, 1), (r, g, b)) + buffer = io.BytesIO() + img.save(buffer, format="JPEG", quality=95) + return buffer.getvalue() + + +def _new_jpeg_factory() -> Iterator[bytes]: + """Generator that yields unique JPEG images by cycling through RGB values.""" + for r in range(0, 256, 16): # Step by 16 to avoid too many combinations + for g in range(0, 256, 16): + for b in range(0, 256, 16): + yield _create_jpeg(r, g, b) + + +_jpeg_factory = _new_jpeg_factory() + + +def make_random_image() -> bytes: + """Generate a random unique JPEG image for testing.""" + global _jpeg_factory + try: + return next(_jpeg_factory) + except StopIteration: + # Reset factory if we run out + _jpeg_factory = _new_jpeg_factory() + return next(_jpeg_factory) + + +def _create_minimal_mp4(seed: int) -> bytes: + """Create a minimal valid MP4 with variable seed data.""" + import base64 + + # Use the original minimal MP4 template (valid MP4 header) + # This is a minimal valid MP4 file that Immich can process + mp4_base64 = "AAAAIGZ0eXBtcDQyAAAAAG1wNDJtcDQxaXNvbWF2YzEAAAGhtZGF0AAACrgYF//+q3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDE0OCByMjA1OCBlYjc2Y2U1IC0gSC4yNjQvTVBFRy00IEFWQyBjb2RlYyAtIENvcHlsZWZ0IDIwMDMtMjAxNyAtIGh0dHA6Ly93d3cudmlkZW9sYW4ub3JnL3gyNjQuaHRtbCAtIG9wdGlvbnM6IGNhYmFjPTEgcmVmPTMgZGVibG9jaz0xOjA6MCBhbmFseXNlPTB4MzoweDExMyBtZT1oZXggc3VibWU9NyBwc3k9MSBwc3lfcmQ9MS4wMDowLjAwIG1peGVkX3JlZj0xIG1lX3JhbmdlPTE2IGNocm9tYV9tZT0xIHRyZWxsaXM9MSA4eDhkY3Q9MSBjcW09MCBkZWFkem9uZT0yMSwxMSBmYXN0X3Bza2lwPTEgY2hyb21hX3FwX29mZnNldD0tMiB0aHJlYWRzPTEgbG9va2FoZWFkX3RocmVhZHM9MSBzbGljZWRfdGhyZWFkcz0wIG5yPTAgZGVjaW1hdGU9MSBpbnRlcmxhY2VkPTAgYmx1cmF5X2NvbXBhdD0wIGNvbnN0cmFpbmVkX2ludHJhPTAgYmZyYW1lcz0zIGJfcHlyYW1pZD0yIGJfYWRhcHQ9MSBiX2JpYXM9MCBkaXJlY3Q9MSB3ZWlnaHRiPTEgb3Blbl9nb3A9MCB3ZWlnaHRwPTIga2V5aW50PTI1MCBrZXlpbnRfbWluPTI1IHNjZW5lY3V0PTQwIGludHJhX3JlZnJlc2g9MCByY19sb29rYWhlYWQ9NDAgcmM9Y3JmIG1idHJlZT0xIGNyZj0yMy4wIHFjb21wPTAuNjAgcXBtaW49MCBxcG1heD02OSBxcHN0ZXA9NCBpcF9yYXRpbz0xLjQwIGFxPTE6MS4wMAA=" + mp4_bytes = bytearray(base64.b64decode(mp4_base64)) + + # Make each MP4 unique by replacing a section with seed-based data + # Replace bytes in multiple locations to ensure significant content difference + seed_bytes = seed.to_bytes(8, byteorder='big') + # Modify multiple sections to ensure uniqueness + offsets = [100, 200, 300, 400] + for offset in offsets: + if offset < len(mp4_bytes) - len(seed_bytes): + for i, byte_val in enumerate(seed_bytes): + if offset + i < len(mp4_bytes): + # Replace with seed data to ensure uniqueness + mp4_bytes[offset + i] = (mp4_bytes[offset + i] + byte_val + seed) % 256 + + return bytes(mp4_bytes) + + +def _new_mp4_factory() -> Iterator[bytes]: + """Generator that yields unique MP4 videos by incrementing seed.""" + seed = 0 + while True: + yield _create_minimal_mp4(seed) + seed = (seed + 1) % (2**32) + + +_mp4_factory = _new_mp4_factory() + + +def make_random_video() -> bytes: + """Generate a random unique MP4 video for testing.""" + return next(_mp4_factory) diff --git a/tests/e2e/client/test_wrappers.py b/tests/e2e/client/test_wrappers.py index d9bae88..27948df 100644 --- a/tests/e2e/client/test_wrappers.py +++ b/tests/e2e/client/test_wrappers.py @@ -2,7 +2,6 @@ from __future__ import annotations -import base64 import os from pathlib import Path from uuid import UUID @@ -13,21 +12,18 @@ from immich.client.exceptions import BadRequestException from immich.client.models.admin_onboarding_update_dto import AdminOnboardingUpdateDto from immich.client.models.api_key_create_dto import APIKeyCreateDto +from immich.client.models.asset_bulk_delete_dto import AssetBulkDeleteDto from immich.client.models.asset_media_size import AssetMediaSize from immich.client.models.download_info_dto import DownloadInfoDto from immich.client.models.login_credential_dto import LoginCredentialDto from immich.client.models.permission import Permission from immich.client.models.sign_up_dto import SignUpDto -# Minimal 1x1 JPEG (base64) - valid minimal JPEG -JPEG_BASE64 = "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/wA==" - -# Minimal MP4 (base64) - minimal valid MP4 header -MP4_BASE64 = "AAAAIGZ0eXBtcDQyAAAAAG1wNDJtcDQxaXNvbWF2YzEAAAGhtZGF0AAACrgYF//+q3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDE0OCByMjA1OCBlYjc2Y2U1IC0gSC4yNjQvTVBFRy00IEFWQyBjb2RlYyAtIENvcHlsZWZ0IDIwMDMtMjAxNyAtIGh0dHA6Ly93d3cudmlkZW9sYW4ub3JnL3gyNjQuaHRtbCAtIG9wdGlvbnM6IGNhYmFjPTEgcmVmPTMgZGVibG9jaz0xOjA6MCBhbmFseXNlPTB4MzoweDExMyBtZT1oZXggc3VibWU9NyBwc3k9MSBwc3lfcmQ9MS4wMDowLjAwIG1peGVkX3JlZj0xIG1lX3JhbmdlPTE2IGNocm9tYV9tZT0xIHRyZWxsaXM9MSA4eDhkY3Q9MSBjcW09MCBkZWFkem9uZT0yMSwxMSBmYXN0X3Bza2lwPTEgY2hyb21hX3FwX29mZnNldD0tMiB0aHJlYWRzPTEgbG9va2FoZWFkX3RocmVhZHM9MSBzbGljZWRfdGhyZWFkcz0wIG5yPTAgZGVjaW1hdGU9MSBpbnRlcmxhY2VkPTAgYmx1cmF5X2NvbXBhdD0wIGNvbnN0cmFpbmVkX2ludHJhPTAgYmZyYW1lcz0zIGJfcHlyYW1pZD0yIGJfYWRhcHQ9MSBiX2JpYXM9MCBkaXJlY3Q9MSB3ZWlnaHRiPTEgb3Blbl9nb3A9MCB3ZWlnaHRwPTIga2V5aW50PTI1MCBrZXlpbnRfbWluPTI1IHNjZW5lY3V0PTQwIGludHJhX3JlZnJlc2g9MCByY19sb29rYWhlYWQ9NDAgcmM9Y3JmIG1idHJlZT0xIGNyZj0yMy4wIHFjb21wPTAuNjAgcXBtaW49MCBxcG1heD02OSBxcHN0ZXA9NCBpcF9yYXRpbz0xLjQwIGFxPTE6MS4wMAA=" +from .generators import make_random_image, make_random_video @pytest.fixture -async def client_with_api_key(tmp_path: Path): +async def client_with_api_key(): """Set up admin user, create API key, and return authenticated client.""" base_url = os.environ.get("IMMICH_API_URL", "http://127.0.0.1:2283/api") @@ -78,7 +74,7 @@ async def client_with_api_key(tmp_path: Path): def test_image(tmp_path: Path) -> Path: """Create a minimal JPEG test image.""" img_path = tmp_path / "test.jpg" - img_path.write_bytes(base64.b64decode(JPEG_BASE64)) + img_path.write_bytes(make_random_image()) return img_path @@ -86,19 +82,57 @@ def test_image(tmp_path: Path) -> Path: def test_video(tmp_path: Path) -> Path: """Create a minimal MP4 test video.""" vid_path = tmp_path / "test.mp4" - vid_path.write_bytes(base64.b64decode(MP4_BASE64)) + vid_path.write_bytes(make_random_video()) return vid_path +@pytest.fixture +def asset_cleanup(): + """Fixture to track uploaded assets and profile images for cleanup.""" + cleanup_data: dict[str, list[UUID] | bool] = { + "asset_ids": [], + "profile_image": False, + } + yield cleanup_data + + +@pytest.fixture(autouse=True) +async def cleanup_assets_teardown( + client_with_api_key: AsyncClient, asset_cleanup: dict +): + """Autouse fixture to clean up uploaded assets after each test.""" + yield + # Teardown: Clean up all uploaded assets + asset_ids = asset_cleanup.get("asset_ids", []) + if asset_ids: + try: + await client_with_api_key.assets.delete_assets( + AssetBulkDeleteDto(ids=asset_ids, force=True) + ) + except Exception: + pass # Ignore cleanup errors + + # Teardown: Clean up profile image if uploaded + if asset_cleanup.get("profile_image", False): + try: + await client_with_api_key.users.delete_profile_image() + except Exception: + pass # Ignore cleanup errors + + @pytest.mark.asyncio @pytest.mark.e2e async def test_assets_upload( - client_with_api_key: AsyncClient, test_image: Path, test_video: Path, tmp_path: Path + client_with_api_key: AsyncClient, + test_image: Path, + test_video: Path, + tmp_path: Path, + asset_cleanup: dict, ): """Test AssetsApiWrapped.upload method.""" result = await client_with_api_key.assets.upload( [test_image, test_video], - check_duplicates=True, + check_duplicates=False, # Disable duplicate checking for test independence concurrency=2, show_progress=False, ) @@ -108,20 +142,28 @@ async def test_assets_upload( assert len(result.uploaded) == 2 assert len(result.rejected) == 0 assert len(result.failed) == 0 + + # Track uploaded assets for cleanup + for uploaded in result.uploaded: + asset_cleanup["asset_ids"].append(UUID(uploaded.asset.id)) @pytest.mark.asyncio @pytest.mark.e2e async def test_assets_download_asset_to_file( - client_with_api_key: AsyncClient, test_image: Path, tmp_path: Path + client_with_api_key: AsyncClient, + test_image: Path, + tmp_path: Path, + asset_cleanup: dict, ): """Test AssetsApiWrapped.download_asset_to_file method.""" # Upload an asset first upload_result = await client_with_api_key.assets.upload( - [test_image], check_duplicates=True, show_progress=False + [test_image], check_duplicates=False, show_progress=False ) assert len(upload_result.uploaded) == 1 asset_id = UUID(upload_result.uploaded[0].asset.id) + asset_cleanup["asset_ids"].append(asset_id) # Download the asset out_dir = tmp_path / "downloads" @@ -137,15 +179,19 @@ async def test_assets_download_asset_to_file( @pytest.mark.asyncio @pytest.mark.e2e async def test_assets_view_asset_to_file( - client_with_api_key: AsyncClient, test_image: Path, tmp_path: Path + client_with_api_key: AsyncClient, + test_image: Path, + tmp_path: Path, + asset_cleanup: dict, ): """Test AssetsApiWrapped.view_asset_to_file method.""" # Upload an asset first upload_result = await client_with_api_key.assets.upload( - [test_image], check_duplicates=True, show_progress=False + [test_image], check_duplicates=False, show_progress=False ) assert len(upload_result.uploaded) == 1 asset_id = UUID(upload_result.uploaded[0].asset.id) + asset_cleanup["asset_ids"].append(asset_id) # Download thumbnail out_dir = tmp_path / "thumbnails" @@ -160,15 +206,19 @@ async def test_assets_view_asset_to_file( @pytest.mark.asyncio @pytest.mark.e2e async def test_assets_play_asset_video_to_file( - client_with_api_key: AsyncClient, test_video: Path, tmp_path: Path + client_with_api_key: AsyncClient, + test_video: Path, + tmp_path: Path, + asset_cleanup: dict, ): """Test AssetsApiWrapped.play_asset_video_to_file method.""" # Upload a video first upload_result = await client_with_api_key.assets.upload( - [test_video], check_duplicates=True, show_progress=False + [test_video], check_duplicates=False, show_progress=False ) assert len(upload_result.uploaded) == 1 asset_id = UUID(upload_result.uploaded[0].asset.id) + asset_cleanup["asset_ids"].append(asset_id) # Download video stream out_dir = tmp_path / "videos" @@ -183,15 +233,19 @@ async def test_assets_play_asset_video_to_file( @pytest.mark.asyncio @pytest.mark.e2e async def test_download_archive_to_file( - client_with_api_key: AsyncClient, test_image: Path, tmp_path: Path + client_with_api_key: AsyncClient, + test_image: Path, + tmp_path: Path, + asset_cleanup: dict, ): """Test DownloadApiWrapped.download_archive_to_file method.""" # Upload assets first upload_result = await client_with_api_key.assets.upload( - [test_image], check_duplicates=True, show_progress=False + [test_image], check_duplicates=False, show_progress=False ) assert len(upload_result.uploaded) == 1 asset_id = UUID(upload_result.uploaded[0].asset.id) + asset_cleanup["asset_ids"].append(asset_id) # Create download info download_info = DownloadInfoDto(asset_ids=[asset_id]) @@ -210,7 +264,10 @@ async def test_download_archive_to_file( @pytest.mark.asyncio @pytest.mark.e2e async def test_users_get_profile_image_to_file( - client_with_api_key: AsyncClient, test_image: Path, tmp_path: Path + client_with_api_key: AsyncClient, + test_image: Path, + tmp_path: Path, + asset_cleanup: dict, ): """Test UsersApiWrapped.get_profile_image_to_file method.""" # Get current user info @@ -219,7 +276,9 @@ async def test_users_get_profile_image_to_file( # Upload profile image img_bytes = test_image.read_bytes() - await client_with_api_key.users.create_profile_image(file=img_bytes) + # Pass as tuple (filename, bytes) to ensure proper content type detection + await client_with_api_key.users.create_profile_image(file=("profile.jpg", img_bytes)) + asset_cleanup["profile_image"] = True # Download profile image out_dir = tmp_path / "profiles" diff --git a/uv.lock b/uv.lock index d5b46e9..e36b55f 100644 --- a/uv.lock +++ b/uv.lock @@ -568,6 +568,7 @@ build = [ [package.dev-dependencies] dev = [ + { name = "pillow" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -591,6 +592,7 @@ provides-extras = ["build"] [package.metadata.requires-dev] dev = [ + { name = "pillow", specifier = ">=10.0.0,<11.0.0" }, { name = "pytest", specifier = ">=9.0.0,<10.0.0" }, { name = "pytest-asyncio", specifier = ">=1.2.0,<2.0.0" }, { name = "pytest-cov", specifier = ">=7.0.0,<8.0.0" }, @@ -753,6 +755,65 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "pillow" +version = "10.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/74/ad3d526f3bf7b6d3f408b73fde271ec69dfac8b81341a318ce825f2b3812/pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06", size = 46555059, upload-time = "2024-07-01T09:48:43.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/69/a31cccd538ca0b5272be2a38347f8839b97a14be104ea08b0db92f749c74/pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e", size = 3509271, upload-time = "2024-07-01T09:45:22.07Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9e/4143b907be8ea0bce215f2ae4f7480027473f8b61fcedfda9d851082a5d2/pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d", size = 3375658, upload-time = "2024-07-01T09:45:25.292Z" }, + { url = "https://files.pythonhosted.org/packages/8a/25/1fc45761955f9359b1169aa75e241551e74ac01a09f487adaaf4c3472d11/pillow-10.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856", size = 4332075, upload-time = "2024-07-01T09:45:27.94Z" }, + { url = "https://files.pythonhosted.org/packages/5e/dd/425b95d0151e1d6c951f45051112394f130df3da67363b6bc75dc4c27aba/pillow-10.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f", size = 4444808, upload-time = "2024-07-01T09:45:30.305Z" }, + { url = "https://files.pythonhosted.org/packages/b1/84/9a15cc5726cbbfe7f9f90bfb11f5d028586595907cd093815ca6644932e3/pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b", size = 4356290, upload-time = "2024-07-01T09:45:32.868Z" }, + { url = "https://files.pythonhosted.org/packages/b5/5b/6651c288b08df3b8c1e2f8c1152201e0b25d240e22ddade0f1e242fc9fa0/pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc", size = 4525163, upload-time = "2024-07-01T09:45:35.279Z" }, + { url = "https://files.pythonhosted.org/packages/07/8b/34854bf11a83c248505c8cb0fcf8d3d0b459a2246c8809b967963b6b12ae/pillow-10.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e", size = 4463100, upload-time = "2024-07-01T09:45:37.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/63/0632aee4e82476d9cbe5200c0cdf9ba41ee04ed77887432845264d81116d/pillow-10.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46", size = 4592880, upload-time = "2024-07-01T09:45:39.89Z" }, + { url = "https://files.pythonhosted.org/packages/df/56/b8663d7520671b4398b9d97e1ed9f583d4afcbefbda3c6188325e8c297bd/pillow-10.4.0-cp310-cp310-win32.whl", hash = "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984", size = 2235218, upload-time = "2024-07-01T09:45:42.771Z" }, + { url = "https://files.pythonhosted.org/packages/f4/72/0203e94a91ddb4a9d5238434ae6c1ca10e610e8487036132ea9bf806ca2a/pillow-10.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141", size = 2554487, upload-time = "2024-07-01T09:45:45.176Z" }, + { url = "https://files.pythonhosted.org/packages/bd/52/7e7e93d7a6e4290543f17dc6f7d3af4bd0b3dd9926e2e8a35ac2282bc5f4/pillow-10.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1", size = 2243219, upload-time = "2024-07-01T09:45:47.274Z" }, + { url = "https://files.pythonhosted.org/packages/a7/62/c9449f9c3043c37f73e7487ec4ef0c03eb9c9afc91a92b977a67b3c0bbc5/pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c", size = 3509265, upload-time = "2024-07-01T09:45:49.812Z" }, + { url = "https://files.pythonhosted.org/packages/f4/5f/491dafc7bbf5a3cc1845dc0430872e8096eb9e2b6f8161509d124594ec2d/pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be", size = 3375655, upload-time = "2024-07-01T09:45:52.462Z" }, + { url = "https://files.pythonhosted.org/packages/73/d5/c4011a76f4207a3c151134cd22a1415741e42fa5ddecec7c0182887deb3d/pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3", size = 4340304, upload-time = "2024-07-01T09:45:55.006Z" }, + { url = "https://files.pythonhosted.org/packages/ac/10/c67e20445a707f7a610699bba4fe050583b688d8cd2d202572b257f46600/pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6", size = 4452804, upload-time = "2024-07-01T09:45:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/a9/83/6523837906d1da2b269dee787e31df3b0acb12e3d08f024965a3e7f64665/pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe", size = 4365126, upload-time = "2024-07-01T09:46:00.713Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e5/8c68ff608a4203085158cff5cc2a3c534ec384536d9438c405ed6370d080/pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319", size = 4533541, upload-time = "2024-07-01T09:46:03.235Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7c/01b8dbdca5bc6785573f4cee96e2358b0918b7b2c7b60d8b6f3abf87a070/pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d", size = 4471616, upload-time = "2024-07-01T09:46:05.356Z" }, + { url = "https://files.pythonhosted.org/packages/c8/57/2899b82394a35a0fbfd352e290945440e3b3785655a03365c0ca8279f351/pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696", size = 4600802, upload-time = "2024-07-01T09:46:08.145Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d7/a44f193d4c26e58ee5d2d9db3d4854b2cfb5b5e08d360a5e03fe987c0086/pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496", size = 2235213, upload-time = "2024-07-01T09:46:10.211Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d0/5866318eec2b801cdb8c82abf190c8343d8a1cd8bf5a0c17444a6f268291/pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91", size = 2554498, upload-time = "2024-07-01T09:46:12.685Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c8/310ac16ac2b97e902d9eb438688de0d961660a87703ad1561fd3dfbd2aa0/pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22", size = 2243219, upload-time = "2024-07-01T09:46:14.83Z" }, + { url = "https://files.pythonhosted.org/packages/05/cb/0353013dc30c02a8be34eb91d25e4e4cf594b59e5a55ea1128fde1e5f8ea/pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94", size = 3509350, upload-time = "2024-07-01T09:46:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/e7/cf/5c558a0f247e0bf9cec92bff9b46ae6474dd736f6d906315e60e4075f737/pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597", size = 3374980, upload-time = "2024-07-01T09:46:19.169Z" }, + { url = "https://files.pythonhosted.org/packages/84/48/6e394b86369a4eb68b8a1382c78dc092245af517385c086c5094e3b34428/pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80", size = 4343799, upload-time = "2024-07-01T09:46:21.883Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f3/a8c6c11fa84b59b9df0cd5694492da8c039a24cd159f0f6918690105c3be/pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca", size = 4459973, upload-time = "2024-07-01T09:46:24.321Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1b/c14b4197b80150fb64453585247e6fb2e1d93761fa0fa9cf63b102fde822/pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef", size = 4370054, upload-time = "2024-07-01T09:46:26.825Z" }, + { url = "https://files.pythonhosted.org/packages/55/77/40daddf677897a923d5d33329acd52a2144d54a9644f2a5422c028c6bf2d/pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a", size = 4539484, upload-time = "2024-07-01T09:46:29.355Z" }, + { url = "https://files.pythonhosted.org/packages/40/54/90de3e4256b1207300fb2b1d7168dd912a2fb4b2401e439ba23c2b2cabde/pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b", size = 4477375, upload-time = "2024-07-01T09:46:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/13/24/1bfba52f44193860918ff7c93d03d95e3f8748ca1de3ceaf11157a14cf16/pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9", size = 4608773, upload-time = "2024-07-01T09:46:33.73Z" }, + { url = "https://files.pythonhosted.org/packages/55/04/5e6de6e6120451ec0c24516c41dbaf80cce1b6451f96561235ef2429da2e/pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42", size = 2235690, upload-time = "2024-07-01T09:46:36.587Z" }, + { url = "https://files.pythonhosted.org/packages/74/0a/d4ce3c44bca8635bd29a2eab5aa181b654a734a29b263ca8efe013beea98/pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a", size = 2554951, upload-time = "2024-07-01T09:46:38.777Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ca/184349ee40f2e92439be9b3502ae6cfc43ac4b50bc4fc6b3de7957563894/pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9", size = 2243427, upload-time = "2024-07-01T09:46:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/c3/00/706cebe7c2c12a6318aabe5d354836f54adff7156fd9e1bd6c89f4ba0e98/pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3", size = 3525685, upload-time = "2024-07-01T09:46:45.194Z" }, + { url = "https://files.pythonhosted.org/packages/cf/76/f658cbfa49405e5ecbfb9ba42d07074ad9792031267e782d409fd8fe7c69/pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb", size = 3374883, upload-time = "2024-07-01T09:46:47.331Z" }, + { url = "https://files.pythonhosted.org/packages/46/2b/99c28c4379a85e65378211971c0b430d9c7234b1ec4d59b2668f6299e011/pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70", size = 4339837, upload-time = "2024-07-01T09:46:49.647Z" }, + { url = "https://files.pythonhosted.org/packages/f1/74/b1ec314f624c0c43711fdf0d8076f82d9d802afd58f1d62c2a86878e8615/pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be", size = 4455562, upload-time = "2024-07-01T09:46:51.811Z" }, + { url = "https://files.pythonhosted.org/packages/4a/2a/4b04157cb7b9c74372fa867096a1607e6fedad93a44deeff553ccd307868/pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0", size = 4366761, upload-time = "2024-07-01T09:46:53.961Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7b/8f1d815c1a6a268fe90481232c98dd0e5fa8c75e341a75f060037bd5ceae/pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc", size = 4536767, upload-time = "2024-07-01T09:46:56.664Z" }, + { url = "https://files.pythonhosted.org/packages/e5/77/05fa64d1f45d12c22c314e7b97398ffb28ef2813a485465017b7978b3ce7/pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a", size = 4477989, upload-time = "2024-07-01T09:46:58.977Z" }, + { url = "https://files.pythonhosted.org/packages/12/63/b0397cfc2caae05c3fb2f4ed1b4fc4fc878f0243510a7a6034ca59726494/pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309", size = 4610255, upload-time = "2024-07-01T09:47:01.189Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f9/cfaa5082ca9bc4a6de66ffe1c12c2d90bf09c309a5f52b27759a596900e7/pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060", size = 2235603, upload-time = "2024-07-01T09:47:03.918Z" }, + { url = "https://files.pythonhosted.org/packages/01/6a/30ff0eef6e0c0e71e55ded56a38d4859bf9d3634a94a88743897b5f96936/pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea", size = 2554972, upload-time = "2024-07-01T09:47:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/48/2c/2e0a52890f269435eee38b21c8218e102c621fe8d8df8b9dd06fabf879ba/pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d", size = 2243375, upload-time = "2024-07-01T09:47:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/38/30/095d4f55f3a053392f75e2eae45eba3228452783bab3d9a920b951ac495c/pillow-10.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4", size = 3493889, upload-time = "2024-07-01T09:48:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e8/4ff79788803a5fcd5dc35efdc9386af153569853767bff74540725b45863/pillow-10.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da", size = 3346160, upload-time = "2024-07-01T09:48:07.206Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ac/4184edd511b14f760c73f5bb8a5d6fd85c591c8aff7c2229677a355c4179/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026", size = 3435020, upload-time = "2024-07-01T09:48:09.66Z" }, + { url = "https://files.pythonhosted.org/packages/da/21/1749cd09160149c0a246a81d646e05f35041619ce76f6493d6a96e8d1103/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e", size = 3490539, upload-time = "2024-07-01T09:48:12.529Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f5/f71fe1888b96083b3f6dfa0709101f61fc9e972c0c8d04e9d93ccef2a045/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5", size = 3476125, upload-time = "2024-07-01T09:48:14.891Z" }, + { url = "https://files.pythonhosted.org/packages/96/b9/c0362c54290a31866c3526848583a2f45a535aa9d725fd31e25d318c805f/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885", size = 3579373, upload-time = "2024-07-01T09:48:17.601Z" }, + { url = "https://files.pythonhosted.org/packages/52/3b/ce7a01026a7cf46e5452afa86f97a5e88ca97f562cafa76570178ab56d8d/pillow-10.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5", size = 2554661, upload-time = "2024-07-01T09:48:20.293Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" From 0079fc0bf4ccc0651664100479fa1691e1ae9ede Mon Sep 17 00:00:00 2001 From: timonrieger Date: Mon, 12 Jan 2026 20:57:12 +0100 Subject: [PATCH 20/26] update pillow --- pyproject.toml | 2 +- uv.lock | 147 +++++++++++++++++++++++++++++++------------------ 2 files changed, 94 insertions(+), 55 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1eefbdc..f8db030 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,11 +47,11 @@ build-backend = "uv_build" [dependency-groups] dev = [ + "pillow>=12.1.0,<13.0.0", "pytest>=9.0.0,<10.0.0", "pytest-asyncio>=1.2.0,<2.0.0", "pytest-cov>=7.0.0,<8.0.0", "ty>=0.0.10,<0.0.11", - "pillow>=10.0.0,<11.0.0", ] [tool.semantic_release] diff --git a/uv.lock b/uv.lock index e36b55f..3924414 100644 --- a/uv.lock +++ b/uv.lock @@ -592,7 +592,7 @@ provides-extras = ["build"] [package.metadata.requires-dev] dev = [ - { name = "pillow", specifier = ">=10.0.0,<11.0.0" }, + { name = "pillow", specifier = ">=12.1.0,<13.0.0" }, { name = "pytest", specifier = ">=9.0.0,<10.0.0" }, { name = "pytest-asyncio", specifier = ">=1.2.0,<2.0.0" }, { name = "pytest-cov", specifier = ">=7.0.0,<8.0.0" }, @@ -757,61 +757,100 @@ wheels = [ [[package]] name = "pillow" -version = "10.4.0" +version = "12.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/74/ad3d526f3bf7b6d3f408b73fde271ec69dfac8b81341a318ce825f2b3812/pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06", size = 46555059, upload-time = "2024-07-01T09:48:43.583Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/69/a31cccd538ca0b5272be2a38347f8839b97a14be104ea08b0db92f749c74/pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e", size = 3509271, upload-time = "2024-07-01T09:45:22.07Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9e/4143b907be8ea0bce215f2ae4f7480027473f8b61fcedfda9d851082a5d2/pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d", size = 3375658, upload-time = "2024-07-01T09:45:25.292Z" }, - { url = "https://files.pythonhosted.org/packages/8a/25/1fc45761955f9359b1169aa75e241551e74ac01a09f487adaaf4c3472d11/pillow-10.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856", size = 4332075, upload-time = "2024-07-01T09:45:27.94Z" }, - { url = "https://files.pythonhosted.org/packages/5e/dd/425b95d0151e1d6c951f45051112394f130df3da67363b6bc75dc4c27aba/pillow-10.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f", size = 4444808, upload-time = "2024-07-01T09:45:30.305Z" }, - { url = "https://files.pythonhosted.org/packages/b1/84/9a15cc5726cbbfe7f9f90bfb11f5d028586595907cd093815ca6644932e3/pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b", size = 4356290, upload-time = "2024-07-01T09:45:32.868Z" }, - { url = "https://files.pythonhosted.org/packages/b5/5b/6651c288b08df3b8c1e2f8c1152201e0b25d240e22ddade0f1e242fc9fa0/pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc", size = 4525163, upload-time = "2024-07-01T09:45:35.279Z" }, - { url = "https://files.pythonhosted.org/packages/07/8b/34854bf11a83c248505c8cb0fcf8d3d0b459a2246c8809b967963b6b12ae/pillow-10.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e", size = 4463100, upload-time = "2024-07-01T09:45:37.74Z" }, - { url = "https://files.pythonhosted.org/packages/78/63/0632aee4e82476d9cbe5200c0cdf9ba41ee04ed77887432845264d81116d/pillow-10.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46", size = 4592880, upload-time = "2024-07-01T09:45:39.89Z" }, - { url = "https://files.pythonhosted.org/packages/df/56/b8663d7520671b4398b9d97e1ed9f583d4afcbefbda3c6188325e8c297bd/pillow-10.4.0-cp310-cp310-win32.whl", hash = "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984", size = 2235218, upload-time = "2024-07-01T09:45:42.771Z" }, - { url = "https://files.pythonhosted.org/packages/f4/72/0203e94a91ddb4a9d5238434ae6c1ca10e610e8487036132ea9bf806ca2a/pillow-10.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141", size = 2554487, upload-time = "2024-07-01T09:45:45.176Z" }, - { url = "https://files.pythonhosted.org/packages/bd/52/7e7e93d7a6e4290543f17dc6f7d3af4bd0b3dd9926e2e8a35ac2282bc5f4/pillow-10.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1", size = 2243219, upload-time = "2024-07-01T09:45:47.274Z" }, - { url = "https://files.pythonhosted.org/packages/a7/62/c9449f9c3043c37f73e7487ec4ef0c03eb9c9afc91a92b977a67b3c0bbc5/pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c", size = 3509265, upload-time = "2024-07-01T09:45:49.812Z" }, - { url = "https://files.pythonhosted.org/packages/f4/5f/491dafc7bbf5a3cc1845dc0430872e8096eb9e2b6f8161509d124594ec2d/pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be", size = 3375655, upload-time = "2024-07-01T09:45:52.462Z" }, - { url = "https://files.pythonhosted.org/packages/73/d5/c4011a76f4207a3c151134cd22a1415741e42fa5ddecec7c0182887deb3d/pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3", size = 4340304, upload-time = "2024-07-01T09:45:55.006Z" }, - { url = "https://files.pythonhosted.org/packages/ac/10/c67e20445a707f7a610699bba4fe050583b688d8cd2d202572b257f46600/pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6", size = 4452804, upload-time = "2024-07-01T09:45:58.437Z" }, - { url = "https://files.pythonhosted.org/packages/a9/83/6523837906d1da2b269dee787e31df3b0acb12e3d08f024965a3e7f64665/pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe", size = 4365126, upload-time = "2024-07-01T09:46:00.713Z" }, - { url = "https://files.pythonhosted.org/packages/ba/e5/8c68ff608a4203085158cff5cc2a3c534ec384536d9438c405ed6370d080/pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319", size = 4533541, upload-time = "2024-07-01T09:46:03.235Z" }, - { url = "https://files.pythonhosted.org/packages/f4/7c/01b8dbdca5bc6785573f4cee96e2358b0918b7b2c7b60d8b6f3abf87a070/pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d", size = 4471616, upload-time = "2024-07-01T09:46:05.356Z" }, - { url = "https://files.pythonhosted.org/packages/c8/57/2899b82394a35a0fbfd352e290945440e3b3785655a03365c0ca8279f351/pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696", size = 4600802, upload-time = "2024-07-01T09:46:08.145Z" }, - { url = "https://files.pythonhosted.org/packages/4d/d7/a44f193d4c26e58ee5d2d9db3d4854b2cfb5b5e08d360a5e03fe987c0086/pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496", size = 2235213, upload-time = "2024-07-01T09:46:10.211Z" }, - { url = "https://files.pythonhosted.org/packages/c1/d0/5866318eec2b801cdb8c82abf190c8343d8a1cd8bf5a0c17444a6f268291/pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91", size = 2554498, upload-time = "2024-07-01T09:46:12.685Z" }, - { url = "https://files.pythonhosted.org/packages/d4/c8/310ac16ac2b97e902d9eb438688de0d961660a87703ad1561fd3dfbd2aa0/pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22", size = 2243219, upload-time = "2024-07-01T09:46:14.83Z" }, - { url = "https://files.pythonhosted.org/packages/05/cb/0353013dc30c02a8be34eb91d25e4e4cf594b59e5a55ea1128fde1e5f8ea/pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94", size = 3509350, upload-time = "2024-07-01T09:46:17.177Z" }, - { url = "https://files.pythonhosted.org/packages/e7/cf/5c558a0f247e0bf9cec92bff9b46ae6474dd736f6d906315e60e4075f737/pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597", size = 3374980, upload-time = "2024-07-01T09:46:19.169Z" }, - { url = "https://files.pythonhosted.org/packages/84/48/6e394b86369a4eb68b8a1382c78dc092245af517385c086c5094e3b34428/pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80", size = 4343799, upload-time = "2024-07-01T09:46:21.883Z" }, - { url = "https://files.pythonhosted.org/packages/3b/f3/a8c6c11fa84b59b9df0cd5694492da8c039a24cd159f0f6918690105c3be/pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca", size = 4459973, upload-time = "2024-07-01T09:46:24.321Z" }, - { url = "https://files.pythonhosted.org/packages/7d/1b/c14b4197b80150fb64453585247e6fb2e1d93761fa0fa9cf63b102fde822/pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef", size = 4370054, upload-time = "2024-07-01T09:46:26.825Z" }, - { url = "https://files.pythonhosted.org/packages/55/77/40daddf677897a923d5d33329acd52a2144d54a9644f2a5422c028c6bf2d/pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a", size = 4539484, upload-time = "2024-07-01T09:46:29.355Z" }, - { url = "https://files.pythonhosted.org/packages/40/54/90de3e4256b1207300fb2b1d7168dd912a2fb4b2401e439ba23c2b2cabde/pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b", size = 4477375, upload-time = "2024-07-01T09:46:31.756Z" }, - { url = "https://files.pythonhosted.org/packages/13/24/1bfba52f44193860918ff7c93d03d95e3f8748ca1de3ceaf11157a14cf16/pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9", size = 4608773, upload-time = "2024-07-01T09:46:33.73Z" }, - { url = "https://files.pythonhosted.org/packages/55/04/5e6de6e6120451ec0c24516c41dbaf80cce1b6451f96561235ef2429da2e/pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42", size = 2235690, upload-time = "2024-07-01T09:46:36.587Z" }, - { url = "https://files.pythonhosted.org/packages/74/0a/d4ce3c44bca8635bd29a2eab5aa181b654a734a29b263ca8efe013beea98/pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a", size = 2554951, upload-time = "2024-07-01T09:46:38.777Z" }, - { url = "https://files.pythonhosted.org/packages/b5/ca/184349ee40f2e92439be9b3502ae6cfc43ac4b50bc4fc6b3de7957563894/pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9", size = 2243427, upload-time = "2024-07-01T09:46:43.15Z" }, - { url = "https://files.pythonhosted.org/packages/c3/00/706cebe7c2c12a6318aabe5d354836f54adff7156fd9e1bd6c89f4ba0e98/pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3", size = 3525685, upload-time = "2024-07-01T09:46:45.194Z" }, - { url = "https://files.pythonhosted.org/packages/cf/76/f658cbfa49405e5ecbfb9ba42d07074ad9792031267e782d409fd8fe7c69/pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb", size = 3374883, upload-time = "2024-07-01T09:46:47.331Z" }, - { url = "https://files.pythonhosted.org/packages/46/2b/99c28c4379a85e65378211971c0b430d9c7234b1ec4d59b2668f6299e011/pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70", size = 4339837, upload-time = "2024-07-01T09:46:49.647Z" }, - { url = "https://files.pythonhosted.org/packages/f1/74/b1ec314f624c0c43711fdf0d8076f82d9d802afd58f1d62c2a86878e8615/pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be", size = 4455562, upload-time = "2024-07-01T09:46:51.811Z" }, - { url = "https://files.pythonhosted.org/packages/4a/2a/4b04157cb7b9c74372fa867096a1607e6fedad93a44deeff553ccd307868/pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0", size = 4366761, upload-time = "2024-07-01T09:46:53.961Z" }, - { url = "https://files.pythonhosted.org/packages/ac/7b/8f1d815c1a6a268fe90481232c98dd0e5fa8c75e341a75f060037bd5ceae/pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc", size = 4536767, upload-time = "2024-07-01T09:46:56.664Z" }, - { url = "https://files.pythonhosted.org/packages/e5/77/05fa64d1f45d12c22c314e7b97398ffb28ef2813a485465017b7978b3ce7/pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a", size = 4477989, upload-time = "2024-07-01T09:46:58.977Z" }, - { url = "https://files.pythonhosted.org/packages/12/63/b0397cfc2caae05c3fb2f4ed1b4fc4fc878f0243510a7a6034ca59726494/pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309", size = 4610255, upload-time = "2024-07-01T09:47:01.189Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f9/cfaa5082ca9bc4a6de66ffe1c12c2d90bf09c309a5f52b27759a596900e7/pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060", size = 2235603, upload-time = "2024-07-01T09:47:03.918Z" }, - { url = "https://files.pythonhosted.org/packages/01/6a/30ff0eef6e0c0e71e55ded56a38d4859bf9d3634a94a88743897b5f96936/pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea", size = 2554972, upload-time = "2024-07-01T09:47:06.152Z" }, - { url = "https://files.pythonhosted.org/packages/48/2c/2e0a52890f269435eee38b21c8218e102c621fe8d8df8b9dd06fabf879ba/pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d", size = 2243375, upload-time = "2024-07-01T09:47:09.065Z" }, - { url = "https://files.pythonhosted.org/packages/38/30/095d4f55f3a053392f75e2eae45eba3228452783bab3d9a920b951ac495c/pillow-10.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4", size = 3493889, upload-time = "2024-07-01T09:48:04.815Z" }, - { url = "https://files.pythonhosted.org/packages/f3/e8/4ff79788803a5fcd5dc35efdc9386af153569853767bff74540725b45863/pillow-10.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da", size = 3346160, upload-time = "2024-07-01T09:48:07.206Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ac/4184edd511b14f760c73f5bb8a5d6fd85c591c8aff7c2229677a355c4179/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026", size = 3435020, upload-time = "2024-07-01T09:48:09.66Z" }, - { url = "https://files.pythonhosted.org/packages/da/21/1749cd09160149c0a246a81d646e05f35041619ce76f6493d6a96e8d1103/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e", size = 3490539, upload-time = "2024-07-01T09:48:12.529Z" }, - { url = "https://files.pythonhosted.org/packages/b6/f5/f71fe1888b96083b3f6dfa0709101f61fc9e972c0c8d04e9d93ccef2a045/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5", size = 3476125, upload-time = "2024-07-01T09:48:14.891Z" }, - { url = "https://files.pythonhosted.org/packages/96/b9/c0362c54290a31866c3526848583a2f45a535aa9d725fd31e25d318c805f/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885", size = 3579373, upload-time = "2024-07-01T09:48:17.601Z" }, - { url = "https://files.pythonhosted.org/packages/52/3b/ce7a01026a7cf46e5452afa86f97a5e88ca97f562cafa76570178ab56d8d/pillow-10.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5", size = 2554661, upload-time = "2024-07-01T09:48:20.293Z" }, + { url = "https://files.pythonhosted.org/packages/fe/41/f73d92b6b883a579e79600d391f2e21cb0df767b2714ecbd2952315dfeef/pillow-12.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:fb125d860738a09d363a88daa0f59c4533529a90e564785e20fe875b200b6dbd", size = 5304089, upload-time = "2026-01-02T09:10:24.953Z" }, + { url = "https://files.pythonhosted.org/packages/94/55/7aca2891560188656e4a91ed9adba305e914a4496800da6b5c0a15f09edf/pillow-12.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cad302dc10fac357d3467a74a9561c90609768a6f73a1923b0fd851b6486f8b0", size = 4657815, upload-time = "2026-01-02T09:10:27.063Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d2/b28221abaa7b4c40b7dba948f0f6a708bd7342c4d47ce342f0ea39643974/pillow-12.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a40905599d8079e09f25027423aed94f2823adaf2868940de991e53a449e14a8", size = 6222593, upload-time = "2026-01-02T09:10:29.115Z" }, + { url = "https://files.pythonhosted.org/packages/71/b8/7a61fb234df6a9b0b479f69e66901209d89ff72a435b49933f9122f94cac/pillow-12.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92a7fe4225365c5e3a8e598982269c6d6698d3e783b3b1ae979e7819f9cd55c1", size = 8027579, upload-time = "2026-01-02T09:10:31.182Z" }, + { url = "https://files.pythonhosted.org/packages/ea/51/55c751a57cc524a15a0e3db20e5cde517582359508d62305a627e77fd295/pillow-12.1.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f10c98f49227ed8383d28174ee95155a675c4ed7f85e2e573b04414f7e371bda", size = 6335760, upload-time = "2026-01-02T09:10:33.02Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7c/60e3e6f5e5891a1a06b4c910f742ac862377a6fe842f7184df4a274ce7bf/pillow-12.1.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8637e29d13f478bc4f153d8daa9ffb16455f0a6cb287da1b432fdad2bfbd66c7", size = 7027127, upload-time = "2026-01-02T09:10:35.009Z" }, + { url = "https://files.pythonhosted.org/packages/06/37/49d47266ba50b00c27ba63a7c898f1bb41a29627ced8c09e25f19ebec0ff/pillow-12.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:21e686a21078b0f9cb8c8a961d99e6a4ddb88e0fc5ea6e130172ddddc2e5221a", size = 6449896, upload-time = "2026-01-02T09:10:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/67fd87d2913902462cd9b79c6211c25bfe95fcf5783d06e1367d6d9a741f/pillow-12.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2415373395a831f53933c23ce051021e79c8cd7979822d8cc478547a3f4da8ef", size = 7151345, upload-time = "2026-01-02T09:10:39.064Z" }, + { url = "https://files.pythonhosted.org/packages/bd/15/f8c7abf82af68b29f50d77c227e7a1f87ce02fdc66ded9bf603bc3b41180/pillow-12.1.0-cp310-cp310-win32.whl", hash = "sha256:e75d3dba8fc1ddfec0cd752108f93b83b4f8d6ab40e524a95d35f016b9683b09", size = 6325568, upload-time = "2026-01-02T09:10:41.035Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/7d1c0e160b6b5ac2605ef7d8be537e28753c0db5363d035948073f5513d7/pillow-12.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:64efdf00c09e31efd754448a383ea241f55a994fd079866b92d2bbff598aad91", size = 7032367, upload-time = "2026-01-02T09:10:43.09Z" }, + { url = "https://files.pythonhosted.org/packages/f4/03/41c038f0d7a06099254c60f618d0ec7be11e79620fc23b8e85e5b31d9a44/pillow-12.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:f188028b5af6b8fb2e9a76ac0f841a575bd1bd396e46ef0840d9b88a48fdbcea", size = 2452345, upload-time = "2026-01-02T09:10:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/43/c4/bf8328039de6cc22182c3ef007a2abfbbdab153661c0a9aa78af8d706391/pillow-12.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3", size = 5304057, upload-time = "2026-01-02T09:10:46.627Z" }, + { url = "https://files.pythonhosted.org/packages/43/06/7264c0597e676104cc22ca73ee48f752767cd4b1fe084662620b17e10120/pillow-12.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0", size = 4657811, upload-time = "2026-01-02T09:10:49.548Z" }, + { url = "https://files.pythonhosted.org/packages/72/64/f9189e44474610daf83da31145fa56710b627b5c4c0b9c235e34058f6b31/pillow-12.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451", size = 6232243, upload-time = "2026-01-02T09:10:51.62Z" }, + { url = "https://files.pythonhosted.org/packages/ef/30/0df458009be6a4caca4ca2c52975e6275c387d4e5c95544e34138b41dc86/pillow-12.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e", size = 8037872, upload-time = "2026-01-02T09:10:53.446Z" }, + { url = "https://files.pythonhosted.org/packages/e4/86/95845d4eda4f4f9557e25381d70876aa213560243ac1a6d619c46caaedd9/pillow-12.1.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84", size = 6345398, upload-time = "2026-01-02T09:10:55.426Z" }, + { url = "https://files.pythonhosted.org/packages/5c/1f/8e66ab9be3aaf1435bc03edd1ebdf58ffcd17f7349c1d970cafe87af27d9/pillow-12.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0", size = 7034667, upload-time = "2026-01-02T09:10:57.11Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f6/683b83cb9b1db1fb52b87951b1c0b99bdcfceaa75febf11406c19f82cb5e/pillow-12.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b", size = 6458743, upload-time = "2026-01-02T09:10:59.331Z" }, + { url = "https://files.pythonhosted.org/packages/9a/7d/de833d63622538c1d58ce5395e7c6cb7e7dce80decdd8bde4a484e095d9f/pillow-12.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18", size = 7159342, upload-time = "2026-01-02T09:11:01.82Z" }, + { url = "https://files.pythonhosted.org/packages/8c/40/50d86571c9e5868c42b81fe7da0c76ca26373f3b95a8dd675425f4a92ec1/pillow-12.1.0-cp311-cp311-win32.whl", hash = "sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64", size = 6328655, upload-time = "2026-01-02T09:11:04.556Z" }, + { url = "https://files.pythonhosted.org/packages/6c/af/b1d7e301c4cd26cd45d4af884d9ee9b6fab893b0ad2450d4746d74a6968c/pillow-12.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75", size = 7031469, upload-time = "2026-01-02T09:11:06.538Z" }, + { url = "https://files.pythonhosted.org/packages/48/36/d5716586d887fb2a810a4a61518a327a1e21c8b7134c89283af272efe84b/pillow-12.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304", size = 2452515, upload-time = "2026-01-02T09:11:08.226Z" }, + { url = "https://files.pythonhosted.org/packages/20/31/dc53fe21a2f2996e1b7d92bf671cdb157079385183ef7c1ae08b485db510/pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b", size = 5262642, upload-time = "2026-01-02T09:11:10.138Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c1/10e45ac9cc79419cedf5121b42dcca5a50ad2b601fa080f58c22fb27626e/pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551", size = 4657464, upload-time = "2026-01-02T09:11:12.319Z" }, + { url = "https://files.pythonhosted.org/packages/ad/26/7b82c0ab7ef40ebede7a97c72d473bda5950f609f8e0c77b04af574a0ddb/pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208", size = 6234878, upload-time = "2026-01-02T09:11:14.096Z" }, + { url = "https://files.pythonhosted.org/packages/76/25/27abc9792615b5e886ca9411ba6637b675f1b77af3104710ac7353fe5605/pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5", size = 8044868, upload-time = "2026-01-02T09:11:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ea/f200a4c36d836100e7bc738fc48cd963d3ba6372ebc8298a889e0cfc3359/pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661", size = 6349468, upload-time = "2026-01-02T09:11:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/11/8f/48d0b77ab2200374c66d344459b8958c86693be99526450e7aee714e03e4/pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17", size = 7041518, upload-time = "2026-01-02T09:11:19.389Z" }, + { url = "https://files.pythonhosted.org/packages/1d/23/c281182eb986b5d31f0a76d2a2c8cd41722d6fb8ed07521e802f9bba52de/pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670", size = 6462829, upload-time = "2026-01-02T09:11:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/25/ef/7018273e0faac099d7b00982abdcc39142ae6f3bd9ceb06de09779c4a9d6/pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616", size = 7166756, upload-time = "2026-01-02T09:11:23.559Z" }, + { url = "https://files.pythonhosted.org/packages/8f/c8/993d4b7ab2e341fe02ceef9576afcf5830cdec640be2ac5bee1820d693d4/pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7", size = 6328770, upload-time = "2026-01-02T09:11:25.661Z" }, + { url = "https://files.pythonhosted.org/packages/a7/87/90b358775a3f02765d87655237229ba64a997b87efa8ccaca7dd3e36e7a7/pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d", size = 7033406, upload-time = "2026-01-02T09:11:27.474Z" }, + { url = "https://files.pythonhosted.org/packages/5d/cf/881b457eccacac9e5b2ddd97d5071fb6d668307c57cbf4e3b5278e06e536/pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c", size = 2452612, upload-time = "2026-01-02T09:11:29.309Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543, upload-time = "2026-01-02T09:11:31.566Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373, upload-time = "2026-01-02T09:11:33.367Z" }, + { url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241, upload-time = "2026-01-02T09:11:35.011Z" }, + { url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410, upload-time = "2026-01-02T09:11:36.682Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312, upload-time = "2026-01-02T09:11:38.535Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605, upload-time = "2026-01-02T09:11:40.602Z" }, + { url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617, upload-time = "2026-01-02T09:11:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509, upload-time = "2026-01-02T09:11:44.955Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117, upload-time = "2026-01-02T09:11:46.736Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151, upload-time = "2026-01-02T09:11:48.625Z" }, + { url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534, upload-time = "2026-01-02T09:11:50.445Z" }, + { url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551, upload-time = "2026-01-02T09:11:52.234Z" }, + { url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087, upload-time = "2026-01-02T09:11:54.822Z" }, + { url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470, upload-time = "2026-01-02T09:11:56.522Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816, upload-time = "2026-01-02T09:11:58.227Z" }, + { url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472, upload-time = "2026-01-02T09:12:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974, upload-time = "2026-01-02T09:12:02.572Z" }, + { url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070, upload-time = "2026-01-02T09:12:04.75Z" }, + { url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176, upload-time = "2026-01-02T09:12:06.626Z" }, + { url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061, upload-time = "2026-01-02T09:12:08.624Z" }, + { url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824, upload-time = "2026-01-02T09:12:10.488Z" }, + { url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911, upload-time = "2026-01-02T09:12:12.772Z" }, + { url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445, upload-time = "2026-01-02T09:12:14.775Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354, upload-time = "2026-01-02T09:12:16.599Z" }, + { url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" }, + { url = "https://files.pythonhosted.org/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", size = 4062533, upload-time = "2026-01-02T09:12:20.791Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", size = 4138546, upload-time = "2026-01-02T09:12:23.664Z" }, + { url = "https://files.pythonhosted.org/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", size = 3601163, upload-time = "2026-01-02T09:12:26.338Z" }, + { url = "https://files.pythonhosted.org/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", size = 5266086, upload-time = "2026-01-02T09:12:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", size = 4657344, upload-time = "2026-01-02T09:12:31.117Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", size = 6232114, upload-time = "2026-01-02T09:12:32.936Z" }, + { url = "https://files.pythonhosted.org/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", size = 8042708, upload-time = "2026-01-02T09:12:34.78Z" }, + { url = "https://files.pythonhosted.org/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", size = 6347762, upload-time = "2026-01-02T09:12:36.748Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", size = 7039265, upload-time = "2026-01-02T09:12:39.082Z" }, + { url = "https://files.pythonhosted.org/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", size = 6462341, upload-time = "2026-01-02T09:12:40.946Z" }, + { url = "https://files.pythonhosted.org/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", size = 7165395, upload-time = "2026-01-02T09:12:42.706Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", size = 6431413, upload-time = "2026-01-02T09:12:44.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", size = 7176779, upload-time = "2026-01-02T09:12:46.727Z" }, + { url = "https://files.pythonhosted.org/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", size = 2543105, upload-time = "2026-01-02T09:12:49.243Z" }, + { url = "https://files.pythonhosted.org/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", size = 5268571, upload-time = "2026-01-02T09:12:51.11Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", size = 4660426, upload-time = "2026-01-02T09:12:52.865Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", size = 6269908, upload-time = "2026-01-02T09:12:54.884Z" }, + { url = "https://files.pythonhosted.org/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", size = 8074733, upload-time = "2026-01-02T09:12:56.802Z" }, + { url = "https://files.pythonhosted.org/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", size = 6381431, upload-time = "2026-01-02T09:12:58.823Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", size = 7068529, upload-time = "2026-01-02T09:13:00.885Z" }, + { url = "https://files.pythonhosted.org/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", size = 6492981, upload-time = "2026-01-02T09:13:03.314Z" }, + { url = "https://files.pythonhosted.org/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", size = 7191878, upload-time = "2026-01-02T09:13:05.276Z" }, + { url = "https://files.pythonhosted.org/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", size = 6438703, upload-time = "2026-01-02T09:13:07.491Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", size = 7182927, upload-time = "2026-01-02T09:13:09.841Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" }, + { url = "https://files.pythonhosted.org/packages/8b/bc/224b1d98cffd7164b14707c91aac83c07b047fbd8f58eba4066a3e53746a/pillow-12.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377", size = 5228605, upload-time = "2026-01-02T09:13:14.084Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ca/49ca7769c4550107de049ed85208240ba0f330b3f2e316f24534795702ce/pillow-12.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72", size = 4622245, upload-time = "2026-01-02T09:13:15.964Z" }, + { url = "https://files.pythonhosted.org/packages/73/48/fac807ce82e5955bcc2718642b94b1bd22a82a6d452aea31cbb678cddf12/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c", size = 5247593, upload-time = "2026-01-02T09:13:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/d2/95/3e0742fe358c4664aed4fd05d5f5373dcdad0b27af52aa0972568541e3f4/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd", size = 6989008, upload-time = "2026-01-02T09:13:20.083Z" }, + { url = "https://files.pythonhosted.org/packages/5a/74/fe2ac378e4e202e56d50540d92e1ef4ff34ed687f3c60f6a121bcf99437e/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc", size = 5313824, upload-time = "2026-01-02T09:13:22.405Z" }, + { url = "https://files.pythonhosted.org/packages/f3/77/2a60dee1adee4e2655ac328dd05c02a955c1cd683b9f1b82ec3feb44727c/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a", size = 5963278, upload-time = "2026-01-02T09:13:24.706Z" }, + { url = "https://files.pythonhosted.org/packages/2d/71/64e9b1c7f04ae0027f788a248e6297d7fcc29571371fe7d45495a78172c0/pillow-12.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19", size = 7029809, upload-time = "2026-01-02T09:13:26.541Z" }, ] [[package]] From 1ce77618352ac9e5244c0ad1c308d303fa47c73d Mon Sep 17 00:00:00 2001 From: timonrieger Date: Mon, 12 Jan 2026 20:57:31 +0100 Subject: [PATCH 21/26] lint --- tests/e2e/client/generators.py | 13 +++++++------ tests/e2e/client/test_wrappers.py | 8 +++++--- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/tests/e2e/client/generators.py b/tests/e2e/client/generators.py index 06b8823..de7bbbc 100644 --- a/tests/e2e/client/generators.py +++ b/tests/e2e/client/generators.py @@ -3,7 +3,6 @@ from __future__ import annotations import io -import time from typing import Iterator from PIL import Image @@ -42,15 +41,15 @@ def make_random_image() -> bytes: def _create_minimal_mp4(seed: int) -> bytes: """Create a minimal valid MP4 with variable seed data.""" import base64 - + # Use the original minimal MP4 template (valid MP4 header) # This is a minimal valid MP4 file that Immich can process mp4_base64 = "AAAAIGZ0eXBtcDQyAAAAAG1wNDJtcDQxaXNvbWF2YzEAAAGhtZGF0AAACrgYF//+q3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDE0OCByMjA1OCBlYjc2Y2U1IC0gSC4yNjQvTVBFRy00IEFWQyBjb2RlYyAtIENvcHlsZWZ0IDIwMDMtMjAxNyAtIGh0dHA6Ly93d3cudmlkZW9sYW4ub3JnL3gyNjQuaHRtbCAtIG9wdGlvbnM6IGNhYmFjPTEgcmVmPTMgZGVibG9jaz0xOjA6MCBhbmFseXNlPTB4MzoweDExMyBtZT1oZXggc3VibWU9NyBwc3k9MSBwc3lfcmQ9MS4wMDowLjAwIG1peGVkX3JlZj0xIG1lX3JhbmdlPTE2IGNocm9tYV9tZT0xIHRyZWxsaXM9MSA4eDhkY3Q9MSBjcW09MCBkZWFkem9uZT0yMSwxMSBmYXN0X3Bza2lwPTEgY2hyb21hX3FwX29mZnNldD0tMiB0aHJlYWRzPTEgbG9va2FoZWFkX3RocmVhZHM9MSBzbGljZWRfdGhyZWFkcz0wIG5yPTAgZGVjaW1hdGU9MSBpbnRlcmxhY2VkPTAgYmx1cmF5X2NvbXBhdD0wIGNvbnN0cmFpbmVkX2ludHJhPTAgYmZyYW1lcz0zIGJfcHlyYW1pZD0yIGJfYWRhcHQ9MSBiX2JpYXM9MCBkaXJlY3Q9MSB3ZWlnaHRiPTEgb3Blbl9nb3A9MCB3ZWlnaHRwPTIga2V5aW50PTI1MCBrZXlpbnRfbWluPTI1IHNjZW5lY3V0PTQwIGludHJhX3JlZnJlc2g9MCByY19sb29rYWhlYWQ9NDAgcmM9Y3JmIG1idHJlZT0xIGNyZj0yMy4wIHFjb21wPTAuNjAgcXBtaW49MCBxcG1heD02OSBxcHN0ZXA9NCBpcF9yYXRpbz0xLjQwIGFxPTE6MS4wMAA=" mp4_bytes = bytearray(base64.b64decode(mp4_base64)) - + # Make each MP4 unique by replacing a section with seed-based data # Replace bytes in multiple locations to ensure significant content difference - seed_bytes = seed.to_bytes(8, byteorder='big') + seed_bytes = seed.to_bytes(8, byteorder="big") # Modify multiple sections to ensure uniqueness offsets = [100, 200, 300, 400] for offset in offsets: @@ -58,8 +57,10 @@ def _create_minimal_mp4(seed: int) -> bytes: for i, byte_val in enumerate(seed_bytes): if offset + i < len(mp4_bytes): # Replace with seed data to ensure uniqueness - mp4_bytes[offset + i] = (mp4_bytes[offset + i] + byte_val + seed) % 256 - + mp4_bytes[offset + i] = ( + mp4_bytes[offset + i] + byte_val + seed + ) % 256 + return bytes(mp4_bytes) diff --git a/tests/e2e/client/test_wrappers.py b/tests/e2e/client/test_wrappers.py index 27948df..13fd91c 100644 --- a/tests/e2e/client/test_wrappers.py +++ b/tests/e2e/client/test_wrappers.py @@ -111,7 +111,7 @@ async def cleanup_assets_teardown( ) except Exception: pass # Ignore cleanup errors - + # Teardown: Clean up profile image if uploaded if asset_cleanup.get("profile_image", False): try: @@ -142,7 +142,7 @@ async def test_assets_upload( assert len(result.uploaded) == 2 assert len(result.rejected) == 0 assert len(result.failed) == 0 - + # Track uploaded assets for cleanup for uploaded in result.uploaded: asset_cleanup["asset_ids"].append(UUID(uploaded.asset.id)) @@ -277,7 +277,9 @@ async def test_users_get_profile_image_to_file( # Upload profile image img_bytes = test_image.read_bytes() # Pass as tuple (filename, bytes) to ensure proper content type detection - await client_with_api_key.users.create_profile_image(file=("profile.jpg", img_bytes)) + await client_with_api_key.users.create_profile_image( + file=("profile.jpg", img_bytes) + ) asset_cleanup["profile_image"] = True # Download profile image From 01533bb69d920e0a3b00f831f1f2684fe2a10f75 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Mon, 12 Jan 2026 21:11:38 +0100 Subject: [PATCH 22/26] fix linux path issue --- immich/_internal/upload.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/immich/_internal/upload.py b/immich/_internal/upload.py index 0a09c11..aa38724 100644 --- a/immich/_internal/upload.py +++ b/immich/_internal/upload.py @@ -257,7 +257,7 @@ def get_file_times(path: Path, stats: os.stat_result) -> tuple[datetime, datetim if sys.platform == "win32": ctime = stats.st_ctime else: - ctime = statx(path).btime + ctime = statx(os.fspath(path)).btime return datetime.fromtimestamp( ctime or mtime, tz=timezone.utc From 5da9ee423f8b2ea804a4585efabb9a0b311c6787 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Mon, 12 Jan 2026 21:13:40 +0100 Subject: [PATCH 23/26] Update CI/CD workflow to use `mise_toml` for Python version management in unit and e2e tests, ensuring consistent interpreter usage. --- .github/workflows/cd.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 2d8ab39..79105d4 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -41,7 +41,12 @@ jobs: - name: Install mise uses: jdx/mise-action@146a28175021df8ca24f8ee1828cc2a60f980bd5 # v3 with: - install-args: "python@${{ matrix.python-version }}" + # `install_args` only installs tools; it does not override versions coming from `mise.toml`. + # Use the action's config override so the matrix Python becomes the active interpreter for this job. + mise_toml: | + [tools] + python = "${{ matrix.python-version }}" + uv = "0.9.21" - name: Run unit tests run: mise run ci:test:unit @@ -61,7 +66,11 @@ jobs: - name: Install mise uses: jdx/mise-action@146a28175021df8ca24f8ee1828cc2a60f980bd5 # v3 with: - install-args: "python@3.14" + # Keep e2e tests on a consistent Python without relying on repo `mise.toml`. + mise_toml: | + [tools] + python = "3.14" + uv = "0.9.21" - name: Read Immich version id: immich-version From 599d56deff596adab44854e831a7c55f48f24d3f Mon Sep 17 00:00:00 2001 From: timonrieger Date: Mon, 12 Jan 2026 21:39:14 +0100 Subject: [PATCH 24/26] Add mise.ci.toml for constant tool definitions and update CI workflow to load it for Python version management --- .github/workflows/cd.yml | 16 ++++------------ mise.ci.toml | 3 +++ 2 files changed, 7 insertions(+), 12 deletions(-) create mode 100644 mise.ci.toml diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 79105d4..c502380 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -41,12 +41,10 @@ jobs: - name: Install mise uses: jdx/mise-action@146a28175021df8ca24f8ee1828cc2a60f980bd5 # v3 with: - # `install_args` only installs tools; it does not override versions coming from `mise.toml`. - # Use the action's config override so the matrix Python becomes the active interpreter for this job. - mise_toml: | - [tools] - python = "${{ matrix.python-version }}" - uv = "0.9.21" + # Workaround: Use `--env ci` to load mise.ci.toml which contains constant tools (uv). + # This allows us to override only the Python version via install-args without duplicating + # tool definitions. + install-args: "python@${{ matrix.python-version }} --env ci" - name: Run unit tests run: mise run ci:test:unit @@ -65,12 +63,6 @@ jobs: - name: Install mise uses: jdx/mise-action@146a28175021df8ca24f8ee1828cc2a60f980bd5 # v3 - with: - # Keep e2e tests on a consistent Python without relying on repo `mise.toml`. - mise_toml: | - [tools] - python = "3.14" - uv = "0.9.21" - name: Read Immich version id: immich-version diff --git a/mise.ci.toml b/mise.ci.toml new file mode 100644 index 0000000..f8be079 --- /dev/null +++ b/mise.ci.toml @@ -0,0 +1,3 @@ +[tools] +# only include constant tools here, dynamic versions are handled by the CI workflow +uv = "0.9.21" \ No newline at end of file From 85a6d7c1c83d0ba08209d35e5e697f96f5cdb3ae Mon Sep 17 00:00:00 2001 From: timonrieger Date: Mon, 12 Jan 2026 21:46:40 +0100 Subject: [PATCH 25/26] Refactor MP4 generation logic in e2e tests to use a base64-decoded template, enhancing uniqueness through seed-based mutations in the payload region. --- tests/e2e/client/generators.py | 45 ++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/tests/e2e/client/generators.py b/tests/e2e/client/generators.py index de7bbbc..730750f 100644 --- a/tests/e2e/client/generators.py +++ b/tests/e2e/client/generators.py @@ -2,6 +2,7 @@ from __future__ import annotations +import base64 import io from typing import Iterator @@ -38,28 +39,34 @@ def make_random_image() -> bytes: return next(_jpeg_factory) -def _create_minimal_mp4(seed: int) -> bytes: - """Create a minimal valid MP4 with variable seed data.""" - import base64 +# Decode the MP4 template once at module import +_MP4_BASE64 = "AAAAIGZ0eXBtcDQyAAAAAG1wNDJtcDQxaXNvbWF2YzEAAAGhtZGF0AAACrgYF//+q3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDE0OCByMjA1OCBlYjc2Y2U1IC0gSC4yNjQvTVBFRy00IEFWQyBjb2RlYyAtIENvcHlsZWZ0IDIwMDMtMjAxNyAtIGh0dHA6Ly93d3cudmlkZW9sYW4ub3JnL3gyNjQuaHRtbCAtIG9wdGlvbnM6IGNhYmFjPTEgcmVmPTMgZGVibG9jaz0xOjA6MCBhbmFseXNlPTB4MzoweDExMyBtZT1oZXggc3VibWU9NyBwc3k9MSBwc3lfcmQ9MS4wMDowLjAwIG1peGVkX3JlZj0xIG1lX3JhbmdlPTE2IGNocm9tYV9tZT0xIHRyZWxsaXM9MSA4eDhkY3Q9MSBjcW09MCBkZWFkem9uZT0yMSwxMSBmYXN0X3Bza2lwPTEgY2hyb21hX3FwX29mZnNldD0tMiB0aHJlYWRzPTEgbG9va2FoZWFkX3RocmVhZHM9MSBzbGljZWRfdGhyZWFkcz0wIG5yPTAgZGVjaW1hdGU9MSBpbnRlcmxhY2VkPTAgYmx1cmF5X2NvbXBhdD0wIGNvbnN0cmFpbmVkX2ludHJhPTAgYmZyYW1lcz0zIGJfcHlyYW1pZD0yIGJfYWRhcHQ9MSBiX2JpYXM9MCBkaXJlY3Q9MSB3ZWlnaHRiPTEgb3Blbl9nb3A9MCB3ZWlnaHRwPTIga2V5aW50PTI1MCBrZXlpbnRfbWluPTI1IHNjZW5lY3V0PTQwIGludHJhX3JlZnJlc2g9MCByY19sb29rYWhlYWQ9NDAgcmM9Y3JmIG1idHJlZT0xIGNyZj0yMy4wIHFjb21wPTAuNjAgcXBtaW49MCBxcG1heD02OSBxcHN0ZXA9NCBpcF9yYXRpbz0xLjQwIGFxPTE6MS4wMAA=" +MODULE_TEMPLATE = base64.b64decode(_MP4_BASE64) - # Use the original minimal MP4 template (valid MP4 header) - # This is a minimal valid MP4 file that Immich can process - mp4_base64 = "AAAAIGZ0eXBtcDQyAAAAAG1wNDJtcDQxaXNvbWF2YzEAAAGhtZGF0AAACrgYF//+q3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDE0OCByMjA1OCBlYjc2Y2U1IC0gSC4yNjQvTVBFRy00IEFWQyBjb2RlYyAtIENvcHlsZWZ0IDIwMDMtMjAxNyAtIGh0dHA6Ly93d3cudmlkZW9sYW4ub3JnL3gyNjQuaHRtbCAtIG9wdGlvbnM6IGNhYmFjPTEgcmVmPTMgZGVibG9jaz0xOjA6MCBhbmFseXNlPTB4MzoweDExMyBtZT1oZXggc3VibWU9NyBwc3k9MSBwc3lfcmQ9MS4wMDowLjAwIG1peGVkX3JlZj0xIG1lX3JhbmdlPTE2IGNocm9tYV9tZT0xIHRyZWxsaXM9MSA4eDhkY3Q9MSBjcW09MCBkZWFkem9uZT0yMSwxMSBmYXN0X3Bza2lwPTEgY2hyb21hX3FwX29mZnNldD0tMiB0aHJlYWRzPTEgbG9va2FoZWFkX3RocmVhZHM9MSBzbGljZWRfdGhyZWFkcz0wIG5yPTAgZGVjaW1hdGU9MSBpbnRlcmxhY2VkPTAgYmx1cmF5X2NvbXBhdD0wIGNvbnN0cmFpbmVkX2ludHJhPTAgYmZyYW1lcz0zIGJfcHlyYW1pZD0yIGJfYWRhcHQ9MSBiX2JpYXM9MCBkaXJlY3Q9MSB3ZWlnaHRiPTEgb3Blbl9nb3A9MCB3ZWlnaHRwPTIga2V5aW50PTI1MCBrZXlpbnRfbWluPTI1IHNjZW5lY3V0PTQwIGludHJhX3JlZnJlc2g9MCByY19sb29rYWhlYWQ9NDAgcmM9Y3JmIG1idHJlZT0xIGNyZj0yMy4wIHFjb21wPTAuNjAgcXBtaW49MCBxcG1heD02OSBxcHN0ZXA9NCBpcF9yYXRpbz0xLjQwIGFxPTE6MS4wMAA=" - mp4_bytes = bytearray(base64.b64decode(mp4_base64)) - # Make each MP4 unique by replacing a section with seed-based data - # Replace bytes in multiple locations to ensure significant content difference +def _create_minimal_mp4(seed: int) -> bytes: + """Create a minimal valid MP4 with variable seed data.""" + # Copy the template before mutating + mp4_bytes = bytearray(MODULE_TEMPLATE) + + # Locate the mdat box (media data box) for safe payload region + mdat_pos = mp4_bytes.find(b"mdat") + if mdat_pos == -1: + # If mdat not found, append a harmless free box at the end + # free box: 8 bytes (size + type) + payload + free_box = b"\x00\x00\x00\x10free" # 16 bytes total (8 header + 8 payload) + mp4_bytes.extend(free_box) + payload_start = len(mp4_bytes) - 8 # Start of the free box payload + else: + # mdat box structure: [4 bytes size][4 bytes 'mdat'][payload...] + # Payload starts after the 8-byte header + payload_start = mdat_pos + 8 + + # Apply seed-based mutations in the payload region seed_bytes = seed.to_bytes(8, byteorder="big") - # Modify multiple sections to ensure uniqueness - offsets = [100, 200, 300, 400] - for offset in offsets: - if offset < len(mp4_bytes) - len(seed_bytes): - for i, byte_val in enumerate(seed_bytes): - if offset + i < len(mp4_bytes): - # Replace with seed data to ensure uniqueness - mp4_bytes[offset + i] = ( - mp4_bytes[offset + i] + byte_val + seed - ) % 256 + for i, byte_val in enumerate(seed_bytes): + if payload_start + i < len(mp4_bytes): + mp4_bytes[payload_start + i] = byte_val return bytes(mp4_bytes) From 4eb94437601f692ed7897d5f09ad2c71ada0ef5d Mon Sep 17 00:00:00 2001 From: timonrieger Date: Mon, 12 Jan 2026 21:47:20 +0100 Subject: [PATCH 26/26] cleanup --- tests/e2e/client/test_wrappers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/e2e/client/test_wrappers.py b/tests/e2e/client/test_wrappers.py index 13fd91c..9421f88 100644 --- a/tests/e2e/client/test_wrappers.py +++ b/tests/e2e/client/test_wrappers.py @@ -126,7 +126,6 @@ async def test_assets_upload( client_with_api_key: AsyncClient, test_image: Path, test_video: Path, - tmp_path: Path, asset_cleanup: dict, ): """Test AssetsApiWrapped.upload method."""