s-storagekit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,9 @@
1
+ storagekit/__init__.py,sha256=GAOkkvCu0mL70MsNij7bpKp7UEhO76GZ6-LWe6t00d0,2789
2
+ storagekit/base.py,sha256=x4TBVEPrjQQy9_gWW5q89euaoDVJv79rso25NP07YSg,1731
3
+ storagekit/images.py,sha256=NPNxEjvAhZW3KWfj6wLebfaHGj0TjmReAFm2npqfX1s,6287
4
+ storagekit/minio_storage.py,sha256=YfVaL7hSSV9izYm4kUNUDA3A3-1C8EXixOt2Cspcns4,3288
5
+ storagekit/presign.py,sha256=kegTwCWvOSlNKU-mxwvfcxLpVpc4PiiXcIYsveair_c,1928
6
+ storagekit/testing.py,sha256=KsRC0bLcl2E5v_ABcJztkE3lBV7NCejvu80AwEj5sJU,1676
7
+ s_storagekit-0.1.0.dist-info/METADATA,sha256=0aDLlHIyolRxBZRnNxizbxBplglKdoGsSEx_pB27UQU,6596
8
+ s_storagekit-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
9
+ s_storagekit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
storagekit/__init__.py ADDED
@@ -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
+ ]
storagekit/base.py ADDED
@@ -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
+ ...
storagekit/images.py ADDED
@@ -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)
storagekit/presign.py ADDED
@@ -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
+ )
storagekit/testing.py ADDED
@@ -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()