From e7eea5ea306e91a29fb661be2dcaafa5d53950ee Mon Sep 17 00:00:00 2001 From: Nulano Date: Sun, 31 Dec 2023 23:57:31 +0100 Subject: [PATCH 01/15] add type hints to _imagingcms --- src/PIL/_imagingcms.pyi | 147 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 145 insertions(+), 2 deletions(-) diff --git a/src/PIL/_imagingcms.pyi b/src/PIL/_imagingcms.pyi index e27843e53..9cfd9d191 100644 --- a/src/PIL/_imagingcms.pyi +++ b/src/PIL/_imagingcms.pyi @@ -1,3 +1,146 @@ -from typing import Any +import datetime +import sys +from typing import Literal, TypedDict -def __getattr__(name: str) -> Any: ... +littlecms_version: str + +_tuple_3f = tuple[float, float, float] +_tuple_2x3f = tuple[_tuple_3f, _tuple_3f] +_tuple_3x3f = tuple[_tuple_3f, _tuple_3f, _tuple_3f] +_tuple_2x3x3f = tuple[_tuple_3x3f, _tuple_3x3f] + +class _IccMeasurementCondition(TypedDict): + observer: int + backing: _tuple_3f + geo: str + flare: float + illuminant_type: str + +class _IccViewingCondition(TypedDict): + illuminant: _tuple_3f + surround: _tuple_3f + illuminant_type: str + +class CmsProfile: + @property + def rendering_intent(self) -> int: ... + @property + def creation_date(self) -> datetime.datetime | None: ... + @property + def copyright(self) -> str | None: ... + @property + def target(self) -> str | None: ... + @property + def manufacturer(self) -> str | None: ... + @property + def model(self) -> str | None: ... + @property + def profile_description(self) -> str | None: ... + @property + def screening_description(self) -> str | None: ... + @property + def viewing_condition(self) -> str | None: ... + @property + def version(self) -> float: ... + @property + def icc_version(self) -> int: ... + @property + def attributes(self) -> int: ... + @property + def header_flags(self) -> int: ... + @property + def header_manufacturer(self) -> str: ... + @property + def header_model(self) -> str: ... + @property + def device_class(self) -> str: ... + @property + def connection_space(self) -> str: ... + @property + def xcolor_space(self) -> str: ... + @property + def profile_id(self) -> bytes: ... + @property + def is_matrix_shaper(self) -> bool: ... + @property + def technology(self) -> str | None: ... + @property + def colorimetric_intent(self) -> str | None: ... + @property + def perceptual_rendering_intent_gamut(self) -> str | None: ... + @property + def saturation_rendering_intent_gamut(self) -> str | None: ... + @property + def red_colorant(self) -> _tuple_2x3f | None: ... + @property + def green_colorant(self) -> _tuple_2x3f | None: ... + @property + def blue_colorant(self) -> _tuple_2x3f | None: ... + @property + def red_primary(self) -> _tuple_2x3f | None: ... + @property + def green_primary(self) -> _tuple_2x3f | None: ... + @property + def blue_primary(self) -> _tuple_2x3f | None: ... + @property + def media_white_point_temperature(self) -> float | None: ... + @property + def media_white_point(self) -> _tuple_2x3f | None: ... + @property + def media_black_point(self) -> _tuple_2x3f | None: ... + @property + def luminance(self) -> _tuple_2x3f | None: ... + @property + def chromatic_adaptation(self) -> _tuple_2x3x3f | None: ... + @property + def chromaticity(self) -> _tuple_3x3f | None: ... + @property + def colorant_table(self) -> list[str] | None: ... + @property + def colorant_table_out(self) -> list[str] | None: ... + @property + def intent_supported(self) -> dict[int, tuple[bool, bool, bool]] | None: ... + @property + def clut(self) -> dict[int, tuple[bool, bool, bool]] | None: ... + @property + def icc_measurement_condition(self) -> _IccMeasurementCondition | None: ... + @property + def icc_viewing_condition(self) -> _IccViewingCondition | None: ... + def is_intent_supported(self, intent: int, direction: int, /) -> int: ... + +class CmsTransform: + @property + def inputMode(self) -> str: ... + @property + def outputMode(self) -> str: ... + def apply(self, id_in: int, id_out: int) -> int: ... + +def profile_open(profile: str, /) -> CmsProfile: ... +def profile_frombytes(profile: bytes, /) -> CmsProfile: ... +def profile_tobytes(profile: CmsProfile, /) -> bytes: ... +def buildTransform( + input_profile: CmsProfile, + output_profile: CmsProfile, + in_mode: str, + out_mode: str, + rendering_intent: int = 0, + cms_flags: int = 0, + /, +) -> CmsTransform: ... +def buildProofTransform( + input_profile: CmsProfile, + output_profile: CmsProfile, + proof_profile: CmsProfile, + in_mode: str, + out_mode: str, + rendering_intent: int = 0, + proof_intent: int = 0, + cms_flags: int = 0, + /, +) -> CmsTransform: ... +def createProfile( + color_space: Literal["LAB", "XYZ", "sRGB"], color_temp: float = 0.0, / +) -> CmsProfile: ... + +if sys.platform == "win32": + def get_display_profile_win32(handle: int = 0, is_dc: int = 0, /) -> str | None: ... From 24ed5db2d117de3f8395d380372009b3ecc5baa6 Mon Sep 17 00:00:00 2001 From: Nulano Date: Mon, 1 Jan 2024 00:48:13 +0100 Subject: [PATCH 02/15] check type given to ImageCms.core.profile_tobytes instead of crashing --- Tests/test_imagecms.py | 6 ++++++ src/_imagingcms.c | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Tests/test_imagecms.py b/Tests/test_imagecms.py index 6be29a70f..c04d08806 100644 --- a/Tests/test_imagecms.py +++ b/Tests/test_imagecms.py @@ -506,6 +506,12 @@ def test_profile_typesafety() -> None: with pytest.raises(TypeError, match="Invalid type for Profile"): ImageCms.ImageCmsProfile(1).tobytes() + # also check core function + with pytest.raises(TypeError): + ImageCms.core.profile_tobytes(0) + with pytest.raises(TypeError): + ImageCms.core.profile_tobytes(1) + def assert_aux_channel_preserved( mode: str, transform_in_place: bool, preserved_channel: str diff --git a/src/_imagingcms.c b/src/_imagingcms.c index c7728770a..7c7763653 100644 --- a/src/_imagingcms.c +++ b/src/_imagingcms.c @@ -143,7 +143,7 @@ cms_profile_tobytes(PyObject *self, PyObject *args) { cmsHPROFILE *profile; PyObject *ret; - if (!PyArg_ParseTuple(args, "O", &CmsProfile)) { + if (!PyArg_ParseTuple(args, "O!", &CmsProfile_Type, &CmsProfile)) { return NULL; } From 0630ef061f532084b8705fb287b0d00a05bd2df9 Mon Sep 17 00:00:00 2001 From: Nulano Date: Mon, 1 Jan 2024 00:46:53 +0100 Subject: [PATCH 03/15] =?UTF-8?q?=EF=BB=BFadd=20type=20hints=20for=20Image?= =?UTF-8?q?Cms.{ImageCmsProfile,ImageCmsTransform}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/PIL/ImageCms.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/PIL/ImageCms.py b/src/PIL/ImageCms.py index 39669d869..ddfc77904 100644 --- a/src/PIL/ImageCms.py +++ b/src/PIL/ImageCms.py @@ -23,7 +23,7 @@ import operator import sys from enum import IntEnum, IntFlag from functools import reduce -from typing import Any +from typing import Any, BinaryIO from . import Image, __version__ from ._deprecate import deprecate @@ -237,7 +237,7 @@ _FLAGS = { class ImageCmsProfile: - def __init__(self, profile): + def __init__(self, profile: str | BinaryIO | core.CmsProfile) -> None: """ :param profile: Either a string representing a filename, a file like object containing a profile or a @@ -260,16 +260,16 @@ class ImageCmsProfile: elif isinstance(profile, _imagingcms.CmsProfile): self._set(profile) else: - msg = "Invalid type for Profile" + msg = "Invalid type for Profile" # type: ignore[unreachable] raise TypeError(msg) - def _set(self, profile, filename=None): + def _set(self, profile: core.CmsProfile, filename: str | None = None) -> None: self.profile = profile self.filename = filename self.product_name = None # profile.product_name self.product_info = None # profile.product_info - def tobytes(self): + def tobytes(self) -> bytes: """ Returns the profile in a format suitable for embedding in saved images. @@ -290,14 +290,14 @@ class ImageCmsTransform(Image.ImagePointHandler): def __init__( self, - input, - output, - input_mode, - output_mode, - intent=Intent.PERCEPTUAL, - proof=None, - proof_intent=Intent.ABSOLUTE_COLORIMETRIC, - flags=Flags.NONE, + input: ImageCmsProfile, + output: ImageCmsProfile, + input_mode: str, + output_mode: str, + intent: Intent = Intent.PERCEPTUAL, + proof: ImageCmsProfile | None = None, + proof_intent: Intent = Intent.ABSOLUTE_COLORIMETRIC, + flags: Flags | int = Flags.NONE, ): if proof is None: self.transform = core.buildTransform( @@ -320,10 +320,10 @@ class ImageCmsTransform(Image.ImagePointHandler): self.output_profile = output - def point(self, im): + def point(self, im: Image.Image) -> Image.Image: return self.apply(im) - def apply(self, im, imOut=None): + def apply(self, im: Image.Image, imOut: Image.Image | None = None) -> Image.Image: im.load() if imOut is None: imOut = Image.new(self.output_mode, im.size, None) @@ -331,7 +331,7 @@ class ImageCmsTransform(Image.ImagePointHandler): imOut.info["icc_profile"] = self.output_profile.tobytes() return imOut - def apply_in_place(self, im): + def apply_in_place(self, im: Image.Image) -> Image.Image: im.load() if im.mode != self.output_mode: msg = "mode mismatch" From a1a687c26142e56e9622ede9bd27dee73c9673c7 Mon Sep 17 00:00:00 2001 From: Nulano Date: Mon, 1 Jan 2024 00:47:39 +0100 Subject: [PATCH 04/15] =?UTF-8?q?=EF=BB=BFadd=20type=20hints=20to=20ImageC?= =?UTF-8?q?ms.get=5Fdisplay=5Fprofile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Tests/test_imagecms.py | 7 ++++++- src/PIL/ImageCms.py | 10 +++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/Tests/test_imagecms.py b/Tests/test_imagecms.py index c04d08806..6530dc160 100644 --- a/Tests/test_imagecms.py +++ b/Tests/test_imagecms.py @@ -4,13 +4,14 @@ import datetime import os import re import shutil +import sys from io import BytesIO from pathlib import Path from typing import Any import pytest -from PIL import Image, ImageMode, features +from PIL import Image, ImageMode, ImageWin, features from .helper import ( assert_image, @@ -213,6 +214,10 @@ def test_display_profile() -> None: # try fetching the profile for the current display device ImageCms.get_display_profile() + if sys.platform == "win32": + ImageCms.get_display_profile(ImageWin.HDC(0)) + ImageCms.get_display_profile(ImageWin.HWND(0)) + def test_lab_color_profile() -> None: ImageCms.createProfile("LAB", 5000) diff --git a/src/PIL/ImageCms.py b/src/PIL/ImageCms.py index ddfc77904..2f0f112a7 100644 --- a/src/PIL/ImageCms.py +++ b/src/PIL/ImageCms.py @@ -23,7 +23,7 @@ import operator import sys from enum import IntEnum, IntFlag from functools import reduce -from typing import Any, BinaryIO +from typing import Any, BinaryIO, SupportsInt from . import Image, __version__ from ._deprecate import deprecate @@ -341,7 +341,7 @@ class ImageCmsTransform(Image.ImagePointHandler): return im -def get_display_profile(handle=None): +def get_display_profile(handle: SupportsInt | None = None) -> ImageCmsProfile | None: """ (experimental) Fetches the profile for the current display device. @@ -351,12 +351,12 @@ def get_display_profile(handle=None): if sys.platform != "win32": return None - from . import ImageWin + from . import ImageWin # type: ignore[unused-ignore, unreachable] if isinstance(handle, ImageWin.HDC): - profile = core.get_display_profile_win32(handle, 1) + profile = core.get_display_profile_win32(int(handle), 1) else: - profile = core.get_display_profile_win32(handle or 0) + profile = core.get_display_profile_win32(int(handle or 0)) if profile is None: return None return ImageCmsProfile(profile) From 21566ebcdc21453bc5bcd6e4c9fdb63c559ece8a Mon Sep 17 00:00:00 2001 From: Nulano Date: Mon, 1 Jan 2024 01:47:35 +0100 Subject: [PATCH 05/15] =?UTF-8?q?=EF=BB=BFadd=20type=20hints=20to=20pyCms?= =?UTF-8?q?=20functions=20in=20ImageCms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conf.py | 5 ++- src/PIL/ImageCms.py | 91 ++++++++++++++++++++++++++------------------- 2 files changed, 56 insertions(+), 40 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 882e07668..af45e5579 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -121,7 +121,10 @@ nitpicky = True # generating warnings in “nitpicky mode”. Note that type should include the domain name # if present. Example entries would be ('py:func', 'int') or # ('envvar', 'LD_LIBRARY_PATH'). -# nitpick_ignore = [] +nitpick_ignore = [ + # sphinx does not understand `typing.Literal[-1]` + ("py:obj", "typing.Literal[-1, 1]"), +] # -- Options for HTML output ---------------------------------------------- diff --git a/src/PIL/ImageCms.py b/src/PIL/ImageCms.py index 2f0f112a7..c76241912 100644 --- a/src/PIL/ImageCms.py +++ b/src/PIL/ImageCms.py @@ -23,7 +23,7 @@ import operator import sys from enum import IntEnum, IntFlag from functools import reduce -from typing import Any, BinaryIO, SupportsInt +from typing import Any, BinaryIO, Literal, SupportsFloat, SupportsInt, Union from . import Image, __version__ from ._deprecate import deprecate @@ -366,6 +366,8 @@ def get_display_profile(handle: SupportsInt | None = None) -> ImageCmsProfile | # pyCMS compatible layer # --------------------------------------------------------------------. +_CmsProfileCompatible = Union[str, BinaryIO, core.CmsProfile, ImageCmsProfile] + class PyCMSError(Exception): """(pyCMS) Exception class. @@ -375,14 +377,14 @@ class PyCMSError(Exception): def profileToProfile( - im, - inputProfile, - outputProfile, - renderingIntent=Intent.PERCEPTUAL, - outputMode=None, - inPlace=False, - flags=Flags.NONE, -): + im: Image.Image, + inputProfile: _CmsProfileCompatible, + outputProfile: _CmsProfileCompatible, + renderingIntent: Intent = Intent.PERCEPTUAL, + outputMode: str | None = None, + inPlace: bool = False, + flags: Flags | int = Flags.NONE, +) -> Image.Image | None: """ (pyCMS) Applies an ICC transformation to a given image, mapping from ``inputProfile`` to ``outputProfile``. @@ -470,7 +472,9 @@ def profileToProfile( return imOut -def getOpenProfile(profileFilename): +def getOpenProfile( + profileFilename: str | BinaryIO | core.CmsProfile, +) -> ImageCmsProfile: """ (pyCMS) Opens an ICC profile file. @@ -493,13 +497,13 @@ def getOpenProfile(profileFilename): def buildTransform( - inputProfile, - outputProfile, - inMode, - outMode, - renderingIntent=Intent.PERCEPTUAL, - flags=Flags.NONE, -): + inputProfile: _CmsProfileCompatible, + outputProfile: _CmsProfileCompatible, + inMode: str, + outMode: str, + renderingIntent: Intent = Intent.PERCEPTUAL, + flags: Flags | int = Flags.NONE, +) -> ImageCmsTransform: """ (pyCMS) Builds an ICC transform mapping from the ``inputProfile`` to the ``outputProfile``. Use applyTransform to apply the transform to a given @@ -576,15 +580,15 @@ def buildTransform( def buildProofTransform( - inputProfile, - outputProfile, - proofProfile, - inMode, - outMode, - renderingIntent=Intent.PERCEPTUAL, - proofRenderingIntent=Intent.ABSOLUTE_COLORIMETRIC, - flags=Flags.SOFTPROOFING, -): + inputProfile: _CmsProfileCompatible, + outputProfile: _CmsProfileCompatible, + proofProfile: _CmsProfileCompatible, + inMode: str, + outMode: str, + renderingIntent: Intent = Intent.PERCEPTUAL, + proofRenderingIntent: Intent = Intent.ABSOLUTE_COLORIMETRIC, + flags: Flags | int = Flags.SOFTPROOFING, +) -> ImageCmsTransform: """ (pyCMS) Builds an ICC transform mapping from the ``inputProfile`` to the ``outputProfile``, but tries to simulate the result that would be @@ -692,7 +696,9 @@ buildTransformFromOpenProfiles = buildTransform buildProofTransformFromOpenProfiles = buildProofTransform -def applyTransform(im, transform, inPlace=False): +def applyTransform( + im: Image.Image, transform: ImageCmsTransform, inPlace: bool = False +) -> Image.Image | None: """ (pyCMS) Applies a transform to a given image. @@ -745,7 +751,9 @@ def applyTransform(im, transform, inPlace=False): return imOut -def createProfile(colorSpace, colorTemp=-1): +def createProfile( + colorSpace: Literal["LAB", "XYZ", "sRGB"], colorTemp: SupportsFloat = -1 +) -> core.CmsProfile: """ (pyCMS) Creates a profile. @@ -787,6 +795,9 @@ def createProfile(colorSpace, colorTemp=-1): except (TypeError, ValueError) as e: msg = f'Color temperature must be numeric, "{colorTemp}" not valid' raise PyCMSError(msg) from e + else: + # colorTemp is unused if colorSpace != "LAB" + colorTemp = 0.0 try: return core.createProfile(colorSpace, colorTemp) @@ -794,7 +805,7 @@ def createProfile(colorSpace, colorTemp=-1): raise PyCMSError(v) from v -def getProfileName(profile): +def getProfileName(profile: _CmsProfileCompatible) -> str: """ (pyCMS) Gets the internal product name for the given profile. @@ -828,15 +839,15 @@ def getProfileName(profile): if not (model or manufacturer): return (profile.profile.profile_description or "") + "\n" - if not manufacturer or len(model) > 30: - return model + "\n" + if not manufacturer or len(model) > 30: # type: ignore[arg-type] + return model + "\n" # type: ignore[operator] return f"{model} - {manufacturer}\n" except (AttributeError, OSError, TypeError, ValueError) as v: raise PyCMSError(v) from v -def getProfileInfo(profile): +def getProfileInfo(profile: _CmsProfileCompatible) -> str: """ (pyCMS) Gets the internal product information for the given profile. @@ -873,7 +884,7 @@ def getProfileInfo(profile): raise PyCMSError(v) from v -def getProfileCopyright(profile): +def getProfileCopyright(profile: _CmsProfileCompatible) -> str: """ (pyCMS) Gets the copyright for the given profile. @@ -901,7 +912,7 @@ def getProfileCopyright(profile): raise PyCMSError(v) from v -def getProfileManufacturer(profile): +def getProfileManufacturer(profile: _CmsProfileCompatible) -> str: """ (pyCMS) Gets the manufacturer for the given profile. @@ -929,7 +940,7 @@ def getProfileManufacturer(profile): raise PyCMSError(v) from v -def getProfileModel(profile): +def getProfileModel(profile: _CmsProfileCompatible) -> str: """ (pyCMS) Gets the model for the given profile. @@ -958,7 +969,7 @@ def getProfileModel(profile): raise PyCMSError(v) from v -def getProfileDescription(profile): +def getProfileDescription(profile: _CmsProfileCompatible) -> str: """ (pyCMS) Gets the description for the given profile. @@ -987,7 +998,7 @@ def getProfileDescription(profile): raise PyCMSError(v) from v -def getDefaultIntent(profile): +def getDefaultIntent(profile: _CmsProfileCompatible) -> int: """ (pyCMS) Gets the default intent name for the given profile. @@ -1026,7 +1037,9 @@ def getDefaultIntent(profile): raise PyCMSError(v) from v -def isIntentSupported(profile, intent, direction): +def isIntentSupported( + profile: _CmsProfileCompatible, intent: Intent, direction: Direction +) -> Literal[-1, 1]: """ (pyCMS) Checks if a given intent is supported. @@ -1077,7 +1090,7 @@ def isIntentSupported(profile, intent, direction): raise PyCMSError(v) from v -def versions(): +def versions() -> tuple[str, str, str, str]: """ (pyCMS) Fetches versions. """ From afd6e1fa297d60696f05923097c01b1846de4b49 Mon Sep 17 00:00:00 2001 From: Nulano Date: Mon, 1 Jan 2024 02:36:46 +0100 Subject: [PATCH 06/15] import _imagingcms as core --- src/PIL/ImageCms.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/PIL/ImageCms.py b/src/PIL/ImageCms.py index c76241912..022967025 100644 --- a/src/PIL/ImageCms.py +++ b/src/PIL/ImageCms.py @@ -29,13 +29,13 @@ from . import Image, __version__ from ._deprecate import deprecate try: - from . import _imagingcms + from . import _imagingcms as core except ImportError as ex: # Allow error import for doc purposes, but error out when accessing # anything in core. from ._util import DeferredError - _imagingcms = DeferredError.new(ex) + core = DeferredError.new(ex) _DESCRIPTION = """ pyCMS @@ -119,7 +119,6 @@ def __getattr__(name: str) -> Any: # --------------------------------------------------------------------. -core = _imagingcms # # intent/direction values @@ -257,7 +256,7 @@ class ImageCmsProfile: self._set(core.profile_open(profile), profile) elif hasattr(profile, "read"): self._set(core.profile_frombytes(profile.read())) - elif isinstance(profile, _imagingcms.CmsProfile): + elif isinstance(profile, core.CmsProfile): self._set(profile) else: msg = "Invalid type for Profile" # type: ignore[unreachable] From 047a0d2a64b135ce5f912814777e4b5862ade07b Mon Sep 17 00:00:00 2001 From: Nulano Date: Tue, 2 Jan 2024 00:25:39 +0100 Subject: [PATCH 07/15] do not allow ImageCms.core.CmsProfile to be instantiated directly --- Tests/test_imagecms.py | 14 +++++++++----- src/_imagingcms.c | 2 -- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/Tests/test_imagecms.py b/Tests/test_imagecms.py index 6530dc160..821443e39 100644 --- a/Tests/test_imagecms.py +++ b/Tests/test_imagecms.py @@ -19,6 +19,7 @@ from .helper import ( assert_image_similar, assert_image_similar_tofile, hopper, + is_pypy, ) try: @@ -501,11 +502,7 @@ def test_non_ascii_path(tmp_path: Path) -> None: def test_profile_typesafety() -> None: - """Profile init type safety - - prepatch, these would segfault, postpatch they should emit a typeerror - """ - + # does not segfault with pytest.raises(TypeError, match="Invalid type for Profile"): ImageCms.ImageCmsProfile(0).tobytes() with pytest.raises(TypeError, match="Invalid type for Profile"): @@ -517,6 +514,13 @@ def test_profile_typesafety() -> None: with pytest.raises(TypeError): ImageCms.core.profile_tobytes(1) + if not is_pypy(): + # core profile should not be directly instantiable + with pytest.raises(TypeError): + ImageCms.core.CmsProfile() + with pytest.raises(TypeError): + ImageCms.core.CmsProfile(0) + def assert_aux_channel_preserved( mode: str, transform_in_place: bool, preserved_channel: str diff --git a/src/_imagingcms.c b/src/_imagingcms.c index 7c7763653..5ffb20a6a 100644 --- a/src/_imagingcms.c +++ b/src/_imagingcms.c @@ -1511,8 +1511,6 @@ setup_module(PyObject *m) { PyObject *v; int vn; - CmsProfile_Type.tp_new = PyType_GenericNew; - /* Ready object types */ PyType_Ready(&CmsProfile_Type); PyType_Ready(&CmsTransform_Type); From 0015e9ce6821d4c4c6313d28eb3e0d1e58078078 Mon Sep 17 00:00:00 2001 From: Nulano Date: Tue, 2 Jan 2024 00:25:57 +0100 Subject: [PATCH 08/15] expose ImageCms.core.CmsTransform --- Tests/test_imagecms.py | 9 +++++++++ src/_imagingcms.c | 3 +++ 2 files changed, 12 insertions(+) diff --git a/Tests/test_imagecms.py b/Tests/test_imagecms.py index 821443e39..c80fab75b 100644 --- a/Tests/test_imagecms.py +++ b/Tests/test_imagecms.py @@ -522,6 +522,15 @@ def test_profile_typesafety() -> None: ImageCms.core.CmsProfile(0) +@pytest.mark.skipif(is_pypy(), reason="fails on PyPy") +def test_transform_typesafety() -> None: + # core transform should not be directly instantiable + with pytest.raises(TypeError): + ImageCms.core.CmsProfile() + with pytest.raises(TypeError): + ImageCms.core.CmsProfile(0) + + def assert_aux_channel_preserved( mode: str, transform_in_place: bool, preserved_channel: str ) -> None: diff --git a/src/_imagingcms.c b/src/_imagingcms.c index 5ffb20a6a..b50c577d8 100644 --- a/src/_imagingcms.c +++ b/src/_imagingcms.c @@ -1518,6 +1518,9 @@ setup_module(PyObject *m) { Py_INCREF(&CmsProfile_Type); PyModule_AddObject(m, "CmsProfile", (PyObject *)&CmsProfile_Type); + Py_INCREF(&CmsTransform_Type); + PyModule_AddObject(m, "CmsTransform", (PyObject *)&CmsTransform_Type); + d = PyModule_GetDict(m); /* this check is also in PIL.features.pilinfo() */ From abb73b5b86a8509a370bae8fd0181ebf8721a1df Mon Sep 17 00:00:00 2001 From: Nulano Date: Sat, 20 Jan 2024 14:06:02 +0100 Subject: [PATCH 09/15] use PIL.ImageCms.core as module for PIL._imagingcms classes --- src/_imagingcms.c | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/_imagingcms.c b/src/_imagingcms.c index b50c577d8..4d66dcc10 100644 --- a/src/_imagingcms.c +++ b/src/_imagingcms.c @@ -1421,9 +1421,9 @@ static struct PyGetSetDef cms_profile_getsetters[] = { {NULL}}; static PyTypeObject CmsProfile_Type = { - PyVarObject_HEAD_INIT(NULL, 0) "PIL._imagingcms.CmsProfile", /*tp_name*/ - sizeof(CmsProfileObject), /*tp_basicsize*/ - 0, /*tp_itemsize*/ + PyVarObject_HEAD_INIT(NULL, 0) "PIL.ImageCms.core.CmsProfile", /*tp_name*/ + sizeof(CmsProfileObject), /*tp_basicsize*/ + 0, /*tp_itemsize*/ /* methods */ (destructor)cms_profile_dealloc, /*tp_dealloc*/ 0, /*tp_vectorcall_offset*/ @@ -1473,9 +1473,9 @@ static struct PyGetSetDef cms_transform_getsetters[] = { {NULL}}; static PyTypeObject CmsTransform_Type = { - PyVarObject_HEAD_INIT(NULL, 0) "CmsTransform", /*tp_name*/ - sizeof(CmsTransformObject), /*tp_basicsize*/ - 0, /*tp_itemsize*/ + PyVarObject_HEAD_INIT(NULL, 0) "PIL.ImageCms.core.CmsTransform", /*tp_name*/ + sizeof(CmsTransformObject), /*tp_basicsize*/ + 0, /*tp_itemsize*/ /* methods */ (destructor)cms_transform_dealloc, /*tp_dealloc*/ 0, /*tp_vectorcall_offset*/ From a81b945129451839b666533543d8643f4e29c260 Mon Sep 17 00:00:00 2001 From: Nulano Date: Sat, 20 Jan 2024 22:33:58 +0100 Subject: [PATCH 10/15] Update CmsProfile docs to match _imagingcms type stubs --- docs/reference/ImageCms.rst | 52 ++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/docs/reference/ImageCms.rst b/docs/reference/ImageCms.rst index c4484cbe2..96bd14dd3 100644 --- a/docs/reference/ImageCms.rst +++ b/docs/reference/ImageCms.rst @@ -73,7 +73,7 @@ can be easily displayed in a chromaticity diagram, for example). :canonical: PIL._imagingcms.CmsProfile .. py:attribute:: creation_date - :type: Optional[datetime.datetime] + :type: datetime.datetime | None Date and time this profile was first created (see 7.2.1 of ICC.1:2010). @@ -156,58 +156,58 @@ can be easily displayed in a chromaticity diagram, for example). not been calculated (see 7.2.18 of ICC.1:2010). .. py:attribute:: copyright - :type: Optional[str] + :type: str | None The text copyright information for the profile (see 9.2.21 of ICC.1:2010). .. py:attribute:: manufacturer - :type: Optional[str] + :type: str | None The (English) display string for the device manufacturer (see 9.2.22 of ICC.1:2010). .. py:attribute:: model - :type: Optional[str] + :type: str | None The (English) display string for the device model of the device for which this profile is created (see 9.2.23 of ICC.1:2010). .. py:attribute:: profile_description - :type: Optional[str] + :type: str | None The (English) display string for the profile description (see 9.2.41 of ICC.1:2010). .. py:attribute:: target - :type: Optional[str] + :type: str | None The name of the registered characterization data set, or the measurement data for a characterization target (see 9.2.14 of ICC.1:2010). .. py:attribute:: red_colorant - :type: Optional[tuple[tuple[float]]] + :type: tuple[tuple[float, float, float], tuple[float, float, float]] | None The first column in the matrix used in matrix/TRC transforms (see 9.2.44 of ICC.1:2010). The value is in the format ``((X, Y, Z), (x, y, Y))``, if available. .. py:attribute:: green_colorant - :type: Optional[tuple[tuple[float]]] + :type: tuple[tuple[float, float, float], tuple[float, float, float]] | None The second column in the matrix used in matrix/TRC transforms (see 9.2.30 of ICC.1:2010). The value is in the format ``((X, Y, Z), (x, y, Y))``, if available. .. py:attribute:: blue_colorant - :type: Optional[tuple[tuple[float]]] + :type: tuple[tuple[float, float, float], tuple[float, float, float]] | None The third column in the matrix used in matrix/TRC transforms (see 9.2.4 of ICC.1:2010). The value is in the format ``((X, Y, Z), (x, y, Y))``, if available. .. py:attribute:: luminance - :type: Optional[tuple[tuple[float]]] + :type: tuple[tuple[float, float, float], tuple[float, float, float]] | None The absolute luminance of emissive devices in candelas per square metre as described by the Y channel (see 9.2.32 of ICC.1:2010). @@ -215,7 +215,7 @@ can be easily displayed in a chromaticity diagram, for example). The value is in the format ``((X, Y, Z), (x, y, Y))``, if available. .. py:attribute:: chromaticity - :type: Optional[tuple[tuple[float]]] + :type: tuple[tuple[float, float, float], tuple[float, float, float], tuple[float, float, float]] | None The data of the phosphor/colorant chromaticity set used (red, green and blue channels, see 9.2.16 of ICC.1:2010). @@ -223,7 +223,7 @@ can be easily displayed in a chromaticity diagram, for example). The value is in the format ``((x, y, Y), (x, y, Y), (x, y, Y))``, if available. .. py:attribute:: chromatic_adaption - :type: tuple[tuple[float]] + :type: tuple[tuple[tuple[float, float, float], tuple[float, float, float], tuple[float, float, float]], tuple[tuple[float, float, float], tuple[float, float, float], tuple[float, float, float]]] | None The chromatic adaption matrix converts a color measured using the actual illumination conditions and relative to the actual adopted @@ -249,34 +249,34 @@ can be easily displayed in a chromaticity diagram, for example). 9.2.19 of ICC.1:2010). .. py:attribute:: colorimetric_intent - :type: Optional[str] + :type: str | None 4-character string (padded with whitespace) identifying the image state of PCS colorimetry produced using the colorimetric intent transforms (see 9.2.20 of ICC.1:2010 for details). .. py:attribute:: perceptual_rendering_intent_gamut - :type: Optional[str] + :type: str | None 4-character string (padded with whitespace) identifying the (one) standard reference medium gamut (see 9.2.37 of ICC.1:2010 for details). .. py:attribute:: saturation_rendering_intent_gamut - :type: Optional[str] + :type: str | None 4-character string (padded with whitespace) identifying the (one) standard reference medium gamut (see 9.2.37 of ICC.1:2010 for details). .. py:attribute:: technology - :type: Optional[str] + :type: str | None 4-character string (padded with whitespace) identifying the device technology (see 9.2.47 of ICC.1:2010 for details). .. py:attribute:: media_black_point - :type: Optional[tuple[tuple[float]]] + :type: tuple[tuple[float, float, float], tuple[float, float, float]] | None This tag specifies the media black point and is used for generating absolute colorimetry. @@ -287,19 +287,19 @@ can be easily displayed in a chromaticity diagram, for example). The value is in the format ``((X, Y, Z), (x, y, Y))``, if available. .. py:attribute:: media_white_point_temperature - :type: Optional[float] + :type: float | None Calculates the white point temperature (see the LCMS documentation for more information). .. py:attribute:: viewing_condition - :type: Optional[str] + :type: str | None The (English) display string for the viewing conditions (see 9.2.48 of ICC.1:2010). .. py:attribute:: screening_description - :type: Optional[str] + :type: str | None The (English) display string for the screening conditions. @@ -307,21 +307,21 @@ can be easily displayed in a chromaticity diagram, for example). version 4. .. py:attribute:: red_primary - :type: Optional[tuple[tuple[float]]] + :type: tuple[tuple[float, float, float], tuple[float, float, float]] | None The XYZ-transformed of the RGB primary color red (1, 0, 0). The value is in the format ``((X, Y, Z), (x, y, Y))``, if available. .. py:attribute:: green_primary - :type: Optional[tuple[tuple[float]]] + :type: tuple[tuple[float, float, float], tuple[float, float, float]] | None The XYZ-transformed of the RGB primary color green (0, 1, 0). The value is in the format ``((X, Y, Z), (x, y, Y))``, if available. .. py:attribute:: blue_primary - :type: Optional[tuple[tuple[float]]] + :type: tuple[tuple[float, float, float], tuple[float, float, float]] | None The XYZ-transformed of the RGB primary color blue (0, 0, 1). @@ -334,7 +334,7 @@ can be easily displayed in a chromaticity diagram, for example). documentation on LCMS). .. py:attribute:: clut - :type: dict[tuple[bool]] + :type: dict[int, tuple[bool, bool, bool]] | None Returns a dictionary of all supported intents and directions for the CLUT model. @@ -353,7 +353,7 @@ can be easily displayed in a chromaticity diagram, for example). that intent is supported for that direction. .. py:attribute:: intent_supported - :type: dict[tuple[bool]] + :type: dict[int, tuple[bool, bool, bool]] | None Returns a dictionary of all supported intents and directions. @@ -372,7 +372,7 @@ can be easily displayed in a chromaticity diagram, for example). There is one function defined on the class: - .. py:method:: is_intent_supported(intent, direction) + .. py:method:: is_intent_supported(intent: int, direction: int, /) Returns if the intent is supported for the given direction. From d3665ea0ea42791293bb4b566a2661798bae9af6 Mon Sep 17 00:00:00 2001 From: Nulano Date: Thu, 28 Mar 2024 18:20:32 +0100 Subject: [PATCH 11/15] fix lint --- pyproject.toml | 1 + src/PIL/_imagingcms.pyi | 35 +++++++++++++++++------------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e6fd05167..740b0ebea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,6 +117,7 @@ ignore = [ "E221", # Multiple spaces before operator "E226", # Missing whitespace around arithmetic operator "E241", # Multiple spaces after ',' + "PYI026", # flake8-pyi: typing.TypeAlias added in Python 3.10 "PYI034", # flake8-pyi: typing.Self added in Python 3.11 ] diff --git a/src/PIL/_imagingcms.pyi b/src/PIL/_imagingcms.pyi index 9cfd9d191..723f80481 100644 --- a/src/PIL/_imagingcms.pyi +++ b/src/PIL/_imagingcms.pyi @@ -4,21 +4,20 @@ from typing import Literal, TypedDict littlecms_version: str -_tuple_3f = tuple[float, float, float] -_tuple_2x3f = tuple[_tuple_3f, _tuple_3f] -_tuple_3x3f = tuple[_tuple_3f, _tuple_3f, _tuple_3f] -_tuple_2x3x3f = tuple[_tuple_3x3f, _tuple_3x3f] +_Tuple3f = tuple[float, float, float] +_Tuple2x3f = tuple[_Tuple3f, _Tuple3f] +_Tuple3x3f = tuple[_Tuple3f, _Tuple3f, _Tuple3f] class _IccMeasurementCondition(TypedDict): observer: int - backing: _tuple_3f + backing: _Tuple3f geo: str flare: float illuminant_type: str class _IccViewingCondition(TypedDict): - illuminant: _tuple_3f - surround: _tuple_3f + illuminant: _Tuple3f + surround: _Tuple3f illuminant_type: str class CmsProfile: @@ -71,29 +70,29 @@ class CmsProfile: @property def saturation_rendering_intent_gamut(self) -> str | None: ... @property - def red_colorant(self) -> _tuple_2x3f | None: ... + def red_colorant(self) -> _Tuple2x3f | None: ... @property - def green_colorant(self) -> _tuple_2x3f | None: ... + def green_colorant(self) -> _Tuple2x3f | None: ... @property - def blue_colorant(self) -> _tuple_2x3f | None: ... + def blue_colorant(self) -> _Tuple2x3f | None: ... @property - def red_primary(self) -> _tuple_2x3f | None: ... + def red_primary(self) -> _Tuple2x3f | None: ... @property - def green_primary(self) -> _tuple_2x3f | None: ... + def green_primary(self) -> _Tuple2x3f | None: ... @property - def blue_primary(self) -> _tuple_2x3f | None: ... + def blue_primary(self) -> _Tuple2x3f | None: ... @property def media_white_point_temperature(self) -> float | None: ... @property - def media_white_point(self) -> _tuple_2x3f | None: ... + def media_white_point(self) -> _Tuple2x3f | None: ... @property - def media_black_point(self) -> _tuple_2x3f | None: ... + def media_black_point(self) -> _Tuple2x3f | None: ... @property - def luminance(self) -> _tuple_2x3f | None: ... + def luminance(self) -> _Tuple2x3f | None: ... @property - def chromatic_adaptation(self) -> _tuple_2x3x3f | None: ... + def chromatic_adaptation(self) -> tuple[_Tuple3x3f, _Tuple3x3f] | None: ... @property - def chromaticity(self) -> _tuple_3x3f | None: ... + def chromaticity(self) -> _Tuple3x3f | None: ... @property def colorant_table(self) -> list[str] | None: ... @property From aef7ccda3ad4f9e224bf4b4728aa1063cba11e42 Mon Sep 17 00:00:00 2001 From: Nulano Date: Thu, 28 Mar 2024 18:35:20 +0100 Subject: [PATCH 12/15] use SupportsRead instead of BinaryIO --- src/PIL/ImageCms.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/PIL/ImageCms.py b/src/PIL/ImageCms.py index 022967025..52937ce83 100644 --- a/src/PIL/ImageCms.py +++ b/src/PIL/ImageCms.py @@ -23,10 +23,11 @@ import operator import sys from enum import IntEnum, IntFlag from functools import reduce -from typing import Any, BinaryIO, Literal, SupportsFloat, SupportsInt, Union +from typing import Any, Literal, SupportsFloat, SupportsInt, Union from . import Image, __version__ from ._deprecate import deprecate +from ._typing import SupportsRead try: from . import _imagingcms as core @@ -236,7 +237,7 @@ _FLAGS = { class ImageCmsProfile: - def __init__(self, profile: str | BinaryIO | core.CmsProfile) -> None: + def __init__(self, profile: str | SupportsRead[bytes] | core.CmsProfile) -> None: """ :param profile: Either a string representing a filename, a file like object containing a profile or a @@ -365,7 +366,9 @@ def get_display_profile(handle: SupportsInt | None = None) -> ImageCmsProfile | # pyCMS compatible layer # --------------------------------------------------------------------. -_CmsProfileCompatible = Union[str, BinaryIO, core.CmsProfile, ImageCmsProfile] +_CmsProfileCompatible = Union[ + str, SupportsRead[bytes], core.CmsProfile, ImageCmsProfile +] class PyCMSError(Exception): @@ -472,7 +475,7 @@ def profileToProfile( def getOpenProfile( - profileFilename: str | BinaryIO | core.CmsProfile, + profileFilename: str | SupportsRead[bytes] | core.CmsProfile, ) -> ImageCmsProfile: """ (pyCMS) Opens an ICC profile file. From 5355af0ddd83d912d6fd12950428becfe44ec2c1 Mon Sep 17 00:00:00 2001 From: Nulano Date: Sat, 30 Mar 2024 09:11:15 +0100 Subject: [PATCH 13/15] use SupportsFloat instead of float in _imagingcms.pyi --- src/PIL/ImageCms.py | 3 --- src/PIL/_imagingcms.pyi | 4 ++-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/PIL/ImageCms.py b/src/PIL/ImageCms.py index 52937ce83..b6e5b9e8f 100644 --- a/src/PIL/ImageCms.py +++ b/src/PIL/ImageCms.py @@ -797,9 +797,6 @@ def createProfile( except (TypeError, ValueError) as e: msg = f'Color temperature must be numeric, "{colorTemp}" not valid' raise PyCMSError(msg) from e - else: - # colorTemp is unused if colorSpace != "LAB" - colorTemp = 0.0 try: return core.createProfile(colorSpace, colorTemp) diff --git a/src/PIL/_imagingcms.pyi b/src/PIL/_imagingcms.pyi index 723f80481..036521b0e 100644 --- a/src/PIL/_imagingcms.pyi +++ b/src/PIL/_imagingcms.pyi @@ -1,6 +1,6 @@ import datetime import sys -from typing import Literal, TypedDict +from typing import Literal, SupportsFloat, TypedDict littlecms_version: str @@ -138,7 +138,7 @@ def buildProofTransform( /, ) -> CmsTransform: ... def createProfile( - color_space: Literal["LAB", "XYZ", "sRGB"], color_temp: float = 0.0, / + color_space: Literal["LAB", "XYZ", "sRGB"], color_temp: SupportsFloat = 0.0, / ) -> CmsProfile: ... if sys.platform == "win32": From c4114adc413b3deb85f58c87559f656233f8ba19 Mon Sep 17 00:00:00 2001 From: Nulano Date: Sat, 30 Mar 2024 09:14:48 +0100 Subject: [PATCH 14/15] use Flags not Flags|int --- src/PIL/ImageCms.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/PIL/ImageCms.py b/src/PIL/ImageCms.py index b6e5b9e8f..3a45572a1 100644 --- a/src/PIL/ImageCms.py +++ b/src/PIL/ImageCms.py @@ -297,7 +297,7 @@ class ImageCmsTransform(Image.ImagePointHandler): intent: Intent = Intent.PERCEPTUAL, proof: ImageCmsProfile | None = None, proof_intent: Intent = Intent.ABSOLUTE_COLORIMETRIC, - flags: Flags | int = Flags.NONE, + flags: Flags = Flags.NONE, ): if proof is None: self.transform = core.buildTransform( @@ -385,7 +385,7 @@ def profileToProfile( renderingIntent: Intent = Intent.PERCEPTUAL, outputMode: str | None = None, inPlace: bool = False, - flags: Flags | int = Flags.NONE, + flags: Flags = Flags.NONE, ) -> Image.Image | None: """ (pyCMS) Applies an ICC transformation to a given image, mapping from @@ -504,7 +504,7 @@ def buildTransform( inMode: str, outMode: str, renderingIntent: Intent = Intent.PERCEPTUAL, - flags: Flags | int = Flags.NONE, + flags: Flags = Flags.NONE, ) -> ImageCmsTransform: """ (pyCMS) Builds an ICC transform mapping from the ``inputProfile`` to the @@ -589,7 +589,7 @@ def buildProofTransform( outMode: str, renderingIntent: Intent = Intent.PERCEPTUAL, proofRenderingIntent: Intent = Intent.ABSOLUTE_COLORIMETRIC, - flags: Flags | int = Flags.SOFTPROOFING, + flags: Flags = Flags.SOFTPROOFING, ) -> ImageCmsTransform: """ (pyCMS) Builds an ICC transform mapping from the ``inputProfile`` to the From 94732782d0e69a86486025159c3ed94a06cd19e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ondrej=20Baranovi=C4=8D?= Date: Sat, 30 Mar 2024 10:26:39 +0100 Subject: [PATCH 15/15] link to sphinx issue Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- docs/conf.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index af45e5579..483535f96 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -122,7 +122,9 @@ nitpicky = True # if present. Example entries would be ('py:func', 'int') or # ('envvar', 'LD_LIBRARY_PATH'). nitpick_ignore = [ - # sphinx does not understand `typing.Literal[-1]` + # Sphinx does not understand typing.Literal[-1] + # Will be fixed in a future version. + # https://github.com/sphinx-doc/sphinx/pull/11904 ("py:obj", "typing.Literal[-1, 1]"), ]