Merge branch 'main' into type_hint

This commit is contained in:
Andrew Murray 2024-06-04 08:18:14 +10:00 committed by GitHub
commit 12559fffc5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 290 additions and 166 deletions

View File

@ -12,7 +12,7 @@ elif [ "${AUDITWHEEL_POLICY::9}" == "musllinux" ]; then
else else
yum install -y fribidi yum install -y fribidi
fi fi
if [ "${AUDITWHEEL_POLICY::9}" != "musllinux" ]; then if [ "${AUDITWHEEL_POLICY::9}" != "musllinux" ] && !([[ "$OSTYPE" == "darwin"* ]] && [[ $(python3 --version) == *"3.13."* ]]); then
python3 -m pip install numpy python3 -m pip install numpy
fi fi

View File

@ -46,6 +46,7 @@ jobs:
- cp310 - cp310
- cp311 - cp311
- cp312 - cp312
- cp313
spec: spec:
- manylinux2014 - manylinux2014
- manylinux_2_28 - manylinux_2_28
@ -80,6 +81,7 @@ jobs:
CIBW_ARCHS: "aarch64" CIBW_ARCHS: "aarch64"
# Likewise, select only one Python version per job to speed this up. # Likewise, select only one Python version per job to speed this up.
CIBW_BUILD: "${{ matrix.python-version }}-${{ matrix.spec == 'musllinux' && 'musllinux' || 'manylinux' }}*" CIBW_BUILD: "${{ matrix.python-version }}-${{ matrix.spec == 'musllinux' && 'musllinux' || 'manylinux' }}*"
CIBW_PRERELEASE_PYTHONS: True
# Extra options for manylinux. # Extra options for manylinux.
CIBW_MANYLINUX_AARCH64_IMAGE: ${{ matrix.spec }} CIBW_MANYLINUX_AARCH64_IMAGE: ${{ matrix.spec }}
CIBW_MANYLINUX_PYPY_AARCH64_IMAGE: ${{ matrix.spec }} CIBW_MANYLINUX_PYPY_AARCH64_IMAGE: ${{ matrix.spec }}
@ -133,6 +135,7 @@ jobs:
CIBW_BUILD: ${{ matrix.build }} CIBW_BUILD: ${{ matrix.build }}
CIBW_MANYLINUX_PYPY_X86_64_IMAGE: ${{ matrix.manylinux }} CIBW_MANYLINUX_PYPY_X86_64_IMAGE: ${{ matrix.manylinux }}
CIBW_MANYLINUX_X86_64_IMAGE: ${{ matrix.manylinux }} CIBW_MANYLINUX_X86_64_IMAGE: ${{ matrix.manylinux }}
CIBW_PRERELEASE_PYTHONS: True
CIBW_SKIP: pp38-* CIBW_SKIP: pp38-*
CIBW_TEST_SKIP: cp38-macosx_arm64 CIBW_TEST_SKIP: cp38-macosx_arm64
MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macosx_deployment_target }} MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macosx_deployment_target }}
@ -204,6 +207,7 @@ jobs:
CIBW_ARCHS: ${{ matrix.cibw_arch }} CIBW_ARCHS: ${{ matrix.cibw_arch }}
CIBW_BEFORE_ALL: "{package}\\winbuild\\build\\build_dep_all.cmd" CIBW_BEFORE_ALL: "{package}\\winbuild\\build\\build_dep_all.cmd"
CIBW_CACHE_PATH: "C:\\cibw" CIBW_CACHE_PATH: "C:\\cibw"
CIBW_PRERELEASE_PYTHONS: True
CIBW_SKIP: pp38-* CIBW_SKIP: pp38-*
CIBW_TEST_SKIP: "*-win_arm64" CIBW_TEST_SKIP: "*-win_arm64"
CIBW_TEST_COMMAND: 'docker run --rm CIBW_TEST_COMMAND: 'docker run --rm

View File

@ -5,6 +5,15 @@ Changelog (Pillow)
10.4.0 (unreleased) 10.4.0 (unreleased)
------------------- -------------------
- Added ImageDraw circle() #8085
[void4, hugovk, radarhere]
- Add mypy target to Makefile #8077
[Yay295]
- Added more modes to Image.MODES #7984
[radarhere]
- Deprecate BGR;15, BGR;16 and BGR;24 modes #7978 - Deprecate BGR;15, BGR;16 and BGR;24 modes #7978
[radarhere, hugovk] [radarhere, hugovk]

View File

@ -118,3 +118,8 @@ lint-fix:
python3 -m black . python3 -m black .
python3 -c "import ruff" > /dev/null 2>&1 || python3 -m pip install ruff python3 -c "import ruff" > /dev/null 2>&1 || python3 -m pip install ruff
python3 -m ruff --fix . python3 -m ruff --fix .
.PHONY: mypy
mypy:
python3 -c "import tox" > /dev/null 2>&1 || python3 -m pip install tox
python3 -m tox -e mypy

View File

@ -174,12 +174,13 @@ def skip_unless_feature(feature: str) -> pytest.MarkDecorator:
def skip_unless_feature_version( def skip_unless_feature_version(
feature: str, required: str, reason: str | None = None feature: str, required: str, reason: str | None = None
) -> pytest.MarkDecorator: ) -> pytest.MarkDecorator:
if not features.check(feature): version = features.version(feature)
if version is None:
return pytest.mark.skip(f"{feature} not available") return pytest.mark.skip(f"{feature} not available")
if reason is None: if reason is None:
reason = f"{feature} is older than {required}" reason = f"{feature} is older than {required}"
version_required = parse_version(required) version_required = parse_version(required)
version_available = parse_version(features.version(feature)) version_available = parse_version(version)
return pytest.mark.skipif(version_available < version_required, reason=reason) return pytest.mark.skipif(version_available < version_required, reason=reason)
@ -189,12 +190,13 @@ def mark_if_feature_version(
version_blacklist: str, version_blacklist: str,
reason: str | None = None, reason: str | None = None,
) -> pytest.MarkDecorator: ) -> pytest.MarkDecorator:
if not features.check(feature): version = features.version(feature)
if version is None:
return pytest.mark.pil_noop_mark() return pytest.mark.pil_noop_mark()
if reason is None: if reason is None:
reason = f"{feature} is {version_blacklist}" reason = f"{feature} is {version_blacklist}"
version_required = parse_version(version_blacklist) version_required = parse_version(version_blacklist)
version_available = parse_version(features.version(feature)) version_available = parse_version(version)
if ( if (
version_available.major == version_required.major version_available.major == version_required.major
and version_available.minor == version_required.minor and version_available.minor == version_required.minor
@ -220,16 +222,11 @@ class PillowLeakTestCase:
from resource import RUSAGE_SELF, getrusage from resource import RUSAGE_SELF, getrusage
mem = getrusage(RUSAGE_SELF).ru_maxrss mem = getrusage(RUSAGE_SELF).ru_maxrss
if sys.platform == "darwin": # man 2 getrusage:
# man 2 getrusage: # ru_maxrss
# ru_maxrss # This is the maximum resident set size utilized
# This is the maximum resident set size utilized (in bytes). # in bytes on macOS, in kilobytes on Linux
return mem / 1024 # Kb return mem / 1024 if sys.platform == "darwin" else mem
# linux
# man 2 getrusage
# ru_maxrss (since Linux 2.6.32)
# This is the maximum resident set size used (in kilobytes).
return mem # Kb
def _test_leak(self, core: Callable[[], None]) -> None: def _test_leak(self, core: Callable[[], None]) -> None:
start_mem = self._get_mem_usage() start_mem = self._get_mem_usage()

View File

@ -12,8 +12,9 @@ from Tests.helper import skip_unless_feature
if sys.platform.startswith("win32"): if sys.platform.startswith("win32"):
pytest.skip("Fuzzer is linux only", allow_module_level=True) pytest.skip("Fuzzer is linux only", allow_module_level=True)
if features.check("libjpeg_turbo"): libjpeg_turbo_version = features.version("libjpeg_turbo")
version = packaging.version.parse(features.version("libjpeg_turbo")) if libjpeg_turbo_version is not None:
version = packaging.version.parse(libjpeg_turbo_version)
if version.major == 2 and version.minor == 0: if version.major == 2 and version.minor == 0:
pytestmark = pytest.mark.valgrind_known_error( pytestmark = pytest.mark.valgrind_known_error(
reason="Known failing with libjpeg_turbo 2.0" reason="Known failing with libjpeg_turbo 2.0"

View File

@ -30,7 +30,7 @@ def test_version() -> None:
# Check the correctness of the convenience function # Check the correctness of the convenience function
# and the format of version numbers # and the format of version numbers
def test(name: str, function: Callable[[str], bool]) -> None: def test(name: str, function: Callable[[str], str | None]) -> None:
version = features.version(name) version = features.version(name)
if not features.check(name): if not features.check(name):
assert version is None assert version is None
@ -67,12 +67,16 @@ def test_webp_anim() -> None:
@skip_unless_feature("libjpeg_turbo") @skip_unless_feature("libjpeg_turbo")
def test_libjpeg_turbo_version() -> None: def test_libjpeg_turbo_version() -> None:
assert re.search(r"\d+\.\d+\.\d+$", features.version("libjpeg_turbo")) version = features.version("libjpeg_turbo")
assert version is not None
assert re.search(r"\d+\.\d+\.\d+$", version)
@skip_unless_feature("libimagequant") @skip_unless_feature("libimagequant")
def test_libimagequant_version() -> None: def test_libimagequant_version() -> None:
assert re.search(r"\d+\.\d+\.\d+$", features.version("libimagequant")) version = features.version("libimagequant")
assert version is not None
assert re.search(r"\d+\.\d+\.\d+$", version)
@pytest.mark.parametrize("feature", features.modules) @pytest.mark.parametrize("feature", features.modules)

View File

@ -1252,10 +1252,11 @@ def test_palette_save_L(tmp_path: Path) -> None:
im = hopper("P") im = hopper("P")
im_l = Image.frombytes("L", im.size, im.tobytes()) im_l = Image.frombytes("L", im.size, im.tobytes())
palette = bytes(im.getpalette()) palette = im.getpalette()
assert palette is not None
out = str(tmp_path / "temp.gif") out = str(tmp_path / "temp.gif")
im_l.save(out, palette=palette) im_l.save(out, palette=bytes(palette))
with Image.open(out) as reloaded: with Image.open(out) as reloaded:
assert_image_equal(reloaded.convert("RGB"), im.convert("RGB")) assert_image_equal(reloaded.convert("RGB"), im.convert("RGB"))

View File

@ -70,7 +70,9 @@ class TestFileJpeg:
def test_sanity(self) -> None: def test_sanity(self) -> None:
# internal version number # internal version number
assert re.search(r"\d+\.\d+$", features.version_codec("jpg")) version = features.version_codec("jpg")
assert version is not None
assert re.search(r"\d+\.\d+$", version)
with Image.open(TEST_FILE) as im: with Image.open(TEST_FILE) as im:
im.load() im.load()
@ -152,7 +154,7 @@ class TestFileJpeg:
assert k > 0.9 assert k > 0.9
def test_rgb(self) -> None: def test_rgb(self) -> None:
def getchannels(im: Image.Image) -> tuple[int, int, int]: def getchannels(im: JpegImagePlugin.JpegImageFile) -> tuple[int, int, int]:
return tuple(v[0] for v in im.layer) return tuple(v[0] for v in im.layer)
im = hopper() im = hopper()
@ -441,7 +443,7 @@ class TestFileJpeg:
assert_image(im1, im2.mode, im2.size) assert_image(im1, im2.mode, im2.size)
def test_subsampling(self) -> None: def test_subsampling(self) -> None:
def getsampling(im: Image.Image): def getsampling(im: JpegImagePlugin.JpegImageFile):
layer = im.layer layer = im.layer
return layer[0][1:3] + layer[1][1:3] + layer[2][1:3] return layer[0][1:3] + layer[1][1:3] + layer[2][1:3]

View File

@ -48,7 +48,9 @@ def roundtrip(im: Image.Image, **options: Any) -> Image.Image:
def test_sanity() -> None: def test_sanity() -> None:
# Internal version number # Internal version number
assert re.search(r"\d+\.\d+\.\d+$", features.version_codec("jpg_2000")) version = features.version_codec("jpg_2000")
assert version is not None
assert re.search(r"\d+\.\d+\.\d+$", version)
with Image.open("Tests/images/test-card-lossless.jp2") as im: with Image.open("Tests/images/test-card-lossless.jp2") as im:
px = im.load() px = im.load()

View File

@ -52,7 +52,9 @@ class LibTiffTestCase:
class TestFileLibTiff(LibTiffTestCase): class TestFileLibTiff(LibTiffTestCase):
def test_version(self) -> None: def test_version(self) -> None:
assert re.search(r"\d+\.\d+\.\d+$", features.version_codec("libtiff")) version = features.version_codec("libtiff")
assert version is not None
assert re.search(r"\d+\.\d+\.\d+$", version)
def test_g4_tiff(self, tmp_path: Path) -> None: def test_g4_tiff(self, tmp_path: Path) -> None:
"""Test the ordinary file path load path""" """Test the ordinary file path load path"""
@ -666,7 +668,8 @@ class TestFileLibTiff(LibTiffTestCase):
pilim.save(buffer_io, format="tiff", compression=compression) pilim.save(buffer_io, format="tiff", compression=compression)
buffer_io.seek(0) buffer_io.seek(0)
assert_image_similar_tofile(pilim, buffer_io, 0) with Image.open(buffer_io) as saved_im:
assert_image_similar(pilim, saved_im, 0)
save_bytesio() save_bytesio()
save_bytesio("raw") save_bytesio("raw")

View File

@ -85,9 +85,9 @@ class TestFilePng:
def test_sanity(self, tmp_path: Path) -> None: def test_sanity(self, tmp_path: Path) -> None:
# internal version number # internal version number
assert re.search( version = features.version_codec("zlib")
r"\d+(\.\d+){1,3}(\.zlib\-ng)?$", features.version_codec("zlib") assert version is not None
) assert re.search(r"\d+(\.\d+){1,3}(\.zlib\-ng)?$", version)
test_file = str(tmp_path / "temp.png") test_file = str(tmp_path / "temp.png")

View File

@ -49,7 +49,9 @@ class TestFileWebp:
def test_version(self) -> None: def test_version(self) -> None:
_webp.WebPDecoderVersion() _webp.WebPDecoderVersion()
_webp.WebPDecoderBuggyAlpha() _webp.WebPDecoderBuggyAlpha()
assert re.search(r"\d+\.\d+\.\d+$", features.version_module("webp")) version = features.version_module("webp")
assert version is not None
assert re.search(r"\d+\.\d+\.\d+$", version)
def test_read_rgb(self) -> None: def test_read_rgb(self) -> None:
""" """

View File

@ -52,8 +52,9 @@ def test_write_animation_L(tmp_path: Path) -> None:
assert_image_similar(im, orig.convert("RGBA"), 32.9) assert_image_similar(im, orig.convert("RGBA"), 32.9)
if is_big_endian(): if is_big_endian():
webp = parse_version(features.version_module("webp")) version = features.version_module("webp")
if webp < parse_version("1.2.2"): assert version is not None
if parse_version(version) < parse_version("1.2.2"):
pytest.skip("Fails with libwebp earlier than 1.2.2") pytest.skip("Fails with libwebp earlier than 1.2.2")
orig.seek(orig.n_frames - 1) orig.seek(orig.n_frames - 1)
im.seek(im.n_frames - 1) im.seek(im.n_frames - 1)
@ -78,8 +79,9 @@ def test_write_animation_RGB(tmp_path: Path) -> None:
# Compare second frame to original # Compare second frame to original
if is_big_endian(): if is_big_endian():
webp = parse_version(features.version_module("webp")) version = features.version_module("webp")
if webp < parse_version("1.2.2"): assert version is not None
if parse_version(version) < parse_version("1.2.2"):
pytest.skip("Fails with libwebp earlier than 1.2.2") pytest.skip("Fails with libwebp earlier than 1.2.2")
im.seek(1) im.seek(1)
im.load() im.load()

View File

@ -12,7 +12,7 @@ class TestTTypeFontLeak(PillowLeakTestCase):
iterations = 10 iterations = 10
mem_limit = 4096 # k mem_limit = 4096 # k
def _test_font(self, font: ImageFont.FreeTypeFont) -> None: def _test_font(self, font: ImageFont.FreeTypeFont | ImageFont.ImageFont) -> None:
im = Image.new("RGB", (255, 255), "white") im = Image.new("RGB", (255, 255), "white")
draw = ImageDraw.ImageDraw(im) draw = ImageDraw.ImageDraw(im)
self._test_leak( self._test_leak(

View File

@ -25,6 +25,7 @@ from PIL import (
from .helper import ( from .helper import (
assert_image_equal, assert_image_equal,
assert_image_equal_tofile, assert_image_equal_tofile,
assert_image_similar,
assert_image_similar_tofile, assert_image_similar_tofile,
assert_not_all_same, assert_not_all_same,
hopper, hopper,
@ -99,10 +100,18 @@ class TestImage:
JPGFILE = "Tests/images/hopper.jpg" JPGFILE = "Tests/images/hopper.jpg"
with pytest.raises(TypeError): with pytest.raises(TypeError):
with Image.open(PNGFILE, formats=123): with Image.open(PNGFILE, formats=123): # type: ignore[arg-type]
pass pass
for formats in [["JPEG"], ("JPEG",), ["jpeg"], ["Jpeg"], ["jPeG"], ["JpEg"]]: format_list: list[list[str] | tuple[str, ...]] = [
["JPEG"],
("JPEG",),
["jpeg"],
["Jpeg"],
["jPeG"],
["JpEg"],
]
for formats in format_list:
with pytest.raises(UnidentifiedImageError): with pytest.raises(UnidentifiedImageError):
with Image.open(PNGFILE, formats=formats): with Image.open(PNGFILE, formats=formats):
pass pass
@ -138,7 +147,7 @@ class TestImage:
def test_bad_mode(self) -> None: def test_bad_mode(self) -> None:
with pytest.raises(ValueError): with pytest.raises(ValueError):
with Image.open("filename", "bad mode"): with Image.open("filename", "bad mode"): # type: ignore[arg-type]
pass pass
def test_stringio(self) -> None: def test_stringio(self) -> None:
@ -185,7 +194,8 @@ class TestImage:
with tempfile.TemporaryFile() as fp: with tempfile.TemporaryFile() as fp:
im.save(fp, "JPEG") im.save(fp, "JPEG")
fp.seek(0) fp.seek(0)
assert_image_similar_tofile(im, fp, 20) with Image.open(fp) as reloaded:
assert_image_similar(im, reloaded, 20)
def test_unknown_extension(self, tmp_path: Path) -> None: def test_unknown_extension(self, tmp_path: Path) -> None:
im = hopper() im = hopper()
@ -497,9 +507,11 @@ class TestImage:
def test_check_size(self) -> None: def test_check_size(self) -> None:
# Checking that the _check_size function throws value errors when we want it to # Checking that the _check_size function throws value errors when we want it to
with pytest.raises(ValueError): with pytest.raises(ValueError):
Image.new("RGB", 0) # not a tuple # not a tuple
Image.new("RGB", 0) # type: ignore[arg-type]
with pytest.raises(ValueError): with pytest.raises(ValueError):
Image.new("RGB", (0,)) # Tuple too short # tuple too short
Image.new("RGB", (0,)) # type: ignore[arg-type]
with pytest.raises(ValueError): with pytest.raises(ValueError):
Image.new("RGB", (-1, -1)) # w,h < 0 Image.new("RGB", (-1, -1)) # w,h < 0

View File

@ -86,8 +86,8 @@ def test_fromarray() -> None:
assert test("RGBX") == ("RGBA", (128, 100), True) assert test("RGBX") == ("RGBA", (128, 100), True)
# Test mode is None with no "typestr" in the array interface # Test mode is None with no "typestr" in the array interface
wrapped = Wrapper(hopper("L"), {"shape": (100, 128)})
with pytest.raises(TypeError): with pytest.raises(TypeError):
wrapped = Wrapper(test("L"), {"shape": (100, 128)})
Image.fromarray(wrapped) Image.fromarray(wrapped)

View File

@ -18,7 +18,7 @@ def test_crop(mode: str) -> None:
def test_wide_crop() -> None: def test_wide_crop() -> None:
def crop(*bbox: int) -> tuple[int, ...]: def crop(bbox: tuple[int, int, int, int]) -> tuple[int, ...]:
i = im.crop(bbox) i = im.crop(bbox)
h = i.histogram() h = i.histogram()
while h and not h[-1]: while h and not h[-1]:
@ -27,23 +27,23 @@ def test_wide_crop() -> None:
im = Image.new("L", (100, 100), 1) im = Image.new("L", (100, 100), 1)
assert crop(0, 0, 100, 100) == (0, 10000) assert crop((0, 0, 100, 100)) == (0, 10000)
assert crop(25, 25, 75, 75) == (0, 2500) assert crop((25, 25, 75, 75)) == (0, 2500)
# sides # sides
assert crop(-25, 0, 25, 50) == (1250, 1250) assert crop((-25, 0, 25, 50)) == (1250, 1250)
assert crop(0, -25, 50, 25) == (1250, 1250) assert crop((0, -25, 50, 25)) == (1250, 1250)
assert crop(75, 0, 125, 50) == (1250, 1250) assert crop((75, 0, 125, 50)) == (1250, 1250)
assert crop(0, 75, 50, 125) == (1250, 1250) assert crop((0, 75, 50, 125)) == (1250, 1250)
assert crop(-25, 25, 125, 75) == (2500, 5000) assert crop((-25, 25, 125, 75)) == (2500, 5000)
assert crop(25, -25, 75, 125) == (2500, 5000) assert crop((25, -25, 75, 125)) == (2500, 5000)
# corners # corners
assert crop(-25, -25, 25, 25) == (1875, 625) assert crop((-25, -25, 25, 25)) == (1875, 625)
assert crop(75, -25, 125, 25) == (1875, 625) assert crop((75, -25, 125, 25)) == (1875, 625)
assert crop(75, 75, 125, 125) == (1875, 625) assert crop((75, 75, 125, 125)) == (1875, 625)
assert crop(-25, 75, 25, 125) == (1875, 625) assert crop((-25, 75, 25, 125)) == (1875, 625)
@pytest.mark.parametrize("box", ((8, 2, 2, 8), (2, 8, 8, 2), (8, 8, 2, 2))) @pytest.mark.parametrize("box", ((8, 2, 2, 8), (2, 8, 8, 2), (8, 8, 2, 2)))

View File

@ -46,9 +46,9 @@ def test_sanity(filter_to_apply: ImageFilter.Filter, mode: str) -> None:
@pytest.mark.parametrize("mode", ("L", "I", "RGB", "CMYK")) @pytest.mark.parametrize("mode", ("L", "I", "RGB", "CMYK"))
def test_sanity_error(mode: str) -> None: def test_sanity_error(mode: str) -> None:
im = hopper(mode)
with pytest.raises(TypeError): with pytest.raises(TypeError):
im = hopper(mode) im.filter("hello") # type: ignore[arg-type]
im.filter("hello")
# crashes on small images # crashes on small images

View File

@ -6,7 +6,7 @@ from .helper import hopper
def test_extrema() -> None: def test_extrema() -> None:
def extrema(mode: str) -> tuple[int, int] | tuple[tuple[int, int], ...]: def extrema(mode: str) -> tuple[float, float] | tuple[tuple[int, int], ...]:
return hopper(mode).getextrema() return hopper(mode).getextrema()
assert extrema("1") == (0, 255) assert extrema("1") == (0, 255)

View File

@ -24,8 +24,9 @@ def test_sanity() -> None:
def test_libimagequant_quantize() -> None: def test_libimagequant_quantize() -> None:
image = hopper() image = hopper()
if is_ppc64le(): if is_ppc64le():
libimagequant = parse_version(features.version_feature("libimagequant")) version = features.version_feature("libimagequant")
if libimagequant < parse_version("4"): assert version is not None
if parse_version(version) < parse_version("4"):
pytest.skip("Fails with libimagequant earlier than 4.0.0 on ppc64le") pytest.skip("Fails with libimagequant earlier than 4.0.0 on ppc64le")
converted = image.quantize(100, Image.Quantize.LIBIMAGEQUANT) converted = image.quantize(100, Image.Quantize.LIBIMAGEQUANT)
assert converted.mode == "P" assert converted.mode == "P"

View File

@ -102,7 +102,7 @@ def test_unsupported_modes(mode: str) -> None:
def get_image(mode: str) -> Image.Image: def get_image(mode: str) -> Image.Image:
mode_info = ImageMode.getmode(mode) mode_info = ImageMode.getmode(mode)
if mode_info.basetype == "L": if mode_info.basetype == "L":
bands = [gradients_image] bands: list[Image.Image] = [gradients_image]
for _ in mode_info.bands[1:]: for _ in mode_info.bands[1:]:
# rotate previous image # rotate previous image
band = bands[-1].transpose(Image.Transpose.ROTATE_90) band = bands[-1].transpose(Image.Transpose.ROTATE_90)

View File

@ -7,7 +7,7 @@ import shutil
import sys import sys
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Literal, cast
import pytest import pytest
@ -60,10 +60,13 @@ def test_sanity() -> None:
assert list(map(type, v)) == [str, str, str, str] assert list(map(type, v)) == [str, str, str, str]
# internal version number # internal version number
assert re.search(r"\d+\.\d+(\.\d+)?$", features.version_module("littlecms2")) version = features.version_module("littlecms2")
assert version is not None
assert re.search(r"\d+\.\d+(\.\d+)?$", version)
skip_missing() skip_missing()
i = ImageCms.profileToProfile(hopper(), SRGB, SRGB) i = ImageCms.profileToProfile(hopper(), SRGB, SRGB)
assert i is not None
assert_image(i, "RGB", (128, 128)) assert_image(i, "RGB", (128, 128))
i = hopper() i = hopper()
@ -72,23 +75,27 @@ def test_sanity() -> None:
t = ImageCms.buildTransform(SRGB, SRGB, "RGB", "RGB") t = ImageCms.buildTransform(SRGB, SRGB, "RGB", "RGB")
i = ImageCms.applyTransform(hopper(), t) i = ImageCms.applyTransform(hopper(), t)
assert i is not None
assert_image(i, "RGB", (128, 128)) assert_image(i, "RGB", (128, 128))
with hopper() as i: with hopper() as i:
t = ImageCms.buildTransform(SRGB, SRGB, "RGB", "RGB") t = ImageCms.buildTransform(SRGB, SRGB, "RGB", "RGB")
ImageCms.applyTransform(hopper(), t, inPlace=True) ImageCms.applyTransform(hopper(), t, inPlace=True)
assert i is not None
assert_image(i, "RGB", (128, 128)) assert_image(i, "RGB", (128, 128))
p = ImageCms.createProfile("sRGB") p = ImageCms.createProfile("sRGB")
o = ImageCms.getOpenProfile(SRGB) o = ImageCms.getOpenProfile(SRGB)
t = ImageCms.buildTransformFromOpenProfiles(p, o, "RGB", "RGB") t = ImageCms.buildTransformFromOpenProfiles(p, o, "RGB", "RGB")
i = ImageCms.applyTransform(hopper(), t) i = ImageCms.applyTransform(hopper(), t)
assert i is not None
assert_image(i, "RGB", (128, 128)) assert_image(i, "RGB", (128, 128))
t = ImageCms.buildProofTransform(SRGB, SRGB, SRGB, "RGB", "RGB") t = ImageCms.buildProofTransform(SRGB, SRGB, SRGB, "RGB", "RGB")
assert t.inputMode == "RGB" assert t.inputMode == "RGB"
assert t.outputMode == "RGB" assert t.outputMode == "RGB"
i = ImageCms.applyTransform(hopper(), t) i = ImageCms.applyTransform(hopper(), t)
assert i is not None
assert_image(i, "RGB", (128, 128)) assert_image(i, "RGB", (128, 128))
# test PointTransform convenience API # test PointTransform convenience API
@ -202,13 +209,13 @@ def test_exceptions() -> None:
ImageCms.buildTransform("foo", "bar", "RGB", "RGB") ImageCms.buildTransform("foo", "bar", "RGB", "RGB")
with pytest.raises(ImageCms.PyCMSError, match="Invalid type for Profile"): with pytest.raises(ImageCms.PyCMSError, match="Invalid type for Profile"):
ImageCms.getProfileName(None) ImageCms.getProfileName(None) # type: ignore[arg-type]
skip_missing() skip_missing()
# Python <= 3.9: "an integer is required (got type NoneType)" # Python <= 3.9: "an integer is required (got type NoneType)"
# Python > 3.9: "'NoneType' object cannot be interpreted as an integer" # Python > 3.9: "'NoneType' object cannot be interpreted as an integer"
with pytest.raises(ImageCms.PyCMSError, match="integer"): with pytest.raises(ImageCms.PyCMSError, match="integer"):
ImageCms.isIntentSupported(SRGB, None, None) ImageCms.isIntentSupported(SRGB, None, None) # type: ignore[arg-type]
def test_display_profile() -> None: def test_display_profile() -> None:
@ -232,7 +239,7 @@ def test_unsupported_color_space() -> None:
"Color space not supported for on-the-fly profile creation (unsupported)" "Color space not supported for on-the-fly profile creation (unsupported)"
), ),
): ):
ImageCms.createProfile("unsupported") ImageCms.createProfile("unsupported") # type: ignore[arg-type]
def test_invalid_color_temperature() -> None: def test_invalid_color_temperature() -> None:
@ -240,7 +247,7 @@ def test_invalid_color_temperature() -> None:
ImageCms.PyCMSError, ImageCms.PyCMSError,
match='Color temperature must be numeric, "invalid" not valid', match='Color temperature must be numeric, "invalid" not valid',
): ):
ImageCms.createProfile("LAB", "invalid") ImageCms.createProfile("LAB", "invalid") # type: ignore[arg-type]
@pytest.mark.parametrize("flag", ("my string", -1)) @pytest.mark.parametrize("flag", ("my string", -1))
@ -249,7 +256,7 @@ def test_invalid_flag(flag: str | int) -> None:
with pytest.raises( with pytest.raises(
ImageCms.PyCMSError, match="flags must be an integer between 0 and " ImageCms.PyCMSError, match="flags must be an integer between 0 and "
): ):
ImageCms.profileToProfile(im, "foo", "bar", flags=flag) ImageCms.profileToProfile(im, "foo", "bar", flags=flag) # type: ignore[arg-type]
def test_simple_lab() -> None: def test_simple_lab() -> None:
@ -260,7 +267,7 @@ def test_simple_lab() -> None:
t = ImageCms.buildTransform(psRGB, pLab, "RGB", "LAB") t = ImageCms.buildTransform(psRGB, pLab, "RGB", "LAB")
i_lab = ImageCms.applyTransform(i, t) i_lab = ImageCms.applyTransform(i, t)
assert i_lab is not None
assert i_lab.mode == "LAB" assert i_lab.mode == "LAB"
k = i_lab.getpixel((0, 0)) k = i_lab.getpixel((0, 0))
@ -284,6 +291,7 @@ def test_lab_color() -> None:
# Need to add a type mapping for some PIL type to TYPE_Lab_8 in findLCMSType, and # Need to add a type mapping for some PIL type to TYPE_Lab_8 in findLCMSType, and
# have that mapping work back to a PIL mode (likely RGB). # have that mapping work back to a PIL mode (likely RGB).
i = ImageCms.applyTransform(hopper(), t) i = ImageCms.applyTransform(hopper(), t)
assert i is not None
assert_image(i, "LAB", (128, 128)) assert_image(i, "LAB", (128, 128))
# i.save('temp.lab.tif') # visually verified vs PS. # i.save('temp.lab.tif') # visually verified vs PS.
@ -298,6 +306,7 @@ def test_lab_srgb() -> None:
with Image.open("Tests/images/hopper.Lab.tif") as img: with Image.open("Tests/images/hopper.Lab.tif") as img:
img_srgb = ImageCms.applyTransform(img, t) img_srgb = ImageCms.applyTransform(img, t)
assert img_srgb is not None
# img_srgb.save('temp.srgb.tif') # visually verified vs ps. # img_srgb.save('temp.srgb.tif') # visually verified vs ps.
@ -317,11 +326,11 @@ def test_lab_roundtrip() -> None:
t2 = ImageCms.buildTransform(pLab, psRGB, "LAB", "RGB") t2 = ImageCms.buildTransform(pLab, psRGB, "LAB", "RGB")
i = ImageCms.applyTransform(hopper(), t) i = ImageCms.applyTransform(hopper(), t)
assert i is not None
assert i.info["icc_profile"] == ImageCmsProfile(pLab).tobytes() assert i.info["icc_profile"] == ImageCmsProfile(pLab).tobytes()
out = ImageCms.applyTransform(i, t2) out = ImageCms.applyTransform(i, t2)
assert out is not None
assert_image_similar(hopper(), out, 2) assert_image_similar(hopper(), out, 2)
@ -343,7 +352,7 @@ def test_extended_information() -> None:
p = o.profile p = o.profile
def assert_truncated_tuple_equal( def assert_truncated_tuple_equal(
tup1: tuple[Any, ...], tup2: tuple[Any, ...], digits: int = 10 tup1: tuple[Any, ...] | None, tup2: tuple[Any, ...], digits: int = 10
) -> None: ) -> None:
# Helper function to reduce precision of tuples of floats # Helper function to reduce precision of tuples of floats
# recursively and then check equality. # recursively and then check equality.
@ -359,6 +368,7 @@ def test_extended_information() -> None:
for val in tuple_value for val in tuple_value
) )
assert tup1 is not None
assert truncate_tuple(tup1) == truncate_tuple(tup2) assert truncate_tuple(tup1) == truncate_tuple(tup2)
assert p.attributes == 4294967296 assert p.attributes == 4294967296
@ -504,22 +514,22 @@ def test_non_ascii_path(tmp_path: Path) -> None:
def test_profile_typesafety() -> None: def test_profile_typesafety() -> None:
# does not segfault # does not segfault
with pytest.raises(TypeError, match="Invalid type for Profile"): with pytest.raises(TypeError, match="Invalid type for Profile"):
ImageCms.ImageCmsProfile(0).tobytes() ImageCms.ImageCmsProfile(0) # type: ignore[arg-type]
with pytest.raises(TypeError, match="Invalid type for Profile"): with pytest.raises(TypeError, match="Invalid type for Profile"):
ImageCms.ImageCmsProfile(1).tobytes() ImageCms.ImageCmsProfile(1) # type: ignore[arg-type]
# also check core function # also check core function
with pytest.raises(TypeError): with pytest.raises(TypeError):
ImageCms.core.profile_tobytes(0) ImageCms.core.profile_tobytes(0) # type: ignore[arg-type]
with pytest.raises(TypeError): with pytest.raises(TypeError):
ImageCms.core.profile_tobytes(1) ImageCms.core.profile_tobytes(1) # type: ignore[arg-type]
if not is_pypy(): if not is_pypy():
# core profile should not be directly instantiable # core profile should not be directly instantiable
with pytest.raises(TypeError): with pytest.raises(TypeError):
ImageCms.core.CmsProfile() ImageCms.core.CmsProfile()
with pytest.raises(TypeError): with pytest.raises(TypeError):
ImageCms.core.CmsProfile(0) ImageCms.core.CmsProfile(0) # type: ignore[call-arg]
@pytest.mark.skipif(is_pypy(), reason="fails on PyPy") @pytest.mark.skipif(is_pypy(), reason="fails on PyPy")
@ -528,7 +538,7 @@ def test_transform_typesafety() -> None:
with pytest.raises(TypeError): with pytest.raises(TypeError):
ImageCms.core.CmsTransform() ImageCms.core.CmsTransform()
with pytest.raises(TypeError): with pytest.raises(TypeError):
ImageCms.core.CmsTransform(0) ImageCms.core.CmsTransform(0) # type: ignore[call-arg]
def assert_aux_channel_preserved( def assert_aux_channel_preserved(
@ -578,11 +588,13 @@ def assert_aux_channel_preserved(
) )
# apply transform # apply transform
result_image: Image.Image | None
if transform_in_place: if transform_in_place:
ImageCms.applyTransform(source_image, t, inPlace=True) ImageCms.applyTransform(source_image, t, inPlace=True)
result_image = source_image result_image = source_image
else: else:
result_image = ImageCms.applyTransform(source_image, t, inPlace=False) result_image = ImageCms.applyTransform(source_image, t, inPlace=False)
assert result_image is not None
result_image_aux = result_image.getchannel(preserved_channel) result_image_aux = result_image.getchannel(preserved_channel)
assert_image_equal(source_image_aux, result_image_aux) assert_image_equal(source_image_aux, result_image_aux)
@ -628,7 +640,8 @@ def test_auxiliary_channels_isolated() -> None:
continue continue
# convert with and without AUX data, test colors are equal # convert with and without AUX data, test colors are equal
source_profile = ImageCms.createProfile(src_format[1]) src_colorSpace = cast(Literal["LAB", "XYZ", "sRGB"], src_format[1])
source_profile = ImageCms.createProfile(src_colorSpace)
destination_profile = ImageCms.createProfile(dst_format[1]) destination_profile = ImageCms.createProfile(dst_format[1])
source_image = src_format[3] source_image = src_format[3]
test_transform = ImageCms.buildTransform( test_transform = ImageCms.buildTransform(
@ -639,6 +652,7 @@ def test_auxiliary_channels_isolated() -> None:
) )
# test conversion from aux-ful source # test conversion from aux-ful source
test_image: Image.Image | None
if transform_in_place: if transform_in_place:
test_image = source_image.copy() test_image = source_image.copy()
ImageCms.applyTransform(test_image, test_transform, inPlace=True) ImageCms.applyTransform(test_image, test_transform, inPlace=True)
@ -646,6 +660,7 @@ def test_auxiliary_channels_isolated() -> None:
test_image = ImageCms.applyTransform( test_image = ImageCms.applyTransform(
source_image, test_transform, inPlace=False source_image, test_transform, inPlace=False
) )
assert test_image is not None
# reference conversion from aux-less source # reference conversion from aux-less source
reference_transform = ImageCms.buildTransform( reference_transform = ImageCms.buildTransform(
@ -657,7 +672,7 @@ def test_auxiliary_channels_isolated() -> None:
reference_image = ImageCms.applyTransform( reference_image = ImageCms.applyTransform(
source_image.convert(src_format[2]), reference_transform source_image.convert(src_format[2]), reference_transform
) )
assert reference_image is not None
assert_image_equal(test_image.convert(dst_format[2]), reference_image) assert_image_equal(test_image.convert(dst_format[2]), reference_image)

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import contextlib import contextlib
import os.path import os.path
from typing import Sequence
import pytest import pytest
@ -265,6 +266,21 @@ def test_chord_too_fat() -> None:
assert_image_equal_tofile(im, "Tests/images/imagedraw_chord_too_fat.png") assert_image_equal_tofile(im, "Tests/images/imagedraw_chord_too_fat.png")
@pytest.mark.parametrize("mode", ("RGB", "L"))
@pytest.mark.parametrize("xy", ((W / 2, H / 2), [W / 2, H / 2]))
def test_circle(mode: str, xy: Sequence[float]) -> None:
# Arrange
im = Image.new(mode, (W, H))
draw = ImageDraw.Draw(im)
expected = f"Tests/images/imagedraw_ellipse_{mode}.png"
# Act
draw.circle(xy, 25, fill="green", outline="blue")
# Assert
assert_image_similar_tofile(im, expected, 1)
@pytest.mark.parametrize("mode", ("RGB", "L")) @pytest.mark.parametrize("mode", ("RGB", "L"))
@pytest.mark.parametrize("bbox", BBOX) @pytest.mark.parametrize("bbox", BBOX)
def test_ellipse(mode: str, bbox: Coords) -> None: def test_ellipse(mode: str, bbox: Coords) -> None:
@ -1067,8 +1083,8 @@ def test_line_horizontal() -> None:
) )
@pytest.mark.xfail(reason="failing test")
def test_line_h_s1_w2() -> None: def test_line_h_s1_w2() -> None:
pytest.skip("failing")
img, draw = create_base_image_draw((20, 20)) img, draw = create_base_image_draw((20, 20))
draw.line((5, 5, 14, 6), BLACK, 2) draw.line((5, 5, 14, 6), BLACK, 2)
assert_image_equal_tofile( assert_image_equal_tofile(

View File

@ -202,6 +202,8 @@ class TestImageFile:
class MockPyDecoder(ImageFile.PyDecoder): class MockPyDecoder(ImageFile.PyDecoder):
last: MockPyDecoder
def __init__(self, mode: str, *args: Any) -> None: def __init__(self, mode: str, *args: Any) -> None:
MockPyDecoder.last = self MockPyDecoder.last = self
@ -213,6 +215,8 @@ class MockPyDecoder(ImageFile.PyDecoder):
class MockPyEncoder(ImageFile.PyEncoder): class MockPyEncoder(ImageFile.PyEncoder):
last: MockPyEncoder | None
def __init__(self, mode: str, *args: Any) -> None: def __init__(self, mode: str, *args: Any) -> None:
MockPyEncoder.last = self MockPyEncoder.last = self
@ -315,6 +319,7 @@ class TestPyEncoder(CodecsTest):
im, fp, [("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize), 0, "RGB")] im, fp, [("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize), 0, "RGB")]
) )
assert MockPyEncoder.last
assert MockPyEncoder.last.state.xoff == xoff assert MockPyEncoder.last.state.xoff == xoff
assert MockPyEncoder.last.state.yoff == yoff assert MockPyEncoder.last.state.yoff == yoff
assert MockPyEncoder.last.state.xsize == xsize assert MockPyEncoder.last.state.xsize == xsize
@ -329,6 +334,7 @@ class TestPyEncoder(CodecsTest):
fp = BytesIO() fp = BytesIO()
ImageFile._save(im, fp, [("MOCK", None, 0, "RGB")]) ImageFile._save(im, fp, [("MOCK", None, 0, "RGB")])
assert MockPyEncoder.last
assert MockPyEncoder.last.state.xoff == 0 assert MockPyEncoder.last.state.xoff == 0
assert MockPyEncoder.last.state.yoff == 0 assert MockPyEncoder.last.state.yoff == 0
assert MockPyEncoder.last.state.xsize == 200 assert MockPyEncoder.last.state.xsize == 200

View File

@ -34,7 +34,9 @@ pytestmark = skip_unless_feature("freetype2")
def test_sanity() -> None: def test_sanity() -> None:
assert re.search(r"\d+\.\d+\.\d+$", features.version_module("freetype2")) version = features.version_module("freetype2")
assert version is not None
assert re.search(r"\d+\.\d+\.\d+$", version)
@pytest.fixture( @pytest.fixture(
@ -547,11 +549,10 @@ def test_find_font(
def loadable_font( def loadable_font(
filepath: str, size: int, index: int, encoding: str, *args: Any filepath: str, size: int, index: int, encoding: str, *args: Any
): ):
_freeTypeFont = getattr(ImageFont, "_FreeTypeFont")
if filepath == path_to_fake: if filepath == path_to_fake:
return ImageFont._FreeTypeFont( return _freeTypeFont(FONT_PATH, size, index, encoding, *args)
FONT_PATH, size, index, encoding, *args return _freeTypeFont(filepath, size, index, encoding, *args)
)
return ImageFont._FreeTypeFont(filepath, size, index, encoding, *args)
m.setattr(ImageFont, "FreeTypeFont", loadable_font) m.setattr(ImageFont, "FreeTypeFont", loadable_font)
font = ImageFont.truetype(fontname) font = ImageFont.truetype(fontname)
@ -630,7 +631,9 @@ def test_complex_font_settings() -> None:
def test_variation_get(font: ImageFont.FreeTypeFont) -> None: def test_variation_get(font: ImageFont.FreeTypeFont) -> None:
freetype = parse_version(features.version_module("freetype2")) version = features.version_module("freetype2")
assert version is not None
freetype = parse_version(version)
if freetype < parse_version("2.9.1"): if freetype < parse_version("2.9.1"):
with pytest.raises(NotImplementedError): with pytest.raises(NotImplementedError):
font.get_variation_names() font.get_variation_names()
@ -700,7 +703,9 @@ def _check_text(font: ImageFont.FreeTypeFont, path: str, epsilon: float) -> None
def test_variation_set_by_name(font: ImageFont.FreeTypeFont) -> None: def test_variation_set_by_name(font: ImageFont.FreeTypeFont) -> None:
freetype = parse_version(features.version_module("freetype2")) version = features.version_module("freetype2")
assert version is not None
freetype = parse_version(version)
if freetype < parse_version("2.9.1"): if freetype < parse_version("2.9.1"):
with pytest.raises(NotImplementedError): with pytest.raises(NotImplementedError):
font.set_variation_by_name("Bold") font.set_variation_by_name("Bold")
@ -725,7 +730,9 @@ def test_variation_set_by_name(font: ImageFont.FreeTypeFont) -> None:
def test_variation_set_by_axes(font: ImageFont.FreeTypeFont) -> None: def test_variation_set_by_axes(font: ImageFont.FreeTypeFont) -> None:
freetype = parse_version(features.version_module("freetype2")) version = features.version_module("freetype2")
assert version is not None
freetype = parse_version(version)
if freetype < parse_version("2.9.1"): if freetype < parse_version("2.9.1"):
with pytest.raises(NotImplementedError): with pytest.raises(NotImplementedError):
font.set_variation_by_axes([100]) font.set_variation_by_axes([100])

View File

@ -4,11 +4,11 @@ from typing import Generator
import pytest import pytest
from PIL import Image, ImageFilter from PIL import Image, ImageFile, ImageFilter
@pytest.fixture @pytest.fixture
def test_images() -> Generator[dict[str, Image.Image], None, None]: def test_images() -> Generator[dict[str, ImageFile.ImageFile], None, None]:
ims = { ims = {
"im": Image.open("Tests/images/hopper.ppm"), "im": Image.open("Tests/images/hopper.ppm"),
"snakes": Image.open("Tests/images/color_snakes.png"), "snakes": Image.open("Tests/images/color_snakes.png"),
@ -20,7 +20,7 @@ def test_images() -> Generator[dict[str, Image.Image], None, None]:
im.close() im.close()
def test_filter_api(test_images: dict[str, Image.Image]) -> None: def test_filter_api(test_images: dict[str, ImageFile.ImageFile]) -> None:
im = test_images["im"] im = test_images["im"]
test_filter = ImageFilter.GaussianBlur(2.0) test_filter = ImageFilter.GaussianBlur(2.0)
@ -34,7 +34,7 @@ def test_filter_api(test_images: dict[str, Image.Image]) -> None:
assert i.size == (128, 128) assert i.size == (128, 128)
def test_usm_formats(test_images: dict[str, Image.Image]) -> None: def test_usm_formats(test_images: dict[str, ImageFile.ImageFile]) -> None:
im = test_images["im"] im = test_images["im"]
usm = ImageFilter.UnsharpMask usm = ImageFilter.UnsharpMask
@ -52,7 +52,7 @@ def test_usm_formats(test_images: dict[str, Image.Image]) -> None:
im.convert("YCbCr").filter(usm) im.convert("YCbCr").filter(usm)
def test_blur_formats(test_images: dict[str, Image.Image]) -> None: def test_blur_formats(test_images: dict[str, ImageFile.ImageFile]) -> None:
im = test_images["im"] im = test_images["im"]
blur = ImageFilter.GaussianBlur blur = ImageFilter.GaussianBlur
@ -70,7 +70,7 @@ def test_blur_formats(test_images: dict[str, Image.Image]) -> None:
im.convert("YCbCr").filter(blur) im.convert("YCbCr").filter(blur)
def test_usm_accuracy(test_images: dict[str, Image.Image]) -> None: def test_usm_accuracy(test_images: dict[str, ImageFile.ImageFile]) -> None:
snakes = test_images["snakes"] snakes = test_images["snakes"]
src = snakes.convert("RGB") src = snakes.convert("RGB")
@ -79,7 +79,7 @@ def test_usm_accuracy(test_images: dict[str, Image.Image]) -> None:
assert i.tobytes() == src.tobytes() assert i.tobytes() == src.tobytes()
def test_blur_accuracy(test_images: dict[str, Image.Image]) -> None: def test_blur_accuracy(test_images: dict[str, ImageFile.ImageFile]) -> None:
snakes = test_images["snakes"] snakes = test_images["snakes"]
i = snakes.filter(ImageFilter.GaussianBlur(0.4)) i = snakes.filter(ImageFilter.GaussianBlur(0.4))

View File

@ -25,10 +25,10 @@ def test_sanity() -> None:
st.stddev st.stddev
with pytest.raises(AttributeError): with pytest.raises(AttributeError):
st.spam() st.spam() # type: ignore[attr-defined]
with pytest.raises(TypeError): with pytest.raises(TypeError):
ImageStat.Stat(1) ImageStat.Stat(1) # type: ignore[arg-type]
def test_hopper() -> None: def test_hopper() -> None:

View File

@ -227,6 +227,18 @@ Methods
.. versionadded:: 5.3.0 .. versionadded:: 5.3.0
.. py:method:: ImageDraw.circle(xy, radius, fill=None, outline=None, width=1)
Draws a circle with a given radius centering on a point.
.. versionadded:: 10.4.0
:param xy: The point for the center of the circle, e.g. ``(x, y)``.
:param radius: Radius of the circle.
:param outline: Color to use for the outline.
:param fill: Color to use for the fill.
:param width: The line width, in pixels.
.. py:method:: ImageDraw.ellipse(xy, fill=None, outline=None, width=1) .. py:method:: ImageDraw.ellipse(xy, fill=None, outline=None, width=1)
Draws an ellipse inside the given bounding box. Draws an ellipse inside the given bounding box.

View File

@ -45,6 +45,13 @@ TODO
API Additions API Additions
============= =============
ImageDraw.circle
^^^^^^^^^^^^^^^^
Added :py:meth:`~PIL.ImageDraw.ImageDraw.circle`. It provides the same functionality as
:py:meth:`~PIL.ImageDraw.ImageDraw.ellipse`, but instead of taking a bounding box, it
takes a center point and radius.
TODO TODO
^^^^ ^^^^
@ -53,7 +60,9 @@ TODO
Other Changes Other Changes
============= =============
TODO Python 3.13 beta
^^^^ ^^^^^^^^^^^^^^^^
TODO To help others prepare for Python 3.13, wheels have been built against the 3.13 beta as
a preview. This is not official support for Python 3.13, but simply an opportunity for
users to test how Pillow works with the beta and report any problems.

View File

@ -30,6 +30,7 @@ import math
import os import os
import subprocess import subprocess
from enum import IntEnum from enum import IntEnum
from functools import cached_property
from . import ( from . import (
Image, Image,
@ -112,8 +113,7 @@ class GifImageFile(ImageFile.ImageFile):
self._fp = self.fp # FIXME: hack self._fp = self.fp # FIXME: hack
self.__rewind = self.fp.tell() self.__rewind = self.fp.tell()
self._n_frames = None self._n_frames: int | None = None
self._is_animated = None
self._seek(0) # get ready to read first frame self._seek(0) # get ready to read first frame
@property @property
@ -128,24 +128,23 @@ class GifImageFile(ImageFile.ImageFile):
self.seek(current) self.seek(current)
return self._n_frames return self._n_frames
@property @cached_property
def is_animated(self): def is_animated(self) -> bool:
if self._is_animated is None: if self._n_frames is not None:
if self._n_frames is not None: return self._n_frames != 1
self._is_animated = self._n_frames != 1
else:
current = self.tell()
if current:
self._is_animated = True
else:
try:
self._seek(1, False)
self._is_animated = True
except EOFError:
self._is_animated = False
self.seek(current) current = self.tell()
return self._is_animated if current:
return True
try:
self._seek(1, False)
is_animated = True
except EOFError:
is_animated = False
self.seek(current)
return is_animated
def seek(self, frame: int) -> None: def seek(self, frame: int) -> None:
if not self._seek_check(frame): if not self._seek_check(frame):

View File

@ -1511,7 +1511,7 @@ class Image:
self._exif._loaded = False self._exif._loaded = False
self.getexif() self.getexif()
def get_child_images(self): def get_child_images(self) -> list[ImageFile.ImageFile]:
child_images = [] child_images = []
exif = self.getexif() exif = self.getexif()
ifds = [] ifds = []
@ -1535,10 +1535,7 @@ class Image:
fp = self.fp fp = self.fp
thumbnail_offset = ifd.get(513) thumbnail_offset = ifd.get(513)
if thumbnail_offset is not None: if thumbnail_offset is not None:
try: thumbnail_offset += getattr(self, "_exif_offset", 0)
thumbnail_offset += self._exif_offset
except AttributeError:
pass
self.fp.seek(thumbnail_offset) self.fp.seek(thumbnail_offset)
data = self.fp.read(ifd.get(514)) data = self.fp.read(ifd.get(514))
fp = io.BytesIO(data) fp = io.BytesIO(data)
@ -1604,7 +1601,7 @@ class Image:
or "transparency" in self.info or "transparency" in self.info
) )
def apply_transparency(self): def apply_transparency(self) -> None:
""" """
If a P mode image has a "transparency" key in the info dictionary, If a P mode image has a "transparency" key in the info dictionary,
remove the key and instead apply the transparency to the palette. remove the key and instead apply the transparency to the palette.
@ -1616,6 +1613,7 @@ class Image:
from . import ImagePalette from . import ImagePalette
palette = self.getpalette("RGBA") palette = self.getpalette("RGBA")
assert palette is not None
transparency = self.info["transparency"] transparency = self.info["transparency"]
if isinstance(transparency, bytes): if isinstance(transparency, bytes):
for i, alpha in enumerate(transparency): for i, alpha in enumerate(transparency):

View File

@ -754,7 +754,7 @@ def applyTransform(
def createProfile( def createProfile(
colorSpace: Literal["LAB", "XYZ", "sRGB"], colorTemp: SupportsFloat = -1 colorSpace: Literal["LAB", "XYZ", "sRGB"], colorTemp: SupportsFloat = 0
) -> core.CmsProfile: ) -> core.CmsProfile:
""" """
(pyCMS) Creates a profile. (pyCMS) Creates a profile.
@ -777,7 +777,7 @@ def createProfile(
:param colorSpace: String, the color space of the profile you wish to :param colorSpace: String, the color space of the profile you wish to
create. create.
Currently only "LAB", "XYZ", and "sRGB" are supported. Currently only "LAB", "XYZ", and "sRGB" are supported.
:param colorTemp: Positive integer for the white point for the profile, in :param colorTemp: Positive number for the white point for the profile, in
degrees Kelvin (i.e. 5000, 6500, 9600, etc.). The default is for D50 degrees Kelvin (i.e. 5000, 6500, 9600, etc.). The default is for D50
illuminant if omitted (5000k). colorTemp is ONLY applied to LAB illuminant if omitted (5000k). colorTemp is ONLY applied to LAB
profiles, and is ignored for XYZ and sRGB. profiles, and is ignored for XYZ and sRGB.
@ -1089,7 +1089,7 @@ def isIntentSupported(
raise PyCMSError(v) from v raise PyCMSError(v) from v
def versions() -> tuple[str, str, str, str]: def versions() -> tuple[str, str | None, str, str]:
""" """
(pyCMS) Fetches versions. (pyCMS) Fetches versions.
""" """

View File

@ -181,6 +181,13 @@ class ImageDraw:
if ink is not None and ink != fill and width != 0: if ink is not None and ink != fill and width != 0:
self.draw.draw_ellipse(xy, ink, 0, width) self.draw.draw_ellipse(xy, ink, 0, width)
def circle(
self, xy: Sequence[float], radius: float, fill=None, outline=None, width=1
) -> None:
"""Draw a circle given center coordinates and a radius."""
ellipse_xy = (xy[0] - radius, xy[1] - radius, xy[0] + radius, xy[1] + radius)
self.ellipse(ellipse_xy, fill, outline, width)
def line(self, xy: Coords, 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.""" """Draw a line, or a connected sequence of line segments."""
ink = self._getink(fill)[0] ink = self._getink(fill)[0]
@ -901,7 +908,13 @@ def getdraw(im=None, hints=None):
return im, handler return im, handler
def floodfill(image: Image.Image, xy, value, border=None, thresh=0) -> None: def floodfill(
image: Image.Image,
xy: tuple[int, int],
value: float | tuple[int, ...],
border: float | tuple[int, ...] | None = None,
thresh: float = 0,
) -> None:
""" """
(experimental) Fills a bounded region with a given color. (experimental) Fills a bounded region with a given color.

View File

@ -160,10 +160,6 @@ class ImageFont:
.. versionadded:: 9.2.0 .. versionadded:: 9.2.0
:param text: Text to render. :param text: Text to render.
:param mode: Used by some graphics drivers to indicate what mode the
driver prefers; if empty, the renderer may return either
mode. Note that the mode is always a string, to simplify
C-level implementations.
:return: ``(left, top, right, bottom)`` bounding box :return: ``(left, top, right, bottom)`` bounding box
""" """

View File

@ -1050,22 +1050,22 @@ class PngImageFile(ImageFile.ImageFile):
# PNG writer # PNG writer
_OUTMODES = { _OUTMODES = {
# supported PIL modes, and corresponding rawmodes/bits/color combinations # supported PIL modes, and corresponding rawmode, bit depth and color type
"1": ("1", b"\x01\x00"), "1": ("1", b"\x01", b"\x00"),
"L;1": ("L;1", b"\x01\x00"), "L;1": ("L;1", b"\x01", b"\x00"),
"L;2": ("L;2", b"\x02\x00"), "L;2": ("L;2", b"\x02", b"\x00"),
"L;4": ("L;4", b"\x04\x00"), "L;4": ("L;4", b"\x04", b"\x00"),
"L": ("L", b"\x08\x00"), "L": ("L", b"\x08", b"\x00"),
"LA": ("LA", b"\x08\x04"), "LA": ("LA", b"\x08", b"\x04"),
"I": ("I;16B", b"\x10\x00"), "I": ("I;16B", b"\x10", b"\x00"),
"I;16": ("I;16B", b"\x10\x00"), "I;16": ("I;16B", b"\x10", b"\x00"),
"I;16B": ("I;16B", b"\x10\x00"), "I;16B": ("I;16B", b"\x10", b"\x00"),
"P;1": ("P;1", b"\x01\x03"), "P;1": ("P;1", b"\x01", b"\x03"),
"P;2": ("P;2", b"\x02\x03"), "P;2": ("P;2", b"\x02", b"\x03"),
"P;4": ("P;4", b"\x04\x03"), "P;4": ("P;4", b"\x04", b"\x03"),
"P": ("P", b"\x08\x03"), "P": ("P", b"\x08", b"\x03"),
"RGB": ("RGB", b"\x08\x02"), "RGB": ("RGB", b"\x08", b"\x02"),
"RGBA": ("RGBA", b"\x08\x06"), "RGBA": ("RGBA", b"\x08", b"\x06"),
} }
@ -1294,7 +1294,7 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False):
# get the corresponding PNG mode # get the corresponding PNG mode
try: try:
rawmode, mode = _OUTMODES[mode] rawmode, bit_depth, color_type = _OUTMODES[mode]
except KeyError as e: except KeyError as e:
msg = f"cannot write mode {mode} as PNG" msg = f"cannot write mode {mode} as PNG"
raise OSError(msg) from e raise OSError(msg) from e
@ -1309,7 +1309,8 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False):
b"IHDR", b"IHDR",
o32(size[0]), # 0: size o32(size[0]), # 0: size
o32(size[1]), o32(size[1]),
mode, # 8: depth/type bit_depth,
color_type,
b"\0", # 10: compression b"\0", # 10: compression
b"\0", # 11: filter category b"\0", # 11: filter category
b"\0", # 12: interlace flag b"\0", # 12: interlace flag

View File

@ -81,7 +81,8 @@ class PyAccess:
""" """
Modifies the pixel at x,y. The color is given as a single Modifies the pixel at x,y. The color is given as a single
numerical value for single band images, and a tuple for numerical value for single band images, and a tuple for
multi-band images multi-band images. In addition to this, RGB and RGBA tuples
are accepted for P and PA images.
:param xy: The pixel coordinate, given as (x, y). See :param xy: The pixel coordinate, given as (x, y). See
:ref:`coordinate-system`. :ref:`coordinate-system`.
@ -112,7 +113,7 @@ class PyAccess:
return self.set_pixel(x, y, color) return self.set_pixel(x, y, color)
def __getitem__(self, xy): def __getitem__(self, xy: tuple[int, int]) -> float | tuple[int, ...]:
""" """
Returns the pixel at x,y. The pixel is returned as a single Returns the pixel at x,y. The pixel is returned as a single
value for single band images or a tuple for multiple band value for single band images or a tuple for multiple band
@ -141,6 +142,12 @@ class PyAccess:
raise ValueError(msg) raise ValueError(msg)
return xy return xy
def get_pixel(self, x: int, y: int) -> float | tuple[int, ...]:
raise NotImplementedError()
def set_pixel(self, x: int, y: int, color: float | tuple[int, ...]) -> None:
raise NotImplementedError()
class _PyAccess32_2(PyAccess): class _PyAccess32_2(PyAccess):
"""PA, LA, stored in first and last bytes of a 32 bit word""" """PA, LA, stored in first and last bytes of a 32 bit word"""
@ -148,7 +155,7 @@ class _PyAccess32_2(PyAccess):
def _post_init(self, *args, **kwargs): def _post_init(self, *args, **kwargs):
self.pixels = ffi.cast("struct Pixel_RGBA **", self.image32) self.pixels = ffi.cast("struct Pixel_RGBA **", self.image32)
def get_pixel(self, x, y): def get_pixel(self, x: int, y: int) -> tuple[int, int]:
pixel = self.pixels[y][x] pixel = self.pixels[y][x]
return pixel.r, pixel.a return pixel.r, pixel.a
@ -203,7 +210,7 @@ class _PyAccess8(PyAccess):
def _post_init(self, *args, **kwargs): def _post_init(self, *args, **kwargs):
self.pixels = self.image8 self.pixels = self.image8
def get_pixel(self, x, y): def get_pixel(self, x: int, y: int) -> int:
return self.pixels[y][x] return self.pixels[y][x]
def set_pixel(self, x, y, color): def set_pixel(self, x, y, color):
@ -221,7 +228,7 @@ class _PyAccessI16_N(PyAccess):
def _post_init(self, *args, **kwargs): def _post_init(self, *args, **kwargs):
self.pixels = ffi.cast("unsigned short **", self.image) self.pixels = ffi.cast("unsigned short **", self.image)
def get_pixel(self, x, y): def get_pixel(self, x: int, y: int) -> int:
return self.pixels[y][x] return self.pixels[y][x]
def set_pixel(self, x, y, color): def set_pixel(self, x, y, color):
@ -239,7 +246,7 @@ class _PyAccessI16_L(PyAccess):
def _post_init(self, *args, **kwargs): def _post_init(self, *args, **kwargs):
self.pixels = ffi.cast("struct Pixel_I16 **", self.image) self.pixels = ffi.cast("struct Pixel_I16 **", self.image)
def get_pixel(self, x, y): def get_pixel(self, x: int, y: int) -> int:
pixel = self.pixels[y][x] pixel = self.pixels[y][x]
return pixel.l + pixel.r * 256 return pixel.l + pixel.r * 256
@ -260,7 +267,7 @@ class _PyAccessI16_B(PyAccess):
def _post_init(self, *args, **kwargs): def _post_init(self, *args, **kwargs):
self.pixels = ffi.cast("struct Pixel_I16 **", self.image) self.pixels = ffi.cast("struct Pixel_I16 **", self.image)
def get_pixel(self, x, y): def get_pixel(self, x: int, y: int) -> int:
pixel = self.pixels[y][x] pixel = self.pixels[y][x]
return pixel.l * 256 + pixel.r return pixel.l * 256 + pixel.r
@ -281,7 +288,7 @@ class _PyAccessI32_N(PyAccess):
def _post_init(self, *args, **kwargs): def _post_init(self, *args, **kwargs):
self.pixels = self.image32 self.pixels = self.image32
def get_pixel(self, x, y): def get_pixel(self, x: int, y: int) -> int:
return self.pixels[y][x] return self.pixels[y][x]
def set_pixel(self, x, y, color): def set_pixel(self, x, y, color):
@ -300,7 +307,7 @@ class _PyAccessI32_Swap(PyAccess):
chars[0], chars[1], chars[2], chars[3] = chars[3], chars[2], chars[1], chars[0] chars[0], chars[1], chars[2], chars[3] = chars[3], chars[2], chars[1], chars[0]
return ffi.cast("int *", chars)[0] return ffi.cast("int *", chars)[0]
def get_pixel(self, x, y): def get_pixel(self, x: int, y: int) -> int:
return self.reverse(self.pixels[y][x]) return self.reverse(self.pixels[y][x])
def set_pixel(self, x, y, color): def set_pixel(self, x, y, color):
@ -313,7 +320,7 @@ class _PyAccessF(PyAccess):
def _post_init(self, *args, **kwargs): def _post_init(self, *args, **kwargs):
self.pixels = ffi.cast("float **", self.image32) self.pixels = ffi.cast("float **", self.image32)
def get_pixel(self, x, y): def get_pixel(self, x: int, y: int) -> float:
return self.pixels[y][x] return self.pixels[y][x]
def set_pixel(self, x, y, color): def set_pixel(self, x, y, color):
@ -361,7 +368,7 @@ else:
mode_map["I;32B"] = _PyAccessI32_N mode_map["I;32B"] = _PyAccessI32_N
def new(img, readonly=False): def new(img: Image.Image, readonly: bool = False) -> PyAccess | None:
access_type = mode_map.get(img.mode, None) access_type = mode_map.get(img.mode, None)
if not access_type: if not access_type:
logger.debug("PyAccess Not Implemented: %s", img.mode) logger.debug("PyAccess Not Implemented: %s", img.mode)

View File

@ -2,7 +2,7 @@ import datetime
import sys import sys
from typing import Literal, SupportsFloat, TypedDict from typing import Literal, SupportsFloat, TypedDict
littlecms_version: str littlecms_version: str | None
_Tuple3f = tuple[float, float, float] _Tuple3f = tuple[float, float, float]
_Tuple2x3f = tuple[_Tuple3f, _Tuple3f] _Tuple2x3f = tuple[_Tuple3f, _Tuple3f]