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.
Files changed (25) hide show
  1. {zerobucket-0.2.0 → zerobucket-0.3.0}/PKG-INFO +8 -3
  2. {zerobucket-0.2.0 → zerobucket-0.3.0}/README.md +4 -2
  3. zerobucket-0.3.0/floor +0 -0
  4. zerobucket-0.3.0/np.ndarray +0 -0
  5. {zerobucket-0.2.0 → zerobucket-0.3.0}/pyproject.toml +5 -1
  6. {zerobucket-0.2.0 → zerobucket-0.3.0}/src/zerobucket/__init__.py +1 -1
  7. {zerobucket-0.2.0 → zerobucket-0.3.0}/src/zerobucket/client.py +2 -1
  8. {zerobucket-0.2.0 → zerobucket-0.3.0}/src/zerobucket/optimization.py +40 -14
  9. zerobucket-0.3.0/src/zerobucket/py.typed +0 -0
  10. {zerobucket-0.2.0 → zerobucket-0.3.0}/src/zerobucket/validation.py +43 -3
  11. zerobucket-0.3.0/tests/__init__.py +0 -0
  12. {zerobucket-0.2.0 → zerobucket-0.3.0}/tests/test_optimization.py +56 -6
  13. {zerobucket-0.2.0 → zerobucket-0.3.0}/tests/test_validation.py +61 -2
  14. {zerobucket-0.2.0 → zerobucket-0.3.0}/.gitignore +0 -0
  15. /zerobucket-0.2.0/src/zerobucket/py.typed → /zerobucket-0.3.0/0 +0 -0
  16. /zerobucket-0.2.0/tests/__init__.py → /zerobucket-0.3.0/float +0 -0
  17. {zerobucket-0.2.0 → zerobucket-0.3.0}/src/zerobucket/adapters/__init__.py +0 -0
  18. {zerobucket-0.2.0 → zerobucket-0.3.0}/src/zerobucket/adapters/base.py +0 -0
  19. {zerobucket-0.2.0 → zerobucket-0.3.0}/src/zerobucket/adapters/postgres.py +0 -0
  20. {zerobucket-0.2.0 → zerobucket-0.3.0}/src/zerobucket/exceptions.py +0 -0
  21. {zerobucket-0.2.0 → zerobucket-0.3.0}/src/zerobucket/types.py +0 -0
  22. {zerobucket-0.2.0 → zerobucket-0.3.0}/tests/conftest.py +0 -0
  23. {zerobucket-0.2.0 → zerobucket-0.3.0}/tests/photo_fixtures.py +0 -0
  24. {zerobucket-0.2.0 → zerobucket-0.3.0}/tests/test_client.py +0 -0
  25. {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.2.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 -- detected from actual file content, never
150
- from filename extension or a client-supplied `Content-Type` header.
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 -- detected from actual file content, never
115
- from filename extension or a client-supplied `Content-Type` header.
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.2.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]
@@ -19,7 +19,7 @@ from .exceptions import (
19
19
  from .optimization import OptimizationResult
20
20
  from .types import Image, ImageMetadata
21
21
 
22
- __version__ = "0.2.0"
22
+ __version__ = "0.3.0"
23
23
 
24
24
  __all__ = [
25
25
  "ZeroBucket",
@@ -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", or "webp". None
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 DEFAULT_MAX_PIXELS, SUPPORTED_FORMATS, validate_image
31
-
32
- # Canonical MIME type -> Pillow format name, and back. Kept separate from
33
- # validation.py's version to avoid a circular import; the two must be kept
34
- # in sync if formats are ever added.
35
- _MIME_TO_PIL_FORMAT = {
36
- "image/jpeg": "JPEG",
37
- "image/png": "PNG",
38
- "image/webp": "WEBP",
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 "webp". None keeps the original
85
- format. Converting a PNG to WebP/JPEG is usually the right
86
- call when the PNG is actually a photo, not flat-color art.
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
- # a one-time encode on upload, not a hot path
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, or CorruptedImageError.
57
- Never raises for reasons unrelated to the image itself.
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(f"Image data is truncated or corrupted: {exc}") from exc
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(original, target_format="jpeg", max_bytes=_MAX_BYTES)
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(original, target_format="webp", max_bytes=_MAX_BYTES)
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 validate_image
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(b"not an image, just some random bytes here" * 5, max_bytes=10_000_000)
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