"""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()