Andrey Epifancev c6dfb3e22c Срез 0 [5.2]: S3Storage (src/core/clients.py): get_bytes, put_bytes, put_json, from_settings с secure
Ошибки не проглатываются: S3ObjectNotFoundError (NoSuchKey) и S3ReadError для чтения, S3WriteError для записи,
исходная причина — в __cause__. JSON пишется в UTF-8 без экранирования, Content-Type application/json.
Шаблонные put_object/get_object удалены (не использовались); удалён и шаблонный nats_callback.py — его
единственный импорт S3Storage, сам он нигде не используется (слой NATS — следующим коммитом в src/core/nats.py).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 03:27:35 +04:00

106 lines
4.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""S3-клиент сервиса (MinIO через `miniopy-async`): чтение исходника, запись сырого ответа ИС и файла результата.
Ошибки не проглатываются и не превращаются в `None`: каждая операция бросает исключение из иерархии
`S3StorageError` с исходной причиной в `__cause__`, чтобы воркер мог классифицировать сбой (design D-7):
- `S3ObjectNotFoundError` (подкласс `S3ReadError`) — объекта нет (`NoSuchKey`);
- `S3ReadError` — любая другая ошибка чтения (нет доступа, нет бакета, сеть) → воркер отвечает `FILE_READ_ERROR`;
- `S3WriteError` — ошибка записи (`put_bytes` / `put_json`).
`asyncio.CancelledError` не оборачивается — отмена задачи проходит как есть.
"""
import json
from io import BytesIO
from typing import Any
from miniopy_async import Minio
from miniopy_async.error import S3Error
from src.core.config import MinioSettings
OCTET_STREAM = "application/octet-stream"
JSON_CONTENT_TYPE = "application/json"
NOT_FOUND_CODES = frozenset({"NoSuchKey"})
class S3StorageError(Exception):
"""Ошибка операции с S3; `bucket` и `key` — объект, на котором она случилась."""
def __init__(self, message: str, *, bucket: str, key: str) -> None:
super().__init__(message)
self.bucket = bucket
self.key = key
class S3ReadError(S3StorageError):
"""Объект не прочитан (нет доступа, нет бакета, сбой сети, ...)."""
class S3ObjectNotFoundError(S3ReadError):
"""Объекта с таким ключом в бакете нет."""
class S3WriteError(S3StorageError):
"""Объект не записан."""
class S3Storage:
"""Операции сервиса над одним бакетом (`OPENBAO.MINIO.MINIO_BUCKET`); чтение может явно указать другой бакет."""
def __init__(self, client: Minio, bucket: str) -> None:
self.client = client
self.bucket = bucket
@classmethod
def from_settings(cls, settings: MinioSettings) -> "S3Storage":
"""Клиент MinIO из настроек: `MINIO_SECURE=false` — http (compose), `true` — https (платформа)."""
client = Minio(
settings.MINIO_URL,
access_key=settings.MINIO_ADMIN_LOGIN,
secret_key=settings.MINIO_ADMIN_PASSWORD,
secure=settings.MINIO_SECURE,
)
return cls(client, settings.MINIO_BUCKET)
async def get_bytes(self, key: str, *, bucket: str | None = None) -> bytes:
"""Содержимое объекта целиком. `bucket` — из запроса (`s3.bucket`), по умолчанию — бакет сервиса.
Нет объекта → `S3ObjectNotFoundError`; иная ошибка → `S3ReadError`.
"""
bucket_name = bucket or self.bucket
try:
response = await self.client.get_object(bucket_name, key)
try:
data: bytes = await response.read()
finally:
response.release()
except S3Error as exc:
error_class = S3ObjectNotFoundError if exc.code in NOT_FOUND_CODES else S3ReadError
raise error_class(
f"S3: объект {bucket_name}/{key} не прочитан ({exc.code})", bucket=bucket_name, key=key
) from exc
except Exception as exc:
raise S3ReadError(
f"S3: объект {bucket_name}/{key} не прочитан ({type(exc).__name__})", bucket=bucket_name, key=key
) from exc
return data
async def put_bytes(self, key: str, data: bytes, content_type: str = OCTET_STREAM) -> None:
"""Записывает `data` в бакет сервиса; ошибка → `S3WriteError`."""
try:
await self.client.put_object(self.bucket, key, BytesIO(data), length=len(data), content_type=content_type)
except Exception as exc:
raise S3WriteError(
f"S3: объект {self.bucket}/{key} не записан ({type(exc).__name__})", bucket=self.bucket, key=key
) from exc
async def put_json(self, key: str, obj: Any) -> None:
"""Записывает `obj` как JSON в UTF-8 без `\\u`-экранирования, `Content-Type: application/json`."""
data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
await self.put_bytes(key, data, content_type=JSON_CONTENT_TYPE)
async def close(self) -> None:
"""Закрывает HTTP-сессию клиента (при остановке приложения)."""
await self.client.close_session()