Compare commits
454 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
877527cefc | ||
|
|
dcd6d41e77 | ||
|
|
94ec04d33e | ||
|
|
764e315923 | ||
|
|
0802206cfc | ||
|
|
e7556b19b7 | ||
|
|
3f74d08263 | ||
|
|
e9855d1705 | ||
|
|
f0f67f8cf8 | ||
|
|
c48b302602 | ||
|
|
78ee80a6fd | ||
|
|
381e264e18 | ||
|
|
9289863c2c | ||
|
|
4dc442fb01 | ||
|
|
0582f43bad | ||
|
|
22e47e38bb | ||
|
|
954269051b | ||
|
|
3ce681240f | ||
|
|
ea5901535d | ||
|
|
24696af889 | ||
|
|
7be56cc100 | ||
|
|
70713d69b0 | ||
|
|
894c5d5335 | ||
|
|
f693a3a0e5 | ||
|
|
6a05e34b69 | ||
|
|
903065f5e9 | ||
|
|
689a7f37fd | ||
|
|
599ddd368c | ||
|
|
ab25042353 | ||
|
|
1cd2d0f67a | ||
|
|
5f469b6bd2 | ||
|
|
ead2f34515 | ||
|
|
a6fc9992f2 | ||
|
|
2128d6465c | ||
|
|
4bba24632f | ||
|
|
21790fc0da | ||
|
|
c234720aca | ||
|
|
575b33d811 | ||
|
|
82614324ed | ||
|
|
32b6c5f0ee | ||
|
|
956d434c68 | ||
|
|
3bbb7a2a04 | ||
|
|
b656f900b4 | ||
|
|
586604d0c3 | ||
|
|
d92b826c4a | ||
|
|
2d02654c54 | ||
|
|
7e4ca8b3ab | ||
|
|
be8563347b | ||
|
|
fc47d07603 | ||
|
|
7fe1b9ee04 | ||
|
|
4af29fb732 | ||
|
|
1f3b8a831d | ||
|
|
0ef81c33af | ||
|
|
3dda1d190f | ||
|
|
f2ee74b2f8 | ||
|
|
99869f0313 | ||
|
|
fe054a1b3f | ||
|
|
852a832832 | ||
|
|
755b73b274 | ||
|
|
f0fe496315 | ||
|
|
fba17910aa | ||
|
|
d2b20102e4 | ||
|
|
8c522096e8 | ||
|
|
855774a175 | ||
|
|
2ae2c4e84f | ||
|
|
a908c62460 | ||
|
|
53800d4fcf | ||
|
|
a0cd878bed | ||
|
|
4e0aeba4af | ||
|
|
5f9112e862 | ||
|
|
9605fccf00 | ||
|
|
1382fc4767 | ||
|
|
c8c391b9c0 | ||
|
|
ecef4fb33f | ||
|
|
0cb00acc92 | ||
|
|
da06640873 | ||
|
|
d3b73ea462 | ||
|
|
5af49b380e | ||
|
|
1f026416f9 | ||
|
|
114e4d5695 | ||
|
|
2911422753 | ||
|
|
13433dc0a9 | ||
|
|
9f6a6a6921 | ||
|
|
9867b51d89 | ||
|
|
087376dc18 | ||
|
|
2593703e51 | ||
|
|
74e07b5b8a | ||
|
|
07b20b3b33 | ||
|
|
0c0bdf8d5a | ||
|
|
b300e78838 | ||
|
|
b893310045 | ||
|
|
b27ae0b2fd | ||
|
|
237ab0763c | ||
|
|
ff00aaa6d3 | ||
|
|
658d9ce258 | ||
|
|
433e46471e | ||
|
|
082cf04e85 | ||
|
|
2d89dcc7eb | ||
|
|
b71b4b98d9 | ||
|
|
c07f7e56a1 | ||
|
|
9f24881521 | ||
|
|
a124ed208f | ||
|
|
ee24a11073 | ||
|
|
6dd03edba8 | ||
|
|
65767a0cf7 | ||
|
|
a49c63208a | ||
|
|
3a3dab8bb0 | ||
|
|
4b911c889b | ||
|
|
b04c9a3d2f | ||
|
|
3157407762 | ||
|
|
fb1375d93b | ||
|
|
6e1ccab749 | ||
|
|
0cbdd2eff9 | ||
|
|
eda14b6c4a | ||
|
|
24b12dc84f | ||
|
|
d016c90108 | ||
|
|
6a0192a40a | ||
|
|
6fe81dd52e | ||
|
|
55989595ea | ||
|
|
b579577aa0 | ||
|
|
6f815c2d8d | ||
|
|
80a91fdb4e | ||
|
|
0d440b7d09 | ||
|
|
00ff8636a2 | ||
|
|
e74a89f70e | ||
|
|
20af4ec89c | ||
|
|
3f90d5c4da | ||
|
|
68be7f30ff | ||
|
|
e0f9e2b98e | ||
|
|
ad582c1a8e | ||
|
|
c2ac2da31c | ||
|
|
3aa076129f | ||
|
|
4a74a20b86 | ||
|
|
64ed4710b9 | ||
|
|
cdaa1bf9ef | ||
|
|
4d63d0b3a6 | ||
|
|
cb5736ea3e | ||
|
|
5ada8c8306 | ||
|
|
6ede62874b | ||
|
|
b97034ae02 | ||
|
|
77b2f6791a | ||
|
|
8f625f19ef | ||
|
|
8edb7734b5 | ||
|
|
05860779a1 | ||
|
|
ab02e810b0 | ||
|
|
ed89b93940 | ||
|
|
7cf4dac7ae | ||
|
|
117de2b181 | ||
|
|
43a3e5ca21 | ||
|
|
c722aaec53 | ||
|
|
b72f5730e1 | ||
|
|
ecc48f9b3e | ||
|
|
fcf033bdfb | ||
|
|
698fbb768a | ||
|
|
abb9b200ef | ||
|
|
b65bc406d8 | ||
|
|
d7d2df8ab2 | ||
|
|
f5ab7bb37b | ||
|
|
17612be407 | ||
|
|
64f6d4ebd8 | ||
|
|
a865345add | ||
|
|
1dd1c9a3e5 | ||
|
|
7f3751d498 | ||
|
|
e81acb8f79 | ||
|
|
e58c67347a | ||
|
|
7f68decf2c | ||
|
|
c03ba8b3c0 | ||
|
|
82ac16d89d | ||
|
|
20307667f9 | ||
|
|
9d790af50c | ||
|
|
3f78ebb542 | ||
|
|
c39eda6348 | ||
|
|
abb1d2bf6e | ||
|
|
d16c00fa0c | ||
|
|
4dc9398402 | ||
|
|
30b3dff0cb | ||
|
|
7d78ac519b | ||
|
|
3c41c09506 | ||
|
|
cdaa29eb52 | ||
|
|
585b2f5a78 | ||
|
|
ecf011ea15 | ||
|
|
cf6de8ca9b | ||
|
|
ffdcede651 | ||
|
|
7929d7760f | ||
|
|
c4f7aa5dfb | ||
|
|
22cdb5f2e4 | ||
|
|
fc15b3b018 | ||
|
|
44db0708c4 | ||
|
|
58f9a1d166 | ||
|
|
459bdf766f | ||
|
|
4ef0ac611d | ||
|
|
f5e893e46e | ||
|
|
ec8272044d | ||
|
|
d9035515f2 | ||
|
|
cf4a8ee0b9 | ||
|
|
3bf614e4b8 | ||
|
|
3cb854e8b2 | ||
|
|
3cb814f338 | ||
|
|
2696e962c2 | ||
|
|
6dfc2be807 | ||
|
|
da0ed929a0 | ||
|
|
2c2c2a1eae | ||
|
|
cc22efda7a | ||
|
|
b2a16f0dbe | ||
|
|
591ce38ca5 | ||
|
|
4bada07dc6 | ||
|
|
d66a77223b | ||
|
|
09c585dc21 | ||
|
|
1f74a55be2 | ||
|
|
228a85e56e | ||
|
|
751b373d41 | ||
|
|
f6b50a540d | ||
|
|
8d801bcafa | ||
|
|
40168cca95 | ||
|
|
7406b371ca | ||
|
|
ded95a6c3d | ||
|
|
73e1ed91e3 | ||
|
|
ea9d4ecf4e | ||
|
|
f80de2152c | ||
|
|
b2e3f788f9 | ||
|
|
33e1518cc7 | ||
|
|
a03b7b52f9 | ||
|
|
007974d35b | ||
|
|
84cb30d7a7 | ||
|
|
07c180b21e | ||
|
|
602acd5828 | ||
|
|
7c121637c9 | ||
|
|
7ef54f6bfd | ||
|
|
f298638632 | ||
|
|
a69b4ec228 | ||
|
|
b62ff96779 | ||
|
|
4b8ae8ede4 | ||
|
|
a9ef0e2922 | ||
|
|
3121c77cad | ||
|
|
ccf9863ba8 | ||
|
|
1ed39726c5 | ||
|
|
9f3f6de109 | ||
|
|
701b49adc5 | ||
|
|
9a7b91e5db | ||
|
|
018801805f | ||
|
|
65c4f4ea8d | ||
|
|
9006c305cf | ||
|
|
91a5a09595 | ||
|
|
754c7ea3a0 | ||
|
|
090ca9461b | ||
|
|
9a358fa289 | ||
|
|
b85b8534d7 | ||
|
|
624fc87d2d | ||
|
|
b337b33564 | ||
|
|
27de86483d | ||
|
|
9568bceeb8 | ||
|
|
396b0a2a39 | ||
|
|
20a9401971 | ||
|
|
40400edd62 | ||
|
|
7672b19af4 | ||
|
|
ef6951d1a5 | ||
|
|
f176f5dad6 | ||
|
|
9b7dccfe32 | ||
|
|
92ccedea87 | ||
|
|
fcecc8c6c4 | ||
|
|
d305ee6a25 | ||
|
|
da729c832c | ||
|
|
43e4ebe037 | ||
|
|
051fb0b995 | ||
|
|
67c0767b64 | ||
|
|
f551ecdc43 | ||
|
|
e4d72b53f5 | ||
|
|
8e9068e36f | ||
|
|
d4f78128ab | ||
|
|
e7f150df7f | ||
|
|
5b69607c35 | ||
|
|
2654d73626 | ||
|
|
33d62fc8a1 | ||
|
|
93729a0062 | ||
|
|
9a89944e73 | ||
|
|
47386d191c | ||
|
|
3a83d6abc3 | ||
|
|
ffd32a861a | ||
|
|
f0b5f56e9f | ||
|
|
4e85badfc1 | ||
|
|
fc0f65998f | ||
|
|
43bc816e88 | ||
|
|
0d7f5077a7 | ||
|
|
1bb14c4ef5 | ||
|
|
4d0089141c | ||
|
|
a4b0e3ecab | ||
|
|
c0fbe54978 | ||
|
|
77df8a36c1 | ||
|
|
a67ce7fba1 | ||
|
|
3b1f70da61 | ||
|
|
6ab139eaab | ||
|
|
46c529fa69 | ||
|
|
c304186190 | ||
|
|
735d02584b | ||
|
|
93de6a78d8 | ||
|
|
98c149f030 | ||
|
|
e6bb8626c8 | ||
|
|
e34c7bee91 | ||
|
|
8442a8541c | ||
|
|
6a06285bf8 | ||
|
|
4f5802b6b1 | ||
|
|
29509ffa75 | ||
|
|
d5d0734169 | ||
|
|
3a44ba1c75 | ||
|
|
5e91231ed6 | ||
|
|
dd042da9c2 | ||
|
|
8004234d87 | ||
|
|
c66ab56b4b | ||
|
|
27ca696c07 | ||
|
|
686174b5cc | ||
|
|
de2845b19a | ||
|
|
42a4af5c81 | ||
|
|
28524f2069 | ||
|
|
5450f9d08a | ||
|
|
2c87ce2d3d | ||
|
|
abbd515e9b | ||
|
|
97bdfeb4a5 | ||
|
|
c68cc49d8e | ||
|
|
55b0cbc273 | ||
|
|
c27d24bad8 | ||
|
|
f7582b8d58 | ||
|
|
f7ee26575e | ||
|
|
04470d5151 | ||
|
|
a8cf13010b | ||
|
|
0fae74731d | ||
|
|
7fc49a5cf4 | ||
|
|
0c2dc2047e | ||
|
|
f273619682 | ||
|
|
bb54c5020f | ||
|
|
26c70950e9 | ||
|
|
e96c5a5a53 | ||
|
|
2fe7c42148 | ||
|
|
e50d8a5192 | ||
|
|
81e0cf2bc4 | ||
|
|
43c12af730 | ||
|
|
4777a0b318 | ||
|
|
02764a0077 | ||
|
|
3cd69cb12f | ||
|
|
a5c9eba30a | ||
|
|
2c00c6f80e | ||
|
|
f708c00527 | ||
|
|
a18a62cda6 | ||
|
|
3c087bb58b | ||
|
|
d4111967a8 | ||
|
|
f71d74eec2 | ||
|
|
0ce21f98e7 | ||
|
|
97673f4e70 | ||
|
|
57be9dc25b | ||
|
|
1457c6032a | ||
|
|
657d0414f0 | ||
|
|
3795a1b916 | ||
|
|
913698b667 | ||
|
|
27765189c8 | ||
|
|
a15f9c6121 | ||
|
|
54ba4db542 | ||
|
|
723e764826 | ||
|
|
612e3c24a4 | ||
|
|
0604d6a2c9 | ||
|
|
3e14bea593 | ||
|
|
f78663b806 | ||
|
|
657d6ea4b6 | ||
|
|
ea9baaf99f | ||
|
|
49bc134ee1 | ||
|
|
26a188c062 | ||
|
|
fd8fa7df79 | ||
|
|
18cab11437 | ||
|
|
a90075a668 | ||
|
|
2a2638e58f | ||
|
|
8eddb86076 | ||
|
|
1ac7691fe5 | ||
|
|
e108e646da | ||
|
|
62aa42f9da | ||
|
|
508e9c9984 | ||
|
|
095cdb3c4a | ||
|
|
7cbe8c4924 | ||
|
|
27924be4fd | ||
|
|
fc4dbc3810 | ||
|
|
799564dd52 | ||
|
|
0e8bb72a66 | ||
|
|
f86ad8b36d | ||
|
|
29ff5fcb55 | ||
|
|
6a5c588c5f | ||
|
|
a293273b31 | ||
|
|
6564325e43 | ||
|
|
93c8a60784 | ||
|
|
b6178303a1 | ||
|
|
d568c8d9e3 | ||
|
|
d08d7ee99e | ||
|
|
2b186fceb8 | ||
|
|
c036185514 | ||
|
|
d737687fc3 | ||
|
|
34814d8d2f | ||
|
|
3968886cf6 | ||
|
|
bc64ccbf28 | ||
|
|
76d3116ef0 | ||
|
|
a6b36f0b6b | ||
|
|
a0f51493ca | ||
|
|
a6a701c4db | ||
|
|
e08f910db4 | ||
|
|
d1974d76f7 | ||
|
|
5ea2d3a056 | ||
|
|
d23a899f23 | ||
|
|
096c479cfb | ||
|
|
7f38f980dd | ||
|
|
b06118c2b3 | ||
|
|
9c8059fdea | ||
|
|
1baf141146 | ||
|
|
6b9de40533 | ||
|
|
ef8ff756fa | ||
|
|
2e9d54887b | ||
|
|
7e208ccf9d | ||
|
|
0f4becea73 | ||
|
|
e2b87a0420 | ||
|
|
400ffbc18d | ||
|
|
d7dfeeb7ad | ||
|
|
426ad8307d | ||
|
|
627d8743b7 | ||
|
|
dcd52ebf65 | ||
|
|
d6e0a8d174 | ||
|
|
2210714a43 | ||
|
|
3d7801417a | ||
|
|
a85d3b135d | ||
|
|
932aa68d2a | ||
|
|
fe236d77a5 | ||
|
|
bc0e2c0e61 | ||
|
|
e66dd607f0 | ||
|
|
d5d8a91597 | ||
|
|
b8351fde41 | ||
|
|
36cf82ae76 | ||
|
|
525842215f | ||
|
|
844b10f894 | ||
|
|
555fb8371c | ||
|
|
0a1d6c3c61 | ||
|
|
00ec73dfd1 | ||
|
|
e924cfd181 | ||
|
|
2360d0df17 | ||
|
|
499b796556 | ||
|
|
1918c6811d | ||
|
|
5b677ca1c6 | ||
|
|
b71109d435 | ||
|
|
4337139f0c | ||
|
|
72931475f2 | ||
|
|
79357a2718 | ||
|
|
3abb62ed29 | ||
|
|
11d599c798 | ||
|
|
4b2d4811e1 | ||
|
|
16691657cc | ||
|
|
9ac4edc54b | ||
|
|
b428f7209f | ||
|
|
04ee0cc3b1 | ||
|
|
d06c8b3591 | ||
|
|
6a9960e8c1 | ||
|
|
94c3ee6944 | ||
|
|
f9f7ba4ce9 |
@ -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
|
||||
# TODO Update condition when pyqt6 supports free-threading
|
||||
if ! [[ "$PYTHON_GIL" == "0" ]]; then python3 -m pip install pyqt6 ; fi
|
||||
# pyqt6 doesn't yet support free-threading; only install if a wheel is available
|
||||
python3 -m pip install --only-binary=:all: pyqt6 || true
|
||||
fi
|
||||
|
||||
# webp
|
||||
@ -53,7 +53,7 @@ pushd depends && ./install_imagequant.sh && popd
|
||||
pushd depends && sudo ./install_raqm.sh && popd
|
||||
|
||||
# libavif
|
||||
pushd depends && sudo ./install_libavif.sh && popd
|
||||
pushd depends && ./install_libavif.sh && popd
|
||||
|
||||
# extra test images
|
||||
pushd depends && ./install_extra_test_images.sh && popd
|
||||
|
||||
@ -1 +1 @@
|
||||
cibuildwheel==3.3.0
|
||||
cibuildwheel==3.4.1
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
mypy==1.19.0
|
||||
mypy==1.20.2
|
||||
arro3-compute
|
||||
arro3-core
|
||||
IceSpringPySideStubs-PyQt6
|
||||
@ -9,7 +9,6 @@ packaging
|
||||
pyarrow-stubs
|
||||
pybind11
|
||||
pytest
|
||||
sphinx
|
||||
types-atheris
|
||||
types-defusedxml
|
||||
types-olefile
|
||||
|
||||
1
.ci/requirements-sbom.txt
Normal file
1
.ci/requirements-sbom.txt
Normal file
@ -0,0 +1 @@
|
||||
check-jsonschema==0.37.1
|
||||
3
.github/FUNDING.yml
vendored
3
.github/FUNDING.yml
vendored
@ -1 +1,2 @@
|
||||
tidelift: "pypi/pillow"
|
||||
github: python-pillow
|
||||
tidelift: pypi/pillow
|
||||
|
||||
424
.github/INCIDENT_RESPONSE.md
vendored
Normal file
424
.github/INCIDENT_RESPONSE.md
vendored
Normal file
@ -0,0 +1,424 @@
|
||||
# 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 7–14 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\>
|
||||
20
.github/SECURITY.md
vendored
20
.github/SECURITY.md
vendored
@ -1,5 +1,21 @@
|
||||
# Security policy
|
||||
|
||||
To report sensitive vulnerability information, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure.
|
||||
## Reporting a vulnerability
|
||||
|
||||
If your organisation/employer is a distributor of Pillow and would like advance notification of security-related bugs, please let us know your preferred contact method.
|
||||
To report sensitive vulnerability information, report it [privately on GitHub](https://github.com/python-pillow/Pillow/security/advisories/new).
|
||||
|
||||
If you cannot use GitHub, use the [Tidelift security contact](https://tidelift.com/docs/security). Tidelift will coordinate the fix and disclosure.
|
||||
|
||||
**DO NOT report sensitive vulnerability information in public.**
|
||||
|
||||
## Threat model
|
||||
|
||||
Pillow's primary attack surface is parsing untrusted image data. A full STRIDE threat model covering spoofing, tampering, repudiation, information disclosure, denial of service, and elevation of privilege is maintained in the [Security handbook page](https://pillow.readthedocs.io/en/latest/handbook/security.html).
|
||||
|
||||
Key risks to be aware of when using Pillow to process untrusted images:
|
||||
|
||||
- **Decompression bombs** — do not set `Image.MAX_IMAGE_PIXELS = None` in production.
|
||||
- **EPS files invoke Ghostscript** — block EPS input at the application layer unless strictly required.
|
||||
- **`ImageMath.unsafe_eval()`** — never pass user-controlled strings to this function; use `lambda_eval` instead.
|
||||
- **C extension memory safety** — keep Pillow and its bundled C libraries (libjpeg, libpng, libtiff, libwebp, etc.) up to date.
|
||||
- **Sandboxing** — for high-risk deployments, run image processing in a sandboxed subprocess.
|
||||
|
||||
271
.github/compare-dist-sizes.py
vendored
Normal file
271
.github/compare-dist-sizes.py
vendored
Normal file
@ -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 <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())
|
||||
19
.github/dependencies.json
vendored
Normal file
19
.github/dependencies.json
vendored
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"brotli": "1.2.0",
|
||||
"bzip2": "1.0.8",
|
||||
"freetype": "2.14.3",
|
||||
"fribidi": "1.0.16",
|
||||
"harfbuzz": "14.2.0",
|
||||
"jpegturbo": "3.1.4.1",
|
||||
"lcms2": "2.19",
|
||||
"libavif": "1.4.1",
|
||||
"libimagequant": "4.4.1",
|
||||
"libpng": "1.6.58",
|
||||
"libwebp": "1.6.0",
|
||||
"libxcb": "1.17.0",
|
||||
"openjpeg": "2.5.4",
|
||||
"tiff": "4.7.1",
|
||||
"xz": "5.8.3",
|
||||
"zlib-ng": "2.3.3",
|
||||
"zstd": "1.5.7"
|
||||
}
|
||||
560
.github/generate-sbom.py
vendored
Executable file
560
.github/generate-sbom.py
vendored
Executable file
@ -0,0 +1,560 @@
|
||||
#!/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()
|
||||
166
.github/renovate.json
vendored
166
.github/renovate.json
vendored
@ -6,16 +6,170 @@
|
||||
"labels": [
|
||||
"Dependency"
|
||||
],
|
||||
"minimumReleaseAge": "7 days",
|
||||
"prCreation": "not-pending",
|
||||
"schedule": [
|
||||
"* * 3 * *"
|
||||
],
|
||||
"customManagers": [
|
||||
{
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
|
||||
"matchStrings": ["\"brotli\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
|
||||
"depNameTemplate": "brotli",
|
||||
"packageNameTemplate": "google/brotli",
|
||||
"datasourceTemplate": "github-releases",
|
||||
"extractVersionTemplate": "^v(?<version>.+)$"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
|
||||
"matchStrings": ["\"bzip2\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
|
||||
"depNameTemplate": "bzip2",
|
||||
"packageNameTemplate": "bzip2/bzip2",
|
||||
"datasourceTemplate": "gitlab-tags",
|
||||
"extractVersionTemplate": "^bzip2-(?<version>.+)$"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
|
||||
"matchStrings": ["\"freetype\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
|
||||
"depNameTemplate": "freetype",
|
||||
"packageNameTemplate": "freetype/freetype",
|
||||
"datasourceTemplate": "gitlab-tags",
|
||||
"registryUrlTemplate": "https://gitlab.freedesktop.org",
|
||||
"extractVersionTemplate": "^VER-(?<version>[\\d-]+)$",
|
||||
"versioningTemplate": "regex:^(?<major>\\d+)[.-](?<minor>\\d+)[.-](?<patch>\\d+)$"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
|
||||
"matchStrings": ["\"fribidi\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
|
||||
"depNameTemplate": "fribidi",
|
||||
"packageNameTemplate": "fribidi/fribidi",
|
||||
"datasourceTemplate": "github-releases",
|
||||
"extractVersionTemplate": "^v(?<version>.+)$"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
|
||||
"matchStrings": ["\"harfbuzz\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
|
||||
"depNameTemplate": "harfbuzz",
|
||||
"packageNameTemplate": "harfbuzz/harfbuzz",
|
||||
"datasourceTemplate": "github-releases"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
|
||||
"matchStrings": ["\"jpegturbo\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
|
||||
"depNameTemplate": "jpegturbo",
|
||||
"packageNameTemplate": "libjpeg-turbo/libjpeg-turbo",
|
||||
"datasourceTemplate": "github-releases"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
|
||||
"matchStrings": ["\"lcms2\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
|
||||
"depNameTemplate": "lcms2",
|
||||
"packageNameTemplate": "mm2/Little-CMS",
|
||||
"datasourceTemplate": "github-releases",
|
||||
"extractVersionTemplate": "^lcms(?<version>.+)$"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
|
||||
"matchStrings": ["\"libavif\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
|
||||
"depNameTemplate": "libavif",
|
||||
"packageNameTemplate": "AOMediaCodec/libavif",
|
||||
"datasourceTemplate": "github-releases",
|
||||
"extractVersionTemplate": "^v(?<version>.+)$"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
|
||||
"matchStrings": ["\"libimagequant\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
|
||||
"depNameTemplate": "libimagequant",
|
||||
"packageNameTemplate": "ImageOptim/libimagequant",
|
||||
"datasourceTemplate": "github-tags"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
|
||||
"matchStrings": ["\"libpng\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
|
||||
"depNameTemplate": "libpng",
|
||||
"packageNameTemplate": "pnggroup/libpng",
|
||||
"datasourceTemplate": "github-tags",
|
||||
"extractVersionTemplate": "^v(?<version>.+)$"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
|
||||
"matchStrings": ["\"libwebp\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
|
||||
"depNameTemplate": "libwebp",
|
||||
"packageNameTemplate": "webmproject/libwebp",
|
||||
"datasourceTemplate": "github-tags",
|
||||
"extractVersionTemplate": "^v(?<version>.+)$"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
|
||||
"matchStrings": ["\"libxcb\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
|
||||
"depNameTemplate": "libxcb",
|
||||
"packageNameTemplate": "xorg/lib/libxcb",
|
||||
"datasourceTemplate": "gitlab-tags",
|
||||
"registryUrlTemplate": "https://gitlab.freedesktop.org",
|
||||
"extractVersionTemplate": "^libxcb-(?<version>.+)$"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
|
||||
"matchStrings": ["\"openjpeg\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
|
||||
"depNameTemplate": "openjpeg",
|
||||
"packageNameTemplate": "uclouvain/openjpeg",
|
||||
"datasourceTemplate": "github-releases",
|
||||
"extractVersionTemplate": "^v(?<version>.+)$"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
|
||||
"matchStrings": ["\"tiff\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
|
||||
"depNameTemplate": "tiff",
|
||||
"packageNameTemplate": "libtiff/libtiff",
|
||||
"datasourceTemplate": "gitlab-tags",
|
||||
"extractVersionTemplate": "^v(?<version>.+)$"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
|
||||
"matchStrings": ["\"xz\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
|
||||
"depNameTemplate": "xz",
|
||||
"packageNameTemplate": "tukaani-project/xz",
|
||||
"datasourceTemplate": "github-releases",
|
||||
"extractVersionTemplate": "^v(?<version>.+)$"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
|
||||
"matchStrings": ["\"zlib-ng\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
|
||||
"depNameTemplate": "zlib-ng",
|
||||
"packageNameTemplate": "zlib-ng/zlib-ng",
|
||||
"datasourceTemplate": "github-releases"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
|
||||
"matchStrings": ["\"zstd\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
|
||||
"depNameTemplate": "zstd",
|
||||
"packageNameTemplate": "facebook/zstd",
|
||||
"datasourceTemplate": "github-releases",
|
||||
"extractVersionTemplate": "^v(?<version>.+)$"
|
||||
}
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"groupName": "github-actions",
|
||||
"matchManagers": [
|
||||
"github-actions"
|
||||
],
|
||||
"matchManagers": ["github-actions"],
|
||||
"separateMajorMinor": false
|
||||
}
|
||||
],
|
||||
"schedule": [
|
||||
"* * 3 * *"
|
||||
]
|
||||
}
|
||||
|
||||
13
.github/workflows/Brewfile
vendored
Normal file
13
.github/workflows/Brewfile
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
brew "aom"
|
||||
brew "dav1d"
|
||||
brew "freetype"
|
||||
brew "ghostscript"
|
||||
brew "jpeg-turbo"
|
||||
brew "libimagequant"
|
||||
brew "libraqm"
|
||||
brew "libtiff"
|
||||
brew "little-cms2"
|
||||
brew "openjpeg"
|
||||
brew "rav1e"
|
||||
brew "svt-av1"
|
||||
brew "webp"
|
||||
20
.github/workflows/cifuzz.yml
vendored
20
.github/workflows/cifuzz.yml
vendored
@ -4,17 +4,14 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
paths:
|
||||
paths: &paths
|
||||
- ".github/dependencies.json"
|
||||
- ".github/workflows/cifuzz.yml"
|
||||
- ".github/workflows/wheels-dependencies.sh"
|
||||
- "**.c"
|
||||
- "**.h"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/cifuzz.yml"
|
||||
- ".github/workflows/wheels-dependencies.sh"
|
||||
- "**.c"
|
||||
- "**.h"
|
||||
paths: *paths
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@ -24,33 +21,36 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
FORCE_COLOR: 1
|
||||
|
||||
jobs:
|
||||
Fuzzing:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Build Fuzzers
|
||||
id: build
|
||||
uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@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@master
|
||||
uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@d87225267726cf7ce1a3e17cf103c5ac943c4f05 # master
|
||||
with:
|
||||
oss-fuzz-project-name: 'pillow'
|
||||
fuzz-seconds: 600
|
||||
language: python
|
||||
dry-run: false
|
||||
- name: Upload New Crash
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: failure() && steps.build.outcome == 'success'
|
||||
with:
|
||||
name: artifacts
|
||||
path: ./out/artifacts
|
||||
- name: Upload Legacy Crash
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: steps.run.outcome == 'success'
|
||||
with:
|
||||
name: crash
|
||||
|
||||
29
.github/workflows/docs.yml
vendored
29
.github/workflows/docs.yml
vendored
@ -4,15 +4,12 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
paths:
|
||||
paths: &paths
|
||||
- ".github/workflows/docs.yml"
|
||||
- "docs/**"
|
||||
- "src/PIL/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/docs.yml"
|
||||
- "docs/**"
|
||||
- "src/PIL/**"
|
||||
paths: *paths
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@ -32,12 +29,12 @@ jobs:
|
||||
name: Docs
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.x"
|
||||
cache: pip
|
||||
@ -48,19 +45,35 @@ jobs:
|
||||
- name: Build system information
|
||||
run: python3 .github/workflows/system-info.py
|
||||
|
||||
- name: Cache libavif
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
id: cache-libavif
|
||||
with:
|
||||
path: ~/cache-libavif
|
||||
key: ${{ runner.os }}-libavif-${{ hashFiles('depends/install_libavif.sh', 'depends/libavif-svt4.patch') }}
|
||||
|
||||
- name: Cache libimagequant
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
id: cache-libimagequant
|
||||
with:
|
||||
path: ~/cache-libimagequant
|
||||
key: ${{ runner.os }}-libimagequant-${{ hashFiles('depends/install_imagequant.sh') }}
|
||||
|
||||
- name: Cache libwebp
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
id: cache-libwebp
|
||||
with:
|
||||
path: ~/cache-libwebp
|
||||
key: ${{ runner.os }}-libwebp-${{ hashFiles('depends/install_webp.sh') }}
|
||||
|
||||
- name: Install Linux dependencies
|
||||
run: |
|
||||
.ci/install.sh
|
||||
env:
|
||||
GHA_PYTHON_VERSION: "3.x"
|
||||
GHA_LIBAVIF_CACHE_HIT: ${{ steps.cache-libavif.outputs.cache-hit }}
|
||||
GHA_LIBIMAGEQUANT_CACHE_HIT: ${{ steps.cache-libimagequant.outputs.cache-hit }}
|
||||
GHA_LIBWEBP_CACHE_HIT: ${{ steps.cache-libwebp.outputs.cache-hit }}
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
|
||||
8
.github/workflows/lint.yml
vendored
8
.github/workflows/lint.yml
vendored
@ -18,14 +18,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Lint
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v6
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.10"
|
||||
python-version: "3.x"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
- name: Lint
|
||||
run: uvx --with tox-uv tox -e lint
|
||||
- name: Mypy
|
||||
|
||||
15
.github/workflows/macos-install.sh
vendored
15
.github/workflows/macos-install.sh
vendored
@ -2,20 +2,7 @@
|
||||
|
||||
set -e
|
||||
|
||||
brew install \
|
||||
aom \
|
||||
dav1d \
|
||||
freetype \
|
||||
ghostscript \
|
||||
jpeg-turbo \
|
||||
libimagequant \
|
||||
libraqm \
|
||||
libtiff \
|
||||
little-cms2 \
|
||||
openjpeg \
|
||||
rav1e \
|
||||
svt-av1 \
|
||||
webp
|
||||
brew bundle --file=.github/workflows/Brewfile
|
||||
export PKG_CONFIG_PATH="/usr/local/opt/openblas/lib/pkgconfig"
|
||||
|
||||
python3 -m pip install coverage
|
||||
|
||||
5
.github/workflows/release-drafter.yml
vendored
5
.github/workflows/release-drafter.yml
vendored
@ -14,6 +14,9 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
FORCE_COLOR: 1
|
||||
|
||||
jobs:
|
||||
update_release_draft:
|
||||
permissions:
|
||||
@ -23,6 +26,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Drafts your next release notes as pull requests are merged into "main"
|
||||
- uses: release-drafter/release-drafter@v6
|
||||
- uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
7
.github/workflows/stale.yml
vendored
7
.github/workflows/stale.yml
vendored
@ -12,9 +12,12 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
FORCE_COLOR: 1
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
if: github.repository_owner == 'python-pillow'
|
||||
if: github.event.repository.fork == false
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
@ -22,7 +25,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: "Check issues"
|
||||
uses: actions/stale@v10
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
only-labels: "Awaiting OP Action"
|
||||
|
||||
37
.github/workflows/test-docker.yml
vendored
37
.github/workflows/test-docker.yml
vendored
@ -4,19 +4,14 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
paths-ignore:
|
||||
paths-ignore: &paths-ignore
|
||||
- ".github/workflows/docs.yml"
|
||||
- ".github/workflows/wheels*"
|
||||
- ".gitmodules"
|
||||
- "docs/**"
|
||||
- "wheels/**"
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- ".github/workflows/docs.yml"
|
||||
- ".github/workflows/wheels*"
|
||||
- ".gitmodules"
|
||||
- "docs/**"
|
||||
- "wheels/**"
|
||||
paths-ignore: *paths-ignore
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@ -26,6 +21,9 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
FORCE_COLOR: 1
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@ -36,39 +34,37 @@ jobs:
|
||||
os: ["ubuntu-latest"]
|
||||
docker: [
|
||||
# Run slower jobs first to give them a headstart and reduce waiting time
|
||||
ubuntu-24.04-noble-ppc64le,
|
||||
ubuntu-24.04-noble-s390x,
|
||||
ubuntu-26.04-resolute-ppc64le,
|
||||
ubuntu-26.04-resolute-s390x,
|
||||
# Then run the remainder
|
||||
alpine,
|
||||
amazon-2-amd64,
|
||||
amazon-2023-amd64,
|
||||
arch,
|
||||
centos-stream-9-amd64,
|
||||
centos-stream-10-amd64,
|
||||
debian-12-bookworm-x86,
|
||||
debian-12-bookworm-amd64,
|
||||
debian-13-trixie-x86,
|
||||
debian-13-trixie-amd64,
|
||||
fedora-42-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-24.04-noble-ppc64le"
|
||||
- docker: "ubuntu-26.04-resolute-ppc64le"
|
||||
qemu-arch: "ppc64le"
|
||||
- docker: "ubuntu-24.04-noble-s390x"
|
||||
- docker: "ubuntu-26.04-resolute-s390x"
|
||||
qemu-arch: "s390x"
|
||||
- docker: "ubuntu-24.04-noble-arm64v8"
|
||||
- docker: "ubuntu-26.04-resolute-arm64v8"
|
||||
os: "ubuntu-24.04-arm"
|
||||
dockerTag: main
|
||||
|
||||
name: ${{ matrix.docker }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -77,13 +73,13 @@ jobs:
|
||||
|
||||
- name: Set up QEMU
|
||||
if: "matrix.qemu-arch"
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
with:
|
||||
platforms: ${{ matrix.qemu-arch }}
|
||||
|
||||
- name: Docker pull
|
||||
run: |
|
||||
docker pull pythonpillow/${{ matrix.docker }}:${{ matrix.dockerTag }}
|
||||
docker pull ${{ matrix.qemu-arch && format('--platform=linux/{0}', matrix.qemu-arch)}} pythonpillow/${{ matrix.docker }}:${{ matrix.dockerTag }}
|
||||
|
||||
- name: Docker build
|
||||
run: |
|
||||
@ -105,11 +101,10 @@ jobs:
|
||||
.ci/after_success.sh
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v5
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
with:
|
||||
flags: GHA_Docker
|
||||
name: ${{ matrix.docker }}
|
||||
token: ${{ secrets.CODECOV_ORG_TOKEN }}
|
||||
|
||||
success:
|
||||
permissions:
|
||||
|
||||
15
.github/workflows/test-mingw.yml
vendored
15
.github/workflows/test-mingw.yml
vendored
@ -4,19 +4,14 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
paths-ignore:
|
||||
paths-ignore: &paths-ignore
|
||||
- ".github/workflows/docs.yml"
|
||||
- ".github/workflows/wheels*"
|
||||
- ".gitmodules"
|
||||
- "docs/**"
|
||||
- "wheels/**"
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- ".github/workflows/docs.yml"
|
||||
- ".github/workflows/wheels*"
|
||||
- ".gitmodules"
|
||||
- "docs/**"
|
||||
- "wheels/**"
|
||||
paths-ignore: *paths-ignore
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@ -28,6 +23,7 @@ concurrency:
|
||||
|
||||
env:
|
||||
COVERAGE_CORE: sysmon
|
||||
FORCE_COLOR: 1
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@ -45,7 +41,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Pillow
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -86,9 +82,8 @@ jobs:
|
||||
.ci/test.sh
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v5
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
with:
|
||||
files: ./coverage.xml
|
||||
flags: GHA_Windows
|
||||
name: "MSYS2 MinGW"
|
||||
token: ${{ secrets.CODECOV_ORG_TOKEN }}
|
||||
|
||||
10
.github/workflows/test-valgrind-memory.yml
vendored
10
.github/workflows/test-valgrind-memory.yml
vendored
@ -8,12 +8,13 @@ on:
|
||||
# branches:
|
||||
# - "**"
|
||||
# paths:
|
||||
# - ".github/workflows/test-valgrind.yml"
|
||||
# - ".github/workflows/test-valgrind-memory.yml"
|
||||
# - "**.c"
|
||||
# - "**.h"
|
||||
# - "depends/docker-test-valgrind-memory.sh"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/test-valgrind.yml"
|
||||
- ".github/workflows/test-valgrind-memory.yml"
|
||||
- "**.c"
|
||||
- "**.h"
|
||||
- "depends/docker-test-valgrind-memory.sh"
|
||||
@ -26,6 +27,9 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
FORCE_COLOR: 1
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@ -41,7 +45,7 @@ jobs:
|
||||
name: ${{ matrix.docker }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
12
.github/workflows/test-valgrind.yml
vendored
12
.github/workflows/test-valgrind.yml
vendored
@ -6,15 +6,12 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
paths:
|
||||
paths: &paths
|
||||
- ".github/workflows/test-valgrind.yml"
|
||||
- "**.c"
|
||||
- "**.h"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/test-valgrind.yml"
|
||||
- "**.c"
|
||||
- "**.h"
|
||||
paths: *paths
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@ -24,6 +21,9 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
FORCE_COLOR: 1
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@ -39,7 +39,7 @@ jobs:
|
||||
name: ${{ matrix.docker }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
36
.github/workflows/test-windows.yml
vendored
36
.github/workflows/test-windows.yml
vendored
@ -4,19 +4,14 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
paths-ignore:
|
||||
paths-ignore: &paths-ignore
|
||||
- ".github/workflows/docs.yml"
|
||||
- ".github/workflows/wheels*"
|
||||
- ".gitmodules"
|
||||
- "docs/**"
|
||||
- "wheels/**"
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- ".github/workflows/docs.yml"
|
||||
- ".github/workflows/wheels*"
|
||||
- ".gitmodules"
|
||||
- "docs/**"
|
||||
- "wheels/**"
|
||||
paths-ignore: *paths-ignore
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@ -28,6 +23,7 @@ concurrency:
|
||||
|
||||
env:
|
||||
COVERAGE_CORE: sysmon
|
||||
FORCE_COLOR: 1
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@ -48,19 +44,19 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Pillow
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout cached dependencies
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: python-pillow/pillow-depends
|
||||
path: winbuild\depends
|
||||
|
||||
- name: Checkout extra test images
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: python-pillow/test-images
|
||||
@ -68,7 +64,7 @@ jobs:
|
||||
|
||||
# sets env: pythonLocation
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
allow-prereleases: true
|
||||
@ -98,8 +94,8 @@ jobs:
|
||||
choco install nasm --no-progress
|
||||
echo "C:\Program Files\NASM" >> $env:GITHUB_PATH
|
||||
|
||||
choco install ghostscript --version=10.6.0 --no-progress
|
||||
echo "C:\Program Files\gs\gs10.06.0\bin" >> $env:GITHUB_PATH
|
||||
choco install ghostscript --version=10.7.0 --no-progress
|
||||
echo "C:\Program Files\gs\gs10.07.0\bin" >> $env:GITHUB_PATH
|
||||
|
||||
# Install extra test images
|
||||
xcopy /S /Y Tests\test-images\* Tests\images
|
||||
@ -112,7 +108,7 @@ jobs:
|
||||
|
||||
- name: Cache build
|
||||
id: build-cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: winbuild\build
|
||||
key:
|
||||
@ -188,8 +184,9 @@ jobs:
|
||||
# trim ~150MB for each job
|
||||
- name: Optimize build cache
|
||||
if: steps.build-cache.outputs.cache-hit != 'true'
|
||||
run: rmdir /S /Q winbuild\build\src
|
||||
shell: cmd
|
||||
run: |
|
||||
rm -rf winbuild\build\src
|
||||
shell: bash
|
||||
|
||||
- name: Build Pillow
|
||||
run: |
|
||||
@ -206,9 +203,7 @@ jobs:
|
||||
|
||||
- name: Test Pillow
|
||||
run: |
|
||||
path %GITHUB_WORKSPACE%\winbuild\build\bin;%PATH%
|
||||
.ci\test.cmd
|
||||
shell: cmd
|
||||
|
||||
- name: Prepare to upload errors
|
||||
if: failure()
|
||||
@ -217,7 +212,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Upload errors
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: failure()
|
||||
with:
|
||||
name: errors
|
||||
@ -229,12 +224,11 @@ jobs:
|
||||
shell: pwsh
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v5
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
with:
|
||||
files: ./coverage.xml
|
||||
flags: GHA_Windows
|
||||
name: ${{ runner.os }} Python ${{ matrix.python-version }}
|
||||
token: ${{ secrets.CODECOV_ORG_TOKEN }}
|
||||
|
||||
success:
|
||||
permissions:
|
||||
|
||||
51
.github/workflows/test.yml
vendored
51
.github/workflows/test.yml
vendored
@ -4,19 +4,14 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
paths-ignore:
|
||||
paths-ignore: &paths-ignore
|
||||
- ".github/workflows/docs.yml"
|
||||
- ".github/workflows/wheels*"
|
||||
- ".gitmodules"
|
||||
- "docs/**"
|
||||
- "wheels/**"
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- ".github/workflows/docs.yml"
|
||||
- ".github/workflows/wheels*"
|
||||
- ".gitmodules"
|
||||
- "docs/**"
|
||||
- "wheels/**"
|
||||
paths-ignore: *paths-ignore
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@ -29,6 +24,7 @@ concurrency:
|
||||
env:
|
||||
COVERAGE_CORE: sysmon
|
||||
FORCE_COLOR: 1
|
||||
PIP_DISABLE_PIP_VERSION_CHECK: 1
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@ -46,7 +42,6 @@ jobs:
|
||||
"3.15",
|
||||
"3.14t",
|
||||
"3.14",
|
||||
"3.13t",
|
||||
"3.13",
|
||||
"3.12",
|
||||
"3.11",
|
||||
@ -55,12 +50,8 @@ 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-15-intel", python-version: "3.10" }
|
||||
- { os: "macos-26-intel", python-version: "3.10" }
|
||||
exclude:
|
||||
- { os: "macos-latest", python-version: "3.10" }
|
||||
|
||||
@ -68,12 +59,12 @@ jobs:
|
||||
name: ${{ matrix.os }} Python ${{ matrix.python-version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
allow-prereleases: true
|
||||
@ -82,29 +73,42 @@ 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
|
||||
|
||||
- name: Cache libavif
|
||||
if: startsWith(matrix.os, 'ubuntu')
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
id: cache-libavif
|
||||
with:
|
||||
path: ~/cache-libavif
|
||||
key: ${{ runner.os }}-libavif-${{ hashFiles('depends/install_libavif.sh', 'depends/libavif-svt4.patch') }}
|
||||
|
||||
- name: Cache libimagequant
|
||||
if: startsWith(matrix.os, 'ubuntu')
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
id: cache-libimagequant
|
||||
with:
|
||||
path: ~/cache-libimagequant
|
||||
key: ${{ runner.os }}-libimagequant-${{ hashFiles('depends/install_imagequant.sh') }}
|
||||
|
||||
- name: Cache libwebp
|
||||
if: startsWith(matrix.os, 'ubuntu')
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
id: cache-libwebp
|
||||
with:
|
||||
path: ~/cache-libwebp
|
||||
key: ${{ runner.os }}-libwebp-${{ hashFiles('depends/install_webp.sh') }}
|
||||
|
||||
- name: Install Linux dependencies
|
||||
if: startsWith(matrix.os, 'ubuntu')
|
||||
run: |
|
||||
.ci/install.sh
|
||||
env:
|
||||
GHA_PYTHON_VERSION: ${{ matrix.python-version }}
|
||||
GHA_LIBAVIF_CACHE_HIT: ${{ steps.cache-libavif.outputs.cache-hit }}
|
||||
GHA_LIBIMAGEQUANT_CACHE_HIT: ${{ steps.cache-libimagequant.outputs.cache-hit }}
|
||||
GHA_LIBWEBP_CACHE_HIT: ${{ steps.cache-libwebp.outputs.cache-hit }}
|
||||
|
||||
- name: Install macOS dependencies
|
||||
if: startsWith(matrix.os, 'macOS')
|
||||
@ -143,7 +147,7 @@ jobs:
|
||||
mkdir -p Tests/errors
|
||||
|
||||
- name: Upload errors
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: failure()
|
||||
with:
|
||||
name: errors
|
||||
@ -154,11 +158,10 @@ jobs:
|
||||
.ci/after_success.sh
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v5
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
with:
|
||||
flags: ${{ matrix.os == 'ubuntu-latest' && 'GHA_Ubuntu' || 'GHA_macOS' }}
|
||||
name: ${{ matrix.os }} Python ${{ matrix.python-version }}
|
||||
token: ${{ secrets.CODECOV_ORG_TOKEN }}
|
||||
|
||||
success:
|
||||
permissions:
|
||||
|
||||
49
.github/workflows/wheels-dependencies.sh
vendored
49
.github/workflows/wheels-dependencies.sh
vendored
@ -89,26 +89,23 @@ fi
|
||||
|
||||
ARCHIVE_SDIR=pillow-depends-main
|
||||
|
||||
# Package versions for fresh source builds.
|
||||
if [[ -n "$IOS_SDK" ]]; then
|
||||
FREETYPE_VERSION=2.13.3
|
||||
else
|
||||
FREETYPE_VERSION=2.14.1
|
||||
fi
|
||||
HARFBUZZ_VERSION=12.3.0
|
||||
LIBPNG_VERSION=1.6.53
|
||||
JPEGTURBO_VERSION=3.1.3
|
||||
OPENJPEG_VERSION=2.5.4
|
||||
XZ_VERSION=5.8.2
|
||||
ZSTD_VERSION=1.5.7
|
||||
TIFF_VERSION=4.7.1
|
||||
LCMS2_VERSION=2.17
|
||||
ZLIB_NG_VERSION=2.3.2
|
||||
LIBWEBP_VERSION=1.6.0
|
||||
BZIP2_VERSION=1.0.8
|
||||
LIBXCB_VERSION=1.17.0
|
||||
BROTLI_VERSION=1.2.0
|
||||
LIBAVIF_VERSION=1.3.0
|
||||
VERSIONS_FILE="$PROJECTDIR/.github/dependencies.json"
|
||||
_get_ver() { python3 -c "import json; print(json.load(open('$VERSIONS_FILE'))['$1'])"; }
|
||||
FREETYPE_VERSION=$(_get_ver freetype)
|
||||
HARFBUZZ_VERSION=$(_get_ver harfbuzz)
|
||||
LIBPNG_VERSION=$(_get_ver libpng)
|
||||
JPEGTURBO_VERSION=$(_get_ver jpegturbo)
|
||||
OPENJPEG_VERSION=$(_get_ver openjpeg)
|
||||
XZ_VERSION=$(_get_ver xz)
|
||||
ZSTD_VERSION=$(_get_ver zstd)
|
||||
TIFF_VERSION=$(_get_ver tiff)
|
||||
LCMS2_VERSION=$(_get_ver lcms2)
|
||||
ZLIB_NG_VERSION=$(_get_ver zlib-ng)
|
||||
LIBWEBP_VERSION=$(_get_ver libwebp)
|
||||
BZIP2_VERSION=$(_get_ver bzip2)
|
||||
LIBXCB_VERSION=$(_get_ver libxcb)
|
||||
BROTLI_VERSION=$(_get_ver brotli)
|
||||
LIBAVIF_VERSION=$(_get_ver libavif)
|
||||
|
||||
function build_pkg_config {
|
||||
if [ -e pkg-config-stamp ]; then return; fi
|
||||
@ -182,7 +179,6 @@ function build_libavif {
|
||||
build_simple nasm 2.16.03 https://www.nasm.us/pub/nasm/releasebuilds/2.16.03
|
||||
fi
|
||||
|
||||
local build_type=MinSizeRel
|
||||
local build_shared=ON
|
||||
local lto=ON
|
||||
|
||||
@ -199,9 +195,6 @@ function build_libavif {
|
||||
build_shared=OFF
|
||||
fi
|
||||
else
|
||||
if [[ "$MB_ML_VER" == 2014 ]] && [[ "$PLAT" == "x86_64" ]]; then
|
||||
build_type=Release
|
||||
fi
|
||||
libavif_cmake_flags=(-DCMAKE_SHARED_LINKER_FLAGS_INIT="-Wl,--strip-all,-z,relro,-z,now")
|
||||
fi
|
||||
if [[ -n "$IOS_SDK" ]] && [[ "$PLAT" == "x86_64" ]]; then
|
||||
@ -230,7 +223,7 @@ function build_libavif {
|
||||
-DCMAKE_INTERPROCEDURAL_OPTIMIZATION=$lto \
|
||||
-DCMAKE_C_VISIBILITY_PRESET=hidden \
|
||||
-DCMAKE_CXX_VISIBILITY_PRESET=hidden \
|
||||
-DCMAKE_BUILD_TYPE=$build_type \
|
||||
-DCMAKE_BUILD_TYPE=MinSizeRel \
|
||||
"${libavif_cmake_flags[@]}" \
|
||||
$HOST_CMAKE_FLAGS . )
|
||||
|
||||
@ -267,7 +260,7 @@ function build {
|
||||
|
||||
build_simple xcb-proto 1.17.0 https://xorg.freedesktop.org/archive/individual/proto
|
||||
if [[ -n "$IS_MACOS" ]]; then
|
||||
build_simple xorgproto 2024.1 https://www.x.org/pub/individual/proto
|
||||
build_simple xorgproto 2025.1 https://www.x.org/pub/individual/proto
|
||||
build_simple libXau 1.0.12 https://www.x.org/pub/individual/lib
|
||||
build_simple libpthread-stubs 0.5 https://xcb.freedesktop.org/dist
|
||||
else
|
||||
@ -310,10 +303,6 @@ function build {
|
||||
|
||||
if [[ -n "$IS_MACOS" ]]; then
|
||||
# Custom freetype build
|
||||
if [[ -z "$IOS_SDK" ]]; then
|
||||
build_simple sed 4.9 https://mirrors.middlendian.com/gnu/sed
|
||||
fi
|
||||
|
||||
build_simple freetype $FREETYPE_VERSION https://download.savannah.gnu.org/releases/freetype tar.gz --with-harfbuzz=no
|
||||
else
|
||||
build_freetype
|
||||
|
||||
200
.github/workflows/wheels.yml
vendored
200
.github/workflows/wheels.yml
vendored
@ -10,9 +10,13 @@ on:
|
||||
# │ │ │ │ │
|
||||
- cron: "42 1 * * 0,3"
|
||||
push:
|
||||
paths:
|
||||
paths: &paths
|
||||
- ".ci/requirements-cibw.txt"
|
||||
- ".github/workflows/wheel*"
|
||||
- ".ci/requirements-sbom.txt"
|
||||
- ".github/compare-dist-sizes.py"
|
||||
- ".github/dependencies.json"
|
||||
- ".github/generate-sbom.py"
|
||||
- ".github/workflows/wheels*"
|
||||
- "pyproject.toml"
|
||||
- "setup.py"
|
||||
- "wheels/*"
|
||||
@ -21,14 +25,7 @@ on:
|
||||
tags:
|
||||
- "*"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".ci/requirements-cibw.txt"
|
||||
- ".github/workflows/wheel*"
|
||||
- "pyproject.toml"
|
||||
- "setup.py"
|
||||
- "wheels/*"
|
||||
- "winbuild/build_prepare.py"
|
||||
- "winbuild/fribidi.cmake"
|
||||
paths: *paths
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@ -39,12 +36,12 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
EXPECTED_DISTS: 91
|
||||
EXPECTED_DISTS: 66
|
||||
FORCE_COLOR: 1
|
||||
|
||||
jobs:
|
||||
build-native-wheels:
|
||||
if: github.event_name != 'schedule' || github.repository_owner == 'python-pillow'
|
||||
if: github.event_name != 'schedule' || github.event.repository.fork == false
|
||||
name: ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
@ -53,19 +50,19 @@ jobs:
|
||||
include:
|
||||
- name: "macOS 10.10 x86_64"
|
||||
platform: macos
|
||||
os: macos-15-intel
|
||||
os: macos-26-intel
|
||||
cibw_arch: x86_64
|
||||
build: "cp3{10,11}*"
|
||||
macosx_deployment_target: "10.10"
|
||||
- name: "macOS 10.13 x86_64"
|
||||
platform: macos
|
||||
os: macos-15-intel
|
||||
os: macos-26-intel
|
||||
cibw_arch: x86_64
|
||||
build: "cp3{12,13}*"
|
||||
macosx_deployment_target: "10.13"
|
||||
- name: "macOS 10.15 x86_64"
|
||||
platform: macos
|
||||
os: macos-15-intel
|
||||
os: macos-26-intel
|
||||
cibw_arch: x86_64
|
||||
build: "{cp314,pp3}*"
|
||||
macosx_deployment_target: "10.15"
|
||||
@ -74,26 +71,26 @@ jobs:
|
||||
os: macos-latest
|
||||
cibw_arch: arm64
|
||||
macosx_deployment_target: "11.0"
|
||||
- name: "manylinux2014 and musllinux x86_64"
|
||||
platform: linux
|
||||
os: ubuntu-latest
|
||||
cibw_arch: x86_64
|
||||
manylinux: "manylinux2014"
|
||||
- name: "manylinux_2_28 x86_64"
|
||||
platform: linux
|
||||
os: ubuntu-latest
|
||||
cibw_arch: x86_64
|
||||
build: "*manylinux*"
|
||||
- name: "manylinux2014 and musllinux aarch64"
|
||||
- name: "musllinux x86_64"
|
||||
platform: linux
|
||||
os: ubuntu-24.04-arm
|
||||
cibw_arch: aarch64
|
||||
manylinux: "manylinux2014"
|
||||
os: ubuntu-latest
|
||||
cibw_arch: x86_64
|
||||
build: "*musllinux*"
|
||||
- name: "manylinux_2_28 aarch64"
|
||||
platform: linux
|
||||
os: ubuntu-24.04-arm
|
||||
cibw_arch: aarch64
|
||||
build: "*manylinux*"
|
||||
- name: "musllinux aarch64"
|
||||
platform: linux
|
||||
os: ubuntu-24.04-arm
|
||||
cibw_arch: aarch64
|
||||
build: "*musllinux*"
|
||||
- name: "iOS arm64 device"
|
||||
platform: ios
|
||||
os: macos-latest
|
||||
@ -104,15 +101,15 @@ jobs:
|
||||
cibw_arch: arm64_iphonesimulator
|
||||
- name: "iOS x86_64 simulator"
|
||||
platform: ios
|
||||
os: macos-15-intel
|
||||
os: macos-26-intel
|
||||
cibw_arch: x86_64_iphonesimulator
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: true
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.x"
|
||||
|
||||
@ -127,20 +124,16 @@ jobs:
|
||||
CIBW_PLATFORM: ${{ matrix.platform }}
|
||||
CIBW_ARCHS: ${{ matrix.cibw_arch }}
|
||||
CIBW_BUILD: ${{ matrix.build }}
|
||||
CIBW_ENABLE: cpython-prerelease cpython-freethreading pypy
|
||||
CIBW_MANYLINUX_AARCH64_IMAGE: ${{ matrix.manylinux }}
|
||||
CIBW_MANYLINUX_PYPY_AARCH64_IMAGE: ${{ matrix.manylinux }}
|
||||
CIBW_MANYLINUX_PYPY_X86_64_IMAGE: ${{ matrix.manylinux }}
|
||||
CIBW_MANYLINUX_X86_64_IMAGE: ${{ matrix.manylinux }}
|
||||
CIBW_ENABLE: cpython-prerelease pypy
|
||||
MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macosx_deployment_target }}
|
||||
|
||||
- uses: actions/upload-artifact@v5
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: dist-${{ matrix.name }}
|
||||
path: ./wheelhouse/*.whl
|
||||
|
||||
windows:
|
||||
if: github.event_name != 'schedule' || github.repository_owner == 'python-pillow'
|
||||
if: github.event_name != 'schedule' || github.event.repository.fork == false
|
||||
name: Windows ${{ matrix.cibw_arch }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
@ -154,18 +147,18 @@ jobs:
|
||||
- cibw_arch: ARM64
|
||||
os: windows-11-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout extra test images
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: python-pillow/test-images
|
||||
path: Tests\test-images
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.x"
|
||||
|
||||
@ -186,29 +179,23 @@ jobs:
|
||||
|
||||
- name: Build wheels
|
||||
run: |
|
||||
setlocal EnableDelayedExpansion
|
||||
for %%f in (winbuild\build\license\*) do (
|
||||
set x=%%~nf
|
||||
rem Skip FriBiDi license, it is not included in the wheel.
|
||||
set fribidi=!x:~0,7!
|
||||
if NOT !fribidi!==fribidi (
|
||||
rem Skip imagequant license, it is not included in the wheel.
|
||||
set libimagequant=!x:~0,13!
|
||||
if NOT !libimagequant!==libimagequant (
|
||||
echo. >> LICENSE
|
||||
echo ===== %%~nf ===== >> LICENSE
|
||||
echo. >> LICENSE
|
||||
type %%f >> LICENSE
|
||||
)
|
||||
)
|
||||
)
|
||||
call winbuild\\build\\build_env.cmd
|
||||
%pythonLocation%\python.exe -m cibuildwheel . --output-dir wheelhouse
|
||||
for f in winbuild/build/license/*; do
|
||||
name=$(basename "${f%.*}")
|
||||
# Skip FriBiDi license, it is not included in the wheel.
|
||||
[[ $name == fribidi* ]] && continue
|
||||
# Skip imagequant license, it is not included in the wheel.
|
||||
[[ $name == libimagequant* ]] && continue
|
||||
echo "" >> LICENSE
|
||||
echo "===== $name =====" >> LICENSE
|
||||
echo "" >> LICENSE
|
||||
cat "$f" >> LICENSE
|
||||
done
|
||||
cmd //c "winbuild\\build\\build_env.cmd && $pythonLocation\\python.exe -m cibuildwheel . --output-dir wheelhouse"
|
||||
env:
|
||||
CIBW_ARCHS: ${{ matrix.cibw_arch }}
|
||||
CIBW_BEFORE_ALL: "{package}\\winbuild\\build\\build_dep_all.cmd"
|
||||
CIBW_CACHE_PATH: "C:\\cibw"
|
||||
CIBW_ENABLE: cpython-prerelease cpython-freethreading pypy
|
||||
CIBW_ENABLE: cpython-prerelease pypy
|
||||
CIBW_TEST_SKIP: "*-win_arm64"
|
||||
CIBW_TEST_COMMAND: 'docker run --rm
|
||||
-v {project}:C:\pillow
|
||||
@ -217,36 +204,36 @@ jobs:
|
||||
-e CI -e GITHUB_ACTIONS
|
||||
mcr.microsoft.com/windows/servercore:ltsc2022
|
||||
powershell C:\pillow\.github\workflows\wheels-test.ps1 %CD%\..\venv-test'
|
||||
shell: cmd
|
||||
shell: bash
|
||||
|
||||
- name: Upload wheels
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: dist-windows-${{ matrix.cibw_arch }}
|
||||
path: ./wheelhouse/*.whl
|
||||
|
||||
- name: Upload fribidi.dll
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: fribidi-windows-${{ matrix.cibw_arch }}
|
||||
path: winbuild\build\bin\fribidi*
|
||||
|
||||
sdist:
|
||||
if: github.event_name != 'schedule' || github.repository_owner == 'python-pillow'
|
||||
if: github.event_name != 'schedule' || github.event.repository.fork == false
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.x"
|
||||
|
||||
- run: make sdist
|
||||
|
||||
- uses: actions/upload-artifact@v5
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: dist-sdist
|
||||
path: dist/*.tar.gz
|
||||
@ -256,7 +243,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Count dists
|
||||
steps:
|
||||
- uses: actions/download-artifact@v6
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: dist-*
|
||||
path: dist
|
||||
@ -269,25 +256,98 @@ 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.repository_owner == 'python-pillow' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
|
||||
if: github.event.repository.fork == false && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
|
||||
needs: count-dists
|
||||
runs-on: ubuntu-latest
|
||||
name: Upload wheels to scientific-python-nightly-wheels
|
||||
environment:
|
||||
name: release-anaconda
|
||||
url: https://anaconda.org/channels/scientific-python-nightly-wheels/packages/pillow/overview
|
||||
steps:
|
||||
- uses: actions/download-artifact@v6
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: dist-!(sdist)*
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
- name: Upload wheels to scientific-python-nightly-wheels
|
||||
uses: scientific-python/upload-nightly-action@b36e8c0c10dbcfd2e05bf95f17ef8c14fd708dbf # 0.6.2
|
||||
uses: scientific-python/upload-nightly-action@e76cfec8a4611fd02808a801b0ff5a7d7c1b2d99 # 0.6.4
|
||||
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.repository_owner == 'python-pillow' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
||||
if: github.event.repository.fork == false && github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
||||
needs: count-dists
|
||||
runs-on: ubuntu-latest
|
||||
name: Upload release to PyPI
|
||||
@ -297,12 +357,12 @@ jobs:
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@v6
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: dist-*
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
with:
|
||||
attestations: true
|
||||
|
||||
8
.github/zizmor.yml
vendored
8
.github/zizmor.yml
vendored
@ -1,8 +0,0 @@
|
||||
# https://docs.zizmor.sh/configuration/
|
||||
rules:
|
||||
obfuscation:
|
||||
disable: true
|
||||
unpinned-uses:
|
||||
config:
|
||||
policies:
|
||||
"*": ref-pin
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -97,3 +97,6 @@ pillow-test-images.zip
|
||||
|
||||
# pyinstaller
|
||||
*.spec
|
||||
|
||||
# Generated SBOM
|
||||
pillow-*.cdx.json
|
||||
|
||||
@ -1,30 +1,30 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.14.7
|
||||
rev: v0.15.12
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
args: [--exit-non-zero-on-fix]
|
||||
|
||||
- repo: https://github.com/psf/black-pre-commit-mirror
|
||||
rev: 25.11.0
|
||||
rev: 26.3.1
|
||||
hooks:
|
||||
- id: black
|
||||
|
||||
- repo: https://github.com/PyCQA/bandit
|
||||
rev: 1.9.2
|
||||
rev: 1.9.4
|
||||
hooks:
|
||||
- id: bandit
|
||||
args: [--severity-level=high]
|
||||
files: ^src/
|
||||
|
||||
- repo: https://github.com/Lucas-C/pre-commit-hooks
|
||||
rev: v1.5.5
|
||||
rev: v1.5.6
|
||||
hooks:
|
||||
- id: remove-tabs
|
||||
exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.gd$|\.opt$)
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-clang-format
|
||||
rev: v21.1.6
|
||||
rev: v22.1.4
|
||||
hooks:
|
||||
- id: clang-format
|
||||
types: [c]
|
||||
@ -38,6 +38,7 @@ repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: check-case-conflict
|
||||
- id: check-executables-have-shebangs
|
||||
- id: check-shebang-scripts-are-executable
|
||||
- id: check-merge-conflict
|
||||
@ -47,18 +48,20 @@ repos:
|
||||
args: [--allow-multiple-documents]
|
||||
- id: end-of-file-fixer
|
||||
exclude: ^Tests/images/
|
||||
- id: file-contents-sorter
|
||||
files: .github/workflows/Brewfile
|
||||
- id: trailing-whitespace
|
||||
exclude: ^\.github/.*TEMPLATE|^Tests/(fonts|images)/
|
||||
|
||||
- repo: https://github.com/python-jsonschema/check-jsonschema
|
||||
rev: 0.35.0
|
||||
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.18.0
|
||||
rev: v1.24.1
|
||||
hooks:
|
||||
- id: zizmor
|
||||
|
||||
@ -68,18 +71,18 @@ repos:
|
||||
- id: sphinx-lint
|
||||
|
||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||
rev: v2.11.1
|
||||
rev: v2.21.1
|
||||
hooks:
|
||||
- id: pyproject-fmt
|
||||
|
||||
- repo: https://github.com/abravalheri/validate-pyproject
|
||||
rev: v0.24.1
|
||||
rev: v0.25
|
||||
hooks:
|
||||
- id: validate-pyproject
|
||||
additional_dependencies: [tomli, trove-classifiers>=2024.10.12]
|
||||
additional_dependencies: [trove-classifiers>=2024.10.12]
|
||||
|
||||
- repo: https://github.com/tox-dev/tox-ini-fmt
|
||||
rev: 1.7.0
|
||||
rev: 1.7.1
|
||||
hooks:
|
||||
- id: tox-ini-fmt
|
||||
|
||||
|
||||
2
LICENSE
2
LICENSE
@ -5,7 +5,7 @@ The Python Imaging Library (PIL) is
|
||||
|
||||
Pillow is the friendly PIL fork. It is
|
||||
|
||||
Copyright © 2010 by Jeffrey A. Clark and contributors
|
||||
Copyright © 2010 by Jeffrey 'Alex' Clark and contributors
|
||||
|
||||
Like PIL, Pillow is licensed under the open source MIT-CMU License:
|
||||
|
||||
|
||||
14
README.md
14
README.md
@ -6,11 +6,13 @@
|
||||
|
||||
## Python Imaging Library (Fork)
|
||||
|
||||
Pillow is the friendly PIL fork by [Jeffrey A. Clark and
|
||||
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.
|
||||
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).
|
||||
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)
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@ -106,4 +108,8 @@ The core image library is designed for fast access to data stored in a few basic
|
||||
|
||||
## Report a vulnerability
|
||||
|
||||
To report a security vulnerability, please follow the procedure described in the [Tidelift security policy](https://tidelift.com/docs/security).
|
||||
To report sensitive vulnerability information, report it [privately on GitHub](https://github.com/python-pillow/Pillow/security/advisories/new).
|
||||
|
||||
If you cannot use GitHub, use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure.
|
||||
|
||||
DO NOT report sensitive vulnerability information in public.
|
||||
|
||||
@ -19,6 +19,7 @@ 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`.
|
||||
@ -38,6 +39,7 @@ 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
|
||||
|
||||
|
||||
@ -1,9 +1,17 @@
|
||||
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:
|
||||
@ -16,6 +24,25 @@ 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",
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
STARTFONT
|
||||
FONT ÿ
|
||||
SIZE 10
|
||||
FONTBOUNDINGBOX
|
||||
CHARS
|
||||
FONTBOUNDINGBOX 1 1 0 0
|
||||
CHARS 1
|
||||
STARTCHAR
|
||||
ENCODING
|
||||
ENCODING 65
|
||||
BBX 2 5
|
||||
ENDCHAR
|
||||
ENDFONT
|
||||
|
||||
BIN
Tests/images/imagedraw_rounded_rectangle_radius.png
Normal file
BIN
Tests/images/imagedraw_rounded_rectangle_radius.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 456 B |
Binary file not shown.
|
Before Width: | Height: | Size: 9.0 KiB |
BIN
Tests/images/pal8rletrns.png
Normal file
BIN
Tests/images/pal8rletrns.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
BIN
Tests/images/psd-oob-write-overflow.psd
Normal file
BIN
Tests/images/psd-oob-write-overflow.psd
Normal file
Binary file not shown.
BIN
Tests/images/separate_planar_extra_samples.tiff
Normal file
BIN
Tests/images/separate_planar_extra_samples.tiff
Normal file
Binary file not shown.
BIN
Tests/images/trailer_loop.pdf
Normal file
BIN
Tests/images/trailer_loop.pdf
Normal file
Binary file not shown.
@ -213,7 +213,7 @@ INT32 = DataShape(
|
||||
),
|
||||
)
|
||||
def test_fromarray(mode: str, data_tp: DataShape, mask: list[int] | None) -> None:
|
||||
(dtype, elt, elts_per_pixel) = data_tp
|
||||
dtype, elt, elts_per_pixel = data_tp
|
||||
|
||||
ct_pixels = TEST_IMAGE_SIZE[0] * TEST_IMAGE_SIZE[1]
|
||||
if dtype == fl_uint8_4_type:
|
||||
@ -239,7 +239,7 @@ def test_fromarray(mode: str, data_tp: DataShape, mask: list[int] | None) -> Non
|
||||
)
|
||||
@pytest.mark.parametrize("data_tp", (UINT32, INT32))
|
||||
def test_from_int32array(mode: str, mask: list[int] | None, data_tp: DataShape) -> None:
|
||||
(dtype, elt, elts_per_pixel) = data_tp
|
||||
dtype, elt, elts_per_pixel = data_tp
|
||||
|
||||
ct_pixels = TEST_IMAGE_SIZE[0] * TEST_IMAGE_SIZE[1]
|
||||
arr = Array([elt] * (ct_pixels * elts_per_pixel), type=dtype)
|
||||
|
||||
@ -68,7 +68,7 @@ def test_multiblock_l_image() -> None:
|
||||
img = Image.new("L", size, 128)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
(schema, arr) = img.__arrow_c_array__()
|
||||
schema, arr = img.__arrow_c_array__()
|
||||
|
||||
|
||||
def test_multiblock_rgba_image() -> None:
|
||||
@ -79,7 +79,7 @@ def test_multiblock_rgba_image() -> None:
|
||||
img = Image.new("RGBA", size, (128, 127, 126, 125))
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
(schema, arr) = img.__arrow_c_array__()
|
||||
schema, arr = img.__arrow_c_array__()
|
||||
|
||||
|
||||
def test_multiblock_l_schema() -> None:
|
||||
@ -114,7 +114,7 @@ def test_singleblock_l_image() -> None:
|
||||
img = Image.new("L", size, 128)
|
||||
assert img.im.isblock()
|
||||
|
||||
(schema, arr) = img.__arrow_c_array__()
|
||||
schema, arr = img.__arrow_c_array__()
|
||||
assert schema
|
||||
assert arr
|
||||
|
||||
@ -130,7 +130,7 @@ def test_singleblock_rgba_image() -> None:
|
||||
img = Image.new("RGBA", size, (128, 127, 126, 125))
|
||||
assert img.im.isblock()
|
||||
|
||||
(schema, arr) = img.__arrow_c_array__()
|
||||
schema, arr = img.__arrow_c_array__()
|
||||
assert schema
|
||||
assert arr
|
||||
Image.core.set_use_block_allocator(0)
|
||||
|
||||
@ -56,7 +56,7 @@ def test_questionable() -> None:
|
||||
im.load()
|
||||
if os.path.basename(f) not in supported:
|
||||
print(f"Please add {f} to the partially supported bmp specs.")
|
||||
except Exception: # as msg:
|
||||
except Exception: # noqa: PERF203
|
||||
if os.path.basename(f) in supported:
|
||||
raise
|
||||
|
||||
@ -106,7 +106,7 @@ def test_good() -> None:
|
||||
|
||||
assert_image_similar(im_converted, compare_converted, 5)
|
||||
|
||||
except Exception as msg:
|
||||
except Exception as msg: # noqa: PERF203
|
||||
# there are three here that are unsupported:
|
||||
unsupported = (
|
||||
os.path.join(base, "g", "rgb32bf.bmp"),
|
||||
|
||||
@ -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.02
|
||||
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, 8.62)
|
||||
assert_image_similar(reloaded, im, 9.39)
|
||||
|
||||
def test_AvifEncoder_with_invalid_args(self) -> None:
|
||||
"""
|
||||
@ -461,12 +461,9 @@ class TestFileAvif:
|
||||
@pytest.mark.parametrize(
|
||||
"advanced",
|
||||
[
|
||||
{
|
||||
"aq-mode": "1",
|
||||
"enable-chroma-deltaq": "1",
|
||||
},
|
||||
(("aq-mode", "1"), ("enable-chroma-deltaq", "1")),
|
||||
[("aq-mode", "1"), ("enable-chroma-deltaq", "1")],
|
||||
{"tune": "psnr"},
|
||||
(("tune", "psnr"),),
|
||||
[("tune", "psnr")],
|
||||
],
|
||||
)
|
||||
def test_encoder_advanced_codec_options(
|
||||
|
||||
@ -42,7 +42,7 @@ def test_fallback_if_mmap_errors() -> None:
|
||||
# This image has been truncated,
|
||||
# so that the buffer is not large enough when using mmap
|
||||
with Image.open("Tests/images/mmap_error.bmp") as im:
|
||||
assert_image_equal_tofile(im, "Tests/images/pal8_offset.bmp")
|
||||
assert_image_equal_tofile(im, "Tests/images/bmp/g/pal8.bmp")
|
||||
|
||||
|
||||
def test_save_to_bytes() -> None:
|
||||
@ -221,6 +221,11 @@ def test_rle8_eof(file_name: str, length: int) -> None:
|
||||
im.load()
|
||||
|
||||
|
||||
def test_rle_delta() -> None:
|
||||
with Image.open("Tests/images/bmp/q/pal8rletrns.bmp") as im:
|
||||
assert_image_equal_tofile(im, "Tests/images/pal8rletrns.png")
|
||||
|
||||
|
||||
def test_unsupported_bmp_bitfields_layout() -> None:
|
||||
fp = io.BytesIO(
|
||||
o32(40) # header size
|
||||
@ -233,11 +238,21 @@ def test_unsupported_bmp_bitfields_layout() -> None:
|
||||
Image.open(fp)
|
||||
|
||||
|
||||
def test_offset() -> None:
|
||||
# This image has been hexedited
|
||||
# to exclude the palette size from the pixel data offset
|
||||
with Image.open("Tests/images/pal8_offset.bmp") as im:
|
||||
assert_image_equal_tofile(im, "Tests/images/bmp/g/pal8.bmp")
|
||||
@pytest.mark.parametrize(
|
||||
"offset, path",
|
||||
(
|
||||
(26, "pal8os2.bmp"),
|
||||
(54, "pal8.bmp"),
|
||||
),
|
||||
)
|
||||
def test_offset(offset: int, path: str) -> None:
|
||||
image_path = "Tests/images/bmp/g/" + path
|
||||
# Exclude the palette size from the pixel data offset
|
||||
with open(image_path, "rb") as fp:
|
||||
data = fp.read()
|
||||
data = data[:10] + o32(offset) + data[14:]
|
||||
with Image.open(io.BytesIO(data)) as im:
|
||||
assert_image_equal_tofile(im, image_path)
|
||||
|
||||
|
||||
def test_use_raw_alpha(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
@ -179,9 +179,7 @@ def test_iter(bytesmode: bool) -> None:
|
||||
container = ContainerIO.ContainerIO(fh, 0, 120)
|
||||
|
||||
# Act
|
||||
data = []
|
||||
for line in container:
|
||||
data.append(line)
|
||||
data = list(container)
|
||||
|
||||
# Assert
|
||||
if bytesmode:
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@ -281,6 +282,11 @@ 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")
|
||||
|
||||
|
||||
@ -310,6 +310,14 @@ def test_roundtrip_save_all_1(tmp_path: Path) -> None:
|
||||
assert reloaded.getpixel((0, 0)) == 255
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size", ((0, 1), (1, 0), (0, 0)))
|
||||
def test_save_zero(size: tuple[int, int]) -> None:
|
||||
b = BytesIO()
|
||||
im = Image.new("RGB", size)
|
||||
with pytest.raises(ValueError, match="cannot write empty image"):
|
||||
im.save(b, "GIF")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path, mode",
|
||||
(
|
||||
@ -399,7 +407,7 @@ def test_save_netpbm_bmp_mode(tmp_path: Path) -> None:
|
||||
b = BytesIO()
|
||||
GifImagePlugin._save_netpbm(img_rgb, b, tempfile)
|
||||
with Image.open(tempfile) as reloaded:
|
||||
assert_image_similar(img_rgb, reloaded.convert("RGB"), 0)
|
||||
assert_image_equal(img_rgb, reloaded.convert("RGB"))
|
||||
|
||||
|
||||
@pytest.mark.skipif(not netpbm_available(), reason="Netpbm not available")
|
||||
@ -411,7 +419,7 @@ def test_save_netpbm_l_mode(tmp_path: Path) -> None:
|
||||
b = BytesIO()
|
||||
GifImagePlugin._save_netpbm(img_l, b, tempfile)
|
||||
with Image.open(tempfile) as reloaded:
|
||||
assert_image_similar(img_l, reloaded.convert("L"), 0)
|
||||
assert_image_equal(img_l, reloaded.convert("L"))
|
||||
|
||||
|
||||
def test_seek() -> None:
|
||||
@ -1433,7 +1441,7 @@ def test_getdata(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# with open('Tests/images/gif_header_data.pkl', 'wb') as f:
|
||||
# pickle.dump((h, d), f, 1)
|
||||
with open("Tests/images/gif_header_data.pkl", "rb") as f:
|
||||
(h_target, d_target) = pickle.load(f)
|
||||
h_target, d_target = pickle.load(f)
|
||||
|
||||
assert h == h_target
|
||||
assert d == d_target
|
||||
|
||||
@ -85,7 +85,7 @@ class TestFileJpeg:
|
||||
def test_zero(self, size: tuple[int, int], tmp_path: Path) -> None:
|
||||
f = tmp_path / "temp.jpg"
|
||||
im = Image.new("RGB", size)
|
||||
with pytest.raises(ValueError):
|
||||
with pytest.raises(ValueError, match="cannot write empty image"):
|
||||
im.save(f)
|
||||
|
||||
def test_app(self) -> None:
|
||||
@ -590,9 +590,7 @@ class TestFileJpeg:
|
||||
assert im2.quantization == {0: bounds_qtable}
|
||||
|
||||
# values from wizard.txt in jpeg9-a src package.
|
||||
standard_l_qtable = [
|
||||
int(s)
|
||||
for s in """
|
||||
standard_l_qtable = [int(s) for s in """
|
||||
16 11 10 16 24 40 51 61
|
||||
12 12 14 19 26 58 60 55
|
||||
14 13 16 24 40 57 69 56
|
||||
@ -601,14 +599,9 @@ class TestFileJpeg:
|
||||
24 35 55 64 81 104 113 92
|
||||
49 64 78 87 103 121 120 101
|
||||
72 92 95 98 112 100 103 99
|
||||
""".split(
|
||||
None
|
||||
)
|
||||
]
|
||||
""".split(None)]
|
||||
|
||||
standard_chrominance_qtable = [
|
||||
int(s)
|
||||
for s in """
|
||||
standard_chrominance_qtable = [int(s) for s in """
|
||||
17 18 24 47 99 99 99 99
|
||||
18 21 26 66 99 99 99 99
|
||||
24 26 56 99 99 99 99 99
|
||||
@ -617,10 +610,7 @@ class TestFileJpeg:
|
||||
99 99 99 99 99 99 99 99
|
||||
99 99 99 99 99 99 99 99
|
||||
99 99 99 99 99 99 99 99
|
||||
""".split(
|
||||
None
|
||||
)
|
||||
]
|
||||
""".split(None)]
|
||||
|
||||
for quality in range(101):
|
||||
qtable_from_qtable_quality = self.roundtrip(
|
||||
|
||||
@ -148,6 +148,22 @@ def test_prog_res_rt(card: ImageFile.ImageFile) -> None:
|
||||
assert_image_equal(im, card)
|
||||
|
||||
|
||||
def test_unknown_progression(tmp_path: Path) -> None:
|
||||
outfile = tmp_path / "temp.jp2"
|
||||
|
||||
im = Image.new("1", (1, 1))
|
||||
with pytest.raises(ValueError, match="unknown progression"):
|
||||
im.save(outfile, progression="invalid")
|
||||
|
||||
|
||||
def test_unknown_cinema_mode(tmp_path: Path) -> None:
|
||||
outfile = tmp_path / "temp.jp2"
|
||||
|
||||
im = Image.new("1", (1, 1))
|
||||
with pytest.raises(ValueError, match="unknown cinema mode"):
|
||||
im.save(outfile, cinema_mode="invalid")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("num_resolutions", range(2, 6))
|
||||
def test_default_num_resolutions(
|
||||
card: ImageFile.ImageFile, num_resolutions: int
|
||||
@ -162,9 +178,9 @@ def test_default_num_resolutions(
|
||||
|
||||
def test_reduce() -> None:
|
||||
with Image.open("Tests/images/test-card-lossless.jp2") as im:
|
||||
assert callable(im.reduce)
|
||||
assert isinstance(im, Jpeg2KImagePlugin.Jpeg2KImageFile)
|
||||
|
||||
im.reduce = 2 # type: ignore[assignment, method-assign]
|
||||
im.reduce = 2
|
||||
assert im.reduce == 2
|
||||
|
||||
im.load()
|
||||
@ -440,11 +456,19 @@ def test_pclr() -> None:
|
||||
assert len(im.palette.colors) == 256
|
||||
assert im.palette.colors[(255, 255, 255)] == 0
|
||||
|
||||
for enumcs in (0, 15, 17):
|
||||
with open(f"{EXTRA_DIR}/issue104_jpxstream.jp2", "rb") as fp:
|
||||
data = bytearray(fp.read())
|
||||
data[114:115] = bytes([enumcs])
|
||||
with Image.open(BytesIO(data)) as im:
|
||||
assert im.mode == "L"
|
||||
|
||||
with Image.open(
|
||||
f"{EXTRA_DIR}/147af3f1083de4393666b7d99b01b58b_signal_sigsegv_130c531_6155_5136.jp2"
|
||||
) as im:
|
||||
assert im.mode == "P"
|
||||
assert im.palette is not None
|
||||
assert im.palette.mode == "CMYK"
|
||||
assert len(im.palette.colors) == 139
|
||||
assert im.palette.colors[(0, 0, 0, 0)] == 0
|
||||
|
||||
|
||||
@ -224,10 +224,7 @@ class TestFileLibTiff(LibTiffTestCase):
|
||||
with Image.open("Tests/images/hopper_g4.tif") as im:
|
||||
assert isinstance(im, TiffImagePlugin.TiffImageFile)
|
||||
for tag in im.tag_v2:
|
||||
try:
|
||||
del core_items[tag]
|
||||
except KeyError:
|
||||
pass
|
||||
core_items.pop(tag, None)
|
||||
del core_items[320] # colormap is special, tested below
|
||||
|
||||
# Type codes:
|
||||
@ -738,7 +735,7 @@ class TestFileLibTiff(LibTiffTestCase):
|
||||
buffer_io.seek(0)
|
||||
|
||||
with Image.open(buffer_io) as saved_im:
|
||||
assert_image_similar(pilim, saved_im, 0)
|
||||
assert_image_equal(pilim, saved_im)
|
||||
|
||||
save_bytesio()
|
||||
save_bytesio("raw")
|
||||
@ -1058,6 +1055,15 @@ class TestFileLibTiff(LibTiffTestCase):
|
||||
with Image.open("Tests/images/tiff_strip_planar_16bit_RGBa.tiff") as im:
|
||||
assert_image_equal_tofile(im, "Tests/images/tiff_16bit_RGBa_target.png")
|
||||
|
||||
def test_separate_planar_extra_samples(self, tmp_path: Path) -> None:
|
||||
out = tmp_path / "temp.tif"
|
||||
with Image.open("Tests/images/separate_planar_extra_samples.tiff") as im:
|
||||
assert im.mode == "L"
|
||||
|
||||
im.save(out)
|
||||
with Image.open(out) as reloaded:
|
||||
assert reloaded.mode == "L"
|
||||
|
||||
@pytest.mark.parametrize("compression", (None, "jpeg"))
|
||||
def test_block_tile_tags(self, compression: str | None, tmp_path: Path) -> None:
|
||||
im = hopper()
|
||||
@ -1244,7 +1250,7 @@ class TestFileLibTiff(LibTiffTestCase):
|
||||
def test_save_zero(self, compression: str | None, tmp_path: Path) -> None:
|
||||
im = Image.new("RGB", (0, 0))
|
||||
out = tmp_path / "temp.tif"
|
||||
with pytest.raises(SystemError):
|
||||
with pytest.raises(ValueError, match="cannot write empty image"):
|
||||
im.save(out, compression=compression)
|
||||
|
||||
def test_save_many_compressed(self, tmp_path: Path) -> None:
|
||||
|
||||
@ -6,7 +6,14 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from PIL import Image, ImageFile, JpegImagePlugin, MpoImagePlugin
|
||||
from PIL import (
|
||||
Image,
|
||||
ImageFile,
|
||||
JpegImagePlugin,
|
||||
MpoImagePlugin,
|
||||
TiffImagePlugin,
|
||||
_binary,
|
||||
)
|
||||
|
||||
from .helper import (
|
||||
assert_image_equal,
|
||||
@ -145,6 +152,32 @@ def test_parallax() -> None:
|
||||
assert exif.get_ifd(0x927C)[0xB211] == -3.125
|
||||
|
||||
|
||||
def test_truncated_makernote() -> None:
|
||||
def check(ifd: TiffImagePlugin.ImageFileDirectory_v2) -> None:
|
||||
fp = BytesIO()
|
||||
ifd.save(fp)
|
||||
|
||||
e = Image.Exif()
|
||||
e.load(fp.getvalue())
|
||||
assert e.get_ifd(37500) == {}
|
||||
|
||||
# Nintendo
|
||||
ifd = TiffImagePlugin.ImageFileDirectory_v2()
|
||||
ifd[271] = "Nintendo"
|
||||
ifd[34665] = {37500: b" "}
|
||||
check(ifd)
|
||||
|
||||
# Fujifilm
|
||||
for data in (
|
||||
b"FUJIFILM",
|
||||
b"FUJIFILM" + _binary.o32le(50),
|
||||
b"FUJIFILM" + _binary.o32le(0),
|
||||
):
|
||||
ifd = TiffImagePlugin.ImageFileDirectory_v2()
|
||||
ifd[34665] = {37500: data}
|
||||
check(ifd)
|
||||
|
||||
|
||||
def test_reload_exif_after_seek() -> None:
|
||||
with Image.open("Tests/images/sugarshack.mpo") as im:
|
||||
exif = im.getexif()
|
||||
|
||||
@ -37,6 +37,14 @@ def test_sanity(tmp_path: Path) -> None:
|
||||
im.save(f)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size", ((0, 1), (1, 0), (0, 0)))
|
||||
def test_save_zero(size: tuple[int, int]) -> None:
|
||||
b = io.BytesIO()
|
||||
im = Image.new("1", size)
|
||||
with pytest.raises(ValueError):
|
||||
im.save(b, "PCX")
|
||||
|
||||
|
||||
def test_p_4_planes() -> None:
|
||||
with Image.open("Tests/images/p_4_planes.pcx") as im:
|
||||
assert im.getpixel((0, 0)) == 3
|
||||
@ -119,36 +127,36 @@ def test_large_count(tmp_path: Path) -> None:
|
||||
_roundtrip(tmp_path, im)
|
||||
|
||||
|
||||
def _test_buffer_overflow(tmp_path: Path, im: Image.Image, size: int = 1024) -> None:
|
||||
_last = ImageFile.MAXBLOCK
|
||||
ImageFile.MAXBLOCK = size
|
||||
try:
|
||||
_roundtrip(tmp_path, im)
|
||||
finally:
|
||||
ImageFile.MAXBLOCK = _last
|
||||
def _test_buffer_overflow(
|
||||
tmp_path: Path, im: Image.Image, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(ImageFile, "MAXBLOCK", 1024)
|
||||
_roundtrip(tmp_path, im)
|
||||
|
||||
|
||||
def test_break_in_count_overflow(tmp_path: Path) -> None:
|
||||
def test_break_in_count_overflow(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
im = Image.new("L", (256, 5))
|
||||
px = im.load()
|
||||
assert px is not None
|
||||
for y in range(4):
|
||||
for x in range(256):
|
||||
px[x, y] = x % 128
|
||||
_test_buffer_overflow(tmp_path, im)
|
||||
_test_buffer_overflow(tmp_path, im, monkeypatch)
|
||||
|
||||
|
||||
def test_break_one_in_loop(tmp_path: Path) -> None:
|
||||
def test_break_one_in_loop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
im = Image.new("L", (256, 5))
|
||||
px = im.load()
|
||||
assert px is not None
|
||||
for y in range(5):
|
||||
for x in range(256):
|
||||
px[x, y] = x % 128
|
||||
_test_buffer_overflow(tmp_path, im)
|
||||
_test_buffer_overflow(tmp_path, im, monkeypatch)
|
||||
|
||||
|
||||
def test_break_many_in_loop(tmp_path: Path) -> None:
|
||||
def test_break_many_in_loop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
im = Image.new("L", (256, 5))
|
||||
px = im.load()
|
||||
assert px is not None
|
||||
@ -157,10 +165,10 @@ def test_break_many_in_loop(tmp_path: Path) -> None:
|
||||
px[x, y] = x % 128
|
||||
for x in range(8):
|
||||
px[x, 4] = 16
|
||||
_test_buffer_overflow(tmp_path, im)
|
||||
_test_buffer_overflow(tmp_path, im, monkeypatch)
|
||||
|
||||
|
||||
def test_break_one_at_end(tmp_path: Path) -> None:
|
||||
def test_break_one_at_end(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
im = Image.new("L", (256, 5))
|
||||
px = im.load()
|
||||
assert px is not None
|
||||
@ -168,10 +176,10 @@ def test_break_one_at_end(tmp_path: Path) -> None:
|
||||
for x in range(256):
|
||||
px[x, y] = x % 128
|
||||
px[0, 3] = 128 + 64
|
||||
_test_buffer_overflow(tmp_path, im)
|
||||
_test_buffer_overflow(tmp_path, im, monkeypatch)
|
||||
|
||||
|
||||
def test_break_many_at_end(tmp_path: Path) -> None:
|
||||
def test_break_many_at_end(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
im = Image.new("L", (256, 5))
|
||||
px = im.load()
|
||||
assert px is not None
|
||||
@ -181,10 +189,10 @@ def test_break_many_at_end(tmp_path: Path) -> None:
|
||||
for x in range(4):
|
||||
px[x * 2, 3] = 128 + 64
|
||||
px[x + 256 - 4, 3] = 0
|
||||
_test_buffer_overflow(tmp_path, im)
|
||||
_test_buffer_overflow(tmp_path, im, monkeypatch)
|
||||
|
||||
|
||||
def test_break_padding(tmp_path: Path) -> None:
|
||||
def test_break_padding(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
im = Image.new("L", (257, 5))
|
||||
px = im.load()
|
||||
assert px is not None
|
||||
@ -193,4 +201,4 @@ def test_break_padding(tmp_path: Path) -> None:
|
||||
px[x, y] = x % 128
|
||||
for x in range(5):
|
||||
px[x, 3] = 0
|
||||
_test_buffer_overflow(tmp_path, im)
|
||||
_test_buffer_overflow(tmp_path, im, monkeypatch)
|
||||
|
||||
@ -4,7 +4,7 @@ import re
|
||||
import sys
|
||||
import warnings
|
||||
import zlib
|
||||
from io import BytesIO
|
||||
from io import BytesIO, TextIOWrapper
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any, cast
|
||||
@ -502,8 +502,9 @@ class TestFilePng:
|
||||
im = roundtrip(im)
|
||||
assert im.info["transparency"] == (248, 248, 248)
|
||||
|
||||
im = roundtrip(im, transparency=(0, 1, 2))
|
||||
assert im.info["transparency"] == (0, 1, 2)
|
||||
for transparency in ((0, 1, 2), [0, 1, 2]):
|
||||
im = roundtrip(im, transparency=transparency)
|
||||
assert im.info["transparency"] == (0, 1, 2)
|
||||
|
||||
def test_trns_p(self, tmp_path: Path) -> None:
|
||||
# Check writing a transparency of 0, issue #528
|
||||
@ -518,6 +519,36 @@ 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"
|
||||
@ -654,21 +685,17 @@ class TestFilePng:
|
||||
with pytest.raises(SyntaxError, match="Unknown compression method"):
|
||||
PngImagePlugin.PngImageFile("Tests/images/unknown_compression_method.png")
|
||||
|
||||
def test_padded_idat(self) -> None:
|
||||
def test_padded_idat(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# This image has been manually hexedited
|
||||
# so that the IDAT chunk has padding at the end
|
||||
# Set MAXBLOCK to the length of the actual data
|
||||
# so that the decoder finishes reading before the chunk ends
|
||||
MAXBLOCK = ImageFile.MAXBLOCK
|
||||
ImageFile.MAXBLOCK = 45
|
||||
ImageFile.LOAD_TRUNCATED_IMAGES = True
|
||||
monkeypatch.setattr(ImageFile, "MAXBLOCK", 45)
|
||||
monkeypatch.setattr(ImageFile, "LOAD_TRUNCATED_IMAGES", True)
|
||||
|
||||
with Image.open("Tests/images/padded_idat.png") as im:
|
||||
im.load()
|
||||
|
||||
ImageFile.MAXBLOCK = MAXBLOCK
|
||||
ImageFile.LOAD_TRUNCATED_IMAGES = False
|
||||
|
||||
assert_image_equal_tofile(im, "Tests/images/bw_gradient.png")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@ -711,6 +738,16 @@ class TestFilePng:
|
||||
assert reloaded.png.im_palette is not None
|
||||
assert len(reloaded.png.im_palette[1]) == 3
|
||||
|
||||
def test_plte_cmyk(self, tmp_path: Path) -> None:
|
||||
im = Image.new("P", (1, 1))
|
||||
im.putpalette((0, 100, 150, 200), "CMYK")
|
||||
|
||||
out = tmp_path / "temp.png"
|
||||
im.save(out)
|
||||
|
||||
with Image.open(out) as reloaded:
|
||||
assert reloaded.convert("CMYK").getpixel((0, 0)) == (200, 222, 232, 0)
|
||||
|
||||
def test_getxmp(self) -> None:
|
||||
with Image.open("Tests/images/color_snakes.png") as im:
|
||||
if ElementTree is None:
|
||||
@ -815,19 +852,15 @@ class TestFilePng:
|
||||
@pytest.mark.parametrize("buffer", (True, False))
|
||||
def test_save_stdout(self, buffer: bool, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
class MyStdOut:
|
||||
buffer = BytesIO()
|
||||
|
||||
mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO()
|
||||
fp = BytesIO()
|
||||
mystdout = TextIOWrapper(fp) if buffer else fp
|
||||
|
||||
monkeypatch.setattr(sys, "stdout", mystdout)
|
||||
|
||||
with Image.open(TEST_PNG_FILE) as im:
|
||||
im.save(sys.stdout, "PNG") # type: ignore[arg-type]
|
||||
|
||||
if isinstance(mystdout, MyStdOut):
|
||||
mystdout = mystdout.buffer
|
||||
with Image.open(mystdout) as reloaded:
|
||||
with Image.open(fp) as reloaded:
|
||||
assert_image_equal_tofile(reloaded, TEST_PNG_FILE)
|
||||
|
||||
def test_truncated_end_chunk(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from io import BytesIO
|
||||
from io import BytesIO, TextIOWrapper
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@ -381,17 +381,13 @@ def test_mimetypes(tmp_path: Path) -> None:
|
||||
@pytest.mark.parametrize("buffer", (True, False))
|
||||
def test_save_stdout(buffer: bool, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
class MyStdOut:
|
||||
buffer = BytesIO()
|
||||
|
||||
mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO()
|
||||
fp = BytesIO()
|
||||
mystdout = TextIOWrapper(fp) if buffer else fp
|
||||
|
||||
monkeypatch.setattr(sys, "stdout", mystdout)
|
||||
|
||||
with Image.open(TEST_FILE) as im:
|
||||
im.save(sys.stdout, "PPM") # type: ignore[arg-type]
|
||||
|
||||
if isinstance(mystdout, MyStdOut):
|
||||
mystdout = mystdout.buffer
|
||||
with Image.open(mystdout) as reloaded:
|
||||
with Image.open(fp) as reloaded:
|
||||
assert_image_equal_tofile(reloaded, TEST_FILE)
|
||||
|
||||
@ -1,12 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
|
||||
from PIL import Image, PsdImagePlugin
|
||||
|
||||
from .helper import assert_image_equal_tofile, assert_image_similar, hopper, is_pypy
|
||||
from .helper import (
|
||||
assert_image_equal_tofile,
|
||||
assert_image_similar,
|
||||
hopper,
|
||||
is_pypy,
|
||||
)
|
||||
|
||||
test_file = "Tests/images/hopper.psd"
|
||||
|
||||
@ -85,6 +91,11 @@ def test_eoferror() -> None:
|
||||
# Test that seeking to the last frame does not raise an error
|
||||
im.seek(n_frames - 1)
|
||||
|
||||
# Test seeking past the last frame without calling n_frames first
|
||||
with Image.open(test_file) as im:
|
||||
with pytest.raises(EOFError):
|
||||
im.seek(3)
|
||||
|
||||
|
||||
def test_seek_tell() -> None:
|
||||
with Image.open(test_file) as im:
|
||||
@ -100,7 +111,7 @@ def test_seek_tell() -> None:
|
||||
|
||||
im.seek(2)
|
||||
layer_number = im.tell()
|
||||
assert layer_number == 2
|
||||
assert layer_number == 2
|
||||
|
||||
|
||||
def test_seek_eoferror() -> None:
|
||||
@ -138,7 +149,7 @@ def test_icc_profile() -> None:
|
||||
assert "icc_profile" in im.info
|
||||
|
||||
icc_profile = im.info["icc_profile"]
|
||||
assert len(icc_profile) == 3144
|
||||
assert len(icc_profile) == 3144
|
||||
|
||||
|
||||
def test_no_icc_profile() -> None:
|
||||
@ -158,17 +169,16 @@ def test_combined_larger_than_size() -> None:
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"test_file,raises",
|
||||
"test_file",
|
||||
[
|
||||
("Tests/images/timeout-c8efc3fded6426986ba867a399791bae544f59bc.psd", OSError),
|
||||
("Tests/images/timeout-dedc7a4ebd856d79b4359bbcc79e8ef231ce38f6.psd", OSError),
|
||||
"Tests/images/timeout-c8efc3fded6426986ba867a399791bae544f59bc.psd",
|
||||
"Tests/images/timeout-dedc7a4ebd856d79b4359bbcc79e8ef231ce38f6.psd",
|
||||
],
|
||||
)
|
||||
def test_crashes(test_file: str, raises: type[Exception]) -> None:
|
||||
with open(test_file, "rb") as f:
|
||||
with pytest.raises(raises):
|
||||
with Image.open(f):
|
||||
pass
|
||||
def test_crashes(test_file: str) -> None:
|
||||
with pytest.raises(OSError):
|
||||
with Image.open(test_file):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@ -179,11 +189,10 @@ def test_crashes(test_file: str, raises: type[Exception]) -> None:
|
||||
],
|
||||
)
|
||||
def test_layer_crashes(test_file: str) -> None:
|
||||
with open(test_file, "rb") as f:
|
||||
with Image.open(f) as im:
|
||||
assert isinstance(im, PsdImagePlugin.PsdImageFile)
|
||||
with pytest.raises(SyntaxError):
|
||||
im.layers
|
||||
with Image.open(test_file) as im:
|
||||
assert isinstance(im, PsdImagePlugin.PsdImageFile)
|
||||
with pytest.raises(SyntaxError):
|
||||
im.layers
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@ -201,3 +210,17 @@ def test_bounds_crash(test_file: str) -> None:
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
im.load()
|
||||
|
||||
|
||||
def test_bounds_crash_overflow() -> None:
|
||||
with Image.open("Tests/images/psd-oob-write-overflow.psd") as im:
|
||||
assert isinstance(im, PsdImagePlugin.PsdImageFile)
|
||||
im.load()
|
||||
if sys.maxsize <= 2**32:
|
||||
with pytest.raises(OverflowError):
|
||||
im.seek(im.n_frames)
|
||||
else:
|
||||
im.seek(im.n_frames)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
im.load()
|
||||
|
||||
@ -63,6 +63,16 @@ def test_save(tmp_path: Path) -> None:
|
||||
assert im2.size == (128, 128)
|
||||
assert im2.format == "SPIDER"
|
||||
|
||||
del Image.EXTENSION[".spider"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size", ((0, 1), (1, 0), (0, 0)))
|
||||
def test_save_zero(size: tuple[int, int]) -> None:
|
||||
b = BytesIO()
|
||||
im = Image.new("1", size)
|
||||
with pytest.raises(ValueError, match="cannot write empty image"):
|
||||
im.save(b, "SPIDER")
|
||||
|
||||
|
||||
def test_tempfile() -> None:
|
||||
# Arrange
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from PIL import Image, UnidentifiedImageError, _binary
|
||||
|
||||
from .helper import assert_image_equal, assert_image_equal_tofile, hopper
|
||||
|
||||
@ -13,8 +14,6 @@ _TGA_DIR = os.path.join("Tests", "images", "tga")
|
||||
_TGA_DIR_COMMON = os.path.join(_TGA_DIR, "common")
|
||||
|
||||
|
||||
_ORIGINS = ("tl", "bl")
|
||||
|
||||
_ORIGIN_TO_ORIENTATION = {"tl": 1, "bl": -1}
|
||||
|
||||
|
||||
@ -29,7 +28,7 @@ _ORIGIN_TO_ORIENTATION = {"tl": 1, "bl": -1}
|
||||
("200x32", "RGBA"),
|
||||
),
|
||||
)
|
||||
@pytest.mark.parametrize("origin", _ORIGINS)
|
||||
@pytest.mark.parametrize("origin", _ORIGIN_TO_ORIENTATION)
|
||||
@pytest.mark.parametrize("rle", (True, False))
|
||||
def test_sanity(
|
||||
size_mode: tuple[str, str], origin: str, rle: str, tmp_path: Path
|
||||
@ -94,6 +93,25 @@ def test_rgba_16() -> None:
|
||||
assert im.getpixel((1, 0)) == (0, 255, 82, 0)
|
||||
|
||||
|
||||
def test_v2_no_alpha() -> None:
|
||||
test_file = "Tests/images/tga/common/200x32_rgba_tl_rle.tga"
|
||||
with open(test_file, "rb") as fp:
|
||||
data = fp.read()
|
||||
data += (
|
||||
b"\x00" * 495
|
||||
+ _binary.o32le(len(data))
|
||||
+ _binary.o32le(0)
|
||||
+ b"TRUEVISION-XFILE.\x00"
|
||||
)
|
||||
with Image.open(BytesIO(data)) as im:
|
||||
with Image.open(test_file) as im2:
|
||||
r, g, b = im2.split()[:3]
|
||||
a = Image.new("L", im2.size, 255)
|
||||
expected = Image.merge("RGBA", (r, g, b, a))
|
||||
|
||||
assert_image_equal(im, expected)
|
||||
|
||||
|
||||
def test_id_field() -> None:
|
||||
# tga file with id field
|
||||
test_file = "Tests/images/tga_id_field.tga"
|
||||
|
||||
@ -16,6 +16,7 @@ from PIL import (
|
||||
TiffImagePlugin,
|
||||
TiffTags,
|
||||
UnidentifiedImageError,
|
||||
_binary,
|
||||
)
|
||||
from PIL.TiffImagePlugin import RESOLUTION_UNIT, X_RESOLUTION, Y_RESOLUTION
|
||||
|
||||
@ -941,6 +942,15 @@ class TestFileTiff:
|
||||
4001,
|
||||
]
|
||||
|
||||
def test_truncated_photoshop_blocks(self) -> None:
|
||||
with Image.open("Tests/images/hopper.tif") as im:
|
||||
assert isinstance(im, TiffImagePlugin.TiffImageFile)
|
||||
im.tag_v2[34377] = b"8BIM"
|
||||
assert im.get_photoshop_blocks() == {}
|
||||
|
||||
im.tag_v2[34377] = b"8BIM" + _binary.o16be(0) + _binary.o8(2) + b" " * 5
|
||||
assert im.get_photoshop_blocks() == {}
|
||||
|
||||
def test_tiff_chunks(self, tmp_path: Path) -> None:
|
||||
tmpfile = tmp_path / "temp.tif"
|
||||
|
||||
|
||||
@ -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?
|
||||
|
||||
@ -18,7 +18,7 @@ def test_load_raw() -> None:
|
||||
# Currently, support for WMF/EMF is Windows-only
|
||||
im.load()
|
||||
# Compare to reference rendering
|
||||
assert_image_similar_tofile(im, "Tests/images/drawing_emf_ref.png", 0)
|
||||
assert_image_equal_tofile(im, "Tests/images/drawing_emf_ref.png")
|
||||
|
||||
# Test basic WMF open and rendering
|
||||
with Image.open("Tests/images/drawing.wmf") as im:
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from .helper import skip_unless_feature
|
||||
@ -20,6 +18,5 @@ class TestFontCrash:
|
||||
|
||||
@skip_unless_feature("freetype2")
|
||||
def test_segfault(self) -> None:
|
||||
with pytest.raises(OSError):
|
||||
font = ImageFont.truetype("Tests/fonts/fuzz_font-5203009437302784")
|
||||
self._fuzz_font(font)
|
||||
font = ImageFont.truetype("Tests/fonts/fuzz_font-5203009437302784")
|
||||
self._fuzz_font(font)
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont, _util
|
||||
|
||||
from .helper import PillowLeakTestCase, features, skip_unless_feature
|
||||
@ -7,11 +9,7 @@ from .helper import PillowLeakTestCase, features, skip_unless_feature
|
||||
original_core = ImageFont.core
|
||||
|
||||
|
||||
class TestTTypeFontLeak(PillowLeakTestCase):
|
||||
# fails at iteration 3 in main
|
||||
iterations = 10
|
||||
mem_limit = 4096 # k
|
||||
|
||||
class TestFontLeak(PillowLeakTestCase):
|
||||
def _test_font(self, font: ImageFont.FreeTypeFont | ImageFont.ImageFont) -> None:
|
||||
im = Image.new("RGB", (255, 255), "white")
|
||||
draw = ImageDraw.ImageDraw(im)
|
||||
@ -21,23 +19,29 @@ class TestTTypeFontLeak(PillowLeakTestCase):
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestTTypeFontLeak(TestFontLeak):
|
||||
# fails at iteration 3 in main
|
||||
iterations = 10
|
||||
mem_limit = 4096 # k
|
||||
|
||||
@skip_unless_feature("freetype2")
|
||||
def test_leak(self) -> None:
|
||||
ttype = ImageFont.truetype("Tests/fonts/FreeMono.ttf", 20)
|
||||
self._test_font(ttype)
|
||||
|
||||
|
||||
class TestDefaultFontLeak(TestTTypeFontLeak):
|
||||
class TestDefaultFontLeak(TestFontLeak):
|
||||
# fails at iteration 37 in main
|
||||
iterations = 100
|
||||
mem_limit = 1024 # k
|
||||
|
||||
def test_leak(self) -> None:
|
||||
def test_leak(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
if features.check_module("freetype2"):
|
||||
ImageFont.core = _util.DeferredError(ImportError("Disabled for testing"))
|
||||
try:
|
||||
default_font = ImageFont.load_default()
|
||||
finally:
|
||||
ImageFont.core = original_core
|
||||
|
||||
monkeypatch.setattr(
|
||||
ImageFont,
|
||||
"core",
|
||||
_util.DeferredError(ImportError("Disabled for testing")),
|
||||
)
|
||||
default_font = ImageFont.load_default()
|
||||
self._test_font(default_font)
|
||||
|
||||
@ -10,7 +10,6 @@ from PIL import FontFile, Image, ImageDraw, ImageFont, PcfFontFile
|
||||
|
||||
from .helper import (
|
||||
assert_image_equal_tofile,
|
||||
assert_image_similar_tofile,
|
||||
skip_unless_feature,
|
||||
)
|
||||
|
||||
@ -73,14 +72,24 @@ def test_draw(request: pytest.FixtureRequest, tmp_path: Path) -> None:
|
||||
im = Image.new("L", (130, 30), "white")
|
||||
draw = ImageDraw.Draw(im)
|
||||
draw.text((0, 0), message, "black", font=font)
|
||||
assert_image_similar_tofile(im, "Tests/images/test_draw_pbm_target.png", 0)
|
||||
assert_image_equal_tofile(im, "Tests/images/test_draw_pbm_target.png")
|
||||
|
||||
|
||||
def test_to_imagefont() -> None:
|
||||
with open(fontname, "rb") as test_file:
|
||||
pcffont = PcfFontFile.PcfFontFile(test_file)
|
||||
imagefont = pcffont.to_imagefont()
|
||||
im = Image.new("L", (130, 30), "white")
|
||||
draw = ImageDraw.Draw(im)
|
||||
draw.text((0, 0), message, "black", font=imagefont)
|
||||
assert_image_equal_tofile(im, "Tests/images/test_draw_pbm_target.png")
|
||||
|
||||
|
||||
def test_textsize(request: pytest.FixtureRequest, tmp_path: Path) -> None:
|
||||
tempname = save_font(request, tmp_path)
|
||||
font = ImageFont.load(tempname)
|
||||
for i in range(255):
|
||||
(ox, oy, dx, dy) = font.getbbox(chr(i))
|
||||
ox, oy, dx, dy = font.getbbox(chr(i))
|
||||
assert ox == 0
|
||||
assert oy == 0
|
||||
assert dy == 20
|
||||
@ -100,7 +109,7 @@ def _test_high_characters(
|
||||
im = Image.new("L", (750, 30), "white")
|
||||
draw = ImageDraw.Draw(im)
|
||||
draw.text((0, 0), message, "black", font=font)
|
||||
assert_image_similar_tofile(im, "Tests/images/high_ascii_chars.png", 0)
|
||||
assert_image_equal_tofile(im, "Tests/images/high_ascii_chars.png")
|
||||
|
||||
|
||||
def test_high_characters(request: pytest.FixtureRequest, tmp_path: Path) -> None:
|
||||
|
||||
@ -10,7 +10,6 @@ from PIL import FontFile, Image, ImageDraw, ImageFont, PcfFontFile
|
||||
|
||||
from .helper import (
|
||||
assert_image_equal_tofile,
|
||||
assert_image_similar_tofile,
|
||||
skip_unless_feature,
|
||||
)
|
||||
|
||||
@ -85,7 +84,7 @@ def test_draw(request: pytest.FixtureRequest, tmp_path: Path, encoding: str) ->
|
||||
draw = ImageDraw.Draw(im)
|
||||
message = charsets[encoding]["message"].encode(encoding)
|
||||
draw.text((0, 0), message, "black", font=font)
|
||||
assert_image_similar_tofile(im, charsets[encoding]["image1"], 0)
|
||||
assert_image_equal_tofile(im, charsets[encoding]["image1"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("encoding", ("iso8859-1", "iso8859-2", "cp1250"))
|
||||
@ -95,7 +94,7 @@ def test_textsize(
|
||||
tempname = save_font(request, tmp_path, encoding)
|
||||
font = ImageFont.load(tempname)
|
||||
for i in range(255):
|
||||
(ox, oy, dx, dy) = font.getbbox(bytearray([i]))
|
||||
ox, oy, dx, dy = font.getbbox(bytearray([i]))
|
||||
assert ox == 0
|
||||
assert oy == 0
|
||||
assert dy == 20
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@ -7,6 +8,15 @@ import pytest
|
||||
from PIL import FontFile, Image
|
||||
|
||||
|
||||
def test_puti16() -> None:
|
||||
fp = BytesIO()
|
||||
FontFile.puti16(fp, (0, 1, 2, 3, 4, 5, 6, 7, 8, 9))
|
||||
assert fp.getvalue() == (
|
||||
b"\x00\x00\x00\x01\x00\x02\x00\x03\x00\x04"
|
||||
b"\x00\x05\x00\x06\x00\x07\x00\x08\x00\t"
|
||||
)
|
||||
|
||||
|
||||
def test_compile() -> None:
|
||||
font = FontFile.FontFile()
|
||||
font.glyph[0] = ((0, 0), (0, 0, 0, 0), (0, 0, 0, 1), Image.new("L", (0, 0)))
|
||||
@ -24,5 +34,11 @@ def test_save(tmp_path: Path) -> None:
|
||||
tempname = str(tmp_path / "temp.pil")
|
||||
|
||||
font = FontFile.FontFile()
|
||||
with pytest.raises(ValueError):
|
||||
with pytest.raises(ValueError, match="No bitmap created"):
|
||||
font.save(tempname)
|
||||
|
||||
|
||||
def test_to_imagefont() -> None:
|
||||
font = FontFile.FontFile()
|
||||
with pytest.raises(ValueError, match="No bitmap created"):
|
||||
font.to_imagefont()
|
||||
|
||||
@ -29,7 +29,7 @@ def linear_gradient() -> Image.Image:
|
||||
im = Image.linear_gradient(mode="L")
|
||||
im90 = im.rotate(90)
|
||||
|
||||
(px, h) = im.size
|
||||
px, h = im.size
|
||||
|
||||
r = Image.new("L", (px * 3, h))
|
||||
g = r.copy()
|
||||
@ -54,7 +54,7 @@ def to_xxx_colorsys(
|
||||
) -> Image.Image:
|
||||
# convert the hard way using the library colorsys routines.
|
||||
|
||||
(r, g, b) = im.split()
|
||||
r, g, b = im.split()
|
||||
|
||||
conv_func = int_to_float
|
||||
|
||||
|
||||
@ -456,9 +456,11 @@ class TestImage:
|
||||
# Assert
|
||||
assert len(Image.ID) == id_length
|
||||
|
||||
def test_registered_extensions_uninitialized(self) -> None:
|
||||
def test_registered_extensions_uninitialized(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# Arrange
|
||||
Image._initialized = 0
|
||||
monkeypatch.setattr(Image, "_initialized", 0)
|
||||
|
||||
# Act
|
||||
Image.registered_extensions()
|
||||
@ -466,6 +468,9 @@ class TestImage:
|
||||
# Assert
|
||||
assert Image._initialized == 2
|
||||
|
||||
for extension in Image.EXTENSION:
|
||||
assert extension in Image._EXTENSION_PLUGIN
|
||||
|
||||
def test_registered_extensions(self) -> None:
|
||||
# Arrange
|
||||
# Open an image to trigger plugin registration
|
||||
@ -857,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 exif == {}
|
||||
assert dict(exif) == {}
|
||||
|
||||
out = tmp_path / "temp.webp"
|
||||
exif[258] = 8
|
||||
@ -879,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 exif == {274: 1}
|
||||
assert dict(exif) == {274: 1}
|
||||
|
||||
out = tmp_path / "temp.png"
|
||||
exif[258] = 8
|
||||
|
||||
@ -278,8 +278,7 @@ class TestEmbeddable:
|
||||
|
||||
with open("embed_pil.c", "w", encoding="utf-8") as fh:
|
||||
home = sys.prefix.replace("\\", "\\\\")
|
||||
fh.write(
|
||||
f"""
|
||||
fh.write(f"""
|
||||
#include "Python.h"
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
@ -300,8 +299,7 @@ int main(int argc, char* argv[])
|
||||
|
||||
return 0;
|
||||
}}
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
objects = compiler.compile(["embed_pil.c"])
|
||||
compiler.link_executable(objects, "embed_pil")
|
||||
|
||||
@ -91,6 +91,21 @@ def test_rgba_palette(mode: str, palette: tuple[int, ...]) -> None:
|
||||
assert im.palette.colors == {(1, 2, 3, 4): 0}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mode, palette",
|
||||
(
|
||||
("CMYK", (1, 2, 3, 4)),
|
||||
("CMYKX", (1, 2, 3, 4, 0)),
|
||||
),
|
||||
)
|
||||
def test_cmyk_palette(mode: str, palette: tuple[int, ...]) -> None:
|
||||
im = Image.new("P", (1, 1))
|
||||
im.putpalette(palette, mode)
|
||||
assert im.getpalette() == [250, 249, 248]
|
||||
assert im.palette is not None
|
||||
assert im.palette.colors == {(1, 2, 3, 4): 0}
|
||||
|
||||
|
||||
def test_empty_palette() -> None:
|
||||
im = Image.new("P", (1, 1))
|
||||
assert im.getpalette() == []
|
||||
|
||||
@ -627,3 +627,37 @@ 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}"
|
||||
|
||||
@ -56,7 +56,7 @@ class TestImageTransform:
|
||||
|
||||
def test_extent(self) -> None:
|
||||
im = hopper("RGB")
|
||||
(w, h) = im.size
|
||||
w, h = im.size
|
||||
transformed = im.transform(
|
||||
im.size,
|
||||
Image.Transform.EXTENT,
|
||||
@ -72,7 +72,7 @@ class TestImageTransform:
|
||||
def test_quad(self) -> None:
|
||||
# one simple quad transform, equivalent to scale & crop upper left quad
|
||||
im = hopper("RGB")
|
||||
(w, h) = im.size
|
||||
w, h = im.size
|
||||
transformed = im.transform(
|
||||
im.size,
|
||||
Image.Transform.QUAD,
|
||||
@ -99,7 +99,7 @@ class TestImageTransform:
|
||||
)
|
||||
def test_fill(self, mode: str, expected_pixel: tuple[int, ...]) -> None:
|
||||
im = hopper(mode)
|
||||
(w, h) = im.size
|
||||
w, h = im.size
|
||||
transformed = im.transform(
|
||||
im.size,
|
||||
Image.Transform.EXTENT,
|
||||
@ -112,7 +112,7 @@ class TestImageTransform:
|
||||
def test_mesh(self) -> None:
|
||||
# this should be a checkerboard of halfsized hoppers in ul, lr
|
||||
im = hopper("RGBA")
|
||||
(w, h) = im.size
|
||||
w, h = im.size
|
||||
transformed = im.transform(
|
||||
im.size,
|
||||
Image.Transform.MESH,
|
||||
@ -174,7 +174,7 @@ class TestImageTransform:
|
||||
|
||||
def test_alpha_premult_transform(self) -> None:
|
||||
def op(im: Image.Image, sz: tuple[int, int]) -> Image.Image:
|
||||
(w, h) = im.size
|
||||
w, h = im.size
|
||||
return im.transform(
|
||||
sz, Image.Transform.EXTENT, (0, 0, w, h), Image.Resampling.BILINEAR
|
||||
)
|
||||
@ -216,7 +216,7 @@ class TestImageTransform:
|
||||
@pytest.mark.parametrize("mode", ("RGBA", "LA"))
|
||||
def test_nearest_transform(self, mode: str) -> None:
|
||||
def op(im: Image.Image, sz: tuple[int, int]) -> Image.Image:
|
||||
(w, h) = im.size
|
||||
w, h = im.size
|
||||
return im.transform(
|
||||
sz, Image.Transform.EXTENT, (0, 0, w, h), Image.Resampling.NEAREST
|
||||
)
|
||||
@ -255,7 +255,7 @@ class TestImageTransform:
|
||||
@pytest.mark.parametrize("resample", (Image.Resampling.BOX, "unknown"))
|
||||
def test_unknown_resampling_filter(self, resample: Image.Resampling | str) -> None:
|
||||
with hopper() as im:
|
||||
(w, h) = im.size
|
||||
w, h = im.size
|
||||
with pytest.raises(ValueError):
|
||||
im.transform((100, 100), Image.Transform.EXTENT, (0, 0, w, h), resample) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@ -68,10 +68,22 @@ def test_sanity() -> None:
|
||||
draw.rectangle(list(range(4)))
|
||||
|
||||
|
||||
def test_valueerror() -> None:
|
||||
def test_new_color() -> None:
|
||||
with Image.open("Tests/images/chi.gif") as im:
|
||||
draw = ImageDraw.Draw(im)
|
||||
assert im.palette is not None
|
||||
assert len(im.palette.colors) == 249
|
||||
|
||||
# Test drawing a new color onto the palette
|
||||
draw.line((0, 0), fill=(0, 0, 0))
|
||||
assert im.palette is not None
|
||||
assert len(im.palette.colors) == 250
|
||||
assert im.palette.dirty
|
||||
|
||||
# Test drawing another new color, now that the palette is dirty
|
||||
draw.point((0, 0), fill=(1, 0, 0))
|
||||
assert len(im.palette.colors) == 251
|
||||
assert im.convert("RGB").getpixel((0, 0)) == (1, 0, 0)
|
||||
|
||||
|
||||
def test_mode_mismatch() -> None:
|
||||
@ -883,6 +895,18 @@ def test_rounded_rectangle_joined_x_different_corners() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_rounded_rectangle_radius() -> None:
|
||||
# Arrange
|
||||
im = Image.new("RGB", (W, H))
|
||||
draw = ImageDraw.Draw(im, "RGB")
|
||||
|
||||
# Act
|
||||
draw.rounded_rectangle((25, 25, 75, 75), 24, fill="red", outline="green", width=5)
|
||||
|
||||
# Assert
|
||||
assert_image_equal_tofile(im, "Tests/images/imagedraw_rounded_rectangle_radius.png")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"xy, radius, type",
|
||||
[
|
||||
@ -1461,21 +1485,15 @@ def test_stroke_multiline() -> None:
|
||||
|
||||
|
||||
@skip_unless_feature("freetype2")
|
||||
def test_setting_default_font() -> None:
|
||||
# Arrange
|
||||
def test_setting_default_font(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
im = Image.new("RGB", (100, 250))
|
||||
draw = ImageDraw.Draw(im)
|
||||
assert isinstance(draw.getfont(), ImageFont.load_default().__class__)
|
||||
|
||||
draw = ImageDraw.Draw(im)
|
||||
font = ImageFont.truetype("Tests/fonts/FreeMono.ttf", 120)
|
||||
|
||||
# Act
|
||||
ImageDraw.ImageDraw.font = font
|
||||
|
||||
# Assert
|
||||
try:
|
||||
assert draw.getfont() == font
|
||||
finally:
|
||||
ImageDraw.ImageDraw.font = None
|
||||
assert isinstance(draw.getfont(), ImageFont.load_default().__class__)
|
||||
monkeypatch.setattr(ImageDraw.ImageDraw, "font", font)
|
||||
assert draw.getfont() == font
|
||||
|
||||
|
||||
def test_default_font_size() -> None:
|
||||
|
||||
@ -31,7 +31,7 @@ SAFEBLOCK = ImageFile.SAFEBLOCK
|
||||
|
||||
|
||||
class TestImageFile:
|
||||
def test_parser(self) -> None:
|
||||
def test_parser(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def roundtrip(format: str) -> tuple[Image.Image, Image.Image]:
|
||||
im = hopper("L").resize((1000, 1000), Image.Resampling.NEAREST)
|
||||
if format in ("MSP", "XBM"):
|
||||
@ -55,12 +55,9 @@ class TestImageFile:
|
||||
assert_image_equal(*roundtrip("IM"))
|
||||
assert_image_equal(*roundtrip("MSP"))
|
||||
if features.check("zlib"):
|
||||
try:
|
||||
# force multiple blocks in PNG driver
|
||||
ImageFile.MAXBLOCK = 8192
|
||||
assert_image_equal(*roundtrip("PNG"))
|
||||
finally:
|
||||
ImageFile.MAXBLOCK = MAXBLOCK
|
||||
# force multiple blocks in PNG driver
|
||||
monkeypatch.setattr(ImageFile, "MAXBLOCK", 8192)
|
||||
assert_image_equal(*roundtrip("PNG"))
|
||||
assert_image_equal(*roundtrip("PPM"))
|
||||
assert_image_equal(*roundtrip("TIFF"))
|
||||
assert_image_equal(*roundtrip("XBM"))
|
||||
@ -120,14 +117,11 @@ class TestImageFile:
|
||||
assert (128, 128) == p.image.size
|
||||
|
||||
@skip_unless_feature("zlib")
|
||||
def test_safeblock(self) -> None:
|
||||
def test_safeblock(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
im1 = hopper()
|
||||
|
||||
try:
|
||||
ImageFile.SAFEBLOCK = 1
|
||||
im2 = fromstring(tostring(im1, "PNG"))
|
||||
finally:
|
||||
ImageFile.SAFEBLOCK = SAFEBLOCK
|
||||
monkeypatch.setattr(ImageFile, "SAFEBLOCK", 1)
|
||||
im2 = fromstring(tostring(im1, "PNG"))
|
||||
|
||||
assert_image_equal(im1, im2)
|
||||
|
||||
@ -176,6 +170,27 @@ class TestImageFile:
|
||||
with pytest.raises(SystemError, match="tile cannot extend outside image"):
|
||||
ImageFile._save(im, fp, [ImageFile._Tile("raw", xy + (1, 1), 0, "1")])
|
||||
|
||||
def test_extents_none(self) -> None:
|
||||
with Image.open("Tests/images/hopper.jpg") as im:
|
||||
im.tile = [im.tile[0]._replace(extents=None)]
|
||||
im.load()
|
||||
|
||||
for extents in ("invalid", (0,), ("0", "0", "0", "0")):
|
||||
with Image.open("Tests/images/hopper.jpg") as im:
|
||||
im.tile = [im.tile[0]._replace(extents=extents)] # type: ignore[arg-type]
|
||||
with pytest.raises(ValueError, match="invalid extents"):
|
||||
im.load()
|
||||
|
||||
im2 = Image.new("L", (1, 1))
|
||||
fp = BytesIO()
|
||||
tile = ImageFile._Tile("jpeg", None, 0, "L")
|
||||
ImageFile._save(im2, fp, [tile])
|
||||
|
||||
for extents in ("invalid", (0,), ("0", "0", "0", "0")):
|
||||
tile = tile._replace(extents=extents) # type: ignore[arg-type]
|
||||
with pytest.raises(ValueError, match="invalid extents"):
|
||||
ImageFile._save(im2, fp, [tile])
|
||||
|
||||
def test_no_format(self) -> None:
|
||||
buf = BytesIO(b"\x00" * 255)
|
||||
|
||||
@ -301,6 +316,26 @@ class TestPyDecoder(CodecsTest):
|
||||
with pytest.raises(ValueError):
|
||||
MockPyDecoder.last.set_as_raw(b"\x00")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"extents",
|
||||
(
|
||||
(-10, yoff, xoff + xsize, yoff + ysize),
|
||||
(xoff, -10, xoff + xsize, yoff + ysize),
|
||||
(xoff, yoff, -10, yoff + ysize),
|
||||
(xoff, yoff, xoff + xsize, -10),
|
||||
(xoff, yoff, xoff + xsize + 100, yoff + ysize),
|
||||
(xoff, yoff, xoff + xsize, yoff + ysize + 100),
|
||||
),
|
||||
)
|
||||
def test_extents(self, extents: tuple[int, int, int, int]) -> None:
|
||||
buf = BytesIO(b"\x00" * 255)
|
||||
|
||||
im = MockImageFile(buf)
|
||||
im.tile = [ImageFile._Tile("MOCK", extents, 32, None)]
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
im.load()
|
||||
|
||||
def test_extents_none(self) -> None:
|
||||
buf = BytesIO(b"\x00" * 255)
|
||||
|
||||
@ -314,40 +349,6 @@ class TestPyDecoder(CodecsTest):
|
||||
assert MockPyDecoder.last.state.xsize == 200
|
||||
assert MockPyDecoder.last.state.ysize == 200
|
||||
|
||||
def test_negsize(self) -> None:
|
||||
buf = BytesIO(b"\x00" * 255)
|
||||
|
||||
im = MockImageFile(buf)
|
||||
im.tile = [ImageFile._Tile("MOCK", (xoff, yoff, -10, yoff + ysize), 32, None)]
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
im.load()
|
||||
|
||||
im.tile = [ImageFile._Tile("MOCK", (xoff, yoff, xoff + xsize, -10), 32, None)]
|
||||
with pytest.raises(ValueError):
|
||||
im.load()
|
||||
|
||||
def test_oversize(self) -> None:
|
||||
buf = BytesIO(b"\x00" * 255)
|
||||
|
||||
im = MockImageFile(buf)
|
||||
im.tile = [
|
||||
ImageFile._Tile(
|
||||
"MOCK", (xoff, yoff, xoff + xsize + 100, yoff + ysize), 32, None
|
||||
)
|
||||
]
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
im.load()
|
||||
|
||||
im.tile = [
|
||||
ImageFile._Tile(
|
||||
"MOCK", (xoff, yoff, xoff + xsize, yoff + ysize + 100), 32, None
|
||||
)
|
||||
]
|
||||
with pytest.raises(ValueError):
|
||||
im.load()
|
||||
|
||||
def test_decode(self) -> None:
|
||||
decoder = ImageFile.PyDecoder("")
|
||||
with pytest.raises(NotImplementedError):
|
||||
@ -377,6 +378,33 @@ class TestPyEncoder(CodecsTest):
|
||||
assert MockPyEncoder.last.state.xsize == xsize
|
||||
assert MockPyEncoder.last.state.ysize == ysize
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"extents",
|
||||
(
|
||||
(-10, yoff, xoff + xsize, yoff + ysize),
|
||||
(xoff, -10, xoff + xsize, yoff + ysize),
|
||||
(xoff, yoff, -10, yoff + ysize),
|
||||
(xoff, yoff, xoff + xsize, -10),
|
||||
(xoff, yoff, xoff + xsize + 100, yoff + ysize),
|
||||
(xoff, yoff, xoff + xsize, yoff + ysize + 100),
|
||||
),
|
||||
)
|
||||
def test_extents(self, extents: tuple[int, int, int, int]) -> None:
|
||||
buf = BytesIO(b"\x00" * 255)
|
||||
|
||||
im = MockImageFile(buf)
|
||||
|
||||
fp = BytesIO()
|
||||
MockPyEncoder.last = None
|
||||
with pytest.raises(ValueError):
|
||||
ImageFile._save(im, fp, [ImageFile._Tile("MOCK", extents, 0, "RGB")])
|
||||
last: MockPyEncoder | None = MockPyEncoder.last
|
||||
assert last
|
||||
assert last.cleanup_called
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
ImageFile._save(im, fp, [ImageFile._Tile("MOCK", extents, 0, "RGB")])
|
||||
|
||||
def test_extents_none(self) -> None:
|
||||
buf = BytesIO(b"\x00" * 255)
|
||||
|
||||
@ -392,58 +420,6 @@ class TestPyEncoder(CodecsTest):
|
||||
assert MockPyEncoder.last.state.xsize == 200
|
||||
assert MockPyEncoder.last.state.ysize == 200
|
||||
|
||||
def test_negsize(self) -> None:
|
||||
buf = BytesIO(b"\x00" * 255)
|
||||
|
||||
im = MockImageFile(buf)
|
||||
|
||||
fp = BytesIO()
|
||||
MockPyEncoder.last = None
|
||||
with pytest.raises(ValueError):
|
||||
ImageFile._save(
|
||||
im,
|
||||
fp,
|
||||
[ImageFile._Tile("MOCK", (xoff, yoff, -10, yoff + ysize), 0, "RGB")],
|
||||
)
|
||||
last: MockPyEncoder | None = MockPyEncoder.last
|
||||
assert last
|
||||
assert last.cleanup_called
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
ImageFile._save(
|
||||
im,
|
||||
fp,
|
||||
[ImageFile._Tile("MOCK", (xoff, yoff, xoff + xsize, -10), 0, "RGB")],
|
||||
)
|
||||
|
||||
def test_oversize(self) -> None:
|
||||
buf = BytesIO(b"\x00" * 255)
|
||||
|
||||
im = MockImageFile(buf)
|
||||
|
||||
fp = BytesIO()
|
||||
with pytest.raises(ValueError):
|
||||
ImageFile._save(
|
||||
im,
|
||||
fp,
|
||||
[
|
||||
ImageFile._Tile(
|
||||
"MOCK", (xoff, yoff, xoff + xsize + 100, yoff + ysize), 0, "RGB"
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
ImageFile._save(
|
||||
im,
|
||||
fp,
|
||||
[
|
||||
ImageFile._Tile(
|
||||
"MOCK", (xoff, yoff, xoff + xsize, yoff + ysize + 100), 0, "RGB"
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def test_encode(self) -> None:
|
||||
encoder = ImageFile.PyEncoder("")
|
||||
with pytest.raises(NotImplementedError):
|
||||
|
||||
@ -365,7 +365,7 @@ def test_rotated_transposed_font(
|
||||
bbox_b[2] - bbox_b[0],
|
||||
)
|
||||
|
||||
# Check top left co-ordinates are correct
|
||||
# Check top left coordinates 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 co-ordinates are correct
|
||||
# Check top left coordinates are correct
|
||||
assert bbox_b[:2] == (20, 20)
|
||||
|
||||
assert length_a == length_b
|
||||
|
||||
@ -38,20 +38,18 @@ def test_invalid_mode() -> None:
|
||||
font._load_pilfont_data(fp, im)
|
||||
|
||||
|
||||
def test_without_freetype() -> None:
|
||||
original_core = ImageFont.core
|
||||
def test_without_freetype(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
if features.check_module("freetype2"):
|
||||
ImageFont.core = _util.DeferredError(ImportError("Disabled for testing"))
|
||||
try:
|
||||
with pytest.raises(ImportError):
|
||||
ImageFont.truetype("Tests/fonts/FreeMono.ttf")
|
||||
monkeypatch.setattr(
|
||||
ImageFont, "core", _util.DeferredError(ImportError("Disabled for testing"))
|
||||
)
|
||||
with pytest.raises(ImportError):
|
||||
ImageFont.truetype("Tests/fonts/FreeMono.ttf")
|
||||
|
||||
assert isinstance(ImageFont.load_default(), ImageFont.ImageFont)
|
||||
assert isinstance(ImageFont.load_default(), ImageFont.ImageFont)
|
||||
|
||||
with pytest.raises(ImportError):
|
||||
ImageFont.load_default(size=14)
|
||||
finally:
|
||||
ImageFont.core = original_core
|
||||
with pytest.raises(ImportError):
|
||||
ImageFont.load_default(size=14)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("font", fonts)
|
||||
|
||||
@ -9,7 +9,7 @@ import pytest
|
||||
|
||||
from PIL import Image, ImageGrab
|
||||
|
||||
from .helper import assert_image_equal_tofile, skip_unless_feature
|
||||
from .helper import assert_image_equal_tofile, on_ci, skip_unless_feature
|
||||
|
||||
|
||||
class TestImageGrab:
|
||||
@ -35,7 +35,7 @@ class TestImageGrab:
|
||||
ImageGrab.grab()
|
||||
|
||||
ImageGrab.grab(xdisplay="")
|
||||
except OSError as e:
|
||||
except (OSError, subprocess.CalledProcessError) as e:
|
||||
pytest.skip(str(e))
|
||||
|
||||
@pytest.mark.skipif(Image.core.HAVE_XCB, reason="tests missing XCB")
|
||||
@ -60,12 +60,44 @@ class TestImageGrab:
|
||||
ImageGrab.grab(xdisplay="error.test:0.0")
|
||||
assert str(e.value).startswith("X connection failed")
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="Windows only")
|
||||
@pytest.mark.skipif(
|
||||
sys.platform != "darwin" or not on_ci(), reason="Only runs on macOS CI"
|
||||
)
|
||||
def test_grab_handle(self) -> None:
|
||||
p = subprocess.Popen(
|
||||
[
|
||||
"osascript",
|
||||
"-e",
|
||||
'tell application "Finder"\n'
|
||||
'open ("/" as POSIX file)\n'
|
||||
"get id of front window\n"
|
||||
"end tell",
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
)
|
||||
stdout = p.stdout
|
||||
assert stdout is not None
|
||||
window = int(stdout.read())
|
||||
|
||||
ImageGrab.grab(window=window)
|
||||
|
||||
im = ImageGrab.grab((0, 0, 10, 10), window=window)
|
||||
assert im.size == (10, 10)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform not in ("darwin", "win32"), reason="macOS and Windows only"
|
||||
)
|
||||
def test_grab_invalid_handle(self) -> None:
|
||||
with pytest.raises(OSError, match="unable to get device context for handle"):
|
||||
ImageGrab.grab(window=-1)
|
||||
with pytest.raises(OSError, match="screen grab failed"):
|
||||
ImageGrab.grab(window=0)
|
||||
if sys.platform == "darwin":
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
ImageGrab.grab(window=-1)
|
||||
else:
|
||||
with pytest.raises(
|
||||
OSError, match="unable to get device context for handle"
|
||||
):
|
||||
ImageGrab.grab(window=-1)
|
||||
with pytest.raises(OSError, match="screen grab failed"):
|
||||
ImageGrab.grab(window=0)
|
||||
|
||||
def test_grabclipboard(self) -> None:
|
||||
if sys.platform == "darwin":
|
||||
|
||||
@ -22,8 +22,7 @@ def string_to_img(image_string: str) -> Image.Image:
|
||||
return im
|
||||
|
||||
|
||||
A = string_to_img(
|
||||
"""
|
||||
A = string_to_img("""
|
||||
.......
|
||||
.......
|
||||
..111..
|
||||
@ -31,8 +30,7 @@ A = string_to_img(
|
||||
..111..
|
||||
.......
|
||||
.......
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def img_to_string(im: Image.Image) -> str:
|
||||
@ -231,15 +229,15 @@ def test_negate() -> None:
|
||||
|
||||
|
||||
def test_incorrect_mode() -> None:
|
||||
im = hopper()
|
||||
mop = ImageMorph.MorphOp(op_name="erosion8")
|
||||
|
||||
with pytest.raises(ValueError, match="Image mode must be 1 or L"):
|
||||
mop.apply(im)
|
||||
with pytest.raises(ValueError, match="Image mode must be 1 or L"):
|
||||
mop.match(im)
|
||||
with pytest.raises(ValueError, match="Image mode must be 1 or L"):
|
||||
mop.get_on_pixels(im)
|
||||
with hopper() as im:
|
||||
with pytest.raises(ValueError, match="Image mode must be 1 or L"):
|
||||
mop.apply(im)
|
||||
with pytest.raises(ValueError, match="Image mode must be 1 or L"):
|
||||
mop.match(im)
|
||||
with pytest.raises(ValueError, match="Image mode must be 1 or L"):
|
||||
mop.get_on_pixels(im)
|
||||
|
||||
|
||||
def test_add_patterns() -> None:
|
||||
|
||||
@ -256,6 +256,13 @@ 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
|
||||
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from PIL import Image, ImagePalette
|
||||
from PIL import Image, ImagePalette, PaletteFile
|
||||
|
||||
from .helper import assert_image_equal, assert_image_equal_tofile
|
||||
|
||||
@ -22,6 +23,13 @@ def test_reload() -> None:
|
||||
assert_image_equal(im.convert("RGB"), original.convert("RGB"))
|
||||
|
||||
|
||||
def test_save_fp() -> None:
|
||||
palette = ImagePalette.ImagePalette()
|
||||
with io.StringIO() as fp:
|
||||
palette.save(fp)
|
||||
assert not fp.closed
|
||||
|
||||
|
||||
def test_getcolor() -> None:
|
||||
palette = ImagePalette.ImagePalette()
|
||||
assert len(palette.palette) == 0
|
||||
@ -202,6 +210,19 @@ def test_2bit_palette(tmp_path: Path) -> None:
|
||||
assert_image_equal_tofile(img, outfile)
|
||||
|
||||
|
||||
def test_getpalette() -> None:
|
||||
b = io.BytesIO(b"0 1\n1 2 3 4")
|
||||
p = PaletteFile.PaletteFile(b)
|
||||
|
||||
palette, rawmode = p.getpalette()
|
||||
assert palette[:6] == b"\x01\x01\x01\x02\x03\x04"
|
||||
assert rawmode == "RGB"
|
||||
|
||||
|
||||
def test_invalid_palette() -> None:
|
||||
with pytest.raises(OSError):
|
||||
ImagePalette.load("Tests/images/hopper.jpg")
|
||||
|
||||
b = io.BytesIO(b"1" * 101)
|
||||
with pytest.raises(SyntaxError, match="bad palette file"):
|
||||
PaletteFile.PaletteFile(b)
|
||||
|
||||
@ -51,6 +51,7 @@ def test_path() -> None:
|
||||
[0.0, 1.0],
|
||||
((0, 1),),
|
||||
[(0, 1)],
|
||||
[[0, 1]],
|
||||
((0.0, 1.0),),
|
||||
[(0.0, 1.0)],
|
||||
array.array("f", [0, 1]),
|
||||
@ -68,6 +69,34 @@ def test_path_constructors(
|
||||
assert list(p) == [(0.0, 1.0)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"coords, expected",
|
||||
(
|
||||
([[0, 1], [2, 3]], [(0.0, 1.0), (2.0, 3.0)]),
|
||||
([[0.0, 1.0], [2.0, 3.0]], [(0.0, 1.0), (2.0, 3.0)]),
|
||||
),
|
||||
)
|
||||
def test_path_list_of_lists(
|
||||
coords: list[list[float]], expected: list[tuple[float, float]]
|
||||
) -> None:
|
||||
p = ImagePath.Path(coords)
|
||||
assert list(p) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"coords, message",
|
||||
(
|
||||
([[1, 2, 3]], "coordinate list must contain exactly 2 coordinates"),
|
||||
([[1]], "coordinate list must contain exactly 2 coordinates"),
|
||||
([[[1, 2], [3, 4]]], "coordinate list must contain numbers"),
|
||||
([["a", "b"]], "coordinate list must contain numbers"),
|
||||
),
|
||||
)
|
||||
def test_invalid_list_coords(coords: list[list[object]], message: str) -> None:
|
||||
with pytest.raises(ValueError, match=message):
|
||||
ImagePath.Path(coords)
|
||||
|
||||
|
||||
def test_invalid_path_constructors() -> None:
|
||||
# Arrange / Act
|
||||
with pytest.raises(ValueError, match="incorrect coordinate type"):
|
||||
|
||||
@ -108,3 +108,123 @@ def test_stroke() -> None:
|
||||
assert_image_similar_tofile(
|
||||
im, "Tests/images/imagedraw_stroke_" + suffix + ".png", 3.1
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data, width, expected",
|
||||
(
|
||||
("Hello World!", 100, "Hello World!"), # No wrap required
|
||||
("Hello World!", 50, "Hello\nWorld!"), # Wrap word to a new line
|
||||
# Keep multiple spaces within a line
|
||||
("Keep multiple spaces", 90, "Keep multiple\nspaces"),
|
||||
(" Keep\n leading space", 100, " Keep\n leading space"),
|
||||
),
|
||||
)
|
||||
@pytest.mark.parametrize("string", (True, False))
|
||||
def test_wrap(data: str, width: int, expected: str, string: bool) -> None:
|
||||
if string:
|
||||
text = ImageText.Text(data)
|
||||
assert text.wrap(width) is None
|
||||
assert text.text == expected
|
||||
else:
|
||||
text_bytes = ImageText.Text(data.encode())
|
||||
assert text_bytes.wrap(width) is None
|
||||
assert text_bytes.text == expected.encode()
|
||||
|
||||
|
||||
def test_wrap_long_word() -> None:
|
||||
text = ImageText.Text("Hello World!")
|
||||
with pytest.raises(ValueError, match="Word does not fit within line"):
|
||||
text.wrap(25)
|
||||
|
||||
|
||||
def test_wrap_unsupported(font: ImageFont.FreeTypeFont) -> None:
|
||||
transposed_font = ImageFont.TransposedFont(font)
|
||||
text = ImageText.Text("Hello World!", transposed_font)
|
||||
with pytest.raises(ValueError, match="TransposedFont not supported"):
|
||||
text.wrap(50)
|
||||
|
||||
text = ImageText.Text("Hello World!", direction="ttb")
|
||||
with pytest.raises(ValueError, match="Only ltr direction supported"):
|
||||
text.wrap(50)
|
||||
|
||||
|
||||
def test_wrap_height() -> None:
|
||||
width = 50 if features.check_module("freetype2") else 60
|
||||
text = ImageText.Text("Text does not fit within height")
|
||||
wrapped = text.wrap(width, 25 if features.check_module("freetype2") else 40)
|
||||
assert wrapped is not None
|
||||
assert wrapped.text == " within height"
|
||||
assert text.text == "Text does\nnot fit"
|
||||
|
||||
text = ImageText.Text("Text does not fit\nwithin height")
|
||||
wrapped = text.wrap(width, 20)
|
||||
assert wrapped is not None
|
||||
assert wrapped.text == " not fit\nwithin height"
|
||||
assert text.text == "Text does"
|
||||
|
||||
text = ImageText.Text("Text does not fit\n\nwithin height")
|
||||
wrapped = text.wrap(width, 25 if features.check_module("freetype2") else 40)
|
||||
assert wrapped is not None
|
||||
assert wrapped.text == "\nwithin height"
|
||||
assert text.text == "Text does\nnot fit"
|
||||
|
||||
|
||||
def test_wrap_scaling_unsupported() -> None:
|
||||
font = ImageFont.load_default_imagefont()
|
||||
text = ImageText.Text("Hello World!", font)
|
||||
with pytest.raises(ValueError, match="'scaling' only supports FreeTypeFont"):
|
||||
text.wrap(50, scaling="shrink")
|
||||
|
||||
if features.check_module("freetype2"):
|
||||
text = ImageText.Text("Hello World!")
|
||||
with pytest.raises(ValueError, match="'scaling' requires 'height'"):
|
||||
text.wrap(50, scaling="shrink")
|
||||
|
||||
|
||||
@skip_unless_feature("freetype2")
|
||||
def test_wrap_shrink() -> None:
|
||||
# No scaling required
|
||||
text = ImageText.Text("Hello World!")
|
||||
assert isinstance(text.font, ImageFont.FreeTypeFont)
|
||||
assert text.font.size == 10
|
||||
assert text.wrap(50, 50, "shrink") is None
|
||||
assert isinstance(text.font, ImageFont.FreeTypeFont)
|
||||
assert text.font.size == 10
|
||||
|
||||
with pytest.raises(ValueError, match="Text could not be scaled"):
|
||||
text.wrap(50, 15, ("shrink", 9))
|
||||
|
||||
assert text.wrap(50, 15, "shrink") is None
|
||||
assert text.font.size == 8
|
||||
|
||||
text = ImageText.Text("Hello World!")
|
||||
assert text.wrap(50, 15, ("shrink", 7)) is None
|
||||
assert isinstance(text.font, ImageFont.FreeTypeFont)
|
||||
assert text.font.size == 8
|
||||
|
||||
|
||||
@skip_unless_feature("freetype2")
|
||||
def test_wrap_grow() -> None:
|
||||
# No scaling required
|
||||
text = ImageText.Text("Hello World!")
|
||||
assert isinstance(text.font, ImageFont.FreeTypeFont)
|
||||
assert text.font.size == 10
|
||||
assert text.wrap(58, 10, "grow") is None
|
||||
assert isinstance(text.font, ImageFont.FreeTypeFont)
|
||||
assert text.font.size == 10
|
||||
|
||||
with pytest.raises(ValueError, match="Text could not be scaled"):
|
||||
text.wrap(50, 50, ("grow", 12))
|
||||
|
||||
assert text.wrap(50, 50, "grow") is None
|
||||
assert text.font.size == 16
|
||||
|
||||
text = ImageText.Text("A\nB")
|
||||
with pytest.raises(ValueError, match="Text could not be scaled"):
|
||||
text.wrap(50, 10, "grow")
|
||||
|
||||
text = ImageText.Text("Hello World!")
|
||||
assert text.wrap(50, 50, ("grow", 18)) is None
|
||||
assert isinstance(text.font, ImageFont.FreeTypeFont)
|
||||
assert text.font.size == 16
|
||||
|
||||
@ -87,7 +87,7 @@ if is_win32():
|
||||
|
||||
def test_pointer(tmp_path: Path) -> None:
|
||||
im = hopper()
|
||||
(width, height) = im.size
|
||||
width, height = im.size
|
||||
opath = tmp_path / "temp.png"
|
||||
imdib = ImageWin.Dib(im)
|
||||
|
||||
|
||||
@ -208,7 +208,7 @@ INT32 = DataShape(
|
||||
),
|
||||
)
|
||||
def test_fromarray(mode: str, data_tp: DataShape, mask: list[int] | None) -> None:
|
||||
(dtype, elt, elts_per_pixel) = data_tp
|
||||
dtype, elt, elts_per_pixel = data_tp
|
||||
|
||||
ct_pixels = TEST_IMAGE_SIZE[0] * TEST_IMAGE_SIZE[1]
|
||||
if dtype == fl_uint8_4_type:
|
||||
@ -241,7 +241,7 @@ def test_fromarray(mode: str, data_tp: DataShape, mask: list[int] | None) -> Non
|
||||
)
|
||||
@pytest.mark.parametrize("data_tp", (UINT32, INT32))
|
||||
def test_from_int32array(mode: str, mask: list[int] | None, data_tp: DataShape) -> None:
|
||||
(dtype, elt, elts_per_pixel) = data_tp
|
||||
dtype, elt, elts_per_pixel = data_tp
|
||||
|
||||
ct_pixels = TEST_IMAGE_SIZE[0] * TEST_IMAGE_SIZE[1]
|
||||
arr = nanoarrow.Array(
|
||||
|
||||
@ -125,3 +125,8 @@ def test_duplicate_xref_entry() -> None:
|
||||
pdf = PdfParser("Tests/images/duplicate_xref_entry.pdf")
|
||||
assert pdf.xref_table.existing_entries[6][0] == 1197
|
||||
pdf.close()
|
||||
|
||||
|
||||
def test_trailer_loop() -> None:
|
||||
with pytest.raises(PdfFormatError, match="trailer loop found"):
|
||||
PdfParser("Tests/images/trailer_loop.pdf")
|
||||
|
||||
@ -5,6 +5,8 @@ import sys
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from PIL import Image, PSDraw
|
||||
|
||||
|
||||
@ -47,21 +49,16 @@ def test_draw_postscript(tmp_path: Path) -> None:
|
||||
assert os.path.getsize(tempfile) > 0
|
||||
|
||||
|
||||
def test_stdout() -> None:
|
||||
def test_stdout(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Temporarily redirect stdout
|
||||
old_stdout = sys.stdout
|
||||
|
||||
class MyStdOut:
|
||||
buffer = BytesIO()
|
||||
|
||||
mystdout = MyStdOut()
|
||||
|
||||
sys.stdout = mystdout
|
||||
monkeypatch.setattr(sys, "stdout", mystdout)
|
||||
|
||||
ps = PSDraw.PSDraw()
|
||||
_create_document(ps)
|
||||
|
||||
# Reset stdout
|
||||
sys.stdout = old_stdout
|
||||
|
||||
assert mystdout.buffer.getvalue() != b""
|
||||
|
||||
@ -112,8 +112,6 @@ def test_to_array(mode: str, dtype: pyarrow.DataType, mask: list[int] | None) ->
|
||||
|
||||
reloaded = Image.fromarrow(arr, mode, img.size)
|
||||
|
||||
assert reloaded
|
||||
|
||||
assert_image_equal(img, reloaded)
|
||||
|
||||
|
||||
@ -211,7 +209,7 @@ INT32 = DataShape(
|
||||
),
|
||||
)
|
||||
def test_fromarray(mode: str, data_tp: DataShape, mask: list[int] | None) -> None:
|
||||
(dtype, elt, elts_per_pixel) = data_tp
|
||||
dtype, elt, elts_per_pixel = data_tp
|
||||
|
||||
ct_pixels = TEST_IMAGE_SIZE[0] * TEST_IMAGE_SIZE[1]
|
||||
arr = pyarrow.array([elt] * (ct_pixels * elts_per_pixel), type=dtype)
|
||||
@ -238,7 +236,7 @@ def test_fromarray(mode: str, data_tp: DataShape, mask: list[int] | None) -> Non
|
||||
),
|
||||
)
|
||||
def test_from_int32array(mode: str, data_tp: DataShape, mask: list[int] | None) -> None:
|
||||
(dtype, elt, elts_per_pixel) = data_tp
|
||||
dtype, elt, elts_per_pixel = data_tp
|
||||
|
||||
ct_pixels = TEST_IMAGE_SIZE[0] * TEST_IMAGE_SIZE[1]
|
||||
arr = pyarrow.array([elt] * (ct_pixels * elts_per_pixel), type=dtype)
|
||||
|
||||
@ -6,10 +6,15 @@ import pytest
|
||||
|
||||
from PIL import __version__
|
||||
|
||||
TYPE_CHECKING = False
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from importlib.metadata import PackageMetadata
|
||||
|
||||
pyroma = pytest.importorskip("pyroma", reason="Pyroma not installed")
|
||||
|
||||
|
||||
def map_metadata_keys(md):
|
||||
def map_metadata_keys(md: PackageMetadata) -> dict[str, str | list[str] | None]:
|
||||
# Convert installed wheel metadata into canonical Core Metadata 2.4 format.
|
||||
# This was a utility method in pyroma 4.3.3; it was removed in 5.0.
|
||||
# This implementation is constructed from the relevant logic from
|
||||
@ -17,16 +22,16 @@ def map_metadata_keys(md):
|
||||
# upstream to Pyroma as https://github.com/regebro/pyroma/pull/116,
|
||||
# so it may be possible to simplify this test in future.
|
||||
data = {}
|
||||
for key in set(md.keys()):
|
||||
for key in set(md):
|
||||
value = md.get_all(key)
|
||||
key = pyroma.projectdata.normalize(key)
|
||||
|
||||
if len(value) == 1:
|
||||
value = value[0]
|
||||
if value.strip() == "UNKNOWN":
|
||||
continue
|
||||
|
||||
data[key] = value
|
||||
if value is not None and len(value) == 1:
|
||||
first_value = value[0]
|
||||
if first_value.strip() != "UNKNOWN":
|
||||
data[key] = first_value
|
||||
else:
|
||||
data[key] = value
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .helper import assert_image_equal, assert_image_similar, hopper
|
||||
from .helper import assert_image_equal, hopper
|
||||
|
||||
|
||||
def check_upload_equal() -> None:
|
||||
@ -12,4 +12,4 @@ def check_upload_equal() -> None:
|
||||
def check_upload_similar() -> None:
|
||||
result = hopper("P").convert("RGB")
|
||||
target = hopper("RGB")
|
||||
assert_image_similar(result, target, 0)
|
||||
assert_image_equal(result, target)
|
||||
|
||||
@ -5,7 +5,10 @@ archive=$1
|
||||
url=$2
|
||||
|
||||
if [ ! -f $archive.tar.gz ]; then
|
||||
wget --no-verbose -O $archive.tar.gz $url
|
||||
wget -O $archive.tar.gz $url \
|
||||
--no-verbose \
|
||||
--retry-connrefused \
|
||||
--retry-on-http-error=429,503,504
|
||||
fi
|
||||
|
||||
rmdir $archive
|
||||
|
||||
@ -1,68 +1,86 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
version=1.3.0
|
||||
version=1.4.1
|
||||
|
||||
./download-and-extract.sh libavif-$version https://github.com/AOMediaCodec/libavif/archive/refs/tags/v$version.tar.gz
|
||||
if [[ "$GHA_LIBAVIF_CACHE_HIT" == "true" ]]; then
|
||||
|
||||
pushd libavif-$version
|
||||
LIBDIR=/usr/lib/x86_64-linux-gnu
|
||||
|
||||
# Apply patch for SVT-AV1 4.0 compatibility
|
||||
# Pending release of https://github.com/AOMediaCodec/libavif/pull/2971
|
||||
patch -p1 < ../libavif-svt4.patch
|
||||
# Copy cached files into place
|
||||
sudo cp ~/cache-libavif/lib/* $LIBDIR/
|
||||
sudo cp -r ~/cache-libavif/include/avif /usr/include/
|
||||
|
||||
if [ $(uname) == "Darwin" ] && [ -x "$(command -v brew)" ]; then
|
||||
PREFIX=$(brew --prefix)
|
||||
else
|
||||
PREFIX=/usr
|
||||
|
||||
./download-and-extract.sh libavif-$version https://github.com/AOMediaCodec/libavif/archive/refs/tags/v$version.tar.gz
|
||||
|
||||
pushd libavif-$version
|
||||
|
||||
if [ $(uname) == "Darwin" ] && [ -x "$(command -v brew)" ]; then
|
||||
PREFIX=$(brew --prefix)
|
||||
else
|
||||
PREFIX=/usr
|
||||
fi
|
||||
|
||||
PKGCONFIG=${PKGCONFIG:-pkg-config}
|
||||
|
||||
LIBAVIF_CMAKE_FLAGS=()
|
||||
HAS_DECODER=0
|
||||
HAS_ENCODER=0
|
||||
|
||||
if $PKGCONFIG --exists aom; then
|
||||
LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_AOM=SYSTEM)
|
||||
HAS_ENCODER=1
|
||||
HAS_DECODER=1
|
||||
fi
|
||||
|
||||
if $PKGCONFIG --exists dav1d; then
|
||||
LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_DAV1D=SYSTEM)
|
||||
HAS_DECODER=1
|
||||
fi
|
||||
|
||||
if $PKGCONFIG --exists libgav1; then
|
||||
LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_LIBGAV1=SYSTEM)
|
||||
HAS_DECODER=1
|
||||
fi
|
||||
|
||||
if $PKGCONFIG --exists rav1e; then
|
||||
LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_RAV1E=SYSTEM)
|
||||
HAS_ENCODER=1
|
||||
fi
|
||||
|
||||
if $PKGCONFIG --exists SvtAv1Enc; then
|
||||
LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_SVT=SYSTEM)
|
||||
HAS_ENCODER=1
|
||||
fi
|
||||
|
||||
if [ "$HAS_ENCODER" != 1 ] || [ "$HAS_DECODER" != 1 ]; then
|
||||
LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_AOM=LOCAL)
|
||||
fi
|
||||
|
||||
cmake \
|
||||
-DCMAKE_INSTALL_PREFIX=$PREFIX \
|
||||
-DCMAKE_INSTALL_NAME_DIR=$PREFIX/lib \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_MACOSX_RPATH=OFF \
|
||||
-DAVIF_LIBSHARPYUV=LOCAL \
|
||||
-DAVIF_LIBYUV=LOCAL \
|
||||
"${LIBAVIF_CMAKE_FLAGS[@]}" \
|
||||
.
|
||||
|
||||
sudo make install
|
||||
|
||||
if [ -n "$GITHUB_ACTIONS" ] && [ "$(uname)" != "Darwin" ]; then
|
||||
# Copy to cache
|
||||
LIBDIR=/usr/lib/x86_64-linux-gnu
|
||||
rm -rf ~/cache-libavif
|
||||
mkdir -p ~/cache-libavif/lib
|
||||
mkdir -p ~/cache-libavif/include
|
||||
cp $LIBDIR/libavif.so* ~/cache-libavif/lib/
|
||||
cp -r /usr/include/avif ~/cache-libavif/include/
|
||||
fi
|
||||
|
||||
popd
|
||||
|
||||
fi
|
||||
|
||||
PKGCONFIG=${PKGCONFIG:-pkg-config}
|
||||
|
||||
LIBAVIF_CMAKE_FLAGS=()
|
||||
HAS_DECODER=0
|
||||
HAS_ENCODER=0
|
||||
|
||||
if $PKGCONFIG --exists aom; then
|
||||
LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_AOM=SYSTEM)
|
||||
HAS_ENCODER=1
|
||||
HAS_DECODER=1
|
||||
fi
|
||||
|
||||
if $PKGCONFIG --exists dav1d; then
|
||||
LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_DAV1D=SYSTEM)
|
||||
HAS_DECODER=1
|
||||
fi
|
||||
|
||||
if $PKGCONFIG --exists libgav1; then
|
||||
LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_LIBGAV1=SYSTEM)
|
||||
HAS_DECODER=1
|
||||
fi
|
||||
|
||||
if $PKGCONFIG --exists rav1e; then
|
||||
LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_RAV1E=SYSTEM)
|
||||
HAS_ENCODER=1
|
||||
fi
|
||||
|
||||
if $PKGCONFIG --exists SvtAv1Enc; then
|
||||
LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_SVT=SYSTEM)
|
||||
HAS_ENCODER=1
|
||||
fi
|
||||
|
||||
if [ "$HAS_ENCODER" != 1 ] || [ "$HAS_DECODER" != 1 ]; then
|
||||
LIBAVIF_CMAKE_FLAGS+=(-DAVIF_CODEC_AOM=LOCAL)
|
||||
fi
|
||||
|
||||
cmake \
|
||||
-DCMAKE_INSTALL_PREFIX=$PREFIX \
|
||||
-DCMAKE_INSTALL_NAME_DIR=$PREFIX/lib \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_MACOSX_RPATH=OFF \
|
||||
-DAVIF_LIBSHARPYUV=LOCAL \
|
||||
-DAVIF_LIBYUV=LOCAL \
|
||||
"${LIBAVIF_CMAKE_FLAGS[@]}" \
|
||||
.
|
||||
|
||||
make install
|
||||
|
||||
popd
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
# install raqm
|
||||
|
||||
|
||||
archive=libraqm-0.10.3
|
||||
archive=libraqm-0.10.5
|
||||
|
||||
./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz
|
||||
|
||||
|
||||
@ -3,10 +3,30 @@
|
||||
|
||||
archive=libwebp-1.6.0
|
||||
|
||||
./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz
|
||||
if [[ "$GHA_LIBWEBP_CACHE_HIT" == "true" ]]; then
|
||||
|
||||
pushd $archive
|
||||
# Copy cached files into place
|
||||
sudo cp ~/cache-libwebp/lib/* /usr/lib/
|
||||
sudo cp -r ~/cache-libwebp/include/webp /usr/include/
|
||||
|
||||
./configure --prefix=/usr --enable-libwebpmux --enable-libwebpdemux && make -j4 && sudo make -j4 install
|
||||
else
|
||||
|
||||
popd
|
||||
./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz
|
||||
|
||||
pushd $archive
|
||||
|
||||
./configure --prefix=/usr --enable-libwebpmux --enable-libwebpdemux && make -j4 && sudo make -j4 install
|
||||
|
||||
if [ -n "$GITHUB_ACTIONS" ]; then
|
||||
# Copy to cache
|
||||
rm -rf ~/cache-libwebp
|
||||
mkdir -p ~/cache-libwebp/lib
|
||||
mkdir -p ~/cache-libwebp/include
|
||||
cp /usr/lib/libwebp*.so* /usr/lib/libwebp*.a ~/cache-libwebp/lib/
|
||||
cp /usr/lib/libsharpyuv.so* /usr/lib/libsharpyuv.a ~/cache-libwebp/lib/
|
||||
cp -r /usr/include/webp ~/cache-libwebp/include/
|
||||
fi
|
||||
|
||||
popd
|
||||
|
||||
fi
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
--- a/src/codec_svt.c
|
||||
+++ b/src/codec_svt.c
|
||||
@@ -162,7 +162,11 @@ static avifResult svtCodecEncodeImage(avifEncoder * encoder,
|
||||
#else
|
||||
svt_config->logical_processors = encoder->maxThreads;
|
||||
#endif
|
||||
+#if SVT_AV1_CHECK_VERSION(4, 0, 0)
|
||||
+ svt_config->aq_mode = 2;
|
||||
+#else
|
||||
svt_config->enable_adaptive_quantization = 2;
|
||||
+#endif
|
||||
// disable 2-pass
|
||||
#if SVT_AV1_CHECK_VERSION(0, 9, 0)
|
||||
svt_config->rc_stats_buffer = (SvtAv1FixedBuf) { NULL, 0 };
|
||||
@ -5,7 +5,7 @@ The Python Imaging Library (PIL) is
|
||||
|
||||
Pillow is the friendly PIL fork. It is
|
||||
|
||||
Copyright © 2010 by Jeffrey A. Clark and contributors
|
||||
Copyright © 2010 by Jeffrey 'Alex' Clark and contributors
|
||||
|
||||
Like PIL, Pillow is licensed under the open source PIL
|
||||
Software License:
|
||||
|
||||
@ -55,9 +55,9 @@ master_doc = "index"
|
||||
project = "Pillow (PIL Fork)"
|
||||
copyright = (
|
||||
"1995-2011 Fredrik Lundh and contributors, "
|
||||
"2010 Jeffrey A. Clark and contributors."
|
||||
"2010 Jeffrey 'Alex' Clark and contributors."
|
||||
)
|
||||
author = "Fredrik Lundh (PIL), Jeffrey A. Clark (Pillow)"
|
||||
author = "Fredrik Lundh (PIL), Jeffrey 'Alex' Clark (Pillow)"
|
||||
|
||||
# The version info for the project you're documenting, acts as replacement for
|
||||
# |version| and |release|, also used in various other places throughout the
|
||||
|
||||
@ -11,7 +11,7 @@ import subprocess
|
||||
|
||||
TYPE_CHECKING = False
|
||||
if TYPE_CHECKING:
|
||||
from sphinx.application import Sphinx
|
||||
from typing import Any
|
||||
|
||||
DOC_NAME_REGEX = re.compile(r"releasenotes/\d+\.\d+\.\d+")
|
||||
VERSION_TITLE_REGEX = re.compile(r"^(\d+\.\d+\.\d+)\n-+\n")
|
||||
@ -28,7 +28,7 @@ def get_date_for(git_version: str) -> str | None:
|
||||
return out.split()[0]
|
||||
|
||||
|
||||
def add_date(app: Sphinx, doc_name: str, source: list[str]) -> None:
|
||||
def add_date(app: Any, doc_name: str, source: list[str]) -> None:
|
||||
if DOC_NAME_REGEX.match(doc_name) and (m := VERSION_TITLE_REGEX.match(source[0])):
|
||||
old_title = m.group(1)
|
||||
|
||||
@ -43,6 +43,6 @@ def add_date(app: Sphinx, doc_name: str, source: list[str]) -> None:
|
||||
source[0] = result
|
||||
|
||||
|
||||
def setup(app: Sphinx) -> dict[str, bool]:
|
||||
def setup(app: Any) -> dict[str, bool]:
|
||||
app.connect("source-read", add_date)
|
||||
return {"parallel_read_safe": True}
|
||||
|
||||
@ -828,16 +828,6 @@ PCX
|
||||
|
||||
Pillow reads and writes PCX files containing ``1``, ``L``, ``P``, or ``RGB`` data.
|
||||
|
||||
PFM
|
||||
^^^
|
||||
|
||||
.. versionadded:: 10.3.0
|
||||
|
||||
Pillow reads and writes grayscale (Pf format) Portable FloatMap (PFM) files
|
||||
containing ``F`` data.
|
||||
|
||||
Color (PF format) PFM files are not supported.
|
||||
|
||||
Opening
|
||||
~~~~~~~
|
||||
|
||||
@ -1081,12 +1071,19 @@ following parameters can also be set:
|
||||
PPM
|
||||
^^^
|
||||
|
||||
Pillow reads and writes PBM, PGM, PPM and PNM files containing ``1``, ``L``, ``I`` or
|
||||
``RGB`` data.
|
||||
Pillow reads and writes PBM, PGM, PPM, PNM and PFM files containing ``1``, ``L``, ``I``,
|
||||
``RGB`` or ``F`` data.
|
||||
|
||||
"Raw" (P4 to P6) formats can be read, and are used when writing.
|
||||
|
||||
Since Pillow 9.2.0, "plain" (P1 to P3) formats can be read as well.
|
||||
.. versionadded:: 9.2.0
|
||||
"Plain" (P1 to P3) formats can be read.
|
||||
|
||||
.. versionadded:: 10.3.0
|
||||
Grayscale (Pf format) Portable FloatMap (PFM) files containing
|
||||
``F`` data can be read and used when writing.
|
||||
|
||||
Color (PF format) PFM files are not supported.
|
||||
|
||||
QOI
|
||||
^^^
|
||||
|
||||
@ -8,3 +8,4 @@ Handbook
|
||||
tutorial
|
||||
concepts
|
||||
appendices
|
||||
security
|
||||
|
||||
259
docs/handbook/security.rst
Normal file
259
docs/handbook/security.rst
Normal file
@ -0,0 +1,259 @@
|
||||
Security
|
||||
========
|
||||
|
||||
Pillow's primary attack surface is **parsing untrusted image data**. This page
|
||||
documents the threat model for developers integrating Pillow into applications
|
||||
that handle images from untrusted sources, along with recommended mitigations.
|
||||
|
||||
To report a vulnerability see :ref:`security-reporting`.
|
||||
|
||||
.. _security-threat-model:
|
||||
|
||||
Threat model (STRIDE)
|
||||
---------------------
|
||||
|
||||
The analysis below follows the `STRIDE
|
||||
<https://en.wikipedia.org/wiki/STRIDE_model>`_ framework and covers the
|
||||
boundary between untrusted image input and the Pillow API.
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
┌──────────────────────────────────────────┐
|
||||
Untrusted zone │ Pillow API │
|
||||
───────────── │ │
|
||||
Image files ────►│ Image.open() ──► Format plugins │
|
||||
Byte streams │ (40+ parsers) (Python + C FFI) │
|
||||
User metadata │ │
|
||||
│ ImageMath.unsafe_eval(expr) ───────────┼──► Python eval()
|
||||
│ ImageShow.show(image) ─────────────────┼──► os.system / subprocess
|
||||
│ EpsImagePlugin.open(eps) ──────────────┼──► Ghostscript (gs)
|
||||
└──────────────┬───────────────────────────┘
|
||||
│ C extensions:
|
||||
│ _imaging · _imagingft · _imagingcms
|
||||
│ _webp · _avif · _imagingtk
|
||||
│ _imagingmath · _imagingmorph
|
||||
▼
|
||||
┌──────────────────────────────────────────┐
|
||||
│ C libraries (bundled or system) │
|
||||
│ libjpeg · libpng · libtiff · libwebp │
|
||||
│ openjpeg · freetype · littlecms2 │
|
||||
└──────────────────────────────────────────┘
|
||||
|
||||
Spoofing
|
||||
^^^^^^^^
|
||||
|
||||
**S-1 — Format sniffing bypass**
|
||||
|
||||
``Image.open()`` detects format by magic bytes, not file extension or MIME
|
||||
type. An attacker can name a file ``safe.png`` while its content is TIFF, JPEG
|
||||
2000, or EPS, causing a different — potentially more dangerous — parser to run.
|
||||
|
||||
*Mitigations:* validate MIME type and magic bytes independently before calling
|
||||
``Image.open()``; pass the ``formats`` argument with an allowlist of accepted
|
||||
formats.
|
||||
|
||||
**S-2 — Plugin registry spoofing**
|
||||
|
||||
Pillow's format registry is a global mutable dictionary. A malicious package
|
||||
installed in the same environment could register a replacement parser for a
|
||||
well-known format.
|
||||
|
||||
*Mitigations:* use isolated virtual environments with pinned, hash-verified
|
||||
dependencies; audit ``Image.registered_extensions()`` at startup.
|
||||
|
||||
Tampering
|
||||
^^^^^^^^^
|
||||
|
||||
**T-1 — Malicious metadata propagation**
|
||||
|
||||
Pillow preserves EXIF, XMP, IPTC, ICC profiles, and comments when
|
||||
round-tripping images. Applications that store or render metadata without
|
||||
sanitisation are vulnerable to second-order injection (SQLi, XSS, command
|
||||
injection).
|
||||
|
||||
*Mitigations:* treat all values from ``image.info``, ``image._getexif()``,
|
||||
``image.getexif()``, and ``image.text`` as untrusted; sanitise before storing
|
||||
or rendering; strip metadata when it is not required.
|
||||
|
||||
**T-2 — Covert data channel (steganography)**
|
||||
|
||||
Pillow does not remove hidden data (JPEG comments, PNG text chunks) when
|
||||
re-saving. An attacker can embed data that survives the
|
||||
encode-decode cycle invisibly.
|
||||
|
||||
*Mitigations:* to guarantee a clean output when saving, create a new image instance via
|
||||
``image.copy()`` and delete the ``image.info`` contents.
|
||||
|
||||
**T-3 — Supply chain tampering**
|
||||
|
||||
Pre-compiled wheels bundle libjpeg-turbo, libpng, libtiff, libwebp, openjpeg,
|
||||
freetype, littlecms2, and other libraries. A compromised PyPI release or build pipeline
|
||||
could ship malicious binaries.
|
||||
|
||||
*Mitigations:* pin with hash verification
|
||||
(``python3 -m pip install --require-hashes``); monitor `Pillow security advisories
|
||||
<https://github.com/python-pillow/Pillow/security/advisories>`_; use
|
||||
Dependabot or OSV-Scanner for bundled C library CVEs.
|
||||
|
||||
Repudiation
|
||||
^^^^^^^^^^^
|
||||
|
||||
**R-1 — No structured audit trail**
|
||||
|
||||
Without application-level logging there is no record of which images were
|
||||
opened, what formats were detected, or what operations were performed, making
|
||||
forensic investigation harder after an incident.
|
||||
|
||||
*Mitigations:* log the filename/hash, detected format, and dimensions of every
|
||||
image processed; log and alert on ``Image.DecompressionBombWarning``,
|
||||
``Image.DecompressionBombError``, and ``PIL.UnidentifiedImageError``.
|
||||
|
||||
Information disclosure
|
||||
^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
**I-1 — Metadata in saved images**
|
||||
|
||||
GPS coordinates, author names, software version strings, and ICC profiles can
|
||||
be inadvertently included in output images served publicly.
|
||||
|
||||
*Mitigations:* explicitly strip EXIF and XMP on save (set ``exif=b""``,
|
||||
``icc_profile=None``, omit ``pnginfo``); verify output with ``exiftool`` in CI.
|
||||
|
||||
**I-2 — Temporary file exposure**
|
||||
|
||||
Several code paths write pixel data to temporary files via
|
||||
``tempfile.mkstemp()``. Exception paths can leave these files behind on shared
|
||||
filesystems.
|
||||
|
||||
*Mitigations:* files are created with mode ``0o600``; mount ``/tmp`` as a
|
||||
per-container ``tmpfs``; ensure ``try/finally`` cleanup is in place.
|
||||
|
||||
Denial of service
|
||||
^^^^^^^^^^^^^^^^^
|
||||
|
||||
**D-1 — Decompression bomb**
|
||||
|
||||
A small compressed image can expand to gigabytes in memory.
|
||||
:py:data:`PIL.Image.MAX_IMAGE_PIXELS` raises
|
||||
``Image.DecompressionBombError`` at 2× the limit and
|
||||
``Image.DecompressionBombWarning`` at 1×. PNG text chunks are
|
||||
separately capped by ``PngImagePlugin.MAX_TEXT_CHUNK`` and
|
||||
``MAX_TEXT_MEMORY``. Check the values in your installed Pillow version at
|
||||
runtime or in the reference/source for the current defaults.
|
||||
|
||||
*Mitigations:* **never** set ``Image.MAX_IMAGE_PIXELS = None`` in production;
|
||||
treat ``Image.DecompressionBombWarning`` as an error; set OS/container memory limits
|
||||
per worker.
|
||||
|
||||
**D-2 — CPU exhaustion**
|
||||
|
||||
Large-but-legal images (within ``MAX_IMAGE_PIXELS``) can still saturate CPU
|
||||
through high-quality resampling, convolution filters, or complex draw
|
||||
operations.
|
||||
|
||||
*Mitigations:* apply per-request CPU time limits; set a practical dimension
|
||||
ceiling below ``MAX_IMAGE_PIXELS``; rate-limit processing requests.
|
||||
|
||||
**D-3 — Algorithmic complexity in parsers**
|
||||
|
||||
Formats such as TIFF (nested IFD chains), animated GIF/WebP (many frames), and
|
||||
PNG (many text chunks) can exhaust CPU or memory before pixel data is decoded.
|
||||
|
||||
*Mitigations:* restrict accepted formats to the minimum required; enforce a
|
||||
file-size limit before passing data to Pillow; use per-request timeouts.
|
||||
|
||||
Elevation of privilege
|
||||
^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
**E-1 — C extension memory corruption (RCE)**
|
||||
|
||||
Pillow's ~87 C source files and its bundled C libraries process
|
||||
attacker-controlled bytes. Historical CVEs include buffer overflows, integer
|
||||
overflows, and use-after-free vulnerabilities that allow arbitrary code
|
||||
execution.
|
||||
|
||||
*Mitigations:* keep Pillow and all C libraries up to date; compile with
|
||||
hardening flags (ASLR, stack canaries, PIE, ``_FORTIFY_SOURCE=2``); run image
|
||||
processing in a sandboxed subprocess (seccomp-bpf, AppArmor, or a restricted
|
||||
container).
|
||||
|
||||
**E-2 — Ghostscript exploitation via EPS (RCE)**
|
||||
|
||||
Opening an EPS file invokes the system Ghostscript binary (``gs``) via
|
||||
``subprocess``. Ghostscript has a long history of sandbox-escape CVEs
|
||||
permitting arbitrary code execution from malicious PostScript.
|
||||
|
||||
*Mitigations:* **block EPS files** at the application input layer before
|
||||
passing files to Pillow; if EPS must be supported, run Ghostscript in a fully
|
||||
isolated sandbox with no network and no sensitive mounts. Pillow does not
|
||||
provide a stable public API for unregistering individual format plugins, so do
|
||||
not rely on mutating internal registries such as ``Image.OPEN`` as a security
|
||||
control.
|
||||
|
||||
|
||||
**E-3 — ImageMath.unsafe_eval() code injection**
|
||||
|
||||
:py:meth:`~PIL.ImageMath.unsafe_eval` calls Python's built-in ``eval()`` with
|
||||
only a minimal ``__builtins__`` restriction, which can be bypassed via
|
||||
introspection. Any user-controlled string passed to this function results in
|
||||
arbitrary code execution.
|
||||
|
||||
*Mitigations:* **never** pass user-controlled strings to
|
||||
``ImageMath.unsafe_eval()``; use :py:meth:`~PIL.ImageMath.lambda_eval` instead,
|
||||
which accepts a Python callable and never calls ``eval``.
|
||||
|
||||
**E-4 — Font path traversal via ImageFont**
|
||||
|
||||
``ImageFont.truetype(font, size)`` passes the filename to the FreeType C
|
||||
library. If font paths are constructed from user input without
|
||||
canonicalisation, an attacker may supply a path like
|
||||
``../../../../etc/passwd``.
|
||||
|
||||
*Mitigations:* never construct font paths from user input; if font selection
|
||||
must be user-driven, resolve names against an explicit allowlist of
|
||||
pre-validated absolute paths.
|
||||
|
||||
.. _security-recommendations:
|
||||
|
||||
Recommendations
|
||||
---------------
|
||||
|
||||
The following mitigations are listed in priority order.
|
||||
|
||||
1. **Sandbox image processing** — run Pillow workers in a seccomp/AppArmor
|
||||
restricted subprocess, isolated from the main application process.
|
||||
2. **Block or sandbox EPS** — reject EPS at the application boundary, or run
|
||||
Ghostscript in an isolated container.
|
||||
3. **Never use** ``ImageMath.unsafe_eval()`` **with user input** — migrate all
|
||||
callers to :py:meth:`~PIL.ImageMath.lambda_eval`.
|
||||
4. **Keep all dependencies current** — Pillow and its C library dependencies
|
||||
(including libjpeg, libpng, libtiff, libwebp, openjpeg, freetype,
|
||||
littlecms2, Ghostscript, and others). Subscribe to `Pillow security
|
||||
advisories <https://github.com/python-pillow/Pillow/security/advisories>`_.
|
||||
5. **Enforce** ``MAX_IMAGE_PIXELS`` — never set it to ``None``; treat
|
||||
``Image.DecompressionBombWarning`` as an error.
|
||||
6. **Allowlist image formats** — restrict accepted formats when opening
|
||||
images, for example with ``Image.open(..., formats=...)``, and isolate
|
||||
installs/environments if you need to minimise supported formats.
|
||||
7. **Strip metadata on output** — never pass through EXIF/XMP/ICC from user
|
||||
uploads to publicly served images.
|
||||
8. **Sanitise all metadata** returned by Pillow before using it downstream.
|
||||
9. **Pin dependencies with hash verification** — use
|
||||
``pip install --require-hashes`` and lockfiles.
|
||||
10. **Log and alert** on ``Image.DecompressionBombWarning``,
|
||||
``Image.DecompressionBombError``, ``PIL.UnidentifiedImageError``,
|
||||
and all exceptions from ``Image.open()``.
|
||||
|
||||
.. _security-reporting:
|
||||
|
||||
Reporting a vulnerability
|
||||
-------------------------
|
||||
|
||||
To report sensitive vulnerability information, report it `privately on GitHub
|
||||
<https://github.com/python-pillow/Pillow/security/advisories/new>`_.
|
||||
|
||||
If you cannot use GitHub, use the `Tidelift security contact
|
||||
<https://tidelift.com/docs/security>`_. Tidelift will coordinate the fix and
|
||||
disclosure.
|
||||
|
||||
**Do not report sensitive vulnerability information in public.**
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user