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.
- {zerobucket-0.2.0 → zerobucket-0.4.0}/PKG-INFO +20 -3
- {zerobucket-0.2.0 → zerobucket-0.4.0}/README.md +16 -2
- zerobucket-0.4.0/floor +0 -0
- zerobucket-0.4.0/np.ndarray +0 -0
- {zerobucket-0.2.0 → zerobucket-0.4.0}/pyproject.toml +5 -1
- {zerobucket-0.2.0 → zerobucket-0.4.0}/src/zerobucket/__init__.py +1 -1
- {zerobucket-0.2.0 → zerobucket-0.4.0}/src/zerobucket/adapters/base.py +20 -4
- {zerobucket-0.2.0 → zerobucket-0.4.0}/src/zerobucket/adapters/postgres.py +44 -9
- {zerobucket-0.2.0 → zerobucket-0.4.0}/src/zerobucket/client.py +46 -13
- {zerobucket-0.2.0 → zerobucket-0.4.0}/src/zerobucket/optimization.py +40 -14
- zerobucket-0.4.0/src/zerobucket/py.typed +0 -0
- {zerobucket-0.2.0 → zerobucket-0.4.0}/src/zerobucket/validation.py +43 -3
- zerobucket-0.4.0/tests/__init__.py +0 -0
- {zerobucket-0.2.0 → zerobucket-0.4.0}/tests/test_optimization.py +56 -6
- zerobucket-0.4.0/tests/test_transactions.py +151 -0
- {zerobucket-0.2.0 → zerobucket-0.4.0}/tests/test_validation.py +61 -2
- {zerobucket-0.2.0 → zerobucket-0.4.0}/.gitignore +0 -0
- /zerobucket-0.2.0/src/zerobucket/py.typed → /zerobucket-0.4.0/0 +0 -0
- /zerobucket-0.2.0/tests/__init__.py → /zerobucket-0.4.0/float +0 -0
- {zerobucket-0.2.0 → zerobucket-0.4.0}/src/zerobucket/adapters/__init__.py +0 -0
- {zerobucket-0.2.0 → zerobucket-0.4.0}/src/zerobucket/exceptions.py +0 -0
- {zerobucket-0.2.0 → zerobucket-0.4.0}/src/zerobucket/types.py +0 -0
- {zerobucket-0.2.0 → zerobucket-0.4.0}/tests/conftest.py +0 -0
- {zerobucket-0.2.0 → zerobucket-0.4.0}/tests/photo_fixtures.py +0 -0
- {zerobucket-0.2.0 → zerobucket-0.4.0}/tests/test_client.py +0 -0
- {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.
|
|
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
|
|
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
|
|
@@ -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
|
|
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
|
|
@@ -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.
|
|
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]
|
|
@@ -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(
|
|
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(
|
|
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.
|
|
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(
|
|
143
|
+
def get(
|
|
144
|
+
self, image_id: str, *, connection: psycopg.Connection | None = None
|
|
145
|
+
) -> StoredRecord | None:
|
|
117
146
|
try:
|
|
118
|
-
with self.
|
|
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(
|
|
165
|
+
def get_metadata(
|
|
166
|
+
self, image_id: str, *, connection: psycopg.Connection | None = None
|
|
167
|
+
) -> StoredRecordMetadata | None:
|
|
137
168
|
try:
|
|
138
|
-
with self.
|
|
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(
|
|
186
|
+
def delete(
|
|
187
|
+
self, image_id: str, *, connection: psycopg.Connection | None = None
|
|
188
|
+
) -> bool:
|
|
156
189
|
try:
|
|
157
|
-
with self.
|
|
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(
|
|
196
|
+
def exists(
|
|
197
|
+
self, image_id: str, *, connection: psycopg.Connection | None = None
|
|
198
|
+
) -> bool:
|
|
164
199
|
try:
|
|
165
|
-
with self.
|
|
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",
|
|
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
|
-
|
|
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(
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
187
|
-
"""
|
|
188
|
-
return self._backend.
|
|
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
|
|
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)
|
|
@@ -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
|
|
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
|