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.
Files changed (199) hide show
  1. ecadlib/__init__.py +3 -0
  2. ecadlib/__main__.py +5 -0
  3. ecadlib/a365_sync.py +637 -0
  4. ecadlib/agent.py +1407 -0
  5. ecadlib/agent_queue.py +780 -0
  6. ecadlib/agent_watch.py +732 -0
  7. ecadlib/altium365.py +385 -0
  8. ecadlib/altium_dblib_sync.py +530 -0
  9. ecadlib/altium_export.py +209 -0
  10. ecadlib/altium_forms.py +768 -0
  11. ecadlib/altium_lib.py +1089 -0
  12. ecadlib/altium_menu.py +1751 -0
  13. ecadlib/altium_pkg.py +396 -0
  14. ecadlib/altium_project.py +1095 -0
  15. ecadlib/app.py +1458 -0
  16. ecadlib/approvals.py +395 -0
  17. ecadlib/assets/demo_bom.csv +8 -0
  18. ecadlib/assets/kicad_action_plugin.py +607 -0
  19. ecadlib/assets/kicad_reread.py +60 -0
  20. ecadlib/assets/pcblib_template.json.gz +0 -0
  21. ecadlib/bom_inputs.py +339 -0
  22. ecadlib/bom_triage.py +519 -0
  23. ecadlib/cad_model.py +528 -0
  24. ecadlib/cfb.py +171 -0
  25. ecadlib/cli.py +185 -0
  26. ecadlib/clihelp.py +149 -0
  27. ecadlib/cliutil.py +119 -0
  28. ecadlib/completion.py +173 -0
  29. ecadlib/costs.py +276 -0
  30. ecadlib/crosscheck.py +412 -0
  31. ecadlib/datasheet.py +2168 -0
  32. ecadlib/dedupe.py +572 -0
  33. ecadlib/detect.py +170 -0
  34. ecadlib/doctor.py +450 -0
  35. ecadlib/ds_route.py +541 -0
  36. ecadlib/eagle_export.py +1409 -0
  37. ecadlib/easy_setup.py +338 -0
  38. ecadlib/easyeda_convert.py +386 -0
  39. ecadlib/easyeda_pro_export.py +972 -0
  40. ecadlib/eval/__init__.py +0 -0
  41. ecadlib/eval/baselines/kicad-v1.json +404 -0
  42. ecadlib/eval/build_set.py +299 -0
  43. ecadlib/eval/cli.py +119 -0
  44. ecadlib/eval/gate.py +358 -0
  45. ecadlib/eval/golden/demo.txt +379 -0
  46. ecadlib/eval/golden/specs.txt +3120 -0
  47. ecadlib/eval/golden/testpack.txt +463 -0
  48. ecadlib/eval/golden.py +446 -0
  49. ecadlib/eval/replay/kicad-v1.json.gz +0 -0
  50. ecadlib/eval/runner.py +456 -0
  51. ecadlib/eval/score.py +179 -0
  52. ecadlib/eval/sets/kicad-v1.json +630 -0
  53. ecadlib/firstrun.py +400 -0
  54. ecadlib/footprint_adv.py +1006 -0
  55. ecadlib/footprint_bench.py +218 -0
  56. ecadlib/footprint_check.py +562 -0
  57. ecadlib/footprint_gen.py +582 -0
  58. ecadlib/footprint_rules.py +1092 -0
  59. ecadlib/forge.py +267 -0
  60. ecadlib/fp_share.py +95 -0
  61. ecadlib/fp_tidy.py +389 -0
  62. ecadlib/fsutil.py +62 -0
  63. ecadlib/house_style.py +1051 -0
  64. ecadlib/httpcache.py +124 -0
  65. ecadlib/hub.py +1490 -0
  66. ecadlib/hub_copy.py +161 -0
  67. ecadlib/hub_tools.py +79 -0
  68. ecadlib/kicad_dblib.py +257 -0
  69. ecadlib/kicad_export.py +837 -0
  70. ecadlib/kicad_import.py +913 -0
  71. ecadlib/kicad_index.py +160 -0
  72. ecadlib/kicad_menu.py +193 -0
  73. ecadlib/kicad_pcm.py +475 -0
  74. ecadlib/launcher.py +156 -0
  75. ecadlib/librarian.py +963 -0
  76. ecadlib/lifecycle.py +691 -0
  77. ecadlib/llm/__init__.py +276 -0
  78. ecadlib/llm/cli.py +70 -0
  79. ecadlib/llm/openai_compat.py +214 -0
  80. ecadlib/llm/profiles.py +245 -0
  81. ecadlib/llm/rails.py +570 -0
  82. ecadlib/llm/run.py +126 -0
  83. ecadlib/llm/sampling.py +49 -0
  84. ecadlib/mcp_server.py +723 -0
  85. ecadlib/messages.py +419 -0
  86. ecadlib/migrate.py +407 -0
  87. ecadlib/notify.py +329 -0
  88. ecadlib/package_schema.json +1238 -0
  89. ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
  90. ecadlib/package_specs/C_0402_1005Metric.json +22 -0
  91. ecadlib/package_specs/C_0805_2012Metric.json +22 -0
  92. ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
  93. ecadlib/package_specs/D_SMA.json +27 -0
  94. ecadlib/package_specs/D_SMB.json +27 -0
  95. ecadlib/package_specs/D_SOD-123.json +43 -0
  96. ecadlib/package_specs/D_SOD-323.json +43 -0
  97. ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
  98. ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
  99. ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
  100. ecadlib/package_specs/L_0805_2012Metric.json +22 -0
  101. ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
  102. ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
  103. ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
  104. ecadlib/package_specs/R_0402_1005Metric.json +22 -0
  105. ecadlib/package_specs/R_0603_1608Metric.json +22 -0
  106. ecadlib/package_specs/R_0805_2012Metric.json +22 -0
  107. ecadlib/package_specs/R_1206_3216Metric.json +22 -0
  108. ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
  109. ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
  110. ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
  111. ecadlib/package_specs/SOT-23-5.json +57 -0
  112. ecadlib/package_specs/SOT-23-6.json +62 -0
  113. ecadlib/package_specs/SOT-23.json +48 -0
  114. ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
  115. ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
  116. ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  117. ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
  118. ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
  119. ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
  120. ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
  121. ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  122. ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  123. ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
  124. ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
  125. ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
  126. ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
  127. ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
  128. ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
  129. ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
  130. ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
  131. ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
  132. ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
  133. ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
  134. ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
  135. ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
  136. ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
  137. ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
  138. ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
  139. ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
  140. ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
  141. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
  142. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
  143. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
  144. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
  145. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
  146. ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
  147. ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
  148. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
  149. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
  150. ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
  151. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
  152. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
  153. ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
  154. ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
  155. ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
  156. ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
  157. ecadlib/package_specs/standard.json.gz +0 -0
  158. ecadlib/parity.py +1204 -0
  159. ecadlib/parts_export.py +154 -0
  160. ecadlib/partview.py +215 -0
  161. ecadlib/plugins.py +231 -0
  162. ecadlib/providers.py +226 -0
  163. ecadlib/rebuild.py +471 -0
  164. ecadlib/release_check.py +437 -0
  165. ecadlib/reports.py +274 -0
  166. ecadlib/resolve.py +556 -0
  167. ecadlib/review_cli.py +292 -0
  168. ecadlib/rules.py +979 -0
  169. ecadlib/rules_infer.py +1205 -0
  170. ecadlib/sexpr.py +149 -0
  171. ecadlib/sheetsafe.py +65 -0
  172. ecadlib/sim_models.py +1087 -0
  173. ecadlib/sourcing.py +1161 -0
  174. ecadlib/stdparts.py +755 -0
  175. ecadlib/step_gen.py +929 -0
  176. ecadlib/symbol_layout.py +798 -0
  177. ecadlib/team.py +558 -0
  178. ecadlib/team_setup.py +288 -0
  179. ecadlib/term.py +420 -0
  180. ecadlib/term_views.py +64 -0
  181. ecadlib/testpack.py +334 -0
  182. ecadlib/ui/__init__.py +1 -0
  183. ecadlib/ui/__main__.py +3 -0
  184. ecadlib/ui/server.py +1029 -0
  185. ecadlib/ui/static/hairline.js +2 -0
  186. ecadlib/ui/static/index.html +2098 -0
  187. ecadlib/ui/static/setup.html +294 -0
  188. ecadlib/upkeep.py +717 -0
  189. ecadlib/upstream.py +573 -0
  190. ecadlib/userconfig.py +257 -0
  191. ecadlib/visual_check.py +1603 -0
  192. ecadlib/ws_status.py +203 -0
  193. ecadlib-0.3.0.dist-info/METADATA +2245 -0
  194. ecadlib-0.3.0.dist-info/RECORD +199 -0
  195. ecadlib-0.3.0.dist-info/WHEEL +5 -0
  196. ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
  197. ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
  198. ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
  199. 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())