wardrobe-us / src /storage.py
Ox1's picture
fix(catalog): ensure data persists across app restarts
913537b
Raw
History Blame Contribute Delete
4.03 kB
"""Storage abstraction layer for garment images and data.
Supports two backends controlled by STORAGE_BACKEND env var:
- "local" (default): filesystem at data/garments/
- "s3": HuggingFace Spaces S3 bucket for persistent storage
"""
import io
import logging
import os
from pathlib import Path
logger = logging.getLogger(__name__)
STORAGE_BACKEND = os.environ.get("STORAGE_BACKEND", "local")
LOCAL_GARMENTS_DIR = Path(__file__).resolve().parent.parent / "data" / "garments"
S3_BUCKET_NAME = os.environ.get("S3_BUCKET_NAME", "")
S3_ENDPOINT_URL = os.environ.get("S3_ENDPOINT_URL", "")
S3_PREFIX = os.environ.get("S3_PREFIX", "garments/")
_s3_client = None
def _get_s3_client():
global _s3_client
if _s3_client is None:
import boto3
kwargs = {}
if S3_ENDPOINT_URL:
kwargs["endpoint_url"] = S3_ENDPOINT_URL
_s3_client = boto3.client("s3", **kwargs)
return _s3_client
def _ensure_local_dir():
LOCAL_GARMENTS_DIR.mkdir(parents=True, exist_ok=True)
def save_image(garment_id: str, image_bytes: bytes, extension: str = "jpg") -> str:
"""Save a garment image and return its storage reference.
The reference is backend-agnostic (just the filename). Use
get_image_url() to resolve it to a serveable path/URL.
"""
filename = f"{garment_id}.{extension}"
if STORAGE_BACKEND == "s3":
_save_to_s3(filename, image_bytes)
else:
_save_to_local(filename, image_bytes)
logger.info("Saved image: %s (backend: %s)", filename, STORAGE_BACKEND)
return filename
def _save_to_local(filename: str, image_bytes: bytes):
_ensure_local_dir()
path = LOCAL_GARMENTS_DIR / filename
path.write_bytes(image_bytes)
def _save_to_s3(filename: str, image_bytes: bytes):
client = _get_s3_client()
key = f"{S3_PREFIX}{filename}"
client.put_object(
Bucket=S3_BUCKET_NAME,
Key=key,
Body=image_bytes,
ContentType="image/jpeg",
)
def get_image_path(garment_id: str, extension: str = "jpg") -> str | None:
"""Return the local filesystem path for a garment image.
For S3 backend, downloads to a local cache first.
Returns None if the image doesn't exist.
"""
filename = f"{garment_id}.{extension}"
if STORAGE_BACKEND == "s3":
return _download_from_s3(filename)
path = LOCAL_GARMENTS_DIR / filename
if path.exists():
return str(path)
return None
def _download_from_s3(filename: str) -> str | None:
_ensure_local_dir()
local_path = LOCAL_GARMENTS_DIR / filename
if local_path.exists():
return str(local_path)
try:
client = _get_s3_client()
key = f"{S3_PREFIX}{filename}"
response = client.get_object(Bucket=S3_BUCKET_NAME, Key=key)
local_path.write_bytes(response["Body"].read())
return str(local_path)
except Exception as e:
logger.warning("Failed to download %s from S3: %s", filename, e)
return None
def delete_image(garment_id: str, extension: str = "jpg"):
"""Remove a garment image from storage."""
filename = f"{garment_id}.{extension}"
if STORAGE_BACKEND == "s3":
try:
client = _get_s3_client()
key = f"{S3_PREFIX}{filename}"
client.delete_object(Bucket=S3_BUCKET_NAME, Key=key)
except Exception as e:
logger.warning("Failed to delete %s from S3: %s", filename, e)
local_path = LOCAL_GARMENTS_DIR / filename
if local_path.exists():
local_path.unlink()
def image_exists(garment_id: str, extension: str = "jpg") -> bool:
"""Check if a garment image exists in storage."""
filename = f"{garment_id}.{extension}"
if STORAGE_BACKEND == "s3":
try:
client = _get_s3_client()
key = f"{S3_PREFIX}{filename}"
client.head_object(Bucket=S3_BUCKET_NAME, Key=key)
return True
except Exception:
return False
return (LOCAL_GARMENTS_DIR / filename).exists()