"""MinIO client singleton with lazy initialization. Provides file upload, presigned download URL generation, and automatic bucket creation for the Chrysopedia post attachment storage. """ from __future__ import annotations import io import logging from datetime import timedelta from minio import Minio from minio.error import S3Error from config import get_settings logger = logging.getLogger(__name__) _client: Minio | None = None _bucket_ensured: bool = False def get_minio_client() -> Minio: """Return the singleton MinIO client, creating it on first call.""" global _client if _client is None: settings = get_settings() _client = Minio( settings.minio_url, access_key=settings.minio_access_key, secret_key=settings.minio_secret_key, secure=settings.minio_secure, ) logger.info("MinIO client initialized (endpoint=%s)", settings.minio_url) return _client def ensure_bucket() -> None: """Create the configured bucket if it doesn't already exist.""" global _bucket_ensured if _bucket_ensured: return settings = get_settings() client = get_minio_client() bucket = settings.minio_bucket try: if not client.bucket_exists(bucket): client.make_bucket(bucket) logger.info("Created MinIO bucket: %s", bucket) else: logger.debug("MinIO bucket already exists: %s", bucket) _bucket_ensured = True except S3Error as exc: logger.error("MinIO bucket check/create failed: %s", exc) raise def upload_file( object_key: str, data: bytes | io.BytesIO, length: int, content_type: str = "application/octet-stream", ) -> None: """Upload a file to MinIO. Args: object_key: The storage path within the bucket. data: File content as bytes or BytesIO stream. length: Size in bytes. content_type: MIME type for the object. """ ensure_bucket() settings = get_settings() client = get_minio_client() stream = io.BytesIO(data) if isinstance(data, bytes) else data client.put_object( settings.minio_bucket, object_key, stream, length, content_type=content_type, ) logger.info("Uploaded %s (%d bytes, %s)", object_key, length, content_type) def generate_download_url(object_key: str, expires: int = 3600) -> str: """Generate a presigned GET URL for downloading a file. Args: object_key: The storage path within the bucket. expires: URL validity in seconds (default 1 hour). Returns: Presigned URL string. """ settings = get_settings() client = get_minio_client() url: str = client.presigned_get_object( settings.minio_bucket, object_key, expires=timedelta(seconds=expires), ) return url def delete_file(object_key: str) -> None: """Delete a file from MinIO. Args: object_key: The storage path within the bucket. """ settings = get_settings() client = get_minio_client() client.remove_object(settings.minio_bucket, object_key) logger.info("Deleted %s from MinIO", object_key)