Files
periscope/backend/services/storage_gcs.py
T
Siddharth Kothari 6672d2be57 Pinscope open-source core
Agentic schematic validation: datasheet extraction via Claude Console
Skills, netlist/BOM design graph, per-IC direct datasheet review with
page citations, capacitor derating, Next.js report UI.

Extracted from the Pinscope cloud codebase. Auth and billing live in the
private gateway repo behind stable seams (billing_hook.py, adapter files
listed in CLAUDE.md).
2026-07-16 21:29:45 -07:00

127 lines
4.9 KiB
Python

"""Google Cloud Storage backend for StorageBackend protocol."""
from __future__ import annotations
import json
from pathlib import Path
from google.api_core.exceptions import PreconditionFailed
from google.cloud import storage as gcs
from backend.services.storage import StaleGeneration
class GCSStorageBackend:
"""StorageBackend implementation using Google Cloud Storage."""
def __init__(self, bucket_name: str) -> None:
self._client = gcs.Client()
self._bucket = self._client.bucket(bucket_name)
def _blob(self, key: str) -> gcs.Blob:
return self._bucket.blob(key)
def read_json(self, key: str) -> dict:
text = self._blob(key).download_as_text()
return json.loads(text)
def write_json(self, key: str, data: dict) -> None:
text = json.dumps(data, indent=2) + "\n"
self._blob(key).upload_from_string(text, content_type="application/json")
def read_bytes(self, key: str) -> bytes:
return self._blob(key).download_as_bytes()
def write_bytes(self, key: str, data: bytes) -> None:
self._blob(key).upload_from_string(data)
def read_text(self, key: str) -> str:
return self._blob(key).download_as_text()
def write_text(self, key: str, text: str) -> None:
self._blob(key).upload_from_string(text, content_type="text/plain")
def exists(self, key: str) -> bool:
return self._blob(key).exists()
def list_prefix(self, prefix: str) -> list[str]:
# List immediate children (one level) using delimiter
blobs = self._client.list_blobs(
self._bucket, prefix=prefix, delimiter="/",
)
keys: list[str] = []
# Files directly under prefix
for blob in blobs:
keys.append(blob.name)
# "Subdirectories" — strip trailing slash for consistency
for pfx in blobs.prefixes:
keys.append(pfx.rstrip("/"))
return sorted(keys)
def list_recursive(self, prefix: str) -> list[str]:
blobs = self._client.list_blobs(self._bucket, prefix=prefix)
return sorted(blob.name for blob in blobs)
def list_prefix_after(self, prefix: str, after_key: str | None = None) -> list[str]:
# Use GCS ``start_offset`` to skip already-seen keys server-side. We
# ask for the next-after value; since after_key may be the last seen
# key, advance one byte so the listing excludes it.
kwargs: dict = {"prefix": prefix, "delimiter": "/"}
if after_key is not None:
# Request keys strictly greater than after_key. Append a NUL byte
# so GCS treats start_offset as "after" rather than "starting at".
kwargs["start_offset"] = after_key + "\x00"
blobs = self._client.list_blobs(self._bucket, **kwargs)
return sorted(blob.name for blob in blobs)
def read_json_with_generation(self, key: str) -> tuple[dict, int]:
blob = self._blob(key)
text = blob.download_as_text()
# download_as_text populates blob.generation as a side effect.
gen = int(blob.generation) if blob.generation is not None else 0
return json.loads(text), gen
def write_json_if_match(self, key: str, data: dict, generation: int) -> int:
text = json.dumps(data, indent=2) + "\n"
blob = self._blob(key)
try:
blob.upload_from_string(
text,
content_type="application/json",
if_generation_match=generation,
)
except PreconditionFailed as exc:
raise StaleGeneration(
f"generation mismatch on {key}: expected {generation}"
) from exc
# blob.generation is set by upload_from_string on success.
return int(blob.generation) if blob.generation is not None else 0
def delete_key(self, key: str) -> None:
blob = self._blob(key)
if blob.exists():
blob.delete()
def delete_prefix(self, prefix: str) -> None:
blobs = list(self._client.list_blobs(self._bucket, prefix=prefix))
if blobs:
self._bucket.delete_blobs(blobs)
def copy_object(self, src_key: str, dst_key: str) -> None:
src_blob = self._blob(src_key)
self._bucket.copy_blob(src_blob, self._bucket, dst_key)
def download_to_local(self, key: str, local_path: Path) -> Path:
local_path.parent.mkdir(parents=True, exist_ok=True)
self._blob(key).download_to_filename(str(local_path))
return local_path
def upload_from_local(self, local_path: Path, key: str) -> None:
self._blob(key).upload_from_filename(str(local_path))
def signed_url(self, key: str, expiration_minutes: int = 15) -> str:
# Not used for GCS on Cloud Run — the backend proxies PDFs directly
# via the /datasheet-proxy/ endpoint instead. Kept for interface
# compatibility.
raise NotImplementedError("Use read_bytes() and proxy instead")