from __future__ import annotations import asyncio from typing import Any from supabase import create_client from storage3.exceptions import StorageApiError from core.config.settings import SupabaseSettings, config from core.config.settings import config as app_config from .service_interface import BaseServiceProvider, register_service_instance class SupabaseService(BaseServiceProvider): def __init__(self, settings: SupabaseSettings | None = None) -> None: super().__init__("supabase") self._settings = settings or config.supabase self._client: Any = None self._admin_client: Any = None async def initialize(self, **_: Any) -> bool: try: self._init_clients() await self._ensure_storage_bucket() self._set_initialized(True) self.logger.info("Supabase service initialized") return True except Exception as exc: # noqa: BLE001 self.logger.warning( "Supabase service initialization failed", error=str(exc) ) self._client = None self._admin_client = None self._set_initialized(False) return False async def close(self) -> bool: self._client = None self._admin_client = None self._set_initialized(False) self.logger.info("Supabase service closed") return True async def health_check(self) -> dict[str, Any]: client = self._client admin_client = self._admin_client if client is None or admin_client is None: return {"status": "unhealthy", "details": {"error": "not initialized"}} try: await asyncio.to_thread(client.auth.get_session) await asyncio.to_thread( admin_client.auth.admin.list_users, page=1, per_page=1 ) return { "status": "healthy", "details": { "anon_client": "ready", "admin_client": "ready", }, } except Exception as exc: # noqa: BLE001 self.logger.warning("Supabase health check failed", error=str(exc)) return {"status": "unhealthy", "details": {"error": str(exc)}} def get_client(self) -> Any: return self._require_client() def get_admin_client(self) -> Any: return self._require_admin_client() def _require_client(self) -> Any: if self._client is None or self._admin_client is None: self._init_clients() self._set_initialized(True) self.logger.info("Supabase service lazily initialized") client = self._client if client is None: raise RuntimeError("Supabase client is not initialized") return client def _require_admin_client(self) -> Any: if self._client is None or self._admin_client is None: self._init_clients() self._set_initialized(True) self.logger.info("Supabase service lazily initialized") admin_client = self._admin_client if admin_client is None: raise RuntimeError("Supabase admin client is not initialized") return admin_client def _init_clients(self) -> None: self._client = create_client( self._settings.url, self._settings.anon_key, ) self._admin_client = create_client( self._settings.url, self._settings.service_role_key, ) async def _ensure_storage_bucket(self) -> None: bucket_name = app_config.storage.bucket storage = getattr(self._admin_client, "storage", None) if storage is None: self.logger.warning("Storage client unavailable, skipping bucket check") return get_bucket = getattr(storage, "get_bucket", None) if not callable(get_bucket): self.logger.warning("Storage get_bucket unavailable, skipping bucket check") return def _check_and_create() -> None: try: get_bucket(bucket_name) self.logger.debug("Storage bucket already exists", bucket=bucket_name) except Exception: # noqa: BLE001 create_bucket = getattr(storage, "create_bucket", None) if not callable(create_bucket): self.logger.warning( "Storage create_bucket unavailable, skipping bucket creation" ) return try: create_bucket(bucket_name, options={"public": False}) self.logger.info("Storage bucket created", bucket=bucket_name) except Exception as exc: # noqa: BLE001 msg = str(exc).lower() if "already exists" in msg or "duplicate" in msg: self.logger.debug( "Storage bucket already exists (race)", bucket=bucket_name ) return self.logger.warning( "Failed to create storage bucket", bucket=bucket_name, error=str(exc), ) await asyncio.to_thread(_check_and_create) def _get_storage(self) -> Any: """Get the storage client from admin client.""" client = self.get_admin_client() storage = getattr(client, "storage", None) if storage is None: raise RuntimeError("Supabase storage client unavailable") return storage def _get_bucket_client(self, bucket: str) -> Any: """Get a bucket client for the specified bucket.""" storage = self._get_storage() from_bucket = getattr(storage, "from_", None) if not callable(from_bucket): raise RuntimeError("Supabase storage bucket accessor unavailable") return from_bucket(bucket) def _validate_bucket(self, bucket: str) -> None: """Validate that the bucket matches the configured bucket.""" expected = app_config.storage.bucket if bucket != expected: raise RuntimeError("Invalid attachment bucket") def _ensure_bucket_client(self, bucket: str) -> Any: """Validate bucket and return authenticated bucket client.""" self._validate_bucket(bucket) return self._get_bucket_client(bucket) def _is_bucket_not_found_error(self, exc: Exception) -> bool: """Check if the exception indicates a bucket was not found.""" if isinstance(exc, StorageApiError): message = str(exc).lower() return "bucket" in message and "not found" in message message = str(exc).lower() return "bucket" in message and "not found" in message async def upload_bytes( self, *, bucket: str, path: str, content: bytes, content_type: str, ) -> str: def _upload() -> object: bucket_client = self._ensure_bucket_client(bucket) upload = getattr(bucket_client, "upload", None) if not callable(upload): raise RuntimeError("Supabase storage upload is unavailable") return upload( path, content, { "content-type": content_type, "upsert": "true", }, ) try: await asyncio.to_thread(_upload) except Exception as exc: # noqa: BLE001 if not self._is_bucket_not_found_error(exc): raise await self._ensure_bucket_exists(bucket=bucket) await asyncio.to_thread(_upload) return path async def _ensure_bucket_exists(self, *, bucket: str) -> None: def _ensure() -> None: storage = self._get_storage() get_bucket = getattr(storage, "get_bucket", None) if not callable(get_bucket): raise RuntimeError("Supabase storage get_bucket is unavailable") try: get_bucket(bucket) except Exception as exc: # noqa: BLE001 msg = str(exc).lower() if "bucket" in msg and "not found" in msg: raise RuntimeError(f"Storage bucket '{bucket}' does not exist") raise await asyncio.to_thread(_ensure) async def download_bytes(self, *, bucket: str, path: str) -> bytes: def _download() -> object: bucket_client = self._ensure_bucket_client(bucket) download = getattr(bucket_client, "download", None) if not callable(download): raise RuntimeError("Supabase storage download is unavailable") return download(path) raw = await asyncio.to_thread(_download) if isinstance(raw, bytes): return raw if isinstance(raw, bytearray): return bytes(raw) if isinstance(raw, memoryview): return raw.tobytes() raise RuntimeError("Invalid attachment payload") async def create_signed_url( self, *, bucket: str, path: str, expires_in_seconds: int, ) -> str: def _create_signed_url() -> object: bucket_client = self._ensure_bucket_client(bucket) signer = getattr(bucket_client, "create_signed_url", None) if not callable(signer): raise RuntimeError("Supabase storage signed url is unavailable") return signer(path, expires_in_seconds) raw = await asyncio.to_thread(_create_signed_url) if isinstance(raw, str): return raw if isinstance(raw, dict): signed_url = raw.get("signedURL") or raw.get("signedUrl") or raw.get("url") if isinstance(signed_url, str) and signed_url: return signed_url raise RuntimeError("Invalid signed url payload") def parse_signed_url(self, url: str) -> tuple[str, str]: from urllib.parse import urlparse parsed = urlparse(url) path_parts = parsed.path.strip("/").split("/") if ( len(path_parts) < 4 or path_parts[0] != "storage" or path_parts[1] != "v1" or path_parts[2] != "object" or path_parts[3] != "sign" ): raise RuntimeError("Invalid signed URL format") bucket = path_parts[4] path = "/".join(path_parts[5:]) return bucket, path supabase_service: SupabaseService = register_service_instance( "supabase", SupabaseService() ) __all__ = ["SupabaseService", "supabase_service"]