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:
Siddharth Kothari
2026-07-16 21:29:45 -07:00
commit 6672d2be57
254 changed files with 56662 additions and 0 deletions
+321
View File
@@ -0,0 +1,321 @@
"""DigiKey API integration — fetch datasheet PDFs and product parameters by MPN.
Uses DigiKey Product Information API v4 with OAuth2 client credentials.
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass, field
import httpx
from backend.config import settings
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# OAuth2 token cache
# ---------------------------------------------------------------------------
_token_cache: dict[str, str | float] = {"access_token": "", "expires_at": 0.0}
_BASE_URLS = {
"production": "https://api.digikey.com",
"sandbox": "https://sandbox-api.digikey.com",
}
async def _get_access_token() -> str:
"""Get a DigiKey OAuth2 access token, refreshing if expired."""
now = time.time()
if _token_cache["access_token"] and float(_token_cache["expires_at"]) > now + 60:
return str(_token_cache["access_token"])
base = _BASE_URLS.get(settings.digikey_environment, _BASE_URLS["production"])
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
f"{base}/v1/oauth2/token",
data={
"client_id": settings.digikey_client_id,
"client_secret": settings.digikey_client_secret,
"grant_type": "client_credentials",
},
)
resp.raise_for_status()
data = resp.json()
_token_cache["access_token"] = data["access_token"]
_token_cache["expires_at"] = now + data.get("expires_in", 3600)
logger.info("DigiKey OAuth token refreshed (expires in %ds)", data.get("expires_in", 3600))
return str(_token_cache["access_token"])
# ---------------------------------------------------------------------------
# Product search
# ---------------------------------------------------------------------------
def _get_mpn(product: dict) -> str:
return product.get("ManufacturerProductNumber") or product.get("ManufacturerPartNumber") or ""
def _get_ds_url(product: dict) -> str:
url = product.get("DatasheetUrl") or product.get("PrimaryDatasheet") or ""
# DigiKey sometimes returns protocol-relative URLs
if url.startswith("//"):
url = "https:" + url
return url
async def _keyword_search(mpn: str) -> list[dict]:
"""Run a DigiKey keyword search and return the raw products list."""
base = _BASE_URLS.get(settings.digikey_environment, _BASE_URLS["production"])
token = await _get_access_token()
headers = {
"Authorization": f"Bearer {token}",
"X-DIGIKEY-Client-Id": settings.digikey_client_id,
"X-DIGIKEY-Locale-Site": settings.digikey_locale_site,
"X-DIGIKEY-Locale-Language": settings.digikey_locale_language,
"X-DIGIKEY-Locale-Currency": settings.digikey_locale_currency,
"Content-Type": "application/json",
}
body = {
"Keywords": mpn,
"Limit": 5,
"Offset": 0,
"ExcludeMarketPlaceProducts": True,
}
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.post(
f"{base}/products/v4/search/keyword",
headers=headers,
json=body,
)
resp.raise_for_status()
data = resp.json()
return data.get("Products") or data.get("products") or []
def _find_product(mpn: str, products: list[dict]) -> dict | None:
"""Find the product whose MPN exactly matches ``mpn`` (case/space-insensitive).
Returns None when no result has a matching MPN. We intentionally do NOT
fall back to ``products[0]`` — keyword-search hits without an MPN match
are usually for a different part, and silently returning them has
polluted the library with wrong specs for non-MPN tokens like ``10uF``.
"""
if not products:
return None
mpn_upper = mpn.upper().replace(" ", "")
for product in products:
if _get_mpn(product).upper().replace(" ", "") == mpn_upper:
return product
return None
async def _search_mpn(mpn: str) -> str | None:
"""Search DigiKey for an MPN and return the primary datasheet URL, or None."""
products = await _keyword_search(mpn)
product = _find_product(mpn, products)
if not product:
return None
url = _get_ds_url(product)
return url or None
# ---------------------------------------------------------------------------
# PDF download + validation
# ---------------------------------------------------------------------------
_PDF_MAGIC = b"%PDF-"
_MIN_PDF_SIZE = 5_000 # 5 KB — anything smaller is probably an error page
async def _download_pdf(url: str) -> bytes:
"""Download a PDF from a URL and validate it.
Raises ValueError if the file isn't a valid PDF or is too small.
Raises httpx.HTTPStatusError on 4xx/5xx responses.
"""
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
resp = await client.get(url)
resp.raise_for_status()
data = resp.content
if not data.startswith(_PDF_MAGIC):
raise ValueError("Downloaded file is not a valid PDF (bad magic bytes)")
if len(data) < _MIN_PDF_SIZE:
raise ValueError(f"PDF too small ({len(data)} bytes) — likely an error page")
return data
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
class DatasheetFetchResult:
"""Result of a datasheet fetch attempt."""
def __init__(
self,
mpn: str,
pdf_bytes: bytes | None = None,
error: str | None = None,
url: str | None = None,
):
self.mpn = mpn
self.pdf_bytes = pdf_bytes
self.error = error
self.url = url # DigiKey datasheet URL (present even when PDF download fails)
@property
def ok(self) -> bool:
return self.pdf_bytes is not None
async def fetch_datasheet(mpn: str) -> DatasheetFetchResult:
"""Fetch a datasheet PDF for the given MPN from DigiKey.
Returns a DatasheetFetchResult with either pdf_bytes or an error message.
The `url` field is set whenever DigiKey returns a datasheet link, even if
the PDF download itself fails.
Never raises — all errors are captured in the result.
"""
if not settings.use_digikey:
return DatasheetFetchResult(mpn, error="DigiKey API not configured")
try:
url = await _search_mpn(mpn)
except httpx.HTTPStatusError as e:
logger.warning("DigiKey search failed for %s: %s", mpn, e)
return DatasheetFetchResult(mpn, error=f"DigiKey search failed ({e.response.status_code})")
except Exception as e:
msg = str(e) or type(e).__name__
logger.warning("DigiKey search error for %s: %s", mpn, msg)
return DatasheetFetchResult(mpn, error=f"DigiKey search error: {msg}")
if not url:
return DatasheetFetchResult(mpn, error="No datasheet found on DigiKey")
try:
pdf_bytes = await _download_pdf(url)
except httpx.HTTPStatusError as e:
logger.warning("Datasheet download blocked for %s (%s): %s", mpn, url, e)
return DatasheetFetchResult(mpn, error=f"Download blocked ({e.response.status_code})", url=url)
except ValueError as e:
logger.warning("Invalid PDF for %s (%s): %s", mpn, url, e)
return DatasheetFetchResult(mpn, error=str(e), url=url)
except httpx.TimeoutException:
logger.warning("Datasheet download timed out for %s (%s)", mpn, url)
return DatasheetFetchResult(mpn, error="Download timed out", url=url)
except Exception as e:
msg = str(e) or type(e).__name__
logger.warning("Datasheet download failed for %s (%s): %s", mpn, url, msg)
return DatasheetFetchResult(mpn, error=f"Download failed: {msg}", url=url)
logger.info("Fetched datasheet for %s (%d KB)", mpn, len(pdf_bytes) // 1024)
return DatasheetFetchResult(mpn, pdf_bytes=pdf_bytes, url=url)
# ---------------------------------------------------------------------------
# Product parameters
# ---------------------------------------------------------------------------
@dataclass
class ProductParams:
"""Structured product parameters from a DigiKey search result."""
mpn: str
parameters: list[dict[str, str]] = field(default_factory=list) # [{"name": ..., "value": ...}]
category: str = ""
description: str = ""
class ParamsFetchResult:
"""Result of a product parameters fetch attempt."""
def __init__(self, mpn: str, params: ProductParams | None = None, error: str | None = None):
self.mpn = mpn
self.params = params
self.error = error
@property
def ok(self) -> bool:
return self.params is not None
def _parse_product_params(mpn: str, product: dict) -> ProductParams:
"""Extract structured parameters from a DigiKey product dict."""
raw_params = product.get("Parameters") or product.get("parameters") or []
parameters = []
for p in raw_params:
name = p.get("ParameterText") or p.get("parameterText") or ""
value = p.get("ValueText") or p.get("valueText") or ""
if name and value and value != "-":
parameters.append({"name": name, "value": value})
# Category
cat = product.get("Category") or product.get("category") or {}
category = cat.get("Name") or cat.get("name") or ""
# Description
desc_obj = product.get("Description") or product.get("description") or {}
if isinstance(desc_obj, str):
description = desc_obj
else:
description = (
desc_obj.get("ProductDescription")
or desc_obj.get("productDescription")
or desc_obj.get("DetailedDescription")
or desc_obj.get("detailedDescription")
or ""
)
return ProductParams(
mpn=mpn,
parameters=parameters,
category=category,
description=description,
)
async def fetch_params(mpn: str) -> ParamsFetchResult:
"""Fetch DigiKey product parameters for the given MPN.
Returns structured parameter data (no PDF download needed).
Never raises — all errors are captured in the result.
"""
if not settings.use_digikey:
return ParamsFetchResult(mpn, error="DigiKey API not configured")
try:
products = await _keyword_search(mpn)
except httpx.HTTPStatusError as e:
logger.warning("DigiKey search failed for %s: %s", mpn, e)
return ParamsFetchResult(mpn, error=f"DigiKey search failed ({e.response.status_code})")
except Exception as e:
msg = str(e) or type(e).__name__
logger.warning("DigiKey search error for %s: %s", mpn, msg)
return ParamsFetchResult(mpn, error=f"DigiKey search error: {msg}")
product = _find_product(mpn, products)
if not product:
return ParamsFetchResult(mpn, error="No results found on DigiKey")
params = _parse_product_params(mpn, product)
if not params.parameters:
return ParamsFetchResult(mpn, error="No parameters available on DigiKey")
logger.info("Fetched %d params for %s (category: %s)", len(params.parameters), mpn, params.category)
return ParamsFetchResult(mpn, params=params)