0fc29a34dd
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
104 lines
3.3 KiB
Python
104 lines
3.3 KiB
Python
"""platform/objectstore — the media-blob port (SD-0002 §6.2, §6.3.1).
|
|
|
|
A tiny put/get/delete port with two adapters, mirroring the SD-0001 mailer
|
|
pattern: local-disk (dev/tests) and GCS (deployed). Owns no semantics — keys
|
|
and content types come from the caller (the image-fetch phase). Objects are
|
|
written once, never mutated (immutable, image-id-addressed).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Protocol
|
|
|
|
from app.platform import config
|
|
|
|
|
|
class ObjectNotFound(Exception):
|
|
"""Raised by get() when the key has no object."""
|
|
|
|
|
|
class ObjectStore(Protocol):
|
|
def put(self, key: str, data: bytes, content_type: str) -> None: ...
|
|
def get(self, key: str) -> bytes: ...
|
|
def delete(self, key: str) -> None: ...
|
|
|
|
|
|
def build_objectstore(kind: str) -> ObjectStore:
|
|
"""Select the objectstore adapter by configured kind (config.objectstore_kind, INV-8)."""
|
|
if kind == "local":
|
|
return LocalObjectStore(config.objectstore_local_dir())
|
|
if kind == "gcs":
|
|
return GcsObjectStore(config.objectstore_bucket())
|
|
raise ValueError(f"unknown objectstore kind: {kind!r}")
|
|
|
|
|
|
def _safe_segments(key: str) -> tuple[str, ...]:
|
|
parts = tuple(p for p in key.split("/") if p)
|
|
if not parts or any(p in {".", ".."} for p in parts):
|
|
raise ValueError(f"unsafe objectstore key: {key!r}")
|
|
return parts
|
|
|
|
|
|
class LocalObjectStore:
|
|
"""Disk-backed adapter under a base dir; key path-segments become subdirs."""
|
|
|
|
def __init__(self, base_dir: str) -> None:
|
|
self._base = Path(base_dir)
|
|
|
|
def _path(self, key: str) -> Path:
|
|
return self._base.joinpath(*_safe_segments(key))
|
|
|
|
def put(self, key: str, data: bytes, content_type: str) -> None:
|
|
path = self._path(key)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(data)
|
|
|
|
def get(self, key: str) -> bytes:
|
|
path = self._path(key)
|
|
try:
|
|
return path.read_bytes()
|
|
except FileNotFoundError as exc:
|
|
raise ObjectNotFound(key) from exc
|
|
|
|
def delete(self, key: str) -> None:
|
|
try:
|
|
os.remove(self._path(key))
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
|
|
class GcsObjectStore:
|
|
"""GCS adapter; auth via the VM service account ADC (no secret bytes).
|
|
|
|
google-cloud-storage is imported lazily so dev/test runs (local adapter)
|
|
don't require the package at import time.
|
|
"""
|
|
|
|
def __init__(self, bucket_name: str) -> None:
|
|
if not bucket_name:
|
|
raise ValueError("gcs objectstore requires ECOMM_OBJECTSTORE_BUCKET")
|
|
from google.cloud import storage # lazy
|
|
|
|
self._bucket = storage.Client().bucket(bucket_name)
|
|
|
|
def put(self, key: str, data: bytes, content_type: str) -> None:
|
|
_safe_segments(key)
|
|
self._bucket.blob(key).upload_from_string(data, content_type=content_type)
|
|
|
|
def get(self, key: str) -> bytes:
|
|
from google.cloud.exceptions import NotFound # lazy
|
|
|
|
try:
|
|
return self._bucket.blob(key).download_as_bytes()
|
|
except NotFound as exc:
|
|
raise ObjectNotFound(key) from exc
|
|
|
|
def delete(self, key: str) -> None:
|
|
from google.cloud.exceptions import NotFound # lazy
|
|
|
|
try:
|
|
self._bucket.blob(key).delete()
|
|
except NotFound:
|
|
pass
|