From ab25042353a72e7d75260bbacad71a3f942a7a4a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 4 May 2026 19:42:55 +1000 Subject: [PATCH 1/8] Set prCreation to not-pending --- .github/renovate.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/renovate.json b/.github/renovate.json index f5af3d05a..387630dd6 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -7,6 +7,7 @@ "Dependency" ], "minimumReleaseAge": "7 days", + "prCreation": "not-pending", "schedule": [ "* * 3 * *" ], From 689a7f37fd346608b30beb4d0e12e1b39fa19560 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 21:45:55 +1000 Subject: [PATCH 2/8] Update google/oss-fuzz digest to d872252 (#9614) --- .github/workflows/cifuzz.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cifuzz.yml b/.github/workflows/cifuzz.yml index a2e1112dc..99d04205f 100644 --- a/.github/workflows/cifuzz.yml +++ b/.github/workflows/cifuzz.yml @@ -30,14 +30,14 @@ jobs: steps: - name: Build Fuzzers id: build - uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@c11174f47deee98f260dede5d661614bda78ae39 # master + uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@d87225267726cf7ce1a3e17cf103c5ac943c4f05 # master with: oss-fuzz-project-name: 'pillow' language: python dry-run: false - name: Run Fuzzers id: run - uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@c11174f47deee98f260dede5d661614bda78ae39 # master + uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@d87225267726cf7ce1a3e17cf103c5ac943c4f05 # master with: oss-fuzz-project-name: 'pillow' fuzz-seconds: 600 From 903065f5e9f496d4db6854315173a1dc171843d2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 17:17:50 +0000 Subject: [PATCH 3/8] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.15.9 → v0.15.12](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.9...v0.15.12) - [github.com/pre-commit/mirrors-clang-format: v22.1.2 → v22.1.4](https://github.com/pre-commit/mirrors-clang-format/compare/v22.1.2...v22.1.4) - [github.com/python-jsonschema/check-jsonschema: 0.37.1 → 0.37.2](https://github.com/python-jsonschema/check-jsonschema/compare/0.37.1...0.37.2) - [github.com/zizmorcore/zizmor-pre-commit: v1.23.1 → v1.24.1](https://github.com/zizmorcore/zizmor-pre-commit/compare/v1.23.1...v1.24.1) - [github.com/tox-dev/pyproject-fmt: v2.21.0 → v2.21.1](https://github.com/tox-dev/pyproject-fmt/compare/v2.21.0...v2.21.1) --- .pre-commit-config.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e61e50087..5ee040297 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.9 + rev: v0.15.12 hooks: - id: ruff-check args: [--exit-non-zero-on-fix] @@ -24,7 +24,7 @@ repos: exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.gd$|\.opt$) - repo: https://github.com/pre-commit/mirrors-clang-format - rev: v22.1.2 + rev: v22.1.4 hooks: - id: clang-format types: [c] @@ -54,14 +54,14 @@ repos: exclude: ^\.github/.*TEMPLATE|^Tests/(fonts|images)/ - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.37.1 + rev: 0.37.2 hooks: - id: check-github-workflows - id: check-readthedocs - id: check-renovate - repo: https://github.com/zizmorcore/zizmor-pre-commit - rev: v1.23.1 + rev: v1.24.1 hooks: - id: zizmor @@ -71,7 +71,7 @@ repos: - id: sphinx-lint - repo: https://github.com/tox-dev/pyproject-fmt - rev: v2.21.0 + rev: v2.21.1 hooks: - id: pyproject-fmt From f693a3a0e5355376438988eb2f35dcd82a936571 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Wed, 6 May 2026 23:51:16 +1000 Subject: [PATCH 4/8] Use plugin method directly when saving PDFs (#9547) --- src/PIL/PdfImagePlugin.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/PIL/PdfImagePlugin.py b/src/PIL/PdfImagePlugin.py index 5594c7e0f..cb26786b0 100644 --- a/src/PIL/PdfImagePlugin.py +++ b/src/PIL/PdfImagePlugin.py @@ -148,10 +148,14 @@ def _write_image( strip_size=math.ceil(width / 8) * height, ) elif decode_filter == "DCTDecode": - Image.SAVE["JPEG"](im, op, filename) + from . import JpegImagePlugin + + JpegImagePlugin._save(im, op, filename) elif decode_filter == "JPXDecode": + from . import Jpeg2KImagePlugin + del dict_obj["BitsPerComponent"] - Image.SAVE["JPEG2000"](im, op, filename) + Jpeg2KImagePlugin._save(im, op, filename) else: msg = f"unsupported PDF filter ({decode_filter})" raise ValueError(msg) From 70713d69b05ebdcf8aa1cd1464d86e143c195020 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 7 May 2026 23:53:24 +1000 Subject: [PATCH 5/8] Do not generate SBOM in scheduled run on fork --- .github/workflows/wheels.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index e0edb3ac0..fa3271de0 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -276,6 +276,7 @@ jobs: anaconda_nightly_upload_token: ${{ secrets.ANACONDA_ORG_UPLOAD_TOKEN }} sbom: + if: github.event_name != 'schedule' || github.event.repository.fork == false runs-on: ubuntu-latest name: Generate SBOM steps: From 24696af8898f86007252c5774da9f14f2be93879 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Fri, 8 May 2026 19:50:29 +1000 Subject: [PATCH 6/8] Increase AVIF test epsilon for riscv64 (#9606) --- Tests/test_file_avif.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/test_file_avif.py b/Tests/test_file_avif.py index a25f77177..3ad38fd7e 100644 --- a/Tests/test_file_avif.py +++ b/Tests/test_file_avif.py @@ -145,14 +145,14 @@ class TestFileAvif: # avifdec hopper.avif avif/hopper_avif_write.png assert_image_similar_tofile( - reloaded, "Tests/images/avif/hopper_avif_write.png", 6.88 + reloaded, "Tests/images/avif/hopper_avif_write.png", 6.93 ) # This test asserts that the images are similar. If the average pixel # difference between the two images is less than the epsilon value, # then we're going to accept that it's a reasonable lossy version of # the image. - assert_image_similar(reloaded, im, 9.28) + assert_image_similar(reloaded, im, 9.39) def test_AvifEncoder_with_invalid_args(self) -> None: """ From ea5901535d94897adb4821fcf7ac4152e66d8a3c Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Tue, 12 May 2026 00:31:03 +0300 Subject: [PATCH 7/8] Compare dist sizes vs latest PyPI release (#9621) Co-authored-by: Andrew Murray --- .github/compare-dist-sizes.py | 271 ++++++++++++++++++++++++++++++++++ .github/workflows/wheels.yml | 23 +++ 2 files changed, 294 insertions(+) create mode 100644 .github/compare-dist-sizes.py diff --git a/.github/compare-dist-sizes.py b/.github/compare-dist-sizes.py new file mode 100644 index 000000000..ed7b9be0e --- /dev/null +++ b/.github/compare-dist-sizes.py @@ -0,0 +1,271 @@ +"""Compare sizes of newly-built dists against the latest release on PyPI. + +Fetches file sizes for the latest Pillow release from the PyPI JSON API +(no download required) and compares them to a directory of freshly-built +wheels and sdist. Outputs a table to stdout (and to +`$GITHUB_STEP_SUMMARY` if set). + +Usage: + `uv run .github/compare-dist-sizes.py ` +""" + +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "humanize", +# "prettytable", +# "termcolor", +# ] +# /// + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import urllib.request +from pathlib import Path + +import humanize +from prettytable import PrettyTable, TableStyle +from termcolor import colored + +PYPI_JSON_URL = "https://pypi.org/pypi/pillow/json" + +# Wheel filename: {distribution}-{version}(-{build})?-{python}-{abi}-{platform}.whl +# sdist filename: {distribution}-{version}.tar.gz +WHEEL_RE = re.compile( + r"^[^-]+-[^-]+(?:-(?P\d[^-]*))?" + r"-(?P[^-]+)-(?P[^-]+)-(?P[^-]+)\.whl$", + re.IGNORECASE, +) +SDIST_RE = re.compile( + r"^(?P[^-]+)-(?P.+)\.tar\.gz$", + re.IGNORECASE, +) + + +def key_for(filename: str) -> str: + """Return a version-independent identifier for a dist file.""" + if m := WHEEL_RE.match(filename): + build = f"{m['build']}-" if m["build"] else "" + return f"wheel:{build}{m['python']}-{m['abi']}-{m['platform']}" + if SDIST_RE.match(filename): + return "sdist" + msg = f"Unexpected dist name: {filename}" + raise ValueError(msg) + + +def display_for(filename: str) -> str: + """Strip the `pillow-{version}-` prefix for compact table display.""" + if m := WHEEL_RE.match(filename): + build = f"{m['build']}-" if m["build"] else "" + return f"{build}{m['python']}-{m['abi']}-{m['platform']}.whl" + if SDIST_RE.match(filename): + return "sdist (.tar.gz)" + return filename + + +def fetch_pypi_sizes() -> tuple[str, dict[str, tuple[str, int]]]: + """Return (version, {key: (filename, size)}) for the latest PyPI release.""" + with urllib.request.urlopen(PYPI_JSON_URL) as response: + data = json.load(response) + version = data["info"]["version"] + sizes: dict[str, tuple[str, int]] = {} + for entry in data.get("urls", []): + filename = entry["filename"] + key = key_for(filename) + sizes[key] = (filename, entry["size"]) + return version, sizes + + +def collect_local_sizes(dist_dir: Path) -> dict[str, tuple[str, int]]: + sizes: dict[str, tuple[str, int]] = {} + for path in sorted(dist_dir.iterdir()): + if not path.is_file(): + continue + key = key_for(path.name) + sizes[key] = (path.name, path.stat().st_size) + return sizes + + +def human(n: int | None) -> str: + if n is None: + return "n/a" + return humanize.naturalsize(n) + + +def pct_change(before: int | None, after: int | None) -> str: + if before is None or after is None: + return "n/a" + delta = 0 if before == 0 else (after - before) / before * 100 + return f"{delta:+.2f}%" + + +def pct_severity(text: str) -> dict[str, str] | None: + """Return status indicators based on the change percent.""" + if text == "n/a": + return None + pct = float(text.rstrip("%")) + if pct >= 5: + return {"color": "red", "emoji": "🔴"} + if pct > 0: + return {"color": "yellow", "emoji": "🟡"} + else: + return {"color": "green", "emoji": "🟢"} + + +def render_table( + baseline_label: str, + baseline_sizes: dict[str, tuple[str, int]], + local_sizes: dict[str, tuple[str, int]], + *, + markdown: bool, +) -> str: + table = PrettyTable() + table.set_style(TableStyle.MARKDOWN if markdown else TableStyle.SINGLE_BORDER) + table.field_names = ["File", "Size before", "Size now", "Change"] + table.align = "r" + table.align["File"] = "l" + + def style(cells: list[str], role: str) -> list[str]: + severity = pct_severity(cells[3]) + if markdown: + if severity: + cells[3] = f"{severity['emoji']} {cells[3]}" + if role == "orphan": + return [f"*{c}*" for c in cells] + if role == "summary": + return [f"**{c}**" for c in cells] + return cells + + if role == "orphan": + return [colored(c, "dark_grey") for c in cells] + + bold_attrs = ["bold"] if role == "summary" else [] + if bold_attrs: + cells[:3] = [colored(c, attrs=bold_attrs) for c in cells[:3]] + if severity: + cells[3] = colored(cells[3], severity["color"], attrs=bold_attrs) + elif bold_attrs: + cells[3] = colored(cells[3], attrs=bold_attrs) + return cells + + keys = list(set(baseline_sizes) | set(local_sizes)) + # Put sdist first for readability + keys.sort(key=lambda k: (k != "sdist", k)) + + wheel_before = [] + wheel_after = [] + total_before = [] + total_after = [] + for key in keys: + baseline_entry = baseline_sizes.get(key) + local_entry = local_sizes.get(key) + display_name = display_for((local_entry or baseline_entry)[0]) + before = baseline_entry[1] if baseline_entry else None + after = local_entry[1] if local_entry else None + if after is None: + # Removed since baseline: ignore in totals + role = "orphan" + else: + # Present locally (in both, or newly added): count in totals + total_after.append(after) + if before is not None: + total_before.append(before) + if key != "sdist": + wheel_after.append(after) + if before is not None: + wheel_before.append(before) + role = "data" + cells = [ + display_name, + human(before), + human(after), + pct_change(before, after), + ] + table.add_row(style(cells, role)) + + if not markdown: + table.add_divider() + + if wheel_after: + avg_before = sum(wheel_before) // len(wheel_before) if wheel_before else None + table.add_row( + style( + [ + f"wheel average ({len(wheel_after)} wheels)", + human(avg_before), + human(sum(wheel_after) // len(wheel_after)), + pct_change(avg_before, sum(wheel_after) // len(wheel_after)), + ], + "summary", + ) + ) + table.add_row( + style( + [ + f"wheel total ({len(wheel_after)} wheels)", + human(sum(wheel_before)), + human(sum(wheel_after)), + pct_change(sum(wheel_before), sum(wheel_after)), + ], + "summary", + ), + divider=not markdown, + ) + + if total_after: + table.add_row( + style( + [ + f"artifacts total ({len(total_after)} artifacts)", + human(sum(total_before)), + human(sum(total_after)), + pct_change(sum(total_before), sum(total_after)), + ], + "summary", + ) + ) + + title = f"## Dist size comparison vs {baseline_label}" + if not markdown: + title = colored(title, attrs=["bold"]) + return f"{title}\n\n{table.get_string()}\n" + + +def main() -> int: + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + parser.add_argument( + "dist_dir", + type=Path, + help="Directory containing newly-built wheels and sdist", + ) + args = parser.parse_args() + + if not args.dist_dir.is_dir(): + print(f"error: {args.dist_dir} is not a directory", file=sys.stderr) + return 1 + + baseline_version, baseline_sizes = fetch_pypi_sizes() + baseline_label = f"Pillow {baseline_version} on PyPI" + + local_sizes = collect_local_sizes(args.dist_dir) + + print(render_table(baseline_label, baseline_sizes, local_sizes, markdown=False)) + + if summary_path := os.environ.get("GITHUB_STEP_SUMMARY"): + with open(summary_path, "a", encoding="utf-8") as f: + f.write( + render_table(baseline_label, baseline_sizes, local_sizes, markdown=True) + ) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index fa3271de0..e2008ac6c 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -13,6 +13,7 @@ on: paths: &paths - ".ci/requirements-cibw.txt" - ".ci/requirements-sbom.txt" + - ".github/compare-dist-sizes.py" - ".github/dependencies.json" - ".github/generate-sbom.py" - ".github/workflows/wheels*" @@ -255,6 +256,28 @@ jobs: echo $files [ "$files" -eq $EXPECTED_DISTS ] || exit 1 + compare-dist-sizes: + needs: [build-native-wheels, windows, sdist] + runs-on: ubuntu-latest + name: Compare dist sizes vs PyPI + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: false + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: dist-* + path: dist + merge-multiple: true + + - name: Compare dist sizes vs latest PyPI release + run: uv run .github/compare-dist-sizes.py dist + scientific-python-nightly-wheels-publish: if: github.event.repository.fork == false && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') needs: count-dists From 3ce681240fa6ad2f19a73b2c5531baada38dee96 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Tue, 12 May 2026 12:11:38 +1000 Subject: [PATCH 8/8] Use _accept check in WebP _open (#9605) --- Tests/test_file_webp.py | 6 ++++++ src/PIL/WebPImagePlugin.py | 9 +++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/Tests/test_file_webp.py b/Tests/test_file_webp.py index f996cce67..e419c29f0 100644 --- a/Tests/test_file_webp.py +++ b/Tests/test_file_webp.py @@ -49,6 +49,12 @@ class TestFileWebp: assert version is not None assert re.search(r"\d+\.\d+\.\d+$", version) + def test_invalid_file(self) -> None: + invalid_file = "Tests/images/flower.jpg" + + with pytest.raises(SyntaxError): + WebPImagePlugin.WebPImageFile(invalid_file) + def test_read_rgb(self) -> None: """ Can we read a RGB mode WebP file without error? diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index e20e40d91..63a481691 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -43,10 +43,15 @@ class WebPImageFile(ImageFile.ImageFile): __logical_frame = 0 def _open(self) -> None: + assert self.fp is not None + s = self.fp.read() + if not _accept(s): + msg = "not a WEBP file" + raise SyntaxError(msg) + # Use the newer AnimDecoder API to parse the (possibly) animated file, # and access muxed chunks like ICC/EXIF/XMP. - assert self.fp is not None - self._decoder = _webp.WebPAnimDecoder(self.fp.read()) + self._decoder = _webp.WebPAnimDecoder(s) # Get info from decoder self._size, self.info["loop"], bgcolor, self.n_frames, self.rawmode = (