ecadlib 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ecadlib/__init__.py +3 -0
- ecadlib/__main__.py +5 -0
- ecadlib/a365_sync.py +637 -0
- ecadlib/agent.py +1407 -0
- ecadlib/agent_queue.py +780 -0
- ecadlib/agent_watch.py +732 -0
- ecadlib/altium365.py +385 -0
- ecadlib/altium_dblib_sync.py +530 -0
- ecadlib/altium_export.py +209 -0
- ecadlib/altium_forms.py +768 -0
- ecadlib/altium_lib.py +1089 -0
- ecadlib/altium_menu.py +1751 -0
- ecadlib/altium_pkg.py +396 -0
- ecadlib/altium_project.py +1095 -0
- ecadlib/app.py +1458 -0
- ecadlib/approvals.py +395 -0
- ecadlib/assets/demo_bom.csv +8 -0
- ecadlib/assets/kicad_action_plugin.py +607 -0
- ecadlib/assets/kicad_reread.py +60 -0
- ecadlib/assets/pcblib_template.json.gz +0 -0
- ecadlib/bom_inputs.py +339 -0
- ecadlib/bom_triage.py +519 -0
- ecadlib/cad_model.py +528 -0
- ecadlib/cfb.py +171 -0
- ecadlib/cli.py +185 -0
- ecadlib/clihelp.py +149 -0
- ecadlib/cliutil.py +119 -0
- ecadlib/completion.py +173 -0
- ecadlib/costs.py +276 -0
- ecadlib/crosscheck.py +412 -0
- ecadlib/datasheet.py +2168 -0
- ecadlib/dedupe.py +572 -0
- ecadlib/detect.py +170 -0
- ecadlib/doctor.py +450 -0
- ecadlib/ds_route.py +541 -0
- ecadlib/eagle_export.py +1409 -0
- ecadlib/easy_setup.py +338 -0
- ecadlib/easyeda_convert.py +386 -0
- ecadlib/easyeda_pro_export.py +972 -0
- ecadlib/eval/__init__.py +0 -0
- ecadlib/eval/baselines/kicad-v1.json +404 -0
- ecadlib/eval/build_set.py +299 -0
- ecadlib/eval/cli.py +119 -0
- ecadlib/eval/gate.py +358 -0
- ecadlib/eval/golden/demo.txt +379 -0
- ecadlib/eval/golden/specs.txt +3120 -0
- ecadlib/eval/golden/testpack.txt +463 -0
- ecadlib/eval/golden.py +446 -0
- ecadlib/eval/replay/kicad-v1.json.gz +0 -0
- ecadlib/eval/runner.py +456 -0
- ecadlib/eval/score.py +179 -0
- ecadlib/eval/sets/kicad-v1.json +630 -0
- ecadlib/firstrun.py +400 -0
- ecadlib/footprint_adv.py +1006 -0
- ecadlib/footprint_bench.py +218 -0
- ecadlib/footprint_check.py +562 -0
- ecadlib/footprint_gen.py +582 -0
- ecadlib/footprint_rules.py +1092 -0
- ecadlib/forge.py +267 -0
- ecadlib/fp_share.py +95 -0
- ecadlib/fp_tidy.py +389 -0
- ecadlib/fsutil.py +62 -0
- ecadlib/house_style.py +1051 -0
- ecadlib/httpcache.py +124 -0
- ecadlib/hub.py +1490 -0
- ecadlib/hub_copy.py +161 -0
- ecadlib/hub_tools.py +79 -0
- ecadlib/kicad_dblib.py +257 -0
- ecadlib/kicad_export.py +837 -0
- ecadlib/kicad_import.py +913 -0
- ecadlib/kicad_index.py +160 -0
- ecadlib/kicad_menu.py +193 -0
- ecadlib/kicad_pcm.py +475 -0
- ecadlib/launcher.py +156 -0
- ecadlib/librarian.py +963 -0
- ecadlib/lifecycle.py +691 -0
- ecadlib/llm/__init__.py +276 -0
- ecadlib/llm/cli.py +70 -0
- ecadlib/llm/openai_compat.py +214 -0
- ecadlib/llm/profiles.py +245 -0
- ecadlib/llm/rails.py +570 -0
- ecadlib/llm/run.py +126 -0
- ecadlib/llm/sampling.py +49 -0
- ecadlib/mcp_server.py +723 -0
- ecadlib/messages.py +419 -0
- ecadlib/migrate.py +407 -0
- ecadlib/notify.py +329 -0
- ecadlib/package_schema.json +1238 -0
- ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
- ecadlib/package_specs/C_0402_1005Metric.json +22 -0
- ecadlib/package_specs/C_0805_2012Metric.json +22 -0
- ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
- ecadlib/package_specs/D_SMA.json +27 -0
- ecadlib/package_specs/D_SMB.json +27 -0
- ecadlib/package_specs/D_SOD-123.json +43 -0
- ecadlib/package_specs/D_SOD-323.json +43 -0
- ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
- ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
- ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
- ecadlib/package_specs/L_0805_2012Metric.json +22 -0
- ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
- ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
- ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
- ecadlib/package_specs/R_0402_1005Metric.json +22 -0
- ecadlib/package_specs/R_0603_1608Metric.json +22 -0
- ecadlib/package_specs/R_0805_2012Metric.json +22 -0
- ecadlib/package_specs/R_1206_3216Metric.json +22 -0
- ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
- ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
- ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
- ecadlib/package_specs/SOT-23-5.json +57 -0
- ecadlib/package_specs/SOT-23-6.json +62 -0
- ecadlib/package_specs/SOT-23.json +48 -0
- ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
- ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
- ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
- ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
- ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
- ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
- ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
- ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
- ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
- ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
- ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
- ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
- ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
- ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
- ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
- ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
- ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
- ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
- ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
- ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
- ecadlib/package_specs/standard.json.gz +0 -0
- ecadlib/parity.py +1204 -0
- ecadlib/parts_export.py +154 -0
- ecadlib/partview.py +215 -0
- ecadlib/plugins.py +231 -0
- ecadlib/providers.py +226 -0
- ecadlib/rebuild.py +471 -0
- ecadlib/release_check.py +437 -0
- ecadlib/reports.py +274 -0
- ecadlib/resolve.py +556 -0
- ecadlib/review_cli.py +292 -0
- ecadlib/rules.py +979 -0
- ecadlib/rules_infer.py +1205 -0
- ecadlib/sexpr.py +149 -0
- ecadlib/sheetsafe.py +65 -0
- ecadlib/sim_models.py +1087 -0
- ecadlib/sourcing.py +1161 -0
- ecadlib/stdparts.py +755 -0
- ecadlib/step_gen.py +929 -0
- ecadlib/symbol_layout.py +798 -0
- ecadlib/team.py +558 -0
- ecadlib/team_setup.py +288 -0
- ecadlib/term.py +420 -0
- ecadlib/term_views.py +64 -0
- ecadlib/testpack.py +334 -0
- ecadlib/ui/__init__.py +1 -0
- ecadlib/ui/__main__.py +3 -0
- ecadlib/ui/server.py +1029 -0
- ecadlib/ui/static/hairline.js +2 -0
- ecadlib/ui/static/index.html +2098 -0
- ecadlib/ui/static/setup.html +294 -0
- ecadlib/upkeep.py +717 -0
- ecadlib/upstream.py +573 -0
- ecadlib/userconfig.py +257 -0
- ecadlib/visual_check.py +1603 -0
- ecadlib/ws_status.py +203 -0
- ecadlib-0.3.0.dist-info/METADATA +2245 -0
- ecadlib-0.3.0.dist-info/RECORD +199 -0
- ecadlib-0.3.0.dist-info/WHEEL +5 -0
- ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
- ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
- ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
- ecadlib-0.3.0.dist-info/top_level.txt +1 -0
ecadlib/lifecycle.py
ADDED
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
"""Lifecycle watcher: keep the library current.
|
|
2
|
+
|
|
3
|
+
Checks every part against lifecycle sources, maps each source's wording to one status, diffs the result
|
|
4
|
+
against the last snapshot and reports what changed (.md + .csv). Exit 1 when any part got worse, for CI.
|
|
5
|
+
|
|
6
|
+
python -m ecadlib.lifecycle examples/sample_bom.csv out/altium/parts.sqlite out/altium365/plan.json \
|
|
7
|
+
[--a365 WORKSPACE_URL] [--providers nexar,digikey,mouser,jlcpcb_search] \
|
|
8
|
+
[--state out/lifecycle_state.json] [-o out/lifecycle_report] [--update-a365 [--apply]]
|
|
9
|
+
|
|
10
|
+
Inputs (auto-detected): a BOM (.csv/.xlsx, via bom_triage.read_bom), the DbLib parts.sqlite (altium_export),
|
|
11
|
+
an Altium 365 plan.json (altium365 plan), and with --a365 the managed components of a workspace (Nexar desLibrary).
|
|
12
|
+
|
|
13
|
+
Statuses by severity: active < nrnd < ltb < obsolete (EOL counts as obsolete); unknown is unranked.
|
|
14
|
+
A part got worse when its status rose in severity or its stock fell to zero.
|
|
15
|
+
Exit codes: 0 nothing got worse, 1 a part got worse (--strict: also a new risky part or one that became
|
|
16
|
+
unknown), 2 bad input or a failed Altium 365 update, 3 no source answered for any part (secrets / network).
|
|
17
|
+
|
|
18
|
+
Response shapes: each provider's `shapes` says what is confirmed from public docs and what is assumed.
|
|
19
|
+
None of these hosts was reachable from the sandbox this was written in; parsers are tested against mocks."""
|
|
20
|
+
import argparse, csv, json, os, re, sqlite3, sys
|
|
21
|
+
from contextlib import closing
|
|
22
|
+
from dataclasses import asdict, dataclass
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from urllib.parse import quote, urlencode
|
|
26
|
+
|
|
27
|
+
from . import providers as P
|
|
28
|
+
from . import sheetsafe as SS
|
|
29
|
+
from .altium365 import M_UPDATE_PARAMS, Nexar as DesignClient
|
|
30
|
+
from .httpcache import CachedHTTP
|
|
31
|
+
from .kicad_index import norm_mpn
|
|
32
|
+
|
|
33
|
+
SEVERITY = {"active": 0, "nrnd": 1, "ltb": 2, "obsolete": 3}
|
|
34
|
+
LABEL = {"active": "Active", "nrnd": "NRND", "ltb": "Last Time Buy", "obsolete": "Obsolete", "unknown": "Unknown"}
|
|
35
|
+
WORDING = [ # first match wins; order matters ('Discontinued at Digi-Key' is nrnd, plain 'discontinued' obsolete)
|
|
36
|
+
(r"last time buy|\bltb\b|final buy", "ltb"),
|
|
37
|
+
(r"\bnrnd\b|not (recommended )?for new design|discontinued at digi[ -]?key", "nrnd"),
|
|
38
|
+
(r"obsolete|\beol\b|end of life|discontinued|no longer (manufactured|available)|inactive", "obsolete"),
|
|
39
|
+
(r"^(active|production|in production|new|new product|preliminary|released)\b", "active"),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def normalise(text):
|
|
44
|
+
t = re.sub(r"\s+", " ", str(text or "")).strip().lower()
|
|
45
|
+
return next((st for rx, st in WORDING if re.search(rx, t)), "unknown")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _int(v):
|
|
49
|
+
m = re.search(r"\d[\d,]*", str(v)) if v is not None else None
|
|
50
|
+
return int(m.group(0).replace(",", "")) if m else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _now():
|
|
54
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class LifecycleResult:
|
|
59
|
+
provider: str
|
|
60
|
+
found: bool | None = None # None = could not determine
|
|
61
|
+
status: str = "unknown"
|
|
62
|
+
raw: str = "" # the provider's own wording
|
|
63
|
+
stock: int | None = None
|
|
64
|
+
mpn: str = ""
|
|
65
|
+
manufacturer: str = ""
|
|
66
|
+
url: str = ""
|
|
67
|
+
note: str = ""
|
|
68
|
+
error: str = ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _pick(items, part, mpn_of, mfr_of):
|
|
72
|
+
"""Exact (normalised) MPN match; prefer one whose manufacturer agrees with the part's."""
|
|
73
|
+
exact = [i for i in items if isinstance(i, dict) and norm_mpn(mpn_of(i)) == norm_mpn(part["mpn"])]
|
|
74
|
+
want = norm_mpn(part.get("manufacturer"))[:6]
|
|
75
|
+
return next((i for i in exact if want and want in norm_mpn(mfr_of(i))), exact[0] if exact else None)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _http_error(res, r):
|
|
79
|
+
res.error = re.sub(r"(?i)(api_?key=)[^&\s'\")]*", r"\1***", r.error or f"HTTP {r.status}") # may be cut mid-key
|
|
80
|
+
return res
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------ Nexar Supply (Octopart)
|
|
84
|
+
class NexarSupply(P.Nexar):
|
|
85
|
+
shapes = ("CONFIRMED: token POST identity.nexar.com/connect/token, client_credentials, scope supply.domain "
|
|
86
|
+
"(support.nexar.com 'Authorization'); query fields validated against tests/nexar/schema.graphql; "
|
|
87
|
+
"specs[].attribute.shortname 'lifecyclestatus' with values Production/New/NRND/EOL/Obsolete "
|
|
88
|
+
"(support.nexar.com 'Supply: Lifecycle information for parts'; needs a plan with Tech Specs + Lifecycle). "
|
|
89
|
+
"ASSUMED: displayValue holds that wording. The schema has no median-stock field: totalAvail is used.")
|
|
90
|
+
TOKEN_URL = os.environ.get("NEXAR_TOKEN_URL", "https://identity.nexar.com/connect/token")
|
|
91
|
+
API_URL = os.environ.get("NEXAR_API_URL", "https://api.nexar.com/graphql")
|
|
92
|
+
QUERY = """query ($q: String!, $limit: Int) {
|
|
93
|
+
supSearchMpn(q: $q, limit: $limit) {
|
|
94
|
+
hits
|
|
95
|
+
results { part { mpn manufacturer { name } octopartUrl totalAvail avgAvail
|
|
96
|
+
specs { attribute { shortname } displayValue value } } }
|
|
97
|
+
}
|
|
98
|
+
}"""
|
|
99
|
+
_token = _token_err = None
|
|
100
|
+
|
|
101
|
+
def configured(self):
|
|
102
|
+
return bool(os.environ.get("NEXAR_SUPPLY_TOKEN")) or super().configured()
|
|
103
|
+
|
|
104
|
+
def token(self):
|
|
105
|
+
if self._token or self._token_err:
|
|
106
|
+
return self._token
|
|
107
|
+
tok = os.environ.get("NEXAR_SUPPLY_TOKEN")
|
|
108
|
+
if not tok:
|
|
109
|
+
body = urlencode({"grant_type": "client_credentials", "client_id": os.environ["NEXAR_CLIENT_ID"],
|
|
110
|
+
"client_secret": os.environ["NEXAR_CLIENT_SECRET"], "scope": "supply.domain"})
|
|
111
|
+
r = self.http.post(self.TOKEN_URL, body, cache=False,
|
|
112
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"})
|
|
113
|
+
try:
|
|
114
|
+
tok = r.json().get("access_token") if r.ok else None
|
|
115
|
+
except ValueError:
|
|
116
|
+
tok = None
|
|
117
|
+
if not tok:
|
|
118
|
+
self._token_err = f"token request failed: {r.error or f'HTTP {r.status}'}"
|
|
119
|
+
self._token = tok
|
|
120
|
+
return tok
|
|
121
|
+
|
|
122
|
+
def check(self, part):
|
|
123
|
+
if not self.configured():
|
|
124
|
+
raise P.NotConfigured(f"{self.name}: set NEXAR_SUPPLY_TOKEN or {', '.join(self.env)} ({self.auth})")
|
|
125
|
+
res = LifecycleResult(self.name, mpn=part["mpn"])
|
|
126
|
+
if not part["mpn"]:
|
|
127
|
+
res.error = "no MPN"
|
|
128
|
+
return res
|
|
129
|
+
tok = self.token()
|
|
130
|
+
if not tok:
|
|
131
|
+
res.error = self._token_err
|
|
132
|
+
return res
|
|
133
|
+
body = json.dumps({"query": self.QUERY, "variables": {"q": part["mpn"], "limit": 5}})
|
|
134
|
+
r = self.http.post(self.API_URL, body, headers={"Authorization": f"Bearer {tok}", "Content-Type": "application/json"})
|
|
135
|
+
if not r.ok:
|
|
136
|
+
return _http_error(res, r)
|
|
137
|
+
try:
|
|
138
|
+
return parse_nexar(r.json(), part, res)
|
|
139
|
+
except (ValueError, AttributeError, TypeError) as e:
|
|
140
|
+
res.error = f"unparseable response: {e}"
|
|
141
|
+
return res
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def parse_nexar(data, part, res):
|
|
145
|
+
if data.get("errors"):
|
|
146
|
+
res.error = "; ".join(str(e.get("message", "?")) for e in data["errors"] if isinstance(e, dict))
|
|
147
|
+
return res
|
|
148
|
+
results = ((data.get("data") or {}).get("supSearchMpn") or {}).get("results") or []
|
|
149
|
+
best = _pick([x.get("part") for x in results if isinstance(x, dict)], part,
|
|
150
|
+
lambda p: p.get("mpn"), lambda p: (p.get("manufacturer") or {}).get("name"))
|
|
151
|
+
res.found = bool(best)
|
|
152
|
+
if best:
|
|
153
|
+
spec = next((s for s in best.get("specs") or [] if (s.get("attribute") or {}).get("shortname") == "lifecyclestatus"), {})
|
|
154
|
+
res.raw = spec.get("displayValue") or spec.get("value") or ""
|
|
155
|
+
res.status = normalise(res.raw)
|
|
156
|
+
res.stock = _int(best.get("totalAvail"))
|
|
157
|
+
res.manufacturer, res.url = (best.get("manufacturer") or {}).get("name", ""), best.get("octopartUrl", "")
|
|
158
|
+
if not spec:
|
|
159
|
+
res.note = "no lifecyclestatus spec (plan without Tech Specs/Lifecycle?)"
|
|
160
|
+
return res
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ------------------------------------------------------------------ DigiKey Product Information v4
|
|
164
|
+
class DigiKeyLifecycle(P.DigiKey):
|
|
165
|
+
shapes = ("CONFIRMED (DigiKey v4 OpenAPI): GET /products/v4/search/{productNumber}/productdetails, header "
|
|
166
|
+
"X-DIGIKEY-Client-Id; Product.ManufacturerProductNumber, Product.ProductStatus ($ref ProductStatusV4), "
|
|
167
|
+
"Product.QuantityAvailable, Product.Discontinued, Product.EndOfLife. ASSUMED: response wrapper "
|
|
168
|
+
"{Product: ...}, ProductStatusV4 = {Id, Status} with wording like 'Active', 'Not For New Designs', "
|
|
169
|
+
"'Last Time Buy', 'Obsolete', 'Discontinued at Digi-Key'; token URL api.digikey.com/v1/oauth2/token.")
|
|
170
|
+
TOKEN_URL = "https://api.digikey.com/v1/oauth2/token"
|
|
171
|
+
URL = "https://api.digikey.com/products/v4/search/{}/productdetails"
|
|
172
|
+
_token = _token_err = None
|
|
173
|
+
|
|
174
|
+
def token(self):
|
|
175
|
+
if not (self._token or self._token_err):
|
|
176
|
+
body = urlencode({"client_id": os.environ["DIGIKEY_CLIENT_ID"], "client_secret": os.environ["DIGIKEY_CLIENT_SECRET"],
|
|
177
|
+
"grant_type": "client_credentials"})
|
|
178
|
+
r = self.http.post(self.TOKEN_URL, body, cache=False, headers={"Content-Type": "application/x-www-form-urlencoded"})
|
|
179
|
+
try:
|
|
180
|
+
self._token = r.json().get("access_token") if r.ok else None
|
|
181
|
+
except ValueError:
|
|
182
|
+
pass
|
|
183
|
+
if not self._token:
|
|
184
|
+
self._token_err = f"token request failed: {r.error or f'HTTP {r.status}'}"
|
|
185
|
+
return self._token
|
|
186
|
+
|
|
187
|
+
def check(self, part):
|
|
188
|
+
if not self.configured():
|
|
189
|
+
raise P.NotConfigured(f"{self.name}: set {', '.join(self.env)} ({self.auth})")
|
|
190
|
+
res = LifecycleResult(self.name, mpn=part["mpn"])
|
|
191
|
+
if not part["mpn"]:
|
|
192
|
+
res.error = "no MPN"
|
|
193
|
+
return res
|
|
194
|
+
if not self.token():
|
|
195
|
+
res.error = self._token_err
|
|
196
|
+
return res
|
|
197
|
+
r = self.http.get(self.URL.format(quote(part["mpn"], safe="")), headers={
|
|
198
|
+
"X-DIGIKEY-Client-Id": os.environ["DIGIKEY_CLIENT_ID"], "Authorization": f"Bearer {self._token}",
|
|
199
|
+
"Accept": "application/json"})
|
|
200
|
+
if r.status == 404:
|
|
201
|
+
res.found = False
|
|
202
|
+
return res
|
|
203
|
+
if not r.ok:
|
|
204
|
+
return _http_error(res, r)
|
|
205
|
+
try:
|
|
206
|
+
return parse_digikey(r.json(), part, res)
|
|
207
|
+
except (ValueError, AttributeError, TypeError) as e:
|
|
208
|
+
res.error = f"unparseable response: {e}"
|
|
209
|
+
return res
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def parse_digikey(data, part, res):
|
|
213
|
+
p = data.get("Product") if isinstance(data.get("Product"), dict) else data
|
|
214
|
+
if norm_mpn(p.get("ManufacturerProductNumber")) != norm_mpn(part["mpn"]):
|
|
215
|
+
res.found, res.note = False, f"best match {p.get('ManufacturerProductNumber')!r} is not this MPN"
|
|
216
|
+
return res
|
|
217
|
+
res.found = True
|
|
218
|
+
words = [(p.get("ProductStatus") or {}).get("Status") or ""]
|
|
219
|
+
if p.get("EndOfLife"):
|
|
220
|
+
words.append("End of Life")
|
|
221
|
+
if p.get("Discontinued"):
|
|
222
|
+
words.append("Discontinued at Digi-Key")
|
|
223
|
+
words = [w for w in words if w]
|
|
224
|
+
res.raw = "; ".join(words)
|
|
225
|
+
known = [s for s in map(normalise, words) if s != "unknown"]
|
|
226
|
+
res.status = max(known, key=SEVERITY.get) if known else "unknown"
|
|
227
|
+
res.stock = _int(p.get("QuantityAvailable"))
|
|
228
|
+
res.manufacturer = (p.get("Manufacturer") or {}).get("Name", "") if isinstance(p.get("Manufacturer"), dict) else ""
|
|
229
|
+
res.url = p.get("ProductUrl") or ""
|
|
230
|
+
return res
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ------------------------------------------------------------------ Mouser Search API v1
|
|
234
|
+
class MouserLifecycle(P.Mouser):
|
|
235
|
+
shapes = ("CONFIRMED (Mouser swagger api.mouser.com/api/docs/V1): POST /api/v1/search/partnumber?apiKey=, body "
|
|
236
|
+
"{SearchByPartRequest: {mouserPartNumber, partSearchOptions}}; response {Errors: [], SearchResults: "
|
|
237
|
+
"{NumberOfResult, Parts: [{ManufacturerPartNumber, Manufacturer, LifecycleStatus, Availability, "
|
|
238
|
+
"AvailabilityInStock, ProductDetailUrl}]}}, all strings. ASSUMED: partSearchOptions 'Exact'; "
|
|
239
|
+
"LifecycleStatus wording ('New Product', 'Not Recommended for New Designs', 'End of Life', 'Obsolete') "
|
|
240
|
+
"and that it is empty for normal production parts (read as active).")
|
|
241
|
+
URL = "https://api.mouser.com/api/v1/search/partnumber?apiKey={}"
|
|
242
|
+
|
|
243
|
+
def check(self, part):
|
|
244
|
+
if not self.configured():
|
|
245
|
+
raise P.NotConfigured(f"{self.name}: set {', '.join(self.env)} ({self.auth})")
|
|
246
|
+
res = LifecycleResult(self.name, mpn=part["mpn"])
|
|
247
|
+
if not part["mpn"]:
|
|
248
|
+
res.error = "no MPN"
|
|
249
|
+
return res
|
|
250
|
+
key = os.environ["MOUSER_API_KEY"]
|
|
251
|
+
body = json.dumps({"SearchByPartRequest": {"mouserPartNumber": part["mpn"], "partSearchOptions": "Exact"}})
|
|
252
|
+
r = self.http.post(self.URL.format(quote(key, safe="")), body, cache=False, # key is in the URL: never on disk
|
|
253
|
+
headers={"Content-Type": "application/json", "Accept": "application/json"})
|
|
254
|
+
if not r.ok:
|
|
255
|
+
return _http_error(res, r)
|
|
256
|
+
try:
|
|
257
|
+
return parse_mouser(r.json(), part, res)
|
|
258
|
+
except (ValueError, AttributeError, TypeError) as e:
|
|
259
|
+
res.error = f"unparseable response: {e}"
|
|
260
|
+
return res
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def parse_mouser(data, part, res):
|
|
264
|
+
errs = [e for e in data.get("Errors") or [] if isinstance(e, dict)]
|
|
265
|
+
if errs:
|
|
266
|
+
res.error = "; ".join(str(e.get("Message") or e.get("Code") or e) for e in errs)
|
|
267
|
+
return res
|
|
268
|
+
best = _pick((data.get("SearchResults") or {}).get("Parts") or [], part,
|
|
269
|
+
lambda p: p.get("ManufacturerPartNumber"), lambda p: p.get("Manufacturer"))
|
|
270
|
+
res.found = bool(best)
|
|
271
|
+
if best:
|
|
272
|
+
res.raw = best.get("LifecycleStatus") or ""
|
|
273
|
+
res.status = normalise(res.raw) if res.raw else "active"
|
|
274
|
+
if not res.raw:
|
|
275
|
+
res.note = "no LifecycleStatus: read as active"
|
|
276
|
+
res.stock = _int(best.get("AvailabilityInStock"))
|
|
277
|
+
if res.stock is None and re.search(r"in stock", str(best.get("Availability")), re.I):
|
|
278
|
+
res.stock = _int(best.get("Availability"))
|
|
279
|
+
res.manufacturer, res.url = best.get("Manufacturer") or "", best.get("ProductDetailUrl") or ""
|
|
280
|
+
return res
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ------------------------------------------------------------------ JLCPCB / LCSC (stock only)
|
|
284
|
+
class JLCLifecycle(P.JLCPCBSearch):
|
|
285
|
+
shapes = ("ASSUMED (undocumented; field paths from easyeda2kicad 1.0.1, as in providers.JLCPCBSearch): "
|
|
286
|
+
"data.componentPageInfo.list[] with componentCode, componentModelEn, componentBrandEn, stockCount. "
|
|
287
|
+
"No lifecycle field is known, so this source reports stock only; a search by LCSC code is assumed to work.")
|
|
288
|
+
|
|
289
|
+
def check(self, part):
|
|
290
|
+
lcsc, mpn = (part.get("lcsc") or "").upper(), part["mpn"]
|
|
291
|
+
res = LifecycleResult(self.name, mpn=mpn)
|
|
292
|
+
if not (lcsc or mpn):
|
|
293
|
+
res.error = "no MPN or LCSC number"
|
|
294
|
+
return res
|
|
295
|
+
r = self.post(lcsc or mpn)
|
|
296
|
+
if not r.ok:
|
|
297
|
+
return _http_error(res, r)
|
|
298
|
+
try:
|
|
299
|
+
return parse_jlc(r.json(), part, res)
|
|
300
|
+
except (ValueError, AttributeError, TypeError) as e:
|
|
301
|
+
res.error = f"unparseable response: {e}"
|
|
302
|
+
return res
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def parse_jlc(data, part, res):
|
|
306
|
+
items = [i for i in (((data.get("data") or {}).get("componentPageInfo") or {}).get("list") or []) if isinstance(i, dict)]
|
|
307
|
+
lcsc = (part.get("lcsc") or "").upper()
|
|
308
|
+
best = (next((i for i in items if str(i.get("componentCode", "")).upper() == lcsc), None) if lcsc
|
|
309
|
+
else _pick(items, part, lambda i: i.get("componentModelEn"), lambda i: i.get("componentBrandEn")))
|
|
310
|
+
res.found = bool(best)
|
|
311
|
+
if best:
|
|
312
|
+
res.stock = _int(best.get("stockCount"))
|
|
313
|
+
res.manufacturer = best.get("componentBrandEn") or ""
|
|
314
|
+
res.url = f"https://jlcpcb.com/partdetail/{best.get('componentCode', '')}"
|
|
315
|
+
res.note = f"LCSC {best.get('componentCode', '')}"
|
|
316
|
+
elif lcsc:
|
|
317
|
+
res.note = f"{lcsc} not listed"
|
|
318
|
+
return res
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
PROVIDERS = [NexarSupply, DigiKeyLifecycle, MouserLifecycle, JLCLifecycle]
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# ------------------------------------------------------------------ part lists
|
|
325
|
+
def _part(mpn, manufacturer="", lcsc="", name="", source="", component_id="", a365_value=None):
|
|
326
|
+
return {"mpn": (mpn or "").strip(), "manufacturer": (manufacturer or "").strip(), "lcsc": (lcsc or "").strip().upper(),
|
|
327
|
+
"name": name or "", "sources": [source] if source else [], "component_id": component_id,
|
|
328
|
+
"a365_value": a365_value}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def key_of(p):
|
|
332
|
+
return norm_mpn(p["mpn"]) if p["mpn"] else f"LCSC:{p['lcsc']}" if p["lcsc"] else ""
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _from_params(prm, name, source, **kw):
|
|
336
|
+
lcsc = prm.get("Supplier Part Number 1", "") if prm.get("Supplier 1", "").upper() in ("LCSC", "JLCPCB") else ""
|
|
337
|
+
return _part(prm.get("Manufacturer Part Number", ""), prm.get("Manufacturer", ""), lcsc, name, source, **kw)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def parts_from_bom(path, columns=None):
|
|
341
|
+
from .altium_export import to_rows
|
|
342
|
+
from .bom_triage import part_numbers, read_bom
|
|
343
|
+
lines = []
|
|
344
|
+
for ln in read_bom(path, columns)[0]:
|
|
345
|
+
if ln.get("dnp"):
|
|
346
|
+
continue
|
|
347
|
+
pns = part_numbers(ln)
|
|
348
|
+
lines.append(dict(ln, mpn=pns[0] if pns else "")) # Altium Comment/LibRef part numbers count too
|
|
349
|
+
return [_from_params(r, r["Part Number"], f"bom:{Path(path).name}")
|
|
350
|
+
for df in to_rows(lines).values() for r in df.to_dict("records")]
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def parts_from_dblib(path):
|
|
354
|
+
out = []
|
|
355
|
+
from urllib.parse import quote # a folder named "Lib #2" or "a?b" must not end the URI path
|
|
356
|
+
with closing(sqlite3.connect(f"file:{quote(Path(path).resolve().as_posix(), safe='/:')}?mode=ro", uri=True)) as con:
|
|
357
|
+
con.row_factory = sqlite3.Row
|
|
358
|
+
for (t,) in con.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall():
|
|
359
|
+
for r in con.execute(f'SELECT * FROM "{t}"'):
|
|
360
|
+
r = {k: str(r[k] or "") for k in r.keys()}
|
|
361
|
+
out.append(_from_params(r, r.get("Part Number", ""), f"dblib:{t}"))
|
|
362
|
+
return out
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def parts_from_plan(path):
|
|
366
|
+
return [_from_params({p["name"]: p["value"] for p in it.get("parameters", [])}, it["name"], "plan")
|
|
367
|
+
for it in json.loads(Path(path).read_text(encoding="utf-8"))]
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
Q_LIBRARY = """query ($ws: String!, $after: String) {
|
|
371
|
+
desLibrary(workspaceUrl: $ws) {
|
|
372
|
+
components(first: 100, after: $after) {
|
|
373
|
+
pageInfo { hasNextPage endCursor }
|
|
374
|
+
nodes { id name isManaged manufacturerParts { companyName partNumber } details { parameters { name value } } }
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}"""
|
|
378
|
+
|
|
379
|
+
Q_FIND_PARAMS = """query ($ws: String!, $name: String!) {
|
|
380
|
+
desLibrary(workspaceUrl: $ws) {
|
|
381
|
+
components(first: 1, where: {name: {eq: $name}}) { nodes { id name isManaged details { parameters { name value } } } }
|
|
382
|
+
}
|
|
383
|
+
}"""
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _node_params(node):
|
|
387
|
+
return {p["name"]: p["value"] for p in ((node.get("details") or {}).get("parameters") or [])}
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def parts_from_a365(client, ws, param="Lifecycle"):
|
|
391
|
+
"""Managed (released) components of a workspace library; MPN from parameters, else the first part choice."""
|
|
392
|
+
out, after = [], None
|
|
393
|
+
while True:
|
|
394
|
+
conn = client.gql(Q_LIBRARY, {"ws": ws, "after": after})["desLibrary"]["components"] or {}
|
|
395
|
+
for n in conn.get("nodes") or []:
|
|
396
|
+
if not n.get("isManaged"):
|
|
397
|
+
continue
|
|
398
|
+
prm, mp = _node_params(n), (n.get("manufacturerParts") or [{}])[0] or {}
|
|
399
|
+
prm.setdefault("Manufacturer Part Number", mp.get("partNumber", ""))
|
|
400
|
+
prm.setdefault("Manufacturer", mp.get("companyName", ""))
|
|
401
|
+
out.append(_from_params(prm, n["name"], "a365", component_id=n["id"], a365_value=prm.get(param)))
|
|
402
|
+
page = conn.get("pageInfo") or {}
|
|
403
|
+
if not page.get("hasNextPage"):
|
|
404
|
+
return out
|
|
405
|
+
after = page.get("endCursor")
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def read_inputs(paths, columns=None):
|
|
409
|
+
"""columns: the rules' [bom] columns ({field: your header}) for BOM inputs."""
|
|
410
|
+
parts = []
|
|
411
|
+
for p in paths:
|
|
412
|
+
ext = Path(p).suffix.lower()
|
|
413
|
+
parts += (parts_from_dblib(p) if ext in (".sqlite", ".db", ".sqlite3") else
|
|
414
|
+
parts_from_plan(p) if ext == ".json" else parts_from_bom(p, columns))
|
|
415
|
+
return parts
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def merge(parts):
|
|
419
|
+
"""One entry per MPN (or LCSC number); fields from later inputs only fill gaps."""
|
|
420
|
+
out, skipped = {}, []
|
|
421
|
+
for p in parts:
|
|
422
|
+
k = key_of(p)
|
|
423
|
+
if not k:
|
|
424
|
+
skipped.append(p["name"])
|
|
425
|
+
continue
|
|
426
|
+
cur = out.setdefault(k, p)
|
|
427
|
+
if cur is not p:
|
|
428
|
+
for f in ("manufacturer", "lcsc", "name", "component_id", "a365_value"):
|
|
429
|
+
cur[f] = cur[f] or p[f]
|
|
430
|
+
cur["sources"] = list(dict.fromkeys(cur["sources"] + p["sources"]))
|
|
431
|
+
return out, skipped
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# ------------------------------------------------------------------ checking, state, diff
|
|
435
|
+
def check_parts(parts, provs):
|
|
436
|
+
unconfigured, out = set(), {}
|
|
437
|
+
for k, p in parts.items():
|
|
438
|
+
results = []
|
|
439
|
+
for prov in provs:
|
|
440
|
+
try:
|
|
441
|
+
results.append(prov.check(p))
|
|
442
|
+
except P.NotConfigured:
|
|
443
|
+
unconfigured.add(prov.name)
|
|
444
|
+
out[k] = combine(p, results)
|
|
445
|
+
return out, sorted(unconfigured)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def combine(part, results):
|
|
449
|
+
"""Worst known status across sources (most conservative); highest stock any source reports."""
|
|
450
|
+
known = [r for r in results if r.status != "unknown"]
|
|
451
|
+
stocks = [r.stock for r in results if r.stock is not None]
|
|
452
|
+
return {**{f: part[f] for f in ("mpn", "manufacturer", "lcsc", "name", "sources", "component_id", "a365_value")},
|
|
453
|
+
"status": max(known, key=lambda r: SEVERITY[r.status]).status if known else "unknown",
|
|
454
|
+
"stock": max(stocks) if stocks else None,
|
|
455
|
+
"raw": "; ".join(f"{r.provider}: {r.raw or '-'}" + (f" (stock {r.stock})" if r.stock is not None else "")
|
|
456
|
+
for r in results if r.found),
|
|
457
|
+
"notes": "; ".join(f"{r.provider}: {r.error or r.note or 'not found'}" for r in results
|
|
458
|
+
if r.error or r.note or r.found is False),
|
|
459
|
+
"answered": any(r.found is not None and not r.error for r in results),
|
|
460
|
+
"results": [asdict(r) for r in results]}
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def classify(prev, cur):
|
|
464
|
+
"""new | unchanged | worse | better | unknown, comparing with the last *known* values in the snapshot."""
|
|
465
|
+
if prev is None:
|
|
466
|
+
return "new"
|
|
467
|
+
ps, pk, cs, ck = prev.get("status", "unknown"), prev.get("stock"), cur["status"], cur["stock"]
|
|
468
|
+
if cs == "unknown" and ck is None:
|
|
469
|
+
return "unknown" if ps != "unknown" or pk is not None else "unchanged"
|
|
470
|
+
worse = better = False
|
|
471
|
+
if cs != "unknown":
|
|
472
|
+
d = SEVERITY[cs] - SEVERITY.get(ps, 0) # never-known counts as active
|
|
473
|
+
worse, better = d > 0, d < 0 and ps != "unknown"
|
|
474
|
+
if ck is not None and pk is not None:
|
|
475
|
+
worse |= pk > 0 and ck == 0
|
|
476
|
+
better |= pk == 0 and ck > 0
|
|
477
|
+
return "worse" if worse else "better" if better else "unchanged"
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def risky(cur):
|
|
481
|
+
return SEVERITY.get(cur["status"], 0) > 0 or cur["stock"] == 0
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def load_state(path):
|
|
485
|
+
try:
|
|
486
|
+
return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
487
|
+
except FileNotFoundError:
|
|
488
|
+
return {"version": 1, "updated": None, "parts": {}}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def diff(state, current):
|
|
492
|
+
rows = []
|
|
493
|
+
for k, cur in current.items():
|
|
494
|
+
prev = state["parts"].get(k)
|
|
495
|
+
rows.append({"key": k, "change": classify(prev, cur), "prev_status": (prev or {}).get("status", ""),
|
|
496
|
+
"prev_stock": (prev or {}).get("stock"), "prev_checked": (prev or {}).get("checked", ""), **cur})
|
|
497
|
+
order = {"worse": 0, "unknown": 1, "new": 2, "better": 3, "unchanged": 4}
|
|
498
|
+
return sorted(rows, key=lambda r: (order[r["change"]], -SEVERITY.get(r["status"], -1), r["mpn"]))
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def next_state(state, current, now):
|
|
502
|
+
"""An unknown observation never overwrites a known status or stock: diffs compare against what was last seen."""
|
|
503
|
+
parts = dict(state["parts"])
|
|
504
|
+
for k, cur in current.items():
|
|
505
|
+
prev = parts.get(k) or {}
|
|
506
|
+
known = cur["status"] != "unknown"
|
|
507
|
+
st = cur["status"] if known else prev.get("status", "unknown")
|
|
508
|
+
parts[k] = {"mpn": cur["mpn"], "manufacturer": cur["manufacturer"], "name": cur["name"], "status": st,
|
|
509
|
+
"raw": cur["raw"] if known else prev.get("raw", ""),
|
|
510
|
+
"stock": cur["stock"] if cur["stock"] is not None else prev.get("stock"),
|
|
511
|
+
"since": prev.get("since") if prev.get("status") == st and prev.get("since") else now,
|
|
512
|
+
"seen": now if known else prev.get("seen"), "checked": now}
|
|
513
|
+
return {"version": 1, "updated": now, "parts": parts}
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def save_state(state, path):
|
|
517
|
+
p = Path(path)
|
|
518
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
519
|
+
tmp = p.with_suffix(".tmp")
|
|
520
|
+
tmp.write_text(json.dumps(state, indent=1, sort_keys=True), encoding="utf-8")
|
|
521
|
+
os.replace(tmp, p)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def exit_code(rows, strict=False):
|
|
525
|
+
bad = [r for r in rows if r["change"] == "worse"]
|
|
526
|
+
if strict:
|
|
527
|
+
bad += [r for r in rows if r["change"] == "unknown" or (r["change"] == "new" and risky(r))]
|
|
528
|
+
return 1 if bad else 0
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
# ------------------------------------------------------------------ Altium 365 write-back
|
|
532
|
+
def a365_updates(rows, client, ws, apply=False, param="Lifecycle", note="ecadlib lifecycle watch"):
|
|
533
|
+
"""Set `param` on released components whose value differs from the checked status (unknown is never written).
|
|
534
|
+
Without a client (dry-run, no token) the component id is a placeholder and the current value is not known."""
|
|
535
|
+
out = []
|
|
536
|
+
for r in rows:
|
|
537
|
+
if r["status"] == "unknown" or not r["name"]:
|
|
538
|
+
continue
|
|
539
|
+
want, cid, cur = LABEL[r["status"]], r.get("component_id"), r.get("a365_value")
|
|
540
|
+
rec = {"name": r["name"], "mpn": r["mpn"], "value": want, "action": "", "detail": ""}
|
|
541
|
+
try:
|
|
542
|
+
cur_params = None
|
|
543
|
+
if client: # fetch the current revision: a replace must carry every parameter
|
|
544
|
+
nodes = client.gql(Q_FIND_PARAMS, {"ws": ws, "name": r["name"]})["desLibrary"]["components"] or {}
|
|
545
|
+
node = (nodes.get("nodes") or [None])[0]
|
|
546
|
+
if not node or not node.get("isManaged"):
|
|
547
|
+
rec["action"] = "skip"
|
|
548
|
+
rec["detail"] = "not in workspace" if not node else "not a released (managed) component"
|
|
549
|
+
out.append(rec)
|
|
550
|
+
continue
|
|
551
|
+
cur_params = _node_params(node)
|
|
552
|
+
cid, cur = node["id"], cur_params.get(param)
|
|
553
|
+
if cur == want:
|
|
554
|
+
rec["action"] = "unchanged"
|
|
555
|
+
out.append(rec)
|
|
556
|
+
continue
|
|
557
|
+
# replaceExisting=false *adds* a second Lifecycle parameter, so send the full merged set with true
|
|
558
|
+
params = [{"name": k, "value": v} for k, v in {**(cur_params or {}), param: want}.items()]
|
|
559
|
+
if apply:
|
|
560
|
+
rec["action"], rec["detail"] = "updated", client.update_params(cid, params, note, replace=True)
|
|
561
|
+
else:
|
|
562
|
+
inp = {"componentId": cid or f"<id of {r['name']}>", "releaseNote": note,
|
|
563
|
+
"replaceExisting": cur_params is not None, "parameters": params}
|
|
564
|
+
rec["action"], rec["detail"] = "dry_run", {"mutation": "desUpdateComponentRevisionParameters",
|
|
565
|
+
"query": M_UPDATE_PARAMS, "variables": {"input": inp}}
|
|
566
|
+
if cur_params is None:
|
|
567
|
+
rec["note"] = "no token: current parameters unknown; --apply fetches them and replaces the full set"
|
|
568
|
+
except Exception as e: # keep going; report per part
|
|
569
|
+
rec["action"], rec["detail"] = "error", f"{type(e).__name__}: {e}"
|
|
570
|
+
out.append(rec)
|
|
571
|
+
return out
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
# ------------------------------------------------------------------ reports
|
|
575
|
+
COLS = ["change", "mpn", "manufacturer", "name", "prev_status", "status", "prev_stock", "stock", "raw", "lcsc",
|
|
576
|
+
"sources", "prev_checked", "notes"]
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def write_reports(rows, out, meta, provs, unconfigured, http, a365=None):
|
|
580
|
+
out = Path(out)
|
|
581
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
582
|
+
with open(out.with_suffix(".csv"), "w", newline="", encoding="utf-8-sig") as f:
|
|
583
|
+
w = SS.DictWriter(f, fieldnames=COLS, extrasaction="ignore")
|
|
584
|
+
w.writeheader()
|
|
585
|
+
w.writerows({**r, "sources": ",".join(r["sources"])} for r in rows)
|
|
586
|
+
st = lambda s: LABEL.get(s, s or "-")
|
|
587
|
+
sk = lambda v: "-" if v is None else str(v)
|
|
588
|
+
counts = {c: sum(r["change"] == c for r in rows) for c in ("worse", "unknown", "new", "better", "unchanged")}
|
|
589
|
+
md = [f"# Lifecycle watch - {meta['now']}", "", f"- inputs: {', '.join(meta['inputs']) or '-'}",
|
|
590
|
+
f"- state: `{meta['state']}` (previous snapshot: {meta['prev'] or 'none'})",
|
|
591
|
+
f"- parts checked: {len(rows)}" + (f"; skipped without MPN or LCSC: {', '.join(meta['skipped'])}" if meta["skipped"] else ""),
|
|
592
|
+
"", "## Summary", ""] + [f"- **{k}**: {v}" for k, v in counts.items()]
|
|
593
|
+
md += ["", "Statuses now: " + ", ".join(f"{LABEL[s]} {n}" for s in LABEL
|
|
594
|
+
if (n := sum(r["status"] == s for r in rows)))]
|
|
595
|
+
hdr = ["| Change | MPN | Manufacturer | Part | Was | Now | Stock was -> now | Source wording | Notes |",
|
|
596
|
+
"|---|---|---|---|---|---|---|---|---|"]
|
|
597
|
+
line = lambda r: (f"| {r['change']} | {r['mpn'] or r['lcsc']} | {r['manufacturer']} | {r['name']} | {st(r['prev_status'])} | "
|
|
598
|
+
f"{st(r['status'])} | {sk(r['prev_stock'])} -> {sk(r['stock'])} | {r['raw']} | {r['notes'][:160]} |")
|
|
599
|
+
changed = [r for r in rows if r["change"] != "unchanged"]
|
|
600
|
+
md += ["", "## Changes since last snapshot", ""] + ((hdr + [line(r) for r in changed]) if changed else ["None."])
|
|
601
|
+
md += ["", "## All parts", ""] + hdr + [line(r) for r in rows]
|
|
602
|
+
md += ["", "## Sources", "", "| provider | configured | auth | response shape |", "|---|---|---|---|"]
|
|
603
|
+
md += [f"| {p.name} | {'no' if p.name in unconfigured else 'yes'} | {p.auth} | {p.shapes} |" for p in provs]
|
|
604
|
+
if a365 is not None:
|
|
605
|
+
md += ["", f"## Altium 365 `Lifecycle` parameter ({meta['a365_mode']})", "", "| component | MPN | value | action | detail |",
|
|
606
|
+
"|---|---|---|---|---|"]
|
|
607
|
+
md += [f"| {u['name']} | {u['mpn']} | {u['value']} | {u['action']} | "
|
|
608
|
+
f"{u['detail'] if isinstance(u['detail'], str) else 'see ' + out.name + '_a365.json'} |" for u in a365]
|
|
609
|
+
hosts = {}
|
|
610
|
+
for m, u, s, cached in http.log:
|
|
611
|
+
hosts.setdefault(re.sub(r"^https?://([^/]+).*", r"\1", u), []).append(("cache " if cached else "") + str(s))
|
|
612
|
+
md += ["", "## HTTP (this run)", ""] + [f"- `{h}`: {len(v)} requests -> {sorted(set(v))}" for h, v in hosts.items()]
|
|
613
|
+
out.with_suffix(".md").write_text("\n".join(md) + "\n", encoding="utf-8")
|
|
614
|
+
return counts
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def main(argv=None):
|
|
618
|
+
ap = argparse.ArgumentParser(description="Watch parts for lifecycle changes (NRND, LTB, obsolete, out of stock)")
|
|
619
|
+
ap.add_argument("inputs", nargs="*", help="BOM (.csv/.xlsx), DbLib parts.sqlite, or Altium 365 plan.json")
|
|
620
|
+
ap.add_argument("--a365", metavar="WORKSPACE_URL", help="also watch the managed components of this workspace (NEXAR_TOKEN)")
|
|
621
|
+
ap.add_argument("--providers", default="nexar,digikey,mouser,jlcpcb_search")
|
|
622
|
+
ap.add_argument("--state", default="out/lifecycle_state.json", help="snapshot of the last-seen status per MPN")
|
|
623
|
+
ap.add_argument("-o", "--out", default="out/lifecycle_report", help="report path stem (.md and .csv written)")
|
|
624
|
+
ap.add_argument("--strict", action="store_true", help="also fail on new parts already NRND+/out of stock, and on became-unknown")
|
|
625
|
+
ap.add_argument("--no-save", action="store_true", help="do not update the snapshot")
|
|
626
|
+
ap.add_argument("--update-a365", action="store_true", help="write a Lifecycle parameter to released A365 components")
|
|
627
|
+
ap.add_argument("--workspace", help="workspace URL for --update-a365 (default: --a365)")
|
|
628
|
+
ap.add_argument("--apply", action="store_true", help="send the A365 updates (default: dry run)")
|
|
629
|
+
ap.add_argument("--param", default="Lifecycle", help="A365 parameter name")
|
|
630
|
+
ap.add_argument("--offline", action="store_true", help="use HTTP cache only")
|
|
631
|
+
ap.add_argument("--cache", default="out/http_cache")
|
|
632
|
+
ap.add_argument("--max-age", type=float, default=20, help="hours a cached response stays fresh")
|
|
633
|
+
ap.add_argument("--rate", type=float, default=1.0, help="min seconds between requests per host")
|
|
634
|
+
a = ap.parse_args(argv)
|
|
635
|
+
ws = a.workspace or a.a365
|
|
636
|
+
if not a.inputs and not a.a365:
|
|
637
|
+
ap.error("give at least one input file or --a365 WORKSPACE_URL")
|
|
638
|
+
if a.update_a365 and not ws:
|
|
639
|
+
ap.error("--update-a365 needs --workspace or --a365")
|
|
640
|
+
token = os.environ.get("NEXAR_TOKEN")
|
|
641
|
+
if (a.a365 or a.apply) and not token:
|
|
642
|
+
ap.error("set NEXAR_TOKEN (Nexar user token with design.domain, user.access, openid scopes)")
|
|
643
|
+
client = DesignClient(token) if token and (a.a365 or a.update_a365) else None
|
|
644
|
+
|
|
645
|
+
try:
|
|
646
|
+
parts = read_inputs(a.inputs) + (parts_from_a365(client, a.a365, a.param) if a.a365 else [])
|
|
647
|
+
except Exception as e:
|
|
648
|
+
print(f"cannot read parts: {type(e).__name__}: {e}", file=sys.stderr)
|
|
649
|
+
return 2
|
|
650
|
+
parts, skipped = merge(parts)
|
|
651
|
+
if not parts:
|
|
652
|
+
print("no parts with an MPN or LCSC number in the inputs", file=sys.stderr)
|
|
653
|
+
return 2
|
|
654
|
+
|
|
655
|
+
http = CachedHTTP(a.cache, min_interval=a.rate, ttl_s=a.max_age * 3600, offline=a.offline)
|
|
656
|
+
want = a.providers.split(",")
|
|
657
|
+
provs = [c(http) for c in PROVIDERS if c.name in want]
|
|
658
|
+
current, unconfigured = check_parts(parts, provs)
|
|
659
|
+
state, now = load_state(a.state), _now()
|
|
660
|
+
rows = diff(state, current)
|
|
661
|
+
|
|
662
|
+
upd = None
|
|
663
|
+
if a.update_a365:
|
|
664
|
+
upd = a365_updates(rows, client, ws, apply=a.apply, param=a.param)
|
|
665
|
+
Path(a.out).parent.mkdir(parents=True, exist_ok=True)
|
|
666
|
+
Path(f"{a.out}_a365.json").write_text(json.dumps(upd, indent=2), encoding="utf-8")
|
|
667
|
+
meta = {"now": now, "inputs": list(a.inputs) + ([f"a365:{a.a365}"] if a.a365 else []), "state": a.state,
|
|
668
|
+
"prev": state.get("updated"), "skipped": skipped, "a365_mode": "applied" if a.apply else "dry run"}
|
|
669
|
+
counts = write_reports(rows, a.out, meta, provs, unconfigured, http, upd)
|
|
670
|
+
if not a.no_save:
|
|
671
|
+
save_state(next_state(state, current, now), a.state)
|
|
672
|
+
|
|
673
|
+
for r in rows:
|
|
674
|
+
if r["change"] != "unchanged":
|
|
675
|
+
print(f"{r['change']:<9} {(r['mpn'] or r['lcsc'])[:26]:<26} {LABEL.get(r['prev_status'], '-'):<13} -> "
|
|
676
|
+
f"{LABEL[r['status']]:<13} stock {r['prev_stock']} -> {r['stock']} {r['notes'][:70]}")
|
|
677
|
+
print(" ".join(f"{k} {v}" for k, v in counts.items()) + f" -> {Path(a.out).with_suffix('.md')} and .csv")
|
|
678
|
+
if unconfigured:
|
|
679
|
+
print(f"not configured: {', '.join(unconfigured)}")
|
|
680
|
+
code = exit_code(rows, a.strict)
|
|
681
|
+
if not any(r["answered"] for r in rows):
|
|
682
|
+
print("no source answered for any part (secrets missing, quota, or network blocked)", file=sys.stderr)
|
|
683
|
+
return code or 3
|
|
684
|
+
if upd and any(u["action"] == "error" for u in upd):
|
|
685
|
+
print(f"some Altium 365 updates failed (see {a.out}_a365.json)", file=sys.stderr)
|
|
686
|
+
return code or 2
|
|
687
|
+
return code
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
if __name__ == "__main__":
|
|
691
|
+
sys.exit(main())
|