traceforge-pcb 1.0.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 (219) hide show
  1. traceforge/__init__.py +3 -0
  2. traceforge/__main__.py +3 -0
  3. traceforge/acp_server.py +570 -0
  4. traceforge/agent.py +680 -0
  5. traceforge/altium/__init__.py +6 -0
  6. traceforge/altium/cfb.py +70 -0
  7. traceforge/altium/cli.py +60 -0
  8. traceforge/altium/model.py +163 -0
  9. traceforge/altium/outputs.py +24 -0
  10. traceforge/altium/pcb.py +301 -0
  11. traceforge/altium/project.py +328 -0
  12. traceforge/altium/schematic.py +400 -0
  13. traceforge/altium/tools.py +166 -0
  14. traceforge/approval.py +219 -0
  15. traceforge/attachments.py +346 -0
  16. traceforge/background.py +253 -0
  17. traceforge/bench/__init__.py +5 -0
  18. traceforge/bench/adapters.py +337 -0
  19. traceforge/bench/checks.py +410 -0
  20. traceforge/bench/cli.py +121 -0
  21. traceforge/bench/design.py +439 -0
  22. traceforge/bench/kicad.py +99 -0
  23. traceforge/bench/partscheck.py +159 -0
  24. traceforge/bench/review.py +100 -0
  25. traceforge/bench/runner.py +226 -0
  26. traceforge/bench/score.py +204 -0
  27. traceforge/bench/tasks.py +102 -0
  28. traceforge/chat.py +1185 -0
  29. traceforge/checks.py +328 -0
  30. traceforge/checks_sides.py +118 -0
  31. traceforge/cli.py +2224 -0
  32. traceforge/clipboard.py +107 -0
  33. traceforge/config.py +299 -0
  34. traceforge/context.py +276 -0
  35. traceforge/design/__init__.py +1 -0
  36. traceforge/design/constraints.py +397 -0
  37. traceforge/design/generate.py +1789 -0
  38. traceforge/design/outline.py +905 -0
  39. traceforge/design/schematic_layout.py +932 -0
  40. traceforge/design/schematic_lint.py +329 -0
  41. traceforge/design/schematic_route.py +207 -0
  42. traceforge/design/sheets.py +854 -0
  43. traceforge/design/sides.py +328 -0
  44. traceforge/design/spec.py +462 -0
  45. traceforge/design/specsheet.py +2351 -0
  46. traceforge/design/specsheet_render.py +183 -0
  47. traceforge/desktop/__init__.py +109 -0
  48. traceforge/desktop/keys.py +90 -0
  49. traceforge/desktop/project.py +443 -0
  50. traceforge/desktop/review.py +20 -0
  51. traceforge/desktop/server.py +1277 -0
  52. traceforge/desktop/static/app.css +223 -0
  53. traceforge/desktop/static/app.js +683 -0
  54. traceforge/desktop/static/highlight.js +77 -0
  55. traceforge/desktop/static/index.html +89 -0
  56. traceforge/desktop/static/md.js +90 -0
  57. traceforge/desktop/static/viewer.js +213 -0
  58. traceforge/desktop/symbols.py +212 -0
  59. traceforge/desktop/update.py +65 -0
  60. traceforge/diagnostics.py +67 -0
  61. traceforge/errata.py +219 -0
  62. traceforge/fab.py +108 -0
  63. traceforge/fab_layers.py +44 -0
  64. traceforge/features/__init__.py +6 -0
  65. traceforge/features/candidates.py +117 -0
  66. traceforge/features/cli.py +200 -0
  67. traceforge/features/package.py +433 -0
  68. traceforge/features/revdiff.py +224 -0
  69. traceforge/features/sarif.py +145 -0
  70. traceforge/features/viewer.py +265 -0
  71. traceforge/firmware/__init__.py +3 -0
  72. traceforge/firmware/cli.py +174 -0
  73. traceforge/firmware/drivers.py +372 -0
  74. traceforge/firmware/drivers_data.py +256 -0
  75. traceforge/firmware/mcus.py +177 -0
  76. traceforge/firmware/netlist.py +55 -0
  77. traceforge/firmware/pinmap.py +296 -0
  78. traceforge/firmware/scaffold.py +466 -0
  79. traceforge/firmware/toolchain.py +262 -0
  80. traceforge/hooks.py +237 -0
  81. traceforge/kicad/__init__.py +0 -0
  82. traceforge/kicad/cli.py +519 -0
  83. traceforge/kicad/diffpair.py +174 -0
  84. traceforge/kicad/edgegeom.py +191 -0
  85. traceforge/kicad/edit.py +405 -0
  86. traceforge/kicad/escape.py +1410 -0
  87. traceforge/kicad/formats.py +1073 -0
  88. traceforge/kicad/impedance.py +320 -0
  89. traceforge/kicad/install.py +75 -0
  90. traceforge/kicad/libraries.py +490 -0
  91. traceforge/kicad/models3d.py +942 -0
  92. traceforge/kicad/outputs.py +81 -0
  93. traceforge/kicad/pcb.py +787 -0
  94. traceforge/kicad/planes.py +1242 -0
  95. traceforge/kicad/project.py +136 -0
  96. traceforge/kicad/route.py +2698 -0
  97. traceforge/kicad/routegeom.py +164 -0
  98. traceforge/kicad/schematic.py +268 -0
  99. traceforge/kicad/sides.py +34 -0
  100. traceforge/kicad/stackup.py +424 -0
  101. traceforge/kicad_plugin/__init__.py +12 -0
  102. traceforge/kicad_plugin/actions.py +92 -0
  103. traceforge/kicad_plugin/cli.py +66 -0
  104. traceforge/kicad_plugin/client.py +86 -0
  105. traceforge/kicad_plugin/context.py +257 -0
  106. traceforge/kicad_plugin/discovery.py +81 -0
  107. traceforge/kicad_plugin/install.py +203 -0
  108. traceforge/kicad_plugin/overlay.py +60 -0
  109. traceforge/kicad_plugin/payload/action_plugin.py +65 -0
  110. traceforge/kicad_plugin/payload/ipc_main.py +42 -0
  111. traceforge/kicad_plugin/payload/plugin.json +90 -0
  112. traceforge/kicad_plugin/payload/requirements.txt +1 -0
  113. traceforge/kicad_plugin/views.py +202 -0
  114. traceforge/langchain.py +57 -0
  115. traceforge/local.py +580 -0
  116. traceforge/mcp_server.py +304 -0
  117. traceforge/models.py +310 -0
  118. traceforge/next_steps.py +110 -0
  119. traceforge/parts/__init__.py +52 -0
  120. traceforge/parts/alternates.py +488 -0
  121. traceforge/parts/bom.py +423 -0
  122. traceforge/parts/brief.py +705 -0
  123. traceforge/parts/catalog.py +297 -0
  124. traceforge/parts/cli_sourcing.py +156 -0
  125. traceforge/parts/data/duty_rates.yaml +37 -0
  126. traceforge/parts/data/hs_codes.yaml +103 -0
  127. traceforge/parts/datasheet.py +278 -0
  128. traceforge/parts/fx.py +100 -0
  129. traceforge/parts/model.py +185 -0
  130. traceforge/parts/net.py +187 -0
  131. traceforge/parts/offers.py +46 -0
  132. traceforge/parts/packages.py +397 -0
  133. traceforge/parts/pinout.py +714 -0
  134. traceforge/parts/providers/__init__.py +17 -0
  135. traceforge/parts/providers/base.py +100 -0
  136. traceforge/parts/providers/jlc.py +364 -0
  137. traceforge/parts/providers/keyed.py +661 -0
  138. traceforge/parts/report.py +508 -0
  139. traceforge/parts/supply.py +330 -0
  140. traceforge/parts/verify.py +455 -0
  141. traceforge/parts/web.py +575 -0
  142. traceforge/plans.py +70 -0
  143. traceforge/preferences.py +127 -0
  144. traceforge/prices.py +130 -0
  145. traceforge/prices.yaml +28 -0
  146. traceforge/prompts/__init__.py +54 -0
  147. traceforge/prompts/system.md +103 -0
  148. traceforge/providers.py +764 -0
  149. traceforge/remote/__init__.py +33 -0
  150. traceforge/remote/client.py +422 -0
  151. traceforge/remote/oidc.py +240 -0
  152. traceforge/remote/orgs.py +510 -0
  153. traceforge/remote/server.py +690 -0
  154. traceforge/remote/store.py +100 -0
  155. traceforge/review_changes.py +187 -0
  156. traceforge/routing.py +99 -0
  157. traceforge/runtime.py +435 -0
  158. traceforge/sessions.py +324 -0
  159. traceforge/sexpr.py +279 -0
  160. traceforge/sim/__init__.py +17 -0
  161. traceforge/sim/acmath.py +234 -0
  162. traceforge/sim/circuit.py +499 -0
  163. traceforge/sim/cli.py +162 -0
  164. traceforge/sim/engine.py +172 -0
  165. traceforge/sim/ltspice.py +175 -0
  166. traceforge/sim/measure.py +123 -0
  167. traceforge/sim/models.py +76 -0
  168. traceforge/sim/project.py +218 -0
  169. traceforge/sim/rawfile.py +123 -0
  170. traceforge/sim/results.py +298 -0
  171. traceforge/sim/tune.py +172 -0
  172. traceforge/skill_commands.py +165 -0
  173. traceforge/skill_library/circuit-simulation/SKILL.md +30 -0
  174. traceforge/skill_library/compliance-review/SKILL.md +51 -0
  175. traceforge/skill_library/design-review/SKILL.md +47 -0
  176. traceforge/skill_library/errata-hunt/SKILL.md +30 -0
  177. traceforge/skill_library/firmware/SKILL.md +26 -0
  178. traceforge/skill_library/power-budget/SKILL.md +27 -0
  179. traceforge/skill_library/release-package/SKILL.md +29 -0
  180. traceforge/skill_library/sourcing-brief/SKILL.md +24 -0
  181. traceforge/skill_library/thermal-review/SKILL.md +15 -0
  182. traceforge/skills.py +131 -0
  183. traceforge/thermal.py +557 -0
  184. traceforge/tools/__init__.py +48 -0
  185. traceforge/tools/agent_tools.py +213 -0
  186. traceforge/tools/base.py +303 -0
  187. traceforge/tools/batch.py +155 -0
  188. traceforge/tools/connections.py +245 -0
  189. traceforge/tools/files.py +777 -0
  190. traceforge/tools/firmware_tools.py +218 -0
  191. traceforge/tools/kicad_tools.py +1072 -0
  192. traceforge/tools/models3d_tools.py +141 -0
  193. traceforge/tools/parts_tools.py +473 -0
  194. traceforge/tools/processes.py +246 -0
  195. traceforge/tools/review_tools.py +178 -0
  196. traceforge/tools/sim_tools.py +230 -0
  197. traceforge/tui.py +620 -0
  198. traceforge/updates.py +124 -0
  199. traceforge/validate/__init__.py +77 -0
  200. traceforge/validate/builtins.py +1091 -0
  201. traceforge/validate/circuits.py +1240 -0
  202. traceforge/validate/config.py +410 -0
  203. traceforge/validate/default.yaml +113 -0
  204. traceforge/validate/design.py +191 -0
  205. traceforge/validate/explain.py +112 -0
  206. traceforge/validate/grounding.py +169 -0
  207. traceforge/validate/ipc2221.py +350 -0
  208. traceforge/validate/multilayer.py +805 -0
  209. traceforge/validate/netmodel.py +261 -0
  210. traceforge/validate/partcheck.py +472 -0
  211. traceforge/validate/report.py +280 -0
  212. traceforge/validate/review.py +322 -0
  213. traceforge/validate/runner.py +662 -0
  214. traceforge/workspace.py +171 -0
  215. traceforge_pcb-1.0.0.dist-info/METADATA +463 -0
  216. traceforge_pcb-1.0.0.dist-info/RECORD +219 -0
  217. traceforge_pcb-1.0.0.dist-info/WHEEL +4 -0
  218. traceforge_pcb-1.0.0.dist-info/entry_points.txt +2 -0
  219. traceforge_pcb-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,661 @@
1
+ """Providers that need an account: Nexar (Octopart), DigiKey, Mouser.
2
+
3
+ Each is skipped unless its credentials are in the environment (``.env`` is read
4
+ by ``traceforge.config``):
5
+
6
+ - Nexar / Octopart: ``NEXAR_CLIENT_ID`` + ``NEXAR_CLIENT_SECRET`` (or a ready
7
+ ``NEXAR_TOKEN``); GraphQL ``supSearchMpn`` / ``supSearch``.
8
+ - DigiKey Product Information v4: ``DIGIKEY_CLIENT_ID`` + ``DIGIKEY_CLIENT_SECRET``
9
+ (OAuth2 client credentials); keyword search.
10
+ - Mouser Search API v1: ``MOUSER_API_KEY``; part-number and keyword search.
11
+
12
+ VERIFICATION STATUS: these three were written against the vendors' published API
13
+ documentation and are exercised only against hand-written fixtures shaped like
14
+ the documented responses (tests/fixtures/parts/{digikey,mouser,nexar}_*.json).
15
+ They have never been run with real credentials. Field names that could not be
16
+ confirmed against a live response are marked "unverified" below.
17
+
18
+ Shared behaviour: the OAuth token is cached until shortly before it expires; a 401
19
+ refreshes the token once and retries; 429 and "too many requests" answers wait
20
+ (Retry-After, bounded) and retry a limited number of times, then raise
21
+ :class:`RateLimited`; a client-side window keeps calls under the documented
22
+ per-minute limits. Responses that are not valid JSON or that carry an error
23
+ body raise :class:`ProviderError` with the vendor's message.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import os
29
+ import re
30
+ import threading
31
+ import time
32
+ from collections import deque
33
+ from typing import Any
34
+
35
+ from traceforge.parts.model import (
36
+ PartInfo,
37
+ PriceBreak,
38
+ SupplierOffer,
39
+ normalize_lifecycle,
40
+ pin_count_from_text,
41
+ )
42
+ from traceforge.parts.net import Offline, ProviderError
43
+ from traceforge.parts.providers.base import PartProvider, SearchQuery, best_mpn_match
44
+
45
+
46
+ class RateLimited(ProviderError):
47
+ """The supplier's rate limit (or daily quota) was hit and retries did not clear it."""
48
+
49
+
50
+ def _env(*names: str) -> str:
51
+ for name in names:
52
+ value = os.environ.get(name, "").strip()
53
+ if value:
54
+ return value
55
+ return ""
56
+
57
+
58
+ class Throttle:
59
+ """At most ``calls`` requests per ``per`` seconds (sliding window); clock and sleep injectable."""
60
+
61
+ def __init__(self, calls: int, per: float = 60.0, clock=time.monotonic, sleep=time.sleep):
62
+ self.calls, self.per, self.clock, self.sleep = calls, per, clock, sleep
63
+ self._stamps: deque[float] = deque()
64
+ self._lock = threading.Lock()
65
+
66
+ def wait(self) -> None:
67
+ with self._lock:
68
+ now = self.clock()
69
+ while self._stamps and now - self._stamps[0] >= self.per:
70
+ self._stamps.popleft()
71
+ if len(self._stamps) >= self.calls:
72
+ delay = self.per - (now - self._stamps[0])
73
+ if delay > 0:
74
+ self.sleep(delay)
75
+ self._stamps.popleft()
76
+ self._stamps.append(self.clock())
77
+
78
+
79
+ MAX_RETRIES = 2
80
+ MAX_WAIT = 30.0 # never sleep longer than this on a Retry-After
81
+
82
+
83
+ def _retry_after(response) -> float:
84
+ try:
85
+ return max(0.0, min(float(response.headers.get("Retry-After", "")), MAX_WAIT))
86
+ except ValueError:
87
+ return 2.0
88
+
89
+
90
+ class _KeyedProvider(PartProvider):
91
+ """Request plumbing shared by the three: throttle, retry on 429, error bodies."""
92
+
93
+ rate: tuple[int, float] = (60, 60.0)
94
+
95
+ def __init__(self, net, sleep=time.sleep):
96
+ super().__init__(net)
97
+ self.sleep = sleep
98
+ self.throttle = Throttle(self.rate[0], self.rate[1], sleep=sleep)
99
+
100
+ def refresh_auth(self) -> None:
101
+ """Drop the cached credential so the next call fetches a fresh one."""
102
+
103
+ def _is_rate_limited(self, response, body: Any) -> bool:
104
+ return response.status_code == 429
105
+
106
+ def _send(self, method: str, url: str, **kwargs: Any) -> Any:
107
+ """Send with throttling, a single auth refresh on 401 and bounded 429 retries."""
108
+ refreshed = False
109
+ attempt = 0
110
+ while True:
111
+ self.throttle.wait()
112
+ call_kwargs = dict(kwargs)
113
+ if callable(kwargs.get("headers")):
114
+ call_kwargs["headers"] = kwargs["headers"]()
115
+ response = self.net.request(method, url, **call_kwargs)
116
+ try:
117
+ body = response.json()
118
+ except ValueError:
119
+ body = None
120
+ where = url.split("?")[0]
121
+ if response.status_code == 401 and not refreshed:
122
+ refreshed = True
123
+ self.refresh_auth()
124
+ continue
125
+ if self._is_rate_limited(response, body):
126
+ if attempt >= MAX_RETRIES:
127
+ raise RateLimited(f"{self.name}: rate limited, retries exhausted ({where})")
128
+ attempt += 1
129
+ self.sleep(_retry_after(response))
130
+ continue
131
+ if response.status_code in (401, 403):
132
+ raise ProviderError(
133
+ f"{self.name}: HTTP {response.status_code} (check the credentials and that "
134
+ f"the app is subscribed to this API): {self._message(body)}"
135
+ )
136
+ if response.status_code >= 500:
137
+ raise Offline(f"{where}: HTTP {response.status_code}")
138
+ if response.status_code >= 400:
139
+ raise ProviderError(
140
+ f"{self.name}: HTTP {response.status_code}: {self._message(body)}"
141
+ )
142
+ if body is None:
143
+ raise ProviderError(f"{self.name}: response is not JSON ({where})")
144
+ return body
145
+
146
+ @staticmethod
147
+ def _message(body: Any) -> str:
148
+ if isinstance(body, dict):
149
+ for key in ("detail", "ErrorMessage", "error_description", "title", "message"):
150
+ if body.get(key):
151
+ return str(body[key])[:200]
152
+ errors = body.get("Errors") or body.get("errors")
153
+ if isinstance(errors, list) and errors and isinstance(errors[0], dict):
154
+ return str(errors[0].get("Message") or errors[0].get("message") or "")[:200]
155
+ return ""
156
+
157
+
158
+ class _OAuthProvider(_KeyedProvider):
159
+ token_url = ""
160
+ scope = ""
161
+ id_vars: tuple[str, ...] = ()
162
+ secret_vars: tuple[str, ...] = ()
163
+ token_var = ""
164
+
165
+ def __init__(self, net, sleep=time.sleep):
166
+ super().__init__(net, sleep)
167
+ self._token: tuple[str, float] | None = None
168
+
169
+ def configured(self) -> bool:
170
+ return bool(
171
+ (self.token_var and _env(self.token_var))
172
+ or (_env(*self.id_vars) and _env(*self.secret_vars))
173
+ )
174
+
175
+ def refresh_auth(self) -> None:
176
+ self._token = None
177
+
178
+ def token(self) -> str:
179
+ if self.token_var and _env(self.token_var):
180
+ return _env(self.token_var)
181
+ if self._token and self._token[1] > time.time() + 30:
182
+ return self._token[0]
183
+ form = {
184
+ "grant_type": "client_credentials",
185
+ "client_id": _env(*self.id_vars),
186
+ "client_secret": _env(*self.secret_vars),
187
+ }
188
+ if self.scope:
189
+ form["scope"] = self.scope
190
+ response = self.net.request("POST", self.token_url, data=form)
191
+ try:
192
+ data = response.json()
193
+ except ValueError:
194
+ data = None
195
+ if response.status_code >= 500:
196
+ raise Offline(f"{self.token_url}: HTTP {response.status_code}")
197
+ if response.status_code >= 400 or not isinstance(data, dict):
198
+ raise ProviderError(
199
+ f"{self.name}: token request failed (HTTP {response.status_code}): "
200
+ f"{self._message(data) or 'check the client id and secret'}"
201
+ )
202
+ token = data.get("access_token")
203
+ if not token:
204
+ raise ProviderError(f"{self.name}: no access token in the OAuth response")
205
+ self._token = (token, time.time() + float(data.get("expires_in") or 600))
206
+ return token
207
+
208
+ def _auth_headers(self) -> dict[str, str]:
209
+ return {"Authorization": f"Bearer {self.token()}"}
210
+
211
+
212
+ # ------------------------------------------------------------------- Nexar
213
+
214
+
215
+ class NexarProvider(_OAuthProvider):
216
+ """Octopart data through Nexar's GraphQL supply API (unverified against a live account)."""
217
+
218
+ name = "nexar"
219
+ mpns = True
220
+ searches = True
221
+ token_url = "https://identity.nexar.com/connect/token"
222
+ scope = "supply.domain"
223
+ id_vars = ("NEXAR_CLIENT_ID",)
224
+ secret_vars = ("NEXAR_CLIENT_SECRET",)
225
+ token_var = "NEXAR_TOKEN"
226
+ endpoint = "https://api.nexar.com/graphql"
227
+ rate = (20, 60.0) # conservative; the plan's own limits apply server-side
228
+
229
+ # The field names below follow Nexar's published schema (supSearch / supSearchMpn, Part,
230
+ # Seller, Offer, Price). `moq`, `orderMultiple`, `factoryLeadDays`, `packaging`,
231
+ # `isAuthorized` and `clickUrl` are documented Offer/Seller fields; they were not checked
232
+ # against a live response.
233
+ QUERY = """
234
+ query Search($q: String!, $limit: Int!) {
235
+ %(op)s(q: $q, limit: $limit) {
236
+ results {
237
+ part {
238
+ mpn
239
+ manufacturer { name }
240
+ shortDescription
241
+ category { name }
242
+ bestDatasheet { url }
243
+ octopartUrl
244
+ specs { attribute { name shortname } displayValue }
245
+ sellers {
246
+ company { name }
247
+ isAuthorized
248
+ offers {
249
+ sku inventoryLevel moq orderMultiple packaging factoryLeadDays clickUrl
250
+ prices { quantity price currency }
251
+ }
252
+ }
253
+ }
254
+ }
255
+ }
256
+ }
257
+ """
258
+
259
+ def _search(self, op: str, text: str, limit: int) -> list[PartInfo]:
260
+ data = self._send(
261
+ "POST",
262
+ self.endpoint,
263
+ json={"query": self.QUERY % {"op": op}, "variables": {"q": text, "limit": limit}},
264
+ headers=self._auth_headers,
265
+ )
266
+ errors = (data or {}).get("errors")
267
+ if errors:
268
+ message = str(errors[0].get("message", "query failed"))
269
+ if re.search(r"too many|rate|limit|quota", message, re.I):
270
+ raise RateLimited(f"nexar: {message}")
271
+ raise ProviderError(f"nexar: {message}")
272
+ results = (((data or {}).get("data") or {}).get(op) or {}).get("results") or []
273
+ return [self.parse(r.get("part") or {}) for r in results]
274
+
275
+ def search(self, query: SearchQuery) -> list[PartInfo]:
276
+ text = f"{query.text} {query.package or ''}".strip()
277
+ parts = self._search("supSearch", text, min(query.limit, 20))
278
+ if query.in_stock:
279
+ parts = [p for p in parts if (p.stock or 0) > 0]
280
+ return parts
281
+
282
+ def lookup_mpn(self, mpn: str) -> PartInfo | None:
283
+ return best_mpn_match(mpn, self._search("supSearchMpn", mpn, 10))
284
+
285
+ @staticmethod
286
+ def parse(part: dict[str, Any]) -> PartInfo:
287
+ attributes = {
288
+ str((s.get("attribute") or {}).get("name")): str(s.get("displayValue"))
289
+ for s in part.get("specs") or []
290
+ if (s.get("attribute") or {}).get("name") and s.get("displayValue")
291
+ }
292
+ stock_by_source: dict[str, int] = {}
293
+ offers: list[SupplierOffer] = []
294
+ lcsc = ""
295
+ for seller in part.get("sellers") or []:
296
+ company = ((seller.get("company") or {}).get("name") or "").strip()
297
+ authorised = seller.get("isAuthorized")
298
+ for offer in seller.get("offers") or []:
299
+ level = offer.get("inventoryLevel")
300
+ level = level if isinstance(level, int) and level >= 0 else None
301
+ if level is not None:
302
+ stock_by_source[company] = max(stock_by_source.get(company, 0), level)
303
+ sku = str(offer.get("sku") or "")
304
+ if company.upper() == "LCSC" and re.match(r"^C\d+$", sku):
305
+ lcsc = sku
306
+ breaks = [
307
+ PriceBreak(int(p["quantity"]), float(p["price"]), p.get("currency") or "USD")
308
+ for p in offer.get("prices") or []
309
+ if p.get("quantity") and p.get("price") is not None
310
+ ]
311
+ lead = offer.get("factoryLeadDays")
312
+ offers.append(
313
+ SupplierOffer(
314
+ supplier=company or "unknown seller",
315
+ sku=sku,
316
+ stock=level,
317
+ price_breaks=sorted(breaks, key=lambda b: b.quantity),
318
+ moq=offer.get("moq") if isinstance(offer.get("moq"), int) else None,
319
+ multiple=(
320
+ offer.get("orderMultiple")
321
+ if isinstance(offer.get("orderMultiple"), int)
322
+ else None
323
+ ),
324
+ lead_time_days=lead if isinstance(lead, int) and lead > 0 else None,
325
+ packaging=str(offer.get("packaging") or ""),
326
+ authorised=authorised if isinstance(authorised, bool) else None,
327
+ url=str(offer.get("clickUrl") or ""),
328
+ source="nexar",
329
+ )
330
+ )
331
+ priced = next((o.price_breaks for o in offers if o.price_breaks), [])
332
+ package = attributes.get("Case/Package") or attributes.get("Package") or ""
333
+ lifecycle = normalize_lifecycle(
334
+ attributes.get("Lifecycle Status") or attributes.get("Lifecycle") or ""
335
+ )
336
+ return PartInfo(
337
+ lcsc=lcsc,
338
+ mpn=str(part.get("mpn") or ""),
339
+ manufacturer=str((part.get("manufacturer") or {}).get("name") or ""),
340
+ description=str(part.get("shortDescription") or ""),
341
+ package=package,
342
+ category=str((part.get("category") or {}).get("name") or ""),
343
+ pin_count=pin_count_from_text(attributes.get("Number of Pins", "")),
344
+ stock=sum(stock_by_source.values()) if stock_by_source else None,
345
+ stock_by_source=stock_by_source,
346
+ price_breaks=list(priced),
347
+ datasheet_url=str((part.get("bestDatasheet") or {}).get("url") or ""),
348
+ product_url=str(part.get("octopartUrl") or ""),
349
+ lifecycle=lifecycle,
350
+ attributes=attributes,
351
+ sources=["nexar"],
352
+ offers=offers,
353
+ )
354
+
355
+
356
+ # ----------------------------------------------------------------- DigiKey
357
+
358
+
359
+ def _lead_days(text: str) -> int | None:
360
+ """'14 weeks' / '84 Days' / '3 months' -> days."""
361
+ match = re.search(r"(\d+(?:\.\d+)?)\s*(day|week|wk|month)", str(text or ""), re.I)
362
+ if not match:
363
+ return None
364
+ unit = match.group(2).lower()
365
+ factor = 1 if unit.startswith("day") else (7 if unit.startswith(("week", "wk")) else 30)
366
+ return int(round(float(match.group(1)) * factor))
367
+
368
+
369
+ class DigikeyProvider(_OAuthProvider):
370
+ """DigiKey Product Information API v4 (unverified against a live account)."""
371
+
372
+ name = "digikey"
373
+ mpns = True
374
+ searches = True
375
+ token_url = "https://api.digikey.com/v1/oauth2/token"
376
+ id_vars = ("DIGIKEY_CLIENT_ID",)
377
+ secret_vars = ("DIGIKEY_CLIENT_SECRET",)
378
+ endpoint = "https://api.digikey.com/products/v4/search/keyword"
379
+ rate = (100, 60.0) # documented default is 120 requests/minute
380
+
381
+ def _headers(self) -> dict[str, str]:
382
+ return {
383
+ **self._auth_headers(),
384
+ "X-DIGIKEY-Client-Id": _env(*self.id_vars),
385
+ "X-DIGIKEY-Locale-Site": os.environ.get("DIGIKEY_LOCALE_SITE", "US"),
386
+ "X-DIGIKEY-Locale-Language": os.environ.get("DIGIKEY_LOCALE_LANGUAGE", "en"),
387
+ "X-DIGIKEY-Locale-Currency": os.environ.get("DIGIKEY_CURRENCY", "USD"),
388
+ }
389
+
390
+ def _is_rate_limited(self, response, body) -> bool:
391
+ if response.status_code == 429:
392
+ return True
393
+ # DigiKey reports a spent quota as 429; some gateways answer 403 with a rate message.
394
+ return response.status_code == 403 and "rate limit" in self._message(body).lower()
395
+
396
+ def _keyword(self, text: str, limit: int, in_stock: bool) -> list[PartInfo]:
397
+ body: dict[str, Any] = {"Keywords": text, "Limit": min(limit, 50), "Offset": 0}
398
+ if in_stock:
399
+ body["FilterOptionsRequest"] = {"SearchOptions": ["InStock"]}
400
+ data = self._send("POST", self.endpoint, json=body, headers=self._headers)
401
+ products = list((data or {}).get("ExactMatches") or [])
402
+ products += list((data or {}).get("Products") or [])
403
+ seen: set[str] = set()
404
+ parts = []
405
+ for product in products:
406
+ part = self.parse(product, currency=os.environ.get("DIGIKEY_CURRENCY", "USD"))
407
+ if part.mpn and part.mpn not in seen:
408
+ seen.add(part.mpn)
409
+ parts.append(part)
410
+ return parts
411
+
412
+ def search(self, query: SearchQuery) -> list[PartInfo]:
413
+ text = f"{query.text} {query.package or ''}".strip()
414
+ return self._keyword(text, query.limit, query.in_stock)
415
+
416
+ def lookup_mpn(self, mpn: str) -> PartInfo | None:
417
+ return best_mpn_match(mpn, self._keyword(mpn, 10, False))
418
+
419
+ @staticmethod
420
+ def parse(product: dict[str, Any], currency: str = "USD") -> PartInfo:
421
+ attributes = {
422
+ str(p.get("ParameterText")): str(p.get("ValueText"))
423
+ for p in product.get("Parameters") or []
424
+ if p.get("ParameterText") and p.get("ValueText") not in (None, "", "-")
425
+ }
426
+ classes = product.get("Classifications") or {}
427
+ for key, label in (
428
+ ("HtsusCode", "HTSUS"),
429
+ ("ExportControlClassNumber", "ECCN"),
430
+ ("RohsStatus", "RoHS"),
431
+ ("MoistureSensitivityLevel", "MSL"),
432
+ ):
433
+ if classes.get(key):
434
+ attributes[label] = str(classes[key])
435
+ status = (product.get("ProductStatus") or {}).get("Status") or ""
436
+ lifecycle = normalize_lifecycle(status)
437
+ if product.get("Discontinued") and lifecycle in ("", "normal"):
438
+ lifecycle = "discontinued"
439
+ if product.get("EndOfLife") and lifecycle in ("", "normal"):
440
+ lifecycle = "obsolete"
441
+ total = product.get("QuantityAvailable")
442
+ total = int(total) if isinstance(total, int | float) else None
443
+ lead = _lead_days(product.get("ManufacturerLeadWeeks") or "") or (
444
+ int(product["ManufacturerLeadWeeks"]) * 7
445
+ if str(product.get("ManufacturerLeadWeeks") or "").isdigit()
446
+ else None
447
+ )
448
+ offers: list[SupplierOffer] = []
449
+ for variation in product.get("ProductVariations") or []:
450
+ breaks = [
451
+ PriceBreak(int(b["BreakQuantity"]), float(b["UnitPrice"]), currency)
452
+ for b in variation.get("StandardPricing") or []
453
+ if b.get("BreakQuantity") and b.get("UnitPrice") is not None
454
+ ]
455
+ at_package = variation.get("QuantityAvailableforPackageType")
456
+ note = ["Marketplace item (third-party seller)"] if variation.get("Marketplace") else []
457
+ if variation.get("TariffActive"):
458
+ note.append("DigiKey marks a tariff as active on this item")
459
+ offers.append(
460
+ SupplierOffer(
461
+ supplier="DigiKey",
462
+ sku=str(variation.get("DigiKeyProductNumber") or ""),
463
+ stock=int(at_package) if isinstance(at_package, int | float) else total,
464
+ price_breaks=sorted(breaks, key=lambda b: b.quantity),
465
+ moq=variation.get("MinimumOrderQuantity"),
466
+ multiple=None, # StandardPackage is the pack size, not an order multiple
467
+ lead_time_days=lead,
468
+ packaging=str((variation.get("PackageType") or {}).get("Name") or ""),
469
+ lifecycle=lifecycle,
470
+ region=f"{os.environ.get('DIGIKEY_LOCALE_SITE', 'US')} site",
471
+ authorised=True,
472
+ url=str(product.get("ProductUrl") or ""),
473
+ source="digikey",
474
+ notes=note,
475
+ )
476
+ )
477
+ priced = next((o.price_breaks for o in offers if o.price_breaks), [])
478
+ description = product.get("Description") or {}
479
+ package = attributes.get("Supplier Device Package") or attributes.get("Package / Case", "")
480
+ mounting = attributes.get("Mounting Type", "").lower()
481
+ return PartInfo(
482
+ mpn=str(product.get("ManufacturerProductNumber") or ""),
483
+ manufacturer=str((product.get("Manufacturer") or {}).get("Name") or ""),
484
+ description=str(
485
+ description.get("DetailedDescription")
486
+ or description.get("ProductDescription")
487
+ or ""
488
+ ),
489
+ package=package,
490
+ category=str((product.get("Category") or {}).get("Name") or ""),
491
+ pin_count=pin_count_from_text(attributes.get("Number of Pins", ""), package),
492
+ stock=total,
493
+ stock_by_source={"digikey": total} if total is not None else {},
494
+ price_breaks=list(priced),
495
+ datasheet_url=str(product.get("DatasheetUrl") or ""),
496
+ product_url=str(product.get("ProductUrl") or ""),
497
+ lifecycle=lifecycle,
498
+ mounting="tht"
499
+ if "through hole" in mounting
500
+ else ("smd" if "surface" in mounting else ""),
501
+ attributes=attributes,
502
+ sources=["digikey"],
503
+ offers=offers,
504
+ )
505
+
506
+
507
+ # ------------------------------------------------------------------ Mouser
508
+
509
+
510
+ def parse_price(text: str, currency: str = "USD") -> float | None:
511
+ """'$1,234.50', '0,123 €', '1.234,50 EUR', '¥12' -> float; None when no number."""
512
+ raw = re.sub(r"[^\d.,]", "", str(text or ""))
513
+ if not raw:
514
+ return None
515
+ if "," in raw and "." in raw:
516
+ decimal = "," if raw.rfind(",") > raw.rfind(".") else "."
517
+ thousands = "." if decimal == "," else ","
518
+ raw = raw.replace(thousands, "").replace(decimal, ".")
519
+ elif "," in raw:
520
+ head, _, tail = raw.rpartition(",")
521
+ grouped = len(tail) == 3 and "," not in head and head not in ("0", "")
522
+ # "1,234" is a thousands group in dollar-style locales, a decimal in comma locales.
523
+ raw = (
524
+ raw.replace(",", "")
525
+ if grouped and currency in {"USD", "GBP", "JPY", "CNY", "INR"}
526
+ else raw.replace(",", ".")
527
+ )
528
+ try:
529
+ return float(raw)
530
+ except ValueError:
531
+ return None
532
+
533
+
534
+ class MouserProvider(_KeyedProvider):
535
+ """Mouser Search API v1 (unverified against a live key)."""
536
+
537
+ name = "mouser"
538
+ mpns = True
539
+ searches = True
540
+ base = "https://api.mouser.com/api/v1/search"
541
+ rate = (28, 60.0) # documented limit: 30 calls per minute (and 1000 per day)
542
+
543
+ def configured(self) -> bool:
544
+ return bool(_env("MOUSER_API_KEY", "MOUSER_SEARCH_API_KEY"))
545
+
546
+ @staticmethod
547
+ def _errors(body: Any) -> list[dict]:
548
+ errors = (body or {}).get("Errors") if isinstance(body, dict) else None
549
+ return [e for e in errors or [] if isinstance(e, dict)]
550
+
551
+ def _is_rate_limited(self, response, body) -> bool:
552
+ if response.status_code == 429:
553
+ return True
554
+ return any(
555
+ re.search(
556
+ r"toomany|too many|maximum calls|exceeded",
557
+ f"{e.get('Code')} {e.get('Message')}",
558
+ re.I,
559
+ )
560
+ for e in self._errors(body)
561
+ )
562
+
563
+ def _post(self, path: str, body: dict[str, Any]) -> list[PartInfo]:
564
+ data = self._send(
565
+ "POST",
566
+ f"{self.base}/{path}",
567
+ params={"apiKey": _env("MOUSER_API_KEY", "MOUSER_SEARCH_API_KEY")},
568
+ json=body,
569
+ )
570
+ errors = self._errors(data)
571
+ if errors:
572
+ raise ProviderError(f"mouser: {errors[0].get('Message') or errors[0].get('Code')}")
573
+ return [self.parse(p) for p in ((data or {}).get("SearchResults") or {}).get("Parts") or []]
574
+
575
+ def lookup_mpn(self, mpn: str) -> PartInfo | None:
576
+ parts = self._post(
577
+ "partnumber",
578
+ {"SearchByPartRequest": {"mouserPartNumber": mpn, "partSearchOptions": "Exact"}},
579
+ )
580
+ return best_mpn_match(mpn, parts)
581
+
582
+ def search(self, query: SearchQuery) -> list[PartInfo]:
583
+ options = "InStock" if query.in_stock else "None"
584
+ return self._post(
585
+ "keyword",
586
+ {
587
+ "SearchByKeywordRequest": {
588
+ "keyword": f"{query.text} {query.package or ''}".strip(),
589
+ "records": min(query.limit, 50),
590
+ "startingRecord": 0,
591
+ "searchOptions": options,
592
+ }
593
+ },
594
+ )
595
+
596
+ @staticmethod
597
+ def parse(part: dict[str, Any]) -> PartInfo:
598
+ attributes = {
599
+ str(a.get("AttributeName")): str(a.get("AttributeValue"))
600
+ for a in part.get("ProductAttributes") or []
601
+ if a.get("AttributeName") and a.get("AttributeValue")
602
+ }
603
+ for item in part.get("ProductCompliance") or []:
604
+ name, value = item.get("ComplianceName"), item.get("ComplianceValue")
605
+ if name and value:
606
+ attributes[f"Compliance {name}"] = str(value)
607
+ breaks = []
608
+ for b in part.get("PriceBreaks") or []:
609
+ currency = b.get("Currency") or "USD"
610
+ price = parse_price(str(b.get("Price") or ""), currency)
611
+ if b.get("Quantity") and price is not None:
612
+ breaks.append(PriceBreak(int(b["Quantity"]), price, currency))
613
+ breaks.sort(key=lambda b: b.quantity)
614
+ raw_stock = part.get("AvailabilityInStock")
615
+ if raw_stock in (None, ""):
616
+ match = re.match(r"\s*([\d,]+)\s+In Stock", str(part.get("Availability") or ""))
617
+ raw_stock = match.group(1).replace(",", "") if match else None
618
+ stock = int(raw_stock) if raw_stock not in (None, "") and str(raw_stock).isdigit() else None
619
+ lifecycle = normalize_lifecycle(part.get("LifecycleStatus"))
620
+ if str(part.get("IsDiscontinued", "")).lower() == "true" and lifecycle in ("", "normal"):
621
+ lifecycle = "discontinued"
622
+ moq = str(part.get("Min") or "")
623
+ mult = str(part.get("Mult") or "")
624
+ notes = []
625
+ if part.get("SuggestedReplacement"):
626
+ notes.append(f"Mouser suggests replacement {part['SuggestedReplacement']}")
627
+ on_order = part.get("AvailabilityOnOrder") or []
628
+ if stock == 0 and on_order:
629
+ first = on_order[0]
630
+ notes.append(f"{first.get('Quantity')} on order, expected {first.get('Date')}")
631
+ offer = SupplierOffer(
632
+ supplier="Mouser",
633
+ sku=str(part.get("MouserPartNumber") or ""),
634
+ stock=stock,
635
+ price_breaks=breaks,
636
+ moq=int(moq) if moq.isdigit() else None,
637
+ multiple=int(mult) if mult.isdigit() else None,
638
+ lead_time_days=_lead_days(part.get("LeadTime") or ""),
639
+ packaging="Reel" if part.get("Reeling") else "",
640
+ lifecycle=lifecycle,
641
+ authorised=True,
642
+ url=str(part.get("ProductDetailUrl") or ""),
643
+ source="mouser",
644
+ notes=notes,
645
+ )
646
+ return PartInfo(
647
+ mpn=str(part.get("ManufacturerPartNumber") or ""),
648
+ manufacturer=str(part.get("Manufacturer") or ""),
649
+ description=str(part.get("Description") or ""),
650
+ package=attributes.get("Package / Case", ""),
651
+ category=str(part.get("Category") or ""),
652
+ stock=stock,
653
+ stock_by_source={"mouser": stock} if stock is not None else {},
654
+ price_breaks=breaks,
655
+ datasheet_url=str(part.get("DataSheetUrl") or ""),
656
+ product_url=str(part.get("ProductDetailUrl") or ""),
657
+ lifecycle=lifecycle,
658
+ attributes=attributes,
659
+ sources=["mouser"],
660
+ offers=[offer],
661
+ )