from __future__ import annotations from pathlib import Path from typing import Any import anyio import boto3 from botocore.config import Config from botocore.exceptions import ClientError class S3StorageAdapter: def __init__(self, config: dict[str, Any]) -> None: self.config = config async def check(self) -> dict[str, str]: client = self._create_client() def _check() -> dict[str, str]: client.head_bucket(Bucket=self.config["bucket"]) return {"status": "healthy", "detail": self.config["bucket"]} return await anyio.to_thread.run_sync(_check) async def put_file(self, source_path: str, storage_key: str) -> dict[str, str | None]: client = self._create_client() object_key = self._object_key(storage_key) def _put() -> dict[str, str | None]: extra_args: dict[str, Any] = {} if self.config.get("storage_class"): extra_args["StorageClass"] = self.config["storage_class"] if self.config.get("server_side_encryption"): extra_args["ServerSideEncryption"] = self.config["server_side_encryption"] client.upload_file(source_path, self.config["bucket"], object_key, ExtraArgs=extra_args) metadata = client.head_object(Bucket=self.config["bucket"], Key=object_key) return {"etag": metadata.get("ETag"), "storage_key": storage_key} return await anyio.to_thread.run_sync(_put) async def download_file(self, storage_key: str, destination_path: Path) -> Path: client = self._create_client() object_key = self._object_key(storage_key) def _download() -> Path: destination_path.parent.mkdir(parents=True, exist_ok=True) client.download_file(self.config["bucket"], object_key, str(destination_path)) return destination_path return await anyio.to_thread.run_sync(_download) async def object_exists(self, storage_key: str) -> bool: client = self._create_client() object_key = self._object_key(storage_key) def _exists() -> bool: try: client.head_object(Bucket=self.config["bucket"], Key=object_key) return True except ClientError as exc: error_code = exc.response.get("Error", {}).get("Code") if error_code in {"404", "NoSuchKey", "NotFound"}: return False raise return await anyio.to_thread.run_sync(_exists) def _create_client(self): client_config = None if self.config.get("force_path_style"): client_config = Config(s3={"addressing_style": "path"}) client_kwargs = { "service_name": "s3", "region_name": self.config.get("region"), "endpoint_url": self.config.get("endpoint_url"), "aws_access_key_id": self.config.get("access_key_id"), "aws_secret_access_key": self.config.get("secret_access_key"), "verify": self.config.get("verify_ssl", True), "config": client_config, } cleaned_kwargs = {key: value for key, value in client_kwargs.items() if value is not None} return boto3.client(**cleaned_kwargs) def _object_key(self, storage_key: str) -> str: prefix = (self.config.get("prefix") or "").strip("/") if not prefix: return storage_key return f"{prefix}/{storage_key}"