s-storagekit 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.
- s_storagekit-0.1.0/.gitignore +37 -0
- s_storagekit-0.1.0/PKG-INFO +217 -0
- s_storagekit-0.1.0/README.md +202 -0
- s_storagekit-0.1.0/pyproject.toml +41 -0
- s_storagekit-0.1.0/storagekit/__init__.py +72 -0
- s_storagekit-0.1.0/storagekit/base.py +43 -0
- s_storagekit-0.1.0/storagekit/images.py +131 -0
- s_storagekit-0.1.0/storagekit/minio_storage.py +94 -0
- s_storagekit-0.1.0/storagekit/presign.py +62 -0
- s_storagekit-0.1.0/storagekit/testing.py +44 -0
- s_storagekit-0.1.0/tests/__init__.py +1 -0
- s_storagekit-0.1.0/tests/test_base.py +76 -0
- s_storagekit-0.1.0/tests/test_images.py +175 -0
- s_storagekit-0.1.0/tests/test_presign.py +23 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*$py.class
|
|
4
|
+
*.so
|
|
5
|
+
.Python
|
|
6
|
+
build/
|
|
7
|
+
develop-eggs/
|
|
8
|
+
dist/
|
|
9
|
+
downloads/
|
|
10
|
+
eggs/
|
|
11
|
+
.eggs/
|
|
12
|
+
lib/
|
|
13
|
+
lib64/
|
|
14
|
+
parts/
|
|
15
|
+
sdist/
|
|
16
|
+
var/
|
|
17
|
+
wheels/
|
|
18
|
+
pip-wheel-metadata/
|
|
19
|
+
share/python-wheels/
|
|
20
|
+
*.egg-info/
|
|
21
|
+
.installed.cfg
|
|
22
|
+
*.egg
|
|
23
|
+
MANIFEST
|
|
24
|
+
.pytest_cache/
|
|
25
|
+
.ruff_cache/
|
|
26
|
+
.venv/
|
|
27
|
+
env/
|
|
28
|
+
venv/
|
|
29
|
+
ENV/
|
|
30
|
+
env.bak/
|
|
31
|
+
venv.bak/
|
|
32
|
+
.vscode/
|
|
33
|
+
.idea/
|
|
34
|
+
*.swp
|
|
35
|
+
*.swo
|
|
36
|
+
*~
|
|
37
|
+
.DS_Store
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: s-storagekit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Объектное хранилище (ObjectStorage protocol + MinIO adapter + image normalization for Skillery)
|
|
5
|
+
Author: Dmitry
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: anyio>=3.0
|
|
9
|
+
Requires-Dist: minio>=7.0
|
|
10
|
+
Requires-Dist: pillow>=10.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# s-storagekit
|
|
17
|
+
|
|
18
|
+
Объектное хранилище для Skillery: портабельное async-API для key→bytes операций с реализациями S3-compatible протокола (MinIO) и вспомогательными утилитами нормализации изображений (Pillow).
|
|
19
|
+
|
|
20
|
+
## Установка
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install s-storagekit>=0.1.0
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Быстрый старт
|
|
27
|
+
|
|
28
|
+
### Использование MinIO
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from storagekit import MinioObjectStorage, ObjectNotFoundError
|
|
32
|
+
|
|
33
|
+
# Инициализация
|
|
34
|
+
storage = MinioObjectStorage(
|
|
35
|
+
endpoint="minio:9000",
|
|
36
|
+
access_key="minioadmin",
|
|
37
|
+
secret_key="minioadmin",
|
|
38
|
+
bucket="my-bucket",
|
|
39
|
+
secure=False, # http в dev, https в prod
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Запись объекта
|
|
43
|
+
await storage.put("avatars/user_1/photo.jpg", image_bytes, "image/jpeg")
|
|
44
|
+
|
|
45
|
+
# Чтение объекта
|
|
46
|
+
data, content_type = await storage.get("avatars/user_1/photo.jpg")
|
|
47
|
+
|
|
48
|
+
# Удаление объекта
|
|
49
|
+
await storage.delete("avatars/user_1/photo.jpg")
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Обработка изображений
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from storagekit import (
|
|
56
|
+
normalize_image,
|
|
57
|
+
normalize_square_avatar,
|
|
58
|
+
square_avatar_variants,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Квадратный аватар одного размера
|
|
62
|
+
jpeg_bytes, ct = normalize_square_avatar(raw_image, size=256)
|
|
63
|
+
await storage.put(f"avatars/user_1/{token}.jpg", jpeg_bytes, ct)
|
|
64
|
+
|
|
65
|
+
# Несколько вариантов размеров (responsive)
|
|
66
|
+
variants = square_avatar_variants(raw_image, sizes=[64, 128, 256])
|
|
67
|
+
for size, blob in variants:
|
|
68
|
+
key = f"avatars/user_1/{token}_{size}.jpg"
|
|
69
|
+
await storage.put(key, blob, "image/jpeg")
|
|
70
|
+
|
|
71
|
+
# Масштабирование обложки (сохраняет пропорции)
|
|
72
|
+
cover_data, ct = normalize_image(raw_image, max_px=1200)
|
|
73
|
+
await storage.put(f"skills/42/cover_{token}.jpg", cover_data, ct)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Тестирование
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
import pytest
|
|
80
|
+
from storagekit.testing import InMemoryObjectStorage, fake_image_bytes
|
|
81
|
+
|
|
82
|
+
@pytest.fixture
|
|
83
|
+
def storage():
|
|
84
|
+
return InMemoryObjectStorage()
|
|
85
|
+
|
|
86
|
+
@pytest.mark.asyncio
|
|
87
|
+
async def test_avatar_upload(storage):
|
|
88
|
+
raw = fake_image_bytes(100, 100)
|
|
89
|
+
jpeg_bytes, _ = normalize_square_avatar(raw, size=256)
|
|
90
|
+
|
|
91
|
+
await storage.put("avatars/test.jpg", jpeg_bytes, "image/jpeg")
|
|
92
|
+
data, ct = await storage.get("avatars/test.jpg")
|
|
93
|
+
|
|
94
|
+
assert ct == "image/jpeg"
|
|
95
|
+
assert len(data) > 0
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Presigned URL (без auth)
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from storagekit import PresignedUrlGenerator
|
|
102
|
+
|
|
103
|
+
gen = PresignedUrlGenerator(
|
|
104
|
+
endpoint="minio:9000",
|
|
105
|
+
access_key="minioadmin",
|
|
106
|
+
secret_key="minioadmin",
|
|
107
|
+
bucket="my-bucket",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Генерировать URL с TTL (по умолчанию 1 час)
|
|
111
|
+
url = await gen.generate_presigned_get_url("avatars/user_1/photo.jpg")
|
|
112
|
+
# Клиент может скачать файл по этому URL без токена
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## API
|
|
116
|
+
|
|
117
|
+
### Протоколы
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
class ObjectStorage(Protocol):
|
|
121
|
+
"""Async key→bytes хранилище."""
|
|
122
|
+
|
|
123
|
+
async def put(self, key: str, data: bytes, content_type: str) -> None:
|
|
124
|
+
"""Записать/перезаписать объект."""
|
|
125
|
+
...
|
|
126
|
+
|
|
127
|
+
async def get(self, key: str) -> tuple[bytes, str]:
|
|
128
|
+
"""Вернуть (data, content_type). Бросает ObjectNotFoundError."""
|
|
129
|
+
...
|
|
130
|
+
|
|
131
|
+
async def delete(self, key: str) -> None:
|
|
132
|
+
"""Удалить объект. Отсутствие не ошибка (idempotent)."""
|
|
133
|
+
...
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Исключения
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
class ObjectNotFoundError(Exception):
|
|
140
|
+
"""Объект не найден. Имеет атрибут .key."""
|
|
141
|
+
def __init__(self, key: str) -> None: ...
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Реализации
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
# S3-compatible хранилище (prod/dev)
|
|
148
|
+
class MinioObjectStorage:
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
*,
|
|
152
|
+
endpoint: str, # host:port БЕЗ схемы
|
|
153
|
+
access_key: str,
|
|
154
|
+
secret_key: str,
|
|
155
|
+
bucket: str,
|
|
156
|
+
secure: bool = False, # http (dev) vs https (prod)
|
|
157
|
+
) -> None: ...
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Image-утилиты
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
# Список допустимых MIME-типов входных файлов
|
|
164
|
+
ALLOWED_IMAGE_CONTENT_TYPES: frozenset[str]
|
|
165
|
+
|
|
166
|
+
# Квадратный аватар (center cover-crop) → JPEG
|
|
167
|
+
def normalize_square_avatar(data: bytes, *, size: int) -> tuple[bytes, str]: ...
|
|
168
|
+
|
|
169
|
+
# Несколько вариантов размеров одного аватара
|
|
170
|
+
def square_avatar_variants(
|
|
171
|
+
data: bytes, *, sizes: list[int]
|
|
172
|
+
) -> list[tuple[int, bytes]]: ...
|
|
173
|
+
|
|
174
|
+
# Масштабирование по длинной стороне (сохраняет пропорции)
|
|
175
|
+
def normalize_image(data: bytes, *, max_px: int) -> tuple[bytes, str]: ...
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Утилиты
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
# Канонический ключ snapshot'а версии
|
|
182
|
+
def snapshot_key(*, skill_id: int | str, semver: str) -> str:
|
|
183
|
+
"""Возвращает f'skills/{skill_id}/versions/{semver}.tar.gz'."""
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Тестирование
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
# Фейк-хранилище в памяти
|
|
190
|
+
class InMemoryObjectStorage:
|
|
191
|
+
async def put(self, key: str, data: bytes, content_type: str) -> None: ...
|
|
192
|
+
async def get(self, key: str) -> tuple[bytes, str]: ...
|
|
193
|
+
async def delete(self, key: str) -> None: ...
|
|
194
|
+
|
|
195
|
+
# Генерировать тестовый PNG-файл
|
|
196
|
+
def fake_image_bytes(width: int = 100, height: int = 100) -> bytes: ...
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Параметризация
|
|
200
|
+
|
|
201
|
+
| Параметр | Значение | Примечание |
|
|
202
|
+
|----------|----------|-----------|
|
|
203
|
+
| MinIO endpoint | `host:port` (БЕЗ схемы) | dev: `minio:9000`, prod: `minio.example.com:9000` |
|
|
204
|
+
| MinIO secure | `False` (dev), `True` (prod) | http vs https |
|
|
205
|
+
| Avatar sizes | `[64, 128, 256]` (default) | Можно переопределить в клиенте |
|
|
206
|
+
| Skill cover max px | `1200` (constant) | Не параметризуется |
|
|
207
|
+
| JPEG quality | `85` (constant) | Не параметризуется |
|
|
208
|
+
|
|
209
|
+
## Зависимости
|
|
210
|
+
|
|
211
|
+
- **minio>=7.0** — S3 SDK
|
|
212
|
+
- **anyio>=3.0** — async threading
|
|
213
|
+
- **Pillow>=10.0** — image processing
|
|
214
|
+
|
|
215
|
+
## Лицензия
|
|
216
|
+
|
|
217
|
+
MIT
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# s-storagekit
|
|
2
|
+
|
|
3
|
+
Объектное хранилище для Skillery: портабельное async-API для key→bytes операций с реализациями S3-compatible протокола (MinIO) и вспомогательными утилитами нормализации изображений (Pillow).
|
|
4
|
+
|
|
5
|
+
## Установка
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install s-storagekit>=0.1.0
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Быстрый старт
|
|
12
|
+
|
|
13
|
+
### Использование MinIO
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from storagekit import MinioObjectStorage, ObjectNotFoundError
|
|
17
|
+
|
|
18
|
+
# Инициализация
|
|
19
|
+
storage = MinioObjectStorage(
|
|
20
|
+
endpoint="minio:9000",
|
|
21
|
+
access_key="minioadmin",
|
|
22
|
+
secret_key="minioadmin",
|
|
23
|
+
bucket="my-bucket",
|
|
24
|
+
secure=False, # http в dev, https в prod
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Запись объекта
|
|
28
|
+
await storage.put("avatars/user_1/photo.jpg", image_bytes, "image/jpeg")
|
|
29
|
+
|
|
30
|
+
# Чтение объекта
|
|
31
|
+
data, content_type = await storage.get("avatars/user_1/photo.jpg")
|
|
32
|
+
|
|
33
|
+
# Удаление объекта
|
|
34
|
+
await storage.delete("avatars/user_1/photo.jpg")
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Обработка изображений
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from storagekit import (
|
|
41
|
+
normalize_image,
|
|
42
|
+
normalize_square_avatar,
|
|
43
|
+
square_avatar_variants,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Квадратный аватар одного размера
|
|
47
|
+
jpeg_bytes, ct = normalize_square_avatar(raw_image, size=256)
|
|
48
|
+
await storage.put(f"avatars/user_1/{token}.jpg", jpeg_bytes, ct)
|
|
49
|
+
|
|
50
|
+
# Несколько вариантов размеров (responsive)
|
|
51
|
+
variants = square_avatar_variants(raw_image, sizes=[64, 128, 256])
|
|
52
|
+
for size, blob in variants:
|
|
53
|
+
key = f"avatars/user_1/{token}_{size}.jpg"
|
|
54
|
+
await storage.put(key, blob, "image/jpeg")
|
|
55
|
+
|
|
56
|
+
# Масштабирование обложки (сохраняет пропорции)
|
|
57
|
+
cover_data, ct = normalize_image(raw_image, max_px=1200)
|
|
58
|
+
await storage.put(f"skills/42/cover_{token}.jpg", cover_data, ct)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Тестирование
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
import pytest
|
|
65
|
+
from storagekit.testing import InMemoryObjectStorage, fake_image_bytes
|
|
66
|
+
|
|
67
|
+
@pytest.fixture
|
|
68
|
+
def storage():
|
|
69
|
+
return InMemoryObjectStorage()
|
|
70
|
+
|
|
71
|
+
@pytest.mark.asyncio
|
|
72
|
+
async def test_avatar_upload(storage):
|
|
73
|
+
raw = fake_image_bytes(100, 100)
|
|
74
|
+
jpeg_bytes, _ = normalize_square_avatar(raw, size=256)
|
|
75
|
+
|
|
76
|
+
await storage.put("avatars/test.jpg", jpeg_bytes, "image/jpeg")
|
|
77
|
+
data, ct = await storage.get("avatars/test.jpg")
|
|
78
|
+
|
|
79
|
+
assert ct == "image/jpeg"
|
|
80
|
+
assert len(data) > 0
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Presigned URL (без auth)
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from storagekit import PresignedUrlGenerator
|
|
87
|
+
|
|
88
|
+
gen = PresignedUrlGenerator(
|
|
89
|
+
endpoint="minio:9000",
|
|
90
|
+
access_key="minioadmin",
|
|
91
|
+
secret_key="minioadmin",
|
|
92
|
+
bucket="my-bucket",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Генерировать URL с TTL (по умолчанию 1 час)
|
|
96
|
+
url = await gen.generate_presigned_get_url("avatars/user_1/photo.jpg")
|
|
97
|
+
# Клиент может скачать файл по этому URL без токена
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## API
|
|
101
|
+
|
|
102
|
+
### Протоколы
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
class ObjectStorage(Protocol):
|
|
106
|
+
"""Async key→bytes хранилище."""
|
|
107
|
+
|
|
108
|
+
async def put(self, key: str, data: bytes, content_type: str) -> None:
|
|
109
|
+
"""Записать/перезаписать объект."""
|
|
110
|
+
...
|
|
111
|
+
|
|
112
|
+
async def get(self, key: str) -> tuple[bytes, str]:
|
|
113
|
+
"""Вернуть (data, content_type). Бросает ObjectNotFoundError."""
|
|
114
|
+
...
|
|
115
|
+
|
|
116
|
+
async def delete(self, key: str) -> None:
|
|
117
|
+
"""Удалить объект. Отсутствие не ошибка (idempotent)."""
|
|
118
|
+
...
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Исключения
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
class ObjectNotFoundError(Exception):
|
|
125
|
+
"""Объект не найден. Имеет атрибут .key."""
|
|
126
|
+
def __init__(self, key: str) -> None: ...
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Реализации
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
# S3-compatible хранилище (prod/dev)
|
|
133
|
+
class MinioObjectStorage:
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
*,
|
|
137
|
+
endpoint: str, # host:port БЕЗ схемы
|
|
138
|
+
access_key: str,
|
|
139
|
+
secret_key: str,
|
|
140
|
+
bucket: str,
|
|
141
|
+
secure: bool = False, # http (dev) vs https (prod)
|
|
142
|
+
) -> None: ...
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Image-утилиты
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
# Список допустимых MIME-типов входных файлов
|
|
149
|
+
ALLOWED_IMAGE_CONTENT_TYPES: frozenset[str]
|
|
150
|
+
|
|
151
|
+
# Квадратный аватар (center cover-crop) → JPEG
|
|
152
|
+
def normalize_square_avatar(data: bytes, *, size: int) -> tuple[bytes, str]: ...
|
|
153
|
+
|
|
154
|
+
# Несколько вариантов размеров одного аватара
|
|
155
|
+
def square_avatar_variants(
|
|
156
|
+
data: bytes, *, sizes: list[int]
|
|
157
|
+
) -> list[tuple[int, bytes]]: ...
|
|
158
|
+
|
|
159
|
+
# Масштабирование по длинной стороне (сохраняет пропорции)
|
|
160
|
+
def normalize_image(data: bytes, *, max_px: int) -> tuple[bytes, str]: ...
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Утилиты
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
# Канонический ключ snapshot'а версии
|
|
167
|
+
def snapshot_key(*, skill_id: int | str, semver: str) -> str:
|
|
168
|
+
"""Возвращает f'skills/{skill_id}/versions/{semver}.tar.gz'."""
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Тестирование
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
# Фейк-хранилище в памяти
|
|
175
|
+
class InMemoryObjectStorage:
|
|
176
|
+
async def put(self, key: str, data: bytes, content_type: str) -> None: ...
|
|
177
|
+
async def get(self, key: str) -> tuple[bytes, str]: ...
|
|
178
|
+
async def delete(self, key: str) -> None: ...
|
|
179
|
+
|
|
180
|
+
# Генерировать тестовый PNG-файл
|
|
181
|
+
def fake_image_bytes(width: int = 100, height: int = 100) -> bytes: ...
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Параметризация
|
|
185
|
+
|
|
186
|
+
| Параметр | Значение | Примечание |
|
|
187
|
+
|----------|----------|-----------|
|
|
188
|
+
| MinIO endpoint | `host:port` (БЕЗ схемы) | dev: `minio:9000`, prod: `minio.example.com:9000` |
|
|
189
|
+
| MinIO secure | `False` (dev), `True` (prod) | http vs https |
|
|
190
|
+
| Avatar sizes | `[64, 128, 256]` (default) | Можно переопределить в клиенте |
|
|
191
|
+
| Skill cover max px | `1200` (constant) | Не параметризуется |
|
|
192
|
+
| JPEG quality | `85` (constant) | Не параметризуется |
|
|
193
|
+
|
|
194
|
+
## Зависимости
|
|
195
|
+
|
|
196
|
+
- **minio>=7.0** — S3 SDK
|
|
197
|
+
- **anyio>=3.0** — async threading
|
|
198
|
+
- **Pillow>=10.0** — image processing
|
|
199
|
+
|
|
200
|
+
## Лицензия
|
|
201
|
+
|
|
202
|
+
MIT
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "s-storagekit"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Объектное хранилище (ObjectStorage protocol + MinIO adapter + image normalization for Skillery)"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
authors = [{ name = "Dmitry" }]
|
|
9
|
+
# Зависимости: async-обёртка sync-SDK, image processing, S3-compatible storage.
|
|
10
|
+
# 0 завязок на FastAPI/pydantic/SQLAlchemy — чистая логика.
|
|
11
|
+
dependencies = [
|
|
12
|
+
"minio>=7.0",
|
|
13
|
+
"anyio>=3.0",
|
|
14
|
+
"Pillow>=10.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.24"]
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["hatchling"]
|
|
22
|
+
build-backend = "hatchling.build"
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["storagekit"]
|
|
26
|
+
|
|
27
|
+
[tool.ruff]
|
|
28
|
+
line-length = 100
|
|
29
|
+
target-version = "py311"
|
|
30
|
+
src = ["."]
|
|
31
|
+
|
|
32
|
+
[tool.ruff.lint]
|
|
33
|
+
select = ["E", "F", "I", "B", "UP", "N", "SIM", "RUF"]
|
|
34
|
+
# RUF001/RUF002/RUF003 — кириллица в строках/докстрингах/комментах намеренная
|
|
35
|
+
# (русскоязычный код), ruff принимает её за «двусмысленную» латиницу.
|
|
36
|
+
ignore = ["RUF001", "RUF002", "RUF003"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
asyncio_mode = "auto"
|
|
40
|
+
testpaths = ["tests"]
|
|
41
|
+
pythonpath = ["."]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""storagekit — объектное хранилище для Skillery.
|
|
2
|
+
|
|
3
|
+
Портабельное async-API для key→bytes операций с реализациями минарального
|
|
4
|
+
протокола (MinIO) и вспомогательными утилитами нормализации изображений
|
|
5
|
+
(Pillow). 0 завязок на FastAPI/pydantic — чистая логика.
|
|
6
|
+
|
|
7
|
+
Основные компоненты:
|
|
8
|
+
|
|
9
|
+
- **Протоколы:** `ObjectStorage`, `ISnapshotStorage` — минимальные async-интерфейсы.
|
|
10
|
+
- **Реализация:** `MinioObjectStorage` — S3-compatible хранилище (prod/dev).
|
|
11
|
+
- **Image-утилиты:** `normalize_image`, `square_avatar_variants`, `normalize_square_avatar`.
|
|
12
|
+
- **Presigning:** `PresignedUrlGenerator`, `snapshot_key`.
|
|
13
|
+
- **Тесты:** `InMemoryObjectStorage`, `fake_image_bytes`.
|
|
14
|
+
|
|
15
|
+
Примеры использования:
|
|
16
|
+
|
|
17
|
+
# Инициализация
|
|
18
|
+
from storagekit import MinioObjectStorage
|
|
19
|
+
storage = MinioObjectStorage(
|
|
20
|
+
endpoint="minio:9000",
|
|
21
|
+
access_key="...",
|
|
22
|
+
secret_key="...",
|
|
23
|
+
bucket="my-bucket",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Загрузка картинки
|
|
27
|
+
from storagekit import square_avatar_variants
|
|
28
|
+
variants = square_avatar_variants(raw_bytes, sizes=[64, 128, 256])
|
|
29
|
+
for size, blob in variants:
|
|
30
|
+
key = f"avatars/{uid}/{token}_{size}.jpg"
|
|
31
|
+
await storage.put(key, blob, "image/jpeg")
|
|
32
|
+
|
|
33
|
+
# Тестирование
|
|
34
|
+
from storagekit.testing import InMemoryObjectStorage, fake_image_bytes
|
|
35
|
+
storage = InMemoryObjectStorage()
|
|
36
|
+
raw = fake_image_bytes(100, 100)
|
|
37
|
+
variants = square_avatar_variants(raw, sizes=[64, 128])
|
|
38
|
+
"""
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
from storagekit.base import ISnapshotStorage, ObjectNotFoundError, ObjectStorage
|
|
42
|
+
from storagekit.images import (
|
|
43
|
+
ALLOWED_IMAGE_CONTENT_TYPES,
|
|
44
|
+
ValidationError,
|
|
45
|
+
normalize_image,
|
|
46
|
+
normalize_square_avatar,
|
|
47
|
+
square_avatar_variants,
|
|
48
|
+
)
|
|
49
|
+
from storagekit.minio_storage import MinioObjectStorage
|
|
50
|
+
from storagekit.presign import PresignedUrlGenerator, snapshot_key
|
|
51
|
+
from storagekit.testing import InMemoryObjectStorage, fake_image_bytes
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
# Протоколы и исключения
|
|
55
|
+
"ObjectStorage",
|
|
56
|
+
"ISnapshotStorage",
|
|
57
|
+
"ObjectNotFoundError",
|
|
58
|
+
# Реализация хранилища
|
|
59
|
+
"MinioObjectStorage",
|
|
60
|
+
# Image-обработка
|
|
61
|
+
"ValidationError",
|
|
62
|
+
"ALLOWED_IMAGE_CONTENT_TYPES",
|
|
63
|
+
"normalize_image",
|
|
64
|
+
"normalize_square_avatar",
|
|
65
|
+
"square_avatar_variants",
|
|
66
|
+
# Presigning
|
|
67
|
+
"PresignedUrlGenerator",
|
|
68
|
+
"snapshot_key",
|
|
69
|
+
# Тестовые утилиты
|
|
70
|
+
"InMemoryObjectStorage",
|
|
71
|
+
"fake_image_bytes",
|
|
72
|
+
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Портокол объектного хранилища.
|
|
2
|
+
|
|
3
|
+
`ObjectStorage` — минимальный async-протокол put/get/delete над key→bytes.
|
|
4
|
+
Реализации: `MinioObjectStorage` (prod/dev, S3-совместимое MinIO) и
|
|
5
|
+
`InMemoryObjectStorage` (тесты). Роуты зависят от протокола, не от MinIO.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Protocol, runtime_checkable
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ObjectNotFoundError(Exception):
|
|
13
|
+
"""Объект с указанным key отсутствует в хранилище."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, key: str) -> None:
|
|
16
|
+
super().__init__(f"object not found: {key}")
|
|
17
|
+
self.key = key
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@runtime_checkable
|
|
21
|
+
class ObjectStorage(Protocol):
|
|
22
|
+
"""Async key→bytes хранилище. Key — это «путь» объекта (напр.
|
|
23
|
+
``avatars/42/ab12.jpg``); семантику ключей задаёт вызывающий код."""
|
|
24
|
+
|
|
25
|
+
async def put(self, key: str, data: bytes, content_type: str) -> None:
|
|
26
|
+
"""Записать (или перезаписать) объект."""
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
async def get(self, key: str) -> tuple[bytes, str]:
|
|
30
|
+
"""Вернуть ``(data, content_type)``. Бросает ``ObjectNotFoundError``."""
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
async def delete(self, key: str) -> None:
|
|
34
|
+
"""Удалить объект. Отсутствие — не ошибка (idempotent)."""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ISnapshotStorage(Protocol):
|
|
39
|
+
"""Только запись (узкий подмножество ObjectStorage для snapshot-версий)."""
|
|
40
|
+
|
|
41
|
+
async def put(self, key: str, data: bytes, content_type: str) -> None:
|
|
42
|
+
"""Записать snapshot."""
|
|
43
|
+
...
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Image-обработка загрузок: нормализация аватара через Pillow.
|
|
2
|
+
|
|
3
|
+
Чистые функции bytes→bytes — тривиально юнит-тестируются, не требуют MinIO.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import io
|
|
8
|
+
|
|
9
|
+
from PIL import Image, UnidentifiedImageError
|
|
10
|
+
|
|
11
|
+
# Pillow 10+ переименовал фильтры в Image.Resampling.*; для совместимости с
|
|
12
|
+
# более ранними версиями используем getattr-fallback.
|
|
13
|
+
_LANCZOS = getattr(Image, "Resampling", Image).LANCZOS
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ValidationError(Exception):
|
|
17
|
+
"""Ошибка валидации изображения."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, message: str, *, details: dict | None = None) -> None:
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.message = message
|
|
22
|
+
self.details = details or {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Допустимые content-type для загружаемых картинок (аватары/скриншоты).
|
|
26
|
+
ALLOWED_IMAGE_CONTENT_TYPES: frozenset[str] = frozenset(
|
|
27
|
+
{"image/jpeg", "image/png", "image/webp", "image/gif"}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def square_avatar_variants(
|
|
32
|
+
data: bytes, *, sizes: list[int]
|
|
33
|
+
) -> list[tuple[int, bytes]]:
|
|
34
|
+
"""Генерировать несколько JPEG-вариантов аватара разных размеров.
|
|
35
|
+
|
|
36
|
+
Алгоритм:
|
|
37
|
+
1. Открыть изображение через Pillow, конвертировать в RGB.
|
|
38
|
+
2. Center cover-crop до квадрата (min(w,h) × min(w,h)).
|
|
39
|
+
3. Ограничить мастер-сторону до max(sizes) — уменьшение без апскейла.
|
|
40
|
+
4. Для каждого размера из ``sizes``: масштабировать (shrink-only) →
|
|
41
|
+
JPEG quality 85.
|
|
42
|
+
|
|
43
|
+
Возвращает [(size, jpeg_bytes), ...] в порядке переданного ``sizes``.
|
|
44
|
+
Бросает ``ValidationError`` если ``data`` — не корректное изображение.
|
|
45
|
+
"""
|
|
46
|
+
if not sizes:
|
|
47
|
+
return []
|
|
48
|
+
master_side = max(sizes)
|
|
49
|
+
try:
|
|
50
|
+
with Image.open(io.BytesIO(data)) as img:
|
|
51
|
+
rgb = img.convert("RGB")
|
|
52
|
+
width, height = rgb.size
|
|
53
|
+
# --- center cover-crop до квадрата ---
|
|
54
|
+
side = min(width, height)
|
|
55
|
+
left = (width - side) // 2
|
|
56
|
+
top = (height - side) // 2
|
|
57
|
+
cropped = rgb.crop((left, top, left + side, top + side))
|
|
58
|
+
# --- ограничить мастер до max(sizes) (shrink-only) ---
|
|
59
|
+
if side > master_side:
|
|
60
|
+
cropped = cropped.resize((master_side, master_side), _LANCZOS)
|
|
61
|
+
side = master_side
|
|
62
|
+
# --- генерировать каждый вариант ---
|
|
63
|
+
result: list[tuple[int, bytes]] = []
|
|
64
|
+
for sz in sizes:
|
|
65
|
+
# shrink-only: если мастер уже равен sz — не апскейлируем
|
|
66
|
+
resized = cropped.resize((sz, sz), _LANCZOS) if side != sz else cropped
|
|
67
|
+
out = io.BytesIO()
|
|
68
|
+
resized.save(out, format="JPEG", quality=85, optimize=True)
|
|
69
|
+
result.append((sz, out.getvalue()))
|
|
70
|
+
return result
|
|
71
|
+
except (UnidentifiedImageError, OSError) as exc:
|
|
72
|
+
raise ValidationError(
|
|
73
|
+
"Файл не является корректным изображением",
|
|
74
|
+
details={"reason": str(exc)},
|
|
75
|
+
) from exc
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def normalize_square_avatar(data: bytes, *, size: int) -> tuple[bytes, str]:
|
|
79
|
+
"""Привести картинку к квадрату ``size×size`` (center cover-crop) + JPEG.
|
|
80
|
+
|
|
81
|
+
Возвращает ``(jpeg_bytes, "image/jpeg")``. Это и есть «уменьшенная версия»
|
|
82
|
+
для аватара. Бросает ``ValidationError`` если bytes — не валидное
|
|
83
|
+
изображение (битый/не-image файл).
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
with Image.open(io.BytesIO(data)) as img:
|
|
87
|
+
rgb = img.convert("RGB")
|
|
88
|
+
width, height = rgb.size
|
|
89
|
+
side = min(width, height)
|
|
90
|
+
left = (width - side) // 2
|
|
91
|
+
top = (height - side) // 2
|
|
92
|
+
cropped = rgb.crop((left, top, left + side, top + side))
|
|
93
|
+
if side != size:
|
|
94
|
+
cropped = cropped.resize((size, size), _LANCZOS)
|
|
95
|
+
out = io.BytesIO()
|
|
96
|
+
cropped.save(out, format="JPEG", quality=85, optimize=True)
|
|
97
|
+
return out.getvalue(), "image/jpeg"
|
|
98
|
+
except (UnidentifiedImageError, OSError) as exc:
|
|
99
|
+
raise ValidationError(
|
|
100
|
+
"Файл не является корректным изображением",
|
|
101
|
+
details={"reason": str(exc)},
|
|
102
|
+
) from exc
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def normalize_image(data: bytes, *, max_px: int) -> tuple[bytes, str]:
|
|
106
|
+
"""Привести картинку к JPEG, ужав до ``max_px`` по ДЛИННОЙ стороне с
|
|
107
|
+
сохранением пропорций (обложки skill'ов).
|
|
108
|
+
|
|
109
|
+
В отличие от ``normalize_square_avatar`` (квадрат + crop), здесь aspect
|
|
110
|
+
ratio сохраняется, а уменьшение происходит ТОЛЬКО если длинная сторона
|
|
111
|
+
больше ``max_px`` (апскейл не делаем — мелкая картинка остаётся как есть).
|
|
112
|
+
Возвращает ``(jpeg_bytes, "image/jpeg")``. Бросает ``ValidationError`` если
|
|
113
|
+
bytes — не валидное изображение.
|
|
114
|
+
"""
|
|
115
|
+
try:
|
|
116
|
+
with Image.open(io.BytesIO(data)) as img:
|
|
117
|
+
rgb = img.convert("RGB")
|
|
118
|
+
width, height = rgb.size
|
|
119
|
+
longest = max(width, height)
|
|
120
|
+
if longest > max_px:
|
|
121
|
+
scale = max_px / longest
|
|
122
|
+
new_size = (max(1, round(width * scale)), max(1, round(height * scale)))
|
|
123
|
+
rgb = rgb.resize(new_size, _LANCZOS)
|
|
124
|
+
out = io.BytesIO()
|
|
125
|
+
rgb.save(out, format="JPEG", quality=85, optimize=True)
|
|
126
|
+
return out.getvalue(), "image/jpeg"
|
|
127
|
+
except (UnidentifiedImageError, OSError) as exc:
|
|
128
|
+
raise ValidationError(
|
|
129
|
+
"Файл не является корректным изображением",
|
|
130
|
+
details={"reason": str(exc)},
|
|
131
|
+
) from exc
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""MinioObjectStorage — S3-совместимое объектное хранилище на MinIO.
|
|
2
|
+
|
|
3
|
+
SDK `minio` синхронный, поэтому каждый I/O-вызов уводим в threadpool через
|
|
4
|
+
`anyio.to_thread.run_sync`, чтобы не блокировать event loop. Bucket создаётся
|
|
5
|
+
лениво при первой записи (ensure-on-write) — не требует, чтобы MinIO был готов
|
|
6
|
+
на момент старта.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import io
|
|
11
|
+
|
|
12
|
+
import anyio
|
|
13
|
+
from minio import Minio
|
|
14
|
+
from minio.error import S3Error
|
|
15
|
+
|
|
16
|
+
from storagekit.base import ObjectNotFoundError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MinioObjectStorage:
|
|
20
|
+
"""Реализация `ObjectStorage` поверх MinIO.
|
|
21
|
+
|
|
22
|
+
`endpoint` — ``host:port`` БЕЗ схемы (схему задаёт `secure`). В dev
|
|
23
|
+
docker-compose это ``minio:9000`` (internal), `secure=False`.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
*,
|
|
29
|
+
endpoint: str,
|
|
30
|
+
access_key: str,
|
|
31
|
+
secret_key: str,
|
|
32
|
+
bucket: str,
|
|
33
|
+
secure: bool = False,
|
|
34
|
+
) -> None:
|
|
35
|
+
self._client = Minio(
|
|
36
|
+
endpoint,
|
|
37
|
+
access_key=access_key,
|
|
38
|
+
secret_key=secret_key,
|
|
39
|
+
secure=secure,
|
|
40
|
+
)
|
|
41
|
+
self._bucket = bucket
|
|
42
|
+
self._bucket_ready = False
|
|
43
|
+
|
|
44
|
+
# ── sync helpers (выполняются в threadpool) ──
|
|
45
|
+
def _ensure_bucket(self) -> None:
|
|
46
|
+
if self._bucket_ready:
|
|
47
|
+
return
|
|
48
|
+
if not self._client.bucket_exists(self._bucket):
|
|
49
|
+
self._client.make_bucket(self._bucket)
|
|
50
|
+
self._bucket_ready = True
|
|
51
|
+
|
|
52
|
+
def _put_sync(self, key: str, data: bytes, content_type: str) -> None:
|
|
53
|
+
self._ensure_bucket()
|
|
54
|
+
self._client.put_object(
|
|
55
|
+
self._bucket,
|
|
56
|
+
key,
|
|
57
|
+
io.BytesIO(data),
|
|
58
|
+
length=len(data),
|
|
59
|
+
content_type=content_type,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def _get_sync(self, key: str) -> tuple[bytes, str]:
|
|
63
|
+
try:
|
|
64
|
+
resp = self._client.get_object(self._bucket, key)
|
|
65
|
+
except S3Error as exc:
|
|
66
|
+
if exc.code in {"NoSuchKey", "NoSuchBucket"}:
|
|
67
|
+
raise ObjectNotFoundError(key) from exc
|
|
68
|
+
raise
|
|
69
|
+
try:
|
|
70
|
+
data = resp.read()
|
|
71
|
+
content_type = resp.headers.get(
|
|
72
|
+
"Content-Type", "application/octet-stream"
|
|
73
|
+
)
|
|
74
|
+
return data, content_type
|
|
75
|
+
finally:
|
|
76
|
+
resp.close()
|
|
77
|
+
resp.release_conn()
|
|
78
|
+
|
|
79
|
+
def _delete_sync(self, key: str) -> None:
|
|
80
|
+
self._ensure_bucket()
|
|
81
|
+
self._client.remove_object(self._bucket, key)
|
|
82
|
+
|
|
83
|
+
# ── async API (ObjectStorage protocol) ──
|
|
84
|
+
async def put(self, key: str, data: bytes, content_type: str) -> None:
|
|
85
|
+
"""Записать объект в MinIO через threadpool."""
|
|
86
|
+
await anyio.to_thread.run_sync(self._put_sync, key, data, content_type)
|
|
87
|
+
|
|
88
|
+
async def get(self, key: str) -> tuple[bytes, str]:
|
|
89
|
+
"""Получить объект из MinIO через threadpool."""
|
|
90
|
+
return await anyio.to_thread.run_sync(self._get_sync, key)
|
|
91
|
+
|
|
92
|
+
async def delete(self, key: str) -> None:
|
|
93
|
+
"""Удалить объект из MinIO через threadpool."""
|
|
94
|
+
await anyio.to_thread.run_sync(self._delete_sync, key)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Presigned URL генерация для MinIO.
|
|
2
|
+
|
|
3
|
+
Позволяет клиентам получать прямые ссылки на объекты с TTL без auth.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import io
|
|
8
|
+
from datetime import timedelta
|
|
9
|
+
|
|
10
|
+
import anyio
|
|
11
|
+
from minio import Minio
|
|
12
|
+
from minio.error import S3Error
|
|
13
|
+
|
|
14
|
+
from storagekit.base import ObjectNotFoundError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def snapshot_key(*, skill_id: int | str, semver: str) -> str:
|
|
18
|
+
"""Канонический ключ snapshot'а версии в object_storage.
|
|
19
|
+
|
|
20
|
+
Возвращает f'skills/{skill_id}/versions/{semver}.tar.gz'.
|
|
21
|
+
|
|
22
|
+
Примеры:
|
|
23
|
+
key = snapshot_key(skill_id=42, semver='1.0.0')
|
|
24
|
+
# → 'skills/42/versions/1.0.0.tar.gz'
|
|
25
|
+
"""
|
|
26
|
+
return f"skills/{skill_id}/versions/{semver}.tar.gz"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PresignedUrlGenerator:
|
|
30
|
+
"""Генератор presigned URL для MinIO объектов."""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
endpoint: str,
|
|
36
|
+
access_key: str,
|
|
37
|
+
secret_key: str,
|
|
38
|
+
bucket: str,
|
|
39
|
+
secure: bool = False,
|
|
40
|
+
) -> None:
|
|
41
|
+
self._client = Minio(
|
|
42
|
+
endpoint,
|
|
43
|
+
access_key=access_key,
|
|
44
|
+
secret_key=secret_key,
|
|
45
|
+
secure=secure,
|
|
46
|
+
)
|
|
47
|
+
self._bucket = bucket
|
|
48
|
+
|
|
49
|
+
def _generate_presigned_get_url_sync(
|
|
50
|
+
self, key: str, ttl_seconds: int = 3600
|
|
51
|
+
) -> str:
|
|
52
|
+
"""Сгенерировать presigned GET-URL для объекта (sync версия)."""
|
|
53
|
+
ttl = timedelta(seconds=ttl_seconds)
|
|
54
|
+
return self._client.get_presigned_object_url("GET", self._bucket, key, ttl)
|
|
55
|
+
|
|
56
|
+
async def generate_presigned_get_url(
|
|
57
|
+
self, key: str, ttl_seconds: int = 3600
|
|
58
|
+
) -> str:
|
|
59
|
+
"""Сгенерировать presigned GET-URL для объекта (async версия)."""
|
|
60
|
+
return await anyio.to_thread.run_sync(
|
|
61
|
+
self._generate_presigned_get_url_sync, key, ttl_seconds
|
|
62
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Тестовые mock-реализации ObjectStorage протокола и утилиты генерации данных."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import io
|
|
5
|
+
|
|
6
|
+
from PIL import Image
|
|
7
|
+
|
|
8
|
+
from storagekit.base import ObjectNotFoundError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InMemoryObjectStorage:
|
|
12
|
+
"""Фейк-хранилище в памяти для unit-тестов.
|
|
13
|
+
|
|
14
|
+
Имитирует ObjectStorage protocol через dict в памяти.
|
|
15
|
+
Синхронное, но async-методы для совместимости с протоколом.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self._storage: dict[str, tuple[bytes, str]] = {}
|
|
20
|
+
|
|
21
|
+
async def put(self, key: str, data: bytes, content_type: str) -> None:
|
|
22
|
+
"""Записать объект в память."""
|
|
23
|
+
self._storage[key] = (data, content_type)
|
|
24
|
+
|
|
25
|
+
async def get(self, key: str) -> tuple[bytes, str]:
|
|
26
|
+
"""Получить объект из памяти."""
|
|
27
|
+
if key not in self._storage:
|
|
28
|
+
raise ObjectNotFoundError(key)
|
|
29
|
+
return self._storage[key]
|
|
30
|
+
|
|
31
|
+
async def delete(self, key: str) -> None:
|
|
32
|
+
"""Удалить объект из памяти (idempotent)."""
|
|
33
|
+
self._storage.pop(key, None)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def fake_image_bytes(width: int = 100, height: int = 100) -> bytes:
|
|
37
|
+
"""Сгенерировать валидное PNG-изображение для тестов.
|
|
38
|
+
|
|
39
|
+
Возвращает bytes PNG-изображения указанных размеров (сплошной серый).
|
|
40
|
+
"""
|
|
41
|
+
img = Image.new("RGB", (width, height), color=(128, 128, 128))
|
|
42
|
+
out = io.BytesIO()
|
|
43
|
+
img.save(out, format="PNG")
|
|
44
|
+
return out.getvalue()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Тесты модуля storagekit."""
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Тесты базового протокола ObjectStorage."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from storagekit import InMemoryObjectStorage, ObjectNotFoundError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.mark.asyncio
|
|
10
|
+
async def test_in_memory_put_get_delete() -> None:
|
|
11
|
+
"""InMemoryObjectStorage: put → get → delete."""
|
|
12
|
+
storage = InMemoryObjectStorage()
|
|
13
|
+
|
|
14
|
+
# Запись
|
|
15
|
+
await storage.put("test-key", b"hello world", "text/plain")
|
|
16
|
+
|
|
17
|
+
# Чтение
|
|
18
|
+
data, ct = await storage.get("test-key")
|
|
19
|
+
assert data == b"hello world"
|
|
20
|
+
assert ct == "text/plain"
|
|
21
|
+
|
|
22
|
+
# Удаление
|
|
23
|
+
await storage.delete("test-key")
|
|
24
|
+
|
|
25
|
+
# Чтение после удаления должно выбросить ошибку
|
|
26
|
+
with pytest.raises(ObjectNotFoundError) as exc_info:
|
|
27
|
+
await storage.get("test-key")
|
|
28
|
+
assert exc_info.value.key == "test-key"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.mark.asyncio
|
|
32
|
+
async def test_in_memory_object_not_found() -> None:
|
|
33
|
+
"""get(unknown_key) → ObjectNotFoundError с .key атрибутом."""
|
|
34
|
+
storage = InMemoryObjectStorage()
|
|
35
|
+
|
|
36
|
+
with pytest.raises(ObjectNotFoundError) as exc_info:
|
|
37
|
+
await storage.get("nonexistent")
|
|
38
|
+
|
|
39
|
+
assert exc_info.value.key == "nonexistent"
|
|
40
|
+
assert "nonexistent" in str(exc_info.value)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.mark.asyncio
|
|
44
|
+
async def test_in_memory_delete_idempotent() -> None:
|
|
45
|
+
"""delete() on nonexistent key — не ошибка."""
|
|
46
|
+
storage = InMemoryObjectStorage()
|
|
47
|
+
|
|
48
|
+
# delete несуществующего ключа не должен выбросить ошибку
|
|
49
|
+
await storage.delete("not-created")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.mark.asyncio
|
|
53
|
+
async def test_in_memory_overwrite() -> None:
|
|
54
|
+
"""put() перезаписывает существующий ключ."""
|
|
55
|
+
storage = InMemoryObjectStorage()
|
|
56
|
+
|
|
57
|
+
await storage.put("key", b"first", "text/plain")
|
|
58
|
+
await storage.put("key", b"second", "text/plain")
|
|
59
|
+
|
|
60
|
+
data, _ = await storage.get("key")
|
|
61
|
+
assert data == b"second"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@pytest.mark.asyncio
|
|
65
|
+
async def test_in_memory_different_content_types() -> None:
|
|
66
|
+
"""Разные content-type для разных ключей."""
|
|
67
|
+
storage = InMemoryObjectStorage()
|
|
68
|
+
|
|
69
|
+
await storage.put("image.jpg", b"jpeg", "image/jpeg")
|
|
70
|
+
await storage.put("image.png", b"png", "image/png")
|
|
71
|
+
|
|
72
|
+
_, ct1 = await storage.get("image.jpg")
|
|
73
|
+
_, ct2 = await storage.get("image.png")
|
|
74
|
+
|
|
75
|
+
assert ct1 == "image/jpeg"
|
|
76
|
+
assert ct2 == "image/png"
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Тесты обработки изображений."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import io
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from PIL import Image
|
|
8
|
+
|
|
9
|
+
from storagekit import (
|
|
10
|
+
ValidationError,
|
|
11
|
+
fake_image_bytes,
|
|
12
|
+
normalize_image,
|
|
13
|
+
normalize_square_avatar,
|
|
14
|
+
square_avatar_variants,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_normalize_square_avatar() -> None:
|
|
19
|
+
"""normalize_square_avatar: PNG → JPEG квадрат."""
|
|
20
|
+
raw = fake_image_bytes(width=200, height=150)
|
|
21
|
+
jpeg_bytes, ct = normalize_square_avatar(raw, size=256)
|
|
22
|
+
|
|
23
|
+
assert ct == "image/jpeg"
|
|
24
|
+
assert len(jpeg_bytes) > 0
|
|
25
|
+
|
|
26
|
+
# Проверить, что результат это корректный JPEG
|
|
27
|
+
img = Image.open(io.BytesIO(jpeg_bytes))
|
|
28
|
+
assert img.size == (256, 256)
|
|
29
|
+
assert img.format == "JPEG"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_normalize_square_avatar_already_square() -> None:
|
|
33
|
+
"""normalize_square_avatar: квадратное изображение."""
|
|
34
|
+
raw = fake_image_bytes(width=100, height=100)
|
|
35
|
+
jpeg_bytes, ct = normalize_square_avatar(raw, size=100)
|
|
36
|
+
|
|
37
|
+
assert ct == "image/jpeg"
|
|
38
|
+
img = Image.open(io.BytesIO(jpeg_bytes))
|
|
39
|
+
assert img.size == (100, 100)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_normalize_square_avatar_landscape() -> None:
|
|
43
|
+
"""normalize_square_avatar: ландшафтное изображение → crop."""
|
|
44
|
+
raw = fake_image_bytes(width=400, height=200)
|
|
45
|
+
jpeg_bytes, ct = normalize_square_avatar(raw, size=256)
|
|
46
|
+
|
|
47
|
+
img = Image.open(io.BytesIO(jpeg_bytes))
|
|
48
|
+
assert img.size == (256, 256) # квадрат
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_normalize_square_avatar_portrait() -> None:
|
|
52
|
+
"""normalize_square_avatar: портретное изображение → crop."""
|
|
53
|
+
raw = fake_image_bytes(width=200, height=400)
|
|
54
|
+
jpeg_bytes, ct = normalize_square_avatar(raw, size=256)
|
|
55
|
+
|
|
56
|
+
img = Image.open(io.BytesIO(jpeg_bytes))
|
|
57
|
+
assert img.size == (256, 256)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_normalize_square_avatar_invalid() -> None:
|
|
61
|
+
"""normalize_square_avatar(garbage) → ValidationError."""
|
|
62
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
63
|
+
normalize_square_avatar(b"not an image", size=256)
|
|
64
|
+
|
|
65
|
+
assert "корректным изображением" in exc_info.value.message
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_square_avatar_variants() -> None:
|
|
69
|
+
"""square_avatar_variants: несколько размеров."""
|
|
70
|
+
raw = fake_image_bytes(width=200, height=150)
|
|
71
|
+
variants = square_avatar_variants(raw, sizes=[64, 128, 256])
|
|
72
|
+
|
|
73
|
+
assert len(variants) == 3
|
|
74
|
+
assert variants[0][0] == 64
|
|
75
|
+
assert variants[1][0] == 128
|
|
76
|
+
assert variants[2][0] == 256
|
|
77
|
+
|
|
78
|
+
# Проверить каждый вариант
|
|
79
|
+
for size, jpeg_bytes in variants:
|
|
80
|
+
assert len(jpeg_bytes) > 0
|
|
81
|
+
img = Image.open(io.BytesIO(jpeg_bytes))
|
|
82
|
+
assert img.size == (size, size)
|
|
83
|
+
assert img.format == "JPEG"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_square_avatar_variants_empty_sizes() -> None:
|
|
87
|
+
"""square_avatar_variants: пустой список размеров."""
|
|
88
|
+
raw = fake_image_bytes(width=100, height=100)
|
|
89
|
+
variants = square_avatar_variants(raw, sizes=[])
|
|
90
|
+
|
|
91
|
+
assert variants == []
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_square_avatar_variants_single_size() -> None:
|
|
95
|
+
"""square_avatar_variants: один размер."""
|
|
96
|
+
raw = fake_image_bytes(width=100, height=100)
|
|
97
|
+
variants = square_avatar_variants(raw, sizes=[128])
|
|
98
|
+
|
|
99
|
+
assert len(variants) == 1
|
|
100
|
+
assert variants[0][0] == 128
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_square_avatar_variants_invalid() -> None:
|
|
104
|
+
"""square_avatar_variants(garbage) → ValidationError."""
|
|
105
|
+
with pytest.raises(ValidationError):
|
|
106
|
+
square_avatar_variants(b"not an image", sizes=[64, 128])
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_normalize_image_landscape() -> None:
|
|
110
|
+
"""normalize_image: ландшафтное изображение уменьшается по длинной стороне."""
|
|
111
|
+
raw = fake_image_bytes(width=2000, height=1000)
|
|
112
|
+
jpeg_bytes, ct = normalize_image(raw, max_px=1200)
|
|
113
|
+
|
|
114
|
+
assert ct == "image/jpeg"
|
|
115
|
+
img = Image.open(io.BytesIO(jpeg_bytes))
|
|
116
|
+
# ширина (длинная) должна быть уменьшена до 1200
|
|
117
|
+
assert img.width == 1200
|
|
118
|
+
# высота должна быть пропорционально уменьшена
|
|
119
|
+
assert img.height == 600
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_normalize_image_portrait() -> None:
|
|
123
|
+
"""normalize_image: портретное изображение."""
|
|
124
|
+
raw = fake_image_bytes(width=1000, height=2000)
|
|
125
|
+
jpeg_bytes, ct = normalize_image(raw, max_px=1200)
|
|
126
|
+
|
|
127
|
+
img = Image.open(io.BytesIO(jpeg_bytes))
|
|
128
|
+
# высота (длинная) должна быть 1200
|
|
129
|
+
assert img.height == 1200
|
|
130
|
+
# ширина должна быть пропорциональна
|
|
131
|
+
assert img.width == 600
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_normalize_image_no_downscale() -> None:
|
|
135
|
+
"""normalize_image: маленькое изображение не апскейлируется."""
|
|
136
|
+
raw = fake_image_bytes(width=200, height=100)
|
|
137
|
+
jpeg_bytes, ct = normalize_image(raw, max_px=1200)
|
|
138
|
+
|
|
139
|
+
img = Image.open(io.BytesIO(jpeg_bytes))
|
|
140
|
+
# Размер не должен измениться (нет апскейла)
|
|
141
|
+
assert img.width == 200
|
|
142
|
+
assert img.height == 100
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_normalize_image_square() -> None:
|
|
146
|
+
"""normalize_image: квадратное изображение."""
|
|
147
|
+
raw = fake_image_bytes(width=1000, height=1000)
|
|
148
|
+
jpeg_bytes, ct = normalize_image(raw, max_px=500)
|
|
149
|
+
|
|
150
|
+
img = Image.open(io.BytesIO(jpeg_bytes))
|
|
151
|
+
assert img.width == 500
|
|
152
|
+
assert img.height == 500
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_normalize_image_invalid() -> None:
|
|
156
|
+
"""normalize_image(garbage) → ValidationError."""
|
|
157
|
+
with pytest.raises(ValidationError):
|
|
158
|
+
normalize_image(b"not an image", max_px=1200)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_fake_image_bytes_default() -> None:
|
|
162
|
+
"""fake_image_bytes: генерирует корректный PNG."""
|
|
163
|
+
raw = fake_image_bytes()
|
|
164
|
+
img = Image.open(io.BytesIO(raw))
|
|
165
|
+
|
|
166
|
+
assert img.size == (100, 100)
|
|
167
|
+
assert img.format == "PNG"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_fake_image_bytes_custom_size() -> None:
|
|
171
|
+
"""fake_image_bytes: пользовательские размеры."""
|
|
172
|
+
raw = fake_image_bytes(width=500, height=300)
|
|
173
|
+
img = Image.open(io.BytesIO(raw))
|
|
174
|
+
|
|
175
|
+
assert img.size == (500, 300)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Тесты presigned URL и утилит."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from storagekit import snapshot_key
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_snapshot_key_int_skill_id() -> None:
|
|
8
|
+
"""snapshot_key: целочисленный skill_id."""
|
|
9
|
+
key = snapshot_key(skill_id=42, semver="1.0.0")
|
|
10
|
+
assert key == "skills/42/versions/1.0.0.tar.gz"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_snapshot_key_str_skill_id() -> None:
|
|
14
|
+
"""snapshot_key: строковый skill_id."""
|
|
15
|
+
key = snapshot_key(skill_id="my-skill", semver="1.0.0")
|
|
16
|
+
assert key == "skills/my-skill/versions/1.0.0.tar.gz"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_snapshot_key_various_semvers() -> None:
|
|
20
|
+
"""snapshot_key: различные версии."""
|
|
21
|
+
assert snapshot_key(skill_id=1, semver="0.1.0") == "skills/1/versions/0.1.0.tar.gz"
|
|
22
|
+
assert snapshot_key(skill_id=1, semver="2.5.10") == "skills/1/versions/2.5.10.tar.gz"
|
|
23
|
+
assert snapshot_key(skill_id=1, semver="1.0.0-rc.1") == "skills/1/versions/1.0.0-rc.1.tar.gz"
|