zerobucket 0.3.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.3.0 → zerobucket-0.4.0}/PKG-INFO +13 -1
  2. {zerobucket-0.3.0 → zerobucket-0.4.0}/README.md +12 -0
  3. {zerobucket-0.3.0 → zerobucket-0.4.0}/pyproject.toml +1 -1
  4. {zerobucket-0.3.0 → zerobucket-0.4.0}/src/zerobucket/__init__.py +1 -1
  5. {zerobucket-0.3.0 → zerobucket-0.4.0}/src/zerobucket/adapters/base.py +20 -4
  6. {zerobucket-0.3.0 → zerobucket-0.4.0}/src/zerobucket/adapters/postgres.py +44 -9
  7. {zerobucket-0.3.0 → zerobucket-0.4.0}/src/zerobucket/client.py +44 -12
  8. zerobucket-0.4.0/tests/test_transactions.py +151 -0
  9. {zerobucket-0.3.0 → zerobucket-0.4.0}/.gitignore +0 -0
  10. {zerobucket-0.3.0 → zerobucket-0.4.0}/0 +0 -0
  11. {zerobucket-0.3.0 → zerobucket-0.4.0}/float +0 -0
  12. {zerobucket-0.3.0 → zerobucket-0.4.0}/floor +0 -0
  13. {zerobucket-0.3.0 → zerobucket-0.4.0}/np.ndarray +0 -0
  14. {zerobucket-0.3.0 → zerobucket-0.4.0}/src/zerobucket/adapters/__init__.py +0 -0
  15. {zerobucket-0.3.0 → zerobucket-0.4.0}/src/zerobucket/exceptions.py +0 -0
  16. {zerobucket-0.3.0 → zerobucket-0.4.0}/src/zerobucket/optimization.py +0 -0
  17. {zerobucket-0.3.0 → zerobucket-0.4.0}/src/zerobucket/py.typed +0 -0
  18. {zerobucket-0.3.0 → zerobucket-0.4.0}/src/zerobucket/types.py +0 -0
  19. {zerobucket-0.3.0 → zerobucket-0.4.0}/src/zerobucket/validation.py +0 -0
  20. {zerobucket-0.3.0 → zerobucket-0.4.0}/tests/__init__.py +0 -0
  21. {zerobucket-0.3.0 → zerobucket-0.4.0}/tests/conftest.py +0 -0
  22. {zerobucket-0.3.0 → zerobucket-0.4.0}/tests/photo_fixtures.py +0 -0
  23. {zerobucket-0.3.0 → zerobucket-0.4.0}/tests/test_client.py +0 -0
  24. {zerobucket-0.3.0 → zerobucket-0.4.0}/tests/test_errors.py +0 -0
  25. {zerobucket-0.3.0 → zerobucket-0.4.0}/tests/test_optimization.py +0 -0
  26. {zerobucket-0.3.0 → zerobucket-0.4.0}/tests/test_validation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: zerobucket
3
- Version: 0.3.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
@@ -161,6 +161,18 @@ on GitHub for the full methodology.
161
161
  [benchmark results](https://github.com/KedarGhadyalji/ZeroBucket/blob/main/benchmarks/RESULTS.md)
162
162
  for why.
163
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
+
164
176
  ## Limitations (read before using in production)
165
177
 
166
178
  - **Not built for large files or high-volume media.** Full images are
@@ -123,6 +123,18 @@ on GitHub for the full methodology.
123
123
  [benchmark results](https://github.com/KedarGhadyalji/ZeroBucket/blob/main/benchmarks/RESULTS.md)
124
124
  for why.
125
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
+
126
138
  ## Limitations (read before using in production)
127
139
 
128
140
  - **Not built for large files or high-volume media.** Full images are
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "zerobucket"
7
- version = "0.3.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"
@@ -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.3.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
 
@@ -104,6 +105,17 @@ class ZeroBucket:
104
105
  zerobucket.optimization for the reasoning and
105
106
  tests/test_optimization.py for the SSIM regression test
106
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).
107
119
  """
108
120
  data, resolved_filename = _read_image_input(image, filename)
109
121
 
@@ -148,11 +160,17 @@ class ZeroBucket:
148
160
  width=width,
149
161
  height=height,
150
162
  checksum_sha256=checksum,
163
+ connection=connection,
151
164
  )
152
165
 
153
- def get(self, image_id: str) -> Image:
154
- """Retrieve a full image, including bytes. Raises ImageNotFoundError if missing."""
155
- 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)
156
174
  if record is None:
157
175
  raise ImageNotFoundError(image_id)
158
176
  return Image(
@@ -165,9 +183,14 @@ class ZeroBucket:
165
183
  checksum_sha256=record.checksum_sha256,
166
184
  )
167
185
 
168
- def metadata(self, image_id: str) -> ImageMetadata:
169
- """Retrieve image metadata without pulling the (potentially large) bytes."""
170
- 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)
171
194
  if record is None:
172
195
  raise ImageNotFoundError(image_id)
173
196
  return ImageMetadata(
@@ -180,13 +203,22 @@ class ZeroBucket:
180
203
  checksum_sha256=record.checksum_sha256,
181
204
  )
182
205
 
183
- def exists(self, image_id: str) -> bool:
184
- """Return whether an image with this id exists."""
185
- 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.
186
208
 
187
- def delete(self, image_id: str) -> bool:
188
- """Delete an image. Returns True if it existed and was deleted, False otherwise."""
189
- 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)
190
222
 
191
223
  def close(self) -> None:
192
224
  """Release underlying database connections."""
@@ -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()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes