diff --git a/examples/dng/example_dng_01.dng b/examples/dng/example_dng_01.dng new file mode 100644 index 0000000..58cb956 Binary files /dev/null and b/examples/dng/example_dng_01.dng differ diff --git a/examples/dng/example_dng_02.dng b/examples/dng/example_dng_02.dng new file mode 100644 index 0000000..92cf8b9 Binary files /dev/null and b/examples/dng/example_dng_02.dng differ diff --git a/examples/dng/example_dng_03.dng b/examples/dng/example_dng_03.dng new file mode 100644 index 0000000..8594bfc Binary files /dev/null and b/examples/dng/example_dng_03.dng differ diff --git a/examples/dng/example_dng_04.dng b/examples/dng/example_dng_04.dng new file mode 100644 index 0000000..4324e97 Binary files /dev/null and b/examples/dng/example_dng_04.dng differ diff --git a/examples/dng/example_dng_05.dng b/examples/dng/example_dng_05.dng new file mode 100644 index 0000000..06c572b Binary files /dev/null and b/examples/dng/example_dng_05.dng differ diff --git a/pyproject.toml b/pyproject.toml index 9407a5c..0134b6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ requires-python = ">=3.14" dependencies = [ "black>=26.5.1", "openai>=3.22.0", + "pillow>=12.3.0", "pydantic>=2.13.5", "pydantic-settings>=2.15.0", "pytest>=9.1.1", diff --git a/src/photools3/photo.py b/src/photools3/photo.py index 5522267..1fe3874 100644 --- a/src/photools3/photo.py +++ b/src/photools3/photo.py @@ -1,15 +1,28 @@ +import base64 +from io import BytesIO from pathlib import Path +from PIL import Image + class Photo: - def __init__(self): ... + def __init__(self, path: Path): + self.path = path + self.image = Image.open(path) - @classmethod - def from_file(cls, path: Path) -> Photo: - # TODO - return cls() - - def resize(self) -> Photo: - # TODO + def resize(self, image_max_size=1024) -> Photo: + """Limiting max dimensions while maintaining aspect ratio""" + self.image.thumbnail((image_max_size, image_max_size), Image.Resampling.LANCZOS) return self + + def to_rgb(self): + if self.image.mode != "RGB": + self.image = self.image.convert("RGB") + return self + + def get_encoded(self): + buffer = BytesIO() + self.image.save(buffer, format="JPEG", quality=85) + image_data = base64.b64encode(buffer.getvalue()).decode("utf-8") + return image_data diff --git a/src/photools3/settings.py b/src/photools3/settings.py index 3fb5f46..1f8e09d 100644 --- a/src/photools3/settings.py +++ b/src/photools3/settings.py @@ -1,6 +1,14 @@ +from pathlib import Path + from pydantic_core import Url from pydantic_settings import BaseSettings, SettingsConfigDict +PHOTOOLS3_DIR = Path(__file__).parent.resolve() +SRC_DIR = PHOTOOLS3_DIR.parent +ROOT_DIR = SRC_DIR.parent +EXAMPLES_DIR = ROOT_DIR / "examples" +DNG_EXAMPLES_DIR = EXAMPLES_DIR / "dng" + class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env") diff --git a/test/test_photo.py b/test/test_photo.py new file mode 100644 index 0000000..0c924fe --- /dev/null +++ b/test/test_photo.py @@ -0,0 +1,276 @@ +import base64 +from io import BytesIO + +import pytest +from PIL import Image + +from photools3.photo import Photo +from photools3.settings import DNG_EXAMPLES_DIR + +PATHS_DNG_EXAMPLES = [ + DNG_EXAMPLES_DIR / "example_dng_01.dng", + DNG_EXAMPLES_DIR / "example_dng_02.dng", + DNG_EXAMPLES_DIR / "example_dng_03.dng", + DNG_EXAMPLES_DIR / "example_dng_04.dng", + DNG_EXAMPLES_DIR / "example_dng_05.dng", +] + +# (PIL mode) -> file format used to persist the test image +MODE_TO_FORMAT = { + "RGB": "PNG", + "L": "PNG", + "P": "PNG", + "RGBA": "PNG", + "LA": "PNG", + "CMYK": "TIFF", + "1": "PNG", + "I": "TIFF", + "F": "TIFF", +} +ALL_MODES = list(MODE_TO_FORMAT) +JPEG_COMPATIBLE_MODES = ["RGB", "L", "CMYK", "1"] +JPEG_INCOMPATIBLE_MODES = ["P", "RGBA", "LA", "I", "F"] + + +def _make_image_file(tmp_path, mode: str, size=(64, 48)): + """Create an image in the given mode, save it to disk and return its path.""" + fmt = MODE_TO_FORMAT[mode] + img = Image.new(mode, size) + path = tmp_path / f"test_{mode}.{fmt.lower()}" + img.save(path, format=fmt) + return path + + +def test_load_photo(): + photo = Photo(PATHS_DNG_EXAMPLES[0]) + assert photo + assert photo.path == PATHS_DNG_EXAMPLES[0] + assert isinstance(photo.image, Image.Image) + + +def test_resize_method_exists(): + """Test that resize method exists and returns a Photo instance""" + photo = Photo(PATHS_DNG_EXAMPLES[0]) + resized_photo = photo.resize() + assert isinstance(resized_photo, Photo) + assert resized_photo is photo # Should return self for chaining + + +def test_resize_with_custom_max_size(): + """Test resize with a custom maximum size""" + photo = Photo(PATHS_DNG_EXAMPLES[0]) + original_width, original_height = photo.image.size + assert original_width > 100 or original_height > 100 # Assume it's larger + + resized_photo = photo.resize(image_max_size=50) + + new_width, new_height = resized_photo.image.size + max_dim = max(new_width, new_height) + assert max_dim <= 50, f"Max dimension {max_dim} exceeds limit of 50" + + +def test_resize_maintains_aspect_ratio(): + """Test that resize maintains the original aspect ratio""" + photo = Photo(PATHS_DNG_EXAMPLES[0]) + original_width, original_height = photo.image.size + + resized_photo = photo.resize(image_max_size=100) + new_width, new_height = resized_photo.image.size + + original_ratio = original_width / original_height + resized_ratio = new_width / new_height + + assert ( + abs(original_ratio - resized_ratio) < 0.01 + ), f"Aspect ratio changed: {original_ratio} -> {resized_ratio}" + + +def test_resize_image_thumbnail_called(): + """Test that the image was actually resized using thumbnail""" + photo = Photo(PATHS_DNG_EXAMPLES[0]) + original_size = photo.image.size + + # Resize to a smaller dimension + resized_photo = photo.resize(image_max_size=10) + + new_size = resized_photo.image.size + assert ( + new_size != original_size + ), "Image should be resized when max_size is smaller than original" + + +def test_resize_chaining(): + """Test that resize returns self for method chaining""" + photo = Photo(PATHS_DNG_EXAMPLES[0]) + + # Should be able to chain multiple operations + result = photo.resize().resize(image_max_size=50) + assert result is photo + + +def test_resize_default_parameter(): + """Test resize with default parameter (no arguments)""" + photo = Photo(PATHS_DNG_EXAMPLES[0]) + resized_photo = photo.resize() + + # Should return a valid Photo instance + assert isinstance(resized_photo, Photo) + assert resized_photo.path == photo.path + + +# --- to_rgb() --- + + +@pytest.mark.parametrize("mode", ALL_MODES) +def test_to_rgb_converts_to_rgb_mode(tmp_path, mode): + """to_rgb() must leave the image in RGB mode regardless of the initial mode""" + path = _make_image_file(tmp_path, mode) + photo = Photo(path) + result = photo.to_rgb() + assert result.image.mode == "RGB" + + +@pytest.mark.parametrize("mode", ALL_MODES) +def test_to_rgb_returns_self_for_chaining(tmp_path, mode): + """to_rgb() returns self so it can be chained""" + path = _make_image_file(tmp_path, mode) + photo = Photo(path) + assert photo.to_rgb() is photo + + +def test_to_rgb_keeps_rgb_image_unmodified(tmp_path): + """RGB images are not re-converted: the same image object is kept""" + path = _make_image_file(tmp_path, "RGB") + photo = Photo(path) + original_image = photo.image + photo.to_rgb() + assert photo.image is original_image + + +@pytest.mark.parametrize("mode", ["L", "P", "RGBA", "LA", "CMYK", "1", "I", "F"]) +def test_to_rgb_replaces_non_rgb_image(tmp_path, mode): + """Non-RGB images are converted: a new image object is assigned""" + path = _make_image_file(tmp_path, mode) + photo = Photo(path) + original_image = photo.image + photo.to_rgb() + assert photo.image is not original_image + assert photo.image.mode == "RGB" + + +@pytest.mark.parametrize("mode", ["RGBA", "LA"]) +def test_to_rgb_drops_alpha_channel(tmp_path, mode): + """Converting alpha-bearing modes to RGB drops the alpha band""" + path = _make_image_file(tmp_path, mode) + photo = Photo(path) + assert "A" in photo.image.getbands() + photo.to_rgb() + assert photo.image.getbands() == ("R", "G", "B") + + +def test_to_rgb_on_dng_examples(tmp_path): + """DNG examples are already RGB; to_rgb() is a safe no-op on every file""" + for path in PATHS_DNG_EXAMPLES: + photo = Photo(path) + original_image = photo.image + assert photo.to_rgb() is photo + assert photo.image is original_image + assert photo.image.mode == "RGB" + + +# --- get_encoded() --- + + +@pytest.mark.parametrize("mode", JPEG_COMPATIBLE_MODES) +def test_get_encoded_returns_valid_base64(tmp_path, mode): + """get_encoded() returns a non-empty, decodable base64 string""" + path = _make_image_file(tmp_path, mode) + photo = Photo(path) + encoded = photo.get_encoded() + assert isinstance(encoded, str) + assert len(encoded) > 0 + raw = base64.b64decode(encoded, validate=True) + assert len(raw) > 0 + + +@pytest.mark.parametrize("mode", JPEG_COMPATIBLE_MODES) +def test_get_encoded_produces_openable_jpeg(tmp_path, mode): + """The decoded payload must be a valid JPEG with the original dimensions""" + path = _make_image_file(tmp_path, mode) + photo = Photo(path) + original_size = photo.image.size + encoded = photo.get_encoded() + img = Image.open(BytesIO(base64.b64decode(encoded))) + assert img.format == "JPEG" + assert img.size == original_size + + +def test_get_encoded_starts_with_jpeg_magic_bytes(tmp_path): + """JPEG files start with the FFD8FF magic bytes""" + path = _make_image_file(tmp_path, "RGB") + photo = Photo(path) + raw = base64.b64decode(photo.get_encoded()) + assert raw[:3] == b"\xff\xd8\xff" + + +def test_get_encoded_reencodes_instead_of_passing_raw_file_bytes(tmp_path): + """The output must be a fresh JPEG encoding, not the source file bytes""" + path = _make_image_file(tmp_path, "RGB") + photo = Photo(path) + raw = base64.b64decode(photo.get_encoded()) + source_bytes = path.read_bytes() + assert raw != source_bytes + assert source_bytes[:4] == b"\x89PNG" # source is a PNG... + assert raw[:3] == b"\xff\xd8\xff" # ...but the encoding is a JPEG + + +def test_get_encoded_is_deterministic(tmp_path): + """Encoding the same image twice yields identical base64""" + path = _make_image_file(tmp_path, "RGB") + assert Photo(path).get_encoded() == Photo(path).get_encoded() + + +@pytest.mark.parametrize("mode", JPEG_INCOMPATIBLE_MODES) +def test_get_encoded_raises_for_non_jpeg_modes(tmp_path, mode): + """Modes JPEG cannot store (palette, alpha, integer/float) must raise OSError""" + path = _make_image_file(tmp_path, mode) + photo = Photo(path) + with pytest.raises(OSError): + photo.get_encoded() + + +@pytest.mark.parametrize("mode", JPEG_INCOMPATIBLE_MODES) +def test_to_rgb_then_get_encoded_works_around_unsupported_modes(tmp_path, mode): + """Chaining to_rgb().get_encoded() fixes modes that JPEG cannot store""" + path = _make_image_file(tmp_path, mode) + photo = Photo(path) + with pytest.raises(OSError): + photo.get_encoded() + encoded = photo.to_rgb().get_encoded() + assert isinstance(encoded, str) + img = Image.open(BytesIO(base64.b64decode(encoded))) + assert img.format == "JPEG" + + +@pytest.mark.parametrize("path", PATHS_DNG_EXAMPLES) +def test_get_encoded_works_on_all_dng_examples(path): + """Every DNG example (all different files) encodes to a valid JPEG""" + photo = Photo(path) + encoded = photo.get_encoded() + assert isinstance(encoded, str) + raw = base64.b64decode(encoded, validate=True) + assert raw[:3] == b"\xff\xd8\xff" + img = Image.open(BytesIO(raw)) + assert img.format == "JPEG" + assert img.size == photo.image.size + + +def test_full_chain_resize_to_rgb_get_encoded(tmp_path): + """The intended workflow: resize -> to_rgb -> get_encoded""" + path = _make_image_file(tmp_path, "RGBA") + photo = Photo(path) + photo.resize(32).to_rgb() + assert photo.image.mode == "RGB" + img = Image.open(BytesIO(base64.b64decode(photo.get_encoded()))) + assert img.format == "JPEG" + assert max(img.size) <= 32 diff --git a/test/test_settings.py b/test/test_settings.py index 45c0ec5..a931d35 100644 --- a/test/test_settings.py +++ b/test/test_settings.py @@ -1,7 +1,38 @@ -from photools3.settings import Settings +from pathlib import Path + +from photools3.settings import ( + DNG_EXAMPLES_DIR, + EXAMPLES_DIR, + PHOTOOLS3_DIR, + ROOT_DIR, + SRC_DIR, + Settings, +) settings = Settings() +def test_photools3_dir(): + assert PHOTOOLS3_DIR == Path(__file__).parent.parent.resolve() / "src" / "photools3" + + +def test_src_dir(): + assert SRC_DIR == Path(__file__).parent.parent.resolve() / "src" + + +def test_root_dir(): + assert ROOT_DIR == Path(__file__).parent.parent.resolve() + + +def test_examples_dir(): + assert EXAMPLES_DIR == Path(__file__).parent.parent.resolve() / "examples" + + +def test_dng_examples_dir(): + assert ( + DNG_EXAMPLES_DIR == Path(__file__).parent.parent.resolve() / "examples" / "dng" + ) + + def test_settings_base(): assert settings.llm_api_base_url diff --git a/uv.lock b/uv.lock index b33de6b..550df97 100644 --- a/uv.lock +++ b/uv.lock @@ -243,6 +243,7 @@ source = { editable = "." } dependencies = [ { name = "black" }, { name = "openai" }, + { name = "pillow" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pytest" }, @@ -253,12 +254,63 @@ dependencies = [ requires-dist = [ { name = "black", specifier = ">=26.5.1" }, { name = "openai", specifier = ">=3.22.0" }, + { name = "pillow", specifier = ">=12.3.0" }, { name = "pydantic", specifier = ">=2.13.5" }, { name = "pydantic-settings", specifier = ">=2.15.0" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "ruff", specifier = ">=0.16.9" }, ] +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, +] + [[package]] name = "platformdirs" version = "4.12.2"