zerobucket 0.2.0__tar.gz → 0.4.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 (26) hide show
  1. {zerobucket-0.2.0 → zerobucket-0.4.0}/PKG-INFO +20 -3
  2. {zerobucket-0.2.0 → zerobucket-0.4.0}/README.md +16 -2
  3. zerobucket-0.4.0/floor +0 -0
  4. zerobucket-0.4.0/np.ndarray +0 -0
  5. {zerobucket-0.2.0 → zerobucket-0.4.0}/pyproject.toml +5 -1
  6. {zerobucket-0.2.0 → zerobucket-0.4.0}/src/zerobucket/__init__.py +1 -1
  7. {zerobucket-0.2.0 → zerobucket-0.4.0}/src/zerobucket/adapters/base.py +20 -4
  8. {zerobucket-0.2.0 → zerobucket-0.4.0}/src/zerobucket/adapters/postgres.py +44 -9
  9. {zerobucket-0.2.0 → zerobucket-0.4.0}/src/zerobucket/client.py +46 -13
  10. {zerobucket-0.2.0 → zerobucket-0.4.0}/src/zerobucket/optimization.py +40 -14
  11. zerobucket-0.4.0/src/zerobucket/py.typed +0 -0
  12. {zerobucket-0.2.0 → zerobucket-0.4.0}/src/zerobucket/validation.py +43 -3
  13. zerobucket-0.4.0/tests/__init__.py +0 -0
  14. {zerobucket-0.2.0 → zerobucket-0.4.0}/tests/test_optimization.py +56 -6
  15. zerobucket-0.4.0/tests/test_transactions.py +151 -0
  16. {zerobucket-0.2.0 → zerobucket-0.4.0}/tests/test_validation.py +61 -2
  17. {zerobucket-0.2.0 → zerobucket-0.4.0}/.gitignore +0 -0
  18. /zerobucket-0.2.0/src/zerobucket/py.typed → /zerobucket-0.4.0/0 +0 -0
  19. /zerobucket-0.2.0/tests/__init__.py → /zerobucket-0.4.0/float +0 -0
  20. {zerobucket-0.2.0 → zerobucket-0.4.0}/src/zerobucket/adapters/__init__.py +0 -0
  21. {zerobucket-0.2.0 → zerobucket-0.4.0}/src/zerobucket/exceptions.py +0 -0
  22. {zerobucket-0.2.0 → zerobucket-0.4.0}/src/zerobucket/types.py +0 -0
  23. {zerobucket-0.2.0 → zerobucket-0.4.0}/tests/conftest.py +0 -0
  24. {zerobucket-0.2.0 → zerobucket-0.4.0}/tests/photo_fixtures.py +0 -0
  25. {zerobucket-0.2.0 → zerobucket-0.4.0}/tests/test_client.py +0 -0
  26. {zerobucket-0.2.0 → zerobucket-0.4.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.4.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
@@ -156,6 +161,18 @@ on GitHub for the full methodology.
156
161
  [benchmark results](https://github.com/KedarGhadyalji/ZeroBucket/blob/main/benchmarks/RESULTS.md)
157
162
  for why.
158
163
 
164
+ ## Transactions
165
+
166
+ By default, `put()`/`get()`/`delete()` each use their own independent
167
+ database connection -- **not** your application's own transaction, even
168
+ against the same database. Pass your own open `psycopg` connection via
169
+ `connection=` to make a write participate in your transaction (e.g. "user
170
+
171
+ - avatar, atomically, or neither"). See the
172
+ [Transactions section](https://github.com/KedarGhadyalji/ZeroBucket#transactions)
173
+ on GitHub for a worked example -- this was verified by direct experiment
174
+ during development, not assumed.
175
+
159
176
  ## Limitations (read before using in production)
160
177
 
161
178
  - **Not built for large files or high-volume media.** Full images are
@@ -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
@@ -121,6 +123,18 @@ on GitHub for the full methodology.
121
123
  [benchmark results](https://github.com/KedarGhadyalji/ZeroBucket/blob/main/benchmarks/RESULTS.md)
122
124
  for why.
123
125
 
126
+ ## Transactions
127
+
128
+ By default, `put()`/`get()`/`delete()` each use their own independent
129
+ database connection -- **not** your application's own transaction, even
130
+ against the same database. Pass your own open `psycopg` connection via
131
+ `connection=` to make a write participate in your transaction (e.g. "user
132
+
133
+ - avatar, atomically, or neither"). See the
134
+ [Transactions section](https://github.com/KedarGhadyalji/ZeroBucket#transactions)
135
+ on GitHub for a worked example -- this was verified by direct experiment
136
+ during development, not assumed.
137
+
124
138
  ## Limitations (read before using in production)
125
139
 
126
140
  - **Not built for large files or high-volume media.** Full images are
zerobucket-0.4.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.4.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.4.0"
23
23
 
24
24
  __all__ = [
25
25
  "ZeroBucket",
@@ -7,6 +7,17 @@ zerobucket.client, above this layer.
7
7
 
8
8
  This separation is what makes a future object-storage backend a real
9
9
  drop-in replacement rather than a rewrite.
10
+
11
+ Every method accepts an optional `connection`. When None (the default),
12
+ the backend uses its own internal pool -- each call commits independently
13
+ on its own connection, exactly as before. When a connection is provided,
14
+ the backend uses it directly and does NOT commit or roll it back -- that
15
+ becomes the caller's responsibility, which is what lets an operation
16
+ participate in the caller's own transaction (see client.py's put() docs
17
+ for why this matters and a worked example). `connection` is intentionally
18
+ typed as `object` here rather than a Postgres-specific type, since this
19
+ interface is meant to stay backend-agnostic; concrete adapters narrow the
20
+ type in their own implementation.
10
21
  """
11
22
 
12
23
  from __future__ import annotations
@@ -56,23 +67,28 @@ class StorageBackend(ABC):
56
67
  width: int | None,
57
68
  height: int | None,
58
69
  checksum_sha256: str,
70
+ connection: object | None = None,
59
71
  ) -> str:
60
72
  """Persist a record and return its generated id."""
61
73
 
62
74
  @abstractmethod
63
- def get(self, image_id: str) -> StoredRecord | None:
75
+ def get(
76
+ self, image_id: str, *, connection: object | None = None
77
+ ) -> StoredRecord | None:
64
78
  """Fetch a full record including bytes, or None if it doesn't exist."""
65
79
 
66
80
  @abstractmethod
67
- def get_metadata(self, image_id: str) -> StoredRecordMetadata | None:
81
+ def get_metadata(
82
+ self, image_id: str, *, connection: object | None = None
83
+ ) -> StoredRecordMetadata | None:
68
84
  """Fetch metadata only (no bytes), or None if it doesn't exist."""
69
85
 
70
86
  @abstractmethod
71
- def delete(self, image_id: str) -> bool:
87
+ def delete(self, image_id: str, *, connection: object | None = None) -> bool:
72
88
  """Delete a record. Returns True if a record was deleted, False if it didn't exist."""
73
89
 
74
90
  @abstractmethod
75
- def exists(self, image_id: str) -> bool:
91
+ def exists(self, image_id: str, *, connection: object | None = None) -> bool:
76
92
  """Return whether a record with this id exists."""
77
93
 
78
94
  @abstractmethod
@@ -6,11 +6,17 @@ parameterized; nothing is ever built via string concatenation.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ from contextlib import contextmanager
10
+ from typing import TYPE_CHECKING
11
+
9
12
  from psycopg_pool import ConnectionPool
10
13
 
11
14
  from ..exceptions import StorageError
12
15
  from .base import StorageBackend, StoredRecord, StoredRecordMetadata
13
16
 
17
+ if TYPE_CHECKING:
18
+ import psycopg
19
+
14
20
  _SCHEMA = """
15
21
  CREATE TABLE IF NOT EXISTS zerobucket_images (
16
22
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -85,6 +91,26 @@ class PostgresBackend(StorageBackend):
85
91
  except Exception as exc: # noqa: BLE001
86
92
  raise StorageError(f"Migration failed: {exc}") from exc
87
93
 
94
+ @contextmanager
95
+ def _cursor(self, connection: psycopg.Connection | None):
96
+ """Yield a cursor, either on the caller's own connection or a
97
+ pooled one.
98
+
99
+ When `connection` is provided, we use it directly and do NOT
100
+ commit or roll it back -- that's the caller's responsibility,
101
+ and is precisely what lets a put()/delete() participate in the
102
+ caller's own transaction (see client.py). When `connection` is
103
+ None, we fall back to the internal pool exactly as before: each
104
+ call gets its own connection and commits independently on clean
105
+ exit.
106
+ """
107
+ if connection is not None:
108
+ with connection.cursor() as cur:
109
+ yield cur
110
+ else:
111
+ with self._pool.connection() as conn, conn.cursor() as cur:
112
+ yield cur
113
+
88
114
  def put(
89
115
  self,
90
116
  *,
@@ -95,9 +121,10 @@ class PostgresBackend(StorageBackend):
95
121
  width: int | None,
96
122
  height: int | None,
97
123
  checksum_sha256: str,
124
+ connection: psycopg.Connection | None = None,
98
125
  ) -> str:
99
126
  try:
100
- with self._pool.connection() as conn, conn.cursor() as cur:
127
+ with self._cursor(connection) as cur:
101
128
  params = (
102
129
  data,
103
130
  mime_type,
@@ -113,9 +140,11 @@ class PostgresBackend(StorageBackend):
113
140
  except Exception as exc: # noqa: BLE001
114
141
  raise StorageError(f"Failed to store image: {exc}") from exc
115
142
 
116
- def get(self, image_id: str) -> StoredRecord | None:
143
+ def get(
144
+ self, image_id: str, *, connection: psycopg.Connection | None = None
145
+ ) -> StoredRecord | None:
117
146
  try:
118
- with self._pool.connection() as conn, conn.cursor() as cur:
147
+ with self._cursor(connection) as cur:
119
148
  cur.execute(_SELECT_FULL, (image_id,))
120
149
  row = cur.fetchone()
121
150
  except Exception as exc: # noqa: BLE001
@@ -133,9 +162,11 @@ class PostgresBackend(StorageBackend):
133
162
  checksum_sha256=row[7],
134
163
  )
135
164
 
136
- def get_metadata(self, image_id: str) -> StoredRecordMetadata | None:
165
+ def get_metadata(
166
+ self, image_id: str, *, connection: psycopg.Connection | None = None
167
+ ) -> StoredRecordMetadata | None:
137
168
  try:
138
- with self._pool.connection() as conn, conn.cursor() as cur:
169
+ with self._cursor(connection) as cur:
139
170
  cur.execute(_SELECT_METADATA, (image_id,))
140
171
  row = cur.fetchone()
141
172
  except Exception as exc: # noqa: BLE001
@@ -152,17 +183,21 @@ class PostgresBackend(StorageBackend):
152
183
  checksum_sha256=row[6],
153
184
  )
154
185
 
155
- def delete(self, image_id: str) -> bool:
186
+ def delete(
187
+ self, image_id: str, *, connection: psycopg.Connection | None = None
188
+ ) -> bool:
156
189
  try:
157
- with self._pool.connection() as conn, conn.cursor() as cur:
190
+ with self._cursor(connection) as cur:
158
191
  cur.execute(_DELETE, (image_id,))
159
192
  return cur.rowcount > 0
160
193
  except Exception as exc: # noqa: BLE001
161
194
  raise StorageError(f"Failed to delete image: {exc}") from exc
162
195
 
163
- def exists(self, image_id: str) -> bool:
196
+ def exists(
197
+ self, image_id: str, *, connection: psycopg.Connection | None = None
198
+ ) -> bool:
164
199
  try:
165
- with self._pool.connection() as conn, conn.cursor() as cur:
200
+ with self._cursor(connection) as cur:
166
201
  cur.execute(_EXISTS, (image_id,))
167
202
  return cur.fetchone() is not None
168
203
  except Exception as exc: # noqa: BLE001
@@ -77,6 +77,7 @@ class ZeroBucket:
77
77
  max_width: int | None = None,
78
78
  format: str | None = None,
79
79
  quality: int | None = None,
80
+ connection: object | None = None,
80
81
  ) -> str:
81
82
  """Validate, optionally optimize, and store an image. Returns its id.
82
83
 
@@ -93,7 +94,8 @@ class ZeroBucket:
93
94
  bytes are stored as-is.
94
95
  max_width: Downscale if wider than this (aspect ratio
95
96
  preserved). Only applies when optimize=True.
96
- format: Re-encode target -- "jpeg", "png", or "webp". None
97
+ format: Re-encode target -- "jpeg", "png", "webp", or
98
+ "heic"/"heif". None
97
99
  keeps the original format. Only applies when
98
100
  optimize=True. Note: quality has no effect when the
99
101
  target (or original) format is PNG -- PNG has no lossy
@@ -103,6 +105,17 @@ class ZeroBucket:
103
105
  zerobucket.optimization for the reasoning and
104
106
  tests/test_optimization.py for the SSIM regression test
105
107
  that enforces it). Only applies when optimize=True.
108
+ connection: Advanced -- pass your own open psycopg connection
109
+ (one you're already using for other writes in the same
110
+ transaction) to make this put() commit or roll back
111
+ together with the rest of that transaction, instead of
112
+ committing independently on ZeroBucket's own internal
113
+ pool. Without this, put() ALWAYS commits on its own,
114
+ regardless of what your application does afterward --
115
+ see the README's "Transactions" section for a worked
116
+ example and why this matters (e.g. "create a user record
117
+ and store their avatar atomically" only works if you
118
+ pass connection= here).
106
119
  """
107
120
  data, resolved_filename = _read_image_input(image, filename)
108
121
 
@@ -147,11 +160,17 @@ class ZeroBucket:
147
160
  width=width,
148
161
  height=height,
149
162
  checksum_sha256=checksum,
163
+ connection=connection,
150
164
  )
151
165
 
152
- def get(self, image_id: str) -> Image:
153
- """Retrieve a full image, including bytes. Raises ImageNotFoundError if missing."""
154
- record = self._backend.get(image_id)
166
+ def get(self, image_id: str, *, connection: object | None = None) -> Image:
167
+ """Retrieve a full image, including bytes. Raises ImageNotFoundError if missing.
168
+
169
+ connection: Advanced -- see put()'s docstring. Passing the same
170
+ open transaction lets you read back a row you just wrote in
171
+ that same transaction, before it's committed.
172
+ """
173
+ record = self._backend.get(image_id, connection=connection)
155
174
  if record is None:
156
175
  raise ImageNotFoundError(image_id)
157
176
  return Image(
@@ -164,9 +183,14 @@ class ZeroBucket:
164
183
  checksum_sha256=record.checksum_sha256,
165
184
  )
166
185
 
167
- def metadata(self, image_id: str) -> ImageMetadata:
168
- """Retrieve image metadata without pulling the (potentially large) bytes."""
169
- record = self._backend.get_metadata(image_id)
186
+ def metadata(
187
+ self, image_id: str, *, connection: object | None = None
188
+ ) -> ImageMetadata:
189
+ """Retrieve image metadata without pulling the (potentially large) bytes.
190
+
191
+ connection: Advanced -- see put()'s docstring.
192
+ """
193
+ record = self._backend.get_metadata(image_id, connection=connection)
170
194
  if record is None:
171
195
  raise ImageNotFoundError(image_id)
172
196
  return ImageMetadata(
@@ -179,13 +203,22 @@ class ZeroBucket:
179
203
  checksum_sha256=record.checksum_sha256,
180
204
  )
181
205
 
182
- def exists(self, image_id: str) -> bool:
183
- """Return whether an image with this id exists."""
184
- return self._backend.exists(image_id)
206
+ def exists(self, image_id: str, *, connection: object | None = None) -> bool:
207
+ """Return whether an image with this id exists.
185
208
 
186
- def delete(self, image_id: str) -> bool:
187
- """Delete an image. Returns True if it existed and was deleted, False otherwise."""
188
- return self._backend.delete(image_id)
209
+ connection: Advanced -- see put()'s docstring.
210
+ """
211
+ return self._backend.exists(image_id, connection=connection)
212
+
213
+ def delete(self, image_id: str, *, connection: object | None = None) -> bool:
214
+ """Delete an image. Returns True if it existed and was deleted, False otherwise.
215
+
216
+ connection: Advanced -- see put()'s docstring. Passing the same
217
+ open transaction lets a delete() roll back together with the
218
+ rest of that transaction (e.g. "delete a user and their
219
+ avatar atomically").
220
+ """
221
+ return self._backend.delete(image_id, connection=connection)
189
222
 
190
223
  def close(self) -> None:
191
224
  """Release underlying database connections."""
@@ -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)
@@ -0,0 +1,151 @@
1
+ """Tests for the connection= parameter: does put()/delete() actually
2
+ participate in the caller's own transaction when given one, and does the
3
+ default behavior (no connection given) remain independently-committing,
4
+ exactly as it always has?
5
+
6
+ These mirror a real experiment run during development: without
7
+ connection=, a put() survives even when the caller's own separate
8
+ transaction rolls back -- proving the two are NOT atomic by default. That
9
+ finding is what motivated this feature; these tests lock in both the old
10
+ default behavior and the new opt-in behavior so neither regresses.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import io
16
+
17
+ import psycopg
18
+ from PIL import Image as PILImage
19
+
20
+
21
+ def _jpeg_bytes() -> bytes:
22
+ img = PILImage.new("RGB", (40, 30), color=(10, 20, 30))
23
+ buf = io.BytesIO()
24
+ img.save(buf, format="JPEG")
25
+ return buf.getvalue()
26
+
27
+
28
+ def test_put_without_connection_commits_independently_of_caller_transaction(images):
29
+ """Default (unchanged) behavior: put() ALWAYS commits on its own,
30
+ regardless of what the caller's own separate connection does
31
+ afterward. This is the current, real behavior -- not an aspiration --
32
+ and this test exists specifically so it can't silently change without
33
+ someone noticing."""
34
+ from tests.conftest import TEST_DATABASE_URL
35
+
36
+ app_conn = psycopg.connect(TEST_DATABASE_URL)
37
+ app_conn.autocommit = False
38
+ try:
39
+ with app_conn.cursor() as cur:
40
+ cur.execute(
41
+ "CREATE TABLE IF NOT EXISTS _test_app_scratch (id serial primary key);"
42
+ )
43
+ app_conn.commit()
44
+
45
+ with app_conn.cursor() as cur:
46
+ cur.execute("INSERT INTO _test_app_scratch DEFAULT VALUES;")
47
+
48
+ image_id = images.put(_jpeg_bytes()) # no connection= passed
49
+
50
+ app_conn.rollback()
51
+
52
+ with app_conn.cursor() as cur:
53
+ cur.execute("SELECT count(*) FROM _test_app_scratch;")
54
+ assert cur.fetchone()[0] == 0 # app's own insert was rolled back
55
+
56
+ # But the image was NOT rolled back -- it committed independently.
57
+ assert images.exists(image_id) is True
58
+ finally:
59
+ with app_conn.cursor() as cur:
60
+ cur.execute("DROP TABLE IF EXISTS _test_app_scratch;")
61
+ app_conn.commit()
62
+ app_conn.close()
63
+
64
+
65
+ def test_put_with_connection_rolls_back_with_caller_transaction(images):
66
+ """The new opt-in behavior: passing connection= makes put() a real
67
+ part of the caller's transaction -- if the caller rolls back, the
68
+ image row is gone too, same as any other write in that transaction."""
69
+ from tests.conftest import TEST_DATABASE_URL
70
+
71
+ app_conn = psycopg.connect(TEST_DATABASE_URL)
72
+ app_conn.autocommit = False
73
+ try:
74
+ image_id = images.put(_jpeg_bytes(), connection=app_conn)
75
+
76
+ # Readable within the SAME still-open transaction (read-your-writes).
77
+ assert images.exists(image_id, connection=app_conn) is True
78
+
79
+ app_conn.rollback()
80
+
81
+ # After rollback, the image must be gone -- it was never committed.
82
+ assert images.exists(image_id) is False
83
+ finally:
84
+ app_conn.close()
85
+
86
+
87
+ def test_put_with_connection_commits_with_caller_transaction(images):
88
+ """The success path: passing connection= and then committing the
89
+ caller's own transaction commits the image too."""
90
+ from tests.conftest import TEST_DATABASE_URL
91
+
92
+ app_conn = psycopg.connect(TEST_DATABASE_URL)
93
+ app_conn.autocommit = False
94
+ try:
95
+ image_id = images.put(_jpeg_bytes(), connection=app_conn)
96
+ app_conn.commit()
97
+
98
+ assert images.exists(image_id) is True
99
+ images.delete(image_id)
100
+ finally:
101
+ app_conn.close()
102
+
103
+
104
+ def test_delete_with_connection_rolls_back_with_caller_transaction(images):
105
+ """Symmetric case for delete(): a delete() done inside a caller's
106
+ transaction is undone if that transaction rolls back."""
107
+ from tests.conftest import TEST_DATABASE_URL
108
+
109
+ image_id = images.put(_jpeg_bytes())
110
+ assert images.exists(image_id) is True
111
+
112
+ app_conn = psycopg.connect(TEST_DATABASE_URL)
113
+ app_conn.autocommit = False
114
+ try:
115
+ images.delete(image_id, connection=app_conn)
116
+ # Within the same open transaction, it looks deleted.
117
+ assert images.exists(image_id, connection=app_conn) is False
118
+
119
+ app_conn.rollback()
120
+
121
+ # After rollback, the image is back -- the delete never committed.
122
+ assert images.exists(image_id) is True
123
+ finally:
124
+ app_conn.close()
125
+ images.delete(image_id)
126
+
127
+
128
+ def test_get_with_connection_sees_uncommitted_write_in_same_transaction(images):
129
+ """Read-your-writes: get() with the same open connection can see a
130
+ row that was put() in that same transaction but not yet committed --
131
+ proving they really do share one transaction, not just coincidence."""
132
+ from tests.conftest import TEST_DATABASE_URL
133
+
134
+ app_conn = psycopg.connect(TEST_DATABASE_URL)
135
+ app_conn.autocommit = False
136
+ try:
137
+ data = _jpeg_bytes()
138
+ image_id = images.put(data, connection=app_conn)
139
+
140
+ # A separate, ordinary ZeroBucket call (its own connection, no
141
+ # connection= passed) should NOT see this uncommitted row yet.
142
+ assert images.exists(image_id) is False
143
+
144
+ # But reading through the SAME transaction sees it fine.
145
+ result = images.get(image_id, connection=app_conn)
146
+ assert result.data == data
147
+
148
+ app_conn.rollback()
149
+ assert images.exists(image_id) is False
150
+ finally:
151
+ app_conn.close()
@@ -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