zerobucket 0.1.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.
@@ -0,0 +1,32 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .mypy_cache/
11
+ htmlcov/
12
+ .coverage
13
+
14
+ # Virtual environments
15
+ .venv/
16
+ venv/
17
+ env/
18
+
19
+ # Environment / secrets -- never commit real credentials
20
+ .env
21
+ .env.*
22
+ !.env.example
23
+
24
+ # Editor/OS cruft
25
+ .vscode/
26
+ .idea/
27
+ .DS_Store
28
+ Thumbs.db
29
+
30
+ # Benchmark output (regenerate, don't version)
31
+ benchmarks/results.csv
32
+ benchmarks/test/retrieved*
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.5
2
+ Name: zerobucket
3
+ Version: 0.1.0
4
+ Summary: Database-native image storage. Your database. Your images. Zero buckets.
5
+ Project-URL: Homepage, https://github.com/KedarGhadyalji/ZeroBucket
6
+ Project-URL: Repository, https://github.com/KedarGhadyalji/ZeroBucket
7
+ Author-email: Kedar Ghadyalji <kedarghadyalji@gmail.com>
8
+ License-Expression: MIT
9
+ Keywords: bytea,image,postgresql,s3-alternative,storage
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Database
17
+ Classifier: Topic :: Multimedia :: Graphics
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: pillow>=10.0
20
+ Requires-Dist: psycopg-pool>=3.2
21
+ Requires-Dist: psycopg[binary]>=3.1
22
+ Provides-Extra: dev
23
+ Requires-Dist: mypy>=1.10; extra == 'dev'
24
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
25
+ Requires-Dist: pytest>=8.0; extra == 'dev'
26
+ Requires-Dist: ruff>=0.6; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # zerobucket
30
+
31
+ Database-native image storage. See the repository root README for full docs.
@@ -0,0 +1,3 @@
1
+ # zerobucket
2
+
3
+ Database-native image storage. See the repository root README for full docs.
@@ -0,0 +1,57 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "zerobucket"
7
+ version = "0.1.0"
8
+ description = "Database-native image storage. Your database. Your images. Zero buckets."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Kedar Ghadyalji", email = "kedarghadyalji@gmail.com" }]
13
+ keywords = ["image", "storage", "postgresql", "bytea", "s3-alternative"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Topic :: Database",
22
+ "Topic :: Multimedia :: Graphics",
23
+ ]
24
+ dependencies = [
25
+ "psycopg[binary]>=3.1",
26
+ "psycopg_pool>=3.2",
27
+ "Pillow>=10.0",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ dev = [
32
+ "pytest>=8.0",
33
+ "pytest-cov>=5.0",
34
+ "ruff>=0.6",
35
+ "mypy>=1.10",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/KedarGhadyalji/ZeroBucket"
40
+ Repository = "https://github.com/KedarGhadyalji/ZeroBucket"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["src/zerobucket"]
44
+
45
+ [tool.ruff]
46
+ line-length = 100
47
+ target-version = "py310"
48
+
49
+ [tool.ruff.lint]
50
+ select = ["E", "F", "I", "UP", "B"]
51
+
52
+ [tool.mypy]
53
+ python_version = "3.10"
54
+ strict = true
55
+
56
+ [tool.pytest.ini_options]
57
+ testpaths = ["tests"]
@@ -0,0 +1,34 @@
1
+ """ZeroBucket: database-native image storage.
2
+
3
+ from zerobucket import ZeroBucket
4
+ images = ZeroBucket(database_url="postgresql://...")
5
+ image_id = images.put("avatar.jpg")
6
+ image = images.get(image_id)
7
+ """
8
+
9
+ from .client import ZeroBucket
10
+ from .exceptions import (
11
+ CorruptedImageError,
12
+ ImageNotFoundError,
13
+ ImageTooLargeError,
14
+ ImageValidationError,
15
+ StorageError,
16
+ UnsupportedFormatError,
17
+ ZeroBucketError,
18
+ )
19
+ from .types import Image, ImageMetadata
20
+
21
+ __version__ = "0.1.0"
22
+
23
+ __all__ = [
24
+ "ZeroBucket",
25
+ "Image",
26
+ "ImageMetadata",
27
+ "ZeroBucketError",
28
+ "ImageValidationError",
29
+ "ImageTooLargeError",
30
+ "UnsupportedFormatError",
31
+ "CorruptedImageError",
32
+ "ImageNotFoundError",
33
+ "StorageError",
34
+ ]
@@ -0,0 +1,9 @@
1
+ from .base import StorageBackend, StoredRecord, StoredRecordMetadata
2
+ from .postgres import PostgresBackend
3
+
4
+ __all__ = [
5
+ "StorageBackend",
6
+ "StoredRecord",
7
+ "StoredRecordMetadata",
8
+ "PostgresBackend",
9
+ ]
@@ -0,0 +1,80 @@
1
+ """Storage backend interface.
2
+
3
+ CRITICAL DESIGN RULE: implementations of this interface know nothing about
4
+ images. They store and retrieve rows of bytes + metadata columns. All
5
+ image-specific logic (validation, format detection, resizing) lives in
6
+ zerobucket.client, above this layer.
7
+
8
+ This separation is what makes a future object-storage backend a real
9
+ drop-in replacement rather than a rewrite.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from abc import ABC, abstractmethod
15
+ from dataclasses import dataclass
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class StoredRecord:
20
+ """Raw record shape as persisted by a storage backend."""
21
+
22
+ id: str
23
+ data: bytes
24
+ mime_type: str
25
+ original_filename: str | None
26
+ size_bytes: int
27
+ width: int | None
28
+ height: int | None
29
+ checksum_sha256: str
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class StoredRecordMetadata:
34
+ """Same as StoredRecord but without `data`, for cheap existence/info checks."""
35
+
36
+ id: str
37
+ mime_type: str
38
+ original_filename: str | None
39
+ size_bytes: int
40
+ width: int | None
41
+ height: int | None
42
+ checksum_sha256: str
43
+
44
+
45
+ class StorageBackend(ABC):
46
+ """Abstract interface every ZeroBucket storage adapter must implement."""
47
+
48
+ @abstractmethod
49
+ def put(
50
+ self,
51
+ *,
52
+ data: bytes,
53
+ mime_type: str,
54
+ original_filename: str | None,
55
+ size_bytes: int,
56
+ width: int | None,
57
+ height: int | None,
58
+ checksum_sha256: str,
59
+ ) -> str:
60
+ """Persist a record and return its generated id."""
61
+
62
+ @abstractmethod
63
+ def get(self, image_id: str) -> StoredRecord | None:
64
+ """Fetch a full record including bytes, or None if it doesn't exist."""
65
+
66
+ @abstractmethod
67
+ def get_metadata(self, image_id: str) -> StoredRecordMetadata | None:
68
+ """Fetch metadata only (no bytes), or None if it doesn't exist."""
69
+
70
+ @abstractmethod
71
+ def delete(self, image_id: str) -> bool:
72
+ """Delete a record. Returns True if a record was deleted, False if it didn't exist."""
73
+
74
+ @abstractmethod
75
+ def exists(self, image_id: str) -> bool:
76
+ """Return whether a record with this id exists."""
77
+
78
+ @abstractmethod
79
+ def close(self) -> None:
80
+ """Release underlying connections/resources."""
@@ -0,0 +1,172 @@
1
+ """PostgreSQL storage adapter.
2
+
3
+ Stores image bytes directly in a BYTEA column. All queries are
4
+ parameterized; nothing is ever built via string concatenation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from psycopg_pool import ConnectionPool
10
+
11
+ from ..exceptions import StorageError
12
+ from .base import StorageBackend, StoredRecord, StoredRecordMetadata
13
+
14
+ _SCHEMA = """
15
+ CREATE TABLE IF NOT EXISTS zerobucket_images (
16
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
17
+ data BYTEA NOT NULL,
18
+ mime_type TEXT NOT NULL,
19
+ original_filename TEXT,
20
+ size_bytes INTEGER NOT NULL,
21
+ width INTEGER,
22
+ height INTEGER,
23
+ checksum_sha256 CHAR(64) NOT NULL,
24
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
25
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
26
+ );
27
+ CREATE INDEX IF NOT EXISTS idx_zerobucket_checksum ON zerobucket_images (checksum_sha256);
28
+ CREATE INDEX IF NOT EXISTS idx_zerobucket_created_at ON zerobucket_images (created_at);
29
+ """
30
+
31
+ _INSERT = """
32
+ INSERT INTO zerobucket_images
33
+ (data, mime_type, original_filename, size_bytes, width, height, checksum_sha256)
34
+ VALUES (%s, %s, %s, %s, %s, %s, %s)
35
+ RETURNING id;
36
+ """
37
+
38
+ _SELECT_FULL = """
39
+ SELECT id, data, mime_type, original_filename, size_bytes, width, height, checksum_sha256
40
+ FROM zerobucket_images
41
+ WHERE id = %s;
42
+ """
43
+
44
+ _SELECT_METADATA = """
45
+ SELECT id, mime_type, original_filename, size_bytes, width, height, checksum_sha256
46
+ FROM zerobucket_images
47
+ WHERE id = %s;
48
+ """
49
+
50
+ _DELETE = "DELETE FROM zerobucket_images WHERE id = %s;"
51
+
52
+ _EXISTS = "SELECT 1 FROM zerobucket_images WHERE id = %s;"
53
+
54
+
55
+ class PostgresBackend(StorageBackend):
56
+ """Storage backend for PostgreSQL using BYTEA columns.
57
+
58
+ Requires the pgcrypto extension (for gen_random_uuid()) on Postgres < 13.
59
+ Postgres 13+ has gen_random_uuid() built in.
60
+ """
61
+
62
+ def __init__(self, database_url: str, *, auto_migrate: bool = True) -> None:
63
+ try:
64
+ self._pool = ConnectionPool(
65
+ database_url, min_size=1, max_size=5, open=True, timeout=10
66
+ )
67
+ except Exception as exc: # noqa: BLE001
68
+ raise StorageError(f"Could not connect to PostgreSQL: {exc}") from exc
69
+
70
+ if auto_migrate:
71
+ try:
72
+ self.migrate()
73
+ except Exception:
74
+ # Don't leak the pool's background worker threads if setup
75
+ # fails partway through -- close it before propagating so
76
+ # callers (and test runners) don't hang on shutdown.
77
+ self._pool.close()
78
+ raise
79
+
80
+ def migrate(self) -> None:
81
+ """Create the zerobucket_images table and indexes if they don't exist."""
82
+ try:
83
+ with self._pool.connection() as conn, conn.cursor() as cur:
84
+ cur.execute(_SCHEMA)
85
+ except Exception as exc: # noqa: BLE001
86
+ raise StorageError(f"Migration failed: {exc}") from exc
87
+
88
+ def put(
89
+ self,
90
+ *,
91
+ data: bytes,
92
+ mime_type: str,
93
+ original_filename: str | None,
94
+ size_bytes: int,
95
+ width: int | None,
96
+ height: int | None,
97
+ checksum_sha256: str,
98
+ ) -> str:
99
+ try:
100
+ with self._pool.connection() as conn, conn.cursor() as cur:
101
+ params = (
102
+ data,
103
+ mime_type,
104
+ original_filename,
105
+ size_bytes,
106
+ width,
107
+ height,
108
+ checksum_sha256,
109
+ )
110
+ cur.execute(_INSERT, params)
111
+ row = cur.fetchone()
112
+ return str(row[0])
113
+ except Exception as exc: # noqa: BLE001
114
+ raise StorageError(f"Failed to store image: {exc}") from exc
115
+
116
+ def get(self, image_id: str) -> StoredRecord | None:
117
+ try:
118
+ with self._pool.connection() as conn, conn.cursor() as cur:
119
+ cur.execute(_SELECT_FULL, (image_id,))
120
+ row = cur.fetchone()
121
+ except Exception as exc: # noqa: BLE001
122
+ raise StorageError(f"Failed to retrieve image: {exc}") from exc
123
+ if row is None:
124
+ return None
125
+ return StoredRecord(
126
+ id=str(row[0]),
127
+ data=bytes(row[1]),
128
+ mime_type=row[2],
129
+ original_filename=row[3],
130
+ size_bytes=row[4],
131
+ width=row[5],
132
+ height=row[6],
133
+ checksum_sha256=row[7],
134
+ )
135
+
136
+ def get_metadata(self, image_id: str) -> StoredRecordMetadata | None:
137
+ try:
138
+ with self._pool.connection() as conn, conn.cursor() as cur:
139
+ cur.execute(_SELECT_METADATA, (image_id,))
140
+ row = cur.fetchone()
141
+ except Exception as exc: # noqa: BLE001
142
+ raise StorageError(f"Failed to retrieve image metadata: {exc}") from exc
143
+ if row is None:
144
+ return None
145
+ return StoredRecordMetadata(
146
+ id=str(row[0]),
147
+ mime_type=row[1],
148
+ original_filename=row[2],
149
+ size_bytes=row[3],
150
+ width=row[4],
151
+ height=row[5],
152
+ checksum_sha256=row[6],
153
+ )
154
+
155
+ def delete(self, image_id: str) -> bool:
156
+ try:
157
+ with self._pool.connection() as conn, conn.cursor() as cur:
158
+ cur.execute(_DELETE, (image_id,))
159
+ return cur.rowcount > 0
160
+ except Exception as exc: # noqa: BLE001
161
+ raise StorageError(f"Failed to delete image: {exc}") from exc
162
+
163
+ def exists(self, image_id: str) -> bool:
164
+ try:
165
+ with self._pool.connection() as conn, conn.cursor() as cur:
166
+ cur.execute(_EXISTS, (image_id,))
167
+ return cur.fetchone() is not None
168
+ except Exception as exc: # noqa: BLE001
169
+ raise StorageError(f"Failed to check image existence: {exc}") from exc
170
+
171
+ def close(self) -> None:
172
+ self._pool.close()
@@ -0,0 +1,165 @@
1
+ """The public ZeroBucket SDK entry point.
2
+
3
+ from zerobucket import ZeroBucket
4
+ images = ZeroBucket(database_url="postgresql://...")
5
+ image_id = images.put("avatar.jpg")
6
+ image = images.get(image_id)
7
+
8
+ The developer never needs to think about BYTEA, checksums, or connection
9
+ pooling -- that's all handled below.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import os
16
+ from pathlib import Path
17
+ from typing import BinaryIO, Union
18
+
19
+ from .adapters.base import StorageBackend
20
+ from .adapters.postgres import PostgresBackend
21
+ from .exceptions import ImageNotFoundError
22
+ from .types import Image, ImageMetadata
23
+ from .validation import DEFAULT_MAX_PIXELS, SUPPORTED_FORMATS, validate_image
24
+
25
+ # What put() accepts. Framework upload objects (e.g. Flask's FileStorage,
26
+ # FastAPI's UploadFile) are duck-typed against BinaryIO via .read().
27
+ ImageInput = Union[str, "os.PathLike[str]", bytes, BinaryIO]
28
+
29
+ # 8 MiB. Chosen as a practical ceiling for "small app" images (see README
30
+ # for rationale); pass max_bytes= to override per-instance.
31
+ DEFAULT_MAX_BYTES = 8 * 1024 * 1024
32
+
33
+
34
+ class ZeroBucket:
35
+ """Database-native image storage.
36
+
37
+ Args:
38
+ database_url: PostgreSQL connection string.
39
+ max_bytes: Maximum accepted image size in bytes. Defaults to 8 MiB.
40
+ ZeroBucket stores full images in a database column; it is not
41
+ designed for arbitrarily large files. See the README for why.
42
+ max_pixels: Decoded-pixel ceiling used to reject decompression
43
+ bombs, independent of compressed file size.
44
+ allowed_formats: Which image formats to accept. Defaults to
45
+ JPEG/PNG/WebP.
46
+ backend: Advanced -- inject a custom StorageBackend instead of
47
+ constructing a PostgresBackend from database_url.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ database_url: str | None = None,
53
+ *,
54
+ max_bytes: int = DEFAULT_MAX_BYTES,
55
+ max_pixels: int = DEFAULT_MAX_PIXELS,
56
+ allowed_formats: frozenset[str] = SUPPORTED_FORMATS,
57
+ backend: StorageBackend | None = None,
58
+ ) -> None:
59
+ if backend is not None:
60
+ self._backend = backend
61
+ elif database_url is not None:
62
+ self._backend = PostgresBackend(database_url)
63
+ else:
64
+ raise ValueError("Either database_url or backend must be provided")
65
+
66
+ self._max_bytes = max_bytes
67
+ self._max_pixels = max_pixels
68
+ self._allowed_formats = allowed_formats
69
+
70
+ def put(self, image: ImageInput, *, filename: str | None = None) -> str:
71
+ """Validate, process, and store an image. Returns its id.
72
+
73
+ Accepts a file path (str or PathLike), raw bytes, or any
74
+ file-like object with a .read() method.
75
+ """
76
+ data, resolved_filename = _read_image_input(image, filename)
77
+
78
+ validated = validate_image(
79
+ data,
80
+ max_bytes=self._max_bytes,
81
+ max_pixels=self._max_pixels,
82
+ allowed_formats=self._allowed_formats,
83
+ )
84
+ checksum = hashlib.sha256(data).hexdigest()
85
+
86
+ return self._backend.put(
87
+ data=data,
88
+ mime_type=validated.mime_type,
89
+ original_filename=resolved_filename,
90
+ size_bytes=validated.size_bytes,
91
+ width=validated.width,
92
+ height=validated.height,
93
+ checksum_sha256=checksum,
94
+ )
95
+
96
+ def get(self, image_id: str) -> Image:
97
+ """Retrieve a full image, including bytes. Raises ImageNotFoundError if missing."""
98
+ record = self._backend.get(image_id)
99
+ if record is None:
100
+ raise ImageNotFoundError(image_id)
101
+ return Image(
102
+ data=record.data,
103
+ mime_type=record.mime_type,
104
+ filename=record.original_filename,
105
+ size_bytes=record.size_bytes,
106
+ width=record.width,
107
+ height=record.height,
108
+ checksum_sha256=record.checksum_sha256,
109
+ )
110
+
111
+ def metadata(self, image_id: str) -> ImageMetadata:
112
+ """Retrieve image metadata without pulling the (potentially large) bytes."""
113
+ record = self._backend.get_metadata(image_id)
114
+ if record is None:
115
+ raise ImageNotFoundError(image_id)
116
+ return ImageMetadata(
117
+ image_id=record.id,
118
+ mime_type=record.mime_type,
119
+ filename=record.original_filename,
120
+ size_bytes=record.size_bytes,
121
+ width=record.width,
122
+ height=record.height,
123
+ checksum_sha256=record.checksum_sha256,
124
+ )
125
+
126
+ def exists(self, image_id: str) -> bool:
127
+ """Return whether an image with this id exists."""
128
+ return self._backend.exists(image_id)
129
+
130
+ def delete(self, image_id: str) -> bool:
131
+ """Delete an image. Returns True if it existed and was deleted, False otherwise."""
132
+ return self._backend.delete(image_id)
133
+
134
+ def close(self) -> None:
135
+ """Release underlying database connections."""
136
+ self._backend.close()
137
+
138
+ def __enter__(self) -> ZeroBucket:
139
+ return self
140
+
141
+ def __exit__(self, *exc_info: object) -> None:
142
+ self.close()
143
+
144
+
145
+ def _read_image_input(
146
+ image: ImageInput, filename: str | None
147
+ ) -> tuple[bytes, str | None]:
148
+ """Normalize any accepted input type into (bytes, filename)."""
149
+ if isinstance(image, bytes):
150
+ return image, filename
151
+ if isinstance(image, (str, os.PathLike)):
152
+ path = Path(image)
153
+ data = path.read_bytes()
154
+ return data, filename or path.name
155
+ if hasattr(image, "read"):
156
+ data = image.read()
157
+ if isinstance(data, str):
158
+ raise TypeError("File-like object must be opened in binary mode")
159
+ raw_name = getattr(image, "filename", None) or getattr(image, "name", None)
160
+ resolved_filename = filename or (os.path.basename(raw_name) if raw_name else None)
161
+ return data, resolved_filename
162
+ raise TypeError(
163
+ f"Unsupported image input type: {type(image)!r}. "
164
+ "Expected a file path, bytes, or a file-like object with .read()."
165
+ )
@@ -0,0 +1,54 @@
1
+ """Exception hierarchy for ZeroBucket.
2
+
3
+ All exceptions inherit from ZeroBucketError so callers can catch broadly
4
+ (`except ZeroBucketError`) or narrowly (`except ImageNotFoundError`).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class ZeroBucketError(Exception):
11
+ """Base class for all ZeroBucket exceptions."""
12
+
13
+
14
+ class ImageValidationError(ZeroBucketError):
15
+ """Raised when an image fails validation (bad format, too large, corrupted, etc.)."""
16
+
17
+
18
+ class ImageTooLargeError(ImageValidationError):
19
+ """Raised when an image exceeds the configured maximum size."""
20
+
21
+ def __init__(self, size_bytes: int, max_bytes: int) -> None:
22
+ self.size_bytes = size_bytes
23
+ self.max_bytes = max_bytes
24
+ super().__init__(
25
+ f"Image is {size_bytes} bytes, which exceeds the maximum of {max_bytes} bytes"
26
+ )
27
+
28
+
29
+ class UnsupportedFormatError(ImageValidationError):
30
+ """Raised when an image's detected format is not in the allowed set."""
31
+
32
+ def __init__(self, detected_format: str | None, allowed: frozenset[str]) -> None:
33
+ self.detected_format = detected_format
34
+ self.allowed = allowed
35
+ super().__init__(
36
+ f"Detected format {detected_format!r} is not supported. "
37
+ f"Allowed formats: {sorted(allowed)}"
38
+ )
39
+
40
+
41
+ class CorruptedImageError(ImageValidationError):
42
+ """Raised when image bytes cannot be decoded despite having a recognizable header."""
43
+
44
+
45
+ class ImageNotFoundError(ZeroBucketError):
46
+ """Raised when get() or metadata() is called with an image_id that doesn't exist."""
47
+
48
+ def __init__(self, image_id: str) -> None:
49
+ self.image_id = image_id
50
+ super().__init__(f"No image found with id {image_id!r}")
51
+
52
+
53
+ class StorageError(ZeroBucketError):
54
+ """Raised for underlying storage/database failures not covered above."""
@@ -0,0 +1,43 @@
1
+ """Public data types returned by the ZeroBucket SDK.
2
+
3
+ These are the only shapes callers should depend on. Internal storage
4
+ representation (BYTEA, column layout, etc.) is never exposed here.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class Image:
14
+ """A retrieved image, ready to be served or written to disk.
15
+
16
+ `data` is raw bytes -- never Base64. Serving it from a web framework
17
+ is just: Response(image.data, mimetype=image.mime_type)
18
+ """
19
+
20
+ data: bytes
21
+ mime_type: str
22
+ filename: str | None
23
+ size_bytes: int
24
+ width: int | None
25
+ height: int | None
26
+ checksum_sha256: str
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class ImageMetadata:
31
+ """Metadata about a stored image, without the pixel data itself.
32
+
33
+ Useful for existence/info checks that shouldn't pull potentially
34
+ multi-megabyte blobs over the wire.
35
+ """
36
+
37
+ image_id: str
38
+ mime_type: str
39
+ filename: str | None
40
+ size_bytes: int
41
+ width: int | None
42
+ height: int | None
43
+ checksum_sha256: str
@@ -0,0 +1,103 @@
1
+ """Image validation.
2
+
3
+ Deliberately does NOT trust file extensions or client-supplied MIME types.
4
+ The actual bytes are decoded with Pillow and the *detected* format is what
5
+ gets validated and stored. This is the only source of truth.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import io
11
+ from dataclasses import dataclass
12
+
13
+ from PIL import Image as PILImage
14
+
15
+ from .exceptions import (
16
+ CorruptedImageError,
17
+ ImageTooLargeError,
18
+ UnsupportedFormatError,
19
+ )
20
+
21
+ # Pillow format name -> canonical MIME type. Deliberately small allowlist;
22
+ # extend this (and SUPPORTED_FORMATS) to add formats, never bypass it.
23
+ _FORMAT_TO_MIME = {
24
+ "JPEG": "image/jpeg",
25
+ "PNG": "image/png",
26
+ "WEBP": "image/webp",
27
+ }
28
+
29
+ SUPPORTED_FORMATS = frozenset(_FORMAT_TO_MIME)
30
+
31
+ # Guard against decompression bombs: reject images that would decode to
32
+ # more than this many pixels, regardless of how small the compressed
33
+ # bytes are. ~89 megapixels ~= a 12000x7400 image.
34
+ DEFAULT_MAX_PIXELS = 89_000_000
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class ValidatedImage:
39
+ """Result of successful validation: everything derived from the bytes."""
40
+
41
+ mime_type: str
42
+ width: int
43
+ height: int
44
+ size_bytes: int
45
+
46
+
47
+ def validate_image(
48
+ data: bytes,
49
+ *,
50
+ max_bytes: int,
51
+ max_pixels: int = DEFAULT_MAX_PIXELS,
52
+ allowed_formats: frozenset[str] = SUPPORTED_FORMATS,
53
+ ) -> ValidatedImage:
54
+ """Validate raw image bytes and return derived metadata.
55
+
56
+ Raises ImageTooLargeError, UnsupportedFormatError, or CorruptedImageError.
57
+ Never raises for reasons unrelated to the image itself.
58
+ """
59
+ size_bytes = len(data)
60
+ if size_bytes > max_bytes:
61
+ raise ImageTooLargeError(size_bytes, max_bytes)
62
+ if size_bytes == 0:
63
+ raise CorruptedImageError("Image data is empty")
64
+
65
+ # Pillow's own decompression-bomb guard, in pixels (not compressed bytes).
66
+ # We set it per-call rather than mutating the module-global so concurrent
67
+ # validate_image() calls with different limits don't race each other.
68
+ original_max_pixels = PILImage.MAX_IMAGE_PIXELS
69
+ try:
70
+ PILImage.MAX_IMAGE_PIXELS = max_pixels
71
+ try:
72
+ with PILImage.open(io.BytesIO(data)) as img:
73
+ detected_format = img.format
74
+ if detected_format not in allowed_formats:
75
+ raise UnsupportedFormatError(detected_format, allowed_formats)
76
+ width, height = img.size
77
+ # .verify() only checks structural integrity; it does not
78
+ # decode pixel data (and Pillow requires reopening after
79
+ # calling it). Force a full pixel decode below to catch
80
+ # truncated/corrupted image bodies, not just bad headers.
81
+ except UnsupportedFormatError:
82
+ raise
83
+ except PILImage.DecompressionBombError as exc:
84
+ raise ImageTooLargeError(size_bytes, max_bytes) from exc
85
+ except Exception as exc: # noqa: BLE001 - Pillow raises many exception types
86
+ raise CorruptedImageError(f"Could not decode image: {exc}") from exc
87
+
88
+ # Force full pixel decode to catch truncated image bodies that pass
89
+ # header parsing but fail partway through the data.
90
+ try:
91
+ with PILImage.open(io.BytesIO(data)) as img:
92
+ img.load()
93
+ except Exception as exc: # noqa: BLE001
94
+ raise CorruptedImageError(f"Image data is truncated or corrupted: {exc}") from exc
95
+ finally:
96
+ PILImage.MAX_IMAGE_PIXELS = original_max_pixels
97
+
98
+ return ValidatedImage(
99
+ mime_type=_FORMAT_TO_MIME[detected_format],
100
+ width=width,
101
+ height=height,
102
+ size_bytes=size_bytes,
103
+ )
File without changes
@@ -0,0 +1,75 @@
1
+ """Shared pytest fixtures.
2
+
3
+ Integration tests require a real PostgreSQL instance. Set
4
+ ZEROBUCKET_TEST_DATABASE_URL to point at a throwaway database, e.g.:
5
+
6
+ export ZEROBUCKET_TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/zerobucket_test
7
+
8
+ Tests truncate the zerobucket_images table between runs rather than
9
+ dropping the database, so they're safe to run repeatedly.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import io
15
+ import os
16
+
17
+ import pytest
18
+ from PIL import Image as PILImage
19
+
20
+ from zerobucket import ZeroBucket
21
+ from zerobucket.adapters.postgres import PostgresBackend
22
+
23
+ TEST_DATABASE_URL = os.environ.get(
24
+ "ZEROBUCKET_TEST_DATABASE_URL",
25
+ "postgresql://postgres:postgres@localhost:5432/zerobucket_test",
26
+ )
27
+
28
+
29
+ def _make_image_bytes(*, size=(64, 48), color=(255, 0, 0), fmt="JPEG") -> bytes:
30
+ img = PILImage.new("RGB", size, color=color)
31
+ buf = io.BytesIO()
32
+ img.save(buf, format=fmt)
33
+ return buf.getvalue()
34
+
35
+
36
+ @pytest.fixture(scope="session")
37
+ def _db_available():
38
+ """Skip all integration tests cleanly if no test database is reachable."""
39
+ try:
40
+ backend = PostgresBackend(TEST_DATABASE_URL)
41
+ backend.close()
42
+ except Exception as exc: # noqa: BLE001
43
+ pytest.skip(f"No reachable test database ({TEST_DATABASE_URL}): {exc}")
44
+
45
+
46
+ @pytest.fixture
47
+ def images(_db_available):
48
+ """A ZeroBucket instance backed by a clean test table for each test."""
49
+ zb = ZeroBucket(database_url=TEST_DATABASE_URL)
50
+ # Ensure a clean slate per test rather than per session.
51
+ with zb._backend._pool.connection() as conn, conn.cursor() as cur: # noqa: SLF001
52
+ cur.execute("TRUNCATE TABLE zerobucket_images;")
53
+ yield zb
54
+ zb.close()
55
+
56
+
57
+ @pytest.fixture
58
+ def jpeg_bytes() -> bytes:
59
+ return _make_image_bytes(fmt="JPEG")
60
+
61
+
62
+ @pytest.fixture
63
+ def png_bytes() -> bytes:
64
+ return _make_image_bytes(fmt="PNG")
65
+
66
+
67
+ @pytest.fixture
68
+ def webp_bytes() -> bytes:
69
+ return _make_image_bytes(fmt="WEBP")
70
+
71
+
72
+ @pytest.fixture
73
+ def make_image_bytes():
74
+ """Factory fixture for tests that need custom size/color/format."""
75
+ return _make_image_bytes
@@ -0,0 +1,181 @@
1
+ """Integration tests against a real PostgreSQL database.
2
+
3
+ Requires ZEROBUCKET_TEST_DATABASE_URL (see conftest.py). Tests are skipped
4
+ automatically if no database is reachable.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import io
10
+
11
+ import pytest
12
+
13
+ from zerobucket import ZeroBucket
14
+ from zerobucket.exceptions import (
15
+ ImageNotFoundError,
16
+ ImageTooLargeError,
17
+ UnsupportedFormatError,
18
+ )
19
+
20
+
21
+ def test_put_and_get_round_trip(images, jpeg_bytes):
22
+ image_id = images.put(jpeg_bytes, filename="photo.jpg")
23
+ result = images.get(image_id)
24
+
25
+ assert result.data == jpeg_bytes
26
+ assert result.mime_type == "image/jpeg"
27
+ assert result.filename == "photo.jpg"
28
+ assert result.width == 64
29
+ assert result.height == 48
30
+ assert result.size_bytes == len(jpeg_bytes)
31
+ assert len(result.checksum_sha256) == 64
32
+
33
+
34
+ def test_put_from_file_path(images, tmp_path, jpeg_bytes):
35
+ path = tmp_path / "avatar.jpg"
36
+ path.write_bytes(jpeg_bytes)
37
+
38
+ image_id = images.put(str(path))
39
+ result = images.get(image_id)
40
+
41
+ assert result.data == jpeg_bytes
42
+ assert result.filename == "avatar.jpg"
43
+
44
+
45
+ def test_put_from_pathlib_path(images, tmp_path, jpeg_bytes):
46
+ path = tmp_path / "avatar2.jpg"
47
+ path.write_bytes(jpeg_bytes)
48
+
49
+ image_id = images.put(path)
50
+ result = images.get(image_id)
51
+
52
+ assert result.data == jpeg_bytes
53
+
54
+
55
+ def test_put_from_file_like_object(images, jpeg_bytes):
56
+ file_obj = io.BytesIO(jpeg_bytes)
57
+ file_obj.name = "upload.jpg"
58
+
59
+ image_id = images.put(file_obj)
60
+ result = images.get(image_id)
61
+
62
+ assert result.data == jpeg_bytes
63
+ assert result.filename == "upload.jpg"
64
+
65
+
66
+ def test_explicit_filename_overrides_inferred_one(images, tmp_path, jpeg_bytes):
67
+ path = tmp_path / "original_name.jpg"
68
+ path.write_bytes(jpeg_bytes)
69
+
70
+ image_id = images.put(str(path), filename="renamed.jpg")
71
+ result = images.get(image_id)
72
+
73
+ assert result.filename == "renamed.jpg"
74
+
75
+
76
+ def test_different_formats_all_supported(images, jpeg_bytes, png_bytes, webp_bytes):
77
+ jpeg_id = images.put(jpeg_bytes)
78
+ png_id = images.put(png_bytes)
79
+ webp_id = images.put(webp_bytes)
80
+
81
+ assert images.get(jpeg_id).mime_type == "image/jpeg"
82
+ assert images.get(png_id).mime_type == "image/png"
83
+ assert images.get(webp_id).mime_type == "image/webp"
84
+
85
+
86
+ def test_get_missing_image_raises(images):
87
+ fake_id = "00000000-0000-0000-0000-000000000000"
88
+ with pytest.raises(ImageNotFoundError):
89
+ images.get(fake_id)
90
+
91
+
92
+ def test_metadata_missing_image_raises(images):
93
+ fake_id = "00000000-0000-0000-0000-000000000000"
94
+ with pytest.raises(ImageNotFoundError):
95
+ images.metadata(fake_id)
96
+
97
+
98
+ def test_metadata_matches_get_but_excludes_data(images, jpeg_bytes):
99
+ image_id = images.put(jpeg_bytes, filename="a.jpg")
100
+ full = images.get(image_id)
101
+ meta = images.metadata(image_id)
102
+
103
+ assert meta.image_id == image_id
104
+ assert meta.mime_type == full.mime_type
105
+ assert meta.filename == full.filename
106
+ assert meta.size_bytes == full.size_bytes
107
+ assert meta.width == full.width
108
+ assert meta.height == full.height
109
+ assert meta.checksum_sha256 == full.checksum_sha256
110
+ assert not hasattr(meta, "data")
111
+
112
+
113
+ def test_exists_true_for_stored_image(images, jpeg_bytes):
114
+ image_id = images.put(jpeg_bytes)
115
+ assert images.exists(image_id) is True
116
+
117
+
118
+ def test_exists_false_for_missing_image(images):
119
+ assert images.exists("00000000-0000-0000-0000-000000000000") is False
120
+
121
+
122
+ def test_delete_removes_image(images, jpeg_bytes):
123
+ image_id = images.put(jpeg_bytes)
124
+ assert images.delete(image_id) is True
125
+ assert images.exists(image_id) is False
126
+ with pytest.raises(ImageNotFoundError):
127
+ images.get(image_id)
128
+
129
+
130
+ def test_delete_missing_image_returns_false(images):
131
+ assert images.delete("00000000-0000-0000-0000-000000000000") is False
132
+
133
+
134
+ def test_uploading_same_image_twice_creates_two_records(images, jpeg_bytes):
135
+ """Dedup is explicitly deferred (see architecture notes) -- verify current,
136
+ documented behavior: duplicate uploads create separate rows with matching
137
+ checksums, rather than silently merging or erroring."""
138
+ id_a = images.put(jpeg_bytes)
139
+ id_b = images.put(jpeg_bytes)
140
+
141
+ assert id_a != id_b
142
+ assert images.get(id_a).checksum_sha256 == images.get(id_b).checksum_sha256
143
+
144
+
145
+ def test_size_limit_enforced(_db_available, make_image_bytes):
146
+ from tests.conftest import TEST_DATABASE_URL
147
+
148
+ small_images = ZeroBucket(database_url=TEST_DATABASE_URL, max_bytes=200)
149
+ try:
150
+ data = make_image_bytes(size=(200, 200))
151
+ with pytest.raises(ImageTooLargeError):
152
+ small_images.put(data)
153
+ finally:
154
+ small_images.close()
155
+
156
+
157
+ def test_unsupported_format_rejected(images):
158
+ with pytest.raises(UnsupportedFormatError):
159
+ # Minimal valid GIF header + trailer.
160
+ gif = bytes.fromhex(
161
+ "47494638396101000100800000000000ffffff21f90401000000002c00000000010001000002024401003b"
162
+ )
163
+ images.put(gif, filename="test.gif")
164
+
165
+
166
+ def test_concurrent_puts_all_succeed(images, make_image_bytes):
167
+ """Basic concurrency smoke test: N images uploaded via threads all round-trip correctly."""
168
+ import concurrent.futures
169
+
170
+ payloads = [make_image_bytes(color=(i % 255, 0, 0)) for i in range(10)]
171
+
172
+ def upload(data: bytes) -> tuple[str, bytes]:
173
+ return images.put(data), data
174
+
175
+ with concurrent.futures.ThreadPoolExecutor(max_workers=5) as pool:
176
+ results = list(pool.map(upload, payloads))
177
+
178
+ ids = [r[0] for r in results]
179
+ assert len(set(ids)) == len(ids) # all ids unique
180
+ for image_id, original_data in results:
181
+ assert images.get(image_id).data == original_data
@@ -0,0 +1,27 @@
1
+ """Tests for error handling that don't require a healthy database connection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import MagicMock
6
+
7
+ import pytest
8
+
9
+ from zerobucket import ZeroBucket
10
+ from zerobucket.client import ZeroBucket as ZB
11
+ from zerobucket.exceptions import StorageError
12
+
13
+
14
+ def test_unreachable_database_raises_storage_error():
15
+ with pytest.raises(StorageError):
16
+ ZeroBucket(database_url="postgresql://baduser:badpass@localhost:1/nonexistent_db")
17
+
18
+
19
+ def test_zerobucket_requires_database_url_or_backend():
20
+ with pytest.raises(ValueError):
21
+ ZeroBucket()
22
+
23
+
24
+ def test_put_rejects_unsupported_input_type():
25
+ zb = ZB(backend=MagicMock())
26
+ with pytest.raises(TypeError):
27
+ zb.put(12345) # not a path, bytes, or file-like object
@@ -0,0 +1,100 @@
1
+ """Unit tests for image validation. No database required."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+
7
+ import pytest
8
+ from PIL import Image as PILImage
9
+
10
+ from zerobucket.exceptions import (
11
+ CorruptedImageError,
12
+ ImageTooLargeError,
13
+ UnsupportedFormatError,
14
+ )
15
+ from zerobucket.validation import validate_image
16
+
17
+
18
+ def _jpeg(size=(32, 32)) -> bytes:
19
+ img = PILImage.new("RGB", size, color=(10, 20, 30))
20
+ buf = io.BytesIO()
21
+ img.save(buf, format="JPEG")
22
+ return buf.getvalue()
23
+
24
+
25
+ def test_valid_jpeg_passes():
26
+ data = _jpeg((100, 50))
27
+ result = validate_image(data, max_bytes=10_000_000)
28
+ assert result.mime_type == "image/jpeg"
29
+ assert result.width == 100
30
+ assert result.height == 50
31
+ assert result.size_bytes == len(data)
32
+
33
+
34
+ def test_valid_png_passes():
35
+ img = PILImage.new("RGB", (10, 10))
36
+ buf = io.BytesIO()
37
+ img.save(buf, format="PNG")
38
+ result = validate_image(buf.getvalue(), max_bytes=10_000_000)
39
+ assert result.mime_type == "image/png"
40
+
41
+
42
+ def test_valid_webp_passes():
43
+ img = PILImage.new("RGB", (10, 10))
44
+ buf = io.BytesIO()
45
+ img.save(buf, format="WEBP")
46
+ result = validate_image(buf.getvalue(), max_bytes=10_000_000)
47
+ assert result.mime_type == "image/webp"
48
+
49
+
50
+ def test_oversized_image_rejected():
51
+ data = _jpeg((500, 500))
52
+ with pytest.raises(ImageTooLargeError):
53
+ validate_image(data, max_bytes=10)
54
+
55
+
56
+ def test_empty_bytes_rejected():
57
+ with pytest.raises(CorruptedImageError):
58
+ validate_image(b"", max_bytes=10_000_000)
59
+
60
+
61
+ def test_random_bytes_rejected():
62
+ with pytest.raises(CorruptedImageError):
63
+ validate_image(b"not an image, just some random bytes here" * 5, max_bytes=10_000_000)
64
+
65
+
66
+ def test_truncated_image_rejected():
67
+ data = _jpeg((200, 200))
68
+ truncated = data[: len(data) // 3]
69
+ with pytest.raises(CorruptedImageError):
70
+ validate_image(truncated, max_bytes=10_000_000)
71
+
72
+
73
+ def test_gif_rejected_as_unsupported_format():
74
+ img = PILImage.new("RGB", (10, 10))
75
+ buf = io.BytesIO()
76
+ img.save(buf, format="GIF")
77
+ with pytest.raises(UnsupportedFormatError):
78
+ validate_image(buf.getvalue(), max_bytes=10_000_000)
79
+
80
+
81
+ def test_does_not_trust_fake_extension_only_content():
82
+ """A PNG's magic bytes determine its type, regardless of what a caller might claim."""
83
+ img = PILImage.new("RGB", (10, 10))
84
+ buf = io.BytesIO()
85
+ img.save(buf, format="PNG")
86
+ data = buf.getvalue()
87
+ # Even though nothing here claims "this is a .jpg", validate_image must
88
+ # detect PNG from content, not trust any external hint.
89
+ result = validate_image(data, max_bytes=10_000_000)
90
+ assert result.mime_type == "image/png"
91
+
92
+
93
+ def test_decompression_bomb_guard():
94
+ """A tiny compressed image that claims an enormous pixel count is rejected."""
95
+ img = PILImage.new("RGB", (20000, 20000), color=(1, 1, 1))
96
+ buf = io.BytesIO()
97
+ img.save(buf, format="PNG", compress_level=9)
98
+ data = buf.getvalue()
99
+ with pytest.raises((ImageTooLargeError, CorruptedImageError)):
100
+ validate_image(data, max_bytes=10_000_000, max_pixels=1_000_000)