zerobucket 0.26.0__tar.gz → 0.26.1__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 (52) hide show
  1. {zerobucket-0.26.0 → zerobucket-0.26.1}/PKG-INFO +1 -1
  2. {zerobucket-0.26.0 → zerobucket-0.26.1}/pyproject.toml +1 -1
  3. {zerobucket-0.26.0 → zerobucket-0.26.1}/src/zerobucket/__init__.py +1 -1
  4. {zerobucket-0.26.0 → zerobucket-0.26.1}/src/zerobucket/adapters/sqlite.py +86 -8
  5. zerobucket-0.26.1/tests/test_sqlite_stream_fallback.py +160 -0
  6. {zerobucket-0.26.0 → zerobucket-0.26.1}/.gitignore +0 -0
  7. {zerobucket-0.26.0 → zerobucket-0.26.1}/README.md +0 -0
  8. {zerobucket-0.26.0 → zerobucket-0.26.1}/src/zerobucket/_ids.py +0 -0
  9. {zerobucket-0.26.0 → zerobucket-0.26.1}/src/zerobucket/adapters/__init__.py +0 -0
  10. {zerobucket-0.26.0 → zerobucket-0.26.1}/src/zerobucket/adapters/base.py +0 -0
  11. {zerobucket-0.26.0 → zerobucket-0.26.1}/src/zerobucket/adapters/base_async.py +0 -0
  12. {zerobucket-0.26.0 → zerobucket-0.26.1}/src/zerobucket/adapters/mysql.py +0 -0
  13. {zerobucket-0.26.0 → zerobucket-0.26.1}/src/zerobucket/adapters/mysql_async.py +0 -0
  14. {zerobucket-0.26.0 → zerobucket-0.26.1}/src/zerobucket/adapters/postgres.py +0 -0
  15. {zerobucket-0.26.0 → zerobucket-0.26.1}/src/zerobucket/adapters/postgres_async.py +0 -0
  16. {zerobucket-0.26.0 → zerobucket-0.26.1}/src/zerobucket/adapters/sqlite_async.py +0 -0
  17. {zerobucket-0.26.0 → zerobucket-0.26.1}/src/zerobucket/async_client.py +0 -0
  18. {zerobucket-0.26.0 → zerobucket-0.26.1}/src/zerobucket/cli.py +0 -0
  19. {zerobucket-0.26.0 → zerobucket-0.26.1}/src/zerobucket/client.py +0 -0
  20. {zerobucket-0.26.0 → zerobucket-0.26.1}/src/zerobucket/content_types.py +0 -0
  21. {zerobucket-0.26.0 → zerobucket-0.26.1}/src/zerobucket/exceptions.py +0 -0
  22. {zerobucket-0.26.0 → zerobucket-0.26.1}/src/zerobucket/object_storage.py +0 -0
  23. {zerobucket-0.26.0 → zerobucket-0.26.1}/src/zerobucket/optimization.py +0 -0
  24. {zerobucket-0.26.0 → zerobucket-0.26.1}/src/zerobucket/py.typed +0 -0
  25. {zerobucket-0.26.0 → zerobucket-0.26.1}/src/zerobucket/types.py +0 -0
  26. {zerobucket-0.26.0 → zerobucket-0.26.1}/src/zerobucket/validation.py +0 -0
  27. {zerobucket-0.26.0 → zerobucket-0.26.1}/src/zerobucket/validators/__init__.py +0 -0
  28. {zerobucket-0.26.0 → zerobucket-0.26.1}/src/zerobucket/validators/pdf.py +0 -0
  29. {zerobucket-0.26.0 → zerobucket-0.26.1}/tests/__init__.py +0 -0
  30. {zerobucket-0.26.0 → zerobucket-0.26.1}/tests/conftest.py +0 -0
  31. {zerobucket-0.26.0 → zerobucket-0.26.1}/tests/photo_fixtures.py +0 -0
  32. {zerobucket-0.26.0 → zerobucket-0.26.1}/tests/test_access_control.py +0 -0
  33. {zerobucket-0.26.0 → zerobucket-0.26.1}/tests/test_async_client.py +0 -0
  34. {zerobucket-0.26.0 → zerobucket-0.26.1}/tests/test_batch.py +0 -0
  35. {zerobucket-0.26.0 → zerobucket-0.26.1}/tests/test_cli.py +0 -0
  36. {zerobucket-0.26.0 → zerobucket-0.26.1}/tests/test_client.py +0 -0
  37. {zerobucket-0.26.0 → zerobucket-0.26.1}/tests/test_custom_validators.py +0 -0
  38. {zerobucket-0.26.0 → zerobucket-0.26.1}/tests/test_dedup.py +0 -0
  39. {zerobucket-0.26.0 → zerobucket-0.26.1}/tests/test_errors.py +0 -0
  40. {zerobucket-0.26.0 → zerobucket-0.26.1}/tests/test_malformed_ids.py +0 -0
  41. {zerobucket-0.26.0 → zerobucket-0.26.1}/tests/test_mysql_adapter.py +0 -0
  42. {zerobucket-0.26.0 → zerobucket-0.26.1}/tests/test_mysql_async_adapter.py +0 -0
  43. {zerobucket-0.26.0 → zerobucket-0.26.1}/tests/test_optimization.py +0 -0
  44. {zerobucket-0.26.0 → zerobucket-0.26.1}/tests/test_pdf_validator.py +0 -0
  45. {zerobucket-0.26.0 → zerobucket-0.26.1}/tests/test_pool_and_metrics.py +0 -0
  46. {zerobucket-0.26.0 → zerobucket-0.26.1}/tests/test_retry.py +0 -0
  47. {zerobucket-0.26.0 → zerobucket-0.26.1}/tests/test_sqlite_adapter.py +0 -0
  48. {zerobucket-0.26.0 → zerobucket-0.26.1}/tests/test_sqlite_async_adapter.py +0 -0
  49. {zerobucket-0.26.0 → zerobucket-0.26.1}/tests/test_streaming.py +0 -0
  50. {zerobucket-0.26.0 → zerobucket-0.26.1}/tests/test_tiering.py +0 -0
  51. {zerobucket-0.26.0 → zerobucket-0.26.1}/tests/test_transactions.py +0 -0
  52. {zerobucket-0.26.0 → zerobucket-0.26.1}/tests/test_validation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: zerobucket
3
- Version: 0.26.0
3
+ Version: 0.26.1
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "zerobucket"
7
- version = "0.26.0"
7
+ version = "0.26.1"
8
8
  description = "Database-native image storage. Your database. Your images. Zero buckets."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -40,7 +40,7 @@ from .types import (
40
40
  ImageMetadata,
41
41
  )
42
42
 
43
- __version__ = "0.26.0"
43
+ __version__ = "0.26.1"
44
44
 
45
45
  __all__ = [
46
46
  "ZeroBucket",
@@ -48,11 +48,14 @@ than left for someone to wonder about while reading unfamiliar code:
48
48
  a dynamically-sized `IN (?, ?, ...)` placeholder list instead.
49
49
  - `RETURNING` IS supported (SQLite >= 3.35, released March 2021) and is
50
50
  used the same way the Postgres adapter uses it.
51
- - get_stream() uses `sqlite3.Connection.blobopen()` (Python >= 3.11), a
52
- genuine incremental-BLOB-I/O API -- not a workaround built on top of
53
- substring()-style range queries the way the Postgres adapter's
54
- version is. This is arguably a more natural fit for streaming than
55
- the Postgres implementation, not a lesser one.
51
+ - get_stream() uses `sqlite3.Connection.blobopen()` where it exists
52
+ (Python >= 3.11), a genuine incremental-BLOB-I/O API -- not a
53
+ workaround built on top of substring()-style range queries the way the
54
+ Postgres adapter's version is. This package supports Python 3.10 too,
55
+ which has no blobopen(); there get_stream() reads the same bytes with
56
+ substr() range queries inside ONE held read transaction, which gives
57
+ the same snapshot behaviour (see `_stream_with_substr`). Both paths are
58
+ covered by tests/test_sqlite_stream_fallback.py on every Python.
56
59
  """
57
60
 
58
61
  from __future__ import annotations
@@ -67,6 +70,13 @@ from ..exceptions import StorageError
67
70
  from ..object_storage import ObjectStorage
68
71
  from .base import StorageBackend, StoredRecord, StoredRecordMetadata
69
72
 
73
+ # Connection.blobopen() (incremental BLOB I/O) was added in Python 3.11; this package
74
+ # supports 3.10. Where it is missing, get_stream() reads the same bytes with substr()
75
+ # range queries inside one held read transaction. A module-level flag, not an inline
76
+ # hasattr(), so the fallback can be exercised on newer Pythons too (the tests switch
77
+ # it off).
78
+ _HAS_BLOBOPEN = hasattr(sqlite3.Connection, "blobopen")
79
+
70
80
  DEFAULT_STREAM_CHUNK_SIZE = 1024 * 1024
71
81
 
72
82
  # 10 seconds. See _connect()'s docstring for why this is set explicitly
@@ -250,6 +260,60 @@ def _now_iso() -> str:
250
260
  return datetime.now(timezone.utc).isoformat()
251
261
 
252
262
 
263
+ def _stream_with_substr(
264
+ conn: sqlite3.Connection,
265
+ table: str,
266
+ rowid: int,
267
+ total_size: int,
268
+ chunk_size: int,
269
+ image_id: str,
270
+ owns_connection: bool,
271
+ ) -> Iterator[bytes]:
272
+ """get_stream() for Pythons without Connection.blobopen() (3.10).
273
+
274
+ Reads the bytes with `substr()` range queries, one per chunk, but inside ONE read
275
+ transaction held open for the whole stream. In WAL mode that transaction pins a
276
+ snapshot, so a stream that has started keeps returning the original bytes even if
277
+ another connection deletes the row meanwhile: the same behaviour as the blobopen()
278
+ path (and unlike the Postgres and async SQLite adapters, whose per-chunk queries
279
+ each see the current state). If the caller passed their own connection that is
280
+ already inside a transaction, that transaction is used as it is and left open.
281
+
282
+ `table` is one of two constants chosen by the caller, never user input.
283
+ """
284
+ began = False
285
+ try:
286
+ if not conn.in_transaction:
287
+ try:
288
+ conn.execute("BEGIN")
289
+ began = True
290
+ except Exception as exc: # noqa: BLE001
291
+ raise StorageError(f"Failed to stream image: {exc}") from exc
292
+ delivered = 0
293
+ while delivered < total_size:
294
+ want = min(chunk_size, total_size - delivered)
295
+ try:
296
+ row = conn.execute(
297
+ f"SELECT substr(data, ?, ?) FROM {table} WHERE rowid = ?;",
298
+ (delivered + 1, want, rowid), # substr() is 1-indexed
299
+ ).fetchone()
300
+ except Exception as exc: # noqa: BLE001
301
+ raise StorageError(f"Failed to stream image: {exc}") from exc
302
+ chunk = bytes(row[0]) if row is not None and row[0] is not None else b""
303
+ if not chunk:
304
+ raise StorageError(
305
+ f"Image {image_id!r} was deleted while streaming "
306
+ f"(delivered {delivered} of {total_size} bytes)."
307
+ )
308
+ yield chunk
309
+ delivered += len(chunk)
310
+ finally:
311
+ if began:
312
+ conn.rollback() # ends the read transaction; nothing was written
313
+ if owns_connection:
314
+ conn.close()
315
+
316
+
253
317
  class SQLiteBackend(StorageBackend):
254
318
  """Storage backend for SQLite. See module docstring for what's
255
319
  implemented in this phase and what isn't yet.
@@ -642,8 +706,10 @@ class SQLiteBackend(StorageBackend):
642
706
  chunk_size: int = DEFAULT_STREAM_CHUNK_SIZE,
643
707
  connection: sqlite3.Connection | None = None,
644
708
  ) -> Iterator[bytes] | None:
645
- """Uses `sqlite3.Connection.blobopen()` -- a real incremental-
646
- BLOB-read API, not a range-query workaround. Unlike the Postgres
709
+ """Uses `sqlite3.Connection.blobopen()` on Python 3.11+ -- a real
710
+ incremental-BLOB-read API, not a range-query workaround -- and
711
+ `substr()` range queries in one held read transaction on 3.10,
712
+ which has no blobopen() (see `_stream_with_substr`). Unlike the Postgres
647
713
  adapter (which does one `_run()` call PER CHUNK, each its own
648
714
  pooled connection), this holds ONE connection open for the
649
715
  WHOLE stream -- necessary because the Blob handle is tied to
@@ -663,7 +729,8 @@ class SQLiteBackend(StorageBackend):
663
729
  deleted by another connection mid-stream causes the NEXT chunk
664
730
  fetch to see no row and raise StorageError. On SQLite, this
665
731
  method holds ONE connection open for the whole stream, and
666
- `blobopen()`'s underlying read transaction gives it a
732
+ the read transaction (blobopen()'s own on Python 3.11+, an explicit
733
+ one on 3.10, where blobopen() does not exist) gives it a
667
734
  consistent snapshot (in WAL mode) of the row as it was when
668
735
  streaming began -- a concurrent DELETE from another connection
669
736
  does NOT interrupt or truncate an in-progress stream here; the
@@ -732,6 +799,17 @@ class SQLiteBackend(StorageBackend):
732
799
  table = "zerobucket_images"
733
800
 
734
801
  def generator() -> Iterator[bytes]:
802
+ if not _HAS_BLOBOPEN:
803
+ yield from _stream_with_substr(
804
+ conn,
805
+ table,
806
+ rowid,
807
+ total_size,
808
+ chunk_size,
809
+ image_id,
810
+ owns_connection,
811
+ )
812
+ return
735
813
  try:
736
814
  blob = conn.blobopen(table, "data", rowid, readonly=True)
737
815
  except Exception as exc: # noqa: BLE001
@@ -0,0 +1,160 @@
1
+ """SQLiteBackend.get_stream() on Pythons without Connection.blobopen() (3.10).
2
+
3
+ blobopen() (incremental BLOB I/O) arrived in Python 3.11, but this package supports 3.10,
4
+ so get_stream() has a fallback that reads the same bytes with substr() range queries
5
+ inside one held read transaction. These tests run every scenario through BOTH
6
+ implementations by switching the module flag, so the fallback is exercised on 3.11 and
7
+ 3.12 as well, not only on the one Python where it is the only option. (On 3.10 the
8
+ "blobopen" variant is skipped: there is nothing to switch to.)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import io
14
+ import os
15
+ import sqlite3
16
+ import tempfile
17
+
18
+ import pytest
19
+ from PIL import Image as PILImage
20
+
21
+ from zerobucket import ZeroBucket
22
+ from zerobucket.adapters import sqlite as sqlite_mod
23
+ from zerobucket.adapters.sqlite import SQLiteBackend
24
+ from zerobucket.exceptions import StorageError
25
+
26
+
27
+ @pytest.fixture(params=["blobopen", "substr"])
28
+ def impl(request, monkeypatch):
29
+ if request.param == "blobopen" and not hasattr(sqlite3.Connection, "blobopen"):
30
+ pytest.skip("this Python has no Connection.blobopen (3.10): only the fallback exists")
31
+ monkeypatch.setattr(sqlite_mod, "_HAS_BLOBOPEN", request.param == "blobopen")
32
+ return request.param
33
+
34
+
35
+ @pytest.fixture
36
+ def path():
37
+ p = tempfile.mktemp(suffix=".db")
38
+ yield p
39
+ for ext in ("", "-wal", "-shm"):
40
+ if os.path.exists(p + ext):
41
+ os.remove(p + ext)
42
+
43
+
44
+ @pytest.fixture(params=["classic", "dedup"])
45
+ def zb(request, path):
46
+ z = ZeroBucket(backend=SQLiteBackend(path, dedup=(request.param == "dedup")))
47
+ yield z
48
+ z.close()
49
+
50
+
51
+ def _noise_jpeg(side: int = 300) -> bytes:
52
+ """A JPEG of random pixels: large for its dimensions and different on every call."""
53
+ img = PILImage.frombytes("RGB", (side, side), os.urandom(side * side * 3))
54
+ buf = io.BytesIO()
55
+ img.save(buf, format="JPEG", quality=95)
56
+ return buf.getvalue()
57
+
58
+
59
+ def _delete_the_bytes_from_another_connection(path: str, image_id: str, dedup: bool) -> None:
60
+ """Delete the row get_stream() actually reads from: the image row, or in dedup mode the
61
+ shared blob row (deleting only the reference would leave the bytes in place and prove
62
+ nothing)."""
63
+ other = sqlite3.connect(path)
64
+ try:
65
+ if dedup:
66
+ # these tests store exactly one distinct image
67
+ other.execute("DELETE FROM zerobucket_blobs")
68
+ else:
69
+ other.execute("DELETE FROM zerobucket_images WHERE id = ?", (image_id,))
70
+ other.commit()
71
+ finally:
72
+ other.close()
73
+
74
+
75
+ def test_the_flag_reflects_this_python():
76
+ assert sqlite_mod._HAS_BLOBOPEN == hasattr(sqlite3.Connection, "blobopen")
77
+
78
+
79
+ def test_streams_the_exact_bytes_at_awkward_chunk_sizes(impl, zb):
80
+ data = _noise_jpeg()
81
+ image_id = zb.put(data)
82
+ for chunk_size in (1_000, 97, 4_096, len(data), len(data) + 5, 1_048_576):
83
+ chunks = list(zb.get_stream(image_id, chunk_size=chunk_size))
84
+ assert b"".join(chunks) == data, chunk_size
85
+ # every chunk but the last is exactly chunk_size, and none is empty
86
+ assert all(len(c) == chunk_size for c in chunks[:-1]), chunk_size
87
+ assert 0 < len(chunks[-1]) <= chunk_size, chunk_size
88
+
89
+
90
+ def test_a_stream_in_progress_keeps_its_snapshot_when_the_row_is_deleted_elsewhere(impl, zb, path):
91
+ data = _noise_jpeg()
92
+ image_id = zb.put(data)
93
+ stream = zb.get_stream(image_id, chunk_size=500)
94
+ first = next(stream)
95
+ _delete_the_bytes_from_another_connection(path, image_id, dedup=zb._backend._dedup)
96
+ rest = b"".join(stream)
97
+ assert first + rest == data
98
+
99
+
100
+ def test_a_row_deleted_before_the_first_chunk_raises_storage_error(impl, zb, path):
101
+ image_id = zb.put(_noise_jpeg())
102
+ stream = zb.get_stream(image_id, chunk_size=500) # nothing has been read yet
103
+ _delete_the_bytes_from_another_connection(path, image_id, dedup=zb._backend._dedup)
104
+ with pytest.raises(StorageError):
105
+ next(stream)
106
+
107
+
108
+ def test_an_open_stream_does_not_block_writers_in_other_connections(impl, zb, path):
109
+ image_id = zb.put(_noise_jpeg())
110
+ stream = zb.get_stream(image_id, chunk_size=500)
111
+ next(stream) # a read transaction is now open
112
+ other = ZeroBucket(backend=SQLiteBackend(path, dedup=zb._backend._dedup))
113
+ try:
114
+ # would raise "database is locked" if the open stream blocked writers
115
+ new_id = other.put(_noise_jpeg(64))
116
+ assert other.exists(new_id)
117
+ finally:
118
+ other.close()
119
+ stream.close()
120
+
121
+
122
+ def test_the_read_transaction_is_released_after_completion_and_after_abandonment(impl, zb, path):
123
+ data = _noise_jpeg()
124
+ image_id = zb.put(data)
125
+ conn = sqlite3.connect(path)
126
+ try:
127
+ assert b"".join(zb.get_stream(image_id, chunk_size=700, connection=conn)) == data
128
+ assert conn.in_transaction is False, "finished stream left a transaction open"
129
+
130
+ stream = zb.get_stream(image_id, chunk_size=700, connection=conn)
131
+ next(stream)
132
+ stream.close() # abandoned half way
133
+ assert conn.in_transaction is False, "abandoned stream left a transaction open"
134
+ finally:
135
+ conn.close()
136
+
137
+
138
+ def test_a_callers_own_open_transaction_is_used_and_left_open(impl, zb, path):
139
+ data = _noise_jpeg()
140
+ image_id = zb.put(data)
141
+ conn = sqlite3.connect(path)
142
+ try:
143
+ conn.execute("BEGIN")
144
+ assert conn.in_transaction
145
+ assert b"".join(zb.get_stream(image_id, chunk_size=700, connection=conn)) == data
146
+ assert conn.in_transaction, "the caller's transaction must not be ended by the stream"
147
+ conn.rollback()
148
+ finally:
149
+ conn.close()
150
+
151
+
152
+ def test_two_references_to_one_dedup_blob_each_stream_correctly(impl, path):
153
+ zb = ZeroBucket(backend=SQLiteBackend(path, dedup=True))
154
+ try:
155
+ data = _noise_jpeg()
156
+ a, b = zb.put(data), zb.put(data)
157
+ assert b"".join(zb.get_stream(a, chunk_size=333)) == data
158
+ assert b"".join(zb.get_stream(b, chunk_size=1_000)) == data
159
+ finally:
160
+ zb.close()
File without changes
File without changes