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).
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user