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.
@@ -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"