Compare commits

..

7 Commits

Author SHA1 Message Date
Andrew Murray
b71096460f Update MANIFEST 2026-04-25 11:30:32 +10:00
Andrew Murray
933716f3d3 Added CLAUDE.md 2026-04-25 11:19:53 +10:00
Andrew Murray
6305a17401 Update alignment 2026-04-24 20:53:43 +10:00
Jeffrey 'Alex' Clark
35c3fcc7e3 Remove CI checks section from AGENTS.md
Not actionable for agents — knowing workflow names and triggers
does not help write better code.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-22 20:04:35 -04:00
Jeffrey 'Alex' Clark
d9fb08b797 Update AGENTS.md
Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com>
2026-04-22 19:53:49 -04:00
Jeffrey 'Alex' Clark
17e6f08f83 Rename copilot-instructions.md -> AGENTS.md 2026-04-22 16:28:06 -04:00
Jeffrey 'Alex' Clark
718edc5cc7 Add .github/copilot-instructions.md
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-22 14:06:09 -04:00
54 changed files with 259 additions and 1597 deletions

View File

@ -39,8 +39,8 @@ python3 -m pip install --only-binary=:all: pyarrow || true
# PyQt6 doesn't support PyPy3
if [[ $GHA_PYTHON_VERSION == 3.* ]]; then
sudo apt-get -qq install libegl1 libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-shape0 libxkbcommon-x11-0
# pyqt6 doesn't yet support free-threading; only install if a wheel is available
python3 -m pip install --only-binary=:all: pyqt6 || true
# TODO Update condition when pyqt6 supports free-threading
if ! [[ "$PYTHON_GIL" == "0" ]]; then python3 -m pip install pyqt6 ; fi
fi
# webp

View File

@ -1 +1 @@
cibuildwheel==3.4.1
cibuildwheel==3.4.0

View File

@ -1,4 +1,4 @@
mypy==1.20.2
mypy==1.19.1
arro3-compute
arro3-core
IceSpringPySideStubs-PyQt6

View File

@ -1 +0,0 @@
check-jsonschema==0.37.1

1
.claude/CLAUDE.md Normal file
View File

@ -0,0 +1 @@
@../AGENTS.md

View File

@ -1,424 +0,0 @@
# Incident Response Plan — Pillow
This document describes how the Pillow maintainers detect, triage, fix, communicate, and
learn from security incidents. It supplements the existing [Security Policy](SECURITY.md)
and [Release Checklist](../RELEASING.md).
---
## 1. Preparation
Maintaining readiness before an incident occurs reduces response time and errors under pressure.
### 1.1 Version Support Matrix
Security fixes are applied to the **latest stable release only**. Users on older versions
are expected to upgrade. Reporters should assume only the latest release will receive a patch.
| Branch | Status |
|---|---|
| `main` / latest stable | ✅ Security fixes applied |
| All older releases | ❌ No security support — please upgrade |
### 1.2 Team Readiness
The four members of the Pillow core team are in regular contact and share collective
responsibility for incident response. Any core team member may act as Incident Lead.
Contact details are known to all team members.
### 1.3 Readiness Review
At each quarterly release, maintainers should re-read this document and update any stale content.
---
## 2. Scope
This plan covers:
| Incident type | Examples |
|---|---|
| Vulnerability in Pillow's own Python or C code | Buffer overflow in an image decoder, integer overflow in `ImagingNew` |
| Vulnerability in a bundled or wheel-shipped C library | libjpeg, libwebp, libtiff, libpng, openjpeg, libavif |
| Supply-chain compromise | Malicious commit, stolen maintainer credentials, tampered PyPI wheel |
| CI/CD or infrastructure compromise | GitHub Actions secret leak, Codecov breach, PyPI token exposure |
| Critical non-security regression | Data-loss bug shipped in a release, crash on all supported platforms |
---
## 3. Definitions
| Term | Meaning |
|---|---|
| **Incident** | Any event that compromises or threatens the confidentiality, integrity, or availability of Pillow's code, release artifacts, or infrastructure. |
| **Vulnerability** | A security flaw in Pillow or a bundled library that can be exploited by a crafted image or API call. |
| **Incident Lead** | The maintainer who owns coordination of the response from triage to closure. |
| **Embargo** | A period during which fix details are kept private to allow coordinated patching before public disclosure. |
| **Yank** | A PyPI action that keeps a release downloadable by pinned users but removes it from default `pip install` resolution. |
| **CVE** | Common Vulnerabilities and Exposures — a public identifier assigned to a specific vulnerability. |
| **CNA** | CVE Numbering Authority — GitHub is a CNA and can assign CVEs directly through the advisory workflow. |
---
## 4. Roles
| Role | Responsibility |
|---|---|
| **Incident Lead** | First maintainer to triage the report. Owns the incident until resolution. |
| **Patch Owner** | Writes and tests the fix (may be the same person as Incident Lead). |
| **Release Manager** | Cuts the point release following [RELEASING.md](../RELEASING.md). |
| **Communications Owner** | Drafts the GitHub Security Advisory, announces on Mastodon, notifies distros. |
| **Tidelift Contact** | For reports that arrive via Tidelift, coordinate through the Tidelift security portal. |
One person may fill multiple roles.
---
## 5. Severity Classification
Use the [CVSS 4.0](https://www.first.org/cvss/v4.0/specification-document) base score as
a guide, mapped to the following levels:
| Severity | CVSS | Definition | Target Response SLA |
|---|---|---|---|
| **Critical** | 9.0 10.0 | Remote code execution, arbitrary write, or complete integrity/confidentiality loss achievable by opening a crafted image | Best effort; embargoed release where possible |
| **High** | 7.0 8.9 | Heap/stack buffer overflow, use-after-free, or significant information disclosure | Best effort |
| **Medium** | 4.0 6.9 | Denial of service via crafted image, out-of-bounds read, limited info disclosure | Next scheduled quarterly release, or earlier point release if needed |
| **Low** | 0.1 3.9 | Minor information disclosure, unlikely to be exploitable in practice | Next quarterly release |
Supply-chain and CI/CD incidents are always treated as **Critical** regardless of CVSS.
> **Note:** These are good-faith targets for a small volunteer maintainer team, not contractual SLAs. Public safety and transparency will always be prioritised, even when timing varies.
---
## 6. Detection Sources
Vulnerabilities and incidents may be reported or discovered through:
1. **GitHub private security advisory** — preferred channel; see [SECURITY.md](SECURITY.md)
2. **Tidelift security contact**<https://tidelift.com/docs/security>
3. **External researcher / coordinated disclosure** — e.g. Google Project Zero, vendor PSIRT
4. **Automated scanning** — Dependabot, GitHub code-scanning (CodeQL), CI fuzzing
5. **Distro security teams** — Debian, Red Hat, Ubuntu, Alpine may report upstream
6. **User bug report** — public issue (reassess if it has security implications and convert to a private advisory if needed)
---
## 7. Response Process
### 7.1 Triage (all severities)
1. **Acknowledge receipt** to the reporter within **72 hours** using the template in
[Appendix A](#appendix-a-communication-templates). Ask the reporter:
- How they would like to be credited (name, handle, or anonymous)
- Whether they intend to publish their own advisory, and if so, their preferred timeline
- Thank them explicitly — reporters do the project a favour by disclosing privately.
2. Reproduce the issue. If the report is invalid, close it and notify the reporter.
3. Assign a severity level ([Section 5: Severity Classification](#5-severity-classification)).
4. If the GitHub Security Advisory was not created by the reporter, create one now and keep
it **private** until the fix is released. Add the reporter as a collaborator if they wish
to be involved.
5. **Request a CVE** through the GitHub Security Advisory workflow (GitHub is a CVE
Numbering Authority — no separate MITRE form required). The CVE is reserved privately
and published automatically when the advisory goes public.
6. **Escalation** — Escalate beyond the core maintainer team if any of the following apply:
- The fix requires changes to CPython or a dependency outside Pillow's control → contact the relevant upstream immediately
- A legal concern arises (e.g. GDPR-reportable data exposure) → contact the project's legal/fiscal sponsor
- The Incident Lead is unreachable for > 24 hours on a Critical issue → any other maintainer may assume the role
### 7.2 Fix Development
1. Develop the fix in a **private fork** or directly in the private security advisory
workspace on GitHub. Do **not** push to a public branch before the embargo lifts.
2. Write a regression test that fails before the fix and passes after.
3. Review the patch with at least one other maintainer.
### 7.3 Standard (Non-Embargoed) Release
For Medium and Low severity, or when no distro pre-notification is needed:
1. Merge the fix to `main`, then cherry-pick to all affected release branches
(see [RELEASING.md — Point release](../RELEASING.md)).
2. Amend commit messages to include the CVE identifier.
3. Follow the [Point release](../RELEASING.md#point-release) process in RELEASING.md to
tag, push, and confirm wheels are live on PyPI.
4. Publish the GitHub Security Advisory (this simultaneously publishes the CVE).
### 7.4 Embargoed Release
For Critical and High severity where distro pre-notification improves user safety:
1. Prepare patches against all affected release branches and test locally.
2. Agree on an **embargo date** with the reporter (typically 714 days out, up to 90 days for
complex issues).
3. Privately send the patch to distros via the
[linux-distros](https://oss-security.openwall.org/wiki/mailing-lists/distros) mailing list
or directly to individual distro security teams.
4. On the embargo date:
- Amend commit messages with the CVE identifier.
- Follow the [Embargoed release](../RELEASING.md#embargoed-release) process in
RELEASING.md to tag, push, and confirm wheels are live on PyPI.
- Publish the GitHub Security Advisory.
### 7.5 Supply-Chain / Infrastructure Compromise
1. **Immediately** revoke any potentially compromised credentials:
- PyPI API tokens
- GitHub personal access tokens and OAuth apps
- Codecov or other CI service tokens
2. Audit recent commits and releases for tampering:
- Verify release tags against known-good SHAs
- Re-inspect any wheel published since the potential compromise window
3. If a PyPI release is suspected to be tampered: yank it immediately via the
[PyPI release management page](https://pypi.org/manage/project/Pillow/releases/)
(login required); see [https://pypi.org/security/](https://pypi.org/security/) for
reporting to the PyPI security team.
4. Issue a public advisory describing the scope and any user action required.
### 7.6 Recovery
After the fix is released and the advisory is public:
1. Verify that the patched wheels are live on PyPI and passing CI across all supported platforms.
2. Confirm any yanked releases are handled correctly .
3. Resume normal development operations on `main`.
4. Monitor the GitHub issue tracker and Mastodon for user reports of residual problems for at least **72 hours** post-release.
5. Close the private GitHub Security Advisory once recovery is confirmed.
---
## 8. Communication
### Internal (during embargo)
- Use the **private GitHub Security Advisory** thread for coordination with the reporter.
- Use private communication channels for all other coordination.
- Do not discuss details in public issues, PRs, or Gitter/IRC channels.
### External (at or after disclosure)
| Audience | Channel | Timing |
|---|---|---|
| General users | [GitHub Security Advisory](https://github.com/python-pillow/Pillow/security/advisories) | At release |
| PyPI ecosystem | CVE published via advisory | At release |
| Downstream distros | Direct email or linux-distros list | Before embargo date (embargoed) |
| Tidelift subscribers | Tidelift security portal | At release (or coordinated) |
| Community | [Mastodon @pillow](https://fosstodon.org/@pillow) | At release |
**Advisory content should include:**
- CVE identifier and CVSS score
- Affected Pillow versions
- Fixed version(s)
- Nature of the vulnerability (without full exploit details if still fresh)
- Credit to the reporter (with their consent)
- Upgrade instructions (`python3 -m pip install --upgrade Pillow`)
---
## 9. Dependency Map
Understanding what Pillow depends on (upstream) and what depends on Pillow (downstream)
is essential for scoping impact and coordinating notifications during an incident.
### 9.1 Upstream Dependencies
#### Bundled C libraries (shipped in official wheels)
These libraries are compiled into Pillow's binary wheels. A CVE in any of them may
require a Pillow point release even if Pillow's own code is unchanged.
| Library | Purpose | Security advisory tracker |
|---|---|---|
| [libjpeg-turbo](https://libjpeg-turbo.org/) | JPEG encode/decode | [GitHub](https://github.com/libjpeg-turbo/libjpeg-turbo/security) |
| [libpng](http://www.libpng.org/pub/png/libpng.html) | PNG encode/decode within FreeType 2, OpenJPEG and WebP | [SourceForge](https://sourceforge.net/p/libpng/bugs/) |
| [libtiff](https://libtiff.gitlab.io/libtiff/) | TIFF encode/decode | [GitLab](https://gitlab.com/libtiff/libtiff/-/work_items) |
| [libwebp](https://chromium.googlesource.com/webm/libwebp) | WebP encode/decode | [Chromium tracker](https://issues.webmproject.org/issues) |
| [libavif](https://github.com/AOMediaCodec/libavif) | AVIF encode/decode | [GitHub](https://github.com/AOMediaCodec/libavif/security) |
| [aom](https://aomedia.googlesource.com/aom/) | AV1 codec (AVIF) | [Chromium tracker](https://aomedia.issues.chromium.org/issues) |
| [dav1d](https://code.videolan.org/videolan/dav1d) | AV1 decode (AVIF) | [VideoLAN Security](https://www.videolan.org/security/) |
| [openjpeg](https://www.openjpeg.org/) | JPEG 2000 encode/decode | [GitHub](https://github.com/uclouvain/openjpeg/security) |
| [freetype2](https://freetype.org/) | Font rendering | [GitLab](https://gitlab.freedesktop.org/freetype/freetype/-/work_items) |
| [lcms2](https://www.littlecms.com/) | ICC color management | [GitHub](https://github.com/mm2/Little-CMS/security) |
| [harfbuzz](https://harfbuzz.github.io/) | Text shaping (via raqm) | [GitHub](https://github.com/harfbuzz/harfbuzz/security) |
| [raqm](https://github.com/HOST-Oman/libraqm) | Complex text layout | [GitHub](https://github.com/HOST-Oman/libraqm) |
| [fribidi](https://github.com/fribidi/fribidi) | Unicode bidi (via raqm) | [GitHub](https://github.com/fribidi/fribidi) |
| [zlib](https://zlib.net/) | Deflate compression | [zlib.net](https://zlib.net/) |
| [liblzma / xz-utils](https://tukaani.org/xz/) | XZ/LZMA compression | [GitHub](https://github.com/tukaani-project/xz/security) |
| [bzip2](https://gitlab.com/bzip2/bzip2) | BZ2 compression | [GitLab](https://gitlab.com/bzip2/bzip2/-/work_items) |
| [zstd](https://github.com/facebook/zstd) | Zstandard compression | [GitHub](https://github.com/facebook/zstd/security) |
| [brotli](https://github.com/google/brotli) | Brotli compression | [GitHub](https://github.com/google/brotli/security) |
| [libyuv](https://chromium.googlesource.com/libyuv/libyuv/) | YUV conversion | [Chromium tracker](https://libyuv.issues.chromium.org/issues) |
#### Python-level dependencies
| Package | Required? | Purpose |
|---|---|---|
| `setuptools` | Build-time only | Package build backend |
| `pybind11` | Build-time only | Compile C files in parallel |
| `olefile` | Optional (`fpx`, `mic` extras) | OLE2 container parsing (FPX, MIC formats) |
| `defusedxml` | Optional (`xmp` extra) | Safe XML parsing for XMP metadata |
See [`pyproject.toml`](../pyproject.toml) for the complete and authoritative list of
optional dependencies.
### 9.2 Responding to an Upstream Vulnerability
When a CVE is published for a bundled C library:
1. Assess whether the vulnerable code path is reachable through Pillow's API.
2. If reachable, treat as a Pillow vulnerability and follow [Section 5: Severity Classification](#5-severity-classification).
3. Update the bundled library version in the wheel build scripts and rebuild wheels.
4. Reference the upstream CVE in Pillow's release notes and GitHub Security Advisory.
5. If not reachable, document the rationale in a public issue so downstream distributors
can make informed decisions about patching their system packages.
### 9.3 Downstream Dependencies
A vulnerability in Pillow can have wide impact. Notify or consider the blast radius of
these downstream consumers when assessing severity and planning communications.
#### Linux distribution packages
| Distribution | Package name | Security contact |
|---|---|---|
| Debian / Ubuntu | `python3-pil` | [Debian Security](https://www.debian.org/security/) / [Ubuntu Security](https://ubuntu.com/security) |
| Fedora / RHEL / CentOS | `python3-pillow` | [Red Hat Security](https://access.redhat.com/security/) |
| Alpine Linux | `py3-pillow` | [Alpine security](https://security.alpinelinux.org/) |
| Arch Linux | `python-pillow` | [Arch security tracker](https://security.archlinux.org/) |
| Homebrew | `pillow` | [Homebrew maintainers](https://github.com/Homebrew/homebrew-core/security) |
| conda-forge | `pillow` | [conda-forge](https://github.com/conda-forge/pillow-feedstock) |
#### Major Python ecosystem consumers
These are high-profile projects known to depend on Pillow; a critical vulnerability may
warrant proactive notification.
| Project | Usage |
|---|---|
| [matplotlib](https://matplotlib.org/) | Image I/O for plots |
| [scikit-image](https://scikit-image.org/) | Image processing |
| [torchvision](https://github.com/pytorch/vision) (PyTorch) | Dataset loading, transforms |
| [Keras / TensorFlow](https://keras.io/) | Image preprocessing utilities |
| [Django](https://www.djangoproject.com/) | `ImageField` validation and thumbnail generation |
| [Wagtail](https://wagtail.org/) | CMS image renditions |
| [Plone](https://plone.org/) | CMS image handling |
| [Jupyter / IPython](https://jupyter.org/) | Inline image display |
| [ReportLab](https://www.reportlab.com/) | PDF image embedding |
| [Tidelift subscribers](https://tidelift.com/) | Enterprise consumers (coordinated via Tidelift) |
#### Pillow ecosystem plugins
Third-party plugins extend Pillow and are distributed separately on PyPI. Their
maintainers should be notified for Critical/High issues that affect the plugin API
or the formats they decode. See the
[full plugin list](https://pillow.readthedocs.io/en/stable/handbook/third-party-plugins.html#plugin-list).
---
## 11. Plan Maintenance
This document is a living record. It should be kept current so it is useful when an incident actually occurs. Revisit it during the [Section 1.3 readiness review](#13-readiness-review) at each quarterly release.
---
## 12. References
- [Security Policy](SECURITY.md)
- [Release Checklist](../RELEASING.md)
- [Contributing Guide](CONTRIBUTING.md)
- [Tidelift Security Contact](https://tidelift.com/docs/security)
- [GitHub: Privately reporting a security vulnerability](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability)
- [GitHub as a CVE Numbering Authority (CNA)](https://docs.github.com/en/code-security/security-advisories/working-with-repository-security-advisories/about-repository-security-advisories)
- [FIRST CVSS 4.0 Calculator](https://www.first.org/cvss/calculator/4.0)
- [linux-distros mailing list](https://oss-security.openwall.org/wiki/mailing-lists/distros)
- [OpenSSF CVD Guide](https://github.com/ossf/oss-vulnerability-guide) *(basis for this plan)*
---
## Appendix A: Communication Templates
### A.1 Reporter Acknowledgment
> Subject: Re: [Security] \<brief issue description\>
>
> Hi \<name\>,
>
> Thank you for taking the time to report this issue. We appreciate it.
>
> We have received your report and will review it as soon as possible. We will
> keep you updated on our progress.
>
> Questions:
>
> - How would you like to be credited in the advisory? (name, handle,
> organisation, or anonymous)
> - Do you plan to publish your own write-up or advisory? If so, do you have a
> disclosure date in mind?
>
> We apply coordinated disclosure principles to all vulnerability reports. If
> you have any questions or concerns at any point, please reply to this thread.
>
> Thank you again,
> The Pillow team
### A.2 Embargoed Distro Notification
> Subject: [EMBARGOED] Pillow security issue — \<CVE-XXXX-XXXXX\> — disclosure \<DATE\>
>
> This is an embargoed notification of a vulnerability in Pillow. Please keep this
> information confidential until the disclosure date listed below.
>
> **CVE:** \<CVE-XXXX-XXXXX\>
>
> **Affected versions:** \<e.g. Pillow < 11.x.x\>
>
> **Fixed version:** \<version\>
>
> **Severity:** \<Critical / High / Medium / Low\> (CVSS \<score\>: \<vector\>)
>
> **Reporter:** \<name / affiliation, or "reported privately"\>
>
> **Public disclosure date:** \<DATE TIME UTC\>
>
> **Summary:**
> \<One paragraph describing the vulnerability class and impact without a full exploit.\>
>
> **Proof of concept:**
> \<Minimal reproducer or attached patch.\>
>
> **Remediation:**
> Upgrade to Pillow \<fixed version\>. No known workaround.
>
> Please do not share this information, issue public patches, or make user communications
> before the disclosure date. We will notify this list immediately if the date changes.
>
> — The Pillow maintainers
### A.3 Public Disclosure Advisory
*(Published as a GitHub Security Advisory; the CVE and date are included automatically.)*
> **Summary:** \<One-paragraph technical summary.\>
>
> **CVE:** \<CVE-XXXX-XXXXX\>
>
> **Affected versions:** Pillow \< \<fixed version\>
>
> **Fixed version:** \<version\>
>
> **Severity:** \<rating\> (CVSS \<score\>)
>
> **Reporter:** \<credited name / "reported privately"\>
>
> **Details:**
> \<Fuller technical description. Include attack scenario where helpful.\>
>
> **Remediation:**
> ```
> python3 -m pip install --upgrade Pillow
> ```
>
> **Timeline:**
> - Reported: \<date\>
> - Fixed: \<date\>
> - Disclosed: \<date\>

View File

@ -1,271 +0,0 @@
"""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 <dist-dir>`
"""
# /// 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<build>\d[^-]*))?"
r"-(?P<python>[^-]+)-(?P<abi>[^-]+)-(?P<platform>[^-]+)\.whl$",
re.IGNORECASE,
)
SDIST_RE = re.compile(
r"^(?P<dist>[^-]+)-(?P<version>.+)\.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())

View File

@ -3,12 +3,12 @@
"bzip2": "1.0.8",
"freetype": "2.14.3",
"fribidi": "1.0.16",
"harfbuzz": "14.2.0",
"harfbuzz": "13.2.1",
"jpegturbo": "3.1.4.1",
"lcms2": "2.19",
"lcms2": "2.18",
"libavif": "1.4.1",
"libimagequant": "4.4.1",
"libpng": "1.6.58",
"libpng": "1.6.56",
"libwebp": "1.6.0",
"libxcb": "1.17.0",
"openjpeg": "2.5.4",

View File

@ -1,560 +0,0 @@
#!/usr/bin/env python3
"""Generate a CycloneDX 1.7 SBOM for Pillow's C extensions and their
vendored/optional native library dependencies.
Usage:
python3 .github/generate-sbom.py [output-file]
Output defaults to pillow-{version}.cdx.json in the current directory.
"""
from __future__ import annotations
import argparse
import base64
import datetime as dt
import difflib
import hashlib
import json
import urllib.request
import uuid
from pathlib import Path
def get_version() -> str:
version_file = Path(__file__).parent.parent / "src" / "PIL" / "_version.py"
return version_file.read_text(encoding="utf-8").split('"')[1]
def load_dep_versions() -> dict[str, str]:
deps_file = Path(__file__).parent / "dependencies.json"
return json.loads(deps_file.read_text(encoding="utf-8"))
def sha256_file(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def upstream_diff_b64(
upstream_url: str,
upstream_display: bytes,
local_path: Path,
local_display: bytes,
) -> str:
"""
Fetch an upstream file and return a base64-encoded unified diff vs the local copy.
"""
with urllib.request.urlopen(upstream_url) as resp:
upstream_text = resp.read()
local_text = local_path.read_bytes()
diff_lines = difflib.diff_bytes(
difflib.unified_diff,
upstream_text.splitlines(keepends=True),
local_text.splitlines(keepends=True),
fromfile=b"a/" + upstream_display,
tofile=b"b/" + local_display,
)
return base64.b64encode(b"".join(diff_lines)).decode()
def generate(version: str) -> dict:
serial = str(uuid.uuid4())
now = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
purl = f"pkg:pypi/pillow@{version}"
root = Path(__file__).parent.parent
thirdparty = root / "src" / "thirdparty"
versions = load_dep_versions()
metadata_component = {
"bom-ref": purl,
"type": "library",
"name": "Pillow",
"version": version,
"description": "Python Imaging Library (fork)",
"licenses": [{"license": {"id": "MIT-CMU"}}],
"purl": purl,
"externalReferences": [
{"type": "website", "url": "https://python-pillow.github.io"},
{"type": "vcs", "url": "https://github.com/python-pillow/Pillow"},
{"type": "documentation", "url": "https://pillow.readthedocs.io"},
{
"type": "security-contact",
"url": "https://github.com/python-pillow/Pillow/security/policy",
},
],
}
c_extensions = [
("PIL._avif", "AVIF image format extension"),
(
"PIL._imaging",
"Core image processing extension "
"(decode, encode, map, display, outline, path, libImaging)",
),
("PIL._imagingcms", "LittleCMS2 colour management extension"),
("PIL._imagingft", "FreeType font rendering extension"),
("PIL._imagingmath", "Image math operations extension"),
("PIL._imagingmorph", "Image morphology extension"),
("PIL._imagingtk", "Tk/Tcl display extension"),
("PIL._webp", "WebP image format extension"),
]
ext_components = [
{
"bom-ref": f"{purl}#c-ext/{name}",
"type": "library",
"name": name,
"version": version,
"description": desc,
"licenses": [{"license": {"id": "MIT-CMU"}}],
"purl": f"{purl}#c-ext/{name}",
}
for name, desc in c_extensions
]
vendored_components = [
{
"bom-ref": f"{purl}#thirdparty/fribidi-shim",
"type": "library",
"name": "fribidi-shim",
"version": "1.x",
"description": "FriBiDi runtime-loading shim "
"(vendored in src/thirdparty/fribidi-shim/); "
"loads libfribidi dynamically",
"licenses": [{"license": {"id": "LGPL-2.1-or-later"}}],
"hashes": [
{
"alg": "SHA-256",
"content": sha256_file(thirdparty / "fribidi-shim" / "fribidi.c"),
}
],
"pedigree": {
"notes": "Pillow-authored shim; not taken from an upstream project."
},
"externalReferences": [
{"type": "website", "url": "https://github.com/fribidi/fribidi"},
],
},
{
"bom-ref": "pkg:github/python/pythoncapi-compat",
"type": "library",
"name": "pythoncapi_compat",
"description": "Backport header for new CPython C-API functions "
"(vendored in src/thirdparty/pythoncapi_compat.h)",
"licenses": [{"license": {"id": "0BSD"}}],
"hashes": [
{
"alg": "SHA-256",
"content": sha256_file(thirdparty / "pythoncapi_compat.h"),
}
],
"pedigree": {
"notes": "Vendored unmodified from upstream python/pythoncapi-compat."
},
"externalReferences": [
{
"type": "vcs",
"url": "https://github.com/python/pythoncapi-compat",
},
],
},
{
"bom-ref": f"{purl}#thirdparty/raqm",
"type": "library",
"name": "raqm",
"version": "0.10.5",
"description": "Complex text layout library "
"(vendored in src/thirdparty/raqm/)",
"licenses": [{"license": {"id": "MIT"}}],
"hashes": [
{
"alg": "SHA-256",
"content": sha256_file(thirdparty / "raqm" / "raqm.c"),
}
],
"pedigree": {
"ancestors": [
{
"bom-ref": "pkg:github/HOST-Oman/libraqm@0.10.5#upstream",
"type": "library",
"name": "raqm",
"version": "0.10.5",
"purl": "pkg:github/HOST-Oman/libraqm@0.10.5",
"externalReferences": [
{
"type": "distribution",
"url": "https://github.com/HOST-Oman/libraqm/releases/tag/v0.10.5",
}
],
}
],
"patches": [
{
"type": "unofficial",
"diff": {
"text": {
# raqm-version.h.in → raqm-version.h:
# template @RAQM_VERSION_*@ placeholders replaced
# with literal 0.10.5 values; filename changed to
# drop the .in suffix; minor indentation fix.
"content": upstream_diff_b64(
"https://raw.githubusercontent.com/HOST-Oman/libraqm/v0.10.5/src/raqm-version.h.in",
b"src/raqm-version.h.in",
thirdparty / "raqm" / "raqm-version.h",
b"src/raqm-version.h",
),
"encoding": "base64",
}
},
},
{
"type": "unofficial",
"diff": {
"text": {
# raqm.c: wrap the <fribidi.h> include in an
# #ifdef HAVE_FRIBIDI_SYSTEM guard so that when
# building without a system FriBiDi Pillow's own
# fribidi-shim is used instead.
"content": upstream_diff_b64(
"https://raw.githubusercontent.com/HOST-Oman/libraqm/v0.10.5/src/raqm.c",
b"src/raqm.c",
thirdparty / "raqm" / "raqm.c",
b"src/raqm.c",
),
"encoding": "base64",
}
},
},
],
"notes": (
"Vendored from upstream HOST-Oman/libraqm v0.10.5 with two "
"Pillow-specific modifications: (1) raqm-version.h.in was "
"pre-processed into raqm-version.h with version placeholders "
"replaced by literal values; (2) raqm.c wraps the <fribidi.h> "
"include in an #ifdef HAVE_FRIBIDI_SYSTEM guard so Pillow's "
"bundled fribidi-shim is used when a system FriBiDi is absent."
),
},
"externalReferences": [
{
"type": "vcs",
"url": "https://github.com/python-pillow/Pillow/tree/main/src/thirdparty/raqm",
},
],
},
]
native_deps = [
{
"bom-ref": "pkg:generic/freetype2",
"type": "library",
"name": "FreeType",
"version": versions["freetype"],
"scope": "optional",
"description": "Font rendering (optional, used by PIL._imagingft). "
"Required for text/font support.",
"licenses": [{"license": {"id": "FTL"}}],
"externalReferences": [
{"type": "website", "url": "https://freetype.org"},
{
"type": "distribution",
"url": "https://download.savannah.gnu.org/releases/freetype/",
},
],
},
{
"bom-ref": "pkg:generic/fribidi",
"type": "library",
"name": "FriBiDi",
"version": versions["fribidi"],
"scope": "optional",
"description": "Unicode bidi algorithm library (optional, "
"loaded at runtime by fribidi-shim).",
"licenses": [{"license": {"id": "LGPL-2.1-or-later"}}],
"externalReferences": [
{"type": "website", "url": "https://github.com/fribidi/fribidi"},
{
"type": "distribution",
"url": "https://github.com/fribidi/fribidi/releases",
},
],
},
{
"bom-ref": "pkg:generic/harfbuzz",
"type": "library",
"name": "HarfBuzz",
"version": versions["harfbuzz"],
"scope": "optional",
"description": "Text shaping (optional, required by libraqm "
"for complex text layout).",
"licenses": [{"license": {"id": "MIT"}}],
"externalReferences": [
{"type": "website", "url": "https://harfbuzz.github.io"},
{
"type": "distribution",
"url": "https://github.com/harfbuzz/harfbuzz/releases",
},
],
},
{
"bom-ref": "pkg:generic/libavif",
"type": "library",
"name": "libavif",
"version": versions["libavif"],
"scope": "optional",
"description": "AVIF codec (optional, used by PIL._avif).",
"licenses": [{"license": {"id": "BSD-2-Clause"}}],
"externalReferences": [
{"type": "website", "url": "https://github.com/AOMediaCodec/libavif"},
{
"type": "distribution",
"url": "https://github.com/AOMediaCodec/libavif/releases",
},
],
},
{
"bom-ref": "pkg:generic/libimagequant",
"type": "library",
"name": "libimagequant",
"version": versions["libimagequant"],
"scope": "optional",
"description": "Improved colour quantization (optional).",
"licenses": [{"license": {"id": "GPL-3.0-or-later"}}],
"externalReferences": [
{"type": "website", "url": "https://pngquant.org/lib/"},
{
"type": "distribution",
"url": "https://github.com/ImageOptim/libimagequant/tags",
},
],
},
{
"bom-ref": "pkg:generic/libjpeg",
"type": "library",
"name": "libjpeg / libjpeg-turbo",
"version": versions["jpegturbo"],
"description": "JPEG codec (required by default; disable with "
"-C jpeg=disable).",
"licenses": [
{"license": {"id": "IJG"}},
{"license": {"id": "BSD-3-Clause"}},
],
"externalReferences": [
{"type": "website", "url": "https://ijg.org"},
{"type": "website", "url": "https://libjpeg-turbo.org"},
{
"type": "distribution",
"url": "https://github.com/libjpeg-turbo/libjpeg-turbo/releases",
},
],
},
{
"bom-ref": "pkg:generic/libtiff",
"type": "library",
"name": "libtiff",
"version": versions["tiff"],
"scope": "optional",
"description": "TIFF codec (optional).",
"licenses": [{"license": {"id": "libtiff"}}],
"externalReferences": [
{"type": "website", "url": "https://libtiff.gitlab.io/libtiff/"},
{
"type": "distribution",
"url": "https://download.osgeo.org/libtiff/",
},
],
},
{
"bom-ref": "pkg:generic/libwebp",
"type": "library",
"name": "libwebp",
"version": versions["libwebp"],
"scope": "optional",
"description": "WebP codec (optional, used by PIL._webp).",
"licenses": [{"license": {"id": "BSD-3-Clause"}}],
"externalReferences": [
{
"type": "website",
"url": "https://chromium.googlesource.com/webm/libwebp",
},
{
"type": "distribution",
"url": "https://chromium.googlesource.com/webm/libwebp",
},
],
},
{
"bom-ref": "pkg:generic/libxcb",
"type": "library",
"name": "libxcb",
"version": versions["libxcb"],
"scope": "optional",
"description": "X11 screen-grab support (optional, "
"used by PIL._imaging on macOS and Linux).",
"licenses": [{"license": {"id": "X11"}}],
"externalReferences": [
{"type": "website", "url": "https://xcb.freedesktop.org"},
{
"type": "distribution",
"url": "https://xcb.freedesktop.org/dist/",
},
],
},
{
"bom-ref": "pkg:generic/littlecms2",
"type": "library",
"name": "Little CMS 2",
"version": versions["lcms2"],
"scope": "optional",
"description": "Colour management (optional, used by PIL._imagingcms).",
"licenses": [{"license": {"id": "MIT"}}],
"externalReferences": [
{"type": "website", "url": "https://www.littlecms.com"},
{
"type": "distribution",
"url": "https://github.com/mm2/Little-CMS/releases",
},
],
},
{
"bom-ref": "pkg:generic/openjpeg",
"type": "library",
"name": "OpenJPEG",
"version": versions["openjpeg"],
"scope": "optional",
"description": "JPEG 2000 codec (optional).",
"licenses": [{"license": {"id": "BSD-2-Clause"}}],
"externalReferences": [
{"type": "website", "url": "https://www.openjpeg.org"},
{
"type": "distribution",
"url": "https://github.com/uclouvain/openjpeg/releases",
},
],
},
{
"bom-ref": "pkg:pypi/pybind11",
"type": "library",
"name": "pybind11",
"scope": "excluded",
"description": "Parallel C compilation library (build-time dependency).",
"licenses": [{"license": {"id": "BSD-3-Clause"}}],
"externalReferences": [
{"type": "website", "url": "https://pybind11.readthedocs.io"},
{
"type": "distribution",
"url": "https://github.com/pybind/pybind11/releases",
},
],
},
{
"bom-ref": "pkg:generic/zlib",
"type": "library",
"name": "zlib",
"version": versions["zlib-ng"],
"description": "Deflate/PNG compression (required by default; "
"disable with -C zlib=disable).",
"licenses": [{"license": {"id": "Zlib"}}],
"externalReferences": [
{"type": "website", "url": "https://zlib.net"},
{"type": "distribution", "url": "https://zlib.net"},
],
},
]
dependencies = [
{
"ref": purl,
"dependsOn": [e["bom-ref"] for e in ext_components],
},
{
"ref": f"{purl}#c-ext/PIL._avif",
"dependsOn": ["pkg:generic/libavif"],
},
{
"ref": f"{purl}#c-ext/PIL._imaging",
"dependsOn": [
"pkg:generic/libimagequant",
"pkg:generic/libjpeg",
"pkg:generic/libtiff",
"pkg:generic/libxcb",
"pkg:generic/openjpeg",
"pkg:generic/zlib",
],
},
{
"ref": f"{purl}#c-ext/PIL._imagingcms",
"dependsOn": ["pkg:generic/littlecms2"],
},
{
"ref": f"{purl}#c-ext/PIL._imagingft",
"dependsOn": [
"pkg:generic/freetype2",
"pkg:generic/fribidi",
"pkg:generic/harfbuzz",
f"{purl}#thirdparty/fribidi-shim",
f"{purl}#thirdparty/raqm",
],
},
{
"ref": f"{purl}#c-ext/PIL._webp",
"dependsOn": ["pkg:generic/libwebp"],
},
{
"ref": f"{purl}#thirdparty/raqm",
"dependsOn": [
"pkg:generic/harfbuzz",
f"{purl}#thirdparty/fribidi-shim",
],
},
]
return {
"$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json",
"bomFormat": "CycloneDX",
"specVersion": "1.7",
"serialNumber": f"urn:uuid:{serial}",
"version": 1,
"metadata": {
"timestamp": now,
"lifecycles": [{"phase": "build"}],
"tools": {
"components": [
{
"type": "application",
"name": "generate-sbom.py",
"group": "pillow",
}
]
},
"component": metadata_component,
},
"components": ext_components + vendored_components + native_deps,
"dependencies": dependencies,
}
def main() -> None:
version = get_version()
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter
)
parser.add_argument(
"output",
nargs="?",
type=Path,
default=Path(f"pillow-{version}.cdx.json"),
help="output file",
)
args = parser.parse_args()
sbom = generate(version)
args.output.write_text(json.dumps(sbom, indent=2) + "\n", encoding="utf-8")
print(
f"Wrote {args.output} (Pillow {version}, {len(sbom['components'])} components)"
)
if __name__ == "__main__":
main()

View File

@ -7,7 +7,6 @@
"Dependency"
],
"minimumReleaseAge": "7 days",
"prCreation": "not-pending",
"schedule": [
"* * 3 * *"
],

View File

@ -4,14 +4,19 @@ on:
push:
branches:
- "**"
paths: &paths
paths:
- ".github/dependencies.json"
- ".github/workflows/cifuzz.yml"
- ".github/workflows/wheels-dependencies.sh"
- "**.c"
- "**.h"
pull_request:
paths: *paths
paths:
- ".github/dependencies.json"
- ".github/workflows/cifuzz.yml"
- ".github/workflows/wheels-dependencies.sh"
- "**.c"
- "**.h"
workflow_dispatch:
permissions:
@ -30,14 +35,14 @@ jobs:
steps:
- name: Build Fuzzers
id: build
uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@d87225267726cf7ce1a3e17cf103c5ac943c4f05 # master
uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@e41e2f295eb18d630932fdd33d072527ba74c87b # 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@d87225267726cf7ce1a3e17cf103c5ac943c4f05 # master
uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@e41e2f295eb18d630932fdd33d072527ba74c87b # master
with:
oss-fuzz-project-name: 'pillow'
fuzz-seconds: 600

View File

@ -4,12 +4,15 @@ on:
push:
branches:
- "**"
paths: &paths
paths:
- ".github/workflows/docs.yml"
- "docs/**"
- "src/PIL/**"
pull_request:
paths: *paths
paths:
- ".github/workflows/docs.yml"
- "docs/**"
- "src/PIL/**"
workflow_dispatch:
permissions:

View File

@ -25,7 +25,7 @@ jobs:
with:
python-version: "3.x"
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Lint
run: uvx --with tox-uv tox -e lint
- name: Mypy

View File

@ -4,14 +4,19 @@ on:
push:
branches:
- "**"
paths-ignore: &paths-ignore
paths-ignore:
- ".github/workflows/docs.yml"
- ".github/workflows/wheels*"
- ".gitmodules"
- "docs/**"
- "wheels/**"
pull_request:
paths-ignore: *paths-ignore
paths-ignore:
- ".github/workflows/docs.yml"
- ".github/workflows/wheels*"
- ".gitmodules"
- "docs/**"
- "wheels/**"
workflow_dispatch:
permissions:
@ -34,8 +39,8 @@ jobs:
os: ["ubuntu-latest"]
docker: [
# Run slower jobs first to give them a headstart and reduce waiting time
ubuntu-26.04-resolute-ppc64le,
ubuntu-26.04-resolute-s390x,
ubuntu-24.04-noble-ppc64le,
ubuntu-24.04-noble-s390x,
# Then run the remainder
alpine,
amazon-2023-amd64,
@ -45,19 +50,17 @@ jobs:
debian-13-trixie-x86,
debian-13-trixie-amd64,
fedora-43-amd64,
fedora-44-amd64,
gentoo,
ubuntu-22.04-jammy-amd64,
ubuntu-24.04-noble-amd64,
ubuntu-26.04-resolute-amd64,
]
dockerTag: [main]
include:
- docker: "ubuntu-26.04-resolute-ppc64le"
- docker: "ubuntu-24.04-noble-ppc64le"
qemu-arch: "ppc64le"
- docker: "ubuntu-26.04-resolute-s390x"
- docker: "ubuntu-24.04-noble-s390x"
qemu-arch: "s390x"
- docker: "ubuntu-26.04-resolute-arm64v8"
- docker: "ubuntu-24.04-noble-arm64v8"
os: "ubuntu-24.04-arm"
dockerTag: main

View File

@ -4,14 +4,19 @@ on:
push:
branches:
- "**"
paths-ignore: &paths-ignore
paths-ignore:
- ".github/workflows/docs.yml"
- ".github/workflows/wheels*"
- ".gitmodules"
- "docs/**"
- "wheels/**"
pull_request:
paths-ignore: *paths-ignore
paths-ignore:
- ".github/workflows/docs.yml"
- ".github/workflows/wheels*"
- ".gitmodules"
- "docs/**"
- "wheels/**"
workflow_dispatch:
permissions:

View File

@ -8,13 +8,12 @@ on:
# branches:
# - "**"
# paths:
# - ".github/workflows/test-valgrind-memory.yml"
# - ".github/workflows/test-valgrind.yml"
# - "**.c"
# - "**.h"
# - "depends/docker-test-valgrind-memory.sh"
pull_request:
paths:
- ".github/workflows/test-valgrind-memory.yml"
- ".github/workflows/test-valgrind.yml"
- "**.c"
- "**.h"
- "depends/docker-test-valgrind-memory.sh"

View File

@ -6,12 +6,15 @@ on:
push:
branches:
- "**"
paths: &paths
paths:
- ".github/workflows/test-valgrind.yml"
- "**.c"
- "**.h"
pull_request:
paths: *paths
paths:
- ".github/workflows/test-valgrind.yml"
- "**.c"
- "**.h"
workflow_dispatch:
permissions:

View File

@ -4,14 +4,19 @@ on:
push:
branches:
- "**"
paths-ignore: &paths-ignore
paths-ignore:
- ".github/workflows/docs.yml"
- ".github/workflows/wheels*"
- ".gitmodules"
- "docs/**"
- "wheels/**"
pull_request:
paths-ignore: *paths-ignore
paths-ignore:
- ".github/workflows/docs.yml"
- ".github/workflows/wheels*"
- ".gitmodules"
- "docs/**"
- "wheels/**"
workflow_dispatch:
permissions:

View File

@ -4,14 +4,19 @@ on:
push:
branches:
- "**"
paths-ignore: &paths-ignore
paths-ignore:
- ".github/workflows/docs.yml"
- ".github/workflows/wheels*"
- ".gitmodules"
- "docs/**"
- "wheels/**"
pull_request:
paths-ignore: *paths-ignore
paths-ignore:
- ".github/workflows/docs.yml"
- ".github/workflows/wheels*"
- ".gitmodules"
- "docs/**"
- "wheels/**"
workflow_dispatch:
permissions:
@ -42,6 +47,7 @@ jobs:
"3.15",
"3.14t",
"3.14",
"3.13t",
"3.13",
"3.12",
"3.11",
@ -50,6 +56,10 @@ jobs:
include:
- { python-version: "3.12", PYTHONOPTIMIZE: 1, REVERSE: "--reverse" }
- { python-version: "3.11", PYTHONOPTIMIZE: 2 }
# Free-threaded
- { python-version: "3.15t", disable-gil: true }
- { python-version: "3.14t", disable-gil: true }
- { python-version: "3.13t", disable-gil: true }
# Intel
- { os: "macos-26-intel", python-version: "3.10" }
exclude:
@ -73,6 +83,11 @@ jobs:
".ci/*.sh"
"pyproject.toml"
- name: Set PYTHON_GIL
if: "${{ matrix.disable-gil }}"
run: |
echo "PYTHON_GIL=0" >> $GITHUB_ENV
- name: Build system information
run: python3 .github/workflows/system-info.py

View File

@ -10,12 +10,9 @@ on:
# │ │ │ │ │
- cron: "42 1 * * 0,3"
push:
paths: &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*"
- "pyproject.toml"
- "setup.py"
@ -25,7 +22,15 @@ on:
tags:
- "*"
pull_request:
paths: *paths
paths:
- ".ci/requirements-cibw.txt"
- ".github/dependencies.json"
- ".github/workflows/wheels*"
- "pyproject.toml"
- "setup.py"
- "wheels/*"
- "winbuild/build_prepare.py"
- "winbuild/fribidi.cmake"
workflow_dispatch:
permissions:
@ -101,7 +106,7 @@ jobs:
cibw_arch: arm64_iphonesimulator
- name: "iOS x86_64 simulator"
platform: ios
os: macos-26-intel
os: macos-15-intel
cibw_arch: x86_64_iphonesimulator
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@ -256,28 +261,6 @@ 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
@ -293,59 +276,11 @@ jobs:
path: dist
merge-multiple: true
- name: Upload wheels to scientific-python-nightly-wheels
uses: scientific-python/upload-nightly-action@e76cfec8a4611fd02808a801b0ff5a7d7c1b2d99 # 0.6.4
uses: scientific-python/upload-nightly-action@5748273c71e2d8d3a61f3a11a16421c8954f9ecf # 0.6.3
with:
artifacts_path: dist
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:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.x"
- name: Generate CycloneDX SBOM
run: python3 .github/generate-sbom.py
- name: Upload SBOM as workflow artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sbom
path: "pillow-*.cdx.json"
- name: Validate SBOM
run: |
python3 -m pip install -r .ci/requirements-sbom.txt
check-jsonschema --schemafile "https://raw.githubusercontent.com/CycloneDX/specification/1.7/schema/bom-1.7.schema.json" pillow-*.cdx.json
sbom-publish:
if: |
github.event.repository.fork == false
&& github.event_name == 'push'
&& startsWith(github.ref, 'refs/tags')
needs: [count-dists, sbom]
runs-on: ubuntu-latest
name: Publish SBOM to GitHub release
permissions:
contents: write
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: sbom
path: .
- name: Attach SBOM to GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "$GITHUB_REF_NAME" pillow-*.cdx.json
pypi-publish:
if: github.event.repository.fork == false && github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
needs: count-dists

3
.gitignore vendored
View File

@ -97,6 +97,3 @@ pillow-test-images.zip
# pyinstaller
*.spec
# Generated SBOM
pillow-*.cdx.json

View File

@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.12
rev: v0.15.9
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.4
rev: v22.1.2
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.2
rev: 0.37.1
hooks:
- id: check-github-workflows
- id: check-readthedocs
- id: check-renovate
- repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: v1.24.1
rev: v1.23.1
hooks:
- id: zizmor
@ -71,7 +71,7 @@ repos:
- id: sphinx-lint
- repo: https://github.com/tox-dev/pyproject-fmt
rev: v2.21.1
rev: v2.21.0
hooks:
- id: pyproject-fmt

95
AGENTS.md Normal file
View File

@ -0,0 +1,95 @@
# Agent Instructions for Pillow
## What this project is
Pillow is the Python Imaging Library fork — a Python 3.10+ library for opening,
manipulating, and saving many image file formats. It is a mix of Python and C:
pure Python format plugins live in `src/PIL/`, and eight C extension modules
(`_imaging`, `_imagingcms`, `_imagingft`, `_imagingmath`, `_imagingmorph`,
`_imagingtk`, `_avif`, `_webp`) are compiled at install time via `setup.py`.
## Project layout
```
src/PIL/ Python source and C extension stubs (.pyi)
src/thirdparty/ Vendored C libraries (raqm, fribidi-shim, pythoncapi_compat)
Tests/ pytest test suite; Tests/helper.py has shared utilities
docs/ Sphinx documentation (RST)
docs/releasenotes/ Per-release changelog entries
setup.py C extension build configuration
_custom_build/ Custom setuptools build backend
src/PIL/_version.py Version number (PEP 440)
pyproject.toml Project metadata and optional dependency groups
.pre-commit-config.yaml All linting and formatting hooks
```
## Install
```bash
python3 -m pip install -e ".[tests]"
```
The C extensions are compiled during install. Native libraries (libjpeg,
libpng, libtiff, libwebp, freetype2, littlecms2, etc.) must already be present
on the system.
## Test
```bash
python3 selftest.py # quick sanity check
python3 -m pytest Tests/ # full test suite
python3 -m pytest Tests/ -n auto # parallel (requires pytest-xdist)
```
Always run `python3 selftest.py` first. New features and bug fixes must include
tests. Test files follow the naming pattern `Tests/test_*.py`.
## Lint and format
```bash
make lint # runs tox -e lint → pre-commit (black, ruff, clang-format,
# bandit, sphinx-lint, pyproject-fmt, and more)
make lint-fix # auto-fixes black and ruff violations
```
Or run pre-commit directly:
```bash
pre-commit run --all-files
```
All hooks must pass before a PR can be merged. Separate reformatting commits
from functional commits.
## Type checking
```bash
python3 -m tox -e mypy
```
## Documentation
Docs use Sphinx/RST. Build locally with `make doc`. Release notes go in
`docs/releasenotes/<version>.rst` using the existing section structure
(Security, Backwards incompatible changes, Deprecations, API changes, API
additions, Other changes). Use the `:cve:` RST role for CVE references.
Include a release note entry for any user-visible change.
## Pillow-specific guidance
Use `python3` and `python3 -m pip install` — never `python` or bare `pip`.
`Image.open()` takes a `formats=` argument (list/tuple of format IDs to
allowlist). There is no `format=` parameter.
`Image.OPEN` is an internal format registry. Do not recommend that users
mutate it as a security or configuration mechanism.
`pybind11` is a build-time-only dependency used for parallel C compilation,
not for C++/Python bindings.
Use fully qualified exception names: `Image.DecompressionBombError` and
`Image.DecompressionBombWarning`, not the bare class names.
Do not embed specific numeric values for thresholds like `MAX_IMAGE_PIXELS`
they may be changed by the user at runtime. Reference the named constant instead.

View File

@ -12,6 +12,7 @@ include .flake8
include LICENSE
include Makefile
include tox.ini
graft .claude
graft Tests
graft Tests/images
graft checks

View File

@ -9,10 +9,8 @@
Pillow is the friendly PIL fork by [Jeffrey 'Alex' Clark and
contributors](https://github.com/python-pillow/Pillow/graphs/contributors).
PIL is the Python Imaging Library by Fredrik Lundh and contributors.
Development is supported by:
- [Tidelift](https://tidelift.com/subscription/pkg/pypi-pillow?utm_source=pypi-pillow&utm_medium=readme&utm_campaign=enterprise) (since 2018)
- [Thanks.dev](https://thanks.dev) (since 2023)
- [GitHub Sponsors](https://github.com/sponsors/python-pillow) (since 2026)
As of 2019, Pillow development is
[supported by Tidelift](https://tidelift.com/subscription/pkg/pypi-pillow?utm_source=pypi-pillow&utm_medium=readme&utm_campaign=enterprise).
<table>
<tr>

View File

@ -19,7 +19,6 @@ Released as needed for security, installation or critical bug fixes.
git checkout -t remotes/origin/5.2.x
```
* [ ] Cherry pick individual commits from `main` branch to release branch e.g. `5.2.x`, then `git push`.
* [ ] If this is a security fix: amend commits to include the CVE identifier in the commit message.
* [ ] Check [GitHub Actions](https://github.com/python-pillow/Pillow/actions) to confirm passing tests in release branch e.g. `5.2.x`.
* [ ] In compliance with [PEP 440](https://peps.python.org/pep-0440/), update version identifier in `src/PIL/_version.py`
* [ ] Run pre-release check via `make release-test`.
@ -39,7 +38,6 @@ Released as needed for security, installation or critical bug fixes.
```bash
git push
```
* [ ] If this is a security fix: publish the [GitHub Security Advisory or Advisories](https://github.com/python-pillow/Pillow/security/advisories).
## Embargoed release

View File

@ -1,17 +1,9 @@
from __future__ import annotations
import io
import sys
import sysconfig
import pytest
FREE_THREADED_BUILD = bool(sysconfig.get_config_var("Py_GIL_DISABLED"))
gil_enabled_at_start = True
if FREE_THREADED_BUILD:
gil_enabled_at_start = sys._is_gil_enabled() # type: ignore[attr-defined]
def pytest_report_header(config: pytest.Config) -> str:
try:
@ -24,25 +16,6 @@ def pytest_report_header(config: pytest.Config) -> str:
return f"pytest_report_header failed: {e}"
def pytest_terminal_summary(terminalreporter: pytest.TerminalReporter) -> None:
if (
FREE_THREADED_BUILD
and not gil_enabled_at_start
and sys._is_gil_enabled() # type: ignore[attr-defined]
):
tr = terminalreporter
tr.ensure_newline()
tr.section("GIL re-enabled", red=True, bold=True)
tr.line("The GIL was re-enabled at runtime during the tests.")
tr.line("This can happen with no test failures if the RuntimeWarning")
tr.line("raised by Python when this happens is filtered by a test.")
tr.line("")
tr.line("Please ensure all new C modules declare support for running")
tr.line("without the GIL. Any new tests that intentionally imports")
tr.line("code that re-enables the GIL should do so in a subprocess.")
pytest.exit("GIL re-enabled during tests", returncode=1)
def pytest_configure(config: pytest.Config) -> None:
config.addinivalue_line(
"markers",

View File

@ -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.93
reloaded, "Tests/images/avif/hopper_avif_write.png", 6.88
)
# 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.39)
assert_image_similar(reloaded, im, 9.28)
def test_AvifEncoder_with_invalid_args(self) -> None:
"""

View File

@ -1,7 +1,6 @@
from __future__ import annotations
import io
import subprocess
from pathlib import Path
import pytest
@ -282,11 +281,6 @@ def test_bytesio_object() -> None:
),
)
def test_1(filename: str) -> None:
gs_binary = EpsImagePlugin.gs_binary
assert isinstance(gs_binary, str)
if subprocess.check_output([gs_binary, "--version"]) == b"10.06.0\n":
pytest.skip("Fails with Ghostscript 10.06.0")
with Image.open(filename) as im:
assert_image_equal_tofile(im, "Tests/images/eps/1.bmp")

View File

@ -502,9 +502,8 @@ class TestFilePng:
im = roundtrip(im)
assert im.info["transparency"] == (248, 248, 248)
for transparency in ((0, 1, 2), [0, 1, 2]):
im = roundtrip(im, transparency=transparency)
assert im.info["transparency"] == (0, 1, 2)
im = roundtrip(im, transparency=(0, 1, 2))
assert im.info["transparency"] == (0, 1, 2)
def test_trns_p(self, tmp_path: Path) -> None:
# Check writing a transparency of 0, issue #528
@ -519,36 +518,6 @@ class TestFilePng:
assert_image_equal(im2.convert("RGBA"), im.convert("RGBA"))
def test_trns_invalid(self, tmp_path: Path) -> None:
out = tmp_path / "temp.png"
for mode in ("1", "L", "I;16"):
im = Image.new(mode, (1, 1))
with pytest.raises(
ValueError, match=f"transparency for {mode} must be an integer"
):
im.save(out, transparency="invalid")
im = Image.new("I", (1, 1))
with pytest.warns(DeprecationWarning, match="Saving I mode images as PNG"):
with pytest.raises(ValueError):
im.save(out, transparency="invalid")
im = Image.new("P", (1, 1))
with pytest.raises(
ValueError, match="transparency for P must be an integer or bytes"
):
im.save(out, transparency="invalid")
im = Image.new("RGB", (1, 1))
with pytest.raises(
ValueError, match="transparency for RGB must be list or tuple"
):
im.save(out, transparency="invalid")
with pytest.raises(ValueError, match="transparency for RGB must have length 3"):
im.save(out, transparency=(1, 2))
def test_trns_null(self) -> None:
# Check reading images with null tRNS value, issue #1239
test_file = "Tests/images/tRNS_null_1x1.png"

View File

@ -49,12 +49,6 @@ 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?

View File

@ -862,7 +862,7 @@ class TestImage:
def test_exif_webp(self, tmp_path: Path) -> None:
with Image.open("Tests/images/hopper.webp") as im:
exif = im.getexif()
assert dict(exif) == {}
assert exif == {}
out = tmp_path / "temp.webp"
exif[258] = 8
@ -884,7 +884,7 @@ class TestImage:
def test_exif_png(self, tmp_path: Path) -> None:
with Image.open("Tests/images/exif.png") as im:
exif = im.getexif()
assert dict(exif) == {274: 1}
assert exif == {274: 1}
out = tmp_path / "temp.png"
exif[258] = 8

View File

@ -627,37 +627,3 @@ class TestCoreResampleBox:
0.4,
f">>> {size} {box} {flt}",
)
class TestCoreResample16bpc:
# Lanczos weighting during downsampling can push accumulated float sums
@pytest.mark.parametrize(
"offset",
(
# below 0. These must be clamped to 0, not corrupted byte-by-byte.
0, # Left half = 65535, right half = 0
# above 65535. These must be clamped to 65535, not corrupted byte-by-byte.
50, # # Left half = 0, right half = 65535
),
)
def test_resampling_clamp_overflow(self, offset: int) -> None:
ims = {}
width, height = 100, 10
for mode in ("I;16", "F"):
im = Image.new(mode, (width, height))
im.paste(65535, (offset, 0, offset + width // 2, height))
# 5x downsampling with Lanczos
# creates ~8.7% overshoot or undershoot at the step edge
ims[mode] = im.resize((20, height), Image.Resampling.LANCZOS)
for y in range(height):
for x in range(20):
v = ims["F"].getpixel((x, y))
assert isinstance(v, float)
expected = max(0, min(65535, round(v)))
value = ims["I;16"].getpixel((x, y))
assert (
value == expected
), f"Pixel ({x}, {y}): expected {expected}, got {value}"

View File

@ -365,7 +365,7 @@ def test_rotated_transposed_font(
bbox_b[2] - bbox_b[0],
)
# Check top left coordinates are correct
# Check top left co-ordinates are correct
assert bbox_b[:2] == (20, 20)
# text length is undefined for vertical text
@ -410,7 +410,7 @@ def test_unrotated_transposed_font(
bbox_b[3] - bbox_b[1],
)
# Check top left coordinates are correct
# Check top left co-ordinates are correct
assert bbox_b[:2] == (20, 20)
assert length_a == length_b

View File

@ -256,13 +256,6 @@ def test_expand_palette(border: int | tuple[int, int, int, int]) -> None:
assert_image_equal(im_cropped, im)
@pytest.mark.parametrize("border", ((1,), (1, 2, 3), (1, 2, 3, 4, 5)))
def test_expand_invalid_border(border: tuple[int, ...]) -> None:
im = Image.new("1", (1, 1))
with pytest.raises(ValueError):
ImageOps.expand(im, border)
def test_colorize_2color() -> None:
# Test the colorizing function with 2-color functionality

View File

@ -51,7 +51,7 @@ Many of Pillow's features require external libraries:
* **littlecms** provides color management
* Pillow version 2.2.1 and below uses liblcms1, Pillow 2.3.0 and
above uses liblcms2. Tested with **1.19** and **2.7-2.19**.
above uses liblcms2. Tested with **1.19** and **2.7-2.18**.
* **libwebp** provides the WebP format.
@ -116,7 +116,7 @@ Many of Pillow's features require external libraries:
.. Note:: ``redhat-rpm-config`` is required on Fedora 23, but not earlier versions.
Prerequisites for **Ubuntu 16.04 LTS - 26.04 LTS** are installed with::
Prerequisites for **Ubuntu 16.04 LTS - 24.04 LTS** are installed with::
sudo apt-get install libtiff5-dev libjpeg8-dev libopenjp2-7-dev zlib1g-dev \
libfreetype6-dev liblcms2-dev libwebp-dev tcl8.6-dev tk8.6-dev python3-tk \

View File

@ -31,8 +31,6 @@ These platforms are built and tested for every change.
+----------------------------------+----------------------------+---------------------+
| Fedora 43 | 3.14 | x86-64 |
+----------------------------------+----------------------------+---------------------+
| Fedora 44 | 3.14 | x86-64 |
+----------------------------------+----------------------------+---------------------+
| Gentoo | 3.13 | x86-64 |
+----------------------------------+----------------------------+---------------------+
| macOS 15 Sequoia | 3.11, 3.12, 3.13, 3.14, | arm64 |
@ -44,9 +42,9 @@ These platforms are built and tested for every change.
+----------------------------------+----------------------------+---------------------+
| Ubuntu Linux 24.04 LTS (Noble) | 3.10, 3.11, 3.12, 3.13, | x86-64 |
| | 3.14, 3.15, PyPy3 | |
+----------------------------------+----------------------------+---------------------+
| Ubuntu Linux 26.04 LTS (Resolute)| 3.14 | x86-64, arm64v8, |
| | | ppc64le, s390x |
| +----------------------------+---------------------+
| | 3.12 | arm64v8, ppc64le, |
| | | s390x |
+----------------------------------+----------------------------+---------------------+
| Windows Server 2022 | 3.10 | x86 |
+----------------------------------+----------------------------+---------------------+

View File

@ -27,10 +27,6 @@ The ImageColor module supports the following string formats:
as three percentages (0% to 100%). For example, ``rgb(255,0,0)`` and
``rgb(100%,0%,0%)`` both specify pure red.
* RGBA functions, given as ``rgba(red, green, blue, alpha)`` where the color
values and the alpha value are integers in the range 0 to 255. For example,
``rgba(255,0,0,128)`` specifies pure red with 50% opacity.
* Hue-Saturation-Lightness (HSL) functions, given as ``hsl(hue, saturation%,
lightness%)`` where hue is the color given as an angle between 0 and 360
(red=0, green=120, blue=240), saturation is a value between 0% and 100%

View File

@ -13,28 +13,28 @@ introduced in Pillow 10.3.0.
The data being read is now limited to only the necessary amount.
:cve:`2026-42311`: Fix OOB write with invalid tile extents
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Fix OOB write with invalid tile extents
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Pillow 12.1.1 addressed :cve:`2026-25990` by improving checks for tile extents to
prevent an OOB write from specially crafted PSD images in Pillow >= 10.3.0. However,
these checks did not consider integer overflow. This has been corrected.
:cve:`2026-42310`: Prevent PDF parsing trailer infinite loop
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Prevent PDF parsing trailer infinite loop
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
When parsing a PDF, if a trailer refers to itself, or a more complex cyclic loop
exists, then an infinite loop occurs. Pillow now keeps a record of which trailers it
has already processed. PdfParser was added in Pillow 4.2.0.
:cve:`2026-42308`: Integer overflow when processing fonts
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Integer overflow when processing fonts
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
If a font advances for each glyph by an exceedingly large amount, when Pillow keeps
track of the current position, it may lead to an integer overflow. This has been fixed.
If a font advances for each glyph by an exceeding large amount, when Pillow keeps track
of the current position, it may lead to an integer overflow. This has been fixed.
:cve:`2026-42309`: Heap buffer overflow with nested list coordinates
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Heap buffer overflow with nested list coordinates
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Passing nested lists as coordinates to APIs that accept coordinates such as
``ImagePath.Path``, :py:meth:`~PIL.ImageDraw.ImageDraw.polygon`

View File

@ -81,7 +81,7 @@ Image.alpha_composite: dest
^^^^^^^^^^^^^^^^^^^^^^^^^^^
When calling :py:meth:`~PIL.Image.Image.alpha_composite`, the ``dest`` argument now
accepts negative coordinates, like the upper left corner of the ``box`` argument of
accepts negative co-ordinates, like the upper left corner of the ``box`` argument of
:py:meth:`~PIL.Image.Image.paste` can be negative. Naturally, this has effect of
cropping the overlaid image.

View File

@ -57,7 +57,7 @@ WEBP_ROOT = None
ZLIB_ROOT = None
FUZZING_BUILD = "LIB_FUZZING_ENGINE" in os.environ
if sys.platform == "win32" and sys.version_info >= (3, 16):
if sys.platform == "win32" and sys.version_info >= (3, 15):
import atexit
atexit.register(

View File

@ -2639,8 +2639,11 @@ class Image:
if is_path(fp):
filename = os.fspath(fp)
open_fp = True
elif fp == sys.stdout and isinstance(sys.stdout, io.TextIOWrapper):
fp = sys.stdout.buffer
elif fp == sys.stdout:
try:
fp = sys.stdout.buffer
except AttributeError:
pass
if not filename and hasattr(fp, "name") and is_path(fp.name):
# only set the name for metadata purposes
filename = os.fspath(fp.name)

View File

@ -175,7 +175,7 @@ class ImageDraw:
) -> None:
"""Draw an arc."""
ink, fill = self._getink(fill)
if ink is not None and width != 0:
if ink is not None:
self.draw.draw_arc(xy, start, end, ink, width)
def bitmap(
@ -235,12 +235,12 @@ class ImageDraw:
self,
xy: Coords,
fill: _Ink | None = None,
width: int = 1,
width: int = 0,
joint: str | None = None,
) -> None:
"""Draw a line, or a connected sequence of line segments."""
ink = self._getink(fill)[0]
if ink is not None and width != 0:
if ink is not None:
self.draw.draw_lines(xy, ink, width)
if joint == "curve" and width > 4:
points: Sequence[Sequence[float]]

View File

@ -36,9 +36,6 @@ def _border(border: int | tuple[int, ...]) -> tuple[int, int, int, int]:
left, top = right, bottom = border
elif len(border) == 4:
left, top, right, bottom = border
else:
msg = "border must be an integer, or a tuple of two or four elements"
raise ValueError(msg)
else:
left = top = right = bottom = border
return left, top, right, bottom

View File

@ -148,14 +148,10 @@ def _write_image(
strip_size=math.ceil(width / 8) * height,
)
elif decode_filter == "DCTDecode":
from . import JpegImagePlugin
JpegImagePlugin._save(im, op, filename)
Image.SAVE["JPEG"](im, op, filename)
elif decode_filter == "JPXDecode":
from . import Jpeg2KImagePlugin
del dict_obj["BitsPerComponent"]
Jpeg2KImagePlugin._save(im, op, filename)
Image.SAVE["JPEG2000"](im, op, filename)
else:
msg = f"unsupported PDF filter ({decode_filter})"
raise ValueError(msg)

View File

@ -383,7 +383,7 @@ class PdfParser:
msg = "specify buf or f or filename, but not both buf and f"
raise RuntimeError(msg)
self.filename = filename
self.buf: bytes | bytearray | memoryview | mmap.mmap | None = buf
self.buf: bytes | bytearray | mmap.mmap | None = buf
self.f = f
self.start_offset = start_offset
self.should_close_buf = False
@ -435,9 +435,7 @@ class PdfParser:
self.seek_end()
def close_buf(self) -> None:
if isinstance(self.buf, memoryview):
self.buf.release()
elif isinstance(self.buf, mmap.mmap):
if isinstance(self.buf, mmap.mmap):
self.buf.close()
self.buf = None
@ -692,7 +690,7 @@ class PdfParser:
self.read_prev_trailer(self.trailer_dict[b"Prev"])
def read_prev_trailer(
self, xref_section_offset: int, processed_offsets: list[int] | None = None
self, xref_section_offset: int, processed_offsets: list[int] = []
) -> None:
assert self.buf is not None
trailer_offset = self.read_xref_table(xref_section_offset=xref_section_offset)
@ -708,8 +706,6 @@ class PdfParser:
)
trailer_dict = self.interpret_trailer(trailer_data)
if b"Prev" in trailer_dict:
if processed_offsets is None:
processed_offsets = []
processed_offsets.append(xref_section_offset)
check_format_condition(
trailer_dict[b"Prev"] not in processed_offsets, "trailer loop found"

View File

@ -1443,47 +1443,35 @@ def _save(
palette_bytes += b"\0"
chunk(fp, b"PLTE", palette_bytes)
transparency = im.encoderinfo.get("transparency", im.info.get("transparency"))
transparency = im.encoderinfo.get("transparency", im.info.get("transparency", None))
if transparency is not None:
if transparency or transparency == 0:
if im.mode == "P":
# limit to actual palette size
alpha_bytes = colors
if isinstance(transparency, bytes):
chunk(fp, b"tRNS", transparency[:alpha_bytes])
elif isinstance(transparency, int):
else:
transparency = max(0, min(255, transparency))
alpha = b"\xff" * transparency + b"\0"
chunk(fp, b"tRNS", alpha[:alpha_bytes])
else:
msg = "transparency for P must be an integer or bytes"
raise ValueError(msg)
elif im.mode in ("1", "L", "I", "I;16"):
if isinstance(transparency, int):
transparency = max(0, min(65535, transparency))
chunk(fp, b"tRNS", o16(transparency))
else:
msg = f"transparency for {im.mode} must be an integer"
raise ValueError(msg)
transparency = max(0, min(65535, transparency))
chunk(fp, b"tRNS", o16(transparency))
elif im.mode == "RGB":
if not isinstance(transparency, (list, tuple)):
msg = "transparency for RGB must be list or tuple"
raise ValueError(msg)
elif len(transparency) != 3:
msg = "transparency for RGB must have length 3"
raise ValueError(msg)
else:
red, green, blue = transparency
chunk(fp, b"tRNS", o16(red) + o16(green) + o16(blue))
elif im.encoderinfo.get("transparency") is not None:
# don't bother with transparency if it's an RGBA
# and it's in the info dict. It's probably just stale.
msg = "cannot use transparency for this mode"
raise OSError(msg)
elif im.mode == "P" and im.im.getpalettemode() == "RGBA":
alpha = im.im.getpalette("RGBA", "A")
alpha_bytes = colors
chunk(fp, b"tRNS", alpha[:alpha_bytes])
red, green, blue = transparency
chunk(fp, b"tRNS", o16(red) + o16(green) + o16(blue))
else:
if "transparency" in im.encoderinfo:
# don't bother with transparency if it's an RGBA
# and it's in the info dict. It's probably just stale.
msg = "cannot use transparency for this mode"
raise OSError(msg)
else:
if im.mode == "P" and im.im.getpalettemode() == "RGBA":
alpha = im.im.getpalette("RGBA", "A")
alpha_bytes = colors
chunk(fp, b"tRNS", alpha[:alpha_bytes])
if dpi := im.encoderinfo.get("dpi"):
chunk(

View File

@ -43,15 +43,10 @@ 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.
self._decoder = _webp.WebPAnimDecoder(s)
assert self.fp is not None
self._decoder = _webp.WebPAnimDecoder(self.fp.read())
# Get info from decoder
self._size, self.info["loop"], bgcolor, self.n_frames, self.rawmode = (

View File

@ -3172,8 +3172,8 @@ _draw_lines(ImagingDrawObject *self, PyObject *args) {
PyObject *data;
int ink;
int width;
if (!PyArg_ParseTuple(args, "Oii", &data, &ink, &width)) {
int width = 0;
if (!PyArg_ParseTuple(args, "Oi|i", &data, &ink, &width)) {
return NULL;
}
@ -3182,7 +3182,7 @@ _draw_lines(ImagingDrawObject *self, PyObject *args) {
return NULL;
}
if (width == 1) {
if (width <= 1) {
double *p = NULL;
for (i = 0; i < n - 1; i++) {
p = &xy[i + i];

View File

@ -33,10 +33,8 @@ _tkinit(PyObject *self, PyObject *args) {
}
interp = (Tcl_Interp *)PyLong_AsVoidPtr(arg);
if (interp == NULL && PyErr_Occurred()) {
return NULL;
}
/* This will bomb if interp is invalid... */
TkImaging_Init(interp);
Py_RETURN_NONE;

View File

@ -37,6 +37,8 @@
#define MAX(a, b) (a) > (b) ? (a) : (b)
#define MIN(a, b) (a) < (b) ? (a) : (b)
#define CLIP16(v) ((v) <= 0 ? 0 : (v) >= 65535 ? 65535 : (v))
/* ITU-R Recommendation 601-2 (assuming nonlinear RGB) */
#define L(rgb) ((INT32)(rgb)[0] * 299 + (INT32)(rgb)[1] * 587 + (INT32)(rgb)[2] * 114)
#define L24(rgb) ((rgb)[0] * 19595 + (rgb)[1] * 38470 + (rgb)[2] * 7471 + 0x8000)

View File

@ -27,8 +27,6 @@
#define CLIP8(v) ((v) <= 0 ? 0 : (v) < 256 ? (v) : 255)
#define CLIP16(v) ((v) <= 0 ? 0 : (v) < 65536 ? (v) : 65535)
/* This is to work around a bug in GCC prior 4.9 in 64 bit mode.
GCC generates code with partial dependency which is 3 times slower.
See: https://stackoverflow.com/a/26588074/253146 */

View File

@ -812,7 +812,7 @@ j2k_decode_entry(Imaging im, ImagingCodecState state) {
break;
}
/* Adjust the tile coordinates based on the reduction (OpenJPEG
/* Adjust the tile co-ordinates based on the reduction (OpenJPEG
doesn't do this for us) */
tile_info.x0 = (tile_info.x0 + correction) >> context->reduce;
tile_info.y0 = (tile_info.y0 + correction) >> context->reduce;

View File

@ -492,9 +492,9 @@ ImagingResampleHorizontal_16bpc(
<< 8)) *
k[x];
}
ss_int = CLIP16(ROUND_UP(ss));
imOut->image8[yy][xx * 2 + (bigendian ? 1 : 0)] = ss_int & 0xFF;
imOut->image8[yy][xx * 2 + (bigendian ? 0 : 1)] = ss_int >> 8;
ss_int = ROUND_UP(ss);
imOut->image8[yy][xx * 2 + (bigendian ? 1 : 0)] = CLIP8(ss_int % 256);
imOut->image8[yy][xx * 2 + (bigendian ? 0 : 1)] = CLIP8(ss_int >> 8);
}
}
ImagingSectionLeave(&cookie);
@ -531,9 +531,9 @@ ImagingResampleVertical_16bpc(
(imIn->image8[y + ymin][xx * 2 + (bigendian ? 0 : 1)] << 8)) *
k[y];
}
ss_int = CLIP16(ROUND_UP(ss));
imOut->image8[yy][xx * 2 + (bigendian ? 1 : 0)] = ss_int & 0xFF;
imOut->image8[yy][xx * 2 + (bigendian ? 0 : 1)] = ss_int >> 8;
ss_int = ROUND_UP(ss);
imOut->image8[yy][xx * 2 + (bigendian ? 1 : 0)] = CLIP8(ss_int % 256);
imOut->image8[yy][xx * 2 + (bigendian ? 0 : 1)] = CLIP8(ss_int >> 8);
}
}
ImagingSectionLeave(&cookie);