from __future__ import annotations from dataclasses import dataclass from typing import BinaryIO import boto3 # type: ignore[import-untyped] import boto3.session # type: ignore[import-untyped] from botocore.config import Config # type: ignore[import-untyped] from botocore.exceptions import ClientError # type: ignore[import-untyped] @dataclass(frozen=True) class S3Config: access_key: str secret_key: str bucket_name: str endpoint_url_internal: str | None endpoint_url_public: str | None presign_expires_seconds: int = 3600 class S3StorageBackend: def __init__(self, cfg: S3Config) -> None: self._cfg = cfg self._bucket_ready = False session = boto3.session.Session() common = { "aws_access_key_id": cfg.access_key, "aws_secret_access_key": cfg.secret_key, "region_name": "us-east-1", "config": Config(signature_version="s3v4", s3={"addressing_style": "path"}), } self._client = session.client( "s3", endpoint_url=cfg.endpoint_url_internal, **common ) presign_endpoint = cfg.endpoint_url_public or cfg.endpoint_url_internal self._presign_client = session.client( "s3", endpoint_url=presign_endpoint, **common ) def ensure_bucket(self) -> None: if self._bucket_ready: return try: self._client.head_bucket(Bucket=self._cfg.bucket_name) except ClientError as e: code = str(e.response.get("Error", {}).get("Code", "")) if code in {"404", "NoSuchBucket"}: self._client.create_bucket(Bucket=self._cfg.bucket_name) else: raise self._bucket_ready = True def upload_fileobj( self, key: str, fileobj: BinaryIO, *, content_type: str | None ) -> None: self.ensure_bucket() extra_args = {"ContentType": content_type} if content_type else None self._client.upload_fileobj( Fileobj=fileobj, Bucket=self._cfg.bucket_name, Key=key, ExtraArgs=extra_args, ) def download_fileobj(self, key: str, fileobj: BinaryIO) -> None: self.ensure_bucket() self._client.download_fileobj(self._cfg.bucket_name, key, fileobj) def exists(self, key: str) -> bool: self.ensure_bucket() try: self._client.head_object(Bucket=self._cfg.bucket_name, Key=key) return True except ClientError as e: code = str(e.response.get("Error", {}).get("Code", "")) if code in {"404", "NoSuchKey"}: return False raise def size(self, key: str) -> int: self.ensure_bucket() resp = self._client.head_object(Bucket=self._cfg.bucket_name, Key=key) return int(resp.get("ContentLength", 0)) def delete(self, key: str) -> None: self.ensure_bucket() self._client.delete_object(Bucket=self._cfg.bucket_name, Key=key) def read(self, key: str) -> bytes: self.ensure_bucket() resp = self._client.get_object(Bucket=self._cfg.bucket_name, Key=key) body = resp["Body"].read() return body def generate_url(self, key: str) -> str: self.ensure_bucket() return self._presign_client.generate_presigned_url( ClientMethod="get_object", Params={"Bucket": self._cfg.bucket_name, "Key": key}, ExpiresIn=self._cfg.presign_expires_seconds, )