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.
- zerobucket-0.1.0/.gitignore +32 -0
- zerobucket-0.1.0/PKG-INFO +31 -0
- zerobucket-0.1.0/README.md +3 -0
- zerobucket-0.1.0/pyproject.toml +57 -0
- zerobucket-0.1.0/src/zerobucket/__init__.py +34 -0
- zerobucket-0.1.0/src/zerobucket/adapters/__init__.py +9 -0
- zerobucket-0.1.0/src/zerobucket/adapters/base.py +80 -0
- zerobucket-0.1.0/src/zerobucket/adapters/postgres.py +172 -0
- zerobucket-0.1.0/src/zerobucket/client.py +165 -0
- zerobucket-0.1.0/src/zerobucket/exceptions.py +54 -0
- zerobucket-0.1.0/src/zerobucket/types.py +43 -0
- zerobucket-0.1.0/src/zerobucket/validation.py +103 -0
- zerobucket-0.1.0/tests/__init__.py +0 -0
- zerobucket-0.1.0/tests/conftest.py +75 -0
- zerobucket-0.1.0/tests/test_client.py +181 -0
- zerobucket-0.1.0/tests/test_errors.py +27 -0
- zerobucket-0.1.0/tests/test_validation.py +100 -0
|
@@ -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,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,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)
|