From f6f3dab6724d72a60ebf0244843a82418f7d7740 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 27 Dec 2023 17:42:17 +1100 Subject: [PATCH 001/361] Import UnidentifiedImageError directly --- Tests/oss-fuzz/test_fuzzers.py | 4 ++-- Tests/test_file_eps.py | 4 ++-- Tests/test_file_psd.py | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Tests/oss-fuzz/test_fuzzers.py b/Tests/oss-fuzz/test_fuzzers.py index 68834045a..186a0efd3 100644 --- a/Tests/oss-fuzz/test_fuzzers.py +++ b/Tests/oss-fuzz/test_fuzzers.py @@ -6,7 +6,7 @@ import fuzzers import packaging import pytest -from PIL import Image, features +from PIL import Image, UnidentifiedImageError, features from Tests.helper import skip_unless_feature if sys.platform.startswith("win32"): @@ -42,7 +42,7 @@ def test_fuzz_images(path): except ( Image.DecompressionBombError, Image.DecompressionBombWarning, - Image.UnidentifiedImageError, + UnidentifiedImageError, ): # Known Image.* exceptions assert True diff --git a/Tests/test_file_eps.py b/Tests/test_file_eps.py index c479c384a..360ae11b5 100644 --- a/Tests/test_file_eps.py +++ b/Tests/test_file_eps.py @@ -3,7 +3,7 @@ import io import pytest -from PIL import EpsImagePlugin, Image, features +from PIL import EpsImagePlugin, Image, UnidentifiedImageError, features from .helper import ( assert_image_similar, @@ -417,7 +417,7 @@ def test_emptyline(): ) def test_timeout(test_file): with open(test_file, "rb") as f: - with pytest.raises(Image.UnidentifiedImageError): + with pytest.raises(UnidentifiedImageError): with Image.open(f): pass diff --git a/Tests/test_file_psd.py b/Tests/test_file_psd.py index 8b06ce2b1..d98f23356 100644 --- a/Tests/test_file_psd.py +++ b/Tests/test_file_psd.py @@ -3,7 +3,7 @@ import warnings import pytest -from PIL import Image, PsdImagePlugin +from PIL import Image, PsdImagePlugin, UnidentifiedImageError from .helper import assert_image_equal_tofile, assert_image_similar, hopper, is_pypy @@ -146,11 +146,11 @@ def test_combined_larger_than_size(): [ ( "Tests/images/timeout-1ee28a249896e05b83840ae8140622de8e648ba9.psd", - Image.UnidentifiedImageError, + UnidentifiedImageError, ), ( "Tests/images/timeout-598843abc37fc080ec36a2699ebbd44f795d3a6f.psd", - Image.UnidentifiedImageError, + UnidentifiedImageError, ), ("Tests/images/timeout-c8efc3fded6426986ba867a399791bae544f59bc.psd", OSError), ("Tests/images/timeout-dedc7a4ebd856d79b4359bbcc79e8ef231ce38f6.psd", OSError), From 8070fe10f1ccc346539f87c88fb65ea55d001b9a Mon Sep 17 00:00:00 2001 From: Nulano Date: Sat, 23 Dec 2023 14:41:50 +0100 Subject: [PATCH 002/361] pass build config before setuptools command; add build_editable to custom build backend --- .github/workflows/wheels.yml | 2 ++ _custom_build/backend.py | 55 +++++++++++++----------------------- pyproject.toml | 2 +- setup.py | 34 +++++++++++++++++++--- 4 files changed, 53 insertions(+), 40 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 5adff7ec1..0c8a941de 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -5,6 +5,7 @@ on: paths: - ".ci/requirements-cibw.txt" - ".github/workflows/wheel*" + - "setup.py" - "wheels/*" - "winbuild/build_prepare.py" - "winbuild/fribidi.cmake" @@ -14,6 +15,7 @@ on: paths: - ".ci/requirements-cibw.txt" - ".github/workflows/wheel*" + - "setup.py" - "wheels/*" - "winbuild/build_prepare.py" - "winbuild/fribidi.cmake" diff --git a/_custom_build/backend.py b/_custom_build/backend.py index d1537b809..0b183a587 100644 --- a/_custom_build/backend.py +++ b/_custom_build/backend.py @@ -11,41 +11,16 @@ backend_class = build_wheel.__self__.__class__ class _CustomBuildMetaBackend(backend_class): def run_setup(self, setup_script="setup.py"): if self.config_settings: + params = [] + for k, v in self.config_settings.items(): + if isinstance(v, list): + msg = "Conflicting options: " + ", ".join( + f"'--config-setting {k}={v_}'" for v_ in v + ) + raise ValueError(msg) + params.append(f"--pillow-configuration={k}={v}") - def config_has(key, value): - settings = self.config_settings.get(key) - if settings: - if not isinstance(settings, list): - settings = [settings] - return value in settings - - flags = [] - for dependency in ( - "zlib", - "jpeg", - "tiff", - "freetype", - "raqm", - "lcms", - "webp", - "webpmux", - "jpeg2000", - "imagequant", - "xcb", - ): - if config_has(dependency, "enable"): - flags.append("--enable-" + dependency) - elif config_has(dependency, "disable"): - flags.append("--disable-" + dependency) - for dependency in ("raqm", "fribidi"): - if config_has(dependency, "vendor"): - flags.append("--vendor-" + dependency) - if self.config_settings.get("platform-guessing") == "disable": - flags.append("--disable-platform-guessing") - if self.config_settings.get("debug") == "true": - flags.append("--debug") - if flags: - sys.argv = sys.argv[:1] + ["build_ext"] + flags + sys.argv[1:] + sys.argv = sys.argv[:1] + params + sys.argv[1:] return super().run_setup(setup_script) def build_wheel( @@ -54,5 +29,15 @@ class _CustomBuildMetaBackend(backend_class): self.config_settings = config_settings return super().build_wheel(wheel_directory, config_settings, metadata_directory) + def build_editable( + self, wheel_directory, config_settings=None, metadata_directory=None + ): + self.config_settings = config_settings + return super().build_editable( + wheel_directory, config_settings, metadata_directory + ) -build_wheel = _CustomBuildMetaBackend().build_wheel + +_backend = _CustomBuildMetaBackend() +build_wheel = _backend.build_wheel +build_editable = _backend.build_editable diff --git a/pyproject.toml b/pyproject.toml index da2537b21..d63e401af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,7 +92,7 @@ version = {attr = "PIL.__version__"} [tool.cibuildwheel] before-all = ".github/workflows/wheels-dependencies.sh" build-verbosity = 1 -config-settings = "raqm=enable raqm=vendor fribidi=vendor imagequant=disable" +config-settings = "raqm=vendor fribidi=vendor imagequant=disable" test-command = "cd {project} && .github/workflows/wheels-test.sh" test-extras = "tests" diff --git a/setup.py b/setup.py index 1bf0bcff5..c74165fb7 100755 --- a/setup.py +++ b/setup.py @@ -28,6 +28,9 @@ def get_version(): return locals()["__version__"] +configuration = {} + + PILLOW_VERSION = get_version() FREETYPE_ROOT = None HARFBUZZ_ROOT = None @@ -334,15 +337,24 @@ class pil_build_ext(build_ext): + [("add-imaging-libs=", None, "Add libs to _imaging build")] ) + @staticmethod + def check_configuration(option, value): + return True if configuration.get(option) == value else None + def initialize_options(self): - self.disable_platform_guessing = None + self.disable_platform_guessing = self.check_configuration( + "platform-guessing", "disable" + ) self.add_imaging_libs = "" build_ext.initialize_options(self) for x in self.feature: - setattr(self, f"disable_{x}", None) - setattr(self, f"enable_{x}", None) + setattr(self, f"disable_{x}", self.check_configuration(x, "disable")) + setattr(self, f"enable_{x}", self.check_configuration(x, "enable")) for x in ("raqm", "fribidi"): - setattr(self, f"vendor_{x}", None) + setattr(self, f"vendor_{x}", self.check_configuration(x, "vendor")) + if self.check_configuration("debug", "true"): + self.debug = True + self.parallel = configuration.get("parallel") def finalize_options(self): build_ext.finalize_options(self) @@ -390,6 +402,9 @@ class pil_build_ext(build_ext): raise ValueError(msg) _dbg("Using vendored version of %s", x) self.feature.vendor.add(x) + if x == "raqm": + _dbg("--vendor-raqm implies --enable-raqm") + self.feature.required.add(x) def _update_extension(self, name, libraries, define_macros=None, sources=None): for extension in self.extensions: @@ -985,6 +1000,17 @@ ext_modules = [ Extension("PIL._imagingmorph", ["src/_imagingmorph.c"]), ] + +# parse configuration from _custom_build/backend.py +while len(sys.argv[1]) >= 2 and sys.argv[1].startswith("--pillow-configuration="): + _, key, value = sys.argv[1].split("=", 2) + old = configuration.get(key) + if old is not None: + msg = f"Conflicting options: '-C {key}={old}' and '-C {key}={value}'" + raise ValueError(msg) + configuration[key] = value + del sys.argv[1] + try: setup( cmdclass={"build_ext": pil_build_ext}, From b4e690049d81dc2569d6f02c5b0c1b9eb29a07b2 Mon Sep 17 00:00:00 2001 From: Nulano Date: Sun, 31 Dec 2023 02:22:05 +0100 Subject: [PATCH 003/361] document config setting "-C parallel=n" for number of CPUs to use for compilation --- docs/installation.rst | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/installation.rst b/docs/installation.rst index fbcfbb907..03011619f 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -390,9 +390,10 @@ After navigating to the Pillow directory, run:: Build Options """"""""""""" -* Environment variable: ``MAX_CONCURRENCY=n``. Pillow can use - multiprocessing to build the extension. Setting ``MAX_CONCURRENCY`` - sets the number of CPUs to use, or can disable parallel building by +* Config setting: ``-C parallel=n``. Can also be given + with environment variable: ``MAX_CONCURRENCY=n``. Pillow can use + multiprocessing to build the extension. Setting ``-C parallel=n`` + sets the number of CPUs to use to ``n``, or can disable parallel building by using a setting of 1. By default, it uses 4 CPUs, or if 4 are not available, as many as are present. @@ -417,14 +418,13 @@ Build Options used to compile the standard Pillow wheels. Compiling libraqm requires a C99-compliant compiler. -* Build flag: ``-C platform-guessing=disable``. Skips all of the +* Config setting: ``-C platform-guessing=disable``. Skips all of the platform dependent guessing of include and library directories for automated build systems that configure the proper paths in the environment variables (e.g. Buildroot). -* Build flag: ``-C debug=true``. Adds a debugging flag to the include and - library search process to dump all paths searched for and found to - stdout. +* Config setting: ``-C debug=true``. Adds a debugging flag to the include and + library search process to dump all paths searched for and found to stdout. Sample usage:: From f27b838a451d0b20befea03da7b2d58dd1da7821 Mon Sep 17 00:00:00 2001 From: Nulano Date: Tue, 2 Jan 2024 15:47:47 +0100 Subject: [PATCH 004/361] support multiple --config-settings --- _custom_build/backend.py | 12 +++++------- pyproject.toml | 2 +- setup.py | 13 +++---------- 3 files changed, 9 insertions(+), 18 deletions(-) diff --git a/_custom_build/backend.py b/_custom_build/backend.py index 0b183a587..2c670ff0a 100644 --- a/_custom_build/backend.py +++ b/_custom_build/backend.py @@ -12,13 +12,11 @@ class _CustomBuildMetaBackend(backend_class): def run_setup(self, setup_script="setup.py"): if self.config_settings: params = [] - for k, v in self.config_settings.items(): - if isinstance(v, list): - msg = "Conflicting options: " + ", ".join( - f"'--config-setting {k}={v_}'" for v_ in v - ) - raise ValueError(msg) - params.append(f"--pillow-configuration={k}={v}") + for key, values in self.config_settings.items(): + if not isinstance(values, list): + values = [values] + for value in values: + params.append(f"--pillow-configuration={key}={value}") sys.argv = sys.argv[:1] + params + sys.argv[1:] return super().run_setup(setup_script) diff --git a/pyproject.toml b/pyproject.toml index d63e401af..da2537b21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,7 +92,7 @@ version = {attr = "PIL.__version__"} [tool.cibuildwheel] before-all = ".github/workflows/wheels-dependencies.sh" build-verbosity = 1 -config-settings = "raqm=vendor fribidi=vendor imagequant=disable" +config-settings = "raqm=enable raqm=vendor fribidi=vendor imagequant=disable" test-command = "cd {project} && .github/workflows/wheels-test.sh" test-extras = "tests" diff --git a/setup.py b/setup.py index c74165fb7..686e8889f 100755 --- a/setup.py +++ b/setup.py @@ -339,7 +339,7 @@ class pil_build_ext(build_ext): @staticmethod def check_configuration(option, value): - return True if configuration.get(option) == value else None + return True if value in configuration.get(option, []) else None def initialize_options(self): self.disable_platform_guessing = self.check_configuration( @@ -354,7 +354,7 @@ class pil_build_ext(build_ext): setattr(self, f"vendor_{x}", self.check_configuration(x, "vendor")) if self.check_configuration("debug", "true"): self.debug = True - self.parallel = configuration.get("parallel") + self.parallel = configuration.get("parallel", [None])[-1] def finalize_options(self): build_ext.finalize_options(self) @@ -402,9 +402,6 @@ class pil_build_ext(build_ext): raise ValueError(msg) _dbg("Using vendored version of %s", x) self.feature.vendor.add(x) - if x == "raqm": - _dbg("--vendor-raqm implies --enable-raqm") - self.feature.required.add(x) def _update_extension(self, name, libraries, define_macros=None, sources=None): for extension in self.extensions: @@ -1004,11 +1001,7 @@ ext_modules = [ # parse configuration from _custom_build/backend.py while len(sys.argv[1]) >= 2 and sys.argv[1].startswith("--pillow-configuration="): _, key, value = sys.argv[1].split("=", 2) - old = configuration.get(key) - if old is not None: - msg = f"Conflicting options: '-C {key}={old}' and '-C {key}={value}'" - raise ValueError(msg) - configuration[key] = value + configuration.setdefault(key, []).append(value) del sys.argv[1] try: From 01e5f06da055490f70b1294f39a2718297ee8067 Mon Sep 17 00:00:00 2001 From: Nulano Date: Tue, 2 Jan 2024 16:12:37 +0100 Subject: [PATCH 005/361] document editable mode installation in winbuild/build.rst --- winbuild/build.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/winbuild/build.rst b/winbuild/build.rst index a8e4ebaa6..26d0da0a3 100644 --- a/winbuild/build.rst +++ b/winbuild/build.rst @@ -87,11 +87,18 @@ are set by running ``winbuild\build\build_env.cmd`` and install Pillow with pip: winbuild\build\build_env.cmd python.exe -m pip install -v -C raqm=vendor -C fribidi=vendor . +You can also install Pillow in `editable mode`_:: + + winbuild\build\build_env.cmd + python.exe -m pip install -v -C raqm=vendor -C fribidi=vendor -e . + To build a wheel instead, run:: winbuild\build\build_env.cmd python.exe -m pip wheel -v -C raqm=vendor -C fribidi=vendor . +.. _editable mode: https://setuptools.pypa.io/en/latest/userguide/development_mode.html + Testing Pillow -------------- From df99d48a0cb98cad2f6ee5ae65b0be5df8d84ef3 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 10 Jan 2024 09:26:56 +1100 Subject: [PATCH 006/361] Simplified code --- src/PIL/PsdImagePlugin.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/PIL/PsdImagePlugin.py b/src/PIL/PsdImagePlugin.py index 5cff56413..9f7bf7829 100644 --- a/src/PIL/PsdImagePlugin.py +++ b/src/PIL/PsdImagePlugin.py @@ -185,14 +185,13 @@ def _layerinfo(fp, ct_bytes): # image info mode = [] ct_types = i16(read(2)) - types = list(range(ct_types)) - if len(types) > 4: - fp.seek(len(types) * 6 + 12, io.SEEK_CUR) + if ct_types > 4: + fp.seek(ct_types * 6 + 12, io.SEEK_CUR) size = i32(read(4)) fp.seek(size, io.SEEK_CUR) continue - for _ in types: + for _ in range(ct_types): type = i16(read(2)) if type == 65535: From c2907dc04967109391a77eea00f7d583a0a0395f Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 10 Jan 2024 23:53:45 +1100 Subject: [PATCH 007/361] Layer co-ordinates may be negative --- Tests/images/negative_top_left_layer.psd | Bin 0 -> 8220 bytes Tests/test_file_psd.py | 5 +++++ src/PIL/PsdImagePlugin.py | 9 +++++---- src/PIL/_binary.py | 10 ++++++++++ 4 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 Tests/images/negative_top_left_layer.psd diff --git a/Tests/images/negative_top_left_layer.psd b/Tests/images/negative_top_left_layer.psd new file mode 100644 index 0000000000000000000000000000000000000000..be9d9d6d03e357d4e3c31aecf0d3f90f9fc49974 GIT binary patch literal 8220 zcmeHLOKclO82+84w2B(Fg$kj{J{Py>O@s5)Tnk;DCfU!vP66^uVR05)uyyaj1IWz(WGT1y$ysS-%1)G!l)3h{_mtCT}}nG*az3o1HEcM}Oi^6FbX3af3SXscWnN8yQRS zkv;f{D6LjB3(OcF9sQ9%?~YCK@mQzlGb7Uxh;VkX$C1md)eI*XI|AUip+A3OS~z{? zDPjBfh(8Y7;a->|Mo>RmD$TJO8<*d|jC}e+WLO*9+)_JX(!fA>1hhC})cp|UXHZTz z!a9>*MR~8WNRX-6`zTNPxIvV^X8KAb|G?xbm(f9#{qp<_`nDHkq1q=``eb9#chE*d zn**lfBL2wfNH&@L9Fv{e67G!`7PXz!eS87?1K1vb6HtI6lt6$AEP)L*2tmZ&MNI(n zI6i%g`TO|%63(jNEE^1*F$*3zK=^!MF+dwEJ#GUlsd80p=QO~!?^>;29|YL`62Q%i zt=1oJwOTjd1{nPo;8V9Z-@A+v{|)sQd%6Q}0_=Jo;DakYUF8*k4=(`R_YLM7;p?!R z9I-5bU)}|nI0JCcHGuJ_Bi?X~jl(-%0hE!exvK!Ly$8HWg)-I`3h#FGOp7j4~@bc%4L8hTQ2-6pAH5O(=3Ana26Ds_C+#E3%+u zbv3EWGFrzj_jbgPxNj_zV8WU@+^iGh91VpEt=eur{_{tl#0A^T$Dd3U!Izr0>dN~Zz;^j<2WvK_UF`##V))XB=~LtY<8-MGLd} zINPsodWLQ4TuiEFI+IbdVkVukM9oU2#9W1@#foYeNjXI`ik0qzD!4&NT!S(w1W(wA zqgh&p=2TfsB~4A#vW6y-6g5RtlXGb$XXbKQtq`YT)&9jXQTZ1fa8)@**71c9Fmofwe|0a>Pzdx#Yc;WwsRrT7AAXKY7r zZQA_1aF)L?NBC*oxY&s>YDH<`}MGrhHY~b&bqgCV)xxx(PuRq~n z6vT%)30?EXB4rUm#za{W75T4>No|iM_TK2YJSF{&lqqh|_oBghkq%#A_&$`nFT^%! zxPv=c+QGP!O9;YnkxlVCq-#jmrWn|i@i4oFbZv@(O&Jff>p!DwymzIdF8&N^;Drg` zuFtStX|eJ-Yk2u$cWM>J*ncUpOYAm=pX_#b3GiANyTop3F8-Em|J8DYS+KHgj;v5W zy2aI&TpLxpnHuKs4!;5vI%7r+C4O-`rTwfiCIzSP* zx;hXQBQmxHbt}H9hJM^W1H*XSHUn!n)>?zmw&GXT+I8e=0NR#tbrrc9fVL%EwblUH L`ro%zxbyW->#0jd literal 0 HcmV?d00001 diff --git a/Tests/test_file_psd.py b/Tests/test_file_psd.py index 8b06ce2b1..e3c1f447a 100644 --- a/Tests/test_file_psd.py +++ b/Tests/test_file_psd.py @@ -112,6 +112,11 @@ def test_rgba(): assert_image_equal_tofile(im, "Tests/images/imagedraw_square.png") +def test_negative_top_left_layer() -> None: + with Image.open("Tests/images/negative_top_left_layer.psd") as im: + assert im.layers[0][2] == (-50, -50, 50, 50) + + def test_layer_skip(): with Image.open("Tests/images/five_channels.psd") as im: assert im.n_frames == 1 diff --git a/src/PIL/PsdImagePlugin.py b/src/PIL/PsdImagePlugin.py index 9f7bf7829..d29bcf997 100644 --- a/src/PIL/PsdImagePlugin.py +++ b/src/PIL/PsdImagePlugin.py @@ -24,6 +24,7 @@ from ._binary import i8 from ._binary import i16be as i16 from ._binary import i32be as i32 from ._binary import si16be as si16 +from ._binary import si32be as si32 MODES = { # (photoshop mode, bits) -> (pil mode, required channels) @@ -177,10 +178,10 @@ def _layerinfo(fp, ct_bytes): for _ in range(abs(ct)): # bounding box - y0 = i32(read(4)) - x0 = i32(read(4)) - y1 = i32(read(4)) - x1 = i32(read(4)) + y0 = si32(read(4)) + x0 = si32(read(4)) + y1 = si32(read(4)) + x1 = si32(read(4)) # image info mode = [] diff --git a/src/PIL/_binary.py b/src/PIL/_binary.py index 0a07e8d0e..4594ccce3 100644 --- a/src/PIL/_binary.py +++ b/src/PIL/_binary.py @@ -77,6 +77,16 @@ def si32le(c: bytes, o: int = 0) -> int: return unpack_from(" int: + """ + Converts a 4-bytes (32 bits) string to a signed integer, big endian. + + :param c: string containing bytes to convert + :param o: offset of bytes to convert in string + """ + return unpack_from(">i", c, o)[0] + + def i16be(c: bytes, o: int = 0) -> int: return unpack_from(">H", c, o)[0] From 2fbd7dda839fb1cb4d9ba0a1e9abecf34e4786ec Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 13 Jan 2024 21:10:02 +1100 Subject: [PATCH 008/361] Use consistent arguments for load_seek --- src/PIL/EpsImagePlugin.py | 2 +- src/PIL/IcoImagePlugin.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PIL/EpsImagePlugin.py b/src/PIL/EpsImagePlugin.py index d2e60aa07..690fb3586 100644 --- a/src/PIL/EpsImagePlugin.py +++ b/src/PIL/EpsImagePlugin.py @@ -408,7 +408,7 @@ class EpsImageFile(ImageFile.ImageFile): self.tile = [] return Image.Image.load(self) - def load_seek(self, *args, **kwargs): + def load_seek(self, pos): # we can't incrementally load, so force ImageFile.parser to # use our custom load method by defining this method. pass diff --git a/src/PIL/IcoImagePlugin.py b/src/PIL/IcoImagePlugin.py index 1b22f8645..2b21d957f 100644 --- a/src/PIL/IcoImagePlugin.py +++ b/src/PIL/IcoImagePlugin.py @@ -339,7 +339,7 @@ class IcoImageFile(ImageFile.ImageFile): self.size = im.size - def load_seek(self): + def load_seek(self, pos): # Flag the ImageFile.Parser so that it # just does all the decode at the end. pass From 543b5a674160c86d8b128e0af6c706a2e96c16d7 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 13 Jan 2024 21:11:16 +1100 Subject: [PATCH 009/361] Use consistent arguments for load_read --- src/PIL/ImageFile.py | 2 +- src/PIL/XpmImagePlugin.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index 0923979af..17b1b3203 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -328,7 +328,7 @@ class ImageFile(Image.Image): # pass # may be defined for blocked formats (e.g. PNG) - # def load_read(self, bytes): + # def load_read(self, read_bytes): # pass def _seek_check(self, frame): diff --git a/src/PIL/XpmImagePlugin.py b/src/PIL/XpmImagePlugin.py index bf73c9bef..3125f8d52 100644 --- a/src/PIL/XpmImagePlugin.py +++ b/src/PIL/XpmImagePlugin.py @@ -103,7 +103,7 @@ class XpmImageFile(ImageFile.ImageFile): self.tile = [("raw", (0, 0) + self.size, self.fp.tell(), ("P", 0, 1))] - def load_read(self, bytes): + def load_read(self, read_bytes): # # load all image data in one chunk From 44e77a22b572a3ffe158a8b4b63c218ae549b8ac Mon Sep 17 00:00:00 2001 From: FangFuxin <38530078+lajiyuan@users.noreply.github.com> Date: Fri, 12 Jan 2024 15:46:49 +0800 Subject: [PATCH 010/361] Fix png image plugin load_end func handle truncated file. --- Tests/images/end_trunc_file.png | Bin 0 -> 30339 bytes Tests/test_file_png.py | 9 +++++++++ src/PIL/PngImagePlugin.py | 8 +++++++- 3 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 Tests/images/end_trunc_file.png diff --git a/Tests/images/end_trunc_file.png b/Tests/images/end_trunc_file.png new file mode 100644 index 0000000000000000000000000000000000000000..5e88c5e4fa2a6ac010219a2469b83efa21102f0e GIT binary patch literal 30339 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 mOz9jWq7NYmRv5v_Ot0CrAEL_t(ca^tlC literal 0 HcmV?d00001 diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index ff3862110..9b6a4c5e6 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -777,6 +777,15 @@ class TestFilePng: mystdout = mystdout.buffer with Image.open(mystdout) as reloaded: assert_image_equal_tofile(reloaded, TEST_PNG_FILE) + + def test_end_truncated_file(self): + ImageFile.LOAD_TRUNCATED_IMAGES = True + try: + with Image.open("Tests/images/end_trunc_file.png") as im: + assert_image_equal_tofile(im, "Tests/images/end_trunc_file.png") + finally: + ImageFile.LOAD_TRUNCATED_IMAGES = False + @pytest.mark.skipif(is_win32(), reason="Requires Unix or macOS") diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 823f12492..1248fb785 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -981,7 +981,13 @@ class PngImageFile(ImageFile.ImageFile): except EOFError: if cid == b"fdAT": length -= 4 - ImageFile._safe_read(self.fp, length) + try: + ImageFile._safe_read(self.fp, length) + except OSError as e: + if ImageFile.LOAD_TRUNCATED_IMAGES: + break + else: + raise e except AttributeError: logger.debug("%r %s %s (unknown)", cid, pos, length) s = ImageFile._safe_read(self.fp, length) From b2711c3e8b019d0d069d58069912abd4ecb99e61 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 16 Jan 2024 07:36:57 +0000 Subject: [PATCH 011/361] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- Tests/test_file_png.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 9b6a4c5e6..3c285b077 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -777,7 +777,7 @@ class TestFilePng: mystdout = mystdout.buffer with Image.open(mystdout) as reloaded: assert_image_equal_tofile(reloaded, TEST_PNG_FILE) - + def test_end_truncated_file(self): ImageFile.LOAD_TRUNCATED_IMAGES = True try: @@ -787,7 +787,6 @@ class TestFilePng: ImageFile.LOAD_TRUNCATED_IMAGES = False - @pytest.mark.skipif(is_win32(), reason="Requires Unix or macOS") @skip_unless_feature("zlib") class TestTruncatedPngPLeaks(PillowLeakTestCase): From fe7b6d9e80ae8ee65d2b4e615971a00690b876fe Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 16 Jan 2024 18:39:33 +1100 Subject: [PATCH 012/361] Corrected expected image path --- Tests/test_file_png.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 3c285b077..aa2aac906 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -782,7 +782,7 @@ class TestFilePng: ImageFile.LOAD_TRUNCATED_IMAGES = True try: with Image.open("Tests/images/end_trunc_file.png") as im: - assert_image_equal_tofile(im, "Tests/images/end_trunc_file.png") + assert_image_equal_tofile(im, "Tests/images/hopper.png") finally: ImageFile.LOAD_TRUNCATED_IMAGES = False From 62e6d62518f21333fbde364d7b7a57e25d39061b Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 16 Jan 2024 18:49:25 +1100 Subject: [PATCH 013/361] Test error is raised without LOAD_TRUNCATED_IMAGES --- .../{end_trunc_file.png => truncated_end_chunk.png} | Bin Tests/test_file_png.py | 8 ++++++-- 2 files changed, 6 insertions(+), 2 deletions(-) rename Tests/images/{end_trunc_file.png => truncated_end_chunk.png} (100%) diff --git a/Tests/images/end_trunc_file.png b/Tests/images/truncated_end_chunk.png similarity index 100% rename from Tests/images/end_trunc_file.png rename to Tests/images/truncated_end_chunk.png diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index aa2aac906..0884ddcc3 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -778,10 +778,14 @@ class TestFilePng: with Image.open(mystdout) as reloaded: assert_image_equal_tofile(reloaded, TEST_PNG_FILE) - def test_end_truncated_file(self): + def test_truncated_end_chunk(self): + with Image.open("Tests/images/truncated_end_chunk.png") as im: + with pytest.raises(OSError): + im.load() + ImageFile.LOAD_TRUNCATED_IMAGES = True try: - with Image.open("Tests/images/end_trunc_file.png") as im: + with Image.open("Tests/images/truncated_end_chunk.png") as im: assert_image_equal_tofile(im, "Tests/images/hopper.png") finally: ImageFile.LOAD_TRUNCATED_IMAGES = False From 74af933a9f0441e1285c398675dc3eca1de5406c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ondrej=20Baranovi=C4=8D?= Date: Sat, 20 Jan 2024 10:08:14 +0100 Subject: [PATCH 014/361] Link to stable setuptools documentation Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- winbuild/build.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/winbuild/build.rst b/winbuild/build.rst index 26d0da0a3..f40982cd5 100644 --- a/winbuild/build.rst +++ b/winbuild/build.rst @@ -97,7 +97,7 @@ To build a wheel instead, run:: winbuild\build\build_env.cmd python.exe -m pip wheel -v -C raqm=vendor -C fribidi=vendor . -.. _editable mode: https://setuptools.pypa.io/en/latest/userguide/development_mode.html +.. _editable mode: https://setuptools.pypa.io/en/stable/userguide/development_mode.html Testing Pillow -------------- From 420150f0e251c9519a5aea1c24546a44d489828c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ondrej=20Baranovi=C4=8D?= Date: Sat, 20 Jan 2024 14:56:20 +0100 Subject: [PATCH 015/361] Update winbuild/build.rst --- winbuild/build.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/winbuild/build.rst b/winbuild/build.rst index f40982cd5..c980d9c75 100644 --- a/winbuild/build.rst +++ b/winbuild/build.rst @@ -92,7 +92,7 @@ You can also install Pillow in `editable mode`_:: winbuild\build\build_env.cmd python.exe -m pip install -v -C raqm=vendor -C fribidi=vendor -e . -To build a wheel instead, run:: +To build a binary wheel instead, run:: winbuild\build\build_env.cmd python.exe -m pip wheel -v -C raqm=vendor -C fribidi=vendor . From 231d54b9df90dcce1cdbb9928f8d21899eed8153 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Mon, 22 Jan 2024 21:37:37 +0200 Subject: [PATCH 016/361] Replace io.BytesIO in type hints --- docs/reference/internal_design.rst | 4 ++-- docs/reference/internal_modules.rst | 12 ++++++++++++ src/PIL/GdImageFile.py | 7 +++++-- src/PIL/MpegImagePlugin.py | 5 ++--- src/PIL/_typing.py | 16 +++++++++++++++- src/PIL/_util.py | 4 ++-- 6 files changed, 38 insertions(+), 10 deletions(-) diff --git a/docs/reference/internal_design.rst b/docs/reference/internal_design.rst index 2e2d3322f..99a18e9ea 100644 --- a/docs/reference/internal_design.rst +++ b/docs/reference/internal_design.rst @@ -1,5 +1,5 @@ -Internal Reference Docs -======================= +Internal Reference +================== .. toctree:: :maxdepth: 2 diff --git a/docs/reference/internal_modules.rst b/docs/reference/internal_modules.rst index f2932c322..c3cc70060 100644 --- a/docs/reference/internal_modules.rst +++ b/docs/reference/internal_modules.rst @@ -33,6 +33,18 @@ Internal Modules Provides a convenient way to import type hints that are not available on some Python versions. +.. py:class:: FileDescriptor + + Typing alias. + +.. py:class:: StrOrBytesPath + + Typing alias. + +.. py:class:: SupportsRead + + An object that supports the read method. + .. py:data:: TypeGuard :value: typing.TypeGuard diff --git a/src/PIL/GdImageFile.py b/src/PIL/GdImageFile.py index 7bb4736af..315ac6d6c 100644 --- a/src/PIL/GdImageFile.py +++ b/src/PIL/GdImageFile.py @@ -27,11 +27,12 @@ """ from __future__ import annotations -from io import BytesIO +from typing import IO from . import ImageFile, ImagePalette, UnidentifiedImageError from ._binary import i16be as i16 from ._binary import i32be as i32 +from ._typing import FileDescriptor, StrOrBytesPath class GdImageFile(ImageFile.ImageFile): @@ -80,7 +81,9 @@ class GdImageFile(ImageFile.ImageFile): ] -def open(fp: BytesIO, mode: str = "r") -> GdImageFile: +def open( + fp: StrOrBytesPath | FileDescriptor | IO[bytes], mode: str = "r" +) -> GdImageFile: """ Load texture from a GD image file. diff --git a/src/PIL/MpegImagePlugin.py b/src/PIL/MpegImagePlugin.py index b9e9243e5..1565612f8 100644 --- a/src/PIL/MpegImagePlugin.py +++ b/src/PIL/MpegImagePlugin.py @@ -14,17 +14,16 @@ # from __future__ import annotations -from io import BytesIO - from . import Image, ImageFile from ._binary import i8 +from ._typing import SupportsRead # # Bitstream parser class BitStream: - def __init__(self, fp: BytesIO) -> None: + def __init__(self, fp: SupportsRead[bytes]) -> None: self.fp = fp self.bits = 0 self.bitbuffer = 0 diff --git a/src/PIL/_typing.py b/src/PIL/_typing.py index 608b2b41f..6eb25c1c1 100644 --- a/src/PIL/_typing.py +++ b/src/PIL/_typing.py @@ -1,6 +1,8 @@ from __future__ import annotations +import os import sys +from typing import Protocol, TypeVar, Union if sys.version_info >= (3, 10): from typing import TypeGuard @@ -15,4 +17,16 @@ else: return bool -__all__ = ["TypeGuard"] +_T_co = TypeVar("_T_co", covariant=True) + + +class SupportsRead(Protocol[_T_co]): + def read(self, __length: int = ...) -> _T_co: + ... + + +FileDescriptor = int +StrOrBytesPath = Union[str, bytes, "os.PathLike[str]", "os.PathLike[bytes]"] + + +__all__ = ["FileDescriptor", "TypeGuard", "StrOrBytesPath", "SupportsRead"] diff --git a/src/PIL/_util.py b/src/PIL/_util.py index 13f369cca..4ecdc4bd3 100644 --- a/src/PIL/_util.py +++ b/src/PIL/_util.py @@ -4,10 +4,10 @@ import os from pathlib import Path from typing import Any, NoReturn -from ._typing import TypeGuard +from ._typing import StrOrBytesPath, TypeGuard -def is_path(f: Any) -> TypeGuard[bytes | str | Path]: +def is_path(f: Any) -> TypeGuard[StrOrBytesPath]: return isinstance(f, (bytes, str, Path)) From 474411b52a3619f01bb626ce8e56100094886d1a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 23 Jan 2024 08:52:14 +1100 Subject: [PATCH 017/361] Updated zlib to 1.3.1 --- .github/workflows/wheels-dependencies.sh | 2 +- winbuild/build_prepare.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 3ec314873..a30d0468c 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -29,7 +29,7 @@ else GIFLIB_VERSION=5.2.1 fi if [[ -n "$IS_MACOS" ]] || [[ "$MB_ML_VER" != 2014 ]]; then - ZLIB_VERSION=1.3 + ZLIB_VERSION=1.3.1 else ZLIB_VERSION=1.2.8 fi diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index df33ea493..3117065e8 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -143,9 +143,9 @@ DEPS = { "bins": ["cjpeg.exe", "djpeg.exe"], }, "zlib": { - "url": "https://zlib.net/zlib13.zip", - "filename": "zlib13.zip", - "dir": "zlib-1.3", + "url": "https://zlib.net/zlib131.zip", + "filename": "zlib131.zip", + "dir": "zlib-1.3.1", "license": "README", "license_pattern": "Copyright notice:\n\n(.+)$", "build": [ From 8caae8739f16cfddade0848ba686b1a40a0c10b4 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 23 Jan 2024 18:51:41 +1100 Subject: [PATCH 018/361] Restored testing of non-TrueType default font --- Tests/test_font_leaks.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Tests/test_font_leaks.py b/Tests/test_font_leaks.py index 4e29a856b..d29e9bcfc 100644 --- a/Tests/test_font_leaks.py +++ b/Tests/test_font_leaks.py @@ -1,8 +1,10 @@ from __future__ import annotations -from PIL import Image, ImageDraw, ImageFont +from PIL import Image, ImageDraw, ImageFont, _util -from .helper import PillowLeakTestCase, skip_unless_feature +from .helper import PillowLeakTestCase, features, skip_unless_feature + +original_core = ImageFont.core class TestTTypeFontLeak(PillowLeakTestCase): @@ -31,5 +33,9 @@ class TestDefaultFontLeak(TestTTypeFontLeak): mem_limit = 1024 # k def test_leak(self): + if features.check_module("freetype2"): + ImageFont.core = _util.DeferredError(ImportError) default_font = ImageFont.load_default() + ImageFont.core = original_core + self._test_font(default_font) From e2d1b2663d2b392089bfe59c7a17d2afbf74ce7f Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Wed, 24 Jan 2024 08:12:06 +1100 Subject: [PATCH 019/361] Restored original state using finally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ondrej Baranovič --- Tests/test_font_leaks.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Tests/test_font_leaks.py b/Tests/test_font_leaks.py index d29e9bcfc..5eea0c34d 100644 --- a/Tests/test_font_leaks.py +++ b/Tests/test_font_leaks.py @@ -35,7 +35,9 @@ class TestDefaultFontLeak(TestTTypeFontLeak): def test_leak(self): if features.check_module("freetype2"): ImageFont.core = _util.DeferredError(ImportError) - default_font = ImageFont.load_default() - ImageFont.core = original_core + try: + default_font = ImageFont.load_default() + finally: + ImageFont.core = original_core self._test_font(default_font) From e3932b7dbaf6aff8ef4b7a24007f4de07477ec91 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Tue, 23 Jan 2024 23:58:41 +0200 Subject: [PATCH 020/361] Exclude from coverage: empty bodies in protocols or abstract methods --- .coveragerc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.coveragerc b/.coveragerc index 46df3f90d..5678e4566 100644 --- a/.coveragerc +++ b/.coveragerc @@ -10,6 +10,9 @@ exclude_also = if DEBUG: # Don't complain about compatibility code for missing optional dependencies except ImportError + # Empty bodies in protocols or abstract methods + ^\s*def [a-zA-Z0-9_]+\(.*\)(\s*->.*)?:\s*\.\.\.(\s*#.*)?$ + ^\s*\.\.\.(\s*#.*)?$ [run] omit = From cf9e6ff2563be6d0458856e2aa35e54973709752 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 25 Jan 2024 08:10:07 +1100 Subject: [PATCH 021/361] Updated libjpeg-turbo to 3.0.2 --- .github/workflows/wheels-dependencies.sh | 2 +- winbuild/build_prepare.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 3ec314873..9013e8ae2 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -18,7 +18,7 @@ ARCHIVE_SDIR=pillow-depends-main FREETYPE_VERSION=2.13.2 HARFBUZZ_VERSION=8.3.0 LIBPNG_VERSION=1.6.40 -JPEGTURBO_VERSION=3.0.1 +JPEGTURBO_VERSION=3.0.2 OPENJPEG_VERSION=2.5.0 XZ_VERSION=5.4.5 TIFF_VERSION=4.6.0 diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index df33ea493..92cbcdf7a 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -113,9 +113,9 @@ ARCHITECTURES = { DEPS = { "libjpeg": { "url": SF_PROJECTS - + "/libjpeg-turbo/files/3.0.1/libjpeg-turbo-3.0.1.tar.gz/download", - "filename": "libjpeg-turbo-3.0.1.tar.gz", - "dir": "libjpeg-turbo-3.0.1", + + "/libjpeg-turbo/files/3.0.2/libjpeg-turbo-3.0.2.tar.gz/download", + "filename": "libjpeg-turbo-3.0.2.tar.gz", + "dir": "libjpeg-turbo-3.0.2", "license": ["README.ijg", "LICENSE.md"], "license_pattern": ( "(LEGAL ISSUES\n============\n\n.+?)\n\nREFERENCES\n==========" From 5721577e4e6f5d64a5c50ab8732eea9e5a437120 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 25 Jan 2024 20:20:53 +1100 Subject: [PATCH 022/361] Stop reading EPS at EOF marker --- Tests/test_file_eps.py | 8 ++++++++ src/PIL/EpsImagePlugin.py | 8 ++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Tests/test_file_eps.py b/Tests/test_file_eps.py index 8def9a435..5ba3a0c14 100644 --- a/Tests/test_file_eps.py +++ b/Tests/test_file_eps.py @@ -436,3 +436,11 @@ def test_eof_before_bounding_box(): with pytest.raises(OSError): with Image.open("Tests/images/zero_bb_eof_before_boundingbox.eps"): pass + + +def test_invalid_data_after_eof() -> None: + with open("Tests/images/illuCS6_preview.eps", "rb") as f: + img_bytes = io.BytesIO(f.read() + b"\r\n%" + (b" " * 255)) + + with Image.open(img_bytes) as img: + assert img.mode == "RGB" diff --git a/src/PIL/EpsImagePlugin.py b/src/PIL/EpsImagePlugin.py index d2e60aa07..94b163bc4 100644 --- a/src/PIL/EpsImagePlugin.py +++ b/src/PIL/EpsImagePlugin.py @@ -356,14 +356,10 @@ class EpsImageFile(ImageFile.ImageFile): self._size = columns, rows return + elif bytes_mv[:5] == b"%%EOF": + break elif trailer_reached and reading_trailer_comments: # Load EPS trailer - - # if this line starts with "%%EOF", - # then we've reached the end of the file - if bytes_mv[:5] == b"%%EOF": - break - s = str(bytes_mv[:bytes_read], "latin-1") _read_comment(s) elif bytes_mv[:9] == b"%%Trailer": From 945253672a74415807b5f685f54ebb1533ff468e Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Fri, 26 Jan 2024 19:11:18 +0200 Subject: [PATCH 023/361] Handle os.PathLike in is_path --- src/PIL/ImageFont.py | 2 +- src/PIL/_util.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index a63b73b33..9eecad1ca 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -230,7 +230,7 @@ class FreeTypeFont: ) if is_path(font): - if isinstance(font, Path): + if isinstance(font, os.PathLike): font = str(font) if sys.platform == "win32": font_bytes_path = font if isinstance(font, bytes) else font.encode() diff --git a/src/PIL/_util.py b/src/PIL/_util.py index 4ecdc4bd3..b649500ab 100644 --- a/src/PIL/_util.py +++ b/src/PIL/_util.py @@ -8,7 +8,7 @@ from ._typing import StrOrBytesPath, TypeGuard def is_path(f: Any) -> TypeGuard[StrOrBytesPath]: - return isinstance(f, (bytes, str, Path)) + return isinstance(f, (bytes, str, os.PathLike)) def is_directory(f: Any) -> TypeGuard[bytes | str | Path]: From f613a9213f4edc7b58ac84a4793223c8e4fd9191 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Fri, 26 Jan 2024 19:15:19 +0200 Subject: [PATCH 024/361] Parameterise test --- Tests/test_util.py | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/Tests/test_util.py b/Tests/test_util.py index 3395ef753..71a862569 100644 --- a/Tests/test_util.py +++ b/Tests/test_util.py @@ -1,27 +1,14 @@ from __future__ import annotations +from pathlib import Path + import pytest from PIL import _util -def test_is_path(): - # Arrange - fp = "filename.ext" - - # Act - it_is = _util.is_path(fp) - - # Assert - assert it_is - - -def test_path_obj_is_path(): - # Arrange - from pathlib import Path - - test_path = Path("filename.ext") - +@pytest.mark.parametrize("test_path", ["filename.ext", Path("filename.ext")]) +def test_is_path(test_path): # Act it_is = _util.is_path(test_path) From 16d4068b42f0a6069e14b2327302df713dabbfed Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Fri, 26 Jan 2024 19:17:13 +0200 Subject: [PATCH 025/361] Test os.PathLike that's not pathlib.Path --- Tests/test_util.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Tests/test_util.py b/Tests/test_util.py index 71a862569..617e5f7c6 100644 --- a/Tests/test_util.py +++ b/Tests/test_util.py @@ -1,13 +1,15 @@ from __future__ import annotations -from pathlib import Path +from pathlib import Path, PurePath import pytest from PIL import _util -@pytest.mark.parametrize("test_path", ["filename.ext", Path("filename.ext")]) +@pytest.mark.parametrize( + "test_path", ["filename.ext", Path("filename.ext"), PurePath("filename.ext")] +) def test_is_path(test_path): # Act it_is = _util.is_path(test_path) From d631afc266c3c1214e12373d3ad0d16978867a7f Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Fri, 26 Jan 2024 20:46:58 +0200 Subject: [PATCH 026/361] Use os.fspath instead of isinstance and str --- src/PIL/ImageFont.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index 9eecad1ca..1feaf447a 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -230,8 +230,7 @@ class FreeTypeFont: ) if is_path(font): - if isinstance(font, os.PathLike): - font = str(font) + font = os.fspath(font) if sys.platform == "win32": font_bytes_path = font if isinstance(font, bytes) else font.encode() try: From cd640e5df27c6c3c58d8cc63c16cba71c237b9a4 Mon Sep 17 00:00:00 2001 From: Nicola Guerrera Date: Mon, 22 Jan 2024 15:19:59 +0100 Subject: [PATCH 027/361] Refactor grabclipboard() for x11 and wayland MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simpified logic and made it more robust against edge cases ( see the `allowed_errors` list ). Doing error checking this way, makes the behaviour of this function for x11 and wayland platforms more silimar to darwin and windows systems. fix typo src/PIL/ImageGrab.py Co-authored-by: Ondrej Baranovič fix typo src/PIL/ImageGrab.py Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> ImageGrab: \added debian edge case to comment --- src/PIL/ImageGrab.py | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index a4993d3d4..1cb02f5f9 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -149,18 +149,7 @@ def grabclipboard(): session_type = None if shutil.which("wl-paste") and session_type in ("wayland", None): - output = subprocess.check_output(["wl-paste", "-l"]).decode() - mimetypes = output.splitlines() - if "image/png" in mimetypes: - mimetype = "image/png" - elif mimetypes: - mimetype = mimetypes[0] - else: - mimetype = None - - args = ["wl-paste"] - if mimetype: - args.extend(["-t", mimetype]) + args = ["wl-paste", "-t", "image"] elif shutil.which("xclip") and session_type in ("x11", None): args = ["xclip", "-selection", "clipboard", "-t", "image/png", "-o"] else: @@ -168,10 +157,19 @@ def grabclipboard(): raise NotImplementedError(msg) p = subprocess.run(args, capture_output=True) - err = p.stderr - if err: - msg = f"{args[0]} error: {err.strip().decode()}" + err = p.stderr.decode() + if p.returncode != 0: + allowed_errors = [ + "Nothing is copied", # wl-paste, when the clipboard is empty + "not available", # wl-paste/debian xclip, when an image isn't available + "cannot convert", # xclip, when an image isn't available + "There is no owner", # xclip, when the clipboard isn't initialized + ] + if any(e in err for e in allowed_errors): + return None + msg = f"{args[0]} error: {err.strip() if err else 'Unknown error'}" raise ChildProcessError(msg) + data = io.BytesIO(p.stdout) im = Image.open(data) im.load() From b81341ae7e62a246adabc40982d2b81ed3b7542d Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 27 Jan 2024 20:15:10 +1100 Subject: [PATCH 028/361] Only decode stderr when necessary --- src/PIL/ImageGrab.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index 1cb02f5f9..730351c0d 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -157,17 +157,21 @@ def grabclipboard(): raise NotImplementedError(msg) p = subprocess.run(args, capture_output=True) - err = p.stderr.decode() + err = p.stderr if p.returncode != 0: allowed_errors = [ - "Nothing is copied", # wl-paste, when the clipboard is empty - "not available", # wl-paste/debian xclip, when an image isn't available - "cannot convert", # xclip, when an image isn't available - "There is no owner", # xclip, when the clipboard isn't initialized + # wl-paste, when the clipboard is empty + b"Nothing is copied", + # wl-paste/debian xclip, when an image isn't available + b"not available", + # xclip, when an image isn't available + b"cannot convert", + # xclip, when the clipboard isn't initialized + b"There is no owner", ] if any(e in err for e in allowed_errors): return None - msg = f"{args[0]} error: {err.strip() if err else 'Unknown error'}" + msg = f"{args[0]} error: {err.strip().decode() if err else 'Unknown error'}" raise ChildProcessError(msg) data = io.BytesIO(p.stdout) From d2d9240de4cafee650f11c085a7ec321240a8e3e Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 27 Jan 2024 19:26:55 +1100 Subject: [PATCH 029/361] Do not declare variable until necessary --- src/PIL/ImageGrab.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index 730351c0d..ca27b520c 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -157,7 +157,6 @@ def grabclipboard(): raise NotImplementedError(msg) p = subprocess.run(args, capture_output=True) - err = p.stderr if p.returncode != 0: allowed_errors = [ # wl-paste, when the clipboard is empty @@ -169,6 +168,7 @@ def grabclipboard(): # xclip, when the clipboard isn't initialized b"There is no owner", ] + err = p.stderr if any(e in err for e in allowed_errors): return None msg = f"{args[0]} error: {err.strip().decode() if err else 'Unknown error'}" From 6998f3476843e2f8da00eb23545aab55dc280006 Mon Sep 17 00:00:00 2001 From: Nicola Guerrera Date: Sat, 27 Jan 2024 12:08:16 +0100 Subject: [PATCH 030/361] Rearrange error handling Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- src/PIL/ImageGrab.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index ca27b520c..a2c7a9351 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -171,7 +171,9 @@ def grabclipboard(): err = p.stderr if any(e in err for e in allowed_errors): return None - msg = f"{args[0]} error: {err.strip().decode() if err else 'Unknown error'}" + msg = f"{args[0]} error" + if err: + msg += f": {err.strip().decode()}" raise ChildProcessError(msg) data = io.BytesIO(p.stdout) From d3205fae192ec10497326aacb7325f5880d07b04 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 27 Jan 2024 22:54:01 +1100 Subject: [PATCH 031/361] Simplified code --- src/PIL/ImageGrab.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index a2c7a9351..c04be521f 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -158,7 +158,8 @@ def grabclipboard(): p = subprocess.run(args, capture_output=True) if p.returncode != 0: - allowed_errors = [ + err = p.stderr + for silent_error in [ # wl-paste, when the clipboard is empty b"Nothing is copied", # wl-paste/debian xclip, when an image isn't available @@ -167,10 +168,9 @@ def grabclipboard(): b"cannot convert", # xclip, when the clipboard isn't initialized b"There is no owner", - ] - err = p.stderr - if any(e in err for e in allowed_errors): - return None + ]: + if err in silent_error: + return None msg = f"{args[0]} error" if err: msg += f": {err.strip().decode()}" From 61d47c3dfa200b186ecacd7b9a5090cedb5523b6 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sat, 27 Jan 2024 14:06:06 +0200 Subject: [PATCH 032/361] More support for arbitrary os.PathLike --- Tests/test_image.py | 3 +-- docs/reference/open_files.rst | 2 +- src/PIL/Image.py | 16 ++++++---------- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/Tests/test_image.py b/Tests/test_image.py index dd989ad99..84189df54 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -7,6 +7,7 @@ import shutil import sys import tempfile import warnings +from pathlib import Path import pytest @@ -161,8 +162,6 @@ class TestImage: pass def test_pathlib(self, tmp_path): - from PIL.Image import Path - with Image.open(Path("Tests/images/multipage-mmap.tiff")) as im: assert im.mode == "P" assert im.size == (10, 10) diff --git a/docs/reference/open_files.rst b/docs/reference/open_files.rst index f31941c9a..730c8da5b 100644 --- a/docs/reference/open_files.rst +++ b/docs/reference/open_files.rst @@ -3,7 +3,7 @@ File Handling in Pillow ======================= -When opening a file as an image, Pillow requires a filename, ``pathlib.Path`` +When opening a file as an image, Pillow requires a filename, ``os.PathLike`` object, or a file-like object. Pillow uses the filename or ``Path`` to open a file, so for the rest of this article, they will all be treated as a file-like object. diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 553f36703..48125b317 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -39,7 +39,6 @@ import tempfile import warnings from collections.abc import Callable, MutableMapping from enum import IntEnum -from pathlib import Path try: from defusedxml import ElementTree @@ -2370,7 +2369,7 @@ class Image: implement the ``seek``, ``tell``, and ``write`` methods, and be opened in binary mode. - :param fp: A filename (string), pathlib.Path object or file object. + :param fp: A filename (string), os.PathLike object or file object. :param format: Optional format override. If omitted, the format to use is determined from the filename extension. If a file object was used instead of a filename, this @@ -2385,11 +2384,8 @@ class Image: filename = "" open_fp = False - if isinstance(fp, Path): - filename = str(fp) - open_fp = True - elif is_path(fp): - filename = fp + if is_path(fp): + filename = os.fspath(fp) open_fp = True elif fp == sys.stdout: try: @@ -3206,7 +3202,7 @@ def open(fp, mode="r", formats=None) -> Image: :py:meth:`~PIL.Image.Image.load` method). See :py:func:`~PIL.Image.new`. See :ref:`file-handling`. - :param fp: A filename (string), pathlib.Path object or a file object. + :param fp: A filename (string), os.PathLike object or a file object. The file object must implement ``file.read``, ``file.seek``, and ``file.tell`` methods, and be opened in binary mode. The file object will also seek to zero @@ -3244,8 +3240,8 @@ def open(fp, mode="r", formats=None) -> Image: exclusive_fp = False filename = "" - if isinstance(fp, Path): - filename = str(fp.resolve()) + if isinstance(fp, os.PathLike): + filename = os.path.realpath(os.fspath(fp)) elif is_path(fp): filename = fp From 40fceedfba5d79cde4891ec70e69aee961cd3165 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 30 Jan 2024 22:22:13 +1100 Subject: [PATCH 033/361] brew remove libxau --- .github/workflows/wheels-dependencies.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 3ec314873..50ac2e18e 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -131,13 +131,13 @@ untar pillow-depends-main.zip if [[ -n "$IS_MACOS" ]]; then # webp, libtiff, libxcb cause a conflict with building webp, libtiff, libxcb - # libxdmcp causes an issue on macOS < 11 + # libxau and libxdmcp cause an issue on macOS < 11 # if php is installed, brew tries to reinstall these after installing openblas # remove cairo to fix building harfbuzz on arm64 # remove lcms2 and libpng to fix building openjpeg on arm64 # remove zstd to avoid inclusion on x86_64 # curl from brew requires zstd, use system curl - brew remove --ignore-dependencies webp libpng libtiff libxcb libxdmcp curl php cairo lcms2 ghostscript zstd + brew remove --ignore-dependencies webp libpng libtiff libxcb libxau libxdmcp curl php cairo lcms2 ghostscript zstd brew install pkg-config fi From b374f2679c4a0b102a1bc59b177a1a6b5cd0e1be Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 30 Jan 2024 22:57:17 +1100 Subject: [PATCH 034/361] Build libxcb dependencies on macOS x86-64 --- .github/workflows/wheels-dependencies.sh | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 50ac2e18e..26bf2f6d6 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -72,13 +72,11 @@ function build { build_simple xcb-proto 1.16.0 https://xorg.freedesktop.org/archive/individual/proto if [ -n "$IS_MACOS" ]; then - if [[ "$CIBW_ARCHS" == "arm64" ]]; then - build_simple xorgproto 2023.2 https://www.x.org/pub/individual/proto - build_simple libXau 1.0.11 https://www.x.org/pub/individual/lib - build_simple libpthread-stubs 0.5 https://xcb.freedesktop.org/dist - if [ -f /Library/Frameworks/Python.framework/Versions/Current/share/pkgconfig/xcb-proto.pc ]; then - cp /Library/Frameworks/Python.framework/Versions/Current/share/pkgconfig/xcb-proto.pc /Library/Frameworks/Python.framework/Versions/Current/lib/pkgconfig/xcb-proto.pc - fi + build_simple xorgproto 2023.2 https://www.x.org/pub/individual/proto + build_simple libXau 1.0.11 https://www.x.org/pub/individual/lib + build_simple libpthread-stubs 0.5 https://xcb.freedesktop.org/dist + if [ -f /Library/Frameworks/Python.framework/Versions/Current/share/pkgconfig/xcb-proto.pc ]; then + cp /Library/Frameworks/Python.framework/Versions/Current/share/pkgconfig/xcb-proto.pc /Library/Frameworks/Python.framework/Versions/Current/lib/pkgconfig/xcb-proto.pc fi else sed s/\${pc_sysrootdir\}// /usr/local/share/pkgconfig/xcb-proto.pc > /usr/local/lib/pkgconfig/xcb-proto.pc From 39cbd4f0f1bf4f40229f50aa5480b4b25eaae1a5 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 28 Jan 2024 16:31:03 +1100 Subject: [PATCH 035/361] Expanded error message strings --- src/PIL/ImageGrab.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index c04be521f..b888e66f1 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -163,11 +163,11 @@ def grabclipboard(): # wl-paste, when the clipboard is empty b"Nothing is copied", # wl-paste/debian xclip, when an image isn't available - b"not available", + b" not available", # xclip, when an image isn't available - b"cannot convert", + b"cannot convert ", # xclip, when the clipboard isn't initialized - b"There is no owner", + b"xclip: Error: There is no owner for the ", ]: if err in silent_error: return None From 5efa2ade222785979c1b085be09eff5ee738c42c Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 28 Jan 2024 16:53:27 +1100 Subject: [PATCH 036/361] Added test --- Tests/test_imagegrab.py | 12 ++++++++++++ src/PIL/ImageGrab.py | 6 +++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/Tests/test_imagegrab.py b/Tests/test_imagegrab.py index 9d3d40398..efef4d908 100644 --- a/Tests/test_imagegrab.py +++ b/Tests/test_imagegrab.py @@ -119,3 +119,15 @@ $ms = new-object System.IO.MemoryStream(, $bytes) subprocess.call(["wl-copy"], stdin=fp) im = ImageGrab.grabclipboard() assert_image_equal_tofile(im, image_path) + + @pytest.mark.skipif( + ( + sys.platform != "linux" + or not all(shutil.which(cmd) for cmd in ("wl-paste", "wl-copy")) + ), + reason="Linux with wl-clipboard only", + ) + @pytest.mark.parametrize("arg", ("text", "--clear")) + def test_grabclipboard_wl_clipboard_errors(self, arg): + subprocess.call(["wl-copy", arg]) + assert ImageGrab.grabclipboard() is None diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index b888e66f1..17f5750b1 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -162,7 +162,11 @@ def grabclipboard(): for silent_error in [ # wl-paste, when the clipboard is empty b"Nothing is copied", - # wl-paste/debian xclip, when an image isn't available + # Ubuntu/Debian wl-paste, when the clipboard is empty + b"No selection", + # Ubuntu/Debian wl-paste, when an image isn't available + b"No suitable type of content copied", + # wl-paste or Ubuntu/Debian xclip, when an image isn't available b" not available", # xclip, when an image isn't available b"cannot convert ", From d57b5e827cfd0e9850a074a4ba27e9f5ad0c9910 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 28 Jan 2024 16:49:44 +1100 Subject: [PATCH 037/361] Corrected check --- src/PIL/ImageGrab.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index 17f5750b1..3f3be706d 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -173,7 +173,7 @@ def grabclipboard(): # xclip, when the clipboard isn't initialized b"xclip: Error: There is no owner for the ", ]: - if err in silent_error: + if silent_error in err: return None msg = f"{args[0]} error" if err: From bb1fece57a2c894773597c9a6fb10bd81e36123d Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 31 Jan 2024 21:55:32 +1100 Subject: [PATCH 038/361] Added type hints --- Tests/test_bmp_reference.py | 6 +- Tests/test_box_blur.py | 12 +- Tests/test_file_apng.py | 6 +- Tests/test_file_container.py | 10 +- Tests/test_file_gif.py | 30 ++-- Tests/test_file_mpo.py | 19 +-- Tests/test_file_ppm.py | 22 +-- Tests/test_file_sgi.py | 2 +- Tests/test_image_frombytes.py | 2 +- Tests/test_image_load.py | 2 +- Tests/test_imagedraw.py | 291 ++++++++++++++++++++++++++++------ Tests/test_imagetk.py | 4 +- Tests/test_lib_pack.py | 16 +- Tests/test_mode_i16.py | 6 +- 14 files changed, 327 insertions(+), 101 deletions(-) diff --git a/Tests/test_bmp_reference.py b/Tests/test_bmp_reference.py index 22ac9443e..0ad496135 100644 --- a/Tests/test_bmp_reference.py +++ b/Tests/test_bmp_reference.py @@ -10,7 +10,7 @@ from .helper import assert_image_similar base = os.path.join("Tests", "images", "bmp") -def get_files(d, ext: str = ".bmp"): +def get_files(d: str, ext: str = ".bmp") -> list[str]: return [ os.path.join(base, d, f) for f in os.listdir(os.path.join(base, d)) if ext in f ] @@ -29,7 +29,7 @@ def test_bad() -> None: pass -def test_questionable(): +def test_questionable() -> None: """These shouldn't crash/dos, but it's not well defined that these are in spec""" supported = [ @@ -80,7 +80,7 @@ def test_good() -> None: "rgb32bf.bmp": "rgb24.png", } - def get_compare(f): + def get_compare(f: str) -> str: name = os.path.split(f)[1] if name in file_map: return os.path.join(base, "html", file_map[name]) diff --git a/Tests/test_box_blur.py b/Tests/test_box_blur.py index dfedb48d9..1f6ed6127 100644 --- a/Tests/test_box_blur.py +++ b/Tests/test_box_blur.py @@ -23,11 +23,11 @@ def test_imageops_box_blur() -> None: assert isinstance(i, Image.Image) -def box_blur(image, radius: int = 1, n: int = 1): +def box_blur(image: Image.Image, radius: float = 1, n: int = 1) -> Image.Image: return image._new(image.im.box_blur((radius, radius), n)) -def assert_image(im, data, delta: int = 0) -> None: +def assert_image(im: Image.Image, data: list[list[int]], delta: int = 0) -> None: it = iter(im.getdata()) for data_row in data: im_row = [next(it) for _ in range(im.size[0])] @@ -37,7 +37,13 @@ def assert_image(im, data, delta: int = 0) -> None: next(it) -def assert_blur(im, radius, data, passes: int = 1, delta: int = 0) -> None: +def assert_blur( + im: Image.Image, + radius: float, + data: list[list[int]], + passes: int = 1, + delta: int = 0, +) -> None: # check grayscale image assert_image(box_blur(im, radius, passes), data, delta) rgba = Image.merge("RGBA", (im, im, im, im)) diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index f9edf6e98..395165b36 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -47,7 +47,7 @@ def test_apng_basic() -> None: "filename", ("Tests/images/apng/split_fdat.png", "Tests/images/apng/split_fdat_zero_chunk.png"), ) -def test_apng_fdat(filename) -> None: +def test_apng_fdat(filename: str) -> None: with Image.open(filename) as im: im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 255, 0, 255) @@ -338,7 +338,7 @@ def test_apng_syntax_errors() -> None: "sequence_fdat_fctl.png", ), ) -def test_apng_sequence_errors(test_file) -> None: +def test_apng_sequence_errors(test_file: str) -> None: with pytest.raises(SyntaxError): with Image.open(f"Tests/images/apng/{test_file}") as im: im.seek(im.n_frames - 1) @@ -681,7 +681,7 @@ def test_seek_after_close() -> None: @pytest.mark.parametrize("default_image", (True, False)) @pytest.mark.parametrize("duplicate", (True, False)) def test_different_modes_in_later_frames( - mode, default_image, duplicate, tmp_path: Path + mode: str, default_image: bool, duplicate: bool, tmp_path: Path ) -> None: test_file = str(tmp_path / "temp.png") diff --git a/Tests/test_file_container.py b/Tests/test_file_container.py index 4dba4be5d..813b444db 100644 --- a/Tests/test_file_container.py +++ b/Tests/test_file_container.py @@ -64,7 +64,7 @@ def test_seek_mode_2() -> None: @pytest.mark.parametrize("bytesmode", (True, False)) -def test_read_n0(bytesmode) -> None: +def test_read_n0(bytesmode: bool) -> None: # Arrange with open(TEST_FILE, "rb" if bytesmode else "r") as fh: container = ContainerIO.ContainerIO(fh, 22, 100) @@ -80,7 +80,7 @@ def test_read_n0(bytesmode) -> None: @pytest.mark.parametrize("bytesmode", (True, False)) -def test_read_n(bytesmode) -> None: +def test_read_n(bytesmode: bool) -> None: # Arrange with open(TEST_FILE, "rb" if bytesmode else "r") as fh: container = ContainerIO.ContainerIO(fh, 22, 100) @@ -96,7 +96,7 @@ def test_read_n(bytesmode) -> None: @pytest.mark.parametrize("bytesmode", (True, False)) -def test_read_eof(bytesmode) -> None: +def test_read_eof(bytesmode: bool) -> None: # Arrange with open(TEST_FILE, "rb" if bytesmode else "r") as fh: container = ContainerIO.ContainerIO(fh, 22, 100) @@ -112,7 +112,7 @@ def test_read_eof(bytesmode) -> None: @pytest.mark.parametrize("bytesmode", (True, False)) -def test_readline(bytesmode) -> None: +def test_readline(bytesmode: bool) -> None: # Arrange with open(TEST_FILE, "rb" if bytesmode else "r") as fh: container = ContainerIO.ContainerIO(fh, 0, 120) @@ -127,7 +127,7 @@ def test_readline(bytesmode) -> None: @pytest.mark.parametrize("bytesmode", (True, False)) -def test_readlines(bytesmode) -> None: +def test_readlines(bytesmode: bool) -> None: # Arrange expected = [ "This is line 1\n", diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index 3f550fd11..db9d3586c 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -3,6 +3,7 @@ from __future__ import annotations import warnings from io import BytesIO from pathlib import Path +from typing import Generator import pytest @@ -144,13 +145,13 @@ def test_strategy() -> None: def test_optimize() -> None: - def test_grayscale(optimize): + def test_grayscale(optimize: int) -> int: im = Image.new("L", (1, 1), 0) filename = BytesIO() im.save(filename, "GIF", optimize=optimize) return len(filename.getvalue()) - def test_bilevel(optimize): + def test_bilevel(optimize: int) -> int: im = Image.new("1", (1, 1), 0) test_file = BytesIO() im.save(test_file, "GIF", optimize=optimize) @@ -178,7 +179,9 @@ def test_optimize() -> None: (4, 513, 256), ), ) -def test_optimize_correctness(colors, size, expected_palette_length) -> None: +def test_optimize_correctness( + colors: int, size: int, expected_palette_length: int +) -> None: # 256 color Palette image, posterize to > 128 and < 128 levels. # Size bigger and smaller than 512x512. # Check the palette for number of colors allocated. @@ -297,7 +300,7 @@ def test_roundtrip_save_all_1(tmp_path: Path) -> None: ("Tests/images/dispose_bgnd_rgba.gif", "RGBA"), ), ) -def test_loading_multiple_palettes(path, mode) -> None: +def test_loading_multiple_palettes(path: str, mode: str) -> None: with Image.open(path) as im: assert im.mode == "P" first_frame_colors = im.palette.colors.keys() @@ -347,9 +350,9 @@ def test_palette_handling(tmp_path: Path) -> None: def test_palette_434(tmp_path: Path) -> None: # see https://github.com/python-pillow/Pillow/issues/434 - def roundtrip(im, *args, **kwargs): + def roundtrip(im: Image.Image, **kwargs: bool) -> Image.Image: out = str(tmp_path / "temp.gif") - im.copy().save(out, *args, **kwargs) + im.copy().save(out, **kwargs) reloaded = Image.open(out) return reloaded @@ -429,7 +432,7 @@ def test_seek_rewind() -> None: ("Tests/images/iss634.gif", 42), ), ) -def test_n_frames(path, n_frames) -> None: +def test_n_frames(path: str, n_frames: int) -> None: # Test is_animated before n_frames with Image.open(path) as im: assert im.is_animated == (n_frames != 1) @@ -541,7 +544,10 @@ def test_dispose_background_transparency() -> None: ), ), ) -def test_transparent_dispose(loading_strategy, expected_colors) -> None: +def test_transparent_dispose( + loading_strategy: GifImagePlugin.LoadingStrategy, + expected_colors: tuple[tuple[int | tuple[int, int, int, int], ...]], +) -> None: GifImagePlugin.LOADING_STRATEGY = loading_strategy try: with Image.open("Tests/images/transparent_dispose.gif") as img: @@ -889,7 +895,9 @@ def test_identical_frames(tmp_path: Path) -> None: 1500, ), ) -def test_identical_frames_to_single_frame(duration, tmp_path: Path) -> None: +def test_identical_frames_to_single_frame( + duration: int | list[int], tmp_path: Path +) -> None: out = str(tmp_path / "temp.gif") im_list = [ Image.new("L", (100, 100), "#000"), @@ -1049,7 +1057,7 @@ def test_retain_comment_in_subsequent_frames(tmp_path: Path) -> None: def test_version(tmp_path: Path) -> None: out = str(tmp_path / "temp.gif") - def assert_version_after_save(im, version) -> None: + def assert_version_after_save(im: Image.Image, version: bytes) -> None: im.save(out) with Image.open(out) as reread: assert reread.info["version"] == version @@ -1088,7 +1096,7 @@ def test_append_images(tmp_path: Path) -> None: assert reread.n_frames == 3 # Tests appending using a generator - def im_generator(ims): + def im_generator(ims: list[Image.Image]) -> Generator[Image.Image, None, None]: yield from ims im.save(out, save_all=True, append_images=im_generator(ims)) diff --git a/Tests/test_file_mpo.py b/Tests/test_file_mpo.py index 55b04a1e0..4fb00d699 100644 --- a/Tests/test_file_mpo.py +++ b/Tests/test_file_mpo.py @@ -2,6 +2,7 @@ from __future__ import annotations import warnings from io import BytesIO +from typing import Any import pytest @@ -19,7 +20,7 @@ test_files = ["Tests/images/sugarshack.mpo", "Tests/images/frozenpond.mpo"] pytestmark = skip_unless_feature("jpg") -def roundtrip(im, **options): +def roundtrip(im: Image.Image, **options: Any) -> Image.Image: out = BytesIO() im.save(out, "MPO", **options) test_bytes = out.tell() @@ -30,7 +31,7 @@ def roundtrip(im, **options): @pytest.mark.parametrize("test_file", test_files) -def test_sanity(test_file) -> None: +def test_sanity(test_file: str) -> None: with Image.open(test_file) as im: im.load() assert im.mode == "RGB" @@ -70,7 +71,7 @@ def test_context_manager() -> None: @pytest.mark.parametrize("test_file", test_files) -def test_app(test_file) -> None: +def test_app(test_file: str) -> None: # Test APP/COM reader (@PIL135) with Image.open(test_file) as im: assert im.applist[0][0] == "APP1" @@ -82,7 +83,7 @@ def test_app(test_file) -> None: @pytest.mark.parametrize("test_file", test_files) -def test_exif(test_file) -> None: +def test_exif(test_file: str) -> None: with Image.open(test_file) as im_original: im_reloaded = roundtrip(im_original, save_all=True, exif=im_original.getexif()) @@ -143,7 +144,7 @@ def test_reload_exif_after_seek() -> None: @pytest.mark.parametrize("test_file", test_files) -def test_mp(test_file) -> None: +def test_mp(test_file: str) -> None: with Image.open(test_file) as im: mpinfo = im._getmp() assert mpinfo[45056] == b"0100" @@ -168,7 +169,7 @@ def test_mp_no_data() -> None: @pytest.mark.parametrize("test_file", test_files) -def test_mp_attribute(test_file) -> None: +def test_mp_attribute(test_file: str) -> None: with Image.open(test_file) as im: mpinfo = im._getmp() for frame_number, mpentry in enumerate(mpinfo[0xB002]): @@ -185,7 +186,7 @@ def test_mp_attribute(test_file) -> None: @pytest.mark.parametrize("test_file", test_files) -def test_seek(test_file) -> None: +def test_seek(test_file: str) -> None: with Image.open(test_file) as im: assert im.tell() == 0 # prior to first image raises an error, both blatant and borderline @@ -229,7 +230,7 @@ def test_eoferror() -> None: @pytest.mark.parametrize("test_file", test_files) -def test_image_grab(test_file) -> None: +def test_image_grab(test_file: str) -> None: with Image.open(test_file) as im: assert im.tell() == 0 im0 = im.tobytes() @@ -244,7 +245,7 @@ def test_image_grab(test_file) -> None: @pytest.mark.parametrize("test_file", test_files) -def test_save(test_file) -> None: +def test_save(test_file: str) -> None: with Image.open(test_file) as im: assert im.tell() == 0 jpg0 = roundtrip(im) diff --git a/Tests/test_file_ppm.py b/Tests/test_file_ppm.py index 94f66ee7d..6e0fa32e4 100644 --- a/Tests/test_file_ppm.py +++ b/Tests/test_file_ppm.py @@ -70,7 +70,9 @@ def test_sanity() -> None: ), ), ) -def test_arbitrary_maxval(data, mode, pixels) -> None: +def test_arbitrary_maxval( + data: bytes, mode: str, pixels: tuple[int | tuple[int, int, int], ...] +) -> None: fp = BytesIO(data) with Image.open(fp) as im: assert im.size == (3, 1) @@ -139,7 +141,7 @@ def test_pfm_big_endian(tmp_path: Path) -> None: b"Pf 1 1 -0.0 \0\0\0\0", ], ) -def test_pfm_invalid(data) -> None: +def test_pfm_invalid(data: bytes) -> None: with pytest.raises(ValueError): with Image.open(BytesIO(data)): pass @@ -162,7 +164,7 @@ def test_pfm_invalid(data) -> None: ), ), ) -def test_plain(plain_path, raw_path) -> None: +def test_plain(plain_path: str, raw_path: str) -> None: with Image.open(plain_path) as im: assert_image_equal_tofile(im, raw_path) @@ -186,7 +188,9 @@ def test_16bit_plain_pgm() -> None: (b"P3\n2 2\n255", b"0 0 0 001 1 1 2 2 2 255 255 255", 10**6), ), ) -def test_plain_data_with_comment(tmp_path: Path, header, data, comment_count) -> None: +def test_plain_data_with_comment( + tmp_path: Path, header: bytes, data: bytes, comment_count: int +) -> None: path1 = str(tmp_path / "temp1.ppm") path2 = str(tmp_path / "temp2.ppm") comment = b"# comment" * comment_count @@ -199,7 +203,7 @@ def test_plain_data_with_comment(tmp_path: Path, header, data, comment_count) -> @pytest.mark.parametrize("data", (b"P1\n128 128\n", b"P3\n128 128\n255\n")) -def test_plain_truncated_data(tmp_path: Path, data) -> None: +def test_plain_truncated_data(tmp_path: Path, data: bytes) -> None: path = str(tmp_path / "temp.ppm") with open(path, "wb") as f: f.write(data) @@ -210,7 +214,7 @@ def test_plain_truncated_data(tmp_path: Path, data) -> None: @pytest.mark.parametrize("data", (b"P1\n128 128\n1009", b"P3\n128 128\n255\n100A")) -def test_plain_invalid_data(tmp_path: Path, data) -> None: +def test_plain_invalid_data(tmp_path: Path, data: bytes) -> None: path = str(tmp_path / "temp.ppm") with open(path, "wb") as f: f.write(data) @@ -227,7 +231,7 @@ def test_plain_invalid_data(tmp_path: Path, data) -> None: b"P3\n128 128\n255\n012345678910 0", # token too long ), ) -def test_plain_ppm_token_too_long(tmp_path: Path, data) -> None: +def test_plain_ppm_token_too_long(tmp_path: Path, data: bytes) -> None: path = str(tmp_path / "temp.ppm") with open(path, "wb") as f: f.write(data) @@ -313,7 +317,7 @@ def test_not_enough_image_data(tmp_path: Path) -> None: @pytest.mark.parametrize("maxval", (b"0", b"65536")) -def test_invalid_maxval(maxval, tmp_path: Path) -> None: +def test_invalid_maxval(maxval: bytes, tmp_path: Path) -> None: path = str(tmp_path / "temp.ppm") with open(path, "wb") as f: f.write(b"P6\n3 1 " + maxval) @@ -351,7 +355,7 @@ def test_mimetypes(tmp_path: Path) -> None: @pytest.mark.parametrize("buffer", (True, False)) -def test_save_stdout(buffer) -> None: +def test_save_stdout(buffer: bool) -> None: old_stdout = sys.stdout if buffer: diff --git a/Tests/test_file_sgi.py b/Tests/test_file_sgi.py index 92aea0735..e13a8019e 100644 --- a/Tests/test_file_sgi.py +++ b/Tests/test_file_sgi.py @@ -72,7 +72,7 @@ def test_invalid_file() -> None: def test_write(tmp_path: Path) -> None: - def roundtrip(img) -> None: + def roundtrip(img: Image.Image) -> None: out = str(tmp_path / "temp.sgi") img.save(out, format="sgi") assert_image_equal_tofile(img, out) diff --git a/Tests/test_image_frombytes.py b/Tests/test_image_frombytes.py index 6474daba1..98c0ea0b4 100644 --- a/Tests/test_image_frombytes.py +++ b/Tests/test_image_frombytes.py @@ -8,7 +8,7 @@ from .helper import assert_image_equal, hopper @pytest.mark.parametrize("data_type", ("bytes", "memoryview")) -def test_sanity(data_type) -> None: +def test_sanity(data_type: str) -> None: im1 = hopper() data = im1.tobytes() diff --git a/Tests/test_image_load.py b/Tests/test_image_load.py index 5b1a9ee2d..0605821e0 100644 --- a/Tests/test_image_load.py +++ b/Tests/test_image_load.py @@ -26,7 +26,7 @@ def test_close() -> None: im.getpixel((0, 0)) -def test_close_after_load(caplog) -> None: +def test_close_after_load(caplog: pytest.LogCaptureFixture) -> None: im = Image.open("Tests/images/hopper.gif") im.load() with caplog.at_level(logging.DEBUG): diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index 86d25b1eb..c02ac49dd 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -74,7 +74,14 @@ def test_mode_mismatch() -> None: @pytest.mark.parametrize("bbox", BBOX) @pytest.mark.parametrize("start, end", ((0, 180), (0.5, 180.4))) -def test_arc(bbox, start, end) -> None: +def test_arc( + bbox: tuple[tuple[int, int], tuple[int, int]] + | list[tuple[int, int]] + | list[int] + | tuple[int, int, int, int], + start: float, + end: float, +) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -87,7 +94,12 @@ def test_arc(bbox, start, end) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_arc_end_le_start(bbox) -> None: +def test_arc_end_le_start( + bbox: tuple[tuple[int, int], tuple[int, int]] + | list[tuple[int, int]] + | list[int] + | tuple[int, int, int, int] +) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -102,7 +114,12 @@ def test_arc_end_le_start(bbox) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_arc_no_loops(bbox) -> None: +def test_arc_no_loops( + bbox: tuple[tuple[int, int], tuple[int, int]] + | list[tuple[int, int]] + | list[int] + | tuple[int, int, int, int] +) -> None: # No need to go in loops # Arrange im = Image.new("RGB", (W, H)) @@ -118,7 +135,12 @@ def test_arc_no_loops(bbox) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_arc_width(bbox) -> None: +def test_arc_width( + bbox: tuple[tuple[int, int], tuple[int, int]] + | list[tuple[int, int]] + | list[int] + | tuple[int, int, int, int] +) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -131,7 +153,12 @@ def test_arc_width(bbox) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_arc_width_pieslice_large(bbox) -> None: +def test_arc_width_pieslice_large( + bbox: tuple[tuple[int, int], tuple[int, int]] + | list[tuple[int, int]] + | list[int] + | tuple[int, int, int, int] +) -> None: # Tests an arc with a large enough width that it is a pieslice # Arrange im = Image.new("RGB", (W, H)) @@ -145,7 +172,12 @@ def test_arc_width_pieslice_large(bbox) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_arc_width_fill(bbox) -> None: +def test_arc_width_fill( + bbox: tuple[tuple[int, int], tuple[int, int]] + | list[tuple[int, int]] + | list[int] + | tuple[int, int, int, int] +) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -158,7 +190,12 @@ def test_arc_width_fill(bbox) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_arc_width_non_whole_angle(bbox) -> None: +def test_arc_width_non_whole_angle( + bbox: tuple[tuple[int, int], tuple[int, int]] + | list[tuple[int, int]] + | list[int] + | tuple[int, int, int, int] +) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -200,7 +237,13 @@ def test_bitmap() -> None: @pytest.mark.parametrize("mode", ("RGB", "L")) @pytest.mark.parametrize("bbox", BBOX) -def test_chord(mode, bbox) -> None: +def test_chord( + mode: str, + bbox: tuple[tuple[int, int], tuple[int, int]] + | list[tuple[int, int]] + | list[int] + | tuple[int, int, int, int], +) -> None: # Arrange im = Image.new(mode, (W, H)) draw = ImageDraw.Draw(im) @@ -214,7 +257,12 @@ def test_chord(mode, bbox) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_chord_width(bbox) -> None: +def test_chord_width( + bbox: tuple[tuple[int, int], tuple[int, int]] + | list[tuple[int, int]] + | list[int] + | tuple[int, int, int, int] +) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -227,7 +275,12 @@ def test_chord_width(bbox) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_chord_width_fill(bbox) -> None: +def test_chord_width_fill( + bbox: tuple[tuple[int, int], tuple[int, int]] + | list[tuple[int, int]] + | list[int] + | tuple[int, int, int, int] +) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -240,7 +293,12 @@ def test_chord_width_fill(bbox) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_chord_zero_width(bbox) -> None: +def test_chord_zero_width( + bbox: tuple[tuple[int, int], tuple[int, int]] + | list[tuple[int, int]] + | list[int] + | tuple[int, int, int, int] +) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -266,7 +324,13 @@ def test_chord_too_fat() -> None: @pytest.mark.parametrize("mode", ("RGB", "L")) @pytest.mark.parametrize("bbox", BBOX) -def test_ellipse(mode, bbox) -> None: +def test_ellipse( + mode: str, + bbox: tuple[tuple[int, int], tuple[int, int]] + | list[tuple[int, int]] + | list[int] + | tuple[int, int, int, int], +) -> None: # Arrange im = Image.new(mode, (W, H)) draw = ImageDraw.Draw(im) @@ -280,7 +344,12 @@ def test_ellipse(mode, bbox) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_ellipse_translucent(bbox) -> None: +def test_ellipse_translucent( + bbox: tuple[tuple[int, int], tuple[int, int]] + | list[tuple[int, int]] + | list[int] + | tuple[int, int, int, int] +) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im, "RGBA") @@ -317,7 +386,12 @@ def test_ellipse_symmetric() -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_ellipse_width(bbox) -> None: +def test_ellipse_width( + bbox: tuple[tuple[int, int], tuple[int, int]] + | list[tuple[int, int]] + | list[int] + | tuple[int, int, int, int] +) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -342,7 +416,12 @@ def test_ellipse_width_large() -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_ellipse_width_fill(bbox) -> None: +def test_ellipse_width_fill( + bbox: tuple[tuple[int, int], tuple[int, int]] + | list[tuple[int, int]] + | list[int] + | tuple[int, int, int, int] +) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -355,7 +434,12 @@ def test_ellipse_width_fill(bbox) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_ellipse_zero_width(bbox) -> None: +def test_ellipse_zero_width( + bbox: tuple[tuple[int, int], tuple[int, int]] + | list[tuple[int, int]] + | list[int] + | tuple[int, int, int, int] +) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -367,7 +451,7 @@ def test_ellipse_zero_width(bbox) -> None: assert_image_equal_tofile(im, "Tests/images/imagedraw_ellipse_zero_width.png") -def ellipse_various_sizes_helper(filled): +def ellipse_various_sizes_helper(filled: bool) -> Image.Image: ellipse_sizes = range(32) image_size = sum(ellipse_sizes) + len(ellipse_sizes) + 1 im = Image.new("RGB", (image_size, image_size)) @@ -409,7 +493,12 @@ def test_ellipse_various_sizes_filled() -> None: @pytest.mark.parametrize("points", POINTS) -def test_line(points) -> None: +def test_line( + points: tuple[tuple[int, int], ...] + | list[tuple[int, int]] + | tuple[int, ...] + | list[int] +) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -482,7 +571,14 @@ def test_transform() -> None: @pytest.mark.parametrize("bbox", BBOX) @pytest.mark.parametrize("start, end", ((-92, 46), (-92.2, 46.2))) -def test_pieslice(bbox, start, end) -> None: +def test_pieslice( + bbox: tuple[tuple[int, int], tuple[int, int]] + | list[tuple[int, int]] + | list[int] + | tuple[int, int, int, int], + start: float, + end: float, +) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -495,7 +591,12 @@ def test_pieslice(bbox, start, end) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_pieslice_width(bbox) -> None: +def test_pieslice_width( + bbox: tuple[tuple[int, int], tuple[int, int]] + | list[tuple[int, int]] + | list[int] + | tuple[int, int, int, int] +) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -508,7 +609,12 @@ def test_pieslice_width(bbox) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_pieslice_width_fill(bbox) -> None: +def test_pieslice_width_fill( + bbox: tuple[tuple[int, int], tuple[int, int]] + | list[tuple[int, int]] + | list[int] + | tuple[int, int, int, int] +) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -522,7 +628,12 @@ def test_pieslice_width_fill(bbox) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_pieslice_zero_width(bbox) -> None: +def test_pieslice_zero_width( + bbox: tuple[tuple[int, int], tuple[int, int]] + | list[tuple[int, int]] + | list[int] + | tuple[int, int, int, int] +) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -577,7 +688,12 @@ def test_pieslice_no_spikes() -> None: @pytest.mark.parametrize("points", POINTS) -def test_point(points) -> None: +def test_point( + points: tuple[tuple[int, int], ...] + | list[tuple[int, int]] + | tuple[int, ...] + | list[int] +) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -602,7 +718,12 @@ def test_point_I16() -> None: @pytest.mark.parametrize("points", POINTS) -def test_polygon(points) -> None: +def test_polygon( + points: tuple[tuple[int, int], ...] + | list[tuple[int, int]] + | tuple[int, ...] + | list[int] +) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -616,7 +737,9 @@ def test_polygon(points) -> None: @pytest.mark.parametrize("mode", ("RGB", "L")) @pytest.mark.parametrize("kite_points", KITE_POINTS) -def test_polygon_kite(mode, kite_points) -> None: +def test_polygon_kite( + mode: str, kite_points: tuple[tuple[int, int], ...] | list[tuple[int, int]] +) -> None: # Test drawing lines of different gradients (dx>dy, dy>dx) and # vertical (dx==0) and horizontal (dy==0) lines # Arrange @@ -673,7 +796,12 @@ def test_polygon_translucent() -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_rectangle(bbox) -> None: +def test_rectangle( + bbox: tuple[tuple[int, int], tuple[int, int]] + | list[tuple[int, int]] + | list[int] + | tuple[int, int, int, int] +) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -700,7 +828,12 @@ def test_big_rectangle() -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_rectangle_width(bbox) -> None: +def test_rectangle_width( + bbox: tuple[tuple[int, int], tuple[int, int]] + | list[tuple[int, int]] + | list[int] + | tuple[int, int, int, int] +) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -714,7 +847,12 @@ def test_rectangle_width(bbox) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_rectangle_width_fill(bbox) -> None: +def test_rectangle_width_fill( + bbox: tuple[tuple[int, int], tuple[int, int]] + | list[tuple[int, int]] + | list[int] + | tuple[int, int, int, int] +) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -728,7 +866,12 @@ def test_rectangle_width_fill(bbox) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_rectangle_zero_width(bbox) -> None: +def test_rectangle_zero_width( + bbox: tuple[tuple[int, int], tuple[int, int]] + | list[tuple[int, int]] + | list[int] + | tuple[int, int, int, int] +) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -741,7 +884,12 @@ def test_rectangle_zero_width(bbox) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_rectangle_I16(bbox) -> None: +def test_rectangle_I16( + bbox: tuple[tuple[int, int], tuple[int, int]] + | list[tuple[int, int]] + | list[int] + | tuple[int, int, int, int] +) -> None: # Arrange im = Image.new("I;16", (W, H)) draw = ImageDraw.Draw(im) @@ -754,7 +902,12 @@ def test_rectangle_I16(bbox) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_rectangle_translucent_outline(bbox) -> None: +def test_rectangle_translucent_outline( + bbox: tuple[tuple[int, int], tuple[int, int]] + | list[tuple[int, int]] + | list[int] + | tuple[int, int, int, int] +) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im, "RGBA") @@ -772,7 +925,11 @@ def test_rectangle_translucent_outline(bbox) -> None: "xy", [(10, 20, 190, 180), ([10, 20], [190, 180]), ((10, 20), (190, 180))], ) -def test_rounded_rectangle(xy) -> None: +def test_rounded_rectangle( + xy: tuple[int, int, int, int] + | tuple[list[int]] + | tuple[tuple[int, int], tuple[int, int]] +) -> None: # Arrange im = Image.new("RGB", (200, 200)) draw = ImageDraw.Draw(im) @@ -789,7 +946,7 @@ def test_rounded_rectangle(xy) -> None: @pytest.mark.parametrize("bottom_right", (True, False)) @pytest.mark.parametrize("bottom_left", (True, False)) def test_rounded_rectangle_corners( - top_left, top_right, bottom_right, bottom_left + top_left: bool, top_right: bool, bottom_right: bool, bottom_left: bool ) -> None: corners = (top_left, top_right, bottom_right, bottom_left) @@ -824,7 +981,9 @@ def test_rounded_rectangle_corners( ((10, 20, 190, 181), 85, "height"), ], ) -def test_rounded_rectangle_non_integer_radius(xy, radius, type) -> None: +def test_rounded_rectangle_non_integer_radius( + xy: tuple[int, int, int, int], radius: float, type: str +) -> None: # Arrange im = Image.new("RGB", (200, 200)) draw = ImageDraw.Draw(im) @@ -840,7 +999,12 @@ def test_rounded_rectangle_non_integer_radius(xy, radius, type) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_rounded_rectangle_zero_radius(bbox) -> None: +def test_rounded_rectangle_zero_radius( + bbox: tuple[tuple[int, int], tuple[int, int]] + | list[tuple[int, int]] + | list[int] + | tuple[int, int, int, int] +) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -862,7 +1026,9 @@ def test_rounded_rectangle_zero_radius(bbox) -> None: ((20, 20, 80, 80), "both"), ], ) -def test_rounded_rectangle_translucent(xy, suffix) -> None: +def test_rounded_rectangle_translucent( + xy: tuple[int, int, int, int], suffix: str +) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im, "RGBA") @@ -879,7 +1045,12 @@ def test_rounded_rectangle_translucent(xy, suffix) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_floodfill(bbox) -> None: +def test_floodfill( + bbox: tuple[tuple[int, int], tuple[int, int]] + | list[tuple[int, int]] + | list[int] + | tuple[int, int, int, int] +) -> None: red = ImageColor.getrgb("red") for mode, value in [("L", 1), ("RGBA", (255, 0, 0, 0)), ("RGB", red)]: @@ -912,7 +1083,12 @@ def test_floodfill(bbox) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_floodfill_border(bbox) -> None: +def test_floodfill_border( + bbox: tuple[tuple[int, int], tuple[int, int]] + | list[tuple[int, int]] + | list[int] + | tuple[int, int, int, int] +) -> None: # floodfill() is experimental # Arrange @@ -934,7 +1110,12 @@ def test_floodfill_border(bbox) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_floodfill_thresh(bbox) -> None: +def test_floodfill_thresh( + bbox: tuple[tuple[int, int], tuple[int, int]] + | list[tuple[int, int]] + | list[int] + | tuple[int, int, int, int] +) -> None: # floodfill() is experimental # Arrange @@ -968,8 +1149,11 @@ def test_floodfill_not_negative() -> None: def create_base_image_draw( - size, mode=DEFAULT_MODE, background1=WHITE, background2=GRAY -): + size: tuple[int, int], + mode: str = DEFAULT_MODE, + background1: tuple[int, int, int] = WHITE, + background2: tuple[int, int, int] = GRAY, +) -> tuple[Image.Image, ImageDraw.ImageDraw]: img = Image.new(mode, size, background1) for x in range(0, size[0]): for y in range(0, size[1]): @@ -1003,7 +1187,7 @@ def test_triangle_right() -> None: "fill, suffix", ((BLACK, "width"), (None, "width_no_fill")), ) -def test_triangle_right_width(fill, suffix) -> None: +def test_triangle_right_width(fill: tuple[int, int, int] | None, suffix: str) -> None: img, draw = create_base_image_draw((100, 100)) draw.polygon([(15, 25), (85, 25), (50, 60)], fill, WHITE, width=5) assert_image_equal_tofile( @@ -1235,7 +1419,7 @@ def test_wide_line_larger_than_int() -> None: ], ], ) -def test_line_joint(xy) -> None: +def test_line_joint(xy: list[tuple[int, int]] | tuple[int, ...] | list[int]) -> None: im = Image.new("RGB", (500, 325)) draw = ImageDraw.Draw(im) @@ -1388,7 +1572,12 @@ def test_default_font_size() -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_same_color_outline(bbox) -> None: +def test_same_color_outline( + bbox: tuple[tuple[int, int], tuple[int, int]] + | list[tuple[int, int]] + | list[int] + | tuple[int, int, int, int] +) -> None: # Prepare shape x0, y0 = 5, 5 x1, y1 = 5, 50 @@ -1402,7 +1591,8 @@ def test_same_color_outline(bbox) -> None: # Begin for mode in ["RGB", "L"]: - for fill, outline in [["red", None], ["red", "red"], ["red", "#f00"]]: + fill = "red" + for outline in [None, "red", "#f00"]: for operation, args in { "chord": [bbox, 0, 180], "ellipse": [bbox], @@ -1417,6 +1607,7 @@ def test_same_color_outline(bbox) -> None: # Act draw_method = getattr(draw, operation) + assert isinstance(args, list) args += [fill, outline] draw_method(*args) @@ -1434,7 +1625,9 @@ def test_same_color_outline(bbox) -> None: (3, "triangle_width", {"width": 5, "outline": "yellow"}), ], ) -def test_draw_regular_polygon(n_sides, polygon_name, args) -> None: +def test_draw_regular_polygon( + n_sides: int, polygon_name: str, args: dict[str, int | str] +) -> None: im = Image.new("RGBA", size=(W, H), color=(255, 0, 0, 0)) filename = f"Tests/images/imagedraw_{polygon_name}.png" draw = ImageDraw.Draw(im) @@ -1471,7 +1664,9 @@ def test_draw_regular_polygon(n_sides, polygon_name, args) -> None: ), ], ) -def test_compute_regular_polygon_vertices(n_sides, expected_vertices) -> None: +def test_compute_regular_polygon_vertices( + n_sides: int, expected_vertices: list[tuple[float, float]] +) -> None: bounding_circle = (W // 2, H // 2, 25) vertices = ImageDraw._compute_regular_polygon_vertices(bounding_circle, n_sides, 0) assert vertices == expected_vertices @@ -1569,7 +1764,7 @@ def test_polygon2() -> None: @pytest.mark.parametrize("xy", ((1, 1, 0, 1), (1, 1, 1, 0))) -def test_incorrectly_ordered_coordinates(xy) -> None: +def test_incorrectly_ordered_coordinates(xy: tuple[int, int, int, int]) -> None: im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) with pytest.raises(ValueError): diff --git a/Tests/test_imagetk.py b/Tests/test_imagetk.py index a216bd21d..b607b8c43 100644 --- a/Tests/test_imagetk.py +++ b/Tests/test_imagetk.py @@ -57,7 +57,7 @@ def test_kw() -> None: @pytest.mark.parametrize("mode", TK_MODES) -def test_photoimage(mode) -> None: +def test_photoimage(mode: str) -> None: # test as image: im = hopper(mode) @@ -79,7 +79,7 @@ def test_photoimage_apply_transparency() -> None: @pytest.mark.parametrize("mode", TK_MODES) -def test_photoimage_blank(mode) -> None: +def test_photoimage_blank(mode: str) -> None: # test a image using mode/size: im_tk = ImageTk.PhotoImage(mode, (100, 100)) diff --git a/Tests/test_lib_pack.py b/Tests/test_lib_pack.py index c8d6d33d2..629a6dc7a 100644 --- a/Tests/test_lib_pack.py +++ b/Tests/test_lib_pack.py @@ -10,7 +10,13 @@ X = 255 class TestLibPack: - def assert_pack(self, mode, rawmode, data, *pixels) -> None: + def assert_pack( + self, + mode: str, + rawmode: str, + data: int | bytes, + *pixels: int | float | tuple[int, ...], + ) -> None: """ data - either raw bytes with data or just number of bytes in rawmode. """ @@ -228,7 +234,13 @@ class TestLibPack: class TestLibUnpack: - def assert_unpack(self, mode, rawmode, data, *pixels) -> None: + def assert_unpack( + self, + mode: str, + rawmode: str, + data: int | bytes, + *pixels: int | float | tuple[int, ...], + ) -> None: """ data - either raw bytes with data or just number of bytes in rawmode. """ diff --git a/Tests/test_mode_i16.py b/Tests/test_mode_i16.py index f2540bb46..903f7e0c6 100644 --- a/Tests/test_mode_i16.py +++ b/Tests/test_mode_i16.py @@ -11,7 +11,7 @@ from .helper import hopper original = hopper().resize((32, 32)).convert("I") -def verify(im1) -> None: +def verify(im1: Image.Image) -> None: im2 = original.copy() assert im1.size == im2.size pix1 = im1.load() @@ -27,7 +27,7 @@ def verify(im1) -> None: @pytest.mark.parametrize("mode", ("L", "I;16", "I;16B", "I;16L", "I")) -def test_basic(tmp_path: Path, mode) -> None: +def test_basic(tmp_path: Path, mode: str) -> None: # PIL 1.1 has limited support for 16-bit image data. Check that # create/copy/transform and save works as expected. @@ -78,7 +78,7 @@ def test_basic(tmp_path: Path, mode) -> None: def test_tobytes() -> None: - def tobytes(mode): + def tobytes(mode: str) -> Image.Image: return Image.new(mode, (1, 1), 1).tobytes() order = 1 if Image._ENDIAN == "<" else -1 From b8769d1cf5782f3db934b861ad764dc9b1466fb4 Mon Sep 17 00:00:00 2001 From: FangFuxin <38530078+lajiyuan@users.noreply.github.com> Date: Wed, 31 Jan 2024 21:02:50 +0800 Subject: [PATCH 039/361] Update Tests/test_file_png.py Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- Tests/test_file_png.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 0884ddcc3..ec8794b30 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -778,7 +778,7 @@ class TestFilePng: with Image.open(mystdout) as reloaded: assert_image_equal_tofile(reloaded, TEST_PNG_FILE) - def test_truncated_end_chunk(self): + def test_truncated_end_chunk(self) -> None: with Image.open("Tests/images/truncated_end_chunk.png") as im: with pytest.raises(OSError): im.load() From f2228e0a7c19d74c83b99f92edc113ba0cac7625 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Wed, 31 Jan 2024 12:37:53 -0700 Subject: [PATCH 040/361] Replace bytes | str | Path with StrOrBytesPath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ondrej Baranovič --- src/PIL/_util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/_util.py b/src/PIL/_util.py index b649500ab..f7a69fae1 100644 --- a/src/PIL/_util.py +++ b/src/PIL/_util.py @@ -11,7 +11,7 @@ def is_path(f: Any) -> TypeGuard[StrOrBytesPath]: return isinstance(f, (bytes, str, os.PathLike)) -def is_directory(f: Any) -> TypeGuard[bytes | str | Path]: +def is_directory(f: Any) -> TypeGuard[StrOrBytesPath]: """Checks if an object is a string, and that it points to a directory.""" return is_path(f) and os.path.isdir(f) From 256f3f1966d6b56f178d0a9bb2bc4b0b334c77b1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 31 Jan 2024 19:38:49 +0000 Subject: [PATCH 041/361] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/PIL/_util.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/PIL/_util.py b/src/PIL/_util.py index f7a69fae1..6bc762816 100644 --- a/src/PIL/_util.py +++ b/src/PIL/_util.py @@ -1,7 +1,6 @@ from __future__ import annotations import os -from pathlib import Path from typing import Any, NoReturn from ._typing import StrOrBytesPath, TypeGuard From 6dba9c988765084c104fd93c9fcc9ba3d18f6873 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 31 Jan 2024 21:14:01 +0000 Subject: [PATCH 042/361] Update github-actions to v4 --- .github/workflows/test-cygwin.yml | 4 ++-- .github/workflows/test-docker.yml | 2 +- .github/workflows/test-mingw.yml | 2 +- .github/workflows/test-windows.yml | 2 +- .github/workflows/test.yml | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test-cygwin.yml b/.github/workflows/test-cygwin.yml index b5c8c39aa..7bbe5a37f 100644 --- a/.github/workflows/test-cygwin.yml +++ b/.github/workflows/test-cygwin.yml @@ -81,7 +81,7 @@ jobs: zlib-devel - name: Add Lapack to PATH - uses: egor-tensin/cleanup-path@v3 + uses: egor-tensin/cleanup-path@v4 with: dirs: 'C:\cygwin\bin;C:\cygwin\lib\lapack' @@ -142,7 +142,7 @@ jobs: bash.exe .ci/after_success.sh - name: Upload coverage - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 with: file: ./coverage.xml flags: GHA_Cygwin diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index 3bb6856f6..75aab9bd4 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -101,7 +101,7 @@ jobs: MATRIX_DOCKER: ${{ matrix.docker }} - name: Upload coverage - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 with: flags: GHA_Docker name: ${{ matrix.docker }} diff --git a/.github/workflows/test-mingw.yml b/.github/workflows/test-mingw.yml index cdd51e2bb..acea78c37 100644 --- a/.github/workflows/test-mingw.yml +++ b/.github/workflows/test-mingw.yml @@ -82,7 +82,7 @@ jobs: python3 -m pytest -vx --cov PIL --cov Tests --cov-report term --cov-report xml Tests - name: Upload coverage - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 with: file: ./coverage.xml flags: GHA_Windows diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 8cad7a8b2..b737615ca 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -202,7 +202,7 @@ jobs: shell: pwsh - name: Upload coverage - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 with: file: ./coverage.xml flags: GHA_Windows diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ae84a4d8f..038bcfbc3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -149,7 +149,7 @@ jobs: .ci/after_success.sh - name: Upload coverage - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 with: flags: ${{ matrix.os == 'ubuntu-latest' && 'GHA_Ubuntu' || 'GHA_macOS' }} name: ${{ matrix.os }} Python ${{ matrix.python-version }} From 2515938cdd321a5940a070f808c01ed48ad4e10e Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 2 Feb 2024 19:04:22 +1100 Subject: [PATCH 043/361] Simplified type hints --- Tests/test_imagedraw.py | 222 ++++++---------------------------------- 1 file changed, 32 insertions(+), 190 deletions(-) diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index c02ac49dd..6e7dce420 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -2,6 +2,7 @@ from __future__ import annotations import contextlib import os.path +from typing import Sequence import pytest @@ -74,14 +75,7 @@ def test_mode_mismatch() -> None: @pytest.mark.parametrize("bbox", BBOX) @pytest.mark.parametrize("start, end", ((0, 180), (0.5, 180.4))) -def test_arc( - bbox: tuple[tuple[int, int], tuple[int, int]] - | list[tuple[int, int]] - | list[int] - | tuple[int, int, int, int], - start: float, - end: float, -) -> None: +def test_arc(bbox: Sequence[int | Sequence[int]], start: float, end: float) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -94,12 +88,7 @@ def test_arc( @pytest.mark.parametrize("bbox", BBOX) -def test_arc_end_le_start( - bbox: tuple[tuple[int, int], tuple[int, int]] - | list[tuple[int, int]] - | list[int] - | tuple[int, int, int, int] -) -> None: +def test_arc_end_le_start(bbox: Sequence[int | Sequence[int]]) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -114,12 +103,7 @@ def test_arc_end_le_start( @pytest.mark.parametrize("bbox", BBOX) -def test_arc_no_loops( - bbox: tuple[tuple[int, int], tuple[int, int]] - | list[tuple[int, int]] - | list[int] - | tuple[int, int, int, int] -) -> None: +def test_arc_no_loops(bbox: Sequence[int | Sequence[int]]) -> None: # No need to go in loops # Arrange im = Image.new("RGB", (W, H)) @@ -135,12 +119,7 @@ def test_arc_no_loops( @pytest.mark.parametrize("bbox", BBOX) -def test_arc_width( - bbox: tuple[tuple[int, int], tuple[int, int]] - | list[tuple[int, int]] - | list[int] - | tuple[int, int, int, int] -) -> None: +def test_arc_width(bbox: Sequence[int | Sequence[int]]) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -153,12 +132,7 @@ def test_arc_width( @pytest.mark.parametrize("bbox", BBOX) -def test_arc_width_pieslice_large( - bbox: tuple[tuple[int, int], tuple[int, int]] - | list[tuple[int, int]] - | list[int] - | tuple[int, int, int, int] -) -> None: +def test_arc_width_pieslice_large(bbox: Sequence[int | Sequence[int]]) -> None: # Tests an arc with a large enough width that it is a pieslice # Arrange im = Image.new("RGB", (W, H)) @@ -172,12 +146,7 @@ def test_arc_width_pieslice_large( @pytest.mark.parametrize("bbox", BBOX) -def test_arc_width_fill( - bbox: tuple[tuple[int, int], tuple[int, int]] - | list[tuple[int, int]] - | list[int] - | tuple[int, int, int, int] -) -> None: +def test_arc_width_fill(bbox: Sequence[int | Sequence[int]]) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -190,12 +159,7 @@ def test_arc_width_fill( @pytest.mark.parametrize("bbox", BBOX) -def test_arc_width_non_whole_angle( - bbox: tuple[tuple[int, int], tuple[int, int]] - | list[tuple[int, int]] - | list[int] - | tuple[int, int, int, int] -) -> None: +def test_arc_width_non_whole_angle(bbox: Sequence[int | Sequence[int]]) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -237,13 +201,7 @@ def test_bitmap() -> None: @pytest.mark.parametrize("mode", ("RGB", "L")) @pytest.mark.parametrize("bbox", BBOX) -def test_chord( - mode: str, - bbox: tuple[tuple[int, int], tuple[int, int]] - | list[tuple[int, int]] - | list[int] - | tuple[int, int, int, int], -) -> None: +def test_chord(mode: str, bbox: Sequence[int | Sequence[int]]) -> None: # Arrange im = Image.new(mode, (W, H)) draw = ImageDraw.Draw(im) @@ -257,12 +215,7 @@ def test_chord( @pytest.mark.parametrize("bbox", BBOX) -def test_chord_width( - bbox: tuple[tuple[int, int], tuple[int, int]] - | list[tuple[int, int]] - | list[int] - | tuple[int, int, int, int] -) -> None: +def test_chord_width(bbox: Sequence[int | Sequence[int]]) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -275,12 +228,7 @@ def test_chord_width( @pytest.mark.parametrize("bbox", BBOX) -def test_chord_width_fill( - bbox: tuple[tuple[int, int], tuple[int, int]] - | list[tuple[int, int]] - | list[int] - | tuple[int, int, int, int] -) -> None: +def test_chord_width_fill(bbox: Sequence[int | Sequence[int]]) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -293,12 +241,7 @@ def test_chord_width_fill( @pytest.mark.parametrize("bbox", BBOX) -def test_chord_zero_width( - bbox: tuple[tuple[int, int], tuple[int, int]] - | list[tuple[int, int]] - | list[int] - | tuple[int, int, int, int] -) -> None: +def test_chord_zero_width(bbox: Sequence[int | Sequence[int]]) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -324,13 +267,7 @@ def test_chord_too_fat() -> None: @pytest.mark.parametrize("mode", ("RGB", "L")) @pytest.mark.parametrize("bbox", BBOX) -def test_ellipse( - mode: str, - bbox: tuple[tuple[int, int], tuple[int, int]] - | list[tuple[int, int]] - | list[int] - | tuple[int, int, int, int], -) -> None: +def test_ellipse(mode: str, bbox: Sequence[int | Sequence[int]]) -> None: # Arrange im = Image.new(mode, (W, H)) draw = ImageDraw.Draw(im) @@ -344,12 +281,7 @@ def test_ellipse( @pytest.mark.parametrize("bbox", BBOX) -def test_ellipse_translucent( - bbox: tuple[tuple[int, int], tuple[int, int]] - | list[tuple[int, int]] - | list[int] - | tuple[int, int, int, int] -) -> None: +def test_ellipse_translucent(bbox: Sequence[int | Sequence[int]]) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im, "RGBA") @@ -386,12 +318,7 @@ def test_ellipse_symmetric() -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_ellipse_width( - bbox: tuple[tuple[int, int], tuple[int, int]] - | list[tuple[int, int]] - | list[int] - | tuple[int, int, int, int] -) -> None: +def test_ellipse_width(bbox: Sequence[int | Sequence[int]]) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -416,12 +343,7 @@ def test_ellipse_width_large() -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_ellipse_width_fill( - bbox: tuple[tuple[int, int], tuple[int, int]] - | list[tuple[int, int]] - | list[int] - | tuple[int, int, int, int] -) -> None: +def test_ellipse_width_fill(bbox: Sequence[int | Sequence[int]]) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -434,12 +356,7 @@ def test_ellipse_width_fill( @pytest.mark.parametrize("bbox", BBOX) -def test_ellipse_zero_width( - bbox: tuple[tuple[int, int], tuple[int, int]] - | list[tuple[int, int]] - | list[int] - | tuple[int, int, int, int] -) -> None: +def test_ellipse_zero_width(bbox: Sequence[int | Sequence[int]]) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -572,12 +489,7 @@ def test_transform() -> None: @pytest.mark.parametrize("bbox", BBOX) @pytest.mark.parametrize("start, end", ((-92, 46), (-92.2, 46.2))) def test_pieslice( - bbox: tuple[tuple[int, int], tuple[int, int]] - | list[tuple[int, int]] - | list[int] - | tuple[int, int, int, int], - start: float, - end: float, + bbox: Sequence[int | Sequence[int]], start: float, end: float ) -> None: # Arrange im = Image.new("RGB", (W, H)) @@ -591,12 +503,7 @@ def test_pieslice( @pytest.mark.parametrize("bbox", BBOX) -def test_pieslice_width( - bbox: tuple[tuple[int, int], tuple[int, int]] - | list[tuple[int, int]] - | list[int] - | tuple[int, int, int, int] -) -> None: +def test_pieslice_width(bbox: Sequence[int | Sequence[int]]) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -609,12 +516,7 @@ def test_pieslice_width( @pytest.mark.parametrize("bbox", BBOX) -def test_pieslice_width_fill( - bbox: tuple[tuple[int, int], tuple[int, int]] - | list[tuple[int, int]] - | list[int] - | tuple[int, int, int, int] -) -> None: +def test_pieslice_width_fill(bbox: Sequence[int | Sequence[int]]) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -628,12 +530,7 @@ def test_pieslice_width_fill( @pytest.mark.parametrize("bbox", BBOX) -def test_pieslice_zero_width( - bbox: tuple[tuple[int, int], tuple[int, int]] - | list[tuple[int, int]] - | list[int] - | tuple[int, int, int, int] -) -> None: +def test_pieslice_zero_width(bbox: Sequence[int | Sequence[int]]) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -796,12 +693,7 @@ def test_polygon_translucent() -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_rectangle( - bbox: tuple[tuple[int, int], tuple[int, int]] - | list[tuple[int, int]] - | list[int] - | tuple[int, int, int, int] -) -> None: +def test_rectangle(bbox: Sequence[int | Sequence[int]]) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -828,12 +720,7 @@ def test_big_rectangle() -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_rectangle_width( - bbox: tuple[tuple[int, int], tuple[int, int]] - | list[tuple[int, int]] - | list[int] - | tuple[int, int, int, int] -) -> None: +def test_rectangle_width(bbox: Sequence[int | Sequence[int]]) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -847,12 +734,7 @@ def test_rectangle_width( @pytest.mark.parametrize("bbox", BBOX) -def test_rectangle_width_fill( - bbox: tuple[tuple[int, int], tuple[int, int]] - | list[tuple[int, int]] - | list[int] - | tuple[int, int, int, int] -) -> None: +def test_rectangle_width_fill(bbox: Sequence[int | Sequence[int]]) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -866,12 +748,7 @@ def test_rectangle_width_fill( @pytest.mark.parametrize("bbox", BBOX) -def test_rectangle_zero_width( - bbox: tuple[tuple[int, int], tuple[int, int]] - | list[tuple[int, int]] - | list[int] - | tuple[int, int, int, int] -) -> None: +def test_rectangle_zero_width(bbox: Sequence[int | Sequence[int]]) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -884,12 +761,7 @@ def test_rectangle_zero_width( @pytest.mark.parametrize("bbox", BBOX) -def test_rectangle_I16( - bbox: tuple[tuple[int, int], tuple[int, int]] - | list[tuple[int, int]] - | list[int] - | tuple[int, int, int, int] -) -> None: +def test_rectangle_I16(bbox: Sequence[int | Sequence[int]]) -> None: # Arrange im = Image.new("I;16", (W, H)) draw = ImageDraw.Draw(im) @@ -902,12 +774,7 @@ def test_rectangle_I16( @pytest.mark.parametrize("bbox", BBOX) -def test_rectangle_translucent_outline( - bbox: tuple[tuple[int, int], tuple[int, int]] - | list[tuple[int, int]] - | list[int] - | tuple[int, int, int, int] -) -> None: +def test_rectangle_translucent_outline(bbox: Sequence[int | Sequence[int]]) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im, "RGBA") @@ -999,12 +866,7 @@ def test_rounded_rectangle_non_integer_radius( @pytest.mark.parametrize("bbox", BBOX) -def test_rounded_rectangle_zero_radius( - bbox: tuple[tuple[int, int], tuple[int, int]] - | list[tuple[int, int]] - | list[int] - | tuple[int, int, int, int] -) -> None: +def test_rounded_rectangle_zero_radius(bbox: Sequence[int | Sequence[int]]) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -1045,12 +907,7 @@ def test_rounded_rectangle_translucent( @pytest.mark.parametrize("bbox", BBOX) -def test_floodfill( - bbox: tuple[tuple[int, int], tuple[int, int]] - | list[tuple[int, int]] - | list[int] - | tuple[int, int, int, int] -) -> None: +def test_floodfill(bbox: Sequence[int | Sequence[int]]) -> None: red = ImageColor.getrgb("red") for mode, value in [("L", 1), ("RGBA", (255, 0, 0, 0)), ("RGB", red)]: @@ -1083,12 +940,7 @@ def test_floodfill( @pytest.mark.parametrize("bbox", BBOX) -def test_floodfill_border( - bbox: tuple[tuple[int, int], tuple[int, int]] - | list[tuple[int, int]] - | list[int] - | tuple[int, int, int, int] -) -> None: +def test_floodfill_border(bbox: Sequence[int | Sequence[int]]) -> None: # floodfill() is experimental # Arrange @@ -1110,12 +962,7 @@ def test_floodfill_border( @pytest.mark.parametrize("bbox", BBOX) -def test_floodfill_thresh( - bbox: tuple[tuple[int, int], tuple[int, int]] - | list[tuple[int, int]] - | list[int] - | tuple[int, int, int, int] -) -> None: +def test_floodfill_thresh(bbox: Sequence[int | Sequence[int]]) -> None: # floodfill() is experimental # Arrange @@ -1572,12 +1419,7 @@ def test_default_font_size() -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_same_color_outline( - bbox: tuple[tuple[int, int], tuple[int, int]] - | list[tuple[int, int]] - | list[int] - | tuple[int, int, int, int] -) -> None: +def test_same_color_outline(bbox: Sequence[int | Sequence[int]]) -> None: # Prepare shape x0, y0 = 5, 5 x1, y1 = 5, 50 From 8d96e3bc590ec9c003efc47ad35295d7de4ed95c Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 2 Feb 2024 23:54:31 +1100 Subject: [PATCH 044/361] Changed name of first _Tile parameter --- src/PIL/ImageFile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index 5ba5a6f82..487f53efe 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -91,7 +91,7 @@ def _tilesort(t): class _Tile(NamedTuple): - encoder_name: str + codec_name: str extents: tuple[int, int, int, int] offset: int args: tuple[Any, ...] | str | None From 6207ad419640475440de2f57c710e1a6235dfe90 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 3 Feb 2024 02:24:17 +0000 Subject: [PATCH 045/361] Update release-drafter/release-drafter action to v6 --- .github/workflows/release-drafter.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 8fc7bd379..a8ddef22c 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -23,6 +23,6 @@ jobs: runs-on: ubuntu-latest steps: # Drafts your next release notes as pull requests are merged into "main" - - uses: release-drafter/release-drafter@v5 + - uses: release-drafter/release-drafter@v6 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From dba7dea3263dfa3252f7381307323477531646c8 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sat, 3 Feb 2024 14:43:04 +0200 Subject: [PATCH 046/361] Pin codecov/codecov-action to v3.1.5 --- .github/workflows/test-cygwin.yml | 2 +- .github/workflows/test-docker.yml | 2 +- .github/workflows/test-mingw.yml | 2 +- .github/workflows/test-windows.yml | 2 +- .github/workflows/test.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test-cygwin.yml b/.github/workflows/test-cygwin.yml index 7bbe5a37f..a6b2935a9 100644 --- a/.github/workflows/test-cygwin.yml +++ b/.github/workflows/test-cygwin.yml @@ -142,7 +142,7 @@ jobs: bash.exe .ci/after_success.sh - name: Upload coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v3.1.5 with: file: ./coverage.xml flags: GHA_Cygwin diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index 75aab9bd4..f40286fe4 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -101,7 +101,7 @@ jobs: MATRIX_DOCKER: ${{ matrix.docker }} - name: Upload coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v3.1.5 with: flags: GHA_Docker name: ${{ matrix.docker }} diff --git a/.github/workflows/test-mingw.yml b/.github/workflows/test-mingw.yml index acea78c37..1c6d15b77 100644 --- a/.github/workflows/test-mingw.yml +++ b/.github/workflows/test-mingw.yml @@ -82,7 +82,7 @@ jobs: python3 -m pytest -vx --cov PIL --cov Tests --cov-report term --cov-report xml Tests - name: Upload coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v3.1.5 with: file: ./coverage.xml flags: GHA_Windows diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index b737615ca..75fccf795 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -202,7 +202,7 @@ jobs: shell: pwsh - name: Upload coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v3.1.5 with: file: ./coverage.xml flags: GHA_Windows diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 038bcfbc3..19f4a6dae 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -149,7 +149,7 @@ jobs: .ci/after_success.sh - name: Upload coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v3.1.5 with: flags: ${{ matrix.os == 'ubuntu-latest' && 'GHA_Ubuntu' || 'GHA_macOS' }} name: ${{ matrix.os }} Python ${{ matrix.python-version }} From 435c884ebbee326daf55599c6248028684206cc4 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 3 Feb 2024 23:44:33 +1100 Subject: [PATCH 047/361] Removed platform argument from setup-cygwin action --- .github/workflows/test-cygwin.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/test-cygwin.yml b/.github/workflows/test-cygwin.yml index 7bbe5a37f..4b958e889 100644 --- a/.github/workflows/test-cygwin.yml +++ b/.github/workflows/test-cygwin.yml @@ -49,7 +49,6 @@ jobs: - name: Install Cygwin uses: egor-tensin/setup-cygwin@v4 with: - platform: x86_64 packages: > gcc-g++ ghostscript From 1b6723967440cf8474a9bd1e1c394c90c5c2f986 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 5 Feb 2024 11:56:55 +1100 Subject: [PATCH 048/361] Update CHANGES.rst [ci skip] --- CHANGES.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 7d80eec03..a8404260f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,12 @@ Changelog (Pillow) 10.3.0 (unreleased) ------------------- +- Update wl-paste handling and return None for some errors in grabclipboard() on Linux #7745 + [nik012003, radarhere] + +- Remove execute bit from ``setup.py`` #7760 + [hugovk] + - Do not support using test-image-results to upload images after test failures #7739 [radarhere] From dfb48ff297aa2b227a98f20ff0ae5a0009644ad3 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 5 Feb 2024 19:16:15 +1100 Subject: [PATCH 049/361] Match mask size to pasted image size --- Tests/test_file_gif.py | 15 +++++++++++++++ src/PIL/GifImagePlugin.py | 4 +--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index 3f550fd11..263c897ef 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -1105,6 +1105,21 @@ def test_append_images(tmp_path: Path) -> None: assert reread.n_frames == 10 +def test_append_different_size_image(tmp_path: Path) -> None: + out = str(tmp_path / "temp.gif") + + im = Image.new("RGB", (100, 100)) + bigger_im = Image.new("RGB", (200, 200), "#f00") + + im.save(out, save_all=True, append_images=[bigger_im]) + + with Image.open(out) as reread: + assert reread.size == (100, 100) + + reread.seek(1) + assert reread.size == (100, 100) + + def test_transparent_optimize(tmp_path: Path) -> None: # From issue #2195, if the transparent color is incorrectly optimized out, GIF loses # transparency. diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 57d87078b..935b95ca8 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -649,9 +649,7 @@ def _write_multiple_frames(im, fp, palette): if "transparency" in encoderinfo: # When the delta is zero, fill the image with transparency diff_frame = im_frame.copy() - fill = Image.new( - "P", diff_frame.size, encoderinfo["transparency"] - ) + fill = Image.new("P", delta.size, encoderinfo["transparency"]) if delta.mode == "RGBA": r, g, b, a = delta.split() mask = ImageMath.eval( From 5a8e7dda79e5ee4d0f8436179f61881a5d8bd286 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 5 Feb 2024 20:36:34 +1100 Subject: [PATCH 050/361] Added type hints --- Tests/test_imagedraw.py | 2 +- src/PIL/Image.py | 2 +- src/PIL/ImageDraw.py | 73 +++++++++++++++++++++-------------------- src/PIL/ImageFont.py | 2 +- 4 files changed, 40 insertions(+), 39 deletions(-) diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index 6e7dce420..4503a9292 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -1519,7 +1519,7 @@ def test_compute_regular_polygon_vertices( [ (None, (50, 50, 25), 0, TypeError, "n_sides should be an int"), (1, (50, 50, 25), 0, ValueError, "n_sides should be an int > 2"), - (3, 50, 0, TypeError, "bounding_circle should be a tuple"), + (3, 50, 0, TypeError, "bounding_circle should be a sequence"), ( 3, (50, 50, 100, 100), diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 553f36703..111d06012 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -571,7 +571,7 @@ class Image: # object is gone. self.im = DeferredError(ValueError("Operation on closed image")) - def _copy(self): + def _copy(self) -> None: self.load() self.im = self.im.copy() self.pyaccess = None diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 84665f54f..650e30857 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -48,7 +48,7 @@ directly. class ImageDraw: font = None - def __init__(self, im, mode=None): + def __init__(self, im: Image.Image, mode: str | None = None) -> None: """ Create a drawing instance. @@ -115,7 +115,7 @@ class ImageDraw: self.font = ImageFont.load_default() return self.font - def _getfont(self, font_size): + def _getfont(self, font_size: float | None): if font_size is not None: from . import ImageFont @@ -124,7 +124,7 @@ class ImageDraw: font = self.getfont() return font - def _getink(self, ink, fill=None): + def _getink(self, ink, fill=None) -> tuple[int | None, int | None]: if ink is None and fill is None: if self.fill: fill = self.ink @@ -145,13 +145,13 @@ class ImageDraw: fill = self.draw.draw_ink(fill) return ink, fill - def arc(self, xy, start, end, fill=None, width=1): + def arc(self, xy, start, end, fill=None, width=1) -> None: """Draw an arc.""" ink, fill = self._getink(fill) if ink is not None: self.draw.draw_arc(xy, start, end, ink, width) - def bitmap(self, xy, bitmap, fill=None): + def bitmap(self, xy, bitmap, fill=None) -> None: """Draw a bitmap.""" bitmap.load() ink, fill = self._getink(fill) @@ -160,7 +160,7 @@ class ImageDraw: if ink is not None: self.draw.draw_bitmap(xy, bitmap.im, ink) - def chord(self, xy, start, end, fill=None, outline=None, width=1): + def chord(self, xy, start, end, fill=None, outline=None, width=1) -> None: """Draw a chord.""" ink, fill = self._getink(outline, fill) if fill is not None: @@ -168,7 +168,7 @@ class ImageDraw: if ink is not None and ink != fill and width != 0: self.draw.draw_chord(xy, start, end, ink, 0, width) - def ellipse(self, xy, fill=None, outline=None, width=1): + def ellipse(self, xy, fill=None, outline=None, width=1) -> None: """Draw an ellipse.""" ink, fill = self._getink(outline, fill) if fill is not None: @@ -176,7 +176,7 @@ class ImageDraw: if ink is not None and ink != fill and width != 0: self.draw.draw_ellipse(xy, ink, 0, width) - def line(self, xy, fill=None, width=0, joint=None): + def line(self, xy, fill=None, width=0, joint=None) -> None: """Draw a line, or a connected sequence of line segments.""" ink = self._getink(fill)[0] if ink is not None: @@ -236,7 +236,7 @@ class ImageDraw: ] self.line(gap_coords, fill, width=3) - def shape(self, shape, fill=None, outline=None): + def shape(self, shape, fill=None, outline=None) -> None: """(Experimental) Draw a shape.""" shape.close() ink, fill = self._getink(outline, fill) @@ -245,7 +245,7 @@ class ImageDraw: if ink is not None and ink != fill: self.draw.draw_outline(shape, ink, 0) - def pieslice(self, xy, start, end, fill=None, outline=None, width=1): + def pieslice(self, xy, start, end, fill=None, outline=None, width=1) -> None: """Draw a pieslice.""" ink, fill = self._getink(outline, fill) if fill is not None: @@ -253,13 +253,13 @@ class ImageDraw: if ink is not None and ink != fill and width != 0: self.draw.draw_pieslice(xy, start, end, ink, 0, width) - def point(self, xy, fill=None): + def point(self, xy, fill=None) -> None: """Draw one or more individual pixels.""" ink, fill = self._getink(fill) if ink is not None: self.draw.draw_points(xy, ink) - def polygon(self, xy, fill=None, outline=None, width=1): + def polygon(self, xy, fill=None, outline=None, width=1) -> None: """Draw a polygon.""" ink, fill = self._getink(outline, fill) if fill is not None: @@ -267,7 +267,7 @@ class ImageDraw: if ink is not None and ink != fill and width != 0: if width == 1: self.draw.draw_polygon(xy, ink, 0, width) - else: + elif self.im is not None: # To avoid expanding the polygon outwards, # use the fill as a mask mask = Image.new("1", self.im.size) @@ -291,12 +291,12 @@ class ImageDraw: def regular_polygon( self, bounding_circle, n_sides, rotation=0, fill=None, outline=None, width=1 - ): + ) -> None: """Draw a regular polygon.""" xy = _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation) self.polygon(xy, fill, outline, width) - def rectangle(self, xy, fill=None, outline=None, width=1): + def rectangle(self, xy, fill=None, outline=None, width=1) -> None: """Draw a rectangle.""" ink, fill = self._getink(outline, fill) if fill is not None: @@ -306,7 +306,7 @@ class ImageDraw: def rounded_rectangle( self, xy, radius=0, fill=None, outline=None, width=1, *, corners=None - ): + ) -> None: """Draw a rounded rectangle.""" if isinstance(xy[0], (list, tuple)): (x0, y0), (x1, y1) = xy @@ -346,7 +346,7 @@ class ImageDraw: r = d // 2 ink, fill = self._getink(outline, fill) - def draw_corners(pieslice): + def draw_corners(pieslice) -> None: if full_x: # Draw top and bottom halves parts = ( @@ -431,12 +431,12 @@ class ImageDraw: right[3] -= r + 1 self.draw.draw_rectangle(right, ink, 1) - def _multiline_check(self, text): + def _multiline_check(self, text) -> bool: split_character = "\n" if isinstance(text, str) else b"\n" return split_character in text - def _multiline_split(self, text): + def _multiline_split(self, text) -> list[str | bytes]: split_character = "\n" if isinstance(text, str) else b"\n" return text.split(split_character) @@ -465,7 +465,7 @@ class ImageDraw: embedded_color=False, *args, **kwargs, - ): + ) -> None: """Draw text.""" if embedded_color and self.mode not in ("RGB", "RGBA"): msg = "Embedded color supported only in RGB and RGBA modes" @@ -497,7 +497,7 @@ class ImageDraw: return fill return ink - def draw_text(ink, stroke_width=0, stroke_offset=None): + def draw_text(ink, stroke_width=0, stroke_offset=None) -> None: mode = self.fontmode if stroke_width == 0 and embedded_color: mode = "RGBA" @@ -547,7 +547,8 @@ class ImageDraw: ink_alpha = struct.pack("i", ink)[3] color.fillband(3, ink_alpha) x, y = coord - self.im.paste(color, (x, y, x + mask.size[0], y + mask.size[1]), mask) + if self.im is not None: + self.im.paste(color, (x, y, x + mask.size[0], y + mask.size[1]), mask) else: self.draw.draw_bitmap(coord, mask, ink) @@ -584,7 +585,7 @@ class ImageDraw: embedded_color=False, *, font_size=None, - ): + ) -> None: if direction == "ttb": msg = "ttb direction is unsupported for multiline text" raise ValueError(msg) @@ -693,7 +694,7 @@ class ImageDraw: embedded_color=False, *, font_size=None, - ): + ) -> tuple[int, int, int, int]: """Get the bounding box of a given string, in pixels.""" if embedded_color and self.mode not in ("RGB", "RGBA"): msg = "Embedded color supported only in RGB and RGBA modes" @@ -738,7 +739,7 @@ class ImageDraw: embedded_color=False, *, font_size=None, - ): + ) -> tuple[int, int, int, int]: if direction == "ttb": msg = "ttb direction is unsupported for multiline text" raise ValueError(msg) @@ -777,7 +778,7 @@ class ImageDraw: elif anchor[1] == "d": top -= (len(lines) - 1) * line_spacing - bbox = None + bbox: tuple[int, int, int, int] | None = None for idx, line in enumerate(lines): left = xy[0] @@ -828,7 +829,7 @@ class ImageDraw: return bbox -def Draw(im, mode=None): +def Draw(im: Image.Image, mode: str | None = None) -> ImageDraw: """ A simple 2D drawing interface for PIL images. @@ -876,7 +877,7 @@ def getdraw(im=None, hints=None): return im, handler -def floodfill(image, xy, value, border=None, thresh=0): +def floodfill(image: Image.Image, xy, value, border=None, thresh=0) -> None: """ (experimental) Fills a bounded region with a given color. @@ -932,7 +933,7 @@ def floodfill(image, xy, value, border=None, thresh=0): edge = new_edge -def _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation): +def _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation) -> list[tuple[float, float]]: """ Generate a list of vertices for a 2D regular polygon. @@ -982,7 +983,7 @@ def _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation): # 1.2 Check `bounding_circle` has an appropriate value if not isinstance(bounding_circle, (list, tuple)): - msg = "bounding_circle should be a tuple" + msg = "bounding_circle should be a sequence" raise TypeError(msg) if len(bounding_circle) == 3: @@ -1014,7 +1015,7 @@ def _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation): raise ValueError(msg) # 2. Define Helper Functions - def _apply_rotation(point, degrees, centroid): + def _apply_rotation(point: list[float], degrees: float) -> tuple[int, int]: return ( round( point[0] * math.cos(math.radians(360 - degrees)) @@ -1030,11 +1031,11 @@ def _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation): ), ) - def _compute_polygon_vertex(centroid, polygon_radius, angle): + def _compute_polygon_vertex(angle: float) -> tuple[int, int]: start_point = [polygon_radius, 0] - return _apply_rotation(start_point, angle, centroid) + return _apply_rotation(start_point, angle) - def _get_angles(n_sides, rotation): + def _get_angles(n_sides: int, rotation: float) -> list[float]: angles = [] degrees = 360 / n_sides # Start with the bottom left polygon vertex @@ -1051,11 +1052,11 @@ def _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation): # 4. Compute Vertices return [ - _compute_polygon_vertex(centroid, polygon_radius, angle) for angle in angles + _compute_polygon_vertex(angle) for angle in angles ] -def _color_diff(color1, color2): +def _color_diff(color1, color2: float | tuple[int, ...]) -> float: """ Uses 1-norm distance to calculate difference between two values. """ diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index a63b73b33..1ec8a9f4d 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -872,7 +872,7 @@ def load_path(filename): raise OSError(msg) -def load_default(size=None): +def load_default(size: float | None = None) -> FreeTypeFont | ImageFont: """If FreeType support is available, load a version of Aileron Regular, https://dotcolon.net/font/aileron, with a more limited character set. From e0da2b71206c8c06ff4a9f67d6dade6973a7ae96 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 5 Feb 2024 17:18:25 +0000 Subject: [PATCH 051/361] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.1.9 → v0.2.0](https://github.com/astral-sh/ruff-pre-commit/compare/v0.1.9...v0.2.0) - [github.com/psf/black-pre-commit-mirror: 23.12.1 → 24.1.1](https://github.com/psf/black-pre-commit-mirror/compare/23.12.1...24.1.1) - [github.com/PyCQA/bandit: 1.7.6 → 1.7.7](https://github.com/PyCQA/bandit/compare/1.7.6...1.7.7) - [github.com/tox-dev/pyproject-fmt: 1.5.3 → 1.7.0](https://github.com/tox-dev/pyproject-fmt/compare/1.5.3...1.7.0) - [github.com/abravalheri/validate-pyproject: v0.15 → v0.16](https://github.com/abravalheri/validate-pyproject/compare/v0.15...v0.16) --- .pre-commit-config.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5ce0c9a17..c52fdcb55 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,17 +1,17 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.9 + rev: v0.2.0 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - repo: https://github.com/psf/black-pre-commit-mirror - rev: 23.12.1 + rev: 24.1.1 hooks: - id: black - repo: https://github.com/PyCQA/bandit - rev: 1.7.6 + rev: 1.7.7 hooks: - id: bandit args: [--severity-level=high] @@ -48,12 +48,12 @@ repos: - id: sphinx-lint - repo: https://github.com/tox-dev/pyproject-fmt - rev: 1.5.3 + rev: 1.7.0 hooks: - id: pyproject-fmt - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.15 + rev: v0.16 hooks: - id: validate-pyproject From 27b0cf67e784c0c9e58e60afd8ffa1ba274681c6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 5 Feb 2024 17:18:49 +0000 Subject: [PATCH 052/361] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .github/workflows/system-info.py | 1 + Tests/helper.py | 1 + Tests/test_file_dds.py | 1 + Tests/test_file_libtiff_small.py | 1 - Tests/test_image_access.py | 4 +--- Tests/test_image_resize.py | 1 + Tests/test_imagecms.py | 8 +++++--- docs/example/DdsImagePlugin.py | 1 + src/PIL/BlpImagePlugin.py | 1 + src/PIL/DdsImagePlugin.py | 1 + src/PIL/FontFile.py | 4 +--- src/PIL/FtexImagePlugin.py | 1 + src/PIL/GifImagePlugin.py | 6 +++--- src/PIL/ImageCms.py | 2 -- src/PIL/JpegPresets.py | 1 + src/PIL/PdfImagePlugin.py | 6 +++--- src/PIL/__init__.py | 1 + src/PIL/_tkinter_finder.py | 1 + 18 files changed, 24 insertions(+), 18 deletions(-) diff --git a/.github/workflows/system-info.py b/.github/workflows/system-info.py index 57f28c620..9e97b8971 100644 --- a/.github/workflows/system-info.py +++ b/.github/workflows/system-info.py @@ -6,6 +6,7 @@ This sort of info is missing from GitHub Actions. Requested here: https://github.com/actions/virtual-environments/issues/79 """ + from __future__ import annotations import os diff --git a/Tests/helper.py b/Tests/helper.py index b2e7d43dd..3e2a40e02 100644 --- a/Tests/helper.py +++ b/Tests/helper.py @@ -1,6 +1,7 @@ """ Helper functions. """ + from __future__ import annotations import logging diff --git a/Tests/test_file_dds.py b/Tests/test_file_dds.py index 09ee8986a..b78a0dd81 100644 --- a/Tests/test_file_dds.py +++ b/Tests/test_file_dds.py @@ -1,4 +1,5 @@ """Test DdsImagePlugin""" + from __future__ import annotations from io import BytesIO diff --git a/Tests/test_file_libtiff_small.py b/Tests/test_file_libtiff_small.py index ac5270eac..617e1e89c 100644 --- a/Tests/test_file_libtiff_small.py +++ b/Tests/test_file_libtiff_small.py @@ -9,7 +9,6 @@ from .test_file_libtiff import LibTiffTestCase class TestFileLibTiffSmall(LibTiffTestCase): - """The small lena image was failing on open in the libtiff decoder because the file pointer was set to the wrong place by a spurious seek. It wasn't failing with the byteio method. diff --git a/Tests/test_image_access.py b/Tests/test_image_access.py index 00cd4e7a9..e4cb2dad1 100644 --- a/Tests/test_image_access.py +++ b/Tests/test_image_access.py @@ -230,9 +230,7 @@ class TestImageGetPixel(AccessTest): assert im.getpixel([0, 0]) == (20, 20, 70) @pytest.mark.parametrize("mode", ("I;16", "I;16B")) - @pytest.mark.parametrize( - "expected_color", (2**15 - 1, 2**15, 2**15 + 1, 2**16 - 1) - ) + @pytest.mark.parametrize("expected_color", (2**15 - 1, 2**15, 2**15 + 1, 2**16 - 1)) def test_signedness(self, mode, expected_color) -> None: # see https://github.com/python-pillow/Pillow/issues/452 # pixelaccess is using signed int* instead of uint* diff --git a/Tests/test_image_resize.py b/Tests/test_image_resize.py index bd45ee893..a64e4a846 100644 --- a/Tests/test_image_resize.py +++ b/Tests/test_image_resize.py @@ -1,6 +1,7 @@ """ Tests for resize functionality. """ + from __future__ import annotations from itertools import permutations diff --git a/Tests/test_imagecms.py b/Tests/test_imagecms.py index 7f6527155..83fc38ed3 100644 --- a/Tests/test_imagecms.py +++ b/Tests/test_imagecms.py @@ -342,9 +342,11 @@ def test_extended_information() -> None: def truncate_tuple(tuple_or_float): return tuple( - truncate_tuple(val) - if isinstance(val, tuple) - else int(val * power) / power + ( + truncate_tuple(val) + if isinstance(val, tuple) + else int(val * power) / power + ) for val in tuple_or_float ) diff --git a/docs/example/DdsImagePlugin.py b/docs/example/DdsImagePlugin.py index e98bb8680..2a2a0ba29 100644 --- a/docs/example/DdsImagePlugin.py +++ b/docs/example/DdsImagePlugin.py @@ -9,6 +9,7 @@ The contents of this file are hereby released in the public domain (CC0) Full text of the CC0 license: https://creativecommons.org/publicdomain/zero/1.0/ """ + from __future__ import annotations import struct diff --git a/src/PIL/BlpImagePlugin.py b/src/PIL/BlpImagePlugin.py index b8f38b78a..f0fbc8cc2 100644 --- a/src/PIL/BlpImagePlugin.py +++ b/src/PIL/BlpImagePlugin.py @@ -28,6 +28,7 @@ BLP files come in many different flavours: - DXT3 compression is used if alpha_encoding == 1. - DXT5 compression is used if alpha_encoding == 7. """ + from __future__ import annotations import os diff --git a/src/PIL/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py index eb4c8f557..3785174ef 100644 --- a/src/PIL/DdsImagePlugin.py +++ b/src/PIL/DdsImagePlugin.py @@ -9,6 +9,7 @@ The contents of this file are hereby released in the public domain (CC0) Full text of the CC0 license: https://creativecommons.org/publicdomain/zero/1.0/ """ + from __future__ import annotations import io diff --git a/src/PIL/FontFile.py b/src/PIL/FontFile.py index 3ec1ae819..1e0c1c166 100644 --- a/src/PIL/FontFile.py +++ b/src/PIL/FontFile.py @@ -50,9 +50,7 @@ class FontFile: | None ] = [None] * 256 - def __getitem__( - self, ix: int - ) -> ( + def __getitem__(self, ix: int) -> ( tuple[ tuple[int, int], tuple[int, int, int, int], diff --git a/src/PIL/FtexImagePlugin.py b/src/PIL/FtexImagePlugin.py index d5513a56a..b4488e6ee 100644 --- a/src/PIL/FtexImagePlugin.py +++ b/src/PIL/FtexImagePlugin.py @@ -50,6 +50,7 @@ bytes for that mipmap level. Note: All data is stored in little-Endian (Intel) byte order. """ + from __future__ import annotations import struct diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 57d87078b..dc842d7a3 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -641,9 +641,9 @@ def _write_multiple_frames(im, fp, palette): if encoderinfo.get("optimize") and im_frame.mode != "1": if "transparency" not in encoderinfo: try: - encoderinfo[ - "transparency" - ] = im_frame.palette._new_color_index(im_frame) + encoderinfo["transparency"] = ( + im_frame.palette._new_color_index(im_frame) + ) except ValueError: pass if "transparency" in encoderinfo: diff --git a/src/PIL/ImageCms.py b/src/PIL/ImageCms.py index 3e40105e4..2b0ed6c9d 100644 --- a/src/PIL/ImageCms.py +++ b/src/PIL/ImageCms.py @@ -281,7 +281,6 @@ class ImageCmsProfile: class ImageCmsTransform(Image.ImagePointHandler): - """ Transform. This can be used with the procedural API, or with the standard :py:func:`~PIL.Image.Image.point` method. @@ -369,7 +368,6 @@ def get_display_profile(handle=None): class PyCMSError(Exception): - """(pyCMS) Exception class. This is used for all errors in the pyCMS API.""" diff --git a/src/PIL/JpegPresets.py b/src/PIL/JpegPresets.py index 9ecfdb259..d0e64a35e 100644 --- a/src/PIL/JpegPresets.py +++ b/src/PIL/JpegPresets.py @@ -62,6 +62,7 @@ Libjpeg ref.: https://web.archive.org/web/20120328125543/http://www.jpegcameras.com/libjpeg/libjpeg-3.html """ + from __future__ import annotations # fmt: off diff --git a/src/PIL/PdfImagePlugin.py b/src/PIL/PdfImagePlugin.py index 3506aadce..1777f1f20 100644 --- a/src/PIL/PdfImagePlugin.py +++ b/src/PIL/PdfImagePlugin.py @@ -188,9 +188,9 @@ def _save(im, fp, filename, save_all=False): x_resolution = y_resolution = im.encoderinfo.get("resolution", 72.0) info = { - "title": None - if is_appending - else os.path.splitext(os.path.basename(filename))[0], + "title": ( + None if is_appending else os.path.splitext(os.path.basename(filename))[0] + ), "author": None, "subject": None, "keywords": None, diff --git a/src/PIL/__init__.py b/src/PIL/__init__.py index 3fcac8643..63a45769b 100644 --- a/src/PIL/__init__.py +++ b/src/PIL/__init__.py @@ -12,6 +12,7 @@ Use PIL.__version__ for this Pillow version. ;-) """ + from __future__ import annotations from . import _version diff --git a/src/PIL/_tkinter_finder.py b/src/PIL/_tkinter_finder.py index 03a6eba44..71c0ad465 100644 --- a/src/PIL/_tkinter_finder.py +++ b/src/PIL/_tkinter_finder.py @@ -1,5 +1,6 @@ """ Find compiled module linking to Tcl / Tk libraries """ + from __future__ import annotations import sys From 1acaf20f7215c567f0ea04bf20c396a237a2a542 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Mon, 5 Feb 2024 20:37:45 +0200 Subject: [PATCH 053/361] Enable LOG rules for Ruff linter --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b1ce9cf1d..48257b750 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -104,12 +104,12 @@ select = [ "F", # pyflakes errors "I", # isort "ISC", # flake8-implicit-str-concat + "LOG", # flake8-logging "PGH", # pygrep-hooks "RUF100", # unused noqa (yesqa) "UP", # pyupgrade "W", # pycodestyle warnings "YTT", # flake8-2020 - # "LOG", # TODO: enable flake8-logging when it's not in preview anymore ] extend-ignore = [ "E203", # Whitespace before ':' From 3bcc7072d68d3d534f06938879cd6b2fa31e61b9 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Mon, 5 Feb 2024 20:39:08 +0200 Subject: [PATCH 054/361] Move linter config from deprecated top-level to own section --- pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 48257b750..d7b60ef17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,7 +96,7 @@ config-settings = "raqm=enable raqm=vendor fribidi=vendor imagequant=disable" test-command = "cd {project} && .github/workflows/wheels-test.sh" test-extras = "tests" -[tool.ruff] +[tool.ruff.lint] select = [ "C4", # flake8-comprehensions "E", # pycodestyle errors @@ -118,11 +118,11 @@ extend-ignore = [ "E241", # Multiple spaces after ',' ] -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] "Tests/oss-fuzz/fuzz_font.py" = ["I002"] "Tests/oss-fuzz/fuzz_pillow.py" = ["I002"] -[tool.ruff.isort] +[tool.ruff.lint.isort] known-first-party = ["PIL"] required-imports = ["from __future__ import annotations"] From 65cb0b0487c29c91f0145226e1cd173511bc3586 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 6 Feb 2024 07:49:43 +1100 Subject: [PATCH 055/361] Added _typing.Coords --- Tests/test_imagedraw.py | 87 +++++++++++++++++------------------------ src/PIL/ImageDraw.py | 87 ++++++++++++++++++++++++----------------- src/PIL/_typing.py | 4 ++ 3 files changed, 91 insertions(+), 87 deletions(-) diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index 4503a9292..4e6cedcd1 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -2,11 +2,11 @@ from __future__ import annotations import contextlib import os.path -from typing import Sequence import pytest from PIL import Image, ImageColor, ImageDraw, ImageFont, features +from PIL._typing import Coords from .helper import ( assert_image_equal, @@ -75,7 +75,7 @@ def test_mode_mismatch() -> None: @pytest.mark.parametrize("bbox", BBOX) @pytest.mark.parametrize("start, end", ((0, 180), (0.5, 180.4))) -def test_arc(bbox: Sequence[int | Sequence[int]], start: float, end: float) -> None: +def test_arc(bbox: Coords, start: float, end: float) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -88,7 +88,7 @@ def test_arc(bbox: Sequence[int | Sequence[int]], start: float, end: float) -> N @pytest.mark.parametrize("bbox", BBOX) -def test_arc_end_le_start(bbox: Sequence[int | Sequence[int]]) -> None: +def test_arc_end_le_start(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -103,7 +103,7 @@ def test_arc_end_le_start(bbox: Sequence[int | Sequence[int]]) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_arc_no_loops(bbox: Sequence[int | Sequence[int]]) -> None: +def test_arc_no_loops(bbox: Coords) -> None: # No need to go in loops # Arrange im = Image.new("RGB", (W, H)) @@ -119,7 +119,7 @@ def test_arc_no_loops(bbox: Sequence[int | Sequence[int]]) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_arc_width(bbox: Sequence[int | Sequence[int]]) -> None: +def test_arc_width(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -132,7 +132,7 @@ def test_arc_width(bbox: Sequence[int | Sequence[int]]) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_arc_width_pieslice_large(bbox: Sequence[int | Sequence[int]]) -> None: +def test_arc_width_pieslice_large(bbox: Coords) -> None: # Tests an arc with a large enough width that it is a pieslice # Arrange im = Image.new("RGB", (W, H)) @@ -146,7 +146,7 @@ def test_arc_width_pieslice_large(bbox: Sequence[int | Sequence[int]]) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_arc_width_fill(bbox: Sequence[int | Sequence[int]]) -> None: +def test_arc_width_fill(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -159,7 +159,7 @@ def test_arc_width_fill(bbox: Sequence[int | Sequence[int]]) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_arc_width_non_whole_angle(bbox: Sequence[int | Sequence[int]]) -> None: +def test_arc_width_non_whole_angle(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -201,7 +201,7 @@ def test_bitmap() -> None: @pytest.mark.parametrize("mode", ("RGB", "L")) @pytest.mark.parametrize("bbox", BBOX) -def test_chord(mode: str, bbox: Sequence[int | Sequence[int]]) -> None: +def test_chord(mode: str, bbox: Coords) -> None: # Arrange im = Image.new(mode, (W, H)) draw = ImageDraw.Draw(im) @@ -215,7 +215,7 @@ def test_chord(mode: str, bbox: Sequence[int | Sequence[int]]) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_chord_width(bbox: Sequence[int | Sequence[int]]) -> None: +def test_chord_width(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -228,7 +228,7 @@ def test_chord_width(bbox: Sequence[int | Sequence[int]]) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_chord_width_fill(bbox: Sequence[int | Sequence[int]]) -> None: +def test_chord_width_fill(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -241,7 +241,7 @@ def test_chord_width_fill(bbox: Sequence[int | Sequence[int]]) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_chord_zero_width(bbox: Sequence[int | Sequence[int]]) -> None: +def test_chord_zero_width(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -267,7 +267,7 @@ def test_chord_too_fat() -> None: @pytest.mark.parametrize("mode", ("RGB", "L")) @pytest.mark.parametrize("bbox", BBOX) -def test_ellipse(mode: str, bbox: Sequence[int | Sequence[int]]) -> None: +def test_ellipse(mode: str, bbox: Coords) -> None: # Arrange im = Image.new(mode, (W, H)) draw = ImageDraw.Draw(im) @@ -281,7 +281,7 @@ def test_ellipse(mode: str, bbox: Sequence[int | Sequence[int]]) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_ellipse_translucent(bbox: Sequence[int | Sequence[int]]) -> None: +def test_ellipse_translucent(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im, "RGBA") @@ -318,7 +318,7 @@ def test_ellipse_symmetric() -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_ellipse_width(bbox: Sequence[int | Sequence[int]]) -> None: +def test_ellipse_width(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -343,7 +343,7 @@ def test_ellipse_width_large() -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_ellipse_width_fill(bbox: Sequence[int | Sequence[int]]) -> None: +def test_ellipse_width_fill(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -356,7 +356,7 @@ def test_ellipse_width_fill(bbox: Sequence[int | Sequence[int]]) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_ellipse_zero_width(bbox: Sequence[int | Sequence[int]]) -> None: +def test_ellipse_zero_width(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -410,12 +410,7 @@ def test_ellipse_various_sizes_filled() -> None: @pytest.mark.parametrize("points", POINTS) -def test_line( - points: tuple[tuple[int, int], ...] - | list[tuple[int, int]] - | tuple[int, ...] - | list[int] -) -> None: +def test_line(points: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -488,9 +483,7 @@ def test_transform() -> None: @pytest.mark.parametrize("bbox", BBOX) @pytest.mark.parametrize("start, end", ((-92, 46), (-92.2, 46.2))) -def test_pieslice( - bbox: Sequence[int | Sequence[int]], start: float, end: float -) -> None: +def test_pieslice(bbox: Coords, start: float, end: float) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -503,7 +496,7 @@ def test_pieslice( @pytest.mark.parametrize("bbox", BBOX) -def test_pieslice_width(bbox: Sequence[int | Sequence[int]]) -> None: +def test_pieslice_width(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -516,7 +509,7 @@ def test_pieslice_width(bbox: Sequence[int | Sequence[int]]) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_pieslice_width_fill(bbox: Sequence[int | Sequence[int]]) -> None: +def test_pieslice_width_fill(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -530,7 +523,7 @@ def test_pieslice_width_fill(bbox: Sequence[int | Sequence[int]]) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_pieslice_zero_width(bbox: Sequence[int | Sequence[int]]) -> None: +def test_pieslice_zero_width(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -585,12 +578,7 @@ def test_pieslice_no_spikes() -> None: @pytest.mark.parametrize("points", POINTS) -def test_point( - points: tuple[tuple[int, int], ...] - | list[tuple[int, int]] - | tuple[int, ...] - | list[int] -) -> None: +def test_point(points: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -615,12 +603,7 @@ def test_point_I16() -> None: @pytest.mark.parametrize("points", POINTS) -def test_polygon( - points: tuple[tuple[int, int], ...] - | list[tuple[int, int]] - | tuple[int, ...] - | list[int] -) -> None: +def test_polygon(points: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -693,7 +676,7 @@ def test_polygon_translucent() -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_rectangle(bbox: Sequence[int | Sequence[int]]) -> None: +def test_rectangle(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -720,7 +703,7 @@ def test_big_rectangle() -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_rectangle_width(bbox: Sequence[int | Sequence[int]]) -> None: +def test_rectangle_width(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -734,7 +717,7 @@ def test_rectangle_width(bbox: Sequence[int | Sequence[int]]) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_rectangle_width_fill(bbox: Sequence[int | Sequence[int]]) -> None: +def test_rectangle_width_fill(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -748,7 +731,7 @@ def test_rectangle_width_fill(bbox: Sequence[int | Sequence[int]]) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_rectangle_zero_width(bbox: Sequence[int | Sequence[int]]) -> None: +def test_rectangle_zero_width(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -761,7 +744,7 @@ def test_rectangle_zero_width(bbox: Sequence[int | Sequence[int]]) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_rectangle_I16(bbox: Sequence[int | Sequence[int]]) -> None: +def test_rectangle_I16(bbox: Coords) -> None: # Arrange im = Image.new("I;16", (W, H)) draw = ImageDraw.Draw(im) @@ -774,7 +757,7 @@ def test_rectangle_I16(bbox: Sequence[int | Sequence[int]]) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_rectangle_translucent_outline(bbox: Sequence[int | Sequence[int]]) -> None: +def test_rectangle_translucent_outline(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im, "RGBA") @@ -866,7 +849,7 @@ def test_rounded_rectangle_non_integer_radius( @pytest.mark.parametrize("bbox", BBOX) -def test_rounded_rectangle_zero_radius(bbox: Sequence[int | Sequence[int]]) -> None: +def test_rounded_rectangle_zero_radius(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -907,7 +890,7 @@ def test_rounded_rectangle_translucent( @pytest.mark.parametrize("bbox", BBOX) -def test_floodfill(bbox: Sequence[int | Sequence[int]]) -> None: +def test_floodfill(bbox: Coords) -> None: red = ImageColor.getrgb("red") for mode, value in [("L", 1), ("RGBA", (255, 0, 0, 0)), ("RGB", red)]: @@ -940,7 +923,7 @@ def test_floodfill(bbox: Sequence[int | Sequence[int]]) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_floodfill_border(bbox: Sequence[int | Sequence[int]]) -> None: +def test_floodfill_border(bbox: Coords) -> None: # floodfill() is experimental # Arrange @@ -962,7 +945,7 @@ def test_floodfill_border(bbox: Sequence[int | Sequence[int]]) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_floodfill_thresh(bbox: Sequence[int | Sequence[int]]) -> None: +def test_floodfill_thresh(bbox: Coords) -> None: # floodfill() is experimental # Arrange @@ -1419,7 +1402,7 @@ def test_default_font_size() -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_same_color_outline(bbox: Sequence[int | Sequence[int]]) -> None: +def test_same_color_outline(bbox: Coords) -> None: # Prepare shape x0, y0 = 5, 5 x1, y1 = 5, 50 diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 650e30857..d4e000087 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -34,8 +34,10 @@ from __future__ import annotations import math import numbers import struct +from typing import Sequence, cast from . import Image, ImageColor +from ._typing import Coords """ A simple 2D drawing interface for PIL images. @@ -145,13 +147,13 @@ class ImageDraw: fill = self.draw.draw_ink(fill) return ink, fill - def arc(self, xy, start, end, fill=None, width=1) -> None: + def arc(self, xy: Coords, start, end, fill=None, width=1) -> None: """Draw an arc.""" ink, fill = self._getink(fill) if ink is not None: self.draw.draw_arc(xy, start, end, ink, width) - def bitmap(self, xy, bitmap, fill=None) -> None: + def bitmap(self, xy: Sequence[int], bitmap, fill=None) -> None: """Draw a bitmap.""" bitmap.load() ink, fill = self._getink(fill) @@ -160,7 +162,7 @@ class ImageDraw: if ink is not None: self.draw.draw_bitmap(xy, bitmap.im, ink) - def chord(self, xy, start, end, fill=None, outline=None, width=1) -> None: + def chord(self, xy: Coords, start, end, fill=None, outline=None, width=1) -> None: """Draw a chord.""" ink, fill = self._getink(outline, fill) if fill is not None: @@ -168,7 +170,7 @@ class ImageDraw: if ink is not None and ink != fill and width != 0: self.draw.draw_chord(xy, start, end, ink, 0, width) - def ellipse(self, xy, fill=None, outline=None, width=1) -> None: + def ellipse(self, xy: Coords, fill=None, outline=None, width=1) -> None: """Draw an ellipse.""" ink, fill = self._getink(outline, fill) if fill is not None: @@ -176,20 +178,29 @@ class ImageDraw: if ink is not None and ink != fill and width != 0: self.draw.draw_ellipse(xy, ink, 0, width) - def line(self, xy, fill=None, width=0, joint=None) -> None: + def line(self, xy: Coords, fill=None, width=0, joint=None) -> None: """Draw a line, or a connected sequence of line segments.""" ink = self._getink(fill)[0] if ink is not None: self.draw.draw_lines(xy, ink, width) if joint == "curve" and width > 4: - if not isinstance(xy[0], (list, tuple)): - xy = [tuple(xy[i : i + 2]) for i in range(0, len(xy), 2)] - for i in range(1, len(xy) - 1): - point = xy[i] + points: Sequence[Sequence[float]] + if isinstance(xy[0], (list, tuple)): + points = cast(Sequence[Sequence[float]], xy) + else: + points = [ + cast(Sequence[float], tuple(xy[i : i + 2])) + for i in range(0, len(xy), 2) + ] + for i in range(1, len(points) - 1): + point = points[i] angles = [ math.degrees(math.atan2(end[0] - start[0], start[1] - end[1])) % 360 - for start, end in ((xy[i - 1], point), (point, xy[i + 1])) + for start, end in ( + (points[i - 1], point), + (point, points[i + 1]), + ) ] if angles[0] == angles[1]: # This is a straight line, so no joint is required @@ -245,7 +256,9 @@ class ImageDraw: if ink is not None and ink != fill: self.draw.draw_outline(shape, ink, 0) - def pieslice(self, xy, start, end, fill=None, outline=None, width=1) -> None: + def pieslice( + self, xy: Coords, start, end, fill=None, outline=None, width=1 + ) -> None: """Draw a pieslice.""" ink, fill = self._getink(outline, fill) if fill is not None: @@ -253,13 +266,13 @@ class ImageDraw: if ink is not None and ink != fill and width != 0: self.draw.draw_pieslice(xy, start, end, ink, 0, width) - def point(self, xy, fill=None) -> None: + def point(self, xy: Coords, fill=None) -> None: """Draw one or more individual pixels.""" ink, fill = self._getink(fill) if ink is not None: self.draw.draw_points(xy, ink) - def polygon(self, xy, fill=None, outline=None, width=1) -> None: + def polygon(self, xy: Coords, fill=None, outline=None, width=1) -> None: """Draw a polygon.""" ink, fill = self._getink(outline, fill) if fill is not None: @@ -296,7 +309,7 @@ class ImageDraw: xy = _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation) self.polygon(xy, fill, outline, width) - def rectangle(self, xy, fill=None, outline=None, width=1) -> None: + def rectangle(self, xy: Coords, fill=None, outline=None, width=1) -> None: """Draw a rectangle.""" ink, fill = self._getink(outline, fill) if fill is not None: @@ -305,13 +318,13 @@ class ImageDraw: self.draw.draw_rectangle(xy, ink, 0, width) def rounded_rectangle( - self, xy, radius=0, fill=None, outline=None, width=1, *, corners=None + self, xy: Coords, radius=0, fill=None, outline=None, width=1, *, corners=None ) -> None: """Draw a rounded rectangle.""" if isinstance(xy[0], (list, tuple)): - (x0, y0), (x1, y1) = xy + (x0, y0), (x1, y1) = cast(Sequence[Sequence[float]], xy) else: - x0, y0, x1, y1 = xy + x0, y0, x1, y1 = cast(Sequence[float], xy) if x1 < x0: msg = "x1 must be greater than or equal to x0" raise ValueError(msg) @@ -347,6 +360,7 @@ class ImageDraw: ink, fill = self._getink(outline, fill) def draw_corners(pieslice) -> None: + parts: tuple[tuple[tuple[float, float, float, float], int, int], ...] if full_x: # Draw top and bottom halves parts = ( @@ -361,17 +375,18 @@ class ImageDraw: ) else: # Draw four separate corners - parts = [] - for i, part in enumerate( - ( - ((x0, y0, x0 + d, y0 + d), 180, 270), - ((x1 - d, y0, x1, y0 + d), 270, 360), - ((x1 - d, y1 - d, x1, y1), 0, 90), - ((x0, y1 - d, x0 + d, y1), 90, 180), + parts = tuple( + part + for i, part in enumerate( + ( + ((x0, y0, x0 + d, y0 + d), 180, 270), + ((x1 - d, y0, x1, y0 + d), 270, 360), + ((x1 - d, y1 - d, x1, y1), 0, 90), + ((x0, y1 - d, x0 + d, y1), 90, 180), + ) ) - ): - if corners[i]: - parts.append(part) + if corners[i] + ) for part in parts: if pieslice: self.draw.draw_pieslice(*(part + (fill, 1))) @@ -520,7 +535,7 @@ class ImageDraw: *args, **kwargs, ) - coord = coord[0] + offset[0], coord[1] + offset[1] + coord = [coord[0] + offset[0], coord[1] + offset[1]] except AttributeError: try: mask = font.getmask( @@ -539,7 +554,7 @@ class ImageDraw: except TypeError: mask = font.getmask(text) if stroke_offset: - coord = coord[0] + stroke_offset[0], coord[1] + stroke_offset[1] + coord = [coord[0] + stroke_offset[0], coord[1] + stroke_offset[1]] if mode == "RGBA": # font.getmask2(mode="RGBA") returns color in RGB bands and mask in A # extract mask and set text alpha @@ -548,7 +563,9 @@ class ImageDraw: color.fillband(3, ink_alpha) x, y = coord if self.im is not None: - self.im.paste(color, (x, y, x + mask.size[0], y + mask.size[1]), mask) + self.im.paste( + color, (x, y, x + mask.size[0], y + mask.size[1]), mask + ) else: self.draw.draw_bitmap(coord, mask, ink) @@ -829,7 +846,7 @@ class ImageDraw: return bbox -def Draw(im: Image.Image, mode: str | None = None) -> ImageDraw: +def Draw(im, mode: str | None = None) -> ImageDraw: """ A simple 2D drawing interface for PIL images. @@ -933,7 +950,9 @@ def floodfill(image: Image.Image, xy, value, border=None, thresh=0) -> None: edge = new_edge -def _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation) -> list[tuple[float, float]]: +def _compute_regular_polygon_vertices( + bounding_circle, n_sides, rotation +) -> list[tuple[float, float]]: """ Generate a list of vertices for a 2D regular polygon. @@ -1051,9 +1070,7 @@ def _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation) -> lis angles = _get_angles(n_sides, rotation) # 4. Compute Vertices - return [ - _compute_polygon_vertex(angle) for angle in angles - ] + return [_compute_polygon_vertex(angle) for angle in angles] def _color_diff(color1, color2: float | tuple[int, ...]) -> float: diff --git a/src/PIL/_typing.py b/src/PIL/_typing.py index 608b2b41f..ddea0b414 100644 --- a/src/PIL/_typing.py +++ b/src/PIL/_typing.py @@ -1,6 +1,7 @@ from __future__ import annotations import sys +from typing import Sequence, Union if sys.version_info >= (3, 10): from typing import TypeGuard @@ -15,4 +16,7 @@ else: return bool +Coords = Union[Sequence[float], Sequence[Sequence[float]]] + + __all__ = ["TypeGuard"] From 5f115df74f7aa26ec94b79ecad720a707be029e8 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 6 Feb 2024 08:05:30 +1100 Subject: [PATCH 056/361] Replace deprecated "extend-ignore" with "ignore" --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d7b60ef17..652ae3633 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,7 +111,7 @@ select = [ "W", # pycodestyle warnings "YTT", # flake8-2020 ] -extend-ignore = [ +ignore = [ "E203", # Whitespace before ':' "E221", # Multiple spaces before operator "E226", # Missing whitespace around arithmetic operator From 469db5114cf317ea128bd8c4b508eed537b7ce9e Mon Sep 17 00:00:00 2001 From: Evan Miller Date: Tue, 6 Feb 2024 15:41:08 -0500 Subject: [PATCH 057/361] Release GIL while calling WebPAnimDecoderGetNext --- src/_webp.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/_webp.c b/src/_webp.c index a1b4dbc1a..4e7d41f11 100644 --- a/src/_webp.c +++ b/src/_webp.c @@ -450,12 +450,16 @@ _anim_decoder_get_next(PyObject *self) { int timestamp; PyObject *bytes; PyObject *ret; + ImagingSectionCookie cookie; WebPAnimDecoderObject *decp = (WebPAnimDecoderObject *)self; + ImagingSectionEnter(&cookie); if (!WebPAnimDecoderGetNext(decp->dec, &buf, ×tamp)) { + ImagingSectionLeave(&cookie); PyErr_SetString(PyExc_OSError, "failed to read next frame"); return NULL; } + ImagingSectionLeave(&cookie); bytes = PyBytes_FromStringAndSize( (char *)buf, decp->info.canvas_width * 4 * decp->info.canvas_height); From 91645f9efffc623cb83221a8d9c9a0b98d3ce548 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 7 Feb 2024 10:19:00 +1100 Subject: [PATCH 058/361] Lint fix --- Tests/test_imagedraw.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index 4e6cedcd1..f7aea3034 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -776,9 +776,11 @@ def test_rectangle_translucent_outline(bbox: Coords) -> None: [(10, 20, 190, 180), ([10, 20], [190, 180]), ((10, 20), (190, 180))], ) def test_rounded_rectangle( - xy: tuple[int, int, int, int] - | tuple[list[int]] - | tuple[tuple[int, int], tuple[int, int]] + xy: ( + tuple[int, int, int, int] + | tuple[list[int]] + | tuple[tuple[int, int], tuple[int, int]] + ) ) -> None: # Arrange im = Image.new("RGB", (200, 200)) From cdc498e6f3b2060906ca14fe9b9187e0a93a1613 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 7 Feb 2024 19:16:28 +1100 Subject: [PATCH 059/361] Added type hints --- Tests/test_color_lut.py | 4 ++- Tests/test_file_eps.py | 32 ++++++++++---------- Tests/test_file_jpeg.py | 35 +++++++++++----------- Tests/test_file_jpeg2k.py | 21 ++++++------- Tests/test_file_libtiff.py | 38 +++++++++++++----------- Tests/test_file_png.py | 15 +++++----- Tests/test_image_paste.py | 41 +++++++++++++++----------- Tests/test_image_reduce.py | 43 +++++++++++++++------------ Tests/test_image_resample.py | 57 ++++++++++++++++++++---------------- Tests/test_imagemath.py | 8 ++--- 10 files changed, 160 insertions(+), 134 deletions(-) diff --git a/Tests/test_color_lut.py b/Tests/test_color_lut.py index e6c8d7819..2bb1b57d4 100644 --- a/Tests/test_color_lut.py +++ b/Tests/test_color_lut.py @@ -15,7 +15,9 @@ except ImportError: class TestColorLut3DCoreAPI: - def generate_identity_table(self, channels, size): + def generate_identity_table( + self, channels: int, size: int | tuple[int, int, int] + ) -> tuple[int, int, int, int, list[float]]: if isinstance(size, tuple): size_1d, size_2d, size_3d = size else: diff --git a/Tests/test_file_eps.py b/Tests/test_file_eps.py index 06f927c7b..00f5f39e8 100644 --- a/Tests/test_file_eps.py +++ b/Tests/test_file_eps.py @@ -84,7 +84,7 @@ simple_eps_file_with_long_binary_data = ( ("filename", "size"), ((FILE1, (460, 352)), (FILE2, (360, 252))) ) @pytest.mark.parametrize("scale", (1, 2)) -def test_sanity(filename, size, scale) -> None: +def test_sanity(filename: str, size: tuple[int, int], scale: int) -> None: expected_size = tuple(s * scale for s in size) with Image.open(filename) as image: image.load(scale=scale) @@ -129,28 +129,28 @@ def test_binary_header_only() -> None: @pytest.mark.parametrize("prefix", (b"", simple_binary_header)) -def test_missing_version_comment(prefix) -> None: +def test_missing_version_comment(prefix: bytes) -> None: data = io.BytesIO(prefix + b"\n".join(simple_eps_file_without_version)) with pytest.raises(SyntaxError): EpsImagePlugin.EpsImageFile(data) @pytest.mark.parametrize("prefix", (b"", simple_binary_header)) -def test_missing_boundingbox_comment(prefix) -> None: +def test_missing_boundingbox_comment(prefix: bytes) -> None: data = io.BytesIO(prefix + b"\n".join(simple_eps_file_without_boundingbox)) with pytest.raises(SyntaxError, match='EPS header missing "%%BoundingBox" comment'): EpsImagePlugin.EpsImageFile(data) @pytest.mark.parametrize("prefix", (b"", simple_binary_header)) -def test_invalid_boundingbox_comment(prefix) -> None: +def test_invalid_boundingbox_comment(prefix: bytes) -> None: data = io.BytesIO(prefix + b"\n".join(simple_eps_file_with_invalid_boundingbox)) with pytest.raises(OSError, match="cannot determine EPS bounding box"): EpsImagePlugin.EpsImageFile(data) @pytest.mark.parametrize("prefix", (b"", simple_binary_header)) -def test_invalid_boundingbox_comment_valid_imagedata_comment(prefix) -> None: +def test_invalid_boundingbox_comment_valid_imagedata_comment(prefix: bytes) -> None: data = io.BytesIO( prefix + b"\n".join(simple_eps_file_with_invalid_boundingbox_valid_imagedata) ) @@ -161,21 +161,21 @@ def test_invalid_boundingbox_comment_valid_imagedata_comment(prefix) -> None: @pytest.mark.parametrize("prefix", (b"", simple_binary_header)) -def test_ascii_comment_too_long(prefix) -> None: +def test_ascii_comment_too_long(prefix: bytes) -> None: data = io.BytesIO(prefix + b"\n".join(simple_eps_file_with_long_ascii_comment)) with pytest.raises(SyntaxError, match="not an EPS file"): EpsImagePlugin.EpsImageFile(data) @pytest.mark.parametrize("prefix", (b"", simple_binary_header)) -def test_long_binary_data(prefix) -> None: +def test_long_binary_data(prefix: bytes) -> None: data = io.BytesIO(prefix + b"\n".join(simple_eps_file_with_long_binary_data)) EpsImagePlugin.EpsImageFile(data) @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") @pytest.mark.parametrize("prefix", (b"", simple_binary_header)) -def test_load_long_binary_data(prefix) -> None: +def test_load_long_binary_data(prefix: bytes) -> None: data = io.BytesIO(prefix + b"\n".join(simple_eps_file_with_long_binary_data)) with Image.open(data) as img: img.load() @@ -305,7 +305,7 @@ def test_render_scale2() -> None: @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") @pytest.mark.parametrize("filename", (FILE1, FILE2, "Tests/images/illu10_preview.eps")) -def test_resize(filename) -> None: +def test_resize(filename: str) -> None: with Image.open(filename) as im: new_size = (100, 100) im = im.resize(new_size) @@ -314,7 +314,7 @@ def test_resize(filename) -> None: @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") @pytest.mark.parametrize("filename", (FILE1, FILE2)) -def test_thumbnail(filename) -> None: +def test_thumbnail(filename: str) -> None: # Issue #619 with Image.open(filename) as im: new_size = (100, 100) @@ -335,7 +335,7 @@ def test_readline_psfile(tmp_path: Path) -> None: line_endings = ["\r\n", "\n", "\n\r", "\r"] strings = ["something", "else", "baz", "bif"] - def _test_readline(t, ending) -> None: + def _test_readline(t: EpsImagePlugin.PSFile, ending: str) -> None: ending = "Failure with line ending: %s" % ( "".join("%s" % ord(s) for s in ending) ) @@ -344,13 +344,13 @@ def test_readline_psfile(tmp_path: Path) -> None: assert t.readline().strip("\r\n") == "baz", ending assert t.readline().strip("\r\n") == "bif", ending - def _test_readline_io_psfile(test_string, ending) -> None: + def _test_readline_io_psfile(test_string: str, ending: str) -> None: f = io.BytesIO(test_string.encode("latin-1")) with pytest.warns(DeprecationWarning): t = EpsImagePlugin.PSFile(f) _test_readline(t, ending) - def _test_readline_file_psfile(test_string, ending) -> None: + def _test_readline_file_psfile(test_string: str, ending: str) -> None: f = str(tmp_path / "temp.txt") with open(f, "wb") as w: w.write(test_string.encode("latin-1")) @@ -376,7 +376,7 @@ def test_psfile_deprecation() -> None: "line_ending", (b"\r\n", b"\n", b"\n\r", b"\r"), ) -def test_readline(prefix, line_ending) -> None: +def test_readline(prefix: bytes, line_ending: bytes) -> None: simple_file = prefix + line_ending.join(simple_eps_file_with_comments) data = io.BytesIO(simple_file) test_file = EpsImagePlugin.EpsImageFile(data) @@ -394,7 +394,7 @@ def test_readline(prefix, line_ending) -> None: "Tests/images/illuCS6_preview.eps", ), ) -def test_open_eps(filename) -> None: +def test_open_eps(filename: str) -> None: # https://github.com/python-pillow/Pillow/issues/1104 with Image.open(filename) as img: assert img.mode == "RGB" @@ -417,7 +417,7 @@ def test_emptyline() -> None: "test_file", ["Tests/images/timeout-d675703545fee17acab56e5fec644c19979175de.eps"], ) -def test_timeout(test_file) -> None: +def test_timeout(test_file: str) -> None: with open(test_file, "rb") as f: with pytest.raises(Image.UnidentifiedImageError): with Image.open(f): diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index ff278d4c1..6b0662e0b 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -5,6 +5,7 @@ import re import warnings from io import BytesIO from pathlib import Path +from typing import Any import pytest @@ -42,7 +43,7 @@ TEST_FILE = "Tests/images/hopper.jpg" @skip_unless_feature("jpg") class TestFileJpeg: - def roundtrip(self, im, **options): + def roundtrip(self, im: Image.Image, **options: Any) -> Image.Image: out = BytesIO() im.save(out, "JPEG", **options) test_bytes = out.tell() @@ -51,7 +52,7 @@ class TestFileJpeg: im.bytes = test_bytes # for testing only return im - def gen_random_image(self, size, mode: str = "RGB"): + def gen_random_image(self, size: tuple[int, int], mode: str = "RGB") -> Image.Image: """Generates a very hard to compress file :param size: tuple :param mode: optional image mode @@ -71,7 +72,7 @@ class TestFileJpeg: assert im.get_format_mimetype() == "image/jpeg" @pytest.mark.parametrize("size", ((1, 0), (0, 1), (0, 0))) - def test_zero(self, size, tmp_path: Path) -> None: + def test_zero(self, size: tuple[int, int], tmp_path: Path) -> None: f = str(tmp_path / "temp.jpg") im = Image.new("RGB", size) with pytest.raises(ValueError): @@ -108,13 +109,11 @@ class TestFileJpeg: assert "comment" not in reloaded.info # Test that a comment argument overrides the default comment - for comment in ("Test comment text", b"Text comment text"): + for comment in ("Test comment text", b"Test comment text"): out = BytesIO() im.save(out, format="JPEG", comment=comment) with Image.open(out) as reloaded: - if not isinstance(comment, bytes): - comment = comment.encode() - assert reloaded.info["comment"] == comment + assert reloaded.info["comment"] == b"Test comment text" def test_cmyk(self) -> None: # Test CMYK handling. Thanks to Tim and Charlie for test data, @@ -145,7 +144,7 @@ class TestFileJpeg: assert k > 0.9 def test_rgb(self) -> None: - def getchannels(im): + def getchannels(im: Image.Image) -> tuple[int, int, int]: return tuple(v[0] for v in im.layer) im = hopper() @@ -161,8 +160,8 @@ class TestFileJpeg: "test_image_path", [TEST_FILE, "Tests/images/pil_sample_cmyk.jpg"], ) - def test_dpi(self, test_image_path) -> None: - def test(xdpi, ydpi=None): + def test_dpi(self, test_image_path: str) -> None: + def test(xdpi: int, ydpi: int | None = None): with Image.open(test_image_path) as im: im = self.roundtrip(im, dpi=(xdpi, ydpi or xdpi)) return im.info.get("dpi") @@ -207,7 +206,7 @@ class TestFileJpeg: ImageFile.MAXBLOCK * 4 + 3, # large block ), ) - def test_icc_big(self, n) -> None: + def test_icc_big(self, n: int) -> None: # Make sure that the "extra" support handles large blocks # The ICC APP marker can store 65519 bytes per marker, so # using a 4-byte test code should allow us to detect out of @@ -433,7 +432,7 @@ class TestFileJpeg: assert_image(im1, im2.mode, im2.size) def test_subsampling(self) -> None: - def getsampling(im): + def getsampling(im: Image.Image): layer = im.layer return layer[0][1:3] + layer[1][1:3] + layer[2][1:3] @@ -530,7 +529,7 @@ class TestFileJpeg: pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" ) def test_qtables(self, tmp_path: Path) -> None: - def _n_qtables_helper(n, test_file) -> None: + def _n_qtables_helper(n: int, test_file: str) -> None: with Image.open(test_file) as im: f = str(tmp_path / "temp.jpg") im.save(f, qtables=[[n] * 64] * n) @@ -666,7 +665,7 @@ class TestFileJpeg: "blocks, rows, markers", ((0, 0, 0), (1, 0, 15), (3, 0, 5), (8, 0, 1), (0, 1, 3), (0, 2, 1)), ) - def test_restart_markers(self, blocks, rows, markers) -> None: + def test_restart_markers(self, blocks: int, rows: int, markers: int) -> None: im = Image.new("RGB", (32, 32)) # 16 MCUs out = BytesIO() im.save( @@ -724,13 +723,13 @@ class TestFileJpeg: assert im.format == "JPEG" @pytest.mark.parametrize("mode", ("1", "L", "RGB", "RGBX", "CMYK", "YCbCr")) - def test_save_correct_modes(self, mode) -> None: + def test_save_correct_modes(self, mode: str) -> None: out = BytesIO() img = Image.new(mode, (20, 20)) img.save(out, "JPEG") @pytest.mark.parametrize("mode", ("LA", "La", "RGBA", "RGBa", "P")) - def test_save_wrong_modes(self, mode) -> None: + def test_save_wrong_modes(self, mode: str) -> None: # ref https://github.com/python-pillow/Pillow/issues/2005 out = BytesIO() img = Image.new(mode, (20, 20)) @@ -982,12 +981,12 @@ class TestFileJpeg: # Even though this decoder never says that it is finished # the image should still end when there is no new data class InfiniteMockPyDecoder(ImageFile.PyDecoder): - def decode(self, buffer): + def decode(self, buffer: bytes) -> tuple[int, int]: return 0, 0 decoder = InfiniteMockPyDecoder(None) - def closure(mode, *args): + def closure(mode: str, *args) -> InfiniteMockPyDecoder: decoder.__init__(mode, *args) return decoder diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index e3f1fa8fd..fab19e2ea 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -4,6 +4,7 @@ import os import re from io import BytesIO from pathlib import Path +from typing import Any import pytest @@ -36,7 +37,7 @@ test_card.load() # 'Not enough memory to handle tile data' -def roundtrip(im, **options): +def roundtrip(im: Image.Image, **options: Any) -> Image.Image: out = BytesIO() im.save(out, "JPEG2000", **options) test_bytes = out.tell() @@ -138,7 +139,7 @@ def test_prog_res_rt() -> None: @pytest.mark.parametrize("num_resolutions", range(2, 6)) -def test_default_num_resolutions(num_resolutions) -> None: +def test_default_num_resolutions(num_resolutions: int) -> None: d = 1 << (num_resolutions - 1) im = test_card.resize((d - 1, d - 1)) with pytest.raises(OSError): @@ -198,9 +199,9 @@ def test_layers_type(tmp_path: Path) -> None: for quality_layers in [[100, 50, 10], (100, 50, 10), None]: test_card.save(outfile, quality_layers=quality_layers) - for quality_layers in ["quality_layers", ("100", "50", "10")]: + for quality_layers_str in ["quality_layers", ("100", "50", "10")]: with pytest.raises(ValueError): - test_card.save(outfile, quality_layers=quality_layers) + test_card.save(outfile, quality_layers=quality_layers_str) def test_layers() -> None: @@ -233,7 +234,7 @@ def test_layers() -> None: ("foo.jp2", {"no_jp2": False}, 4, b"jP"), ), ) -def test_no_jp2(name, args, offset, data) -> None: +def test_no_jp2(name: str, args: dict[str, bool], offset: int, data: bytes) -> None: out = BytesIO() if name: out.name = name @@ -278,7 +279,7 @@ def test_sgnd(tmp_path: Path) -> None: @pytest.mark.parametrize("ext", (".j2k", ".jp2")) -def test_rgba(ext) -> None: +def test_rgba(ext: str) -> None: # Arrange with Image.open("Tests/images/rgb_trns_ycbc" + ext) as im: # Act @@ -289,7 +290,7 @@ def test_rgba(ext) -> None: @pytest.mark.parametrize("ext", (".j2k", ".jp2")) -def test_16bit_monochrome_has_correct_mode(ext) -> None: +def test_16bit_monochrome_has_correct_mode(ext: str) -> None: with Image.open("Tests/images/16bit.cropped" + ext) as im: im.load() assert im.mode == "I;16" @@ -346,12 +347,12 @@ def test_parser_feed() -> None: not os.path.exists(EXTRA_DIR), reason="Extra image files not installed" ) @pytest.mark.parametrize("name", ("subsampling_1", "subsampling_2", "zoo1", "zoo2")) -def test_subsampling_decode(name) -> None: +def test_subsampling_decode(name: str) -> None: test = f"{EXTRA_DIR}/{name}.jp2" reference = f"{EXTRA_DIR}/{name}.ppm" with Image.open(test) as im: - epsilon = 3 # for YCbCr images + epsilon = 3.0 # for YCbCr images with Image.open(reference) as im2: width, height = im2.size if name[-1] == "2": @@ -400,7 +401,7 @@ def test_save_comment() -> None: "Tests/images/crash-d2c93af851d3ab9a19e34503626368b2ecde9c03.j2k", ], ) -def test_crashes(test_file) -> None: +def test_crashes(test_file: str) -> None: with open(test_file, "rb") as f: with Image.open(f) as im: # Valgrind should not complain here diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index 1386034e5..0994d9904 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -27,7 +27,7 @@ from .helper import ( @skip_unless_feature("libtiff") class LibTiffTestCase: - def _assert_noerr(self, tmp_path: Path, im) -> None: + def _assert_noerr(self, tmp_path: Path, im: Image.Image) -> None: """Helper tests that assert basic sanity about the g4 tiff reading""" # 1 bit assert im.mode == "1" @@ -140,7 +140,7 @@ class TestFileLibTiff(LibTiffTestCase): assert_image_equal_tofile(im, "Tests/images/tiff_adobe_deflate.png") @pytest.mark.parametrize("legacy_api", (False, True)) - def test_write_metadata(self, legacy_api, tmp_path: Path) -> None: + def test_write_metadata(self, legacy_api: bool, tmp_path: Path) -> None: """Test metadata writing through libtiff""" f = str(tmp_path / "temp.tiff") with Image.open("Tests/images/hopper_g4.tif") as img: @@ -243,7 +243,7 @@ class TestFileLibTiff(LibTiffTestCase): TiffImagePlugin.WRITE_LIBTIFF = False def test_custom_metadata(self, tmp_path: Path) -> None: - tc = namedtuple("test_case", "value,type,supported_by_default") + tc = namedtuple("tc", "value,type,supported_by_default") custom = { 37000 + k: v for k, v in enumerate( @@ -284,7 +284,9 @@ class TestFileLibTiff(LibTiffTestCase): for libtiff in libtiffs: TiffImagePlugin.WRITE_LIBTIFF = libtiff - def check_tags(tiffinfo) -> None: + def check_tags( + tiffinfo: TiffImagePlugin.ImageFileDirectory_v2 | dict[int, str] + ) -> None: im = hopper() out = str(tmp_path / "temp.tif") @@ -502,7 +504,7 @@ class TestFileLibTiff(LibTiffTestCase): assert_image_equal_tofile(im, out) @pytest.mark.parametrize("im", (hopper("P"), Image.new("P", (1, 1), "#000"))) - def test_palette_save(self, im, tmp_path: Path) -> None: + def test_palette_save(self, im: Image.Image, tmp_path: Path) -> None: out = str(tmp_path / "temp.tif") TiffImagePlugin.WRITE_LIBTIFF = True @@ -514,7 +516,7 @@ class TestFileLibTiff(LibTiffTestCase): assert len(reloaded.tag_v2[320]) == 768 @pytest.mark.parametrize("compression", ("tiff_ccitt", "group3", "group4")) - def test_bw_compression_w_rgb(self, compression, tmp_path: Path) -> None: + def test_bw_compression_w_rgb(self, compression: str, tmp_path: Path) -> None: im = hopper("RGB") out = str(tmp_path / "temp.tif") @@ -647,7 +649,7 @@ class TestFileLibTiff(LibTiffTestCase): # Generate test image pilim = hopper() - def save_bytesio(compression=None) -> None: + def save_bytesio(compression: str | None = None) -> None: buffer_io = io.BytesIO() pilim.save(buffer_io, format="tiff", compression=compression) buffer_io.seek(0) @@ -731,7 +733,7 @@ class TestFileLibTiff(LibTiffTestCase): assert icc == icc_libtiff def test_write_icc(self, tmp_path: Path) -> None: - def check_write(libtiff) -> None: + def check_write(libtiff: bool) -> None: TiffImagePlugin.WRITE_LIBTIFF = libtiff with Image.open("Tests/images/hopper.iccprofile.tif") as img: @@ -837,7 +839,7 @@ class TestFileLibTiff(LibTiffTestCase): assert reloaded.mode == "F" assert reloaded.getexif()[SAMPLEFORMAT] == 3 - def test_lzma(self, capfd): + def test_lzma(self, capfd: pytest.CaptureFixture[str]) -> None: try: with Image.open("Tests/images/hopper_lzma.tif") as im: assert im.mode == "RGB" @@ -853,7 +855,7 @@ class TestFileLibTiff(LibTiffTestCase): sys.stderr.write(captured.err) raise - def test_webp(self, capfd): + def test_webp(self, capfd: pytest.CaptureFixture[str]) -> None: try: with Image.open("Tests/images/hopper_webp.tif") as im: assert im.mode == "RGB" @@ -971,7 +973,7 @@ class TestFileLibTiff(LibTiffTestCase): assert_image_equal_tofile(im, "Tests/images/tiff_16bit_RGBa_target.png") @pytest.mark.parametrize("compression", (None, "jpeg")) - def test_block_tile_tags(self, compression, tmp_path: Path) -> None: + def test_block_tile_tags(self, compression: str | None, tmp_path: Path) -> None: im = hopper() out = str(tmp_path / "temp.tif") @@ -1020,7 +1022,9 @@ class TestFileLibTiff(LibTiffTestCase): ), ], ) - def test_wrong_bits_per_sample(self, file_name, mode, size, tile) -> None: + def test_wrong_bits_per_sample( + self, file_name: str, mode: str, size: tuple[int, int], tile + ) -> None: with Image.open("Tests/images/" + file_name) as im: assert im.mode == mode assert im.size == size @@ -1086,7 +1090,7 @@ class TestFileLibTiff(LibTiffTestCase): TiffImagePlugin.READ_LIBTIFF = False @pytest.mark.parametrize("compression", ("tiff_adobe_deflate", "jpeg")) - def test_save_multistrip(self, compression, tmp_path: Path) -> None: + def test_save_multistrip(self, compression: str, tmp_path: Path) -> None: im = hopper("RGB").resize((256, 256)) out = str(tmp_path / "temp.tif") im.save(out, compression=compression) @@ -1096,14 +1100,14 @@ class TestFileLibTiff(LibTiffTestCase): assert len(im.tag_v2[STRIPOFFSETS]) > 1 @pytest.mark.parametrize("argument", (True, False)) - def test_save_single_strip(self, argument, tmp_path: Path) -> None: + def test_save_single_strip(self, argument: bool, tmp_path: Path) -> None: im = hopper("RGB").resize((256, 256)) out = str(tmp_path / "temp.tif") if not argument: TiffImagePlugin.STRIP_SIZE = 2**18 try: - arguments = {"compression": "tiff_adobe_deflate"} + arguments: dict[str, str | int] = {"compression": "tiff_adobe_deflate"} if argument: arguments["strip_size"] = 2**18 im.save(out, **arguments) @@ -1114,7 +1118,7 @@ class TestFileLibTiff(LibTiffTestCase): TiffImagePlugin.STRIP_SIZE = 65536 @pytest.mark.parametrize("compression", ("tiff_adobe_deflate", None)) - def test_save_zero(self, compression, tmp_path: Path) -> None: + def test_save_zero(self, compression: str | None, tmp_path: Path) -> None: im = Image.new("RGB", (0, 0)) out = str(tmp_path / "temp.tif") with pytest.raises(SystemError): @@ -1134,7 +1138,7 @@ class TestFileLibTiff(LibTiffTestCase): ("Tests/images/child_ifd_jpeg.tiff", (20,)), ), ) - def test_get_child_images(self, path, sizes) -> None: + def test_get_child_images(self, path: str, sizes: tuple[int, ...]) -> None: with Image.open(path) as im: ims = im.get_child_images() diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 0f1d96365..d4a634316 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -6,6 +6,7 @@ import warnings import zlib from io import BytesIO from pathlib import Path +from typing import Any import pytest @@ -36,7 +37,7 @@ TEST_PNG_FILE = "Tests/images/hopper.png" MAGIC = PngImagePlugin._MAGIC -def chunk(cid, *data): +def chunk(cid: bytes, *data: bytes) -> bytes: test_file = BytesIO() PngImagePlugin.putchunk(*(test_file, cid) + data) return test_file.getvalue() @@ -52,11 +53,11 @@ HEAD = MAGIC + IHDR TAIL = IDAT + IEND -def load(data): +def load(data: bytes) -> Image.Image: return Image.open(BytesIO(data)) -def roundtrip(im, **options): +def roundtrip(im: Image.Image, **options: Any) -> Image.Image: out = BytesIO() im.save(out, "PNG", **options) out.seek(0) @@ -65,7 +66,7 @@ def roundtrip(im, **options): @skip_unless_feature("zlib") class TestFilePng: - def get_chunks(self, filename): + def get_chunks(self, filename: str) -> list[bytes]: chunks = [] with open(filename, "rb") as fp: fp.read(8) @@ -436,7 +437,7 @@ class TestFilePng: def test_unicode_text(self) -> None: # Check preservation of non-ASCII characters - def rt_text(value) -> None: + def rt_text(value: str) -> None: im = Image.new("RGB", (32, 32)) info = PngImagePlugin.PngInfo() info.add_text("Text", value) @@ -636,7 +637,7 @@ class TestFilePng: @pytest.mark.parametrize( "cid", (b"IHDR", b"sRGB", b"pHYs", b"acTL", b"fcTL", b"fdAT") ) - def test_truncated_chunks(self, cid) -> None: + def test_truncated_chunks(self, cid: bytes) -> None: fp = BytesIO() with PngImagePlugin.PngStream(fp) as png: with pytest.raises(ValueError): @@ -755,7 +756,7 @@ class TestFilePng: im.seek(1) @pytest.mark.parametrize("buffer", (True, False)) - def test_save_stdout(self, buffer) -> None: + def test_save_stdout(self, buffer: bool) -> None: old_stdout = sys.stdout if buffer: diff --git a/Tests/test_image_paste.py b/Tests/test_image_paste.py index 34a2f8f3d..c4d7a5dd2 100644 --- a/Tests/test_image_paste.py +++ b/Tests/test_image_paste.py @@ -11,10 +11,9 @@ class TestImagingPaste: masks = {} size = 128 - def assert_9points_image(self, im, expected) -> None: - expected = [ - point[0] if im.mode == "L" else point[: len(im.mode)] for point in expected - ] + def assert_9points_image( + self, im: Image.Image, expected: list[tuple[int, int, int, int]] + ) -> None: px = im.load() actual = [ px[0, 0], @@ -27,9 +26,17 @@ class TestImagingPaste: px[self.size // 2, self.size - 1], px[self.size - 1, self.size - 1], ] - assert actual == expected + assert actual == [ + point[0] if im.mode == "L" else point[: len(im.mode)] for point in expected + ] - def assert_9points_paste(self, im, im2, mask, expected) -> None: + def assert_9points_paste( + self, + im: Image.Image, + im2: Image.Image, + mask: Image.Image, + expected: list[tuple[int, int, int, int]], + ) -> None: im3 = im.copy() im3.paste(im2, (0, 0), mask) self.assert_9points_image(im3, expected) @@ -106,7 +113,7 @@ class TestImagingPaste: ) @pytest.mark.parametrize("mode", ["RGBA", "RGB", "L"]) - def test_image_solid(self, mode) -> None: + def test_image_solid(self, mode: str) -> None: im = Image.new(mode, (200, 200), "red") im2 = getattr(self, "gradient_" + mode) @@ -116,7 +123,7 @@ class TestImagingPaste: assert_image_equal(im, im2) @pytest.mark.parametrize("mode", ["RGBA", "RGB", "L"]) - def test_image_mask_1(self, mode) -> None: + def test_image_mask_1(self, mode: str) -> None: im = Image.new(mode, (200, 200), "white") im2 = getattr(self, "gradient_" + mode) @@ -138,7 +145,7 @@ class TestImagingPaste: ) @pytest.mark.parametrize("mode", ["RGBA", "RGB", "L"]) - def test_image_mask_L(self, mode) -> None: + def test_image_mask_L(self, mode: str) -> None: im = Image.new(mode, (200, 200), "white") im2 = getattr(self, "gradient_" + mode) @@ -160,7 +167,7 @@ class TestImagingPaste: ) @pytest.mark.parametrize("mode", ["RGBA", "RGB", "L"]) - def test_image_mask_LA(self, mode) -> None: + def test_image_mask_LA(self, mode: str) -> None: im = Image.new(mode, (200, 200), "white") im2 = getattr(self, "gradient_" + mode) @@ -182,7 +189,7 @@ class TestImagingPaste: ) @pytest.mark.parametrize("mode", ["RGBA", "RGB", "L"]) - def test_image_mask_RGBA(self, mode) -> None: + def test_image_mask_RGBA(self, mode: str) -> None: im = Image.new(mode, (200, 200), "white") im2 = getattr(self, "gradient_" + mode) @@ -204,7 +211,7 @@ class TestImagingPaste: ) @pytest.mark.parametrize("mode", ["RGBA", "RGB", "L"]) - def test_image_mask_RGBa(self, mode) -> None: + def test_image_mask_RGBa(self, mode: str) -> None: im = Image.new(mode, (200, 200), "white") im2 = getattr(self, "gradient_" + mode) @@ -226,7 +233,7 @@ class TestImagingPaste: ) @pytest.mark.parametrize("mode", ["RGBA", "RGB", "L"]) - def test_color_solid(self, mode) -> None: + def test_color_solid(self, mode: str) -> None: im = Image.new(mode, (200, 200), "black") rect = (12, 23, 128 + 12, 128 + 23) @@ -239,7 +246,7 @@ class TestImagingPaste: assert sum(head[:255]) == 0 @pytest.mark.parametrize("mode", ["RGBA", "RGB", "L"]) - def test_color_mask_1(self, mode) -> None: + def test_color_mask_1(self, mode: str) -> None: im = Image.new(mode, (200, 200), (50, 60, 70, 80)[: len(mode)]) color = (10, 20, 30, 40)[: len(mode)] @@ -261,7 +268,7 @@ class TestImagingPaste: ) @pytest.mark.parametrize("mode", ["RGBA", "RGB", "L"]) - def test_color_mask_L(self, mode) -> None: + def test_color_mask_L(self, mode: str) -> None: im = getattr(self, "gradient_" + mode).copy() color = "white" @@ -283,7 +290,7 @@ class TestImagingPaste: ) @pytest.mark.parametrize("mode", ["RGBA", "RGB", "L"]) - def test_color_mask_RGBA(self, mode) -> None: + def test_color_mask_RGBA(self, mode: str) -> None: im = getattr(self, "gradient_" + mode).copy() color = "white" @@ -305,7 +312,7 @@ class TestImagingPaste: ) @pytest.mark.parametrize("mode", ["RGBA", "RGB", "L"]) - def test_color_mask_RGBa(self, mode) -> None: + def test_color_mask_RGBa(self, mode: str) -> None: im = getattr(self, "gradient_" + mode).copy() color = "white" diff --git a/Tests/test_image_reduce.py b/Tests/test_image_reduce.py index c29830a7e..33b33d6b7 100644 --- a/Tests/test_image_reduce.py +++ b/Tests/test_image_reduce.py @@ -48,7 +48,7 @@ gradients_image.load() ((1, 3), (10, 4)), ), ) -def test_args_factor(size, expected) -> None: +def test_args_factor(size: int | tuple[int, int], expected: tuple[int, int]) -> None: im = Image.new("L", (10, 10)) assert expected == im.reduce(size).size @@ -56,7 +56,7 @@ def test_args_factor(size, expected) -> None: @pytest.mark.parametrize( "size, expected_error", ((0, ValueError), (2.0, TypeError), ((0, 10), ValueError)) ) -def test_args_factor_error(size, expected_error) -> None: +def test_args_factor_error(size: float | tuple[int, int], expected_error) -> None: im = Image.new("L", (10, 10)) with pytest.raises(expected_error): im.reduce(size) @@ -69,7 +69,7 @@ def test_args_factor_error(size, expected_error) -> None: ((5, 5, 6, 6), (1, 1)), ), ) -def test_args_box(size, expected) -> None: +def test_args_box(size: tuple[int, int, int, int], expected: tuple[int, int]) -> None: im = Image.new("L", (10, 10)) assert expected == im.reduce(2, size).size @@ -86,20 +86,20 @@ def test_args_box(size, expected) -> None: ((5, 0, 5, 10), ValueError), ), ) -def test_args_box_error(size, expected_error) -> None: +def test_args_box_error(size: str | tuple[int, int, int, int], expected_error) -> None: im = Image.new("L", (10, 10)) with pytest.raises(expected_error): im.reduce(2, size).size @pytest.mark.parametrize("mode", ("P", "1", "I;16")) -def test_unsupported_modes(mode) -> None: +def test_unsupported_modes(mode: str) -> None: im = Image.new("P", (10, 10)) with pytest.raises(ValueError): im.reduce(3) -def get_image(mode): +def get_image(mode: str) -> Image.Image: mode_info = ImageMode.getmode(mode) if mode_info.basetype == "L": bands = [gradients_image] @@ -119,7 +119,7 @@ def get_image(mode): return im.crop((0, 0, im.width, im.height - 5)) -def compare_reduce_with_box(im, factor) -> None: +def compare_reduce_with_box(im: Image.Image, factor: int | tuple[int, int]) -> None: box = (11, 13, 146, 164) reduced = im.reduce(factor, box=box) reference = im.crop(box).reduce(factor) @@ -127,7 +127,10 @@ def compare_reduce_with_box(im, factor) -> None: def compare_reduce_with_reference( - im, factor, average_diff: float = 0.4, max_diff: int = 1 + im: Image.Image, + factor: int | tuple[int, int], + average_diff: float = 0.4, + max_diff: int = 1, ) -> None: """Image.reduce() should look very similar to Image.resize(BOX). @@ -173,7 +176,9 @@ def compare_reduce_with_reference( assert_compare_images(reduced, reference, average_diff, max_diff) -def assert_compare_images(a, b, max_average_diff, max_diff: int = 255) -> None: +def assert_compare_images( + a: Image.Image, b: Image.Image, max_average_diff: float, max_diff: int = 255 +) -> None: assert a.mode == b.mode, f"got mode {repr(a.mode)}, expected {repr(b.mode)}" assert a.size == b.size, f"got size {repr(a.size)}, expected {repr(b.size)}" @@ -201,20 +206,20 @@ def assert_compare_images(a, b, max_average_diff, max_diff: int = 255) -> None: @pytest.mark.parametrize("factor", remarkable_factors) -def test_mode_L(factor) -> None: +def test_mode_L(factor: int | tuple[int, int]) -> None: im = get_image("L") compare_reduce_with_reference(im, factor) compare_reduce_with_box(im, factor) @pytest.mark.parametrize("factor", remarkable_factors) -def test_mode_LA(factor) -> None: +def test_mode_LA(factor: int | tuple[int, int]) -> None: im = get_image("LA") compare_reduce_with_reference(im, factor, 0.8, 5) @pytest.mark.parametrize("factor", remarkable_factors) -def test_mode_LA_opaque(factor) -> None: +def test_mode_LA_opaque(factor: int | tuple[int, int]) -> None: im = get_image("LA") # With opaque alpha, an error should be way smaller. im.putalpha(Image.new("L", im.size, 255)) @@ -223,27 +228,27 @@ def test_mode_LA_opaque(factor) -> None: @pytest.mark.parametrize("factor", remarkable_factors) -def test_mode_La(factor) -> None: +def test_mode_La(factor: int | tuple[int, int]) -> None: im = get_image("La") compare_reduce_with_reference(im, factor) compare_reduce_with_box(im, factor) @pytest.mark.parametrize("factor", remarkable_factors) -def test_mode_RGB(factor) -> None: +def test_mode_RGB(factor: int | tuple[int, int]) -> None: im = get_image("RGB") compare_reduce_with_reference(im, factor) compare_reduce_with_box(im, factor) @pytest.mark.parametrize("factor", remarkable_factors) -def test_mode_RGBA(factor) -> None: +def test_mode_RGBA(factor: int | tuple[int, int]) -> None: im = get_image("RGBA") compare_reduce_with_reference(im, factor, 0.8, 5) @pytest.mark.parametrize("factor", remarkable_factors) -def test_mode_RGBA_opaque(factor) -> None: +def test_mode_RGBA_opaque(factor: int | tuple[int, int]) -> None: im = get_image("RGBA") # With opaque alpha, an error should be way smaller. im.putalpha(Image.new("L", im.size, 255)) @@ -252,21 +257,21 @@ def test_mode_RGBA_opaque(factor) -> None: @pytest.mark.parametrize("factor", remarkable_factors) -def test_mode_RGBa(factor) -> None: +def test_mode_RGBa(factor: int | tuple[int, int]) -> None: im = get_image("RGBa") compare_reduce_with_reference(im, factor) compare_reduce_with_box(im, factor) @pytest.mark.parametrize("factor", remarkable_factors) -def test_mode_I(factor) -> None: +def test_mode_I(factor: int | tuple[int, int]) -> None: im = get_image("I") compare_reduce_with_reference(im, factor) compare_reduce_with_box(im, factor) @pytest.mark.parametrize("factor", remarkable_factors) -def test_mode_F(factor) -> None: +def test_mode_F(factor: int | tuple[int, int]) -> None: im = get_image("F") compare_reduce_with_reference(im, factor, 0, 0) compare_reduce_with_box(im, factor) diff --git a/Tests/test_image_resample.py b/Tests/test_image_resample.py index f4c9eb0e6..f3ec12c05 100644 --- a/Tests/test_image_resample.py +++ b/Tests/test_image_resample.py @@ -1,6 +1,7 @@ from __future__ import annotations from contextlib import contextmanager +from typing import Generator import pytest @@ -51,7 +52,7 @@ class TestImagingResampleVulnerability: class TestImagingCoreResampleAccuracy: - def make_case(self, mode, size, color): + def make_case(self, mode: str, size: tuple[int, int], color: int) -> Image.Image: """Makes a sample image with two dark and two bright squares. For example: e0 e0 1f 1f @@ -66,7 +67,7 @@ class TestImagingCoreResampleAccuracy: return Image.merge(mode, [case] * len(mode)) - def make_sample(self, data, size): + def make_sample(self, data: str, size: tuple[int, int]) -> Image.Image: """Restores a sample image from given data string which contains hex-encoded pixels from the top left fourth of a sample. """ @@ -83,7 +84,7 @@ class TestImagingCoreResampleAccuracy: s_px[size[0] - x - 1, y] = 255 - val return sample - def check_case(self, case, sample) -> None: + def check_case(self, case: Image.Image, sample: Image.Image) -> None: s_px = sample.load() c_px = case.load() for y in range(case.size[1]): @@ -95,7 +96,7 @@ class TestImagingCoreResampleAccuracy: ) assert s_px[x, y] == c_px[x, y], message - def serialize_image(self, image): + def serialize_image(self, image: Image.Image) -> str: s_px = image.load() return "\n".join( " ".join(f"{s_px[x, y]:02x}" for x in range(image.size[0])) @@ -103,7 +104,7 @@ class TestImagingCoreResampleAccuracy: ) @pytest.mark.parametrize("mode", ("RGBX", "RGB", "La", "L")) - def test_reduce_box(self, mode) -> None: + def test_reduce_box(self, mode: str) -> None: case = self.make_case(mode, (8, 8), 0xE1) case = case.resize((4, 4), Image.Resampling.BOX) # fmt: off @@ -114,7 +115,7 @@ class TestImagingCoreResampleAccuracy: self.check_case(channel, self.make_sample(data, (4, 4))) @pytest.mark.parametrize("mode", ("RGBX", "RGB", "La", "L")) - def test_reduce_bilinear(self, mode) -> None: + def test_reduce_bilinear(self, mode: str) -> None: case = self.make_case(mode, (8, 8), 0xE1) case = case.resize((4, 4), Image.Resampling.BILINEAR) # fmt: off @@ -125,7 +126,7 @@ class TestImagingCoreResampleAccuracy: self.check_case(channel, self.make_sample(data, (4, 4))) @pytest.mark.parametrize("mode", ("RGBX", "RGB", "La", "L")) - def test_reduce_hamming(self, mode) -> None: + def test_reduce_hamming(self, mode: str) -> None: case = self.make_case(mode, (8, 8), 0xE1) case = case.resize((4, 4), Image.Resampling.HAMMING) # fmt: off @@ -136,7 +137,7 @@ class TestImagingCoreResampleAccuracy: self.check_case(channel, self.make_sample(data, (4, 4))) @pytest.mark.parametrize("mode", ("RGBX", "RGB", "La", "L")) - def test_reduce_bicubic(self, mode) -> None: + def test_reduce_bicubic(self, mode: str) -> None: case = self.make_case(mode, (12, 12), 0xE1) case = case.resize((6, 6), Image.Resampling.BICUBIC) # fmt: off @@ -148,7 +149,7 @@ class TestImagingCoreResampleAccuracy: self.check_case(channel, self.make_sample(data, (6, 6))) @pytest.mark.parametrize("mode", ("RGBX", "RGB", "La", "L")) - def test_reduce_lanczos(self, mode) -> None: + def test_reduce_lanczos(self, mode: str) -> None: case = self.make_case(mode, (16, 16), 0xE1) case = case.resize((8, 8), Image.Resampling.LANCZOS) # fmt: off @@ -161,7 +162,7 @@ class TestImagingCoreResampleAccuracy: self.check_case(channel, self.make_sample(data, (8, 8))) @pytest.mark.parametrize("mode", ("RGBX", "RGB", "La", "L")) - def test_enlarge_box(self, mode) -> None: + def test_enlarge_box(self, mode: str) -> None: case = self.make_case(mode, (2, 2), 0xE1) case = case.resize((4, 4), Image.Resampling.BOX) # fmt: off @@ -172,7 +173,7 @@ class TestImagingCoreResampleAccuracy: self.check_case(channel, self.make_sample(data, (4, 4))) @pytest.mark.parametrize("mode", ("RGBX", "RGB", "La", "L")) - def test_enlarge_bilinear(self, mode) -> None: + def test_enlarge_bilinear(self, mode: str) -> None: case = self.make_case(mode, (2, 2), 0xE1) case = case.resize((4, 4), Image.Resampling.BILINEAR) # fmt: off @@ -183,7 +184,7 @@ class TestImagingCoreResampleAccuracy: self.check_case(channel, self.make_sample(data, (4, 4))) @pytest.mark.parametrize("mode", ("RGBX", "RGB", "La", "L")) - def test_enlarge_hamming(self, mode) -> None: + def test_enlarge_hamming(self, mode: str) -> None: case = self.make_case(mode, (2, 2), 0xE1) case = case.resize((4, 4), Image.Resampling.HAMMING) # fmt: off @@ -194,7 +195,7 @@ class TestImagingCoreResampleAccuracy: self.check_case(channel, self.make_sample(data, (4, 4))) @pytest.mark.parametrize("mode", ("RGBX", "RGB", "La", "L")) - def test_enlarge_bicubic(self, mode) -> None: + def test_enlarge_bicubic(self, mode: str) -> None: case = self.make_case(mode, (4, 4), 0xE1) case = case.resize((8, 8), Image.Resampling.BICUBIC) # fmt: off @@ -207,7 +208,7 @@ class TestImagingCoreResampleAccuracy: self.check_case(channel, self.make_sample(data, (8, 8))) @pytest.mark.parametrize("mode", ("RGBX", "RGB", "La", "L")) - def test_enlarge_lanczos(self, mode) -> None: + def test_enlarge_lanczos(self, mode: str) -> None: case = self.make_case(mode, (6, 6), 0xE1) case = case.resize((12, 12), Image.Resampling.LANCZOS) data = ( @@ -230,7 +231,7 @@ class TestImagingCoreResampleAccuracy: class TestCoreResampleConsistency: - def make_case(self, mode, fill): + def make_case(self, mode: str, fill: tuple[int, int, int] | float): im = Image.new(mode, (512, 9), fill) return im.resize((9, 512), Image.Resampling.LANCZOS), im.load()[0, 0] @@ -265,7 +266,7 @@ class TestCoreResampleConsistency: class TestCoreResampleAlphaCorrect: - def make_levels_case(self, mode): + def make_levels_case(self, mode: str) -> Image.Image: i = Image.new(mode, (256, 16)) px = i.load() for y in range(i.size[1]): @@ -275,7 +276,7 @@ class TestCoreResampleAlphaCorrect: px[x, y] = tuple(pix) return i - def run_levels_case(self, i) -> None: + def run_levels_case(self, i: Image.Image) -> None: px = i.load() for y in range(i.size[1]): used_colors = {px[x, y][0] for x in range(i.size[0])} @@ -302,7 +303,9 @@ class TestCoreResampleAlphaCorrect: self.run_levels_case(case.resize((512, 32), Image.Resampling.BICUBIC)) self.run_levels_case(case.resize((512, 32), Image.Resampling.LANCZOS)) - def make_dirty_case(self, mode, clean_pixel, dirty_pixel): + def make_dirty_case( + self, mode: str, clean_pixel: tuple[int, ...], dirty_pixel: tuple[int, ...] + ) -> Image.Image: i = Image.new(mode, (64, 64), dirty_pixel) px = i.load() xdiv4 = i.size[0] // 4 @@ -312,7 +315,7 @@ class TestCoreResampleAlphaCorrect: px[x + xdiv4, y + ydiv4] = clean_pixel return i - def run_dirty_case(self, i, clean_pixel) -> None: + def run_dirty_case(self, i: Image.Image, clean_pixel: tuple[int, ...]) -> None: px = i.load() for y in range(i.size[1]): for x in range(i.size[0]): @@ -432,7 +435,7 @@ class TestCoreResampleBox: Image.Resampling.LANCZOS, ), ) - def test_wrong_arguments(self, resample) -> None: + def test_wrong_arguments(self, resample: Image.Resampling) -> None: im = hopper() im.resize((32, 32), resample, (0, 0, im.width, im.height)) im.resize((32, 32), resample, (20, 20, im.width, im.height)) @@ -459,8 +462,12 @@ class TestCoreResampleBox: with pytest.raises(ValueError, match="can't exceed"): im.resize((32, 32), resample, (0, 0, im.width, im.height + 1)) - def resize_tiled(self, im, dst_size, xtiles, ytiles): - def split_range(size, tiles): + def resize_tiled( + self, im: Image.Image, dst_size: tuple[int, int], xtiles: int, ytiles: int + ) -> Image.Image: + def split_range( + size: int, tiles: int + ) -> Generator[tuple[int, int], None, None]: scale = size / tiles for i in range(tiles): yield int(round(scale * i)), int(round(scale * (i + 1))) @@ -518,7 +525,7 @@ class TestCoreResampleBox: @pytest.mark.parametrize( "resample", (Image.Resampling.NEAREST, Image.Resampling.BILINEAR) ) - def test_formats(self, mode, resample) -> None: + def test_formats(self, mode: str, resample: Image.Resampling) -> None: im = hopper(mode) box = (20, 20, im.size[0] - 20, im.size[1] - 20) with_box = im.resize((32, 32), resample, box) @@ -558,7 +565,7 @@ class TestCoreResampleBox: @pytest.mark.parametrize( "flt", (Image.Resampling.NEAREST, Image.Resampling.BICUBIC) ) - def test_skip_horizontal(self, flt) -> None: + def test_skip_horizontal(self, flt: Image.Resampling) -> None: # Can skip resize for one dimension im = hopper() @@ -581,7 +588,7 @@ class TestCoreResampleBox: @pytest.mark.parametrize( "flt", (Image.Resampling.NEAREST, Image.Resampling.BICUBIC) ) - def test_skip_vertical(self, flt) -> None: + def test_skip_vertical(self, flt: Image.Resampling) -> None: # Can skip resize for one dimension im = hopper() diff --git a/Tests/test_imagemath.py b/Tests/test_imagemath.py index ea6e80f1e..b65ea8740 100644 --- a/Tests/test_imagemath.py +++ b/Tests/test_imagemath.py @@ -5,11 +5,11 @@ import pytest from PIL import Image, ImageMath -def pixel(im): - if hasattr(im, "im"): - return f"{im.mode} {repr(im.getpixel((0, 0)))}" +def pixel(im: Image.Image | int) -> str | int: if isinstance(im, int): return int(im) # hack to deal with booleans + else: + return f"{im.mode} {repr(im.getpixel((0, 0)))}" A = Image.new("L", (1, 1), 1) @@ -60,7 +60,7 @@ def test_ops() -> None: "(lambda: (lambda: exec('pass'))())()", ), ) -def test_prevent_exec(expression) -> None: +def test_prevent_exec(expression: str) -> None: with pytest.raises(ValueError): ImageMath.eval(expression) From 463c36821136652a05517a4db810a265d25c9b0c Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Wed, 7 Feb 2024 21:02:34 +1100 Subject: [PATCH 060/361] Simplified code Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- Tests/test_imagemath.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/test_imagemath.py b/Tests/test_imagemath.py index b65ea8740..a21e2307d 100644 --- a/Tests/test_imagemath.py +++ b/Tests/test_imagemath.py @@ -8,8 +8,8 @@ from PIL import Image, ImageMath def pixel(im: Image.Image | int) -> str | int: if isinstance(im, int): return int(im) # hack to deal with booleans - else: - return f"{im.mode} {repr(im.getpixel((0, 0)))}" + + return f"{im.mode} {repr(im.getpixel((0, 0)))}" A = Image.new("L", (1, 1), 1) From c93b23239d4cbe8b8c6d4d6c04db35763a25db62 Mon Sep 17 00:00:00 2001 From: Evan Miller Date: Wed, 7 Feb 2024 20:20:27 -0500 Subject: [PATCH 061/361] Update src/_webp.c Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- src/_webp.c | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/_webp.c b/src/_webp.c index 4e7d41f11..927d8dc3f 100644 --- a/src/_webp.c +++ b/src/_webp.c @@ -448,18 +448,19 @@ PyObject * _anim_decoder_get_next(PyObject *self) { uint8_t *buf; int timestamp; + int ok; PyObject *bytes; PyObject *ret; ImagingSectionCookie cookie; WebPAnimDecoderObject *decp = (WebPAnimDecoderObject *)self; ImagingSectionEnter(&cookie); - if (!WebPAnimDecoderGetNext(decp->dec, &buf, ×tamp)) { - ImagingSectionLeave(&cookie); + ok = WebPAnimDecoderGetNext(decp->dec, &buf, ×tamp) + ImagingSectionLeave(&cookie); + if (!ok) { PyErr_SetString(PyExc_OSError, "failed to read next frame"); return NULL; } - ImagingSectionLeave(&cookie); bytes = PyBytes_FromStringAndSize( (char *)buf, decp->info.canvas_width * 4 * decp->info.canvas_height); From cb39b1c89e71f67ce4dacd41cebf723ff86306dd Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Thu, 8 Feb 2024 12:29:06 +1100 Subject: [PATCH 062/361] Corrected syntax --- src/_webp.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_webp.c b/src/_webp.c index 927d8dc3f..47592547c 100644 --- a/src/_webp.c +++ b/src/_webp.c @@ -455,7 +455,7 @@ _anim_decoder_get_next(PyObject *self) { WebPAnimDecoderObject *decp = (WebPAnimDecoderObject *)self; ImagingSectionEnter(&cookie); - ok = WebPAnimDecoderGetNext(decp->dec, &buf, ×tamp) + ok = WebPAnimDecoderGetNext(decp->dec, &buf, ×tamp); ImagingSectionLeave(&cookie); if (!ok) { PyErr_SetString(PyExc_OSError, "failed to read next frame"); From a276cf2c9fadf39cc5e663e44bc160c566d7c050 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 8 Feb 2024 18:48:38 +1100 Subject: [PATCH 063/361] Use _typing alias --- src/PIL/ImageFont.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index 9f8394d63..7be2fdf04 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -33,10 +33,10 @@ import sys import warnings from enum import IntEnum from io import BytesIO -from pathlib import Path from typing import BinaryIO from . import Image +from ._typing import StrOrBytesPath from ._util import is_directory, is_path @@ -193,7 +193,7 @@ class FreeTypeFont: def __init__( self, - font: bytes | str | Path | BinaryIO | None = None, + font: StrOrBytesPath | BinaryIO | None = None, size: float = 10, index: int = 0, encoding: str = "", From a118a82c30acf6427653b129fab263fde3bdbbac Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 8 Feb 2024 18:35:37 +1100 Subject: [PATCH 064/361] Use os.path.realpath consistently when os.fspath is used --- src/PIL/Image.py | 2 +- src/PIL/ImageFont.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index d7d0a1ae7..adb63b07f 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -2385,7 +2385,7 @@ class Image: filename = "" open_fp = False if is_path(fp): - filename = os.fspath(fp) + filename = os.path.realpath(os.fspath(fp)) open_fp = True elif fp == sys.stdout: try: diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index 7be2fdf04..256c581df 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -230,7 +230,7 @@ class FreeTypeFont: ) if is_path(font): - font = os.fspath(font) + font = os.path.realpath(os.fspath(font)) if sys.platform == "win32": font_bytes_path = font if isinstance(font, bytes) else font.encode() try: From e6a521130e975f89529442597735a6a48cc3685f Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 9 Feb 2024 19:47:09 +1100 Subject: [PATCH 065/361] If previous disposal was 2, do not fill identical pixels --- Tests/test_file_gif.py | 3 +++ src/PIL/GifImagePlugin.py | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index db9d3586c..0399c6b67 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -647,6 +647,9 @@ def test_dispose2_palette(tmp_path: Path) -> None: # Center remains red every frame assert rgb_img.getpixel((50, 50)) == circle + # Check that frame transparency wasn't added unnecessarily + assert img._frame_transparency is None + def test_dispose2_diff(tmp_path: Path) -> None: out = str(tmp_path / "temp.gif") diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index dc842d7a3..73a5487d9 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -638,7 +638,11 @@ def _write_multiple_frames(im, fp, palette): background_im = Image.new("P", im_frame.size, background) background_im.putpalette(im_frames[0]["im"].palette) delta, bbox = _getbbox(background_im, im_frame) - if encoderinfo.get("optimize") and im_frame.mode != "1": + if ( + encoderinfo.get("optimize") + and im_frames[-1]["encoderinfo"].get("disposal") != 2 + and im_frame.mode != "1" + ): if "transparency" not in encoderinfo: try: encoderinfo["transparency"] = ( From 152a24e13abfe099d4cf75dc7982290feb200ad2 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 10 Feb 2024 16:48:02 +1100 Subject: [PATCH 066/361] Simplified code --- src/PIL/Image.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index adb63b07f..231674f54 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -3240,10 +3240,8 @@ def open(fp, mode="r", formats=None) -> Image: exclusive_fp = False filename = "" - if isinstance(fp, os.PathLike): + if is_path(fp): filename = os.path.realpath(os.fspath(fp)) - elif is_path(fp): - filename = fp if filename: fp = builtins.open(filename, "rb") From 373c62e5cbcd27a4e497e61b44cda89911f38807 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 10 Feb 2024 19:47:50 +1100 Subject: [PATCH 067/361] Use subprocess with CREATE_NO_WINDOW flag in WindowsViewer --- src/PIL/ImageShow.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/PIL/ImageShow.py b/src/PIL/ImageShow.py index c03122c11..4a801e5b0 100644 --- a/src/PIL/ImageShow.py +++ b/src/PIL/ImageShow.py @@ -138,6 +138,17 @@ class WindowsViewer(Viewer): f'&& del /f "{file}"' ) + def show_file(self, path: str, **options: Any) -> int: + """ + Display given file. + """ + subprocess.Popen( + self.get_command(path, **options), + shell=True, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW"), + ) # nosec + return 1 + if sys.platform == "win32": register(WindowsViewer) From 19a6edeecce2f3605fcdb074c00ac0152c6bdf05 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 10 Feb 2024 19:50:45 +1100 Subject: [PATCH 068/361] Added type hints --- pyproject.toml | 10 ---- src/PIL/DdsImagePlugin.py | 16 ++++-- src/PIL/ImImagePlugin.py | 4 +- src/PIL/Image.py | 86 +++++++++++++++++++---------- src/PIL/ImageQt.py | 12 +++- src/PIL/PdfParser.py | 11 +++- src/PIL/PyAccess.py | 1 + src/PIL/TiffImagePlugin.py | 109 +++++++++++++++++++++---------------- src/PIL/TiffTags.py | 4 +- src/PIL/_imaging.pyi | 5 ++ src/PIL/_tkinter_finder.py | 3 +- src/PIL/_webp.pyi | 5 ++ tox.ini | 5 ++ 13 files changed, 171 insertions(+), 100 deletions(-) create mode 100644 src/PIL/_imaging.pyi create mode 100644 src/PIL/_webp.pyi diff --git a/pyproject.toml b/pyproject.toml index 652ae3633..48c59f2a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -141,16 +141,6 @@ warn_redundant_casts = true warn_unreachable = true warn_unused_ignores = true exclude = [ - '^src/PIL/_tkinter_finder.py$', - '^src/PIL/DdsImagePlugin.py$', '^src/PIL/FpxImagePlugin.py$', - '^src/PIL/Image.py$', - '^src/PIL/ImageQt.py$', - '^src/PIL/ImImagePlugin.py$', '^src/PIL/MicImagePlugin.py$', - '^src/PIL/PdfParser.py$', - '^src/PIL/PyAccess.py$', - '^src/PIL/TiffImagePlugin.py$', - '^src/PIL/TiffTags.py$', - '^src/PIL/WebPImagePlugin.py$', ] diff --git a/src/PIL/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py index 3785174ef..be17f4223 100644 --- a/src/PIL/DdsImagePlugin.py +++ b/src/PIL/DdsImagePlugin.py @@ -270,13 +270,17 @@ class D3DFMT(IntEnum): # Backward compatibility layer module = sys.modules[__name__] for item in DDSD: + assert item.name is not None setattr(module, "DDSD_" + item.name, item.value) -for item in DDSCAPS: - setattr(module, "DDSCAPS_" + item.name, item.value) -for item in DDSCAPS2: - setattr(module, "DDSCAPS2_" + item.name, item.value) -for item in DDPF: - setattr(module, "DDPF_" + item.name, item.value) +for item1 in DDSCAPS: + assert item1.name is not None + setattr(module, "DDSCAPS_" + item1.name, item1.value) +for item2 in DDSCAPS2: + assert item2.name is not None + setattr(module, "DDSCAPS2_" + item2.name, item2.value) +for item3 in DDPF: + assert item3.name is not None + setattr(module, "DDPF_" + item3.name, item3.value) DDS_FOURCC = DDPF.FOURCC DDS_RGB = DDPF.RGB diff --git a/src/PIL/ImImagePlugin.py b/src/PIL/ImImagePlugin.py index 97d726a8a..4613e40b6 100644 --- a/src/PIL/ImImagePlugin.py +++ b/src/PIL/ImImagePlugin.py @@ -93,8 +93,8 @@ for i in ["16", "16L", "16B"]: for i in ["32S"]: OPEN[f"L {i} image"] = ("I", f"I;{i}") OPEN[f"L*{i} image"] = ("I", f"I;{i}") -for i in range(2, 33): - OPEN[f"L*{i} image"] = ("F", f"F;{i}") +for j in range(2, 33): + OPEN[f"L*{j} image"] = ("F", f"F;{j}") # -------------------------------------------------------------------- diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 111d06012..d32a0fc19 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -26,6 +26,7 @@ from __future__ import annotations +import abc import atexit import builtins import io @@ -40,11 +41,8 @@ import warnings from collections.abc import Callable, MutableMapping from enum import IntEnum from pathlib import Path - -try: - from defusedxml import ElementTree -except ImportError: - ElementTree = None +from types import ModuleType +from typing import IO, TYPE_CHECKING, Any # VERSION was removed in Pillow 6.0.0. # PILLOW_VERSION was removed in Pillow 9.0.0. @@ -60,6 +58,12 @@ from . import ( from ._binary import i32le, o32be, o32le from ._util import DeferredError, is_path +ElementTree: ModuleType | None +try: + from defusedxml import ElementTree +except ImportError: + ElementTree = None + logger = logging.getLogger(__name__) @@ -110,6 +114,7 @@ except ImportError as v: USE_CFFI_ACCESS = False +cffi: ModuleType | None try: import cffi except ImportError: @@ -211,14 +216,22 @@ if hasattr(core, "DEFAULT_STRATEGY"): # -------------------------------------------------------------------- # Registries -ID = [] -OPEN = {} -MIME = {} -SAVE = {} -SAVE_ALL = {} -EXTENSION = {} -DECODERS = {} -ENCODERS = {} +if TYPE_CHECKING: + from . import ImageFile # pragma: no cover +ID: list[str] = [] +OPEN: dict[ + str, + tuple[ + Callable[[IO[bytes], str | bytes], ImageFile.ImageFile], + Callable[[bytes], bool] | None, + ], +] = {} +MIME: dict[str, str] = {} +SAVE: dict[str, Callable[[Image, IO[bytes], str | bytes], None]] = {} +SAVE_ALL: dict[str, Callable[[Image, IO[bytes], str | bytes], None]] = {} +EXTENSION: dict[str, str] = {} +DECODERS: dict[str, object] = {} +ENCODERS: dict[str, object] = {} # -------------------------------------------------------------------- # Modes @@ -2383,12 +2396,12 @@ class Image: may have been created, and may contain partial data. """ - filename = "" + filename: str | bytes = "" open_fp = False if isinstance(fp, Path): filename = str(fp) open_fp = True - elif is_path(fp): + elif isinstance(fp, (str, bytes)): filename = fp open_fp = True elif fp == sys.stdout: @@ -2398,7 +2411,7 @@ class Image: pass if not filename and hasattr(fp, "name") and is_path(fp.name): # only set the name for metadata purposes - filename = fp.name + filename = os.path.realpath(os.fspath(fp.name)) # may mutate self! self._ensure_mutable() @@ -2409,7 +2422,8 @@ class Image: preinit() - ext = os.path.splitext(filename)[1].lower() + filename_ext = os.path.splitext(filename)[1].lower() + ext = filename_ext.decode() if isinstance(filename_ext, bytes) else filename_ext if not format: if ext not in EXTENSION: @@ -2451,7 +2465,7 @@ class Image: if open_fp: fp.close() - def seek(self, frame) -> Image: + def seek(self, frame) -> None: """ Seeks to the given frame in this sequence file. If you seek beyond the end of the sequence, the method raises an @@ -2511,10 +2525,9 @@ class Image: self.load() if self.im.bands == 1: - ims = [self.copy()] + return (self.copy(),) else: - ims = map(self._new, self.im.split()) - return tuple(ims) + return tuple(map(self._new, self.im.split())) def getchannel(self, channel): """ @@ -2871,7 +2884,14 @@ class ImageTransformHandler: (for use with :py:meth:`~PIL.Image.Image.transform`) """ - pass + @abc.abstractmethod + def transform( + self, + size: tuple[int, int], + image: Image, + **options: dict[str, str | int | tuple[int, ...] | list[int]], + ) -> Image: + pass # pragma: no cover # -------------------------------------------------------------------- @@ -3243,11 +3263,9 @@ def open(fp, mode="r", formats=None) -> Image: raise TypeError(msg) exclusive_fp = False - filename = "" - if isinstance(fp, Path): - filename = str(fp.resolve()) - elif is_path(fp): - filename = fp + filename: str | bytes = "" + if is_path(fp): + filename = os.path.realpath(os.fspath(fp)) if filename: fp = builtins.open(filename, "rb") @@ -3421,7 +3439,11 @@ def merge(mode, bands): # Plugin registry -def register_open(id, factory, accept=None) -> None: +def register_open( + id, + factory: Callable[[IO[bytes], str | bytes], ImageFile.ImageFile], + accept: Callable[[bytes], bool] | None = None, +) -> None: """ Register an image file plugin. This function should not be used in application code. @@ -3631,7 +3653,13 @@ _apply_env_variables() atexit.register(core.clear_cache) -class Exif(MutableMapping): +if TYPE_CHECKING: + _ExifBase = MutableMapping[int, Any] # pragma: no cover +else: + _ExifBase = MutableMapping + + +class Exif(_ExifBase): """ This class provides read and write access to EXIF image data:: diff --git a/src/PIL/ImageQt.py b/src/PIL/ImageQt.py index 6377c7501..293ba4941 100644 --- a/src/PIL/ImageQt.py +++ b/src/PIL/ImageQt.py @@ -19,19 +19,26 @@ from __future__ import annotations import sys from io import BytesIO +from typing import Callable from . import Image from ._util import is_path +qt_version: str | None qt_versions = [ ["6", "PyQt6"], ["side6", "PySide6"], ] # If a version has already been imported, attempt it first -qt_versions.sort(key=lambda qt_version: qt_version[1] in sys.modules, reverse=True) -for qt_version, qt_module in qt_versions: +qt_versions.sort(key=lambda version: version[1] in sys.modules, reverse=True) +for version, qt_module in qt_versions: try: + QBuffer: type + QIODevice: type + QImage: type + QPixmap: type + qRgba: Callable[[int, int, int, int], int] if qt_module == "PyQt6": from PyQt6.QtCore import QBuffer, QIODevice from PyQt6.QtGui import QImage, QPixmap, qRgba @@ -41,6 +48,7 @@ for qt_version, qt_module in qt_versions: except (ImportError, RuntimeError): continue qt_is_installed = True + qt_version = version break else: qt_is_installed = False diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index 014460006..9aa8dde83 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -8,6 +8,7 @@ import os import re import time import zlib +from typing import TYPE_CHECKING, Any, List, Union # see 7.9.2.2 Text String Type on page 86 and D.3 PDFDocEncoding Character Set @@ -239,12 +240,18 @@ class PdfName: return bytes(result) -class PdfArray(list): +class PdfArray(List[Any]): def __bytes__(self): return b"[ " + b" ".join(pdf_repr(x) for x in self) + b" ]" -class PdfDict(collections.UserDict): +if TYPE_CHECKING: + _DictBase = collections.UserDict[Union[str, bytes], Any] # pragma: no cover +else: + _DictBase = collections.UserDict + + +class PdfDict(_DictBase): def __setattr__(self, key, value): if key == "data": collections.UserDict.__setattr__(self, key, value) diff --git a/src/PIL/PyAccess.py b/src/PIL/PyAccess.py index 07bb712d8..2c831913d 100644 --- a/src/PIL/PyAccess.py +++ b/src/PIL/PyAccess.py @@ -25,6 +25,7 @@ import sys from ._deprecate import deprecate +FFI: type try: from cffi import FFI diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index e20d4d5ea..af22d76cb 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -50,6 +50,7 @@ import warnings from collections.abc import MutableMapping from fractions import Fraction from numbers import Number, Rational +from typing import TYPE_CHECKING, Any, Callable from . import ExifTags, Image, ImageFile, ImageOps, ImagePalette, TiffTags from ._binary import i16be as i16 @@ -306,6 +307,13 @@ _load_dispatch = {} _write_dispatch = {} +def _delegate(op): + def delegate(self, *args): + return getattr(self._val, op)(*args) + + return delegate + + class IFDRational(Rational): """Implements a rational class where 0/0 is a legal value to match the in the wild use of exif rationals. @@ -391,12 +399,6 @@ class IFDRational(Rational): self._numerator = _numerator self._denominator = _denominator - def _delegate(op): - def delegate(self, *args): - return getattr(self._val, op)(*args) - - return delegate - """ a = ['add','radd', 'sub', 'rsub', 'mul', 'rmul', 'truediv', 'rtruediv', 'floordiv', 'rfloordiv', 'mod','rmod', 'pow','rpow', 'pos', 'neg', @@ -436,7 +438,50 @@ class IFDRational(Rational): __int__ = _delegate("__int__") -class ImageFileDirectory_v2(MutableMapping): +def _register_loader(idx, size): + def decorator(func): + from .TiffTags import TYPES + + if func.__name__.startswith("load_"): + TYPES[idx] = func.__name__[5:].replace("_", " ") + _load_dispatch[idx] = size, func # noqa: F821 + return func + + return decorator + + +def _register_writer(idx): + def decorator(func): + _write_dispatch[idx] = func # noqa: F821 + return func + + return decorator + + +def _register_basic(idx_fmt_name): + from .TiffTags import TYPES + + idx, fmt, name = idx_fmt_name + TYPES[idx] = name + size = struct.calcsize("=" + fmt) + _load_dispatch[idx] = ( # noqa: F821 + size, + lambda self, data, legacy_api=True: ( + self._unpack(f"{len(data) // size}{fmt}", data) + ), + ) + _write_dispatch[idx] = lambda self, *values: ( # noqa: F821 + b"".join(self._pack(fmt, value) for value in values) + ) + + +if TYPE_CHECKING: + _IFDv2Base = MutableMapping[int, Any] # pragma: no cover +else: + _IFDv2Base = MutableMapping + + +class ImageFileDirectory_v2(_IFDv2Base): """This class represents a TIFF tag directory. To speed things up, we don't decode tags unless they're asked for. @@ -497,6 +542,9 @@ class ImageFileDirectory_v2(MutableMapping): """ + _load_dispatch: dict[int, Callable[[ImageFileDirectory_v2, bytes, bool], Any]] = {} + _write_dispatch: dict[int, Callable[..., Any]] = {} + def __init__(self, ifh=b"II\052\0\0\0\0\0", prefix=None, group=None): """Initialize an ImageFileDirectory. @@ -531,7 +579,10 @@ class ImageFileDirectory_v2(MutableMapping): prefix = property(lambda self: self._prefix) offset = property(lambda self: self._offset) - legacy_api = property(lambda self: self._legacy_api) + + @property + def legacy_api(self): + return self._legacy_api @legacy_api.setter def legacy_api(self, value): @@ -674,40 +725,6 @@ class ImageFileDirectory_v2(MutableMapping): def _pack(self, fmt, *values): return struct.pack(self._endian + fmt, *values) - def _register_loader(idx, size): - def decorator(func): - from .TiffTags import TYPES - - if func.__name__.startswith("load_"): - TYPES[idx] = func.__name__[5:].replace("_", " ") - _load_dispatch[idx] = size, func # noqa: F821 - return func - - return decorator - - def _register_writer(idx): - def decorator(func): - _write_dispatch[idx] = func # noqa: F821 - return func - - return decorator - - def _register_basic(idx_fmt_name): - from .TiffTags import TYPES - - idx, fmt, name = idx_fmt_name - TYPES[idx] = name - size = struct.calcsize("=" + fmt) - _load_dispatch[idx] = ( # noqa: F821 - size, - lambda self, data, legacy_api=True: ( - self._unpack(f"{len(data) // size}{fmt}", data) - ), - ) - _write_dispatch[idx] = lambda self, *values: ( # noqa: F821 - b"".join(self._pack(fmt, value) for value in values) - ) - list( map( _register_basic, @@ -995,7 +1012,7 @@ class ImageFileDirectory_v1(ImageFileDirectory_v2): tagdata = property(lambda self: self._tagdata) # defined in ImageFileDirectory_v2 - tagtype: dict + tagtype: dict[int, int] """Dictionary of tag types""" @classmethod @@ -1835,11 +1852,11 @@ def _save(im, fp, filename): tags = list(atts.items()) tags.sort() a = (rawmode, compression, _fp, filename, tags, types) - e = Image._getencoder(im.mode, "libtiff", a, encoderconfig) - e.setimage(im.im, (0, 0) + im.size) + encoder = Image._getencoder(im.mode, "libtiff", a, encoderconfig) + encoder.setimage(im.im, (0, 0) + im.size) while True: # undone, change to self.decodermaxblock: - errcode, data = e.encode(16 * 1024)[1:] + errcode, data = encoder.encode(16 * 1024)[1:] if not _fp: fp.write(data) if errcode: diff --git a/src/PIL/TiffTags.py b/src/PIL/TiffTags.py index 88ff2f4fc..b94193931 100644 --- a/src/PIL/TiffTags.py +++ b/src/PIL/TiffTags.py @@ -22,7 +22,7 @@ from collections import namedtuple class TagInfo(namedtuple("_TagInfo", "value name type length enum")): - __slots__ = [] + __slots__: list[str] = [] def __new__(cls, value=None, name="unknown", type=None, length=None, enum=None): return super().__new__(cls, value, name, type, length, enum or {}) @@ -437,7 +437,7 @@ _populate() ## # Map type numbers to type names -- defined in ImageFileDirectory. -TYPES = {} +TYPES: dict[int, str] = {} # # These tags are handled by default in libtiff, without diff --git a/src/PIL/_imaging.pyi b/src/PIL/_imaging.pyi new file mode 100644 index 000000000..b0235555d --- /dev/null +++ b/src/PIL/_imaging.pyi @@ -0,0 +1,5 @@ +from __future__ import annotations + +from typing import Any + +def __getattr__(name: str) -> Any: ... diff --git a/src/PIL/_tkinter_finder.py b/src/PIL/_tkinter_finder.py index 71c0ad465..beddfb062 100644 --- a/src/PIL/_tkinter_finder.py +++ b/src/PIL/_tkinter_finder.py @@ -5,7 +5,8 @@ from __future__ import annotations import sys import tkinter -from tkinter import _tkinter as tk + +tk = getattr(tkinter, "_tkinter") try: if hasattr(sys, "pypy_find_executable"): diff --git a/src/PIL/_webp.pyi b/src/PIL/_webp.pyi new file mode 100644 index 000000000..b0235555d --- /dev/null +++ b/src/PIL/_webp.pyi @@ -0,0 +1,5 @@ +from __future__ import annotations + +from typing import Any + +def __getattr__(name: str) -> Any: ... diff --git a/tox.ini b/tox.ini index fb6746ce7..8c818df7a 100644 --- a/tox.ini +++ b/tox.ini @@ -33,9 +33,14 @@ commands = [testenv:mypy] skip_install = true deps = + IceSpringPySideStubs-PyQt6 + IceSpringPySideStubs-PySide6 ipython mypy==1.7.1 numpy + packaging + types-cffi + types-defusedxml extras = typing commands = From 517b797132a65a8a873d0c22008d760d2a706ff6 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 10 Feb 2024 20:47:32 +1100 Subject: [PATCH 069/361] Removed FileDescriptor --- src/PIL/GdImageFile.py | 6 ++---- src/PIL/_typing.py | 3 +-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/PIL/GdImageFile.py b/src/PIL/GdImageFile.py index 315ac6d6c..88b87a22c 100644 --- a/src/PIL/GdImageFile.py +++ b/src/PIL/GdImageFile.py @@ -32,7 +32,7 @@ from typing import IO from . import ImageFile, ImagePalette, UnidentifiedImageError from ._binary import i16be as i16 from ._binary import i32be as i32 -from ._typing import FileDescriptor, StrOrBytesPath +from ._typing import StrOrBytesPath class GdImageFile(ImageFile.ImageFile): @@ -81,9 +81,7 @@ class GdImageFile(ImageFile.ImageFile): ] -def open( - fp: StrOrBytesPath | FileDescriptor | IO[bytes], mode: str = "r" -) -> GdImageFile: +def open(fp: StrOrBytesPath | IO[bytes], mode: str = "r") -> GdImageFile: """ Load texture from a GD image file. diff --git a/src/PIL/_typing.py b/src/PIL/_typing.py index 346702037..7075e8672 100644 --- a/src/PIL/_typing.py +++ b/src/PIL/_typing.py @@ -27,8 +27,7 @@ class SupportsRead(Protocol[_T_co]): def read(self, __length: int = ...) -> _T_co: ... -FileDescriptor = int StrOrBytesPath = Union[str, bytes, "os.PathLike[str]", "os.PathLike[bytes]"] -__all__ = ["FileDescriptor", "TypeGuard", "StrOrBytesPath", "SupportsRead"] +__all__ = ["TypeGuard", "StrOrBytesPath", "SupportsRead"] From 430f50606e2fc5620c6605cd43eb360be6bbd655 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 10 Feb 2024 21:33:32 +1100 Subject: [PATCH 070/361] Current delta is determined by previous disposal --- Tests/test_file_gif.py | 19 +++++++++++++++++++ src/PIL/GifImagePlugin.py | 10 +++------- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index 0399c6b67..6527d90de 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -737,6 +737,25 @@ def test_dispose2_background_frame(tmp_path: Path) -> None: assert im.n_frames == 3 +def test_dispose2_previous_frame(tmp_path: Path) -> None: + out = str(tmp_path / "temp.gif") + + im = Image.new("P", (100, 100)) + im.info["transparency"] = 0 + d = ImageDraw.Draw(im) + d.rectangle([(0, 0), (100, 50)], 1) + im.putpalette((0, 0, 0, 255, 0, 0)) + + im2 = Image.new("P", (100, 100)) + im2.putpalette((0, 0, 0)) + + im.save(out, save_all=True, append_images=[im2], disposal=[0, 2]) + + with Image.open(out) as im: + im.seek(1) + assert im.getpixel((0, 0)) == (0, 0, 0, 255) + + def test_transparency_in_second_frame(tmp_path: Path) -> None: out = str(tmp_path / "temp.gif") with Image.open("Tests/images/different_transparency.gif") as im: diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 73a5487d9..9368dd7e7 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -629,7 +629,7 @@ def _write_multiple_frames(im, fp, palette): "duration" ] continue - if encoderinfo.get("disposal") == 2: + if im_frames[-1]["encoderinfo"].get("disposal") == 2: if background_im is None: color = im.encoderinfo.get( "transparency", im.info.get("transparency", (0, 0, 0)) @@ -637,12 +637,8 @@ def _write_multiple_frames(im, fp, palette): background = _get_background(im_frame, color) background_im = Image.new("P", im_frame.size, background) background_im.putpalette(im_frames[0]["im"].palette) - delta, bbox = _getbbox(background_im, im_frame) - if ( - encoderinfo.get("optimize") - and im_frames[-1]["encoderinfo"].get("disposal") != 2 - and im_frame.mode != "1" - ): + bbox = _getbbox(background_im, im_frame)[1] + elif encoderinfo.get("optimize") and im_frame.mode != "1": if "transparency" not in encoderinfo: try: encoderinfo["transparency"] = ( From 68db96981c0819efc51bea995915eaa389a292e7 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Sat, 10 Feb 2024 21:50:48 +1100 Subject: [PATCH 071/361] Removed else Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- src/PIL/Image.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index d32a0fc19..c3ab62174 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -2526,8 +2526,7 @@ class Image: self.load() if self.im.bands == 1: return (self.copy(),) - else: - return tuple(map(self._new, self.im.split())) + return tuple(map(self._new, self.im.split())) def getchannel(self, channel): """ From d02a778efd443db9f69233763f187e14eebde6db Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Sat, 10 Feb 2024 21:57:59 +1100 Subject: [PATCH 072/361] Removed no cover pragmas Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- src/PIL/Image.py | 6 +++--- src/PIL/PdfParser.py | 2 +- src/PIL/TiffImagePlugin.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index c3ab62174..d9d708d5d 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -217,7 +217,7 @@ if hasattr(core, "DEFAULT_STRATEGY"): # Registries if TYPE_CHECKING: - from . import ImageFile # pragma: no cover + from . import ImageFile ID: list[str] = [] OPEN: dict[ str, @@ -2890,7 +2890,7 @@ class ImageTransformHandler: image: Image, **options: dict[str, str | int | tuple[int, ...] | list[int]], ) -> Image: - pass # pragma: no cover + pass # -------------------------------------------------------------------- @@ -3653,7 +3653,7 @@ atexit.register(core.clear_cache) if TYPE_CHECKING: - _ExifBase = MutableMapping[int, Any] # pragma: no cover + _ExifBase = MutableMapping[int, Any] else: _ExifBase = MutableMapping diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index 9aa8dde83..4c5101738 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -246,7 +246,7 @@ class PdfArray(List[Any]): if TYPE_CHECKING: - _DictBase = collections.UserDict[Union[str, bytes], Any] # pragma: no cover + _DictBase = collections.UserDict[Union[str, bytes], Any] else: _DictBase = collections.UserDict diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index af22d76cb..3ba4de9d1 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -476,7 +476,7 @@ def _register_basic(idx_fmt_name): if TYPE_CHECKING: - _IFDv2Base = MutableMapping[int, Any] # pragma: no cover + _IFDv2Base = MutableMapping[int, Any] else: _IFDv2Base = MutableMapping From 8ef0ffc2b849245bde6f96b58b4af48bf498bda7 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 10 Feb 2024 22:37:42 +1100 Subject: [PATCH 073/361] Removed no cover pragma --- src/PIL/ImageShow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/ImageShow.py b/src/PIL/ImageShow.py index c03122c11..d90545e92 100644 --- a/src/PIL/ImageShow.py +++ b/src/PIL/ImageShow.py @@ -184,7 +184,7 @@ class UnixViewer(Viewer): @abc.abstractmethod def get_command_ex(self, file: str, **options: Any) -> tuple[str, str]: - pass # pragma: no cover + pass def get_command(self, file: str, **options: Any) -> str: command = self.get_command_ex(file, **options)[0] From e614bbfe501811bcb4a080ba9f07d745ba3ad231 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 10 Feb 2024 22:39:18 +1100 Subject: [PATCH 074/361] Exclude code only for type checking --- .coveragerc | 1 + 1 file changed, 1 insertion(+) diff --git a/.coveragerc b/.coveragerc index 46df3f90d..115286b74 100644 --- a/.coveragerc +++ b/.coveragerc @@ -10,6 +10,7 @@ exclude_also = if DEBUG: # Don't complain about compatibility code for missing optional dependencies except ImportError + if TYPE_CHECKING: [run] omit = From 112a5a4813f34235530c2ac382108a5cf722789a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 10 Feb 2024 22:40:24 +1100 Subject: [PATCH 075/361] Exclude abstract methods --- .coveragerc | 1 + 1 file changed, 1 insertion(+) diff --git a/.coveragerc b/.coveragerc index 115286b74..ca5f114c6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -11,6 +11,7 @@ exclude_also = # Don't complain about compatibility code for missing optional dependencies except ImportError if TYPE_CHECKING: + @abc.abstractmethod [run] omit = From 3977124908b934a9b037d1e8ba5549393bed9dda Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sat, 10 Feb 2024 14:54:20 +0200 Subject: [PATCH 076/361] Update docs/reference/internal_modules.rst Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- docs/reference/internal_modules.rst | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/reference/internal_modules.rst b/docs/reference/internal_modules.rst index c3cc70060..899e4966f 100644 --- a/docs/reference/internal_modules.rst +++ b/docs/reference/internal_modules.rst @@ -33,10 +33,6 @@ Internal Modules Provides a convenient way to import type hints that are not available on some Python versions. -.. py:class:: FileDescriptor - - Typing alias. - .. py:class:: StrOrBytesPath Typing alias. From 3f6422b512ff39cffaa5a37915c970d7683b6d62 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 12 Feb 2024 09:28:53 +1100 Subject: [PATCH 077/361] Added type hints --- Tests/helper.py | 2 +- Tests/test_image_access.py | 47 ++++++++++++++++++-------------- Tests/test_image_array.py | 10 ++++--- Tests/test_image_draft.py | 7 ++++- Tests/test_image_entropy.py | 2 +- Tests/test_image_filter.py | 27 ++++++++++++------ Tests/test_image_getextrema.py | 2 +- Tests/test_image_getpalette.py | 2 +- Tests/test_image_paste.py | 14 +++++----- Tests/test_image_putdata.py | 6 ++-- Tests/test_image_putpalette.py | 4 +-- Tests/test_image_resample.py | 8 ++++-- Tests/test_image_rotate.py | 14 +++++++--- Tests/test_image_thumbnail.py | 2 +- Tests/test_image_transform.py | 50 ++++++++++++++++++++++++---------- 15 files changed, 124 insertions(+), 73 deletions(-) diff --git a/Tests/helper.py b/Tests/helper.py index 3e2a40e02..b98883946 100644 --- a/Tests/helper.py +++ b/Tests/helper.py @@ -244,7 +244,7 @@ def fromstring(data: bytes) -> Image.Image: return Image.open(BytesIO(data)) -def tostring(im: Image.Image, string_format: str, **options: dict[str, Any]) -> bytes: +def tostring(im: Image.Image, string_format: str, **options: Any) -> bytes: out = BytesIO() im.save(out, string_format, **options) return out.getvalue() diff --git a/Tests/test_image_access.py b/Tests/test_image_access.py index e4cb2dad1..3bdaea750 100644 --- a/Tests/test_image_access.py +++ b/Tests/test_image_access.py @@ -4,6 +4,7 @@ import os import subprocess import sys import sysconfig +from types import ModuleType import pytest @@ -23,6 +24,7 @@ else: except ImportError: cffi = None +numpy: ModuleType | None try: import numpy except ImportError: @@ -71,9 +73,10 @@ class TestImagePutPixel(AccessTest): pix1 = im1.load() pix2 = im2.load() - for x, y in ((0, "0"), ("0", 0)): - with pytest.raises(TypeError): - pix1[x, y] + with pytest.raises(TypeError): + pix1[0, "0"] + with pytest.raises(TypeError): + pix1["0", 0] for y in range(im1.size[1]): for x in range(im1.size[0]): @@ -123,12 +126,13 @@ class TestImagePutPixel(AccessTest): im = hopper() pix = im.load() + assert numpy is not None assert pix[numpy.int32(1), numpy.int32(2)] == (18, 20, 59) class TestImageGetPixel(AccessTest): @staticmethod - def color(mode): + def color(mode: str) -> int | tuple[int, ...]: bands = Image.getmodebands(mode) if bands == 1: return 1 @@ -138,12 +142,13 @@ class TestImageGetPixel(AccessTest): return (16, 32, 49) return tuple(range(1, bands + 1)) - def check(self, mode, expected_color=None) -> None: + def check(self, mode: str, expected_color_int: int | None = None) -> None: if self._need_cffi_access and mode.startswith("BGR;"): pytest.skip("Support not added to deprecated module for BGR;* modes") - if not expected_color: - expected_color = self.color(mode) + expected_color = ( + expected_color_int if expected_color_int is not None else self.color(mode) + ) # check putpixel im = Image.new(mode, (1, 1), None) @@ -222,7 +227,7 @@ class TestImageGetPixel(AccessTest): "YCbCr", ), ) - def test_basic(self, mode) -> None: + def test_basic(self, mode: str) -> None: self.check(mode) def test_list(self) -> None: @@ -231,14 +236,14 @@ class TestImageGetPixel(AccessTest): @pytest.mark.parametrize("mode", ("I;16", "I;16B")) @pytest.mark.parametrize("expected_color", (2**15 - 1, 2**15, 2**15 + 1, 2**16 - 1)) - def test_signedness(self, mode, expected_color) -> None: + def test_signedness(self, mode: str, expected_color: int) -> None: # see https://github.com/python-pillow/Pillow/issues/452 # pixelaccess is using signed int* instead of uint* self.check(mode, expected_color) @pytest.mark.parametrize("mode", ("P", "PA")) @pytest.mark.parametrize("color", ((255, 0, 0), (255, 0, 0, 255))) - def test_p_putpixel_rgb_rgba(self, mode, color) -> None: + def test_p_putpixel_rgb_rgba(self, mode: str, color: tuple[int, ...]) -> None: im = Image.new(mode, (1, 1)) im.putpixel((0, 0), color) @@ -262,7 +267,7 @@ class TestCffiGetPixel(TestImageGetPixel): class TestCffi(AccessTest): _need_cffi_access = True - def _test_get_access(self, im) -> None: + def _test_get_access(self, im: Image.Image) -> None: """Do we get the same thing as the old pixel access Using private interfaces, forcing a capi access and @@ -299,7 +304,7 @@ class TestCffi(AccessTest): # im = Image.new('I;32B', (10, 10), 2**10) # self._test_get_access(im) - def _test_set_access(self, im, color) -> None: + def _test_set_access(self, im: Image.Image, color: tuple[int, ...] | float) -> None: """Are we writing the correct bits into the image? Using private interfaces, forcing a capi access and @@ -359,7 +364,7 @@ class TestCffi(AccessTest): assert px[i, 0] == 0 @pytest.mark.parametrize("mode", ("P", "PA")) - def test_p_putpixel_rgb_rgba(self, mode) -> None: + def test_p_putpixel_rgb_rgba(self, mode: str) -> None: for color in ((255, 0, 0), (255, 0, 0, 127 if mode == "PA" else 255)): im = Image.new(mode, (1, 1)) with pytest.warns(DeprecationWarning): @@ -377,7 +382,7 @@ class TestImagePutPixelError(AccessTest): INVALID_TYPES = ["foo", 1.0, None] @pytest.mark.parametrize("mode", IMAGE_MODES1) - def test_putpixel_type_error1(self, mode) -> None: + def test_putpixel_type_error1(self, mode: str) -> None: im = hopper(mode) for v in self.INVALID_TYPES: with pytest.raises(TypeError, match="color must be int or tuple"): @@ -400,14 +405,16 @@ class TestImagePutPixelError(AccessTest): ), ), ) - def test_putpixel_invalid_number_of_bands(self, mode, band_numbers, match) -> None: + def test_putpixel_invalid_number_of_bands( + self, mode: str, band_numbers: tuple[int, ...], match: str + ) -> None: im = hopper(mode) for band_number in band_numbers: with pytest.raises(TypeError, match=match): im.putpixel((0, 0), (0,) * band_number) @pytest.mark.parametrize("mode", IMAGE_MODES2) - def test_putpixel_type_error2(self, mode) -> None: + def test_putpixel_type_error2(self, mode: str) -> None: im = hopper(mode) for v in self.INVALID_TYPES: with pytest.raises( @@ -416,7 +423,7 @@ class TestImagePutPixelError(AccessTest): im.putpixel((0, 0), v) @pytest.mark.parametrize("mode", IMAGE_MODES1 + IMAGE_MODES2) - def test_putpixel_overflow_error(self, mode) -> None: + def test_putpixel_overflow_error(self, mode: str) -> None: im = hopper(mode) with pytest.raises(OverflowError): im.putpixel((0, 0), 2**80) @@ -428,7 +435,7 @@ class TestEmbeddable: def test_embeddable(self) -> None: import ctypes - from setuptools.command.build_ext import new_compiler + from setuptools.command import build_ext with open("embed_pil.c", "w", encoding="utf-8") as fh: fh.write( @@ -457,7 +464,7 @@ int main(int argc, char* argv[]) % sys.prefix.replace("\\", "\\\\") ) - compiler = new_compiler() + compiler = getattr(build_ext, "new_compiler")() compiler.add_include_dir(sysconfig.get_config_var("INCLUDEPY")) libdir = sysconfig.get_config_var("LIBDIR") or sysconfig.get_config_var( @@ -471,7 +478,7 @@ int main(int argc, char* argv[]) env["PATH"] = sys.prefix + ";" + env["PATH"] # do not display the Windows Error Reporting dialog - ctypes.windll.kernel32.SetErrorMode(0x0002) + getattr(ctypes, "windll").kernel32.SetErrorMode(0x0002) process = subprocess.Popen(["embed_pil.exe"], env=env) process.communicate() diff --git a/Tests/test_image_array.py b/Tests/test_image_array.py index 0125ab56a..cf85ee4fa 100644 --- a/Tests/test_image_array.py +++ b/Tests/test_image_array.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Any + import pytest from packaging.version import parse as parse_version @@ -13,7 +15,7 @@ im = hopper().resize((128, 100)) def test_toarray() -> None: - def test(mode): + def test(mode: str) -> tuple[tuple[int, ...], str, int]: ai = numpy.array(im.convert(mode)) return ai.shape, ai.dtype.str, ai.nbytes @@ -50,14 +52,14 @@ def test_fromarray() -> None: class Wrapper: """Class with API matching Image.fromarray""" - def __init__(self, img, arr_params) -> None: + def __init__(self, img: Image.Image, arr_params: dict[str, Any]) -> None: self.img = img self.__array_interface__ = arr_params - def tobytes(self): + def tobytes(self) -> bytes: return self.img.tobytes() - def test(mode): + def test(mode: str) -> tuple[str, tuple[int, int], bool]: i = im.convert(mode) a = numpy.array(i) # Make wrapper instance for image, new array interface diff --git a/Tests/test_image_draft.py b/Tests/test_image_draft.py index 54474311a..1ce1a7cd8 100644 --- a/Tests/test_image_draft.py +++ b/Tests/test_image_draft.py @@ -7,7 +7,12 @@ from .helper import fromstring, skip_unless_feature, tostring pytestmark = skip_unless_feature("jpg") -def draft_roundtrip(in_mode, in_size, req_mode, req_size): +def draft_roundtrip( + in_mode: str, + in_size: tuple[int, int], + req_mode: str | None, + req_size: tuple[int, int] | None, +) -> Image.Image: im = Image.new(in_mode, in_size) data = tostring(im, "JPEG") im = fromstring(data) diff --git a/Tests/test_image_entropy.py b/Tests/test_image_entropy.py index 01107ae6b..c1dbb879b 100644 --- a/Tests/test_image_entropy.py +++ b/Tests/test_image_entropy.py @@ -4,7 +4,7 @@ from .helper import hopper def test_entropy() -> None: - def entropy(mode): + def entropy(mode: str) -> float: return hopper(mode).entropy() assert round(abs(entropy("1") - 0.9138803254693582), 7) == 0 diff --git a/Tests/test_image_filter.py b/Tests/test_image_filter.py index 2b6787933..6a10ae453 100644 --- a/Tests/test_image_filter.py +++ b/Tests/test_image_filter.py @@ -36,7 +36,7 @@ from .helper import assert_image_equal, hopper ), ) @pytest.mark.parametrize("mode", ("L", "I", "RGB", "CMYK")) -def test_sanity(filter_to_apply, mode) -> None: +def test_sanity(filter_to_apply: ImageFilter.Filter, mode: str) -> None: im = hopper(mode) if mode != "I" or isinstance(filter_to_apply, ImageFilter.BuiltinFilter): out = im.filter(filter_to_apply) @@ -45,7 +45,7 @@ def test_sanity(filter_to_apply, mode) -> None: @pytest.mark.parametrize("mode", ("L", "I", "RGB", "CMYK")) -def test_sanity_error(mode) -> None: +def test_sanity_error(mode: str) -> None: with pytest.raises(TypeError): im = hopper(mode) im.filter("hello") @@ -53,7 +53,7 @@ def test_sanity_error(mode) -> None: # crashes on small images @pytest.mark.parametrize("size", ((1, 1), (2, 2), (3, 3))) -def test_crash(size) -> None: +def test_crash(size: tuple[int, int]) -> None: im = Image.new("RGB", size) im.filter(ImageFilter.SMOOTH) @@ -67,7 +67,10 @@ def test_crash(size) -> None: ("RGB", ((4, 0, 0), (0, 0, 0))), ), ) -def test_modefilter(mode, expected) -> None: +def test_modefilter( + mode: str, + expected: tuple[int, int] | tuple[tuple[int, int, int], tuple[int, int, int]], +) -> None: im = Image.new(mode, (3, 3), None) im.putdata(list(range(9))) # image is: @@ -90,7 +93,13 @@ def test_modefilter(mode, expected) -> None: ("F", (0.0, 4.0, 8.0)), ), ) -def test_rankfilter(mode, expected) -> None: +def test_rankfilter( + mode: str, + expected: ( + tuple[float, float, float] + | tuple[tuple[int, int, int], tuple[int, int, int], tuple[int, int, int]] + ), +) -> None: im = Image.new(mode, (3, 3), None) im.putdata(list(range(9))) # image is: @@ -106,7 +115,7 @@ def test_rankfilter(mode, expected) -> None: @pytest.mark.parametrize( "filter", (ImageFilter.MinFilter, ImageFilter.MedianFilter, ImageFilter.MaxFilter) ) -def test_rankfilter_error(filter) -> None: +def test_rankfilter_error(filter: ImageFilter.RankFilter) -> None: with pytest.raises(ValueError): im = Image.new("P", (3, 3), None) im.putdata(list(range(9))) @@ -137,7 +146,7 @@ def test_kernel_not_enough_coefficients() -> None: @pytest.mark.parametrize("mode", ("L", "LA", "I", "RGB", "CMYK")) -def test_consistency_3x3(mode) -> None: +def test_consistency_3x3(mode: str) -> None: with Image.open("Tests/images/hopper.bmp") as source: reference_name = "hopper_emboss" reference_name += "_I.png" if mode == "I" else ".bmp" @@ -163,7 +172,7 @@ def test_consistency_3x3(mode) -> None: @pytest.mark.parametrize("mode", ("L", "LA", "I", "RGB", "CMYK")) -def test_consistency_5x5(mode) -> None: +def test_consistency_5x5(mode: str) -> None: with Image.open("Tests/images/hopper.bmp") as source: reference_name = "hopper_emboss_more" reference_name += "_I.png" if mode == "I" else ".bmp" @@ -199,7 +208,7 @@ def test_consistency_5x5(mode) -> None: (2, -2), ), ) -def test_invalid_box_blur_filter(radius) -> None: +def test_invalid_box_blur_filter(radius: int | tuple[int, int]) -> None: with pytest.raises(ValueError): ImageFilter.BoxBlur(radius) diff --git a/Tests/test_image_getextrema.py b/Tests/test_image_getextrema.py index 0107fdcc4..a5b974459 100644 --- a/Tests/test_image_getextrema.py +++ b/Tests/test_image_getextrema.py @@ -6,7 +6,7 @@ from .helper import hopper def test_extrema() -> None: - def extrema(mode): + def extrema(mode: str) -> tuple[int, int] | tuple[tuple[int, int], ...]: return hopper(mode).getextrema() assert extrema("1") == (0, 255) diff --git a/Tests/test_image_getpalette.py b/Tests/test_image_getpalette.py index e7304c98f..6a8f157fc 100644 --- a/Tests/test_image_getpalette.py +++ b/Tests/test_image_getpalette.py @@ -6,7 +6,7 @@ from .helper import hopper def test_palette() -> None: - def palette(mode): + def palette(mode: str) -> list[int] | None: p = hopper(mode).getpalette() if p: return p[:10] diff --git a/Tests/test_image_paste.py b/Tests/test_image_paste.py index c4d7a5dd2..ce7345572 100644 --- a/Tests/test_image_paste.py +++ b/Tests/test_image_paste.py @@ -46,7 +46,7 @@ class TestImagingPaste: self.assert_9points_image(im, expected) @CachedProperty - def mask_1(self): + def mask_1(self) -> Image.Image: mask = Image.new("1", (self.size, self.size)) px = mask.load() for y in range(mask.height): @@ -55,11 +55,11 @@ class TestImagingPaste: return mask @CachedProperty - def mask_L(self): + def mask_L(self) -> Image.Image: return self.gradient_L.transpose(Image.Transpose.ROTATE_270) @CachedProperty - def gradient_L(self): + def gradient_L(self) -> Image.Image: gradient = Image.new("L", (self.size, self.size)) px = gradient.load() for y in range(gradient.height): @@ -68,7 +68,7 @@ class TestImagingPaste: return gradient @CachedProperty - def gradient_RGB(self): + def gradient_RGB(self) -> Image.Image: return Image.merge( "RGB", [ @@ -79,7 +79,7 @@ class TestImagingPaste: ) @CachedProperty - def gradient_LA(self): + def gradient_LA(self) -> Image.Image: return Image.merge( "LA", [ @@ -89,7 +89,7 @@ class TestImagingPaste: ) @CachedProperty - def gradient_RGBA(self): + def gradient_RGBA(self) -> Image.Image: return Image.merge( "RGBA", [ @@ -101,7 +101,7 @@ class TestImagingPaste: ) @CachedProperty - def gradient_RGBa(self): + def gradient_RGBa(self) -> Image.Image: return Image.merge( "RGBa", [ diff --git a/Tests/test_image_putdata.py b/Tests/test_image_putdata.py index 103019916..73145faac 100644 --- a/Tests/test_image_putdata.py +++ b/Tests/test_image_putdata.py @@ -31,7 +31,7 @@ def test_sanity() -> None: def test_long_integers() -> None: # see bug-200802-systemerror - def put(value): + def put(value: int) -> tuple[int, int, int, int]: im = Image.new("RGBA", (1, 1)) im.putdata([value]) return im.getpixel((0, 0)) @@ -58,7 +58,7 @@ def test_mode_with_L_with_float() -> None: @pytest.mark.parametrize("mode", ("I", "I;16", "I;16L", "I;16B")) -def test_mode_i(mode) -> None: +def test_mode_i(mode: str) -> None: src = hopper("L") data = list(src.getdata()) im = Image.new(mode, src.size, 0) @@ -79,7 +79,7 @@ def test_mode_F() -> None: @pytest.mark.parametrize("mode", ("BGR;15", "BGR;16", "BGR;24")) -def test_mode_BGR(mode) -> None: +def test_mode_BGR(mode: str) -> None: data = [(16, 32, 49), (32, 32, 98)] im = Image.new(mode, (1, 2)) im.putdata(data) diff --git a/Tests/test_image_putpalette.py b/Tests/test_image_putpalette.py index ffe2551d2..cc7cf58f0 100644 --- a/Tests/test_image_putpalette.py +++ b/Tests/test_image_putpalette.py @@ -8,7 +8,7 @@ from .helper import assert_image_equal, assert_image_equal_tofile, hopper def test_putpalette() -> None: - def palette(mode): + def palette(mode: str) -> str | tuple[str, list[int]]: im = hopper(mode).copy() im.putpalette(list(range(256)) * 3) p = im.getpalette() @@ -81,7 +81,7 @@ def test_putpalette_with_alpha_values() -> None: ("RGBAX", (1, 2, 3, 4, 0)), ), ) -def test_rgba_palette(mode, palette) -> None: +def test_rgba_palette(mode: str, palette: tuple[int, ...]) -> None: im = Image.new("P", (1, 1)) im.putpalette(palette, mode) assert im.getpalette() == [1, 2, 3] diff --git a/Tests/test_image_resample.py b/Tests/test_image_resample.py index f3ec12c05..7090ff9cd 100644 --- a/Tests/test_image_resample.py +++ b/Tests/test_image_resample.py @@ -231,11 +231,13 @@ class TestImagingCoreResampleAccuracy: class TestCoreResampleConsistency: - def make_case(self, mode: str, fill: tuple[int, int, int] | float): + def make_case( + self, mode: str, fill: tuple[int, int, int] | float + ) -> tuple[Image.Image, tuple[int, ...]]: im = Image.new(mode, (512, 9), fill) return im.resize((9, 512), Image.Resampling.LANCZOS), im.load()[0, 0] - def run_case(self, case) -> None: + def run_case(self, case: tuple[Image.Image, Image.Image]) -> None: channel, color = case px = channel.load() for x in range(channel.size[0]): @@ -353,7 +355,7 @@ class TestCoreResampleAlphaCorrect: class TestCoreResamplePasses: @contextmanager - def count(self, diff): + def count(self, diff: int) -> Generator[None, None, None]: count = Image.core.get_stats()["new_count"] yield assert Image.core.get_stats()["new_count"] - count == diff diff --git a/Tests/test_image_rotate.py b/Tests/test_image_rotate.py index 51e0f5854..c10c96da6 100644 --- a/Tests/test_image_rotate.py +++ b/Tests/test_image_rotate.py @@ -12,7 +12,13 @@ from .helper import ( ) -def rotate(im, mode, angle, center=None, translate=None) -> None: +def rotate( + im: Image.Image, + mode: str, + angle: int, + center: tuple[int, int] | None = None, + translate: tuple[int, int] | None = None, +) -> None: out = im.rotate(angle, center=center, translate=translate) assert out.mode == mode assert out.size == im.size # default rotate clips output @@ -27,13 +33,13 @@ def rotate(im, mode, angle, center=None, translate=None) -> None: @pytest.mark.parametrize("mode", ("1", "P", "L", "RGB", "I", "F")) -def test_mode(mode) -> None: +def test_mode(mode: str) -> None: im = hopper(mode) rotate(im, mode, 45) @pytest.mark.parametrize("angle", (0, 90, 180, 270)) -def test_angle(angle) -> None: +def test_angle(angle: int) -> None: with Image.open("Tests/images/test-card.png") as im: rotate(im, im.mode, angle) @@ -42,7 +48,7 @@ def test_angle(angle) -> None: @pytest.mark.parametrize("angle", (0, 45, 90, 180, 270)) -def test_zero(angle) -> None: +def test_zero(angle: int) -> None: im = Image.new("RGB", (0, 0)) rotate(im, im.mode, angle) diff --git a/Tests/test_image_thumbnail.py b/Tests/test_image_thumbnail.py index 6aeeea2ed..2ca1d2cfc 100644 --- a/Tests/test_image_thumbnail.py +++ b/Tests/test_image_thumbnail.py @@ -111,7 +111,7 @@ def test_load_first_unless_jpeg() -> None: with Image.open("Tests/images/hopper.jpg") as im: draft = im.draft - def im_draft(mode, size): + def im_draft(mode: str, size: tuple[int, int]): result = draft(mode, size) assert result is not None diff --git a/Tests/test_image_transform.py b/Tests/test_image_transform.py index 1067dd563..638d12247 100644 --- a/Tests/test_image_transform.py +++ b/Tests/test_image_transform.py @@ -1,6 +1,7 @@ from __future__ import annotations import math +from typing import Callable import pytest @@ -91,7 +92,7 @@ class TestImageTransform: ("LA", (76, 0)), ), ) - def test_fill(self, mode, expected_pixel) -> None: + def test_fill(self, mode: str, expected_pixel: tuple[int, ...]) -> None: im = hopper(mode) (w, h) = im.size transformed = im.transform( @@ -142,7 +143,9 @@ class TestImageTransform: assert_image_equal(blank, transformed.crop((w // 2, 0, w, h // 2))) assert_image_equal(blank, transformed.crop((0, h // 2, w // 2, h))) - def _test_alpha_premult(self, op) -> None: + def _test_alpha_premult( + self, op: Callable[[Image.Image, tuple[int, int]], Image.Image] + ) -> None: # create image with half white, half black, # with the black half transparent. # do op, @@ -159,13 +162,13 @@ class TestImageTransform: assert 40 * 10 == hist[-1] def test_alpha_premult_resize(self) -> None: - def op(im, sz): + def op(im: Image.Image, sz: tuple[int, int]) -> Image.Image: return im.resize(sz, Image.Resampling.BILINEAR) self._test_alpha_premult(op) def test_alpha_premult_transform(self) -> None: - def op(im, sz): + def op(im: Image.Image, sz: tuple[int, int]) -> Image.Image: (w, h) = im.size return im.transform( sz, Image.Transform.EXTENT, (0, 0, w, h), Image.Resampling.BILINEAR @@ -173,7 +176,9 @@ class TestImageTransform: self._test_alpha_premult(op) - def _test_nearest(self, op, mode) -> None: + def _test_nearest( + self, op: Callable[[Image.Image, tuple[int, int]], Image.Image], mode: str + ) -> None: # create white image with half transparent, # do op, # the image should remain white with half transparent @@ -196,15 +201,15 @@ class TestImageTransform: ) @pytest.mark.parametrize("mode", ("RGBA", "LA")) - def test_nearest_resize(self, mode) -> None: - def op(im, sz): + def test_nearest_resize(self, mode: str) -> None: + def op(im: Image.Image, sz: tuple[int, int]) -> Image.Image: return im.resize(sz, Image.Resampling.NEAREST) self._test_nearest(op, mode) @pytest.mark.parametrize("mode", ("RGBA", "LA")) - def test_nearest_transform(self, mode) -> None: - def op(im, sz): + def test_nearest_transform(self, mode: str) -> None: + def op(im: Image.Image, sz: tuple[int, int]) -> Image.Image: (w, h) = im.size return im.transform( sz, Image.Transform.EXTENT, (0, 0, w, h), Image.Resampling.NEAREST @@ -227,7 +232,9 @@ class TestImageTransform: # Running by default, but I'd totally understand not doing it in # the future - pattern = [Image.new("RGBA", (1024, 1024), (a, a, a, a)) for a in range(1, 65)] + pattern: list[Image.Image] | None = [ + Image.new("RGBA", (1024, 1024), (a, a, a, a)) for a in range(1, 65) + ] # Yeah. Watch some JIT optimize this out. pattern = None # noqa: F841 @@ -240,7 +247,7 @@ class TestImageTransform: im.transform((100, 100), None) @pytest.mark.parametrize("resample", (Image.Resampling.BOX, "unknown")) - def test_unknown_resampling_filter(self, resample) -> None: + def test_unknown_resampling_filter(self, resample: Image.Resampling | str) -> None: with hopper() as im: (w, h) = im.size with pytest.raises(ValueError): @@ -250,7 +257,7 @@ class TestImageTransform: class TestImageTransformAffine: transform = Image.Transform.AFFINE - def _test_image(self): + def _test_image(self) -> Image.Image: im = hopper("RGB") return im.crop((10, 20, im.width - 10, im.height - 20)) @@ -263,7 +270,7 @@ class TestImageTransformAffine: (270, Image.Transpose.ROTATE_270), ), ) - def test_rotate(self, deg, transpose) -> None: + def test_rotate(self, deg: int, transpose: Image.Transpose | None) -> None: im = self._test_image() angle = -math.radians(deg) @@ -313,7 +320,13 @@ class TestImageTransformAffine: (Image.Resampling.BICUBIC, 1), ), ) - def test_resize(self, scale, epsilon_scale, resample, epsilon) -> None: + def test_resize( + self, + scale: float, + epsilon_scale: float, + resample: Image.Resampling, + epsilon: int, + ) -> None: im = self._test_image() size_up = int(round(im.width * scale)), int(round(im.height * scale)) @@ -342,7 +355,14 @@ class TestImageTransformAffine: (Image.Resampling.BICUBIC, 1), ), ) - def test_translate(self, x, y, epsilon_scale, resample, epsilon) -> None: + def test_translate( + self, + x: float, + y: float, + epsilon_scale: float, + resample: Image.Resampling, + epsilon: float, + ) -> None: im = self._test_image() size_up = int(round(im.width + x)), int(round(im.height + y)) From ea0240bf2d414ff297c7959f904c252bae696ff3 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Mon, 12 Feb 2024 19:12:08 +1100 Subject: [PATCH 078/361] Use is None Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- Tests/test_image_access.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/test_image_access.py b/Tests/test_image_access.py index 3bdaea750..380b89de8 100644 --- a/Tests/test_image_access.py +++ b/Tests/test_image_access.py @@ -147,7 +147,7 @@ class TestImageGetPixel(AccessTest): pytest.skip("Support not added to deprecated module for BGR;* modes") expected_color = ( - expected_color_int if expected_color_int is not None else self.color(mode) + self.color(mode) if expected_color_int is None else expected_color_int ) # check putpixel From 4ce06aac3bc6aeb0425f78fc691210a839d6d875 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 12 Feb 2024 21:06:17 +1100 Subject: [PATCH 079/361] Added type hints --- Tests/test_color_lut.py | 4 ++++ Tests/test_core_resources.py | 2 +- Tests/test_file_dds.py | 18 ++++++++-------- Tests/test_file_fli.py | 4 ++-- Tests/test_file_gribstub.py | 7 +++--- Tests/test_file_hdf5stub.py | 7 +++--- Tests/test_file_jpeg.py | 22 ++++++++++--------- Tests/test_file_palm.py | 8 +++---- Tests/test_file_tar.py | 2 +- Tests/test_file_webp_metadata.py | 2 ++ Tests/test_image.py | 37 +++++++++++++++++++------------- Tests/test_imagefontctl.py | 14 +++++++----- Tests/test_imagemorph.py | 10 ++++----- Tests/test_imagepalette.py | 2 +- Tests/test_pickle.py | 22 ++++++++++++------- Tests/test_psdraw.py | 4 ++-- Tests/test_sgi_crash.py | 2 +- Tests/test_shell_injection.py | 8 ++++++- Tests/test_tiff_crashes.py | 2 +- Tests/test_tiff_ifdrational.py | 6 +++--- 20 files changed, 108 insertions(+), 75 deletions(-) diff --git a/Tests/test_color_lut.py b/Tests/test_color_lut.py index 2bb1b57d4..c8886a779 100644 --- a/Tests/test_color_lut.py +++ b/Tests/test_color_lut.py @@ -1,6 +1,7 @@ from __future__ import annotations from array import array +from types import ModuleType import pytest @@ -8,6 +9,7 @@ from PIL import Image, ImageFilter from .helper import assert_image_equal +numpy: ModuleType | None try: import numpy except ImportError: @@ -397,6 +399,7 @@ class TestColorLut3DFilter: @pytest.mark.skipif(numpy is None, reason="NumPy not installed") def test_numpy_sources(self) -> None: + assert numpy is not None table = numpy.ones((5, 6, 7, 3), dtype=numpy.float16) with pytest.raises(ValueError, match="should have either channels"): lut = ImageFilter.Color3DLUT((5, 6, 7), table) @@ -430,6 +433,7 @@ class TestColorLut3DFilter: @pytest.mark.skipif(numpy is None, reason="NumPy not installed") def test_numpy_formats(self) -> None: + assert numpy is not None g = Image.linear_gradient("L") im = Image.merge( "RGB", diff --git a/Tests/test_core_resources.py b/Tests/test_core_resources.py index 5eabe8f11..2c1de8bc3 100644 --- a/Tests/test_core_resources.py +++ b/Tests/test_core_resources.py @@ -187,6 +187,6 @@ class TestEnvVars: {"PILLOW_BLOCKS_MAX": "wat"}, ), ) - def test_warnings(self, var) -> None: + def test_warnings(self, var: dict[str, str]) -> None: with pytest.warns(UserWarning): Image._apply_env_variables(var) diff --git a/Tests/test_file_dds.py b/Tests/test_file_dds.py index b78a0dd81..ebc0e89a1 100644 --- a/Tests/test_file_dds.py +++ b/Tests/test_file_dds.py @@ -48,7 +48,7 @@ TEST_FILE_UNCOMPRESSED_RGB_WITH_ALPHA = "Tests/images/uncompressed_rgb.dds" TEST_FILE_DX10_BC1_TYPELESS, ), ) -def test_sanity_dxt1_bc1(image_path) -> None: +def test_sanity_dxt1_bc1(image_path: str) -> None: """Check DXT1 and BC1 images can be opened""" with Image.open(TEST_FILE_DXT1.replace(".dds", ".png")) as target: target = target.convert("RGBA") @@ -96,7 +96,7 @@ def test_sanity_dxt5() -> None: TEST_FILE_BC4U, ), ) -def test_sanity_ati1_bc4u(image_path) -> None: +def test_sanity_ati1_bc4u(image_path: str) -> None: """Check ATI1 and BC4U images can be opened""" with Image.open(image_path) as im: @@ -117,7 +117,7 @@ def test_sanity_ati1_bc4u(image_path) -> None: TEST_FILE_DX10_BC4_TYPELESS, ), ) -def test_dx10_bc4(image_path) -> None: +def test_dx10_bc4(image_path: str) -> None: """Check DX10 BC4 images can be opened""" with Image.open(image_path) as im: @@ -138,7 +138,7 @@ def test_dx10_bc4(image_path) -> None: TEST_FILE_BC5U, ), ) -def test_sanity_ati2_bc5u(image_path) -> None: +def test_sanity_ati2_bc5u(image_path: str) -> None: """Check ATI2 and BC5U images can be opened""" with Image.open(image_path) as im: @@ -162,7 +162,7 @@ def test_sanity_ati2_bc5u(image_path) -> None: (TEST_FILE_BC5S, TEST_FILE_BC5S), ), ) -def test_dx10_bc5(image_path, expected_path) -> None: +def test_dx10_bc5(image_path: str, expected_path: str) -> None: """Check DX10 BC5 images can be opened""" with Image.open(image_path) as im: @@ -176,7 +176,7 @@ def test_dx10_bc5(image_path, expected_path) -> None: @pytest.mark.parametrize("image_path", (TEST_FILE_BC6H, TEST_FILE_BC6HS)) -def test_dx10_bc6h(image_path) -> None: +def test_dx10_bc6h(image_path: str) -> None: """Check DX10 BC6H/BC6HS images can be opened""" with Image.open(image_path) as im: @@ -257,7 +257,7 @@ def test_dx10_r8g8b8a8_unorm_srgb() -> None: ("RGBA", (800, 600), TEST_FILE_UNCOMPRESSED_RGB_WITH_ALPHA), ], ) -def test_uncompressed(mode, size, test_file) -> None: +def test_uncompressed(mode: str, size: tuple[int, int], test_file: str) -> None: """Check uncompressed images can be opened""" with Image.open(test_file) as im: @@ -359,7 +359,7 @@ def test_unsupported_bitcount() -> None: "Tests/images/unimplemented_pfflags.dds", ), ) -def test_not_implemented(test_file) -> None: +def test_not_implemented(test_file: str) -> None: with pytest.raises(NotImplementedError): with Image.open(test_file): pass @@ -381,7 +381,7 @@ def test_save_unsupported_mode(tmp_path: Path) -> None: ("RGBA", "Tests/images/pil123rgba.png"), ], ) -def test_save(mode, test_file, tmp_path: Path) -> None: +def test_save(mode: str, test_file: str, tmp_path: Path) -> None: out = str(tmp_path / "temp.dds") with Image.open(test_file) as im: assert im.mode == mode diff --git a/Tests/test_file_fli.py b/Tests/test_file_fli.py index a673d4af8..fc524721c 100644 --- a/Tests/test_file_fli.py +++ b/Tests/test_file_fli.py @@ -147,7 +147,7 @@ def test_seek() -> None: ], ) @pytest.mark.timeout(timeout=3) -def test_timeouts(test_file) -> None: +def test_timeouts(test_file: str) -> None: with open(test_file, "rb") as f: with Image.open(f) as im: with pytest.raises(OSError): @@ -160,7 +160,7 @@ def test_timeouts(test_file) -> None: "Tests/images/crash-5762152299364352.fli", ], ) -def test_crash(test_file) -> None: +def test_crash(test_file: str) -> None: with open(test_file, "rb") as f: with Image.open(f) as im: with pytest.raises(OSError): diff --git a/Tests/test_file_gribstub.py b/Tests/test_file_gribstub.py index 4945468be..096a5b88b 100644 --- a/Tests/test_file_gribstub.py +++ b/Tests/test_file_gribstub.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +from typing import IO import pytest @@ -55,15 +56,15 @@ def test_handler(tmp_path: Path) -> None: loaded = False saved = False - def open(self, im) -> None: + def open(self, im: Image.Image) -> None: self.opened = True - def load(self, im): + def load(self, im: Image.Image) -> Image.Image: self.loaded = True im.fp.close() return Image.new("RGB", (1, 1)) - def save(self, im, fp, filename) -> None: + def save(self, im: Image.Image, fp: IO[bytes], filename: str) -> None: self.saved = True handler = TestHandler() diff --git a/Tests/test_file_hdf5stub.py b/Tests/test_file_hdf5stub.py index ac3d40bf2..f871e2eff 100644 --- a/Tests/test_file_hdf5stub.py +++ b/Tests/test_file_hdf5stub.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +from typing import IO import pytest @@ -56,15 +57,15 @@ def test_handler(tmp_path: Path) -> None: loaded = False saved = False - def open(self, im) -> None: + def open(self, im: Image.Image) -> None: self.opened = True - def load(self, im): + def load(self, im: Image.Image) -> Image.Image: self.loaded = True im.fp.close() return Image.new("RGB", (1, 1)) - def save(self, im, fp, filename) -> None: + def save(self, im: Image.Image, fp: IO[bytes], filename: str) -> None: self.saved = True handler = TestHandler() diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index 6b0662e0b..654242148 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -5,6 +5,7 @@ import re import warnings from io import BytesIO from pathlib import Path +from types import ModuleType from typing import Any import pytest @@ -33,6 +34,7 @@ from .helper import ( skip_unless_feature, ) +ElementTree: ModuleType | None try: from defusedxml import ElementTree except ImportError: @@ -440,25 +442,25 @@ class TestFileJpeg: for subsampling in (-1, 3): # (default, invalid) im = self.roundtrip(hopper(), subsampling=subsampling) assert getsampling(im) == (2, 2, 1, 1, 1, 1) - for subsampling in (0, "4:4:4"): - im = self.roundtrip(hopper(), subsampling=subsampling) + for subsampling1 in (0, "4:4:4"): + im = self.roundtrip(hopper(), subsampling=subsampling1) assert getsampling(im) == (1, 1, 1, 1, 1, 1) - for subsampling in (1, "4:2:2"): - im = self.roundtrip(hopper(), subsampling=subsampling) + for subsampling1 in (1, "4:2:2"): + im = self.roundtrip(hopper(), subsampling=subsampling1) assert getsampling(im) == (2, 1, 1, 1, 1, 1) - for subsampling in (2, "4:2:0", "4:1:1"): - im = self.roundtrip(hopper(), subsampling=subsampling) + for subsampling1 in (2, "4:2:0", "4:1:1"): + im = self.roundtrip(hopper(), subsampling=subsampling1) assert getsampling(im) == (2, 2, 1, 1, 1, 1) # RGB colorspace - for subsampling in (-1, 0, "4:4:4"): + for subsampling1 in (-1, 0, "4:4:4"): # "4:4:4" doesn't really make sense for RGB, but the conversion # to an integer happens at a higher level - im = self.roundtrip(hopper(), keep_rgb=True, subsampling=subsampling) + im = self.roundtrip(hopper(), keep_rgb=True, subsampling=subsampling1) assert getsampling(im) == (1, 1, 1, 1, 1, 1) - for subsampling in (1, "4:2:2", 2, "4:2:0", 3): + for subsampling1 in (1, "4:2:2", 2, "4:2:0", 3): with pytest.raises(OSError): - self.roundtrip(hopper(), keep_rgb=True, subsampling=subsampling) + self.roundtrip(hopper(), keep_rgb=True, subsampling=subsampling1) with pytest.raises(TypeError): self.roundtrip(hopper(), subsampling="1:1:1") diff --git a/Tests/test_file_palm.py b/Tests/test_file_palm.py index 55041a4b2..194f39b30 100644 --- a/Tests/test_file_palm.py +++ b/Tests/test_file_palm.py @@ -11,7 +11,7 @@ from PIL import Image from .helper import assert_image_equal, hopper, magick_command -def helper_save_as_palm(tmp_path: Path, mode) -> None: +def helper_save_as_palm(tmp_path: Path, mode: str) -> None: # Arrange im = hopper(mode) outfile = str(tmp_path / ("temp_" + mode + ".palm")) @@ -24,7 +24,7 @@ def helper_save_as_palm(tmp_path: Path, mode) -> None: assert os.path.getsize(outfile) > 0 -def open_with_magick(magick, tmp_path: Path, f): +def open_with_magick(magick: list[str], tmp_path: Path, f: str) -> Image.Image: outfile = str(tmp_path / "temp.png") rc = subprocess.call( magick + [f, outfile], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT @@ -33,7 +33,7 @@ def open_with_magick(magick, tmp_path: Path, f): return Image.open(outfile) -def roundtrip(tmp_path: Path, mode) -> None: +def roundtrip(tmp_path: Path, mode: str) -> None: magick = magick_command() if not magick: return @@ -66,6 +66,6 @@ def test_p_mode(tmp_path: Path) -> None: @pytest.mark.parametrize("mode", ("L", "RGB")) -def test_oserror(tmp_path: Path, mode) -> None: +def test_oserror(tmp_path: Path, mode: str) -> None: with pytest.raises(OSError): helper_save_as_palm(tmp_path, mode) diff --git a/Tests/test_file_tar.py b/Tests/test_file_tar.py index 44e78e972..6217ebedd 100644 --- a/Tests/test_file_tar.py +++ b/Tests/test_file_tar.py @@ -19,7 +19,7 @@ TEST_TAR_FILE = "Tests/images/hopper.tar" ("jpg", "hopper.jpg", "JPEG"), ), ) -def test_sanity(codec, test_path, format) -> None: +def test_sanity(codec: str, test_path: str, format: str) -> None: if features.check(codec): with TarIO.TarIO(TEST_TAR_FILE, test_path) as tar: with Image.open(tar) as im: diff --git a/Tests/test_file_webp_metadata.py b/Tests/test_file_webp_metadata.py index fea196941..875941240 100644 --- a/Tests/test_file_webp_metadata.py +++ b/Tests/test_file_webp_metadata.py @@ -2,6 +2,7 @@ from __future__ import annotations from io import BytesIO from pathlib import Path +from types import ModuleType import pytest @@ -14,6 +15,7 @@ pytestmark = [ skip_unless_feature("webp_mux"), ] +ElementTree: ModuleType | None try: from defusedxml import ElementTree except ImportError: diff --git a/Tests/test_image.py b/Tests/test_image.py index 67a7d7eca..75b28c2dc 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -8,6 +8,7 @@ import sys import tempfile import warnings from pathlib import Path +from typing import IO import pytest @@ -61,11 +62,11 @@ class TestImage: "HSV", ), ) - def test_image_modes_success(self, mode) -> None: + def test_image_modes_success(self, mode: str) -> None: Image.new(mode, (1, 1)) @pytest.mark.parametrize("mode", ("", "bad", "very very long")) - def test_image_modes_fail(self, mode) -> None: + def test_image_modes_fail(self, mode: str) -> None: with pytest.raises(ValueError) as e: Image.new(mode, (1, 1)) assert str(e.value) == "unrecognized image mode" @@ -100,7 +101,7 @@ class TestImage: def test_repr_pretty(self) -> None: class Pretty: - def text(self, text) -> None: + def text(self, text: str) -> None: self.pretty_output = text im = Image.new("L", (100, 100)) @@ -184,7 +185,9 @@ class TestImage: temp_file = str(tmp_path / "temp.jpg") class FP: - def write(self, b) -> None: + name: str + + def write(self, b: bytes) -> None: pass fp = FP() @@ -538,7 +541,7 @@ class TestImage: "PILLOW_VALGRIND_TEST" in os.environ, reason="Valgrind is slower" ) @pytest.mark.parametrize("size", ((0, 100000000), (100000000, 0))) - def test_empty_image(self, size) -> None: + def test_empty_image(self, size: tuple[int, int]) -> None: Image.new("RGB", size) def test_storage_neg(self) -> None: @@ -565,7 +568,7 @@ class TestImage: Image.linear_gradient(wrong_mode) @pytest.mark.parametrize("mode", ("L", "P", "I", "F")) - def test_linear_gradient(self, mode) -> None: + def test_linear_gradient(self, mode: str) -> None: # Arrange target_file = "Tests/images/linear_gradient.png" @@ -590,7 +593,7 @@ class TestImage: Image.radial_gradient(wrong_mode) @pytest.mark.parametrize("mode", ("L", "P", "I", "F")) - def test_radial_gradient(self, mode) -> None: + def test_radial_gradient(self, mode: str) -> None: # Arrange target_file = "Tests/images/radial_gradient.png" @@ -665,7 +668,11 @@ class TestImage: blank_p.palette = None blank_pa.palette = None - def _make_new(base_image, image, palette_result=None) -> None: + def _make_new( + base_image: Image.Image, + image: Image.Image, + palette_result: ImagePalette.ImagePalette | None = None, + ) -> None: new_image = base_image._new(image.im) assert new_image.mode == image.mode assert new_image.size == image.size @@ -713,7 +720,7 @@ class TestImage: def test_load_on_nonexclusive_multiframe(self) -> None: with open("Tests/images/frozenpond.mpo", "rb") as fp: - def act(fp) -> None: + def act(fp: IO[bytes]) -> None: im = Image.open(fp) im.load() @@ -906,12 +913,12 @@ class TestImage: assert exif.get_ifd(0xA005) @pytest.mark.parametrize("size", ((1, 0), (0, 1), (0, 0))) - def test_zero_tobytes(self, size) -> None: + def test_zero_tobytes(self, size: tuple[int, int]) -> None: im = Image.new("RGB", size) assert im.tobytes() == b"" @pytest.mark.parametrize("size", ((1, 0), (0, 1), (0, 0))) - def test_zero_frombytes(self, size) -> None: + def test_zero_frombytes(self, size: tuple[int, int]) -> None: Image.frombytes("RGB", size, b"") im = Image.new("RGB", size) @@ -996,7 +1003,7 @@ class TestImage: "01r_00.pcx", ], ) - def test_overrun(self, path) -> None: + def test_overrun(self, path: str) -> None: """For overrun completeness, test as: valgrind pytest -qq Tests/test_image.py::TestImage::test_overrun | grep decode.c """ @@ -1023,7 +1030,7 @@ class TestImage: pass assert not hasattr(im, "fp") - def test_close_graceful(self, caplog) -> None: + def test_close_graceful(self, caplog: pytest.LogCaptureFixture) -> None: with Image.open("Tests/images/hopper.jpg") as im: copy = im.copy() with caplog.at_level(logging.DEBUG): @@ -1034,10 +1041,10 @@ class TestImage: class MockEncoder: - pass + args: tuple[str, ...] -def mock_encode(*args): +def mock_encode(*args: str) -> MockEncoder: encoder = MockEncoder() encoder.args = args return encoder diff --git a/Tests/test_imagefontctl.py b/Tests/test_imagefontctl.py index 325e7ef21..24c7b871a 100644 --- a/Tests/test_imagefontctl.py +++ b/Tests/test_imagefontctl.py @@ -208,7 +208,9 @@ def test_language() -> None: ), ids=("None", "ltr", "rtl2", "rtl", "ttb"), ) -def test_getlength(mode, text, direction, expected) -> None: +def test_getlength( + mode: str, text: str, direction: str | None, expected: float +) -> None: ttf = ImageFont.truetype(FONT_PATH, FONT_SIZE) im = Image.new(mode, (1, 1), 0) d = ImageDraw.Draw(im) @@ -230,7 +232,7 @@ def test_getlength(mode, text, direction, expected) -> None: ("i" + ("\u030C" * 15) + "i", "i" + "\u032C" * 15 + "i", "\u035Cii", "i\u0305i"), ids=("caron-above", "caron-below", "double-breve", "overline"), ) -def test_getlength_combine(mode, direction, text) -> None: +def test_getlength_combine(mode: str, direction: str, text: str) -> None: if text == "i\u0305i" and direction == "ttb": pytest.skip("fails with this font") @@ -250,7 +252,7 @@ def test_getlength_combine(mode, direction, text) -> None: @pytest.mark.parametrize("anchor", ("lt", "mm", "rb", "sm")) -def test_anchor_ttb(anchor) -> None: +def test_anchor_ttb(anchor: str) -> None: text = "f" path = f"Tests/images/test_anchor_ttb_{text}_{anchor}.png" f = ImageFont.truetype("Tests/fonts/NotoSans-Regular.ttf", 120) @@ -306,7 +308,9 @@ combine_tests = ( @pytest.mark.parametrize( "name, text, anchor, dir, epsilon", combine_tests, ids=[r[0] for r in combine_tests] ) -def test_combine(name, text, dir, anchor, epsilon) -> None: +def test_combine( + name: str, text: str, dir: str | None, anchor: str | None, epsilon: float +) -> None: path = f"Tests/images/test_combine_{name}.png" f = ImageFont.truetype("Tests/fonts/NotoSans-Regular.ttf", 48) @@ -337,7 +341,7 @@ def test_combine(name, text, dir, anchor, epsilon) -> None: ("rm", "right"), # pass with getsize ), ) -def test_combine_multiline(anchor, align) -> None: +def test_combine_multiline(anchor: str, align: str) -> None: # test that multiline text uses getlength, not getsize or getbbox path = f"Tests/images/test_combine_multiline_{anchor}_{align}.png" diff --git a/Tests/test_imagemorph.py b/Tests/test_imagemorph.py index 0b0c6d2d3..46b473d7a 100644 --- a/Tests/test_imagemorph.py +++ b/Tests/test_imagemorph.py @@ -10,7 +10,7 @@ from PIL import Image, ImageMorph, _imagingmorph from .helper import assert_image_equal_tofile, hopper -def string_to_img(image_string): +def string_to_img(image_string: str) -> Image.Image: """Turn a string image representation into a binary image""" rows = [s for s in image_string.replace(" ", "").split("\n") if len(s)] height = len(rows) @@ -38,7 +38,7 @@ A = string_to_img( ) -def img_to_string(im): +def img_to_string(im: Image.Image) -> str: """Turn a (small) binary image into a string representation""" chars = ".1" width, height = im.size @@ -48,11 +48,11 @@ def img_to_string(im): ) -def img_string_normalize(im): +def img_string_normalize(im: str) -> str: return img_to_string(string_to_img(im)) -def assert_img_equal_img_string(a, b_string) -> None: +def assert_img_equal_img_string(a: Image.Image, b_string: str) -> None: assert img_to_string(a) == img_string_normalize(b_string) @@ -63,7 +63,7 @@ def test_str_to_img() -> None: @pytest.mark.parametrize( "op", ("corner", "dilation4", "dilation8", "erosion4", "erosion8", "edge") ) -def test_lut(op) -> None: +def test_lut(op: str) -> None: lb = ImageMorph.LutBuilder(op_name=op) assert lb.get_lut() is None diff --git a/Tests/test_imagepalette.py b/Tests/test_imagepalette.py index 545229500..8e2db15aa 100644 --- a/Tests/test_imagepalette.py +++ b/Tests/test_imagepalette.py @@ -67,7 +67,7 @@ def test_getcolor_rgba_color_rgb_palette() -> None: (255, ImagePalette.ImagePalette("RGB", list(range(256)) * 3)), ], ) -def test_getcolor_not_special(index, palette) -> None: +def test_getcolor_not_special(index: int, palette: ImagePalette.ImagePalette) -> None: im = Image.new("P", (1, 1)) # Do not use transparency index as a new color diff --git a/Tests/test_pickle.py b/Tests/test_pickle.py index 560cdbd35..ed415953f 100644 --- a/Tests/test_pickle.py +++ b/Tests/test_pickle.py @@ -13,7 +13,9 @@ FONT_SIZE = 20 FONT_PATH = "Tests/fonts/DejaVuSans/DejaVuSans.ttf" -def helper_pickle_file(tmp_path: Path, pickle, protocol, test_file, mode) -> None: +def helper_pickle_file( + tmp_path: Path, protocol: int, test_file: str, mode: str | None +) -> None: # Arrange with Image.open(test_file) as im: filename = str(tmp_path / "temp.pkl") @@ -30,7 +32,7 @@ def helper_pickle_file(tmp_path: Path, pickle, protocol, test_file, mode) -> Non assert im == loaded_im -def helper_pickle_string(pickle, protocol, test_file, mode) -> None: +def helper_pickle_string(protocol: int, test_file: str, mode: str | None) -> None: with Image.open(test_file) as im: if mode: im = im.convert(mode) @@ -64,10 +66,12 @@ def helper_pickle_string(pickle, protocol, test_file, mode) -> None: ], ) @pytest.mark.parametrize("protocol", range(0, pickle.HIGHEST_PROTOCOL + 1)) -def test_pickle_image(tmp_path: Path, test_file, test_mode, protocol) -> None: +def test_pickle_image( + tmp_path: Path, test_file: str, test_mode: str | None, protocol: int +) -> None: # Act / Assert - helper_pickle_string(pickle, protocol, test_file, test_mode) - helper_pickle_file(tmp_path, pickle, protocol, test_file, test_mode) + helper_pickle_string(protocol, test_file, test_mode) + helper_pickle_file(tmp_path, protocol, test_file, test_mode) def test_pickle_la_mode_with_palette(tmp_path: Path) -> None: @@ -99,7 +103,9 @@ def test_pickle_tell() -> None: assert unpickled_image.tell() == 0 -def helper_assert_pickled_font_images(font1, font2) -> None: +def helper_assert_pickled_font_images( + font1: ImageFont.FreeTypeFont, font2: ImageFont.FreeTypeFont +) -> None: # Arrange im1 = Image.new(mode="RGBA", size=(300, 100)) im2 = Image.new(mode="RGBA", size=(300, 100)) @@ -117,7 +123,7 @@ def helper_assert_pickled_font_images(font1, font2) -> None: @skip_unless_feature("freetype2") @pytest.mark.parametrize("protocol", list(range(0, pickle.HIGHEST_PROTOCOL + 1))) -def test_pickle_font_string(protocol) -> None: +def test_pickle_font_string(protocol: int) -> None: # Arrange font = ImageFont.truetype(FONT_PATH, FONT_SIZE) @@ -131,7 +137,7 @@ def test_pickle_font_string(protocol) -> None: @skip_unless_feature("freetype2") @pytest.mark.parametrize("protocol", list(range(0, pickle.HIGHEST_PROTOCOL + 1))) -def test_pickle_font_file(tmp_path: Path, protocol) -> None: +def test_pickle_font_file(tmp_path: Path, protocol: int) -> None: # Arrange font = ImageFont.truetype(FONT_PATH, FONT_SIZE) filename = str(tmp_path / "temp.pkl") diff --git a/Tests/test_psdraw.py b/Tests/test_psdraw.py index 797539f35..64dfb2c95 100644 --- a/Tests/test_psdraw.py +++ b/Tests/test_psdraw.py @@ -10,7 +10,7 @@ import pytest from PIL import Image, PSDraw -def _create_document(ps) -> None: +def _create_document(ps: PSDraw.PSDraw) -> None: title = "hopper" box = (1 * 72, 2 * 72, 7 * 72, 10 * 72) # in points @@ -50,7 +50,7 @@ def test_draw_postscript(tmp_path: Path) -> None: @pytest.mark.parametrize("buffer", (True, False)) -def test_stdout(buffer) -> None: +def test_stdout(buffer: bool) -> None: # Temporarily redirect stdout old_stdout = sys.stdout diff --git a/Tests/test_sgi_crash.py b/Tests/test_sgi_crash.py index 9442801d0..3ce31cd2d 100644 --- a/Tests/test_sgi_crash.py +++ b/Tests/test_sgi_crash.py @@ -21,7 +21,7 @@ from PIL import Image "Tests/images/crash-db8bfa78b19721225425530c5946217720d7df4e.sgi", ], ) -def test_crashes(test_file) -> None: +def test_crashes(test_file: str) -> None: with open(test_file, "rb") as f: with Image.open(f) as im: with pytest.raises(OSError): diff --git a/Tests/test_shell_injection.py b/Tests/test_shell_injection.py index 3db0660ea..2a072fd44 100644 --- a/Tests/test_shell_injection.py +++ b/Tests/test_shell_injection.py @@ -2,6 +2,7 @@ from __future__ import annotations import shutil from pathlib import Path +from typing import Callable import pytest @@ -17,7 +18,12 @@ test_filenames = ("temp_';", 'temp_";', "temp_'\"|", "temp_'\"||", "temp_'\"&&") @pytest.mark.skipif(is_win32(), reason="Requires Unix or macOS") class TestShellInjection: - def assert_save_filename_check(self, tmp_path: Path, src_img, save_func) -> None: + def assert_save_filename_check( + self, + tmp_path: Path, + src_img: Image.Image, + save_func: Callable[[Image.Image, int, str], None], + ) -> None: for filename in test_filenames: dest_file = str(tmp_path / filename) save_func(src_img, 0, dest_file) diff --git a/Tests/test_tiff_crashes.py b/Tests/test_tiff_crashes.py index 64e781cba..f51e8b3a8 100644 --- a/Tests/test_tiff_crashes.py +++ b/Tests/test_tiff_crashes.py @@ -42,7 +42,7 @@ from .helper import on_ci @pytest.mark.filterwarnings("ignore:Possibly corrupt EXIF data") @pytest.mark.filterwarnings("ignore:Metadata warning") @pytest.mark.filterwarnings("ignore:Truncated File Read") -def test_tiff_crashes(test_file): +def test_tiff_crashes(test_file: str) -> None: try: with Image.open(test_file) as im: im.load() diff --git a/Tests/test_tiff_ifdrational.py b/Tests/test_tiff_ifdrational.py index 536854523..f6adae3e6 100644 --- a/Tests/test_tiff_ifdrational.py +++ b/Tests/test_tiff_ifdrational.py @@ -53,9 +53,9 @@ def test_nonetype() -> None: def test_ifd_rational_save(tmp_path: Path) -> None: - methods = (True, False) - if not features.check("libtiff"): - methods = (False,) + methods = [True] + if features.check("libtiff"): + methods.append(False) for libtiff in methods: TiffImagePlugin.WRITE_LIBTIFF = libtiff From 47eaf0937f8e4f10fce8473007aba449c0c280f7 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 13 Feb 2024 22:26:23 +1100 Subject: [PATCH 080/361] Use IO[bytes] in type hints --- src/PIL/ImageFile.py | 4 ++-- src/PIL/MspImagePlugin.py | 3 ++- src/PIL/PcxImagePlugin.py | 3 ++- src/PIL/PpmImagePlugin.py | 4 ++-- src/PIL/SgiImagePlugin.py | 4 ++-- src/PIL/TgaImagePlugin.py | 4 ++-- src/PIL/XbmImagePlugin.py | 4 ++-- 7 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index 487f53efe..e929b665e 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -32,7 +32,7 @@ import io import itertools import struct import sys -from typing import Any, NamedTuple +from typing import IO, Any, NamedTuple from . import Image from ._deprecate import deprecate @@ -616,7 +616,7 @@ class PyCodecState: class PyCodec: - fd: io.BytesIO | None + fd: IO[bytes] | None def __init__(self, mode, *args): self.im = None diff --git a/src/PIL/MspImagePlugin.py b/src/PIL/MspImagePlugin.py index bb7e466a7..65cc70624 100644 --- a/src/PIL/MspImagePlugin.py +++ b/src/PIL/MspImagePlugin.py @@ -26,6 +26,7 @@ from __future__ import annotations import io import struct +from typing import IO from . import Image, ImageFile from ._binary import i16le as i16 @@ -163,7 +164,7 @@ Image.register_decoder("MSP", MspDecoder) # write MSP files (uncompressed only) -def _save(im: Image.Image, fp: io.BytesIO, filename: str) -> None: +def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: if im.mode != "1": msg = f"cannot write mode {im.mode} as MSP" raise OSError(msg) diff --git a/src/PIL/PcxImagePlugin.py b/src/PIL/PcxImagePlugin.py index 3e0968a83..026bfd9a0 100644 --- a/src/PIL/PcxImagePlugin.py +++ b/src/PIL/PcxImagePlugin.py @@ -28,6 +28,7 @@ from __future__ import annotations import io import logging +from typing import IO from . import Image, ImageFile, ImagePalette from ._binary import i16le as i16 @@ -143,7 +144,7 @@ SAVE = { } -def _save(im: Image.Image, fp: io.BytesIO, filename: str) -> None: +def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: try: version, bits, planes, rawmode = SAVE[im.mode] except KeyError as e: diff --git a/src/PIL/PpmImagePlugin.py b/src/PIL/PpmImagePlugin.py index 3e45ba95c..6ac7a9bbc 100644 --- a/src/PIL/PpmImagePlugin.py +++ b/src/PIL/PpmImagePlugin.py @@ -16,7 +16,7 @@ from __future__ import annotations import math -from io import BytesIO +from typing import IO from . import Image, ImageFile from ._binary import i16be as i16 @@ -324,7 +324,7 @@ class PpmDecoder(ImageFile.PyDecoder): # -------------------------------------------------------------------- -def _save(im: Image.Image, fp: BytesIO, filename: str) -> None: +def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: if im.mode == "1": rawmode, head = "1;I", b"P4" elif im.mode == "L": diff --git a/src/PIL/SgiImagePlugin.py b/src/PIL/SgiImagePlugin.py index ccf661ff1..7bd84ebd4 100644 --- a/src/PIL/SgiImagePlugin.py +++ b/src/PIL/SgiImagePlugin.py @@ -24,7 +24,7 @@ from __future__ import annotations import os import struct -from io import BytesIO +from typing import IO from . import Image, ImageFile from ._binary import i16be as i16 @@ -125,7 +125,7 @@ class SgiImageFile(ImageFile.ImageFile): ] -def _save(im: Image.Image, fp: BytesIO, filename: str) -> None: +def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: if im.mode not in {"RGB", "RGBA", "L"}: msg = "Unsupported SGI image mode" raise ValueError(msg) diff --git a/src/PIL/TgaImagePlugin.py b/src/PIL/TgaImagePlugin.py index 584932d2c..828701342 100644 --- a/src/PIL/TgaImagePlugin.py +++ b/src/PIL/TgaImagePlugin.py @@ -18,7 +18,7 @@ from __future__ import annotations import warnings -from io import BytesIO +from typing import IO from . import Image, ImageFile, ImagePalette from ._binary import i16le as i16 @@ -175,7 +175,7 @@ SAVE = { } -def _save(im: Image.Image, fp: BytesIO, filename: str) -> None: +def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: try: rawmode, bits, colormaptype, imagetype = SAVE[im.mode] except KeyError as e: diff --git a/src/PIL/XbmImagePlugin.py b/src/PIL/XbmImagePlugin.py index 0291e2858..eee727436 100644 --- a/src/PIL/XbmImagePlugin.py +++ b/src/PIL/XbmImagePlugin.py @@ -21,7 +21,7 @@ from __future__ import annotations import re -from io import BytesIO +from typing import IO from . import Image, ImageFile @@ -70,7 +70,7 @@ class XbmImageFile(ImageFile.ImageFile): self.tile = [("xbm", (0, 0) + self.size, m.end(), None)] -def _save(im: Image.Image, fp: BytesIO, filename: str) -> None: +def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: if im.mode != "1": msg = f"cannot write mode {im.mode} as XBM" raise OSError(msg) From 63987b7abaf6907a8985b867be154debfef0ec1b Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 14 Feb 2024 06:55:13 +1100 Subject: [PATCH 081/361] Set mode to L if palette is missing --- Tests/test_file_tga.py | 5 +++++ src/PIL/TgaImagePlugin.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Tests/test_file_tga.py b/Tests/test_file_tga.py index bd8e522c7..87a59ff3d 100644 --- a/Tests/test_file_tga.py +++ b/Tests/test_file_tga.py @@ -133,6 +133,11 @@ def test_small_palette(tmp_path: Path) -> None: assert reloaded.getpalette() == colors +def test_missing_palette() -> None: + with Image.open("Tests/images/dilation4.lut") as im: + assert im.mode == "L" + + def test_save_wrong_mode(tmp_path: Path) -> None: im = hopper("PA") out = str(tmp_path / "temp.tga") diff --git a/src/PIL/TgaImagePlugin.py b/src/PIL/TgaImagePlugin.py index 584932d2c..5d4827525 100644 --- a/src/PIL/TgaImagePlugin.py +++ b/src/PIL/TgaImagePlugin.py @@ -85,7 +85,7 @@ class TgaImageFile(ImageFile.ImageFile): elif depth == 16: self._mode = "LA" elif imagetype in (1, 9): - self._mode = "P" + self._mode = "P" if colormaptype else "L" elif imagetype in (2, 10): self._mode = "RGB" if depth == 32: From 818500b329555969cdb852c81d667cc70faaed94 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 14 Feb 2024 07:10:44 +1100 Subject: [PATCH 082/361] Raise an error if map depth is unknown --- Tests/images/p_8.tga | Bin 0 -> 18 bytes Tests/test_file_tga.py | 7 ++++++- src/PIL/TgaImagePlugin.py | 3 +++ 3 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 Tests/images/p_8.tga diff --git a/Tests/images/p_8.tga b/Tests/images/p_8.tga new file mode 100644 index 0000000000000000000000000000000000000000..73759a2822419fd38134be80acf7918c0a437e43 GIT binary patch literal 18 Wcmb1RWMp7qVB`Q23=KfY!2kdU!vQh? literal 0 HcmV?d00001 diff --git a/Tests/test_file_tga.py b/Tests/test_file_tga.py index 87a59ff3d..75c592da2 100644 --- a/Tests/test_file_tga.py +++ b/Tests/test_file_tga.py @@ -7,7 +7,7 @@ from pathlib import Path import pytest -from PIL import Image +from PIL import Image, UnidentifiedImageError from .helper import assert_image_equal, assert_image_equal_tofile, hopper @@ -65,6 +65,11 @@ def test_sanity(mode, tmp_path: Path) -> None: roundtrip(original_im) +def test_palette_depth_8(tmp_path: Path) -> None: + with pytest.raises(UnidentifiedImageError): + Image.open("Tests/images/p_8.tga") + + def test_palette_depth_16(tmp_path: Path) -> None: with Image.open("Tests/images/p_16.tga") as im: assert_image_equal_tofile(im.convert("RGB"), "Tests/images/p_16.png") diff --git a/src/PIL/TgaImagePlugin.py b/src/PIL/TgaImagePlugin.py index 5d4827525..b6748c25e 100644 --- a/src/PIL/TgaImagePlugin.py +++ b/src/PIL/TgaImagePlugin.py @@ -128,6 +128,9 @@ class TgaImageFile(ImageFile.ImageFile): self.palette = ImagePalette.raw( "BGRA", b"\0" * 4 * start + self.fp.read(4 * size) ) + else: + msg = "unknown TGA map depth" + raise SyntaxError(msg) # setup tile descriptor try: From 21e5d5d082dfe47fda2779e10d446e6593c04dd8 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 14 Feb 2024 09:17:22 +1100 Subject: [PATCH 083/361] Use palette when loading --- Tests/test_file_ico.py | 11 +++++++++++ src/PIL/IcoImagePlugin.py | 2 ++ 2 files changed, 13 insertions(+) diff --git a/Tests/test_file_ico.py b/Tests/test_file_ico.py index 65f090931..e75561f69 100644 --- a/Tests/test_file_ico.py +++ b/Tests/test_file_ico.py @@ -38,6 +38,17 @@ def test_black_and_white() -> None: assert im.size == (16, 16) +def test_palette(tmp_path: Path) -> None: + temp_file = str(tmp_path / "temp.ico") + + im = Image.new("P", (16, 16)) + im.save(temp_file) + + with Image.open(temp_file) as reloaded: + assert reloaded.mode == "P" + assert reloaded.palette is not None + + def test_invalid_file() -> None: with open("Tests/images/flower.jpg", "rb") as fp: with pytest.raises(SyntaxError): diff --git a/src/PIL/IcoImagePlugin.py b/src/PIL/IcoImagePlugin.py index 1b22f8645..b558fdf34 100644 --- a/src/PIL/IcoImagePlugin.py +++ b/src/PIL/IcoImagePlugin.py @@ -329,6 +329,8 @@ class IcoImageFile(ImageFile.ImageFile): self.im = im.im self.pyaccess = None self._mode = im.mode + if im.palette: + self.palette = im.palette if im.size != self.size: warnings.warn("Image was not the expected size") From 3199c0ea40c041d41fe2499c86893a7e795f0929 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 15 Feb 2024 20:20:42 +1100 Subject: [PATCH 084/361] Decoder and encoders subclass PyDecoder and PyEncoder --- Tests/test_file_jpeg.py | 8 +----- Tests/test_image.py | 16 ++++------- Tests/test_imagefile.py | 64 ++++++++++++++++++++--------------------- src/PIL/Image.py | 14 ++++----- 4 files changed, 45 insertions(+), 57 deletions(-) diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index 654242148..4858d92e6 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -986,13 +986,7 @@ class TestFileJpeg: def decode(self, buffer: bytes) -> tuple[int, int]: return 0, 0 - decoder = InfiniteMockPyDecoder(None) - - def closure(mode: str, *args) -> InfiniteMockPyDecoder: - decoder.__init__(mode, *args) - return decoder - - Image.register_decoder("INFINITE", closure) + Image.register_decoder("INFINITE", InfiniteMockPyDecoder) with Image.open(TEST_FILE) as im: im.tile = [ diff --git a/Tests/test_image.py b/Tests/test_image.py index 4c04e0da4..aae51eaa4 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -16,6 +16,7 @@ from PIL import ( ExifTags, Image, ImageDraw, + ImageFile, ImagePalette, UnidentifiedImageError, features, @@ -1038,25 +1039,20 @@ class TestImage: assert im.fp is None -class MockEncoder: - args: tuple[str, ...] - - -def mock_encode(*args: str) -> MockEncoder: - encoder = MockEncoder() - encoder.args = args - return encoder +class MockEncoder(ImageFile.PyEncoder): + pass class TestRegistry: def test_encode_registry(self) -> None: - Image.register_encoder("MOCK", mock_encode) + Image.register_encoder("MOCK", MockEncoder) assert "MOCK" in Image.ENCODERS enc = Image._getencoder("RGB", "MOCK", ("args",), extra=("extra",)) assert isinstance(enc, MockEncoder) - assert enc.args == ("RGB", "args", "extra") + assert enc.mode == "RGB" + assert enc.args == ("args", "extra") def test_encode_registry_fail(self) -> None: with pytest.raises(OSError): diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py index 491409781..cf251c9ce 100644 --- a/Tests/test_imagefile.py +++ b/Tests/test_imagefile.py @@ -1,6 +1,7 @@ from __future__ import annotations from io import BytesIO +from typing import Any import pytest @@ -201,12 +202,22 @@ class TestImageFile: class MockPyDecoder(ImageFile.PyDecoder): + def __init__(self, mode: str, *args: Any) -> None: + MockPyDecoder.last = self + + super().__init__(mode, *args) + def decode(self, buffer): # eof return -1, 0 class MockPyEncoder(ImageFile.PyEncoder): + def __init__(self, mode: str, *args: Any) -> None: + MockPyEncoder.last = self + + super().__init__(mode, *args) + def encode(self, buffer): return 1, 1, b"" @@ -228,19 +239,8 @@ class MockImageFile(ImageFile.ImageFile): class CodecsTest: @classmethod def setup_class(cls) -> None: - cls.decoder = MockPyDecoder(None) - cls.encoder = MockPyEncoder(None) - - def decoder_closure(mode, *args): - cls.decoder.__init__(mode, *args) - return cls.decoder - - def encoder_closure(mode, *args): - cls.encoder.__init__(mode, *args) - return cls.encoder - - Image.register_decoder("MOCK", decoder_closure) - Image.register_encoder("MOCK", encoder_closure) + Image.register_decoder("MOCK", MockPyDecoder) + Image.register_encoder("MOCK", MockPyEncoder) class TestPyDecoder(CodecsTest): @@ -251,13 +251,13 @@ class TestPyDecoder(CodecsTest): im.load() - assert self.decoder.state.xoff == xoff - assert self.decoder.state.yoff == yoff - assert self.decoder.state.xsize == xsize - assert self.decoder.state.ysize == ysize + assert MockPyDecoder.last.state.xoff == xoff + assert MockPyDecoder.last.state.yoff == yoff + assert MockPyDecoder.last.state.xsize == xsize + assert MockPyDecoder.last.state.ysize == ysize with pytest.raises(ValueError): - self.decoder.set_as_raw(b"\x00") + MockPyDecoder.last.set_as_raw(b"\x00") def test_extents_none(self) -> None: buf = BytesIO(b"\x00" * 255) @@ -267,10 +267,10 @@ class TestPyDecoder(CodecsTest): im.load() - assert self.decoder.state.xoff == 0 - assert self.decoder.state.yoff == 0 - assert self.decoder.state.xsize == 200 - assert self.decoder.state.ysize == 200 + assert MockPyDecoder.last.state.xoff == 0 + assert MockPyDecoder.last.state.yoff == 0 + assert MockPyDecoder.last.state.xsize == 200 + assert MockPyDecoder.last.state.ysize == 200 def test_negsize(self) -> None: buf = BytesIO(b"\x00" * 255) @@ -315,10 +315,10 @@ class TestPyEncoder(CodecsTest): im, fp, [("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize), 0, "RGB")] ) - assert self.encoder.state.xoff == xoff - assert self.encoder.state.yoff == yoff - assert self.encoder.state.xsize == xsize - assert self.encoder.state.ysize == ysize + assert MockPyEncoder.last.state.xoff == xoff + assert MockPyEncoder.last.state.yoff == yoff + assert MockPyEncoder.last.state.xsize == xsize + assert MockPyEncoder.last.state.ysize == ysize def test_extents_none(self) -> None: buf = BytesIO(b"\x00" * 255) @@ -329,10 +329,10 @@ class TestPyEncoder(CodecsTest): fp = BytesIO() ImageFile._save(im, fp, [("MOCK", None, 0, "RGB")]) - assert self.encoder.state.xoff == 0 - assert self.encoder.state.yoff == 0 - assert self.encoder.state.xsize == 200 - assert self.encoder.state.ysize == 200 + assert MockPyEncoder.last.state.xoff == 0 + assert MockPyEncoder.last.state.yoff == 0 + assert MockPyEncoder.last.state.xsize == 200 + assert MockPyEncoder.last.state.ysize == 200 def test_negsize(self) -> None: buf = BytesIO(b"\x00" * 255) @@ -340,12 +340,12 @@ class TestPyEncoder(CodecsTest): im = MockImageFile(buf) fp = BytesIO() - self.encoder.cleanup_called = False + MockPyEncoder.last = None with pytest.raises(ValueError): ImageFile._save( im, fp, [("MOCK", (xoff, yoff, -10, yoff + ysize), 0, "RGB")] ) - assert self.encoder.cleanup_called + assert MockPyEncoder.last.cleanup_called with pytest.raises(ValueError): ImageFile._save( diff --git a/src/PIL/Image.py b/src/PIL/Image.py index a770488b7..eba30537f 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -229,8 +229,8 @@ MIME: dict[str, str] = {} SAVE: dict[str, Callable[[Image, IO[bytes], str | bytes], None]] = {} SAVE_ALL: dict[str, Callable[[Image, IO[bytes], str | bytes], None]] = {} EXTENSION: dict[str, str] = {} -DECODERS: dict[str, object] = {} -ENCODERS: dict[str, object] = {} +DECODERS: dict[str, type[ImageFile.PyDecoder]] = {} +ENCODERS: dict[str, type[ImageFile.PyEncoder]] = {} # -------------------------------------------------------------------- # Modes @@ -3524,28 +3524,26 @@ def registered_extensions(): return EXTENSION -def register_decoder(name: str, decoder) -> None: +def register_decoder(name: str, decoder: type[ImageFile.PyDecoder]) -> None: """ Registers an image decoder. This function should not be used in application code. :param name: The name of the decoder - :param decoder: A callable(mode, args) that returns an - ImageFile.PyDecoder object + :param decoder: An ImageFile.PyDecoder object .. versionadded:: 4.1.0 """ DECODERS[name] = decoder -def register_encoder(name, encoder): +def register_encoder(name: str, encoder: type[ImageFile.PyEncoder]) -> None: """ Registers an image encoder. This function should not be used in application code. :param name: The name of the encoder - :param encoder: A callable(mode, args) that returns an - ImageFile.PyEncoder object + :param encoder: An ImageFile.PyEncoder object .. versionadded:: 4.1.0 """ From 26e0f6df56c1289d52b156642c6ee1197d2bf69b Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 17 Feb 2024 10:18:54 +1100 Subject: [PATCH 085/361] Pin Python 3.13 on Windows to a3 --- .github/workflows/test-windows.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 75fccf795..79a2e60b2 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -32,7 +32,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["pypy3.10", "pypy3.9", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["pypy3.10", "pypy3.9", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13.0-alpha.3"] timeout-minutes: 30 From 5c858d75e4a58e895bed56c2ff6c0bae245c88cf Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 17 Feb 2024 10:45:52 +1100 Subject: [PATCH 086/361] Added type hints --- src/PIL/Image.py | 33 ++++---- src/PIL/ImageColor.py | 2 +- src/PIL/ImageOps.py | 171 +++++++++++++++++++++++++++------------- src/PIL/ImagePalette.py | 7 +- 4 files changed, 139 insertions(+), 74 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index a770488b7..ba81a22c7 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1430,7 +1430,7 @@ class Image: root = ElementTree.fromstring(xmp_tags) return {get_name(root.tag): get_value(root)} - def getexif(self): + def getexif(self) -> Exif: """ Gets EXIF data from the image. @@ -1438,7 +1438,6 @@ class Image: """ if self._exif is None: self._exif = Exif() - self._exif._loaded = False elif self._exif._loaded: return self._exif self._exif._loaded = True @@ -1525,7 +1524,7 @@ class Image: self.load() return self.im.ptr - def getpalette(self, rawmode="RGB"): + def getpalette(self, rawmode: str | None = "RGB") -> list[int] | None: """ Returns the image palette as a list. @@ -1615,7 +1614,7 @@ class Image: x, y = self.im.getprojection() return list(x), list(y) - def histogram(self, mask=None, extrema=None): + def histogram(self, mask=None, extrema=None) -> list[int]: """ Returns a histogram for the image. The histogram is returned as a list of pixel counts, one for each pixel value in the source @@ -1804,7 +1803,7 @@ class Image: result = alpha_composite(background, overlay) self.paste(result, box) - def point(self, lut, mode=None): + def point(self, lut, mode: str | None = None) -> Image: """ Maps this image through a lookup table or function. @@ -1928,7 +1927,7 @@ class Image: self.im.putdata(data, scale, offset) - def putpalette(self, data, rawmode="RGB"): + def putpalette(self, data, rawmode="RGB") -> None: """ Attaches a palette to this image. The image must be a "P", "PA", "L" or "LA" image. @@ -2108,7 +2107,7 @@ class Image: min(self.size[1], math.ceil(box[3] + support_y)), ) - def resize(self, size, resample=None, box=None, reducing_gap=None): + def resize(self, size, resample=None, box=None, reducing_gap=None) -> Image: """ Returns a resized copy of this image. @@ -2200,10 +2199,11 @@ class Image: if factor_x > 1 or factor_y > 1: reduce_box = self._get_safe_box(size, resample, box) factor = (factor_x, factor_y) - if callable(self.reduce): - self = self.reduce(factor, box=reduce_box) - else: - self = Image.reduce(self, factor, box=reduce_box) + self = ( + self.reduce(factor, box=reduce_box) + if callable(self.reduce) + else Image.reduce(self, factor, box=reduce_box) + ) box = ( (box[0] - reduce_box[0]) / factor_x, (box[1] - reduce_box[1]) / factor_y, @@ -2818,7 +2818,7 @@ class Image: self.im.transform2(box, image.im, method, data, resample, fill) - def transpose(self, method): + def transpose(self, method: Transpose) -> Image: """ Transpose image (flip or rotate in 90 degree steps) @@ -2870,7 +2870,9 @@ class ImagePointHandler: (for use with :py:meth:`~PIL.Image.Image.point`) """ - pass + @abc.abstractmethod + def point(self, im: Image) -> Image: + pass class ImageTransformHandler: @@ -3690,6 +3692,7 @@ class Exif(_ExifBase): endian = None bigtiff = False + _loaded = False def __init__(self): self._data = {} @@ -3805,7 +3808,7 @@ class Exif(_ExifBase): return merged_dict - def tobytes(self, offset=8): + def tobytes(self, offset: int = 8) -> bytes: from . import TiffImagePlugin head = self._get_head() @@ -3960,7 +3963,7 @@ class Exif(_ExifBase): del self._info[tag] self._data[tag] = value - def __delitem__(self, tag): + def __delitem__(self, tag: int) -> None: if self._info is not None and tag in self._info: del self._info[tag] else: diff --git a/src/PIL/ImageColor.py b/src/PIL/ImageColor.py index ad59b0667..5fb80b753 100644 --- a/src/PIL/ImageColor.py +++ b/src/PIL/ImageColor.py @@ -124,7 +124,7 @@ def getrgb(color): @lru_cache -def getcolor(color, mode): +def getcolor(color, mode: str) -> tuple[int, ...]: """ Same as :py:func:`~PIL.ImageColor.getrgb` for most modes. However, if ``mode`` is HSV, converts the RGB value to a HSV value, or if ``mode`` is diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index a9e626b2b..6218c723f 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -21,6 +21,7 @@ from __future__ import annotations import functools import operator import re +from typing import Protocol, Sequence, cast from . import ExifTags, Image, ImagePalette @@ -28,7 +29,7 @@ from . import ExifTags, Image, ImagePalette # helpers -def _border(border): +def _border(border: int | tuple[int, ...]) -> tuple[int, int, int, int]: if isinstance(border, tuple): if len(border) == 2: left, top = right, bottom = border @@ -39,7 +40,7 @@ def _border(border): return left, top, right, bottom -def _color(color, mode): +def _color(color: str | int | tuple[int, ...], mode: str) -> int | tuple[int, ...]: if isinstance(color, str): from . import ImageColor @@ -47,7 +48,7 @@ def _color(color, mode): return color -def _lut(image, lut): +def _lut(image: Image.Image, lut: list[int]) -> Image.Image: if image.mode == "P": # FIXME: apply to lookup table, not image data msg = "mode P support coming soon" @@ -65,7 +66,13 @@ def _lut(image, lut): # actions -def autocontrast(image, cutoff=0, ignore=None, mask=None, preserve_tone=False): +def autocontrast( + image: Image.Image, + cutoff: float | tuple[float, float] = 0, + ignore: int | Sequence[int] | None = None, + mask: Image.Image | None = None, + preserve_tone: bool = False, +) -> Image.Image: """ Maximize (normalize) image contrast. This function calculates a histogram of the input image (or mask region), removes ``cutoff`` percent of the @@ -97,10 +104,9 @@ def autocontrast(image, cutoff=0, ignore=None, mask=None, preserve_tone=False): h = histogram[layer : layer + 256] if ignore is not None: # get rid of outliers - try: + if isinstance(ignore, int): h[ignore] = 0 - except TypeError: - # assume sequence + else: for ix in ignore: h[ix] = 0 if cutoff: @@ -112,7 +118,7 @@ def autocontrast(image, cutoff=0, ignore=None, mask=None, preserve_tone=False): for ix in range(256): n = n + h[ix] # remove cutoff% pixels from the low end - cut = n * cutoff[0] // 100 + cut = int(n * cutoff[0] // 100) for lo in range(256): if cut > h[lo]: cut = cut - h[lo] @@ -123,7 +129,7 @@ def autocontrast(image, cutoff=0, ignore=None, mask=None, preserve_tone=False): if cut <= 0: break # remove cutoff% samples from the high end - cut = n * cutoff[1] // 100 + cut = int(n * cutoff[1] // 100) for hi in range(255, -1, -1): if cut > h[hi]: cut = cut - h[hi] @@ -156,7 +162,15 @@ def autocontrast(image, cutoff=0, ignore=None, mask=None, preserve_tone=False): return _lut(image, lut) -def colorize(image, black, white, mid=None, blackpoint=0, whitepoint=255, midpoint=127): +def colorize( + image: Image.Image, + black: str | tuple[int, ...], + white: str | tuple[int, ...], + mid: str | int | tuple[int, ...] | None = None, + blackpoint: int = 0, + whitepoint: int = 255, + midpoint: int = 127, +) -> Image.Image: """ Colorize grayscale image. This function calculates a color wedge which maps all black pixels in @@ -188,10 +202,9 @@ def colorize(image, black, white, mid=None, blackpoint=0, whitepoint=255, midpoi assert 0 <= blackpoint <= midpoint <= whitepoint <= 255 # Define colors from arguments - black = _color(black, "RGB") - white = _color(white, "RGB") - if mid is not None: - mid = _color(mid, "RGB") + rgb_black = cast(Sequence[int], _color(black, "RGB")) + rgb_white = cast(Sequence[int], _color(white, "RGB")) + rgb_mid = cast(Sequence[int], _color(mid, "RGB")) if mid is not None else None # Empty lists for the mapping red = [] @@ -200,18 +213,24 @@ def colorize(image, black, white, mid=None, blackpoint=0, whitepoint=255, midpoi # Create the low-end values for i in range(0, blackpoint): - red.append(black[0]) - green.append(black[1]) - blue.append(black[2]) + red.append(rgb_black[0]) + green.append(rgb_black[1]) + blue.append(rgb_black[2]) # Create the mapping (2-color) - if mid is None: + if rgb_mid is None: range_map = range(0, whitepoint - blackpoint) for i in range_map: - red.append(black[0] + i * (white[0] - black[0]) // len(range_map)) - green.append(black[1] + i * (white[1] - black[1]) // len(range_map)) - blue.append(black[2] + i * (white[2] - black[2]) // len(range_map)) + red.append( + rgb_black[0] + i * (rgb_white[0] - rgb_black[0]) // len(range_map) + ) + green.append( + rgb_black[1] + i * (rgb_white[1] - rgb_black[1]) // len(range_map) + ) + blue.append( + rgb_black[2] + i * (rgb_white[2] - rgb_black[2]) // len(range_map) + ) # Create the mapping (3-color) else: @@ -219,26 +238,36 @@ def colorize(image, black, white, mid=None, blackpoint=0, whitepoint=255, midpoi range_map2 = range(0, whitepoint - midpoint) for i in range_map1: - red.append(black[0] + i * (mid[0] - black[0]) // len(range_map1)) - green.append(black[1] + i * (mid[1] - black[1]) // len(range_map1)) - blue.append(black[2] + i * (mid[2] - black[2]) // len(range_map1)) + red.append( + rgb_black[0] + i * (rgb_mid[0] - rgb_black[0]) // len(range_map1) + ) + green.append( + rgb_black[1] + i * (rgb_mid[1] - rgb_black[1]) // len(range_map1) + ) + blue.append( + rgb_black[2] + i * (rgb_mid[2] - rgb_black[2]) // len(range_map1) + ) for i in range_map2: - red.append(mid[0] + i * (white[0] - mid[0]) // len(range_map2)) - green.append(mid[1] + i * (white[1] - mid[1]) // len(range_map2)) - blue.append(mid[2] + i * (white[2] - mid[2]) // len(range_map2)) + red.append(rgb_mid[0] + i * (rgb_white[0] - rgb_mid[0]) // len(range_map2)) + green.append( + rgb_mid[1] + i * (rgb_white[1] - rgb_mid[1]) // len(range_map2) + ) + blue.append(rgb_mid[2] + i * (rgb_white[2] - rgb_mid[2]) // len(range_map2)) # Create the high-end values for i in range(0, 256 - whitepoint): - red.append(white[0]) - green.append(white[1]) - blue.append(white[2]) + red.append(rgb_white[0]) + green.append(rgb_white[1]) + blue.append(rgb_white[2]) # Return converted image image = image.convert("RGB") return _lut(image, red + green + blue) -def contain(image, size, method=Image.Resampling.BICUBIC): +def contain( + image: Image.Image, size: tuple[int, int], method: int = Image.Resampling.BICUBIC +) -> Image.Image: """ Returns a resized version of the image, set to the maximum width and height within the requested size, while maintaining the original aspect ratio. @@ -267,7 +296,9 @@ def contain(image, size, method=Image.Resampling.BICUBIC): return image.resize(size, resample=method) -def cover(image, size, method=Image.Resampling.BICUBIC): +def cover( + image: Image.Image, size: tuple[int, int], method: int = Image.Resampling.BICUBIC +) -> Image.Image: """ Returns a resized version of the image, so that the requested size is covered, while maintaining the original aspect ratio. @@ -296,7 +327,13 @@ def cover(image, size, method=Image.Resampling.BICUBIC): return image.resize(size, resample=method) -def pad(image, size, method=Image.Resampling.BICUBIC, color=None, centering=(0.5, 0.5)): +def pad( + image: Image.Image, + size: tuple[int, int], + method: int = Image.Resampling.BICUBIC, + color: str | int | tuple[int, ...] | None = None, + centering: tuple[float, float] = (0.5, 0.5), +) -> Image.Image: """ Returns a resized and padded version of the image, expanded to fill the requested aspect ratio and size. @@ -334,7 +371,7 @@ def pad(image, size, method=Image.Resampling.BICUBIC, color=None, centering=(0.5 return out -def crop(image, border=0): +def crop(image: Image.Image, border: int = 0) -> Image.Image: """ Remove border from image. The same amount of pixels are removed from all four sides. This function works on all image modes. @@ -349,7 +386,9 @@ def crop(image, border=0): return image.crop((left, top, image.size[0] - right, image.size[1] - bottom)) -def scale(image, factor, resample=Image.Resampling.BICUBIC): +def scale( + image: Image.Image, factor: float, resample: int = Image.Resampling.BICUBIC +) -> Image.Image: """ Returns a rescaled image by a specific factor given in parameter. A factor greater than 1 expands the image, between 0 and 1 contracts the @@ -372,7 +411,19 @@ def scale(image, factor, resample=Image.Resampling.BICUBIC): return image.resize(size, resample) -def deform(image, deformer, resample=Image.Resampling.BILINEAR): +class _SupportsGetMesh(Protocol): + def getmesh( + self, image: Image.Image + ) -> list[ + tuple[tuple[int, int, int, int], tuple[int, int, int, int, int, int, int, int]] + ]: ... + + +def deform( + image: Image.Image, + deformer: _SupportsGetMesh, + resample: int = Image.Resampling.BILINEAR, +) -> Image.Image: """ Deform the image. @@ -388,7 +439,7 @@ def deform(image, deformer, resample=Image.Resampling.BILINEAR): ) -def equalize(image, mask=None): +def equalize(image: Image.Image, mask: Image.Image | None = None) -> Image.Image: """ Equalize the image histogram. This function applies a non-linear mapping to the input image, in order to create a uniform @@ -419,7 +470,11 @@ def equalize(image, mask=None): return _lut(image, lut) -def expand(image, border=0, fill=0): +def expand( + image: Image.Image, + border: int | tuple[int, ...] = 0, + fill: str | int | tuple[int, ...] = 0, +) -> Image.Image: """ Add border to the image @@ -445,7 +500,13 @@ def expand(image, border=0, fill=0): return out -def fit(image, size, method=Image.Resampling.BICUBIC, bleed=0.0, centering=(0.5, 0.5)): +def fit( + image: Image.Image, + size: tuple[int, int], + method: int = Image.Resampling.BICUBIC, + bleed: float = 0.0, + centering: tuple[float, float] = (0.5, 0.5), +) -> Image.Image: """ Returns a resized and cropped version of the image, cropped to the requested aspect ratio and size. @@ -479,13 +540,12 @@ def fit(image, size, method=Image.Resampling.BICUBIC, bleed=0.0, centering=(0.5, # kevin@cazabon.com # https://www.cazabon.com - # ensure centering is mutable - centering = list(centering) + centering_x, centering_y = centering - if not 0.0 <= centering[0] <= 1.0: - centering[0] = 0.5 - if not 0.0 <= centering[1] <= 1.0: - centering[1] = 0.5 + if not 0.0 <= centering_x <= 1.0: + centering_x = 0.5 + if not 0.0 <= centering_y <= 1.0: + centering_y = 0.5 if not 0.0 <= bleed < 0.5: bleed = 0.0 @@ -522,8 +582,8 @@ def fit(image, size, method=Image.Resampling.BICUBIC, bleed=0.0, centering=(0.5, crop_height = live_size[0] / output_ratio # make the crop - crop_left = bleed_pixels[0] + (live_size[0] - crop_width) * centering[0] - crop_top = bleed_pixels[1] + (live_size[1] - crop_height) * centering[1] + crop_left = bleed_pixels[0] + (live_size[0] - crop_width) * centering_x + crop_top = bleed_pixels[1] + (live_size[1] - crop_height) * centering_y crop = (crop_left, crop_top, crop_left + crop_width, crop_top + crop_height) @@ -531,7 +591,7 @@ def fit(image, size, method=Image.Resampling.BICUBIC, bleed=0.0, centering=(0.5, return image.resize(size, method, box=crop) -def flip(image): +def flip(image: Image.Image) -> Image.Image: """ Flip the image vertically (top to bottom). @@ -541,7 +601,7 @@ def flip(image): return image.transpose(Image.Transpose.FLIP_TOP_BOTTOM) -def grayscale(image): +def grayscale(image: Image.Image) -> Image.Image: """ Convert the image to grayscale. @@ -551,7 +611,7 @@ def grayscale(image): return image.convert("L") -def invert(image): +def invert(image: Image.Image) -> Image.Image: """ Invert (negate) the image. @@ -562,7 +622,7 @@ def invert(image): return image.point(lut) if image.mode == "1" else _lut(image, lut) -def mirror(image): +def mirror(image: Image.Image) -> Image.Image: """ Flip image horizontally (left to right). @@ -572,7 +632,7 @@ def mirror(image): return image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) -def posterize(image, bits): +def posterize(image: Image.Image, bits: int) -> Image.Image: """ Reduce the number of bits for each color channel. @@ -585,7 +645,7 @@ def posterize(image, bits): return _lut(image, lut) -def solarize(image, threshold=128): +def solarize(image: Image.Image, threshold: int = 128) -> Image.Image: """ Invert all pixel values above a threshold. @@ -602,7 +662,7 @@ def solarize(image, threshold=128): return _lut(image, lut) -def exif_transpose(image, *, in_place=False): +def exif_transpose(image: Image.Image, *, in_place: bool = False) -> Image.Image | None: """ If an image has an EXIF Orientation tag, other than 1, transpose the image accordingly, and remove the orientation data. @@ -616,7 +676,7 @@ def exif_transpose(image, *, in_place=False): """ image.load() image_exif = image.getexif() - orientation = image_exif.get(ExifTags.Base.Orientation) + orientation = image_exif.get(ExifTags.Base.Orientation, 1) method = { 2: Image.Transpose.FLIP_LEFT_RIGHT, 3: Image.Transpose.ROTATE_180, @@ -653,3 +713,4 @@ def exif_transpose(image, *, in_place=False): return transposed_image elif not in_place: return image.copy() + return None diff --git a/src/PIL/ImagePalette.py b/src/PIL/ImagePalette.py index 2b6cecc61..770d10025 100644 --- a/src/PIL/ImagePalette.py +++ b/src/PIL/ImagePalette.py @@ -18,6 +18,7 @@ from __future__ import annotations import array +from typing import Sequence from . import GimpGradientFile, GimpPaletteFile, ImageColor, PaletteFile @@ -34,11 +35,11 @@ class ImagePalette: Defaults to an empty palette. """ - def __init__(self, mode="RGB", palette=None): + def __init__(self, mode: str = "RGB", palette: Sequence[int] | None = None) -> None: self.mode = mode self.rawmode = None # if set, palette contains raw data self.palette = palette or bytearray() - self.dirty = None + self.dirty: int | None = None @property def palette(self): @@ -127,7 +128,7 @@ class ImagePalette: raise ValueError(msg) from e return index - def getcolor(self, color, image=None): + def getcolor(self, color, image=None) -> int: """Given an rgb tuple, allocate palette entry. .. warning:: This method is experimental. From d3b974b78607d36fbfa392b80c81adbd08d277ac Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 17 Feb 2024 11:19:32 +1100 Subject: [PATCH 087/361] Use font in ImageDraw examples --- docs/deprecations.rst | 8 ++++---- docs/releasenotes/9.2.0.rst | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/deprecations.rst b/docs/deprecations.rst index 205fcb9ab..a58ce9bcb 100644 --- a/docs/deprecations.rst +++ b/docs/deprecations.rst @@ -232,10 +232,10 @@ Previous code:: im = Image.new("RGB", (100, 100)) draw = ImageDraw.Draw(im) - width, height = draw.textsize("Hello world") + width, height = draw.textsize("Hello world", font) width, height = font.getsize_multiline("Hello\nworld") - width, height = draw.multiline_textsize("Hello\nworld") + width, height = draw.multiline_textsize("Hello\nworld", font) Use instead:: @@ -247,9 +247,9 @@ Use instead:: im = Image.new("RGB", (100, 100)) draw = ImageDraw.Draw(im) - width = draw.textlength("Hello world") + width = draw.textlength("Hello world", font) - left, top, right, bottom = draw.multiline_textbbox((0, 0), "Hello\nworld") + left, top, right, bottom = draw.multiline_textbbox((0, 0), "Hello\nworld", font) width, height = right - left, bottom - top FreeTypeFont.getmask2 fill parameter diff --git a/docs/releasenotes/9.2.0.rst b/docs/releasenotes/9.2.0.rst index b875edf8e..3b8d2535f 100644 --- a/docs/releasenotes/9.2.0.rst +++ b/docs/releasenotes/9.2.0.rst @@ -69,10 +69,10 @@ Previous code:: im = Image.new("RGB", (100, 100)) draw = ImageDraw.Draw(im) - width, height = draw.textsize("Hello world") + width, height = draw.textsize("Hello world", font) width, height = font.getsize_multiline("Hello\nworld") - width, height = draw.multiline_textsize("Hello\nworld") + width, height = draw.multiline_textsize("Hello\nworld", font) Use instead:: @@ -84,9 +84,9 @@ Use instead:: im = Image.new("RGB", (100, 100)) draw = ImageDraw.Draw(im) - width = draw.textlength("Hello world") + width = draw.textlength("Hello world", font) - left, top, right, bottom = draw.multiline_textbbox((0, 0), "Hello\nworld") + left, top, right, bottom = draw.multiline_textbbox((0, 0), "Hello\nworld", font) width, height = right - left, bottom - top API Additions From 617b9cbc00759f042d2c38ea63a14adca2bc902b Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 17 Feb 2024 11:50:48 +1100 Subject: [PATCH 088/361] Describe difference between size and bbox --- docs/deprecations.rst | 4 ++++ docs/releasenotes/9.2.0.rst | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/docs/deprecations.rst b/docs/deprecations.rst index a58ce9bcb..c90ad481a 100644 --- a/docs/deprecations.rst +++ b/docs/deprecations.rst @@ -252,6 +252,10 @@ Use instead:: left, top, right, bottom = draw.multiline_textbbox((0, 0), "Hello\nworld", font) width, height = right - left, bottom - top +Previously, the ``size`` methods returned a ``height`` that included the vertical +offset of the text, while the new ``bbox`` methods explicitly distinguish this as a +``top`` offset. + FreeTypeFont.getmask2 fill parameter ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/releasenotes/9.2.0.rst b/docs/releasenotes/9.2.0.rst index 3b8d2535f..495926ca7 100644 --- a/docs/releasenotes/9.2.0.rst +++ b/docs/releasenotes/9.2.0.rst @@ -89,6 +89,10 @@ Use instead:: left, top, right, bottom = draw.multiline_textbbox((0, 0), "Hello\nworld", font) width, height = right - left, bottom - top +Previously, the ``size`` methods returned a ``height`` that included the vertical +offset of the text, while the new ``bbox`` methods explicitly distinguish this as a +``top`` offset. + API Additions ============= From 1a108281b9b6d894574ec63534a043385549b3be Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 17 Feb 2024 14:03:56 +1100 Subject: [PATCH 089/361] Removed unused code --- Tests/test_format_hsv.py | 4 ---- Tests/test_image_paste.py | 1 - 2 files changed, 5 deletions(-) diff --git a/Tests/test_format_hsv.py b/Tests/test_format_hsv.py index 73aaae6e7..da909c06c 100644 --- a/Tests/test_format_hsv.py +++ b/Tests/test_format_hsv.py @@ -12,10 +12,6 @@ def int_to_float(i): return i / 255 -def str_to_float(i): - return ord(i) / 255 - - def tuple_to_ints(tp): x, y, z = tp return int(x * 255.0), int(y * 255.0), int(z * 255.0) diff --git a/Tests/test_image_paste.py b/Tests/test_image_paste.py index ce7345572..2966f724f 100644 --- a/Tests/test_image_paste.py +++ b/Tests/test_image_paste.py @@ -8,7 +8,6 @@ from .helper import CachedProperty, assert_image_equal class TestImagingPaste: - masks = {} size = 128 def assert_9points_image( From 5ff7d926fd24acc2d6d575959635d59123b308a6 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 17 Feb 2024 15:00:38 +1100 Subject: [PATCH 090/361] Added type hints --- Tests/test_features.py | 7 +- Tests/test_file_blp.py | 2 +- Tests/test_file_bmp.py | 4 +- Tests/test_file_im.py | 2 +- Tests/test_file_pcx.py | 6 +- Tests/test_file_pdf.py | 15 ++-- Tests/test_file_tiff.py | 19 ++--- Tests/test_format_hsv.py | 19 +++-- Tests/test_imagechops.py | 6 +- Tests/test_imagedraw2.py | 11 +-- Tests/test_imageenhance.py | 8 ++- Tests/test_imagefile.py | 2 +- Tests/test_imagefont.py | 140 ++++++++++++++++++++++--------------- Tests/test_imageops.py | 22 +++--- Tests/test_imageops_usm.py | 14 ++-- Tests/test_imagepath.py | 13 ++-- 16 files changed, 170 insertions(+), 120 deletions(-) diff --git a/Tests/test_features.py b/Tests/test_features.py index de74e9c18..8d2d198ff 100644 --- a/Tests/test_features.py +++ b/Tests/test_features.py @@ -2,6 +2,7 @@ from __future__ import annotations import io import re +from typing import Callable import pytest @@ -29,7 +30,7 @@ def test_version() -> None: # Check the correctness of the convenience function # and the format of version numbers - def test(name, function) -> None: + def test(name: str, function: Callable[[str], bool]) -> None: version = features.version(name) if not features.check(name): assert version is None @@ -73,12 +74,12 @@ def test_libimagequant_version() -> None: @pytest.mark.parametrize("feature", features.modules) -def test_check_modules(feature) -> None: +def test_check_modules(feature: str) -> None: assert features.check_module(feature) in [True, False] @pytest.mark.parametrize("feature", features.codecs) -def test_check_codecs(feature) -> None: +def test_check_codecs(feature: str) -> None: assert features.check_codec(feature) in [True, False] diff --git a/Tests/test_file_blp.py b/Tests/test_file_blp.py index 3904d3bc5..1e2f20c40 100644 --- a/Tests/test_file_blp.py +++ b/Tests/test_file_blp.py @@ -71,7 +71,7 @@ def test_save(tmp_path: Path) -> None: "Tests/images/timeout-ef9112a065e7183fa7faa2e18929b03e44ee16bf.blp", ], ) -def test_crashes(test_file) -> None: +def test_crashes(test_file: str) -> None: with open(test_file, "rb") as f: with Image.open(f) as im: with pytest.raises(OSError): diff --git a/Tests/test_file_bmp.py b/Tests/test_file_bmp.py index c36466e02..1eaff0c7d 100644 --- a/Tests/test_file_bmp.py +++ b/Tests/test_file_bmp.py @@ -16,7 +16,7 @@ from .helper import ( def test_sanity(tmp_path: Path) -> None: - def roundtrip(im) -> None: + def roundtrip(im: Image.Image) -> None: outfile = str(tmp_path / "temp.bmp") im.save(outfile, "BMP") @@ -194,7 +194,7 @@ def test_rle4() -> None: ("Tests/images/bmp/g/pal8rle.bmp", 1064), ), ) -def test_rle8_eof(file_name, length) -> None: +def test_rle8_eof(file_name: str, length: int) -> None: with open(file_name, "rb") as fp: data = fp.read(length) with Image.open(io.BytesIO(data)) as im: diff --git a/Tests/test_file_im.py b/Tests/test_file_im.py index f932069b9..036965bf5 100644 --- a/Tests/test_file_im.py +++ b/Tests/test_file_im.py @@ -82,7 +82,7 @@ def test_eoferror() -> None: @pytest.mark.parametrize("mode", ("RGB", "P", "PA")) -def test_roundtrip(mode, tmp_path: Path) -> None: +def test_roundtrip(mode: str, tmp_path: Path) -> None: out = str(tmp_path / "temp.im") im = hopper(mode) im.save(out) diff --git a/Tests/test_file_pcx.py b/Tests/test_file_pcx.py index a2486be40..ab9f9663e 100644 --- a/Tests/test_file_pcx.py +++ b/Tests/test_file_pcx.py @@ -9,7 +9,7 @@ from PIL import Image, ImageFile, PcxImagePlugin from .helper import assert_image_equal, hopper -def _roundtrip(tmp_path: Path, im) -> None: +def _roundtrip(tmp_path: Path, im: Image.Image) -> None: f = str(tmp_path / "temp.pcx") im.save(f) with Image.open(f) as im2: @@ -44,7 +44,7 @@ def test_invalid_file() -> None: @pytest.mark.parametrize("mode", ("1", "L", "P", "RGB")) -def test_odd(tmp_path: Path, mode) -> None: +def test_odd(tmp_path: Path, mode: str) -> None: # See issue #523, odd sized images should have a stride that's even. # Not that ImageMagick or GIMP write PCX that way. # We were not handling properly. @@ -89,7 +89,7 @@ def test_large_count(tmp_path: Path) -> None: _roundtrip(tmp_path, im) -def _test_buffer_overflow(tmp_path: Path, im, size: int = 1024) -> None: +def _test_buffer_overflow(tmp_path: Path, im: Image.Image, size: int = 1024) -> None: _last = ImageFile.MAXBLOCK ImageFile.MAXBLOCK = size try: diff --git a/Tests/test_file_pdf.py b/Tests/test_file_pdf.py index 65a93c138..d39a86565 100644 --- a/Tests/test_file_pdf.py +++ b/Tests/test_file_pdf.py @@ -6,6 +6,7 @@ import os.path import tempfile import time from pathlib import Path +from typing import Any, Generator import pytest @@ -14,7 +15,7 @@ from PIL import Image, PdfParser, features from .helper import hopper, mark_if_feature_version, skip_unless_feature -def helper_save_as_pdf(tmp_path: Path, mode, **kwargs): +def helper_save_as_pdf(tmp_path: Path, mode: str, **kwargs: Any) -> str: # Arrange im = hopper(mode) outfile = str(tmp_path / ("temp_" + mode + ".pdf")) @@ -41,13 +42,13 @@ def helper_save_as_pdf(tmp_path: Path, mode, **kwargs): @pytest.mark.parametrize("mode", ("L", "P", "RGB", "CMYK")) -def test_save(tmp_path: Path, mode) -> None: +def test_save(tmp_path: Path, mode: str) -> None: helper_save_as_pdf(tmp_path, mode) @skip_unless_feature("jpg_2000") @pytest.mark.parametrize("mode", ("LA", "RGBA")) -def test_save_alpha(tmp_path: Path, mode) -> None: +def test_save_alpha(tmp_path: Path, mode: str) -> None: helper_save_as_pdf(tmp_path, mode) @@ -112,7 +113,7 @@ def test_resolution(tmp_path: Path) -> None: {"dpi": (75, 150), "resolution": 200}, ), ) -def test_dpi(params, tmp_path: Path) -> None: +def test_dpi(params: dict[str, int | tuple[int, int]], tmp_path: Path) -> None: im = hopper() outfile = str(tmp_path / "temp.pdf") @@ -156,7 +157,7 @@ def test_save_all(tmp_path: Path) -> None: assert os.path.getsize(outfile) > 0 # Test appending using a generator - def im_generator(ims): + def im_generator(ims: list[Image.Image]) -> Generator[Image.Image, None, None]: yield from ims im.save(outfile, save_all=True, append_images=im_generator(ims)) @@ -226,7 +227,7 @@ def test_pdf_append_fails_on_nonexistent_file() -> None: im.save(os.path.join(temp_dir, "nonexistent.pdf"), append=True) -def check_pdf_pages_consistency(pdf) -> None: +def check_pdf_pages_consistency(pdf: PdfParser.PdfParser) -> None: pages_info = pdf.read_indirect(pdf.pages_ref) assert b"Parent" not in pages_info assert b"Kids" in pages_info @@ -339,7 +340,7 @@ def test_pdf_append_to_bytesio() -> None: @pytest.mark.timeout(1) @pytest.mark.skipif("PILLOW_VALGRIND_TEST" in os.environ, reason="Valgrind is slower") @pytest.mark.parametrize("newline", (b"\r", b"\n")) -def test_redos(newline) -> None: +def test_redos(newline: bytes) -> None: malicious = b" trailer<<>>" + newline * 3456 # This particular exception isn't relevant here. diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index a16b76e19..0110948ae 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -4,6 +4,8 @@ import os import warnings from io import BytesIO from pathlib import Path +from types import ModuleType +from typing import Generator import pytest @@ -20,6 +22,7 @@ from .helper import ( is_win32, ) +ElementTree: ModuleType | None try: from defusedxml import ElementTree except ImportError: @@ -156,7 +159,7 @@ class TestFileTiff: "resolution_unit, dpi", [(None, 72.8), (2, 72.8), (3, 184.912)], ) - def test_load_float_dpi(self, resolution_unit, dpi) -> None: + def test_load_float_dpi(self, resolution_unit: int | None, dpi: float) -> None: with Image.open( "Tests/images/hopper_float_dpi_" + str(resolution_unit) + ".tif" ) as im: @@ -284,7 +287,7 @@ class TestFileTiff: ("Tests/images/multipage.tiff", 3), ), ) - def test_n_frames(self, path, n_frames) -> None: + def test_n_frames(self, path: str, n_frames: int) -> None: with Image.open(path) as im: assert im.n_frames == n_frames assert im.is_animated == (n_frames != 1) @@ -402,7 +405,7 @@ class TestFileTiff: assert len_before == len_after + 1 @pytest.mark.parametrize("legacy_api", (False, True)) - def test_load_byte(self, legacy_api) -> None: + def test_load_byte(self, legacy_api: bool) -> None: ifd = TiffImagePlugin.ImageFileDirectory_v2() data = b"abc" ret = ifd.load_byte(data, legacy_api) @@ -431,7 +434,7 @@ class TestFileTiff: assert 0x8825 in im.tag_v2 def test_exif(self, tmp_path: Path) -> None: - def check_exif(exif) -> None: + def check_exif(exif: Image.Exif) -> None: assert sorted(exif.keys()) == [ 256, 257, @@ -511,7 +514,7 @@ class TestFileTiff: assert im.getexif()[273] == (1408, 1907) @pytest.mark.parametrize("mode", ("1", "L")) - def test_photometric(self, mode, tmp_path: Path) -> None: + def test_photometric(self, mode: str, tmp_path: Path) -> None: filename = str(tmp_path / "temp.tif") im = hopper(mode) im.save(filename, tiffinfo={262: 0}) @@ -660,7 +663,7 @@ class TestFileTiff: assert_image_equal_tofile(reloaded, infile) @pytest.mark.parametrize("mode", ("P", "PA")) - def test_palette(self, mode, tmp_path: Path) -> None: + def test_palette(self, mode: str, tmp_path: Path) -> None: outfile = str(tmp_path / "temp.tif") im = hopper(mode) @@ -689,7 +692,7 @@ class TestFileTiff: assert reread.n_frames == 3 # Test appending using a generator - def im_generator(ims): + def im_generator(ims: list[Image.Image]) -> Generator[Image.Image, None, None]: yield from ims mp = BytesIO() @@ -860,7 +863,7 @@ class TestFileTiff: ], ) @pytest.mark.timeout(2) - def test_oom(self, test_file) -> None: + def test_oom(self, test_file: str) -> None: with pytest.raises(UnidentifiedImageError): with pytest.warns(UserWarning): with Image.open(test_file): diff --git a/Tests/test_format_hsv.py b/Tests/test_format_hsv.py index 73aaae6e7..fe055bf4b 100644 --- a/Tests/test_format_hsv.py +++ b/Tests/test_format_hsv.py @@ -2,21 +2,22 @@ from __future__ import annotations import colorsys import itertools +from typing import Callable from PIL import Image from .helper import assert_image_similar, hopper -def int_to_float(i): +def int_to_float(i: int) -> float: return i / 255 -def str_to_float(i): +def str_to_float(i: str) -> float: return ord(i) / 255 -def tuple_to_ints(tp): +def tuple_to_ints(tp: tuple[float, float, float]) -> tuple[int, int, int]: x, y, z = tp return int(x * 255.0), int(y * 255.0), int(z * 255.0) @@ -25,7 +26,7 @@ def test_sanity() -> None: Image.new("HSV", (100, 100)) -def wedge(): +def wedge() -> Image.Image: w = Image._wedge() w90 = w.rotate(90) @@ -49,7 +50,11 @@ def wedge(): return img -def to_xxx_colorsys(im, func, mode): +def to_xxx_colorsys( + im: Image.Image, + func: Callable[[float, float, float], tuple[float, float, float]], + mode: str, +) -> Image.Image: # convert the hard way using the library colorsys routines. (r, g, b) = im.split() @@ -70,11 +75,11 @@ def to_xxx_colorsys(im, func, mode): return hsv -def to_hsv_colorsys(im): +def to_hsv_colorsys(im: Image.Image) -> Image.Image: return to_xxx_colorsys(im, colorsys.rgb_to_hsv, "HSV") -def to_rgb_colorsys(im): +def to_rgb_colorsys(im: Image.Image) -> Image.Image: return to_xxx_colorsys(im, colorsys.hsv_to_rgb, "RGB") diff --git a/Tests/test_imagechops.py b/Tests/test_imagechops.py index 94f57e066..7e2290c15 100644 --- a/Tests/test_imagechops.py +++ b/Tests/test_imagechops.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Callable + from PIL import Image, ImageChops from .helper import assert_image_equal, hopper @@ -387,7 +389,9 @@ def test_overlay() -> None: def test_logical() -> None: - def table(op, a, b): + def table( + op: Callable[[Image.Image, Image.Image], Image.Image], a: int, b: int + ) -> tuple[int, int, int, int]: out = [] for x in (a, b): imx = Image.new("1", (1, 1), x) diff --git a/Tests/test_imagedraw2.py b/Tests/test_imagedraw2.py index 07a25b84b..3171eb9ae 100644 --- a/Tests/test_imagedraw2.py +++ b/Tests/test_imagedraw2.py @@ -5,6 +5,7 @@ import os.path import pytest from PIL import Image, ImageDraw, ImageDraw2, features +from PIL._typing import Coords from .helper import ( assert_image_equal, @@ -56,7 +57,7 @@ def test_sanity() -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_ellipse(bbox) -> None: +def test_ellipse(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw2.Draw(im) @@ -84,7 +85,7 @@ def test_ellipse_edge() -> None: @pytest.mark.parametrize("points", POINTS) -def test_line(points) -> None: +def test_line(points: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw2.Draw(im) @@ -98,7 +99,7 @@ def test_line(points) -> None: @pytest.mark.parametrize("points", POINTS) -def test_line_pen_as_brush(points) -> None: +def test_line_pen_as_brush(points: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw2.Draw(im) @@ -114,7 +115,7 @@ def test_line_pen_as_brush(points) -> None: @pytest.mark.parametrize("points", POINTS) -def test_polygon(points) -> None: +def test_polygon(points: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw2.Draw(im) @@ -129,7 +130,7 @@ def test_polygon(points) -> None: @pytest.mark.parametrize("bbox", BBOX) -def test_rectangle(bbox) -> None: +def test_rectangle(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw2.Draw(im) diff --git a/Tests/test_imageenhance.py b/Tests/test_imageenhance.py index 9ce9cda82..6ebc61e1b 100644 --- a/Tests/test_imageenhance.py +++ b/Tests/test_imageenhance.py @@ -22,7 +22,7 @@ def test_crash() -> None: ImageEnhance.Sharpness(im).enhance(0.5) -def _half_transparent_image(): +def _half_transparent_image() -> Image.Image: # returns an image, half transparent, half solid im = hopper("RGB") @@ -34,7 +34,9 @@ def _half_transparent_image(): return im -def _check_alpha(im, original, op, amount) -> None: +def _check_alpha( + im: Image.Image, original: Image.Image, op: str, amount: float +) -> None: assert im.getbands() == original.getbands() assert_image_equal( im.getchannel("A"), @@ -44,7 +46,7 @@ def _check_alpha(im, original, op, amount) -> None: @pytest.mark.parametrize("op", ("Color", "Brightness", "Contrast", "Sharpness")) -def test_alpha(op) -> None: +def test_alpha(op: str) -> None: # Issue https://github.com/python-pillow/Pillow/issues/899 # Is alpha preserved through image enhancement? diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py index 491409781..44521a8b3 100644 --- a/Tests/test_imagefile.py +++ b/Tests/test_imagefile.py @@ -31,7 +31,7 @@ SAFEBLOCK = ImageFile.SAFEBLOCK class TestImageFile: def test_parser(self) -> None: - def roundtrip(format): + def roundtrip(format: str) -> tuple[Image.Image, Image.Image]: im = hopper("L").resize((1000, 1000), Image.Resampling.NEAREST) if format in ("MSP", "XBM"): im = im.convert("1") diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index 909026dc8..c79b36ca4 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -7,11 +7,13 @@ import shutil import sys from io import BytesIO from pathlib import Path +from typing import BinaryIO import pytest from packaging.version import parse as parse_version from PIL import Image, ImageDraw, ImageFont, features +from PIL._typing import StrOrBytesPath from .helper import ( assert_image_equal, @@ -47,11 +49,11 @@ def layout_engine(request): @pytest.fixture(scope="module") -def font(layout_engine): +def font(layout_engine: ImageFont.Layout) -> ImageFont.FreeTypeFont: return ImageFont.truetype(FONT_PATH, FONT_SIZE, layout_engine=layout_engine) -def test_font_properties(font) -> None: +def test_font_properties(font: ImageFont.FreeTypeFont) -> None: assert font.path == FONT_PATH assert font.size == FONT_SIZE @@ -67,7 +69,9 @@ def test_font_properties(font) -> None: assert font_copy.path == second_font_path -def _render(font, layout_engine): +def _render( + font: StrOrBytesPath | BinaryIO, layout_engine: ImageFont.Layout +) -> Image.Image: txt = "Hello World!" ttf = ImageFont.truetype(font, FONT_SIZE, layout_engine=layout_engine) ttf.getbbox(txt) @@ -80,12 +84,12 @@ def _render(font, layout_engine): @pytest.mark.parametrize("font", (FONT_PATH, Path(FONT_PATH))) -def test_font_with_name(layout_engine, font) -> None: +def test_font_with_name(layout_engine: ImageFont.Layout, font: str | Path) -> None: _render(font, layout_engine) -def test_font_with_filelike(layout_engine) -> None: - def _font_as_bytes(): +def test_font_with_filelike(layout_engine: ImageFont.Layout) -> None: + def _font_as_bytes() -> BytesIO: with open(FONT_PATH, "rb") as f: font_bytes = BytesIO(f.read()) return font_bytes @@ -102,12 +106,12 @@ def test_font_with_filelike(layout_engine) -> None: # _render(shared_bytes) -def test_font_with_open_file(layout_engine) -> None: +def test_font_with_open_file(layout_engine: ImageFont.Layout) -> None: with open(FONT_PATH, "rb") as f: _render(f, layout_engine) -def test_render_equal(layout_engine) -> None: +def test_render_equal(layout_engine: ImageFont.Layout) -> None: img_path = _render(FONT_PATH, layout_engine) with open(FONT_PATH, "rb") as f: font_filelike = BytesIO(f.read()) @@ -116,7 +120,7 @@ def test_render_equal(layout_engine) -> None: assert_image_equal(img_path, img_filelike) -def test_non_ascii_path(tmp_path: Path, layout_engine) -> None: +def test_non_ascii_path(tmp_path: Path, layout_engine: ImageFont.Layout) -> None: tempfile = str(tmp_path / ("temp_" + chr(128) + ".ttf")) try: shutil.copy(FONT_PATH, tempfile) @@ -126,7 +130,7 @@ def test_non_ascii_path(tmp_path: Path, layout_engine) -> None: ImageFont.truetype(tempfile, FONT_SIZE, layout_engine=layout_engine) -def test_transparent_background(font) -> None: +def test_transparent_background(font: ImageFont.FreeTypeFont) -> None: im = Image.new(mode="RGBA", size=(300, 100)) draw = ImageDraw.Draw(im) @@ -140,7 +144,7 @@ def test_transparent_background(font) -> None: assert_image_similar_tofile(im.convert("L"), target, 0.01) -def test_I16(font) -> None: +def test_I16(font: ImageFont.FreeTypeFont) -> None: im = Image.new(mode="I;16", size=(300, 100)) draw = ImageDraw.Draw(im) @@ -153,7 +157,7 @@ def test_I16(font) -> None: assert_image_similar_tofile(im.convert("L"), target, 0.01) -def test_textbbox_equal(font) -> None: +def test_textbbox_equal(font: ImageFont.FreeTypeFont) -> None: im = Image.new(mode="RGB", size=(300, 100)) draw = ImageDraw.Draw(im) @@ -181,7 +185,13 @@ def test_textbbox_equal(font) -> None: ), ) def test_getlength( - text, mode, fontname, size, layout_engine, length_basic, length_raqm + text: str, + mode: str, + fontname: str, + size: int, + layout_engine: ImageFont.Layout, + length_basic: int, + length_raqm: float, ) -> None: f = ImageFont.truetype("Tests/fonts/" + fontname, size, layout_engine=layout_engine) @@ -207,7 +217,7 @@ def test_float_size() -> None: assert lengths[0] != lengths[1] != lengths[2] -def test_render_multiline(font) -> None: +def test_render_multiline(font: ImageFont.FreeTypeFont) -> None: im = Image.new(mode="RGB", size=(300, 100)) draw = ImageDraw.Draw(im) line_spacing = font.getbbox("A")[3] + 4 @@ -223,7 +233,7 @@ def test_render_multiline(font) -> None: assert_image_similar_tofile(im, "Tests/images/multiline_text.png", 6.2) -def test_render_multiline_text(font) -> None: +def test_render_multiline_text(font: ImageFont.FreeTypeFont) -> None: # Test that text() correctly connects to multiline_text() # and that align defaults to left im = Image.new(mode="RGB", size=(300, 100)) @@ -243,7 +253,9 @@ def test_render_multiline_text(font) -> None: @pytest.mark.parametrize( "align, ext", (("left", ""), ("center", "_center"), ("right", "_right")) ) -def test_render_multiline_text_align(font, align, ext) -> None: +def test_render_multiline_text_align( + font: ImageFont.FreeTypeFont, align: str, ext: str +) -> None: im = Image.new(mode="RGB", size=(300, 100)) draw = ImageDraw.Draw(im) draw.multiline_text((0, 0), TEST_TEXT, font=font, align=align) @@ -251,7 +263,7 @@ def test_render_multiline_text_align(font, align, ext) -> None: assert_image_similar_tofile(im, f"Tests/images/multiline_text{ext}.png", 0.01) -def test_unknown_align(font) -> None: +def test_unknown_align(font: ImageFont.FreeTypeFont) -> None: im = Image.new(mode="RGB", size=(300, 100)) draw = ImageDraw.Draw(im) @@ -260,14 +272,14 @@ def test_unknown_align(font) -> None: draw.multiline_text((0, 0), TEST_TEXT, font=font, align="unknown") -def test_draw_align(font) -> None: +def test_draw_align(font: ImageFont.FreeTypeFont) -> None: im = Image.new("RGB", (300, 100), "white") draw = ImageDraw.Draw(im) line = "some text" draw.text((100, 40), line, (0, 0, 0), font=font, align="left") -def test_multiline_bbox(font) -> None: +def test_multiline_bbox(font: ImageFont.FreeTypeFont) -> None: im = Image.new(mode="RGB", size=(300, 100)) draw = ImageDraw.Draw(im) @@ -285,7 +297,7 @@ def test_multiline_bbox(font) -> None: draw.textbbox((0, 0), TEST_TEXT, font=font, spacing=4) -def test_multiline_width(font) -> None: +def test_multiline_width(font: ImageFont.FreeTypeFont) -> None: im = Image.new(mode="RGB", size=(300, 100)) draw = ImageDraw.Draw(im) @@ -295,7 +307,7 @@ def test_multiline_width(font) -> None: ) -def test_multiline_spacing(font) -> None: +def test_multiline_spacing(font: ImageFont.FreeTypeFont) -> None: im = Image.new(mode="RGB", size=(300, 100)) draw = ImageDraw.Draw(im) draw.multiline_text((0, 0), TEST_TEXT, font=font, spacing=10) @@ -306,7 +318,9 @@ def test_multiline_spacing(font) -> None: @pytest.mark.parametrize( "orientation", (Image.Transpose.ROTATE_90, Image.Transpose.ROTATE_270) ) -def test_rotated_transposed_font(font, orientation) -> None: +def test_rotated_transposed_font( + font: ImageFont.FreeTypeFont, orientation: Image.Transpose +) -> None: img_gray = Image.new("L", (100, 100)) draw = ImageDraw.Draw(img_gray) word = "testing" @@ -347,7 +361,9 @@ def test_rotated_transposed_font(font, orientation) -> None: Image.Transpose.FLIP_TOP_BOTTOM, ), ) -def test_unrotated_transposed_font(font, orientation) -> None: +def test_unrotated_transposed_font( + font: ImageFont.FreeTypeFont, orientation: Image.Transpose +) -> None: img_gray = Image.new("L", (100, 100)) draw = ImageDraw.Draw(img_gray) word = "testing" @@ -382,7 +398,9 @@ def test_unrotated_transposed_font(font, orientation) -> None: @pytest.mark.parametrize( "orientation", (Image.Transpose.ROTATE_90, Image.Transpose.ROTATE_270) ) -def test_rotated_transposed_font_get_mask(font, orientation) -> None: +def test_rotated_transposed_font_get_mask( + font: ImageFont.FreeTypeFont, orientation: Image.Transpose +) -> None: # Arrange text = "mask this" transposed_font = ImageFont.TransposedFont(font, orientation=orientation) @@ -403,7 +421,9 @@ def test_rotated_transposed_font_get_mask(font, orientation) -> None: Image.Transpose.FLIP_TOP_BOTTOM, ), ) -def test_unrotated_transposed_font_get_mask(font, orientation) -> None: +def test_unrotated_transposed_font_get_mask( + font: ImageFont.FreeTypeFont, orientation: Image.Transpose +) -> None: # Arrange text = "mask this" transposed_font = ImageFont.TransposedFont(font, orientation=orientation) @@ -415,11 +435,11 @@ def test_unrotated_transposed_font_get_mask(font, orientation) -> None: assert mask.size == (108, 13) -def test_free_type_font_get_name(font) -> None: +def test_free_type_font_get_name(font: ImageFont.FreeTypeFont) -> None: assert ("FreeMono", "Regular") == font.getname() -def test_free_type_font_get_metrics(font) -> None: +def test_free_type_font_get_metrics(font: ImageFont.FreeTypeFont) -> None: ascent, descent = font.getmetrics() assert isinstance(ascent, int) @@ -427,7 +447,7 @@ def test_free_type_font_get_metrics(font) -> None: assert (ascent, descent) == (16, 4) -def test_free_type_font_get_mask(font) -> None: +def test_free_type_font_get_mask(font: ImageFont.FreeTypeFont) -> None: # Arrange text = "mask this" @@ -473,16 +493,16 @@ def test_default_font() -> None: @pytest.mark.parametrize("mode", (None, "1", "RGBA")) -def test_getbbox(font, mode) -> None: +def test_getbbox(font: ImageFont.FreeTypeFont, mode: str | None) -> None: assert (0, 4, 12, 16) == font.getbbox("A", mode) -def test_getbbox_empty(font) -> None: +def test_getbbox_empty(font: ImageFont.FreeTypeFont) -> None: # issue #2614, should not crash. assert (0, 0, 0, 0) == font.getbbox("") -def test_render_empty(font) -> None: +def test_render_empty(font: ImageFont.FreeTypeFont) -> None: # issue 2666 im = Image.new(mode="RGB", size=(300, 100)) target = im.copy() @@ -492,7 +512,7 @@ def test_render_empty(font) -> None: assert_image_equal(im, target) -def test_unicode_extended(layout_engine) -> None: +def test_unicode_extended(layout_engine: ImageFont.Layout) -> None: # issue #3777 text = "A\u278A\U0001F12B" target = "Tests/images/unicode_extended.png" @@ -516,7 +536,7 @@ def test_unicode_extended(layout_engine) -> None: ) @pytest.mark.skipif(is_win32(), reason="requires Unix or macOS") def test_find_font(monkeypatch, platform, font_directory) -> None: - def _test_fake_loading_font(path_to_fake, fontname) -> None: + def _test_fake_loading_font(path_to_fake: str, fontname: str) -> None: # Make a copy of FreeTypeFont so we can patch the original free_type_font = copy.deepcopy(ImageFont.FreeTypeFont) with monkeypatch.context() as m: @@ -567,7 +587,7 @@ def test_find_font(monkeypatch, platform, font_directory) -> None: _test_fake_loading_font(font_directory + "/Duplicate.ttf", "Duplicate") -def test_imagefont_getters(font) -> None: +def test_imagefont_getters(font: ImageFont.FreeTypeFont) -> None: assert font.getmetrics() == (16, 4) assert font.font.ascent == 16 assert font.font.descent == 4 @@ -588,7 +608,7 @@ def test_imagefont_getters(font) -> None: @pytest.mark.parametrize("stroke_width", (0, 2)) -def test_getsize_stroke(font, stroke_width) -> None: +def test_getsize_stroke(font: ImageFont.FreeTypeFont, stroke_width: int) -> None: assert font.getbbox("A", stroke_width=stroke_width) == ( 0 - stroke_width, 4 - stroke_width, @@ -607,7 +627,7 @@ def test_complex_font_settings() -> None: t.getmask("абвг", language="sr") -def test_variation_get(font) -> None: +def test_variation_get(font: ImageFont.FreeTypeFont) -> None: freetype = parse_version(features.version_module("freetype2")) if freetype < parse_version("2.9.1"): with pytest.raises(NotImplementedError): @@ -662,7 +682,7 @@ def test_variation_get(font) -> None: ] -def _check_text(font, path, epsilon): +def _check_text(font: ImageFont.FreeTypeFont, path: str, epsilon: float) -> None: im = Image.new("RGB", (100, 75), "white") d = ImageDraw.Draw(im) d.text((10, 10), "Text", font=font, fill="black") @@ -677,7 +697,7 @@ def _check_text(font, path, epsilon): raise -def test_variation_set_by_name(font) -> None: +def test_variation_set_by_name(font: ImageFont.FreeTypeFont) -> None: freetype = parse_version(features.version_module("freetype2")) if freetype < parse_version("2.9.1"): with pytest.raises(NotImplementedError): @@ -702,7 +722,7 @@ def test_variation_set_by_name(font) -> None: _check_text(font, "Tests/images/variation_tiny_name.png", 40) -def test_variation_set_by_axes(font) -> None: +def test_variation_set_by_axes(font: ImageFont.FreeTypeFont) -> None: freetype = parse_version(features.version_module("freetype2")) if freetype < parse_version("2.9.1"): with pytest.raises(NotImplementedError): @@ -737,7 +757,9 @@ def test_variation_set_by_axes(font) -> None: ), ids=("ls", "ms", "rs", "ma", "mt", "mm", "mb", "md"), ) -def test_anchor(layout_engine, anchor, left, top) -> None: +def test_anchor( + layout_engine: ImageFont.Layout, anchor: str, left: int, top: int +) -> None: name, text = "quick", "Quick" path = f"Tests/images/test_anchor_{name}_{anchor}.png" @@ -782,7 +804,9 @@ def test_anchor(layout_engine, anchor, left, top) -> None: ("md", "center"), ), ) -def test_anchor_multiline(layout_engine, anchor, align) -> None: +def test_anchor_multiline( + layout_engine: ImageFont.Layout, anchor: str, align: str +) -> None: target = f"Tests/images/test_anchor_multiline_{anchor}_{align}.png" text = "a\nlong\ntext sample" @@ -800,7 +824,7 @@ def test_anchor_multiline(layout_engine, anchor, align) -> None: assert_image_similar_tofile(im, target, 4) -def test_anchor_invalid(font) -> None: +def test_anchor_invalid(font: ImageFont.FreeTypeFont) -> None: im = Image.new("RGB", (100, 100), "white") d = ImageDraw.Draw(im) d.font = font @@ -826,7 +850,7 @@ def test_anchor_invalid(font) -> None: @pytest.mark.parametrize("bpp", (1, 2, 4, 8)) -def test_bitmap_font(layout_engine, bpp) -> None: +def test_bitmap_font(layout_engine: ImageFont.Layout, bpp: int) -> None: text = "Bitmap Font" layout_name = ["basic", "raqm"][layout_engine] target = f"Tests/images/bitmap_font_{bpp}_{layout_name}.png" @@ -843,7 +867,7 @@ def test_bitmap_font(layout_engine, bpp) -> None: assert_image_equal_tofile(im, target) -def test_bitmap_font_stroke(layout_engine) -> None: +def test_bitmap_font_stroke(layout_engine: ImageFont.Layout) -> None: text = "Bitmap Font" layout_name = ["basic", "raqm"][layout_engine] target = f"Tests/images/bitmap_font_stroke_{layout_name}.png" @@ -861,7 +885,7 @@ def test_bitmap_font_stroke(layout_engine) -> None: @pytest.mark.parametrize("embedded_color", (False, True)) -def test_bitmap_blend(layout_engine, embedded_color) -> None: +def test_bitmap_blend(layout_engine: ImageFont.Layout, embedded_color: bool) -> None: font = ImageFont.truetype( "Tests/fonts/EBDTTestFont.ttf", size=64, layout_engine=layout_engine ) @@ -873,7 +897,7 @@ def test_bitmap_blend(layout_engine, embedded_color) -> None: assert_image_equal_tofile(im, "Tests/images/bitmap_font_blend.png") -def test_standard_embedded_color(layout_engine) -> None: +def test_standard_embedded_color(layout_engine: ImageFont.Layout) -> None: txt = "Hello World!" ttf = ImageFont.truetype(FONT_PATH, 40, layout_engine=layout_engine) ttf.getbbox(txt) @@ -886,7 +910,7 @@ def test_standard_embedded_color(layout_engine) -> None: @pytest.mark.parametrize("fontmode", ("1", "L", "RGBA")) -def test_float_coord(layout_engine, fontmode): +def test_float_coord(layout_engine: ImageFont.Layout, fontmode: str) -> None: txt = "Hello World!" ttf = ImageFont.truetype(FONT_PATH, 40, layout_engine=layout_engine) @@ -908,7 +932,7 @@ def test_float_coord(layout_engine, fontmode): raise -def test_cbdt(layout_engine) -> None: +def test_cbdt(layout_engine: ImageFont.Layout) -> None: try: font = ImageFont.truetype( "Tests/fonts/CBDTTestFont.ttf", size=64, layout_engine=layout_engine @@ -925,7 +949,7 @@ def test_cbdt(layout_engine) -> None: pytest.skip("freetype compiled without libpng or CBDT support") -def test_cbdt_mask(layout_engine) -> None: +def test_cbdt_mask(layout_engine: ImageFont.Layout) -> None: try: font = ImageFont.truetype( "Tests/fonts/CBDTTestFont.ttf", size=64, layout_engine=layout_engine @@ -942,7 +966,7 @@ def test_cbdt_mask(layout_engine) -> None: pytest.skip("freetype compiled without libpng or CBDT support") -def test_sbix(layout_engine) -> None: +def test_sbix(layout_engine: ImageFont.Layout) -> None: try: font = ImageFont.truetype( "Tests/fonts/chromacheck-sbix.woff", size=300, layout_engine=layout_engine @@ -959,7 +983,7 @@ def test_sbix(layout_engine) -> None: pytest.skip("freetype compiled without libpng or SBIX support") -def test_sbix_mask(layout_engine) -> None: +def test_sbix_mask(layout_engine: ImageFont.Layout) -> None: try: font = ImageFont.truetype( "Tests/fonts/chromacheck-sbix.woff", size=300, layout_engine=layout_engine @@ -977,7 +1001,7 @@ def test_sbix_mask(layout_engine) -> None: @skip_unless_feature_version("freetype2", "2.10.0") -def test_colr(layout_engine) -> None: +def test_colr(layout_engine: ImageFont.Layout) -> None: font = ImageFont.truetype( "Tests/fonts/BungeeColor-Regular_colr_Windows.ttf", size=64, @@ -993,7 +1017,7 @@ def test_colr(layout_engine) -> None: @skip_unless_feature_version("freetype2", "2.10.0") -def test_colr_mask(layout_engine) -> None: +def test_colr_mask(layout_engine: ImageFont.Layout) -> None: font = ImageFont.truetype( "Tests/fonts/BungeeColor-Regular_colr_Windows.ttf", size=64, @@ -1008,7 +1032,7 @@ def test_colr_mask(layout_engine) -> None: assert_image_similar_tofile(im, "Tests/images/colr_bungee_mask.png", 22) -def test_woff2(layout_engine) -> None: +def test_woff2(layout_engine: ImageFont.Layout) -> None: try: font = ImageFont.truetype( "Tests/fonts/OpenSans.woff2", @@ -1042,7 +1066,7 @@ def test_render_mono_size() -> None: assert_image_equal_tofile(im, "Tests/images/text_mono.gif") -def test_too_many_characters(font) -> None: +def test_too_many_characters(font: ImageFont.FreeTypeFont) -> None: with pytest.raises(ValueError): font.getlength("A" * 1_000_001) with pytest.raises(ValueError): @@ -1070,7 +1094,7 @@ def test_too_many_characters(font) -> None: "Tests/fonts/oom-4da0210eb7081b0bf15bf16cc4c52ce02c1e1bbc.ttf", ], ) -def test_oom(test_file) -> None: +def test_oom(test_file: str) -> None: with open(test_file, "rb") as f: font = ImageFont.truetype(BytesIO(f.read())) with pytest.raises(Image.DecompressionBombError): @@ -1091,6 +1115,8 @@ def test_raqm_missing_warning(monkeypatch) -> None: @pytest.mark.parametrize("size", [-1, 0]) -def test_invalid_truetype_sizes_raise_valueerror(layout_engine, size) -> None: +def test_invalid_truetype_sizes_raise_valueerror( + layout_engine: ImageFont.Layout, size: int +) -> None: with pytest.raises(ValueError): ImageFont.truetype(FONT_PATH, size, layout_engine=layout_engine) diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 50bf404ae..b320e79c1 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -14,7 +14,7 @@ from .helper import ( class Deformer: - def getmesh(self, im): + def getmesh(self, im: Image.Image) -> list[tuple[tuple[int, ...], tuple[int, ...]]]: x, y = im.size return [((0, 0, x, y), (0, 0, x, 0, x, y, y, 0))] @@ -108,7 +108,7 @@ def test_fit_same_ratio() -> None: @pytest.mark.parametrize("new_size", ((256, 256), (512, 256), (256, 512))) -def test_contain(new_size) -> None: +def test_contain(new_size: tuple[int, int]) -> None: im = hopper() new_im = ImageOps.contain(im, new_size) assert new_im.size == (256, 256) @@ -132,7 +132,7 @@ def test_contain_round() -> None: ("hopper.png", (256, 256)), # square ), ) -def test_cover(image_name, expected_size) -> None: +def test_cover(image_name: str, expected_size: tuple[int, int]) -> None: with Image.open("Tests/images/" + image_name) as im: new_im = ImageOps.cover(im, (256, 256)) assert new_im.size == expected_size @@ -168,7 +168,7 @@ def test_pad_round() -> None: @pytest.mark.parametrize("mode", ("P", "PA")) -def test_palette(mode) -> None: +def test_palette(mode: str) -> None: im = hopper(mode) # Expand @@ -210,7 +210,7 @@ def test_scale() -> None: @pytest.mark.parametrize("border", (10, (1, 2, 3, 4))) -def test_expand_palette(border) -> None: +def test_expand_palette(border: int | tuple[int, int, int, int]) -> None: with Image.open("Tests/images/p_16.tga") as im: im_expanded = ImageOps.expand(im, border, (255, 0, 0)) @@ -366,7 +366,7 @@ def test_exif_transpose() -> None: for ext in exts: with Image.open("Tests/images/hopper" + ext) as base_im: - def check(orientation_im) -> None: + def check(orientation_im: Image.Image) -> None: for im in [ orientation_im, orientation_im.copy(), @@ -445,7 +445,7 @@ def test_autocontrast_cutoff() -> None: # Test the cutoff argument of autocontrast with Image.open("Tests/images/bw_gradient.png") as img: - def autocontrast(cutoff): + def autocontrast(cutoff: int | tuple[int, int]): return ImageOps.autocontrast(img, cutoff).histogram() assert autocontrast(10) == autocontrast((10, 10)) @@ -486,20 +486,20 @@ def test_autocontrast_mask_real_input() -> None: assert result_nomask != result assert_tuple_approx_equal( ImageStat.Stat(result, mask=rect_mask).median, - [195, 202, 184], + (195, 202, 184), threshold=2, msg="autocontrast with mask pixel incorrect", ) assert_tuple_approx_equal( ImageStat.Stat(result_nomask).median, - [119, 106, 79], + (119, 106, 79), threshold=2, msg="autocontrast without mask pixel incorrect", ) def test_autocontrast_preserve_tone() -> None: - def autocontrast(mode, preserve_tone): + def autocontrast(mode: str, preserve_tone: bool) -> Image.Image: im = hopper(mode) return ImageOps.autocontrast(im, preserve_tone=preserve_tone).histogram() @@ -533,7 +533,7 @@ def test_autocontrast_preserve_gradient() -> None: @pytest.mark.parametrize( "color", ((255, 255, 255), (127, 255, 0), (127, 127, 127), (0, 0, 0)) ) -def test_autocontrast_preserve_one_color(color) -> None: +def test_autocontrast_preserve_one_color(color: tuple[int, int, int]) -> None: img = Image.new("RGB", (10, 10), color) # single color images shouldn't change diff --git a/Tests/test_imageops_usm.py b/Tests/test_imageops_usm.py index 03302e20f..519d79105 100644 --- a/Tests/test_imageops_usm.py +++ b/Tests/test_imageops_usm.py @@ -1,12 +1,14 @@ from __future__ import annotations +from typing import Generator + import pytest from PIL import Image, ImageFilter @pytest.fixture -def test_images(): +def test_images() -> Generator[dict[str, Image.Image], None, None]: ims = { "im": Image.open("Tests/images/hopper.ppm"), "snakes": Image.open("Tests/images/color_snakes.png"), @@ -18,7 +20,7 @@ def test_images(): im.close() -def test_filter_api(test_images) -> None: +def test_filter_api(test_images: dict[str, Image.Image]) -> None: im = test_images["im"] test_filter = ImageFilter.GaussianBlur(2.0) @@ -32,7 +34,7 @@ def test_filter_api(test_images) -> None: assert i.size == (128, 128) -def test_usm_formats(test_images) -> None: +def test_usm_formats(test_images: dict[str, Image.Image]) -> None: im = test_images["im"] usm = ImageFilter.UnsharpMask @@ -50,7 +52,7 @@ def test_usm_formats(test_images) -> None: im.convert("YCbCr").filter(usm) -def test_blur_formats(test_images) -> None: +def test_blur_formats(test_images: dict[str, Image.Image]) -> None: im = test_images["im"] blur = ImageFilter.GaussianBlur @@ -68,7 +70,7 @@ def test_blur_formats(test_images) -> None: im.convert("YCbCr").filter(blur) -def test_usm_accuracy(test_images) -> None: +def test_usm_accuracy(test_images: dict[str, Image.Image]) -> None: snakes = test_images["snakes"] src = snakes.convert("RGB") @@ -77,7 +79,7 @@ def test_usm_accuracy(test_images) -> None: assert i.tobytes() == src.tobytes() -def test_blur_accuracy(test_images) -> None: +def test_blur_accuracy(test_images: dict[str, Image.Image]) -> None: snakes = test_images["snakes"] i = snakes.filter(ImageFilter.GaussianBlur(0.4)) diff --git a/Tests/test_imagepath.py b/Tests/test_imagepath.py index 8ba745f21..bd600b177 100644 --- a/Tests/test_imagepath.py +++ b/Tests/test_imagepath.py @@ -3,6 +3,7 @@ from __future__ import annotations import array import math import struct +from typing import Sequence import pytest @@ -75,7 +76,9 @@ def test_path_constructors(coords) -> None: [[0.0, 1.0]], ), ) -def test_invalid_path_constructors(coords) -> None: +def test_invalid_path_constructors( + coords: tuple[str, str] | Sequence[Sequence[int]] +) -> None: # Act with pytest.raises(ValueError) as e: ImagePath.Path(coords) @@ -93,7 +96,7 @@ def test_invalid_path_constructors(coords) -> None: [0, 1, 2], ), ) -def test_path_odd_number_of_coordinates(coords) -> None: +def test_path_odd_number_of_coordinates(coords: Sequence[int]) -> None: # Act with pytest.raises(ValueError) as e: ImagePath.Path(coords) @@ -111,7 +114,9 @@ def test_path_odd_number_of_coordinates(coords) -> None: (1, (0.0, 0.0, 0.0, 0.0)), ], ) -def test_getbbox(coords, expected) -> None: +def test_getbbox( + coords: int | list[int], expected: tuple[float, float, float, float] +) -> None: # Arrange p = ImagePath.Path(coords) @@ -135,7 +140,7 @@ def test_getbbox_no_args() -> None: (list(range(6)), [(0.0, 3.0), (4.0, 9.0), (8.0, 15.0)]), ], ) -def test_map(coords, expected) -> None: +def test_map(coords: int | list[int], expected: list[tuple[float, float]]) -> None: # Arrange p = ImagePath.Path(coords) From 96fc60d5d2aa0ad13be0951efb1fe990a64f190a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 17 Feb 2024 20:21:25 +1100 Subject: [PATCH 091/361] Removed mypy excludes --- pyproject.toml | 4 ---- tox.ini | 1 + 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 48c59f2a1..e687f4bcf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -140,7 +140,3 @@ follow_imports = "silent" warn_redundant_casts = true warn_unreachable = true warn_unused_ignores = true -exclude = [ - '^src/PIL/FpxImagePlugin.py$', - '^src/PIL/MicImagePlugin.py$', -] diff --git a/tox.ini b/tox.ini index 8c818df7a..3ef011c9e 100644 --- a/tox.ini +++ b/tox.ini @@ -41,6 +41,7 @@ deps = packaging types-cffi types-defusedxml + types-olefile extras = typing commands = From b6fdf2e9e7a65bf23cac224b2ab96c6b1d2c8449 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 19 Feb 2024 07:54:58 +1100 Subject: [PATCH 092/361] Updated package name for Tidelift --- .github/FUNDING.yml | 2 +- README.md | 2 +- docs/conf.py | 2 +- docs/index.rst | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index e0e6804bf..8fc6bd0ad 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1 @@ -tidelift: "pypi/Pillow" +tidelift: "pypi/pillow" diff --git a/README.md b/README.md index 6ca870166..9776c40e2 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ As of 2019, Pillow development is src="https://zenodo.org/badge/17549/python-pillow/Pillow.svg"> Tidelift + src="https://tidelift.com/badges/package/pypi/pillow?style=flat"> Newest PyPI version diff --git a/docs/conf.py b/docs/conf.py index 9ae7ae605..97289c91d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -326,7 +326,7 @@ linkcheck_allowed_redirects = { r"https://gitter.im/python-pillow/Pillow?.*": r"https://app.gitter.im/#/room/#python-pillow_Pillow:gitter.im?.*", r"https://pillow.readthedocs.io/?badge=latest": r"https://pillow.readthedocs.io/en/stable/?badge=latest", r"https://pillow.readthedocs.io": r"https://pillow.readthedocs.io/en/stable/", - r"https://tidelift.com/badges/package/pypi/Pillow?.*": r"https://img.shields.io/badge/.*", + r"https://tidelift.com/badges/package/pypi/pillow?.*": r"https://img.shields.io/badge/.*", r"https://zenodo.org/badge/17549/python-pillow/Pillow.svg": r"https://zenodo.org/badge/doi/[\.0-9]+/zenodo.[0-9]+.svg", r"https://zenodo.org/badge/latestdoi/17549/python-pillow/Pillow": r"https://zenodo.org/record/[0-9]+", } diff --git a/docs/index.rst b/docs/index.rst index 558369919..bf2feea9a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -49,7 +49,7 @@ Pillow for enterprise is available via the Tidelift Subscription. `Learn more Date: Mon, 19 Feb 2024 17:08:21 +1100 Subject: [PATCH 093/361] Added image to illustrate size vs bbox --- docs/deprecations.rst | 8 ++++++-- docs/example/size_vs_bbox.png | Bin 0 -> 12934 bytes docs/releasenotes/9.2.0.rst | 8 ++++++-- 3 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 docs/example/size_vs_bbox.png diff --git a/docs/deprecations.rst b/docs/deprecations.rst index c90ad481a..74021a218 100644 --- a/docs/deprecations.rst +++ b/docs/deprecations.rst @@ -253,8 +253,12 @@ Use instead:: width, height = right - left, bottom - top Previously, the ``size`` methods returned a ``height`` that included the vertical -offset of the text, while the new ``bbox`` methods explicitly distinguish this as a -``top`` offset. +offset of the text, while the new ``bbox`` methods distinguish this as a ``top`` +offset. + +.. image:: ./example/size_vs_bbox.png + :alt: Demonstration of size height vs bbox top and bottom + :align: center FreeTypeFont.getmask2 fill parameter ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/example/size_vs_bbox.png b/docs/example/size_vs_bbox.png new file mode 100644 index 0000000000000000000000000000000000000000..11a05d2a8146a612741c7e4ec4d3cf0ce05a35a0 GIT binary patch literal 12934 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 literal 0 HcmV?d00001 diff --git a/docs/releasenotes/9.2.0.rst b/docs/releasenotes/9.2.0.rst index 495926ca7..db514068f 100644 --- a/docs/releasenotes/9.2.0.rst +++ b/docs/releasenotes/9.2.0.rst @@ -90,8 +90,12 @@ Use instead:: width, height = right - left, bottom - top Previously, the ``size`` methods returned a ``height`` that included the vertical -offset of the text, while the new ``bbox`` methods explicitly distinguish this as a -``top`` offset. +offset of the text, while the new ``bbox`` methods distinguish this as a ``top`` +offset. + +.. image:: ./example/size_vs_bbox.png + :alt: Demonstration of size height vs bbox top and bottom + :align: center API Additions ============= From 0692ad8cdd4fe979478d910b705dcc12f8e82f61 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 19 Feb 2024 23:29:58 +1100 Subject: [PATCH 094/361] Updated giflib on macOS to 5.2.2 --- .github/workflows/wheels-dependencies.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 26bf2f6d6..1ec2811f6 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -24,7 +24,7 @@ XZ_VERSION=5.4.5 TIFF_VERSION=4.6.0 LCMS2_VERSION=2.16 if [[ -n "$IS_MACOS" ]]; then - GIFLIB_VERSION=5.1.4 + GIFLIB_VERSION=5.2.2 else GIFLIB_VERSION=5.2.1 fi From fe1edb1e0f8608e3924f67b53776f653c418ddfe Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Mon, 19 Feb 2024 16:47:07 +0200 Subject: [PATCH 095/361] Install mypy from requirements file So Renovate can update it on a schedule --- .ci/requirements-mypy.txt | 1 + tox.ini | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 .ci/requirements-mypy.txt diff --git a/.ci/requirements-mypy.txt b/.ci/requirements-mypy.txt new file mode 100644 index 000000000..ed3269460 --- /dev/null +++ b/.ci/requirements-mypy.txt @@ -0,0 +1 @@ +mypy==1.7.1 diff --git a/tox.ini b/tox.ini index 8c818df7a..85800ff8d 100644 --- a/tox.ini +++ b/tox.ini @@ -33,10 +33,10 @@ commands = [testenv:mypy] skip_install = true deps = + -r .ci/requirements-mypy.txt IceSpringPySideStubs-PyQt6 IceSpringPySideStubs-PySide6 ipython - mypy==1.7.1 numpy packaging types-cffi From 7490aee8362c589a475035bf86c05b29c894593c Mon Sep 17 00:00:00 2001 From: Nulano Date: Mon, 19 Feb 2024 20:18:18 +0100 Subject: [PATCH 096/361] Show how to use anchors to align text in imagefont deprecations --- docs/deprecations.rst | 24 ++++++++++++++++++++++++ docs/releasenotes/9.2.0.rst | 24 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/docs/deprecations.rst b/docs/deprecations.rst index 74021a218..9efb1316d 100644 --- a/docs/deprecations.rst +++ b/docs/deprecations.rst @@ -260,6 +260,30 @@ offset. :alt: Demonstration of size height vs bbox top and bottom :align: center +If you are using these methods for aligning text, consider using :ref:`text-anchors` instead +which avoid issues that can occur with non-English text or unusual fonts. +For example, instead of the following code:: + + from PIL import Image, ImageDraw, ImageFont + + font = ImageFont.truetype("Tests/fonts/FreeMono.ttf") + + im = Image.new("RGB", (100, 100)) + draw = ImageDraw.Draw(im) + width, height = draw.textsize("Hello world", font) + x, y = (100 - width) / 2, (100 - height) / 2 + draw.text((x, y), "Hello world", font=font) + +Use instead:: + + from PIL import Image, ImageDraw, ImageFont + + font = ImageFont.truetype("Tests/fonts/FreeMono.ttf") + + im = Image.new("RGB", (100, 100)) + draw = ImageDraw.Draw(im) + draw.text((100 / 2, 100 / 2), "Hello world", font=font, anchor="mm") + FreeTypeFont.getmask2 fill parameter ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/releasenotes/9.2.0.rst b/docs/releasenotes/9.2.0.rst index db514068f..e8bf33b60 100644 --- a/docs/releasenotes/9.2.0.rst +++ b/docs/releasenotes/9.2.0.rst @@ -97,6 +97,30 @@ offset. :alt: Demonstration of size height vs bbox top and bottom :align: center +If you are using these methods for aligning text, consider using :ref:`text-anchors` instead +which avoid issues that can occur with non-English text or unusual fonts. +For example, instead of the following code:: + + from PIL import Image, ImageDraw, ImageFont + + font = ImageFont.truetype("Tests/fonts/FreeMono.ttf") + + im = Image.new("RGB", (100, 100)) + draw = ImageDraw.Draw(im) + width, height = draw.textsize("Hello world", font) + x, y = (100 - width) / 2, (100 - height) / 2 + draw.text((x, y), "Hello world", font=font) + +Use instead:: + + from PIL import Image, ImageDraw, ImageFont + + font = ImageFont.truetype("Tests/fonts/FreeMono.ttf") + + im = Image.new("RGB", (100, 100)) + draw = ImageDraw.Draw(im) + draw.text((100 / 2, 100 / 2), "Hello world", font=font, anchor="mm") + API Additions ============= From 531b1e1b9a6b3f83519d9b6687523474f4a18d83 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Mon, 19 Feb 2024 22:50:06 +0200 Subject: [PATCH 097/361] Remove outdated installation warnings --- docs/installation.rst | 9 --------- 1 file changed, 9 deletions(-) diff --git a/docs/installation.rst b/docs/installation.rst index 116bdcf2f..980bbd99d 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -9,15 +9,6 @@ Installation }); -Warnings --------- - -.. warning:: Pillow and PIL cannot co-exist in the same environment. Before installing Pillow, please uninstall PIL. - -.. warning:: Pillow >= 1.0 no longer supports ``import Image``. Please use ``from PIL import Image`` instead. - -.. warning:: Pillow >= 2.1.0 no longer supports ``import _imaging``. Please use ``from PIL.Image import core as _imaging`` instead. - Python Support -------------- From e39765d755cc2d37e79d07f58ebc77a8e44812c6 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 20 Feb 2024 15:41:20 +1100 Subject: [PATCH 098/361] Added type hints --- Tests/test_deprecate.py | 6 +++--- Tests/test_file_ico.py | 2 +- Tests/test_file_iptc.py | 2 +- Tests/test_file_msp.py | 2 +- Tests/test_file_png.py | 2 ++ Tests/test_file_psd.py | 2 +- Tests/test_file_tga.py | 4 ++-- Tests/test_file_tiff_metadata.py | 6 ++++-- Tests/test_imagecms.py | 28 ++++++++++++++++++++++------ Tests/test_imagefont.py | 22 ++++++++++++---------- Tests/test_imagegrab.py | 6 ++++-- Tests/test_imagepath.py | 8 +++++--- Tests/test_imageqt.py | 2 +- Tests/test_imageshow.py | 10 ++++++---- Tests/test_imagewin_pointers.py | 2 +- Tests/test_numpy.py | 6 +++--- Tests/test_qt_image_qapplication.py | 4 ++-- Tests/test_qt_image_toqimage.py | 2 +- Tests/test_util.py | 2 +- 19 files changed, 73 insertions(+), 45 deletions(-) diff --git a/Tests/test_deprecate.py b/Tests/test_deprecate.py index 6ffc8f6f5..584d8f91d 100644 --- a/Tests/test_deprecate.py +++ b/Tests/test_deprecate.py @@ -20,7 +20,7 @@ from PIL import _deprecate ), ], ) -def test_version(version, expected) -> None: +def test_version(version: int | None, expected: str) -> None: with pytest.warns(DeprecationWarning, match=expected): _deprecate.deprecate("Old thing", version, "new thing") @@ -46,7 +46,7 @@ def test_unknown_version() -> None: ), ], ) -def test_old_version(deprecated, plural, expected) -> None: +def test_old_version(deprecated: str, plural: bool, expected: str) -> None: expected = r"" with pytest.raises(RuntimeError, match=expected): _deprecate.deprecate(deprecated, 1, plural=plural) @@ -76,7 +76,7 @@ def test_replacement_and_action() -> None: "Upgrade to new thing.", ], ) -def test_action(action) -> None: +def test_action(action: str) -> None: expected = ( r"Old thing is deprecated and will be removed in Pillow 11 \(2024-10-15\)\. " r"Upgrade to new thing\." diff --git a/Tests/test_file_ico.py b/Tests/test_file_ico.py index 65f090931..f69a290fa 100644 --- a/Tests/test_file_ico.py +++ b/Tests/test_file_ico.py @@ -135,7 +135,7 @@ def test_different_bit_depths(tmp_path: Path) -> None: @pytest.mark.parametrize("mode", ("1", "L", "P", "RGB", "RGBA")) -def test_save_to_bytes_bmp(mode) -> None: +def test_save_to_bytes_bmp(mode: str) -> None: output = io.BytesIO() im = hopper(mode) im.save(output, "ico", bitmap_format="bmp", sizes=[(32, 32), (64, 64)]) diff --git a/Tests/test_file_iptc.py b/Tests/test_file_iptc.py index 9c0969437..88c30d468 100644 --- a/Tests/test_file_iptc.py +++ b/Tests/test_file_iptc.py @@ -98,7 +98,7 @@ def test_i() -> None: assert ret == 97 -def test_dump(monkeypatch) -> None: +def test_dump(monkeypatch: pytest.MonkeyPatch) -> None: # Arrange c = b"abc" # Temporarily redirect stdout diff --git a/Tests/test_file_msp.py b/Tests/test_file_msp.py index f9f81d114..b0964aabe 100644 --- a/Tests/test_file_msp.py +++ b/Tests/test_file_msp.py @@ -52,7 +52,7 @@ def test_open_windows_v1() -> None: assert isinstance(im, MspImagePlugin.MspImageFile) -def _assert_file_image_equal(source_path, target_path) -> None: +def _assert_file_image_equal(source_path: str, target_path: str) -> None: with Image.open(source_path) as im: assert_image_equal_tofile(im, target_path) diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index d4a634316..c51f56ce7 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -6,6 +6,7 @@ import warnings import zlib from io import BytesIO from pathlib import Path +from types import ModuleType from typing import Any import pytest @@ -23,6 +24,7 @@ from .helper import ( skip_unless_feature, ) +ElementTree: ModuleType | None try: from defusedxml import ElementTree except ImportError: diff --git a/Tests/test_file_psd.py b/Tests/test_file_psd.py index 7eca8d9b1..e60638b22 100644 --- a/Tests/test_file_psd.py +++ b/Tests/test_file_psd.py @@ -157,7 +157,7 @@ def test_combined_larger_than_size() -> None: ("Tests/images/timeout-dedc7a4ebd856d79b4359bbcc79e8ef231ce38f6.psd", OSError), ], ) -def test_crashes(test_file, raises) -> None: +def test_crashes(test_file: str, raises) -> None: with open(test_file, "rb") as f: with pytest.raises(raises): with Image.open(f): diff --git a/Tests/test_file_tga.py b/Tests/test_file_tga.py index bd8e522c7..3c6da50c5 100644 --- a/Tests/test_file_tga.py +++ b/Tests/test_file_tga.py @@ -22,8 +22,8 @@ _ORIGIN_TO_ORIENTATION = {"tl": 1, "bl": -1} @pytest.mark.parametrize("mode", _MODES) -def test_sanity(mode, tmp_path: Path) -> None: - def roundtrip(original_im) -> None: +def test_sanity(mode: str, tmp_path: Path) -> None: + def roundtrip(original_im: Image.Image) -> None: out = str(tmp_path / "temp.tga") original_im.save(out, rle=rle) diff --git a/Tests/test_file_tiff_metadata.py b/Tests/test_file_tiff_metadata.py index bb6225d07..d7a18c725 100644 --- a/Tests/test_file_tiff_metadata.py +++ b/Tests/test_file_tiff_metadata.py @@ -189,7 +189,9 @@ def test_iptc(tmp_path: Path) -> None: @pytest.mark.parametrize("value, expected", ((b"test", "test"), (1, "1"))) -def test_writing_other_types_to_ascii(value, expected, tmp_path: Path) -> None: +def test_writing_other_types_to_ascii( + value: bytes | int, expected: str, tmp_path: Path +) -> None: info = TiffImagePlugin.ImageFileDirectory_v2() tag = TiffTags.TAGS_V2[271] @@ -206,7 +208,7 @@ def test_writing_other_types_to_ascii(value, expected, tmp_path: Path) -> None: @pytest.mark.parametrize("value", (1, IFDRational(1))) -def test_writing_other_types_to_bytes(value, tmp_path: Path) -> None: +def test_writing_other_types_to_bytes(value: int | IFDRational, tmp_path: Path) -> None: im = hopper() info = TiffImagePlugin.ImageFileDirectory_v2() diff --git a/Tests/test_imagecms.py b/Tests/test_imagecms.py index 83fc38ed3..21a0dd75b 100644 --- a/Tests/test_imagecms.py +++ b/Tests/test_imagecms.py @@ -237,7 +237,7 @@ def test_invalid_color_temperature() -> None: @pytest.mark.parametrize("flag", ("my string", -1)) -def test_invalid_flag(flag) -> None: +def test_invalid_flag(flag: str | int) -> None: with hopper() as im: with pytest.raises( ImageCms.PyCMSError, match="flags must be an integer between 0 and " @@ -335,12 +335,26 @@ def test_extended_information() -> None: o = ImageCms.getOpenProfile(SRGB) p = o.profile - def assert_truncated_tuple_equal(tup1, tup2, digits: int = 10) -> None: + def assert_truncated_tuple_equal( + tup1: tuple[tuple[float, float, float], ...] | tuple[float], + tup2: ( + tuple[tuple[tuple[float, float, float], ...], ...] + | tuple[tuple[float, float, float], ...] + | tuple[float] + ), + digits: int = 10, + ) -> None: # Helper function to reduce precision of tuples of floats # recursively and then check equality. power = 10**digits - def truncate_tuple(tuple_or_float): + def truncate_tuple( + tuple_or_float: ( + tuple[tuple[tuple[float, float, float], ...], ...] + | tuple[tuple[float, float, float], ...] + | tuple[float, ...] + ) + ) -> tuple[tuple[float, ...], ...]: return tuple( ( truncate_tuple(val) @@ -504,8 +518,10 @@ def test_profile_typesafety() -> None: ImageCms.ImageCmsProfile(1).tobytes() -def assert_aux_channel_preserved(mode, transform_in_place, preserved_channel) -> None: - def create_test_image(): +def assert_aux_channel_preserved( + mode: str, transform_in_place: bool, preserved_channel: str +) -> None: + def create_test_image() -> Image.Image: # set up test image with something interesting in the tested aux channel. # fmt: off nine_grid_deltas = [ @@ -633,7 +649,7 @@ def test_auxiliary_channels_isolated() -> None: @pytest.mark.parametrize("mode", ("RGB", "RGBA", "RGBX")) -def test_rgb_lab(mode) -> None: +def test_rgb_lab(mode: str) -> None: im = Image.new(mode, (1, 1)) converted_im = im.convert("LAB") assert converted_im.getpixel((0, 0)) == (0, 128, 128) diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index c79b36ca4..05b5d4716 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -7,7 +7,7 @@ import shutil import sys from io import BytesIO from pathlib import Path -from typing import BinaryIO +from typing import Any, BinaryIO import pytest from packaging.version import parse as parse_version @@ -44,7 +44,7 @@ def test_sanity() -> None: pytest.param(ImageFont.Layout.RAQM, marks=skip_unless_feature("raqm")), ], ) -def layout_engine(request): +def layout_engine(request: pytest.FixtureRequest) -> ImageFont.Layout: return request.param @@ -535,21 +535,23 @@ def test_unicode_extended(layout_engine: ImageFont.Layout) -> None: (("linux", "/usr/local/share/fonts"), ("darwin", "/System/Library/Fonts")), ) @pytest.mark.skipif(is_win32(), reason="requires Unix or macOS") -def test_find_font(monkeypatch, platform, font_directory) -> None: +def test_find_font( + monkeypatch: pytest.MonkeyPatch, platform: str, font_directory: str +) -> None: def _test_fake_loading_font(path_to_fake: str, fontname: str) -> None: # Make a copy of FreeTypeFont so we can patch the original free_type_font = copy.deepcopy(ImageFont.FreeTypeFont) with monkeypatch.context() as m: m.setattr(ImageFont, "_FreeTypeFont", free_type_font, raising=False) - def loadable_font(filepath, size, index, encoding, *args, **kwargs): + def loadable_font( + filepath: str, size: int, index: int, encoding: str, *args: Any + ): if filepath == path_to_fake: return ImageFont._FreeTypeFont( - FONT_PATH, size, index, encoding, *args, **kwargs + FONT_PATH, size, index, encoding, *args ) - return ImageFont._FreeTypeFont( - filepath, size, index, encoding, *args, **kwargs - ) + return ImageFont._FreeTypeFont(filepath, size, index, encoding, *args) m.setattr(ImageFont, "FreeTypeFont", loadable_font) font = ImageFont.truetype(fontname) @@ -563,7 +565,7 @@ def test_find_font(monkeypatch, platform, font_directory) -> None: if platform == "linux": monkeypatch.setenv("XDG_DATA_DIRS", "/usr/share/:/usr/local/share/") - def fake_walker(path): + def fake_walker(path: str) -> list[tuple[str, list[str], list[str]]]: if path == font_directory: return [ ( @@ -1101,7 +1103,7 @@ def test_oom(test_file: str) -> None: font.getmask("Test Text") -def test_raqm_missing_warning(monkeypatch) -> None: +def test_raqm_missing_warning(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(ImageFont.core, "HAVE_RAQM", False) with pytest.warns(UserWarning) as record: font = ImageFont.truetype( diff --git a/Tests/test_imagegrab.py b/Tests/test_imagegrab.py index 235a2f993..e23adeb70 100644 --- a/Tests/test_imagegrab.py +++ b/Tests/test_imagegrab.py @@ -84,6 +84,7 @@ $bmp = New-Object Drawing.Bitmap 200, 200 @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") def test_grabclipboard_file(self) -> None: p = subprocess.Popen(["powershell", "-command", "-"], stdin=subprocess.PIPE) + assert p.stdin is not None p.stdin.write(rb'Set-Clipboard -Path "Tests\images\hopper.gif"') p.communicate() @@ -94,6 +95,7 @@ $bmp = New-Object Drawing.Bitmap 200, 200 @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") def test_grabclipboard_png(self) -> None: p = subprocess.Popen(["powershell", "-command", "-"], stdin=subprocess.PIPE) + assert p.stdin is not None p.stdin.write( rb"""$bytes = [System.IO.File]::ReadAllBytes("Tests\images\hopper.png") $ms = new-object System.IO.MemoryStream(, $bytes) @@ -113,7 +115,7 @@ $ms = new-object System.IO.MemoryStream(, $bytes) reason="Linux with wl-clipboard only", ) @pytest.mark.parametrize("ext", ("gif", "png", "ico")) - def test_grabclipboard_wl_clipboard(self, ext) -> None: + def test_grabclipboard_wl_clipboard(self, ext: str) -> None: image_path = "Tests/images/hopper." + ext with open(image_path, "rb") as fp: subprocess.call(["wl-copy"], stdin=fp) @@ -128,6 +130,6 @@ $ms = new-object System.IO.MemoryStream(, $bytes) reason="Linux with wl-clipboard only", ) @pytest.mark.parametrize("arg", ("text", "--clear")) - def test_grabclipboard_wl_clipboard_errors(self, arg): + def test_grabclipboard_wl_clipboard_errors(self, arg: str) -> None: subprocess.call(["wl-copy", arg]) assert ImageGrab.grabclipboard() is None diff --git a/Tests/test_imagepath.py b/Tests/test_imagepath.py index bd600b177..9487560af 100644 --- a/Tests/test_imagepath.py +++ b/Tests/test_imagepath.py @@ -58,7 +58,9 @@ def test_path() -> None: ImagePath.Path((0, 1)), ), ) -def test_path_constructors(coords) -> None: +def test_path_constructors( + coords: Sequence[float] | array.array[float] | ImagePath.Path, +) -> None: # Arrange / Act p = ImagePath.Path(coords) @@ -206,9 +208,9 @@ class Evil: def __init__(self) -> None: self.corrupt = Image.core.path(0x4000000000000000) - def __getitem__(self, i): + def __getitem__(self, i: int) -> bytes: x = self.corrupt[i] return struct.pack("dd", x[0], x[1]) - def __setitem__(self, i, x) -> None: + def __setitem__(self, i: int, x: bytes) -> None: self.corrupt[i] = struct.unpack("dd", x) diff --git a/Tests/test_imageqt.py b/Tests/test_imageqt.py index 909f97167..88ad1f9ee 100644 --- a/Tests/test_imageqt.py +++ b/Tests/test_imageqt.py @@ -28,7 +28,7 @@ def test_rgb() -> None: assert qRgb(0, 0, 0) == qRgba(0, 0, 0, 255) - def checkrgb(r, g, b) -> None: + def checkrgb(r: int, g: int, b: int) -> None: val = ImageQt.rgb(r, g, b) val = val % 2**24 # drop the alpha assert val >> 16 == r diff --git a/Tests/test_imageshow.py b/Tests/test_imageshow.py index f7269d45b..8d741d94a 100644 --- a/Tests/test_imageshow.py +++ b/Tests/test_imageshow.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Any + import pytest from PIL import Image, ImageShow @@ -24,9 +26,9 @@ def test_register() -> None: "order", [-1, 0], ) -def test_viewer_show(order) -> None: +def test_viewer_show(order: int) -> None: class TestViewer(ImageShow.Viewer): - def show_image(self, image, **options) -> bool: + def show_image(self, image: Image.Image, **options: Any) -> bool: self.methodCalled = True return True @@ -48,7 +50,7 @@ def test_viewer_show(order) -> None: reason="Only run on CIs; hangs on Windows CIs", ) @pytest.mark.parametrize("mode", ("1", "I;16", "LA", "RGB", "RGBA")) -def test_show(mode) -> None: +def test_show(mode: str) -> None: im = hopper(mode) assert ImageShow.show(im) @@ -73,7 +75,7 @@ def test_viewer() -> None: @pytest.mark.parametrize("viewer", ImageShow._viewers) -def test_viewers(viewer) -> None: +def test_viewers(viewer: ImageShow.Viewer) -> None: try: viewer.get_command("test.jpg") except NotImplementedError: diff --git a/Tests/test_imagewin_pointers.py b/Tests/test_imagewin_pointers.py index c7f633e62..f59ee7284 100644 --- a/Tests/test_imagewin_pointers.py +++ b/Tests/test_imagewin_pointers.py @@ -70,7 +70,7 @@ if is_win32(): ] CreateDIBSection.restype = ctypes.wintypes.HBITMAP - def serialize_dib(bi, pixels): + def serialize_dib(bi, pixels) -> bytearray: bf = BITMAPFILEHEADER() bf.bfType = 0x4D42 bf.bfOffBits = ctypes.sizeof(bf) + bi.biSize diff --git a/Tests/test_numpy.py b/Tests/test_numpy.py index 6ba95c2d7..9f4e6534e 100644 --- a/Tests/test_numpy.py +++ b/Tests/test_numpy.py @@ -14,7 +14,7 @@ TEST_IMAGE_SIZE = (10, 10) def test_numpy_to_image() -> None: - def to_image(dtype, bands: int = 1, boolean: int = 0): + def to_image(dtype, bands: int = 1, boolean: int = 0) -> Image.Image: if bands == 1: if boolean: data = [0, 255] * 50 @@ -99,7 +99,7 @@ def test_1d_array() -> None: assert_image(Image.fromarray(a), "L", (1, 5)) -def _test_img_equals_nparray(img, np) -> None: +def _test_img_equals_nparray(img: Image.Image, np) -> None: assert len(np.shape) >= 2 np_size = np.shape[1], np.shape[0] assert img.size == np_size @@ -157,7 +157,7 @@ def test_save_tiff_uint16() -> None: ("HSV", numpy.uint8), ), ) -def test_to_array(mode, dtype) -> None: +def test_to_array(mode: str, dtype) -> None: img = hopper(mode) # Resize to non-square diff --git a/Tests/test_qt_image_qapplication.py b/Tests/test_qt_image_qapplication.py index 7d6c0a8cb..3cd323553 100644 --- a/Tests/test_qt_image_qapplication.py +++ b/Tests/test_qt_image_qapplication.py @@ -4,7 +4,7 @@ from pathlib import Path import pytest -from PIL import ImageQt +from PIL import Image, ImageQt from .helper import assert_image_equal_tofile, assert_image_similar, hopper @@ -37,7 +37,7 @@ if ImageQt.qt_is_installed: lbl.setPixmap(pixmap1.copy()) -def roundtrip(expected) -> None: +def roundtrip(expected: Image.Image) -> None: result = ImageQt.fromqpixmap(ImageQt.toqpixmap(expected)) # Qt saves all pixmaps as rgb assert_image_similar(result, expected.convert("RGB"), 1) diff --git a/Tests/test_qt_image_toqimage.py b/Tests/test_qt_image_toqimage.py index a222a7d71..6110be707 100644 --- a/Tests/test_qt_image_toqimage.py +++ b/Tests/test_qt_image_toqimage.py @@ -17,7 +17,7 @@ if ImageQt.qt_is_installed: @pytest.mark.parametrize("mode", ("RGB", "RGBA", "L", "P", "1")) -def test_sanity(mode, tmp_path: Path) -> None: +def test_sanity(mode: str, tmp_path: Path) -> None: src = hopper(mode) data = ImageQt.toqimage(src) diff --git a/Tests/test_util.py b/Tests/test_util.py index 73e4acd55..197ef79ee 100644 --- a/Tests/test_util.py +++ b/Tests/test_util.py @@ -10,7 +10,7 @@ from PIL import _util @pytest.mark.parametrize( "test_path", ["filename.ext", Path("filename.ext"), PurePath("filename.ext")] ) -def test_is_path(test_path) -> None: +def test_is_path(test_path: str | Path | PurePath) -> None: # Act it_is = _util.is_path(test_path) From a655d7606e2f12f0e7700ef754ed92a6da45f658 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 20 Feb 2024 21:27:30 +1100 Subject: [PATCH 099/361] Simplified type hints --- Tests/test_imagecms.py | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/Tests/test_imagecms.py b/Tests/test_imagecms.py index 21a0dd75b..a7bb31db5 100644 --- a/Tests/test_imagecms.py +++ b/Tests/test_imagecms.py @@ -6,6 +6,7 @@ import re import shutil from io import BytesIO from pathlib import Path +from typing import Any import pytest @@ -336,25 +337,13 @@ def test_extended_information() -> None: p = o.profile def assert_truncated_tuple_equal( - tup1: tuple[tuple[float, float, float], ...] | tuple[float], - tup2: ( - tuple[tuple[tuple[float, float, float], ...], ...] - | tuple[tuple[float, float, float], ...] - | tuple[float] - ), - digits: int = 10, + tup1: tuple[Any, ...], tup2: tuple[Any, ...], digits: int = 10 ) -> None: # Helper function to reduce precision of tuples of floats # recursively and then check equality. power = 10**digits - def truncate_tuple( - tuple_or_float: ( - tuple[tuple[tuple[float, float, float], ...], ...] - | tuple[tuple[float, float, float], ...] - | tuple[float, ...] - ) - ) -> tuple[tuple[float, ...], ...]: + def truncate_tuple(tuple_or_float: tuple[Any, ...]) -> tuple[Any, ...]: return tuple( ( truncate_tuple(val) From 64579510c018440edb1b0c9c67f36d478f969f38 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 20 Feb 2024 21:37:06 +1100 Subject: [PATCH 100/361] Updated alt text --- docs/deprecations.rst | 2 +- docs/releasenotes/9.2.0.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/deprecations.rst b/docs/deprecations.rst index 74021a218..9fd64fdaa 100644 --- a/docs/deprecations.rst +++ b/docs/deprecations.rst @@ -257,7 +257,7 @@ offset of the text, while the new ``bbox`` methods distinguish this as a ``top`` offset. .. image:: ./example/size_vs_bbox.png - :alt: Demonstration of size height vs bbox top and bottom + :alt: In bbox methods, top measures the vertical distance above the text, while bottom measures that plus the vertical distance of the text itself. In size methods, height also measures the vertical distance above the text plus the vertical distance of the text itself. :align: center FreeTypeFont.getmask2 fill parameter diff --git a/docs/releasenotes/9.2.0.rst b/docs/releasenotes/9.2.0.rst index db514068f..b596a6ab2 100644 --- a/docs/releasenotes/9.2.0.rst +++ b/docs/releasenotes/9.2.0.rst @@ -94,7 +94,7 @@ offset of the text, while the new ``bbox`` methods distinguish this as a ``top`` offset. .. image:: ./example/size_vs_bbox.png - :alt: Demonstration of size height vs bbox top and bottom + :alt: In bbox methods, top measures the vertical distance above the text, while bottom measures that plus the vertical distance of the text itself. In size methods, height also measures the vertical distance above the text plus the vertical distance of the text itself. :align: center API Additions From 56a02b76eb403356436e6480c5ada23930825e36 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Tue, 20 Feb 2024 21:37:40 +1100 Subject: [PATCH 101/361] Corrected image path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ondrej Baranovič --- docs/releasenotes/9.2.0.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releasenotes/9.2.0.rst b/docs/releasenotes/9.2.0.rst index b596a6ab2..677438bc6 100644 --- a/docs/releasenotes/9.2.0.rst +++ b/docs/releasenotes/9.2.0.rst @@ -93,7 +93,7 @@ Previously, the ``size`` methods returned a ``height`` that included the vertica offset of the text, while the new ``bbox`` methods distinguish this as a ``top`` offset. -.. image:: ./example/size_vs_bbox.png +.. image:: ../example/size_vs_bbox.png :alt: In bbox methods, top measures the vertical distance above the text, while bottom measures that plus the vertical distance of the text itself. In size methods, height also measures the vertical distance above the text plus the vertical distance of the text itself. :align: center From 5b20811cabd4594fd4fafc596e3f94afe12bb361 Mon Sep 17 00:00:00 2001 From: Nulano Date: Tue, 20 Feb 2024 20:36:36 +0100 Subject: [PATCH 102/361] Add `--bugreport` argument to __main__.py to omit supported formats --- src/PIL/__main__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/PIL/__main__.py b/src/PIL/__main__.py index 943789923..32de33624 100644 --- a/src/PIL/__main__.py +++ b/src/PIL/__main__.py @@ -1,5 +1,7 @@ from __future__ import annotations +import sys + from .features import pilinfo -pilinfo() +pilinfo(supported_formats="--bugreport" not in sys.argv) From 10712be53d575a37d9dc2522c9f8f0f62871f3b6 Mon Sep 17 00:00:00 2001 From: Nulano Date: Tue, 20 Feb 2024 21:21:10 +0100 Subject: [PATCH 103/361] Build docs for Python changes --- .github/workflows/docs.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4319cc8ff..92e860cb5 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -7,10 +7,12 @@ on: paths: - ".github/workflows/docs.yml" - "docs/**" + - "src/PIL/**" pull_request: paths: - ".github/workflows/docs.yml" - "docs/**" + - "src/PIL/**" workflow_dispatch: permissions: From ab9dfd8181868922abee46dffb11ffcb9a772958 Mon Sep 17 00:00:00 2001 From: Nulano Date: Tue, 20 Feb 2024 20:37:33 +0100 Subject: [PATCH 104/361] Add sys.{executable,base_prefix,prefix} to features.pilinfo --- Tests/test_features.py | 12 +++++++++--- Tests/test_main.py | 12 +++++++++--- src/PIL/features.py | 9 +++++++-- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/Tests/test_features.py b/Tests/test_features.py index 8d2d198ff..3fffa032f 100644 --- a/Tests/test_features.py +++ b/Tests/test_features.py @@ -129,9 +129,15 @@ def test_pilinfo() -> None: while lines[0].startswith(" "): lines = lines[1:] assert lines[0] == "-" * 68 - assert lines[1].startswith("Python modules loaded from ") - assert lines[2].startswith("Binary modules loaded from ") - assert lines[3] == "-" * 68 + assert lines[1].startswith("Python executable is") + lines = lines[2:] + if lines[0].startswith("Environment Python files loaded from"): + lines = lines[1:] + assert lines[0].startswith("System Python files loaded from") + assert lines[1] == "-" * 68 + assert lines[2].startswith("Python Pillow modules loaded from ") + assert lines[3].startswith("Binary Pillow modules loaded from ") + assert lines[4] == "-" * 68 jpeg = ( "\n" + "-" * 68 diff --git a/Tests/test_main.py b/Tests/test_main.py index 46259f1dc..e13e0c5e3 100644 --- a/Tests/test_main.py +++ b/Tests/test_main.py @@ -15,9 +15,15 @@ def test_main() -> None: while lines[0].startswith(" "): lines = lines[1:] assert lines[0] == "-" * 68 - assert lines[1].startswith("Python modules loaded from ") - assert lines[2].startswith("Binary modules loaded from ") - assert lines[3] == "-" * 68 + assert lines[1].startswith("Python executable is") + lines = lines[2:] + if lines[0].startswith("Environment Python files loaded from"): + lines = lines[1:] + assert lines[0].startswith("System Python files loaded from") + assert lines[1] == "-" * 68 + assert lines[2].startswith("Python Pillow modules loaded from ") + assert lines[3].startswith("Binary Pillow modules loaded from ") + assert lines[4] == "-" * 68 jpeg = ( os.linesep + "-" * 68 diff --git a/src/PIL/features.py b/src/PIL/features.py index b14d6df13..7c5112ef6 100644 --- a/src/PIL/features.py +++ b/src/PIL/features.py @@ -249,12 +249,17 @@ def pilinfo(out=None, supported_formats=True): for py_version in py_version[1:]: print(f" {py_version.strip()}", file=out) print("-" * 68, file=out) + print(f"Python executable is {sys.executable or 'unknown'}", file=out) + if sys.prefix != sys.base_prefix: + print(f"Environment Python files loaded from {sys.prefix}", file=out) + print(f"System Python files loaded from {sys.base_prefix}", file=out) + print("-" * 68, file=out) print( - f"Python modules loaded from {os.path.dirname(Image.__file__)}", + f"Python Pillow modules loaded from {os.path.dirname(Image.__file__)}", file=out, ) print( - f"Binary modules loaded from {os.path.dirname(Image.core.__file__)}", + f"Binary Pillow modules loaded from {os.path.dirname(Image.core.__file__)}", file=out, ) print("-" * 68, file=out) From 89c44be404081c77e68457c1c50a78e96a6d42b9 Mon Sep 17 00:00:00 2001 From: Nulano Date: Tue, 20 Feb 2024 20:39:40 +0100 Subject: [PATCH 105/361] Mention `python -m PIL --bugreport` in the issue template --- .github/ISSUE_TEMPLATE/ISSUE_REPORT.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/ISSUE_REPORT.md b/.github/ISSUE_TEMPLATE/ISSUE_REPORT.md index 115f6135d..cfd576f35 100644 --- a/.github/ISSUE_TEMPLATE/ISSUE_REPORT.md +++ b/.github/ISSUE_TEMPLATE/ISSUE_REPORT.md @@ -48,6 +48,10 @@ Thank you. * Python: * Pillow: +```text +please paste the output of running `python3 -m PIL --bugreport` here +``` +