From 19a6edeecce2f3605fcdb074c00ac0152c6bdf05 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 10 Feb 2024 19:50:45 +1100 Subject: [PATCH] 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 =