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).
127 lines
4.9 KiB
Python
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")
|