Files
periscope/backend/services/purple_parts.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

335 lines
12 KiB
Python

"""Purple Parts API client — LCSC code → MPN resolution.
Wraps the external `purple-parts` HTTP service (a read-only API over the
jlcparts/LCSC catalogue, deployed at the URL in `settings.purple_parts_url`).
Used by the BOM-parse stage to convert LCSC codes (e.g. "C12345") into
manufacturer part numbers before the DigiKey resolver runs.
The remote service is Cloud Run with IAM auth, so calls send a Google
identity token (audience = purple_parts_url) plus an X-API-Key header. In
Cloud Run the identity token is minted automatically via ADC + the
metadata server; locally `fetch_id_token` only works if
GOOGLE_APPLICATION_CREDENTIALS points at a service-account key file. On
local dev with user creds the helper logs a debug line and the call is
skipped (returns an empty result), which the caller treats as a no-op.
"""
from __future__ import annotations
import asyncio
import logging
import re
import time
from typing import Optional
import httpx
from backend.config import settings
logger = logging.getLogger(__name__)
_LCSC_RE = re.compile(r"^C\d+$", re.IGNORECASE)
# Identity tokens are valid for ~1h; refresh ~10 min early.
_TOKEN_TTL_SECONDS = 50 * 60
_token_cache: dict[str, float | str] = {"token": "", "expires_at": 0.0}
_token_lock = asyncio.Lock()
# Conservative batch size — purple-parts accepts up to 500 per request.
_BATCH_SIZE = 400
def is_lcsc_code(value: str | None) -> bool:
"""Return True if `value` looks like an LCSC part number (e.g. C12345)."""
if not value:
return False
return bool(_LCSC_RE.match(value.strip()))
async def _get_identity_token() -> str | None:
"""Mint a Google ID token for the purple-parts audience, cached.
Returns None when credentials don't support identity-token minting
(typical for local dev with `gcloud auth application-default login` user
creds). Caller should treat None as "skip the purple-parts call."
"""
now = time.time()
cached = _token_cache.get("token", "")
if cached and float(_token_cache.get("expires_at", 0.0)) > now:
return str(cached)
async with _token_lock:
cached = _token_cache.get("token", "")
if cached and float(_token_cache.get("expires_at", 0.0)) > now:
return str(cached)
try:
from google.auth.transport.requests import Request
from google.oauth2 import id_token as gid_token
except ImportError:
logger.warning("google-auth not installed; purple-parts disabled")
return None
loop = asyncio.get_running_loop()
try:
token = await loop.run_in_executor(
None,
lambda: gid_token.fetch_id_token(Request(), settings.purple_parts_url),
)
except Exception as e:
logger.debug(
"purple-parts: identity-token mint failed (%s: %s) — "
"expected for local user creds, skipping",
type(e).__name__, e,
)
return None
_token_cache["token"] = token
_token_cache["expires_at"] = now + _TOKEN_TTL_SECONDS
return token
def detect_lcsc_column(csv_bytes: bytes, mpn_col: str) -> bool:
"""Return True when every non-empty value in `mpn_col` matches `^C\\d+$`.
Used by the upload endpoint to auto-detect when the user's chosen MPN
column is actually an LCSC column (i.e. the user pasted LCSC ids into
the MPN slot, or labeled their LCSC column as "Manufacturer Part Number").
Column-level — a single non-LCSC entry disqualifies the column so that
BOMs mixing real MPNs with LCSC ids aren't silently mangled.
"""
import csv as csv_mod
import io
text = csv_bytes.decode("utf-8", errors="replace")
reader = csv_mod.DictReader(io.StringIO(text))
if not reader.fieldnames or mpn_col not in reader.fieldnames:
return False
seen_any = False
for row in reader:
val = (row.get(mpn_col) or "").strip()
if not val:
continue
if not is_lcsc_code(val):
return False
seen_any = True
return seen_any
async def resolve_lcsc_column_bytes(
csv_bytes: bytes,
*,
mpn_col: str = "Manufacturer Part Number",
) -> tuple[bytes, int, dict[str, str], dict[str, dict]]:
"""Replace every value in `mpn_col` with the manufacturer part number
resolved via purple-parts.
Returns `(new_csv_bytes, rows_updated, lcsc_to_mpn_map, lcsc_payloads_map)`.
The first map is keyed by LCSC id (e.g. "C12044") → resolved MPN string,
so the caller can surface "C12044 → STM32F103C8T6" in the UI. The second
map is keyed by the same LCSC id → the full purple-parts payload (mpn,
manufacturer, package, description, category, subcategory, ...) so the
caller can cache it on the project for the wizard's per-row resolve
endpoint. Preserves column order, headers, and untouched cells. No-op
when purple-parts isn't configured.
"""
import csv as csv_mod
import io
if not settings.use_purple_parts:
return csv_bytes, 0, {}, {}
text = csv_bytes.decode("utf-8", errors="replace")
reader = csv_mod.DictReader(io.StringIO(text))
fieldnames = reader.fieldnames or []
rows = list(reader)
if not rows or mpn_col not in fieldnames:
return csv_bytes, 0, {}, {}
todo: list[tuple[int, str]] = []
for i, row in enumerate(rows):
code = (row.get(mpn_col) or "").strip()
if is_lcsc_code(code):
todo.append((i, code))
if not todo:
return csv_bytes, 0, {}, {}
unique_codes = sorted({c for _, c in todo})
resolved = await lookup_lcsc_batch(unique_codes)
updated = 0
lcsc_to_mpn: dict[str, str] = {}
lcsc_payloads: dict[str, dict] = {}
for i, code in todo:
part = resolved.get(code)
if part and part.get("mpn"):
rows[i][mpn_col] = part["mpn"]
lcsc_to_mpn[code] = part["mpn"]
lcsc_payloads[code] = dict(part)
updated += 1
if updated == 0:
return csv_bytes, 0, {}, {}
out = io.StringIO()
writer = csv_mod.DictWriter(out, fieldnames=fieldnames, extrasaction="ignore")
writer.writeheader()
writer.writerows(rows)
return out.getvalue().encode("utf-8"), updated, lcsc_to_mpn, lcsc_payloads
async def lookup_lcsc_batch(lcsc_codes: list[str]) -> dict[str, Optional[dict]]:
"""Batch LCSC → MPN lookup.
Returns `{lcsc_code: part_dict_or_None}` for every code in input. Misses,
invalid codes, and (after warning) total failures all return None values
so the caller can treat the result as a uniform per-code map. The pipeline
never aborts on a purple-parts miss; the row simply stays unresolved and
the existing DigiKey/Haiku paths handle it.
Part dict shape: {lcsc, mpn, manufacturer, package, description, stock,
basic, preferred}.
"""
if not settings.use_purple_parts:
return {c: None for c in lcsc_codes}
codes = [c for c in (raw.strip() for raw in lcsc_codes) if c]
if not codes:
return {}
token = await _get_identity_token()
if token is None:
logger.info("purple-parts: no identity token, skipping batch of %d", len(codes))
return {c: None for c in codes}
base_url = settings.purple_parts_url.rstrip("/")
headers = {
"Authorization": f"Bearer {token}",
"X-API-Key": settings.purple_parts_api_key,
"Content-Type": "application/json",
}
results: dict[str, Optional[dict]] = {c: None for c in codes}
async with httpx.AsyncClient(timeout=15) as client:
for i in range(0, len(codes), _BATCH_SIZE):
chunk = codes[i:i + _BATCH_SIZE]
try:
resp = await client.post(
f"{base_url}/v1/parts/by-lcsc/batch",
headers=headers,
json={"ids": chunk},
)
resp.raise_for_status()
body = resp.json()
except httpx.HTTPStatusError as e:
logger.warning(
"purple-parts: batch call failed %s for chunk of %d",
e.response.status_code, len(chunk),
)
continue
except Exception as e:
logger.warning("purple-parts: batch call error: %s", e)
continue
for code, part in (body.get("results") or {}).items():
results[code] = part
return results
def _norm_mpn(value: str | None) -> str:
"""Normalize an MPN for comparison: drop whitespace, uppercase."""
return "".join((value or "").split()).upper()
def _pick_exact(query: str, candidates: list[dict]) -> Optional[dict]:
"""Return the candidate whose ``mpn`` exactly matches ``query``.
Match is case- and whitespace-insensitive. purple-parts' ``by-mpn``
endpoint returns exact matches first and then prefix matches, but we
re-check rather than trust ordering — a prefix-only hit (e.g. a series
family for a more specific MPN) must be treated as a miss so it can't
pollute the shared passive library. Mirrors the exact-MPN discipline of
``services.digikey._find_product``.
"""
q = _norm_mpn(query)
for part in candidates:
if part and _norm_mpn(part.get("mpn")) == q:
return part
return None
async def lookup_mpn_batch(mpns: list[str]) -> dict[str, Optional[dict]]:
"""Reverse lookup: manufacturer part number → LCSC catalogue record.
Fans the unique MPNs out to purple-parts' batch endpoint
(``POST /v1/parts/by-mpn/batch``) in chunks of ``_BATCH_SIZE`` — one indexed
query per chunk instead of a GET per MPN, which is what stalled huge-BOM
uploads when the by-mpn query was seq-scanning. Returns
``{mpn: part_dict_or_None}`` keyed by the *input* MPN string.
The endpoint is exact-match only, and we additionally run :func:`_pick_exact`
over each MPN's candidate list (case/whitespace-insensitive) to keep the
exact-MPN discipline — a prefix / family hit can carry the wrong
voltage / dielectric / package and must never reach the shared
``library/passives``. Misses, missing creds (no identity token), and per-chunk
failures all come back as ``None`` so the caller can treat the map uniformly.
No-op (all ``None``) when purple-parts isn't configured.
Part dict shape matches :func:`lookup_lcsc_batch`: {lcsc, mpn, manufacturer,
package, description, category, subcategory, stock, basic, preferred}.
"""
if not settings.use_purple_parts:
return {m: None for m in mpns}
# Preserve input keys but query each unique, non-empty MPN once.
names = list(dict.fromkeys(m.strip() for m in mpns if m and m.strip()))
if not names:
return {}
token = await _get_identity_token()
if token is None:
logger.info("purple-parts: no identity token, skipping by-mpn batch of %d", len(names))
return {m: None for m in names}
base_url = settings.purple_parts_url.rstrip("/")
headers = {
"Authorization": f"Bearer {token}",
"X-API-Key": settings.purple_parts_api_key,
"Content-Type": "application/json",
}
results: dict[str, Optional[dict]] = {m: None for m in names}
async with httpx.AsyncClient(timeout=15) as client:
for i in range(0, len(names), _BATCH_SIZE):
chunk = names[i:i + _BATCH_SIZE]
try:
resp = await client.post(
f"{base_url}/v1/parts/by-mpn/batch",
headers=headers,
json={"mpns": chunk},
)
resp.raise_for_status()
body = resp.json()
except httpx.HTTPStatusError as e:
logger.warning(
"purple-parts: by-mpn batch call failed %s for chunk of %d",
e.response.status_code, len(chunk),
)
continue
except Exception as e:
msg = str(e) or type(e).__name__
logger.warning("purple-parts: by-mpn batch call error: %s", msg)
continue
# Each MPN maps to a candidate list; keep only the exact match.
for mpn, candidates in (body.get("results") or {}).items():
results[mpn] = _pick_exact(mpn, candidates or [])
return results