zerobucket 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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()
zerobucket/client.py ADDED
@@ -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."""
zerobucket/types.py ADDED
@@ -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
+ )
@@ -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,11 @@
1
+ zerobucket/__init__.py,sha256=opwT-a0m8sayhLZYKLngwpjvywJYKhqXxmzR85RvUmc,755
2
+ zerobucket/client.py,sha256=c6x_tOHfTH7MNIOnKq4HDuyj8e5lIa5oZkTn3K0Kxps,6028
3
+ zerobucket/exceptions.py,sha256=OEreRbHRQMHnh1n0jWYmoOyBXOwJfGa6Cb__6VB93cY,1819
4
+ zerobucket/types.py,sha256=1EMrqTIuWQ5b8XMYtmseYxbLrMqJrgtMfYZuLMtBsyo,1073
5
+ zerobucket/validation.py,sha256=0viA57YMZp4r-UrouawDHaslieP_pU9aFdaUGc8JQxw,3663
6
+ zerobucket/adapters/__init__.py,sha256=pjl6-FkFNqtCN2A9O7H8jIC1ZkerZSaGsfNj2hC2ezA,215
7
+ zerobucket/adapters/base.py,sha256=hVwHmjHtRoFAtJfDesq0P8ARLMxNZePugq_CUB1PA7o,2260
8
+ zerobucket/adapters/postgres.py,sha256=GbmnAWEZC8OT0twKg0XK9z9zQBDF8EzgrYD2glBptbM,5968
9
+ zerobucket-0.1.0.dist-info/METADATA,sha256=1Xg0wE-AqsQHrPIH4-Fei3mmLXFslbGU24JlUsd_B4o,1223
10
+ zerobucket-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
11
+ zerobucket-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any