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 +34 -0
- zerobucket/adapters/__init__.py +9 -0
- zerobucket/adapters/base.py +80 -0
- zerobucket/adapters/postgres.py +172 -0
- zerobucket/client.py +165 -0
- zerobucket/exceptions.py +54 -0
- zerobucket/types.py +43 -0
- zerobucket/validation.py +103 -0
- zerobucket-0.1.0.dist-info/METADATA +31 -0
- zerobucket-0.1.0.dist-info/RECORD +11 -0
- zerobucket-0.1.0.dist-info/WHEEL +4 -0
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,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
|
+
)
|
zerobucket/exceptions.py
ADDED
|
@@ -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
|
zerobucket/validation.py
ADDED
|
@@ -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,,
|