zerobucket 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {zerobucket-0.2.0 → zerobucket-0.3.0}/PKG-INFO +8 -3
- {zerobucket-0.2.0 → zerobucket-0.3.0}/README.md +4 -2
- zerobucket-0.3.0/floor +0 -0
- zerobucket-0.3.0/np.ndarray +0 -0
- {zerobucket-0.2.0 → zerobucket-0.3.0}/pyproject.toml +5 -1
- {zerobucket-0.2.0 → zerobucket-0.3.0}/src/zerobucket/__init__.py +1 -1
- {zerobucket-0.2.0 → zerobucket-0.3.0}/src/zerobucket/client.py +2 -1
- {zerobucket-0.2.0 → zerobucket-0.3.0}/src/zerobucket/optimization.py +40 -14
- zerobucket-0.3.0/src/zerobucket/py.typed +0 -0
- {zerobucket-0.2.0 → zerobucket-0.3.0}/src/zerobucket/validation.py +43 -3
- zerobucket-0.3.0/tests/__init__.py +0 -0
- {zerobucket-0.2.0 → zerobucket-0.3.0}/tests/test_optimization.py +56 -6
- {zerobucket-0.2.0 → zerobucket-0.3.0}/tests/test_validation.py +61 -2
- {zerobucket-0.2.0 → zerobucket-0.3.0}/.gitignore +0 -0
- /zerobucket-0.2.0/src/zerobucket/py.typed → /zerobucket-0.3.0/0 +0 -0
- /zerobucket-0.2.0/tests/__init__.py → /zerobucket-0.3.0/float +0 -0
- {zerobucket-0.2.0 → zerobucket-0.3.0}/src/zerobucket/adapters/__init__.py +0 -0
- {zerobucket-0.2.0 → zerobucket-0.3.0}/src/zerobucket/adapters/base.py +0 -0
- {zerobucket-0.2.0 → zerobucket-0.3.0}/src/zerobucket/adapters/postgres.py +0 -0
- {zerobucket-0.2.0 → zerobucket-0.3.0}/src/zerobucket/exceptions.py +0 -0
- {zerobucket-0.2.0 → zerobucket-0.3.0}/src/zerobucket/types.py +0 -0
- {zerobucket-0.2.0 → zerobucket-0.3.0}/tests/conftest.py +0 -0
- {zerobucket-0.2.0 → zerobucket-0.3.0}/tests/photo_fixtures.py +0 -0
- {zerobucket-0.2.0 → zerobucket-0.3.0}/tests/test_client.py +0 -0
- {zerobucket-0.2.0 → zerobucket-0.3.0}/tests/test_errors.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: zerobucket
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Database-native image storage. Your database. Your images. Zero buckets.
|
|
5
5
|
Project-URL: Homepage, https://github.com/KedarGhadyalji/ZeroBucket
|
|
6
6
|
Project-URL: Repository, https://github.com/KedarGhadyalji/ZeroBucket
|
|
@@ -27,10 +27,13 @@ Requires-Dist: psycopg[binary]>=3.1
|
|
|
27
27
|
Provides-Extra: dev
|
|
28
28
|
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
29
29
|
Requires-Dist: numpy>=1.26; extra == 'dev'
|
|
30
|
+
Requires-Dist: pillow-heif>=0.13; extra == 'dev'
|
|
30
31
|
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
31
32
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
32
33
|
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
33
34
|
Requires-Dist: scikit-image>=0.22; extra == 'dev'
|
|
35
|
+
Provides-Extra: heic
|
|
36
|
+
Requires-Dist: pillow-heif>=0.13; extra == 'heic'
|
|
34
37
|
Description-Content-Type: text/markdown
|
|
35
38
|
|
|
36
39
|
# ZeroBucket
|
|
@@ -146,8 +149,10 @@ on GitHub for the full methodology.
|
|
|
146
149
|
|
|
147
150
|
## What it validates
|
|
148
151
|
|
|
149
|
-
- **Format**: JPEG, PNG, WebP
|
|
150
|
-
|
|
152
|
+
- **Format**: JPEG, PNG, WebP built in, plus HEIC/HEIF (iPhone photos) via
|
|
153
|
+
the optional `pip install zerobucket[heic]` extra -- detected from
|
|
154
|
+
actual file content, never from filename extension or a client-supplied
|
|
155
|
+
`Content-Type` header.
|
|
151
156
|
- **Corruption**: truncated or malformed images are decoded and rejected
|
|
152
157
|
before they reach the database.
|
|
153
158
|
- **Decompression bombs**: a tiny compressed file that decodes to an
|
|
@@ -111,8 +111,10 @@ on GitHub for the full methodology.
|
|
|
111
111
|
|
|
112
112
|
## What it validates
|
|
113
113
|
|
|
114
|
-
- **Format**: JPEG, PNG, WebP
|
|
115
|
-
|
|
114
|
+
- **Format**: JPEG, PNG, WebP built in, plus HEIC/HEIF (iPhone photos) via
|
|
115
|
+
the optional `pip install zerobucket[heic]` extra -- detected from
|
|
116
|
+
actual file content, never from filename extension or a client-supplied
|
|
117
|
+
`Content-Type` header.
|
|
116
118
|
- **Corruption**: truncated or malformed images are decoded and rejected
|
|
117
119
|
before they reach the database.
|
|
118
120
|
- **Decompression bombs**: a tiny compressed file that decodes to an
|
zerobucket-0.3.0/floor
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "zerobucket"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Database-native image storage. Your database. Your images. Zero buckets."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -31,6 +31,9 @@ dependencies = [
|
|
|
31
31
|
]
|
|
32
32
|
|
|
33
33
|
[project.optional-dependencies]
|
|
34
|
+
heic = [
|
|
35
|
+
"pillow-heif>=0.13",
|
|
36
|
+
]
|
|
34
37
|
dev = [
|
|
35
38
|
"pytest>=8.0",
|
|
36
39
|
"pytest-cov>=5.0",
|
|
@@ -38,6 +41,7 @@ dev = [
|
|
|
38
41
|
"mypy>=1.10",
|
|
39
42
|
"scikit-image>=0.22",
|
|
40
43
|
"numpy>=1.26",
|
|
44
|
+
"pillow-heif>=0.13",
|
|
41
45
|
]
|
|
42
46
|
|
|
43
47
|
[project.urls]
|
|
@@ -93,7 +93,8 @@ class ZeroBucket:
|
|
|
93
93
|
bytes are stored as-is.
|
|
94
94
|
max_width: Downscale if wider than this (aspect ratio
|
|
95
95
|
preserved). Only applies when optimize=True.
|
|
96
|
-
format: Re-encode target -- "jpeg", "png",
|
|
96
|
+
format: Re-encode target -- "jpeg", "png", "webp", or
|
|
97
|
+
"heic"/"heif". None
|
|
97
98
|
keeps the original format. Only applies when
|
|
98
99
|
optimize=True. Note: quality has no effect when the
|
|
99
100
|
target (or original) format is PNG -- PNG has no lossy
|
|
@@ -27,19 +27,30 @@ from dataclasses import dataclass
|
|
|
27
27
|
from PIL import Image as PILImage
|
|
28
28
|
|
|
29
29
|
from .exceptions import ImageValidationError
|
|
30
|
-
from .validation import
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
30
|
+
from .validation import (
|
|
31
|
+
DEFAULT_MAX_PIXELS,
|
|
32
|
+
HEIF_SUPPORT_INSTALLED,
|
|
33
|
+
SUPPORTED_FORMATS,
|
|
34
|
+
validate_image,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Normalizes user-facing target_format strings to what Pillow's save()
|
|
38
|
+
# actually expects. Most formats are their own name uppercased, but HEIC
|
|
39
|
+
# is the one exception: Pillow (via pillow-heif) only recognizes the save
|
|
40
|
+
# format string "HEIF", never "HEIC" -- even though "HEIC" is what the
|
|
41
|
+
# file extension and most people call it. Verified empirically; passing
|
|
42
|
+
# format="HEIC" directly to Pillow raises KeyError, not a graceful error.
|
|
43
|
+
_FORMAT_ALIASES = {
|
|
44
|
+
"HEIC": "HEIF",
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
DEFAULT_JPEG_QUALITY = 90
|
|
42
48
|
DEFAULT_WEBP_QUALITY = 88
|
|
49
|
+
# NOT measured with the same SSIM methodology as the two defaults above
|
|
50
|
+
# (see benchmarks/COMPRESSION_RESULTS.md) -- this is a reasonable starting
|
|
51
|
+
# point, not a verified claim. Treat it as provisional until it's been
|
|
52
|
+
# through the same measurement process.
|
|
53
|
+
DEFAULT_HEIC_QUALITY = 90
|
|
43
54
|
|
|
44
55
|
|
|
45
56
|
@dataclass(frozen=True, slots=True)
|
|
@@ -81,9 +92,13 @@ def optimize_image(
|
|
|
81
92
|
(aspect ratio preserved, LANCZOS resampling -- the sharpest
|
|
82
93
|
standard downscale filter, chosen specifically to avoid
|
|
83
94
|
introducing blur that a cheaper filter like BILINEAR would).
|
|
84
|
-
target_format: "jpeg", "png", or "
|
|
85
|
-
format. Converting a PNG to WebP/JPEG is usually
|
|
86
|
-
call when the PNG is actually a photo, not
|
|
95
|
+
target_format: "jpeg", "png", "webp", or "heic"/"heif". None keeps
|
|
96
|
+
the original format. Converting a PNG to WebP/JPEG is usually
|
|
97
|
+
the right call when the PNG is actually a photo, not
|
|
98
|
+
flat-color art. Converting TO heic requires the optional
|
|
99
|
+
pillow-heif dependency; if it's missing, this raises
|
|
100
|
+
ImageValidationError with an install hint rather than
|
|
101
|
+
producing an unclear internal error.
|
|
87
102
|
quality: 1-100. Only meaningful for JPEG/WebP output -- PNG has
|
|
88
103
|
no lossy quality knob and this is ignored for PNG targets.
|
|
89
104
|
None uses DEFAULT_JPEG_QUALITY / DEFAULT_WEBP_QUALITY.
|
|
@@ -117,8 +132,12 @@ def optimize_image(
|
|
|
117
132
|
working = img
|
|
118
133
|
|
|
119
134
|
output_format = (target_format or source_format or "JPEG").upper()
|
|
135
|
+
output_format = _FORMAT_ALIASES.get(output_format, output_format)
|
|
120
136
|
if output_format == "JPEG" and working.mode in ("RGBA", "P", "LA"):
|
|
121
137
|
working = working.convert("RGB")
|
|
138
|
+
# Note: unlike JPEG, HEIF encoding via pillow-heif handles RGBA
|
|
139
|
+
# source images fine (verified empirically) -- no forced conversion
|
|
140
|
+
# needed there.
|
|
122
141
|
|
|
123
142
|
if max_width is not None and working.width > max_width:
|
|
124
143
|
ratio = max_width / working.width
|
|
@@ -138,8 +157,15 @@ def optimize_image(
|
|
|
138
157
|
save_kwargs = {
|
|
139
158
|
"quality": quality or DEFAULT_WEBP_QUALITY,
|
|
140
159
|
"method": 6, # slowest, best-compression effort; fine for
|
|
141
|
-
|
|
160
|
+
# a one-time encode on upload, not a hot path
|
|
142
161
|
}
|
|
162
|
+
elif output_format == "HEIF":
|
|
163
|
+
if not HEIF_SUPPORT_INSTALLED:
|
|
164
|
+
raise ImageValidationError(
|
|
165
|
+
"Converting to HEIC/HEIF requires an optional dependency. "
|
|
166
|
+
"Install it with: pip install zerobucket[heic]"
|
|
167
|
+
)
|
|
168
|
+
save_kwargs = {"quality": quality or DEFAULT_HEIC_QUALITY}
|
|
143
169
|
elif output_format == "PNG":
|
|
144
170
|
# No lossy quality knob for PNG. `quality` is silently ignored
|
|
145
171
|
# here by design -- see the docstring above.
|
|
@@ -169,4 +195,4 @@ def optimize_image(
|
|
|
169
195
|
height=revalidated.height,
|
|
170
196
|
size_bytes=revalidated.size_bytes,
|
|
171
197
|
original_size_bytes=original_size,
|
|
172
|
-
)
|
|
198
|
+
)
|
|
File without changes
|
|
@@ -15,6 +15,7 @@ from PIL import Image as PILImage
|
|
|
15
15
|
from .exceptions import (
|
|
16
16
|
CorruptedImageError,
|
|
17
17
|
ImageTooLargeError,
|
|
18
|
+
ImageValidationError,
|
|
18
19
|
UnsupportedFormatError,
|
|
19
20
|
)
|
|
20
21
|
|
|
@@ -26,6 +27,20 @@ _FORMAT_TO_MIME = {
|
|
|
26
27
|
"WEBP": "image/webp",
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
# HEIC/HEIF support is optional (pip install zerobucket[heic]) because
|
|
31
|
+
# pillow-heif pulls in a native libheif wheel -- not everyone needs it, and
|
|
32
|
+
# we don't want it in the default install. If it's present, registering the
|
|
33
|
+
# opener makes Image.open()/save() handle HEIC transparently everywhere
|
|
34
|
+
# else in this codebase; no other code needs to know HEIC exists.
|
|
35
|
+
try:
|
|
36
|
+
import pillow_heif
|
|
37
|
+
|
|
38
|
+
pillow_heif.register_heif_opener()
|
|
39
|
+
_FORMAT_TO_MIME["HEIF"] = "image/heic"
|
|
40
|
+
HEIF_SUPPORT_INSTALLED = True
|
|
41
|
+
except ImportError:
|
|
42
|
+
HEIF_SUPPORT_INSTALLED = False
|
|
43
|
+
|
|
29
44
|
SUPPORTED_FORMATS = frozenset(_FORMAT_TO_MIME)
|
|
30
45
|
|
|
31
46
|
# Guard against decompression bombs: reject images that would decode to
|
|
@@ -44,6 +59,22 @@ class ValidatedImage:
|
|
|
44
59
|
size_bytes: int
|
|
45
60
|
|
|
46
61
|
|
|
62
|
+
def _looks_like_heic(data: bytes) -> bool:
|
|
63
|
+
"""Sniff for an ISO-BMFF 'ftyp' box with a HEIC/HEIF brand.
|
|
64
|
+
|
|
65
|
+
Used only to give a clear, actionable error when pillow-heif isn't
|
|
66
|
+
installed -- without this, a genuinely valid HEIC file would fail with
|
|
67
|
+
a generic "could not decode image" message that looks like corruption
|
|
68
|
+
rather than a missing optional dependency.
|
|
69
|
+
"""
|
|
70
|
+
if len(data) < 12:
|
|
71
|
+
return False
|
|
72
|
+
if data[4:8] != b"ftyp":
|
|
73
|
+
return False
|
|
74
|
+
brand = data[8:12]
|
|
75
|
+
return brand in (b"heic", b"heix", b"hevc", b"heim", b"heis", b"mif1", b"msf1")
|
|
76
|
+
|
|
77
|
+
|
|
47
78
|
def validate_image(
|
|
48
79
|
data: bytes,
|
|
49
80
|
*,
|
|
@@ -53,8 +84,9 @@ def validate_image(
|
|
|
53
84
|
) -> ValidatedImage:
|
|
54
85
|
"""Validate raw image bytes and return derived metadata.
|
|
55
86
|
|
|
56
|
-
Raises ImageTooLargeError, UnsupportedFormatError,
|
|
57
|
-
Never raises for reasons unrelated to the image
|
|
87
|
+
Raises ImageTooLargeError, UnsupportedFormatError, ImageValidationError,
|
|
88
|
+
or CorruptedImageError. Never raises for reasons unrelated to the image
|
|
89
|
+
itself.
|
|
58
90
|
"""
|
|
59
91
|
size_bytes = len(data)
|
|
60
92
|
if size_bytes > max_bytes:
|
|
@@ -62,6 +94,12 @@ def validate_image(
|
|
|
62
94
|
if size_bytes == 0:
|
|
63
95
|
raise CorruptedImageError("Image data is empty")
|
|
64
96
|
|
|
97
|
+
if not HEIF_SUPPORT_INSTALLED and _looks_like_heic(data):
|
|
98
|
+
raise ImageValidationError(
|
|
99
|
+
"This looks like a HEIC/HEIF image, which requires an optional "
|
|
100
|
+
"dependency. Install it with: pip install zerobucket[heic]"
|
|
101
|
+
)
|
|
102
|
+
|
|
65
103
|
# Pillow's own decompression-bomb guard, in pixels (not compressed bytes).
|
|
66
104
|
# We set it per-call rather than mutating the module-global so concurrent
|
|
67
105
|
# validate_image() calls with different limits don't race each other.
|
|
@@ -91,7 +129,9 @@ def validate_image(
|
|
|
91
129
|
with PILImage.open(io.BytesIO(data)) as img:
|
|
92
130
|
img.load()
|
|
93
131
|
except Exception as exc: # noqa: BLE001
|
|
94
|
-
raise CorruptedImageError(
|
|
132
|
+
raise CorruptedImageError(
|
|
133
|
+
f"Image data is truncated or corrupted: {exc}"
|
|
134
|
+
) from exc
|
|
95
135
|
finally:
|
|
96
136
|
PILImage.MAX_IMAGE_PIXELS = original_max_pixels
|
|
97
137
|
|
|
File without changes
|
|
@@ -33,6 +33,7 @@ from zerobucket.optimization import (
|
|
|
33
33
|
DEFAULT_WEBP_QUALITY,
|
|
34
34
|
optimize_image,
|
|
35
35
|
)
|
|
36
|
+
from zerobucket.validation import HEIF_SUPPORT_INSTALLED
|
|
36
37
|
|
|
37
38
|
from .photo_fixtures import ALL_FIXTURES
|
|
38
39
|
|
|
@@ -79,7 +80,9 @@ def test_default_jpeg_quality_meets_content_appropriate_floor(fixture_name):
|
|
|
79
80
|
original = fixture_fn()
|
|
80
81
|
floor = SSIM_FLOOR_BY_FIXTURE[fixture_name]
|
|
81
82
|
|
|
82
|
-
result = optimize_image(
|
|
83
|
+
result = optimize_image(
|
|
84
|
+
original, target_format="jpeg", max_bytes=_MAX_BYTES
|
|
85
|
+
)
|
|
83
86
|
|
|
84
87
|
score = _compute_ssim(original, result.data)
|
|
85
88
|
assert score >= floor, (
|
|
@@ -94,7 +97,9 @@ def test_default_webp_quality_meets_content_appropriate_floor(fixture_name):
|
|
|
94
97
|
original = fixture_fn()
|
|
95
98
|
floor = SSIM_FLOOR_BY_FIXTURE[fixture_name]
|
|
96
99
|
|
|
97
|
-
result = optimize_image(
|
|
100
|
+
result = optimize_image(
|
|
101
|
+
original, target_format="webp", max_bytes=_MAX_BYTES
|
|
102
|
+
)
|
|
98
103
|
|
|
99
104
|
score = _compute_ssim(original, result.data)
|
|
100
105
|
assert score >= floor, (
|
|
@@ -111,10 +116,7 @@ def test_low_quality_actually_degrades_ssim():
|
|
|
111
116
|
original = ALL_FIXTURES["busy_texture"]()
|
|
112
117
|
|
|
113
118
|
good = optimize_image(
|
|
114
|
-
original,
|
|
115
|
-
target_format="jpeg",
|
|
116
|
-
quality=DEFAULT_JPEG_QUALITY,
|
|
117
|
-
max_bytes=_MAX_BYTES,
|
|
119
|
+
original, target_format="jpeg", quality=DEFAULT_JPEG_QUALITY, max_bytes=_MAX_BYTES
|
|
118
120
|
)
|
|
119
121
|
bad = optimize_image(
|
|
120
122
|
original, target_format="jpeg", quality=15, max_bytes=_MAX_BYTES
|
|
@@ -215,3 +217,51 @@ def test_optimized_output_size_still_enforces_max_bytes():
|
|
|
215
217
|
original = ALL_FIXTURES["busy_texture"]()
|
|
216
218
|
with pytest.raises(ImageTooLargeError):
|
|
217
219
|
optimize_image(original, target_format="jpeg", quality=100, max_bytes=100)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@pytest.mark.skipif(not HEIF_SUPPORT_INSTALLED, reason="pillow-heif not installed")
|
|
223
|
+
def test_heic_source_converts_to_jpeg():
|
|
224
|
+
"""The main real-world use case: iPhone uploads HEIC, app wants JPEG
|
|
225
|
+
for broad browser compatibility."""
|
|
226
|
+
img = PILImage.new("RGB", (200, 150), color=(100, 150, 200))
|
|
227
|
+
buf = io.BytesIO()
|
|
228
|
+
img.save(buf, format="HEIF", quality=95)
|
|
229
|
+
original = buf.getvalue()
|
|
230
|
+
|
|
231
|
+
result = optimize_image(original, target_format="jpeg", max_bytes=_MAX_BYTES)
|
|
232
|
+
assert result.mime_type == "image/jpeg"
|
|
233
|
+
assert result.width == 200
|
|
234
|
+
assert result.height == 150
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@pytest.mark.skipif(not HEIF_SUPPORT_INSTALLED, reason="pillow-heif not installed")
|
|
238
|
+
def test_jpeg_source_converts_to_heic():
|
|
239
|
+
"""Reverse direction: also supported, since it's effectively free
|
|
240
|
+
through Pillow's plugin architecture, even though the main real-world
|
|
241
|
+
need is HEIC-in, not HEIC-out."""
|
|
242
|
+
original = ALL_FIXTURES["gradient_landscape"]()
|
|
243
|
+
result = optimize_image(original, target_format="heic", max_bytes=_MAX_BYTES)
|
|
244
|
+
assert result.mime_type == "image/heic"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@pytest.mark.skipif(not HEIF_SUPPORT_INSTALLED, reason="pillow-heif not installed")
|
|
248
|
+
def test_heic_target_accepts_both_heic_and_heif_spelling():
|
|
249
|
+
"""Users will naturally type 'heic' (the file extension they know),
|
|
250
|
+
not 'heif' (Pillow's internal format name) -- both must work."""
|
|
251
|
+
original = ALL_FIXTURES["flat_graphic"]()
|
|
252
|
+
result_heic = optimize_image(original, target_format="heic", max_bytes=_MAX_BYTES)
|
|
253
|
+
result_heif = optimize_image(original, target_format="heif", max_bytes=_MAX_BYTES)
|
|
254
|
+
assert result_heic.mime_type == "image/heic"
|
|
255
|
+
assert result_heif.mime_type == "image/heic"
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def test_heic_target_without_optional_dependency_gives_actionable_error(monkeypatch):
|
|
259
|
+
"""Requesting format='heic' without pillow-heif installed should fail
|
|
260
|
+
clearly, not with a raw Pillow KeyError."""
|
|
261
|
+
import zerobucket.optimization as optimization_module
|
|
262
|
+
|
|
263
|
+
monkeypatch.setattr(optimization_module, "HEIF_SUPPORT_INSTALLED", False)
|
|
264
|
+
|
|
265
|
+
original = ALL_FIXTURES["flat_graphic"]()
|
|
266
|
+
with pytest.raises(ImageValidationError, match=r"pip install zerobucket\[heic\]"):
|
|
267
|
+
optimize_image(original, target_format="heic", max_bytes=_MAX_BYTES)
|
|
@@ -10,9 +10,14 @@ from PIL import Image as PILImage
|
|
|
10
10
|
from zerobucket.exceptions import (
|
|
11
11
|
CorruptedImageError,
|
|
12
12
|
ImageTooLargeError,
|
|
13
|
+
ImageValidationError,
|
|
13
14
|
UnsupportedFormatError,
|
|
14
15
|
)
|
|
15
|
-
from zerobucket.validation import
|
|
16
|
+
from zerobucket.validation import (
|
|
17
|
+
HEIF_SUPPORT_INSTALLED,
|
|
18
|
+
_looks_like_heic,
|
|
19
|
+
validate_image,
|
|
20
|
+
)
|
|
16
21
|
|
|
17
22
|
|
|
18
23
|
def _jpeg(size=(32, 32)) -> bytes:
|
|
@@ -22,6 +27,13 @@ def _jpeg(size=(32, 32)) -> bytes:
|
|
|
22
27
|
return buf.getvalue()
|
|
23
28
|
|
|
24
29
|
|
|
30
|
+
def _heic(size=(32, 32)) -> bytes:
|
|
31
|
+
img = PILImage.new("RGB", size, color=(10, 20, 30))
|
|
32
|
+
buf = io.BytesIO()
|
|
33
|
+
img.save(buf, format="HEIF", quality=90)
|
|
34
|
+
return buf.getvalue()
|
|
35
|
+
|
|
36
|
+
|
|
25
37
|
def test_valid_jpeg_passes():
|
|
26
38
|
data = _jpeg((100, 50))
|
|
27
39
|
result = validate_image(data, max_bytes=10_000_000)
|
|
@@ -60,7 +72,9 @@ def test_empty_bytes_rejected():
|
|
|
60
72
|
|
|
61
73
|
def test_random_bytes_rejected():
|
|
62
74
|
with pytest.raises(CorruptedImageError):
|
|
63
|
-
validate_image(
|
|
75
|
+
validate_image(
|
|
76
|
+
b"not an image, just some random bytes here" * 5, max_bytes=10_000_000
|
|
77
|
+
)
|
|
64
78
|
|
|
65
79
|
|
|
66
80
|
def test_truncated_image_rejected():
|
|
@@ -98,3 +112,48 @@ def test_decompression_bomb_guard():
|
|
|
98
112
|
data = buf.getvalue()
|
|
99
113
|
with pytest.raises((ImageTooLargeError, CorruptedImageError)):
|
|
100
114
|
validate_image(data, max_bytes=10_000_000, max_pixels=1_000_000)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@pytest.mark.skipif(not HEIF_SUPPORT_INSTALLED, reason="pillow-heif not installed")
|
|
118
|
+
def test_valid_heic_passes():
|
|
119
|
+
data = _heic((100, 50))
|
|
120
|
+
result = validate_image(data, max_bytes=10_000_000)
|
|
121
|
+
assert result.mime_type == "image/heic"
|
|
122
|
+
assert result.width == 100
|
|
123
|
+
assert result.height == 50
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_looks_like_heic_detects_real_heic_magic_bytes():
|
|
127
|
+
"""Sanity check for the sniffer itself, independent of whether
|
|
128
|
+
pillow-heif is installed -- this only inspects raw bytes."""
|
|
129
|
+
# A real HEIC ftyp box: box size (4 bytes) + 'ftyp' + 'heic' brand.
|
|
130
|
+
heic_like = b"\x00\x00\x00\x1cftypheic\x00\x00\x00\x00" + b"\x00" * 20
|
|
131
|
+
assert _looks_like_heic(heic_like) is True
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_looks_like_heic_rejects_non_heic_bytes():
|
|
135
|
+
assert _looks_like_heic(b"not an image at all, just text" * 3) is False
|
|
136
|
+
assert _looks_like_heic(_jpeg()) is False
|
|
137
|
+
assert _looks_like_heic(b"") is False
|
|
138
|
+
assert _looks_like_heic(b"short") is False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_heic_without_optional_dependency_gives_actionable_error(monkeypatch):
|
|
142
|
+
"""If pillow-heif isn't installed, a real HEIC upload should get a
|
|
143
|
+
clear "install this extra" message -- not a generic "corrupted image"
|
|
144
|
+
error that looks like the file itself is broken.
|
|
145
|
+
|
|
146
|
+
Simulated via monkeypatch rather than an actual separate environment,
|
|
147
|
+
since pillow-heif IS installed in the test/dev environment (it's in
|
|
148
|
+
the dev extra) -- this only forces the code path, it doesn't prove
|
|
149
|
+
the real uninstalled environment behaves identically. That was
|
|
150
|
+
verified manually in a clean venv during development; see the PR/
|
|
151
|
+
commit notes for that verification.
|
|
152
|
+
"""
|
|
153
|
+
import zerobucket.validation as validation_module
|
|
154
|
+
|
|
155
|
+
monkeypatch.setattr(validation_module, "HEIF_SUPPORT_INSTALLED", False)
|
|
156
|
+
|
|
157
|
+
heic_bytes = b"\x00\x00\x00\x1cftypheic\x00\x00\x00\x00" + b"\x00" * 500
|
|
158
|
+
with pytest.raises(ImageValidationError, match=r"pip install zerobucket\[heic\]"):
|
|
159
|
+
validate_image(heic_bytes, max_bytes=10_000_000)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|