Keep validate.py and the finding engine as-is. AGPL LICENSE stays at the repo root. Docker overlays dependency then src. Do not delete the inherited tree.
335 lines
12 KiB
Python
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
|