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/librarian.py ADDED
@@ -0,0 +1,963 @@
1
+ """One command from BOM to library: triage the BOM, fill every missing symbol and footprint, write the Altium
2
+ models and the altium365 plan, run the release check, and stop with a review list.
3
+
4
+ python3 -m ecadlib.librarian examples/sample_bom.csv --lib examples/kicad_lib --lib out/footprints -o out/librarian \
5
+ [--sources kicad,easyeda,standard,datasheet] [--allow PART] [--box] [--refresh [PART ...]] [--refresh-index] [--offline]
6
+
7
+ Per plan item (one per unique part), parts the triage found in a --lib library are converted from there; for
8
+ anything still missing, the sources are tried in --sources order, each filling only what is still missing:
9
+ kicad the official KiCad libraries on gitlab.com. A name index of kicad-symbols (KiCad 9 <Lib>.kicad_symdir/
10
+ <Name>.kicad_sym, so derived symbols/aliases are names too) and kicad-footprints is built once from the
11
+ GitLab tree API and cached (<cache>/kicad_library_index.json, 30 days; a build stopped by a timeout or
12
+ HTTP 429 resumes from the cached pages after a pause of 15 min / 1 h, in which runs skip gitlab.com).
13
+ Symbol by MPN: exact name, a name with KiCad's `x` wildcards (STM32F103C8Tx), or the MPN minus an
14
+ ordering suffix (TPS62162DSG for TPS62162DSGR); generic connectors by value (Conn_01x04). Footprint: the BOM footprint name, the symbol's
15
+ Footprint property, a footprint named after the MPN, or the shortest name starting with the BOM footprint.
16
+ R/C/L/LED use Device:R/C/C_Polarized/L/LED and <Family>_SMD:<X>_<imperial>_<metric>Metric by package.
17
+ easyeda LCSC number (BOM, or found by MPN in triage) -> EasyEDA component JSON -> easyeda_convert -> KiCad text
18
+ standard no network, no API: chip passives and standard packages (SOT-23, SOIC-8, QFN-32 5x5 ...) built from the
19
+ checked package table, symbols where the pinout is standard (ecadlib/stdparts.py); always after kicad
20
+ datasheet datasheet URL (BOM column, KiCad symbol, EasyEDA/LCSC link, LCSC pattern) + ANTHROPIC_API_KEY ->
21
+ ecadlib.datasheet; the result is marked `verify` so release_check holds it until a person has looked (or
22
+ --allow). --datasheet-batch sends every part that reaches this source as one Message Batch (half price)
23
+ after the other sources ran, then builds them; results are cached, so re-runs are free
24
+ Footprints without a KiCad STEP model get a package-shaped STEP from step_gen embedded (--box: extruded box).
25
+ Everything is converted through kicad_import into <out>/models/{SchLib,PcbLib} (plus the .kicad_mod in
26
+ <out>/models/KiCad for footprint_check). Outputs: <out>/plan.json (each item records `source`, `librarian.tried`
27
+ and the release-check result), <out>/review.{md,csv}, <out>/release_check.{md,csv}, <out>/triage.{md,csv}.
28
+ Resumable: <out>/librarian_state.json remembers finished parts; a re-run skips them unless --refresh (all, or the
29
+ named parts) or their BOM line changed. --allow PART is recorded in plan.json and survives re-runs, and so does a
30
+ symbol or footprint chosen with `ecadlib resolve` (held parts: see ecadlib/resolve.py).
31
+ A part already in the Altium 365 workspace (an item `ecadlib a365 pull` put in <out>/plan.json, matched by name or
32
+ MPN) is reused as it is: nothing is fetched or converted for it, and pulled items the BOM does not use stay in the plan."""
33
+ import argparse, csv, json, os, re, sys, time
34
+ from pathlib import Path
35
+
36
+ import pandas as pd
37
+
38
+ from . import __version__
39
+ from . import (a365_sync as A365S, altium365 as A365, altium_lib as AL, bom_triage as T, datasheet as D, easyeda_convert as EC,
40
+ footprint_gen as F, fp_share as FS, kicad_import as K, providers as P, release_check as RC, rules as RU, sexpr)
41
+ from .fsutil import write_text_atomic
42
+ from .httpcache import CachedHTTP
43
+ from . import crosscheck as CC, messages as M, sheetsafe as SS
44
+ from . import fp_tidy as FT, symbol_layout as SL
45
+ from .kicad_index import KiCadIndex, METRIC_TO_IMPERIAL, norm_mpn, package_of
46
+
47
+ TREE = "https://gitlab.com/api/v4/projects/kicad%2Flibraries%2F{repo}/repository/tree?ref=master&recursive=true&per_page=100&page={page}"
48
+ SOURCES = ("kicad", "easyeda", "standard", "datasheet")
49
+ IMP2MET = {v: k for k, v in METRIC_TO_IMPERIAL.items()}
50
+ PASSIVE = {"R": ("Device:R", "Resistor_SMD:R_{}_{}Metric"), "C": ("Device:C", "Capacitor_SMD:C_{}_{}Metric"),
51
+ "CP": ("Device:C_Polarized", None), "L": ("Device:L", "Inductor_SMD:L_{}_{}Metric"), "LED": ("Device:LED", "LED_SMD:LED_{}_{}Metric")}
52
+ SPECS = Path(F.__file__).with_name("package_specs")
53
+
54
+
55
+ # ---------------------------------------------------------------- official KiCad library name index
56
+ class KiCadLibrary:
57
+ """Name index of the official KiCad libraries (see the module docstring). Building it reads ~400 GitLab tree pages;
58
+ each page is kept in the HTTP cache, so a build that stops (timeout, HTTP 429) resumes where it stopped. After a
59
+ failed build, <index>.retry.json holds the next attempt back for RETRY_AFTER s (RETRY_AFTER_429 after a 429) so
60
+ every run in between goes straight to the other sources instead of waiting on gitlab.com again."""
61
+ PAGE_TIMEOUT = 20 # s per tree page (the client's own timeout is 60 s)
62
+ RETRY_AFTER, RETRY_AFTER_429 = 15 * 60, 60 * 60
63
+
64
+ def __init__(self, http, cache_file, refresh=False, max_age=30 * 86400, now=time.time):
65
+ self.http, self.error, self.symbols, self.footprints = http, None, {}, {}
66
+ f = Path(cache_file)
67
+ retry = f.with_name(f.name + ".retry.json")
68
+ wait = self._backoff(retry, now()) if not refresh else None
69
+ if f.exists() and not refresh and (getattr(http, "offline", False) or now() - f.stat().st_mtime < max_age):
70
+ self._load(f)
71
+ elif wait:
72
+ self.error = wait
73
+ if f.exists(): # a stale index beats none
74
+ self._load(f)
75
+ else:
76
+ try:
77
+ self.symbols = self._crawl("kicad-symbols", ".kicad_symdir", ".kicad_sym")
78
+ self.footprints = self._crawl("kicad-footprints", ".pretty", ".kicad_mod")
79
+ f.parent.mkdir(parents=True, exist_ok=True)
80
+ f.write_text(json.dumps({"built": time.strftime("%Y-%m-%d"), "symbols": self.symbols, "footprints": self.footprints}), encoding="utf-8")
81
+ retry.unlink(missing_ok=True)
82
+ except IOError as e:
83
+ self.error = str(e)
84
+ self.symbols, self.footprints = {}, {}
85
+ if not getattr(http, "offline", False):
86
+ try:
87
+ retry.parent.mkdir(parents=True, exist_ok=True)
88
+ hold = self.RETRY_AFTER_429 if "HTTP 429" in self.error else self.RETRY_AFTER
89
+ retry.write_text(json.dumps({"after": now() + hold, "error": self.error}), encoding="utf-8")
90
+ except OSError:
91
+ pass
92
+ if f.exists(): # a stale index beats none
93
+ self._load(f)
94
+ self._norm, self._wild, self._fpn = {}, [], []
95
+ for name, libs in self.symbols.items():
96
+ self._norm.setdefault(norm_mpn(name), []).extend(f"{l}:{name}" for l in libs)
97
+ if re.search(r"(?<![0-9])x|x(?![0-9])", name) and re.search(r"[A-Z0-9]x", name) and not any(l.startswith("Connector") for l in libs):
98
+ rx = "".join("[A-Z0-9]" if c == "x" else re.escape(c.upper()) for c in re.sub(r"[^A-Za-z0-9]", "", name))
99
+ self._wild.append((re.compile(rx), [f"{l}:{name}" for l in libs]))
100
+ self._fpn = [(norm_mpn(n), f"{libs[0]}:{n}") for n, libs in self.footprints.items()]
101
+
102
+ def _load(self, f):
103
+ d = json.loads(f.read_text(encoding="utf-8"))
104
+ self.symbols, self.footprints = d["symbols"], d["footprints"]
105
+
106
+ @staticmethod
107
+ def _backoff(retry, t):
108
+ """Why the index is not fetched now (a recent failed build), or None."""
109
+ try:
110
+ d = json.loads(retry.read_text(encoding="utf-8"))
111
+ after = float(d["after"])
112
+ except (OSError, ValueError, KeyError, TypeError):
113
+ return None
114
+ if t >= after:
115
+ return None
116
+ return (f"{d.get('error') or 'the last build failed'}; not retried until "
117
+ f"{time.strftime('%H:%M', time.localtime(after))} (--refresh-index retries now)")
118
+
119
+ def _get(self, url):
120
+ try:
121
+ return self.http.get(url, timeout=self.PAGE_TIMEOUT)
122
+ except TypeError: # a client without a per-request timeout
123
+ return self.http.get(url)
124
+
125
+ def _crawl(self, repo, dsuf, fsuf):
126
+ out = {}
127
+ for page in range(1, 2000):
128
+ r = self._get(TREE.format(repo=repo, page=page))
129
+ if not r.ok:
130
+ raise IOError(f"{repo} tree page {page}: {r.error or f'HTTP {r.status}'}")
131
+ items = r.json()
132
+ for it in items:
133
+ lib, _, fn = it.get("path", "").partition("/")
134
+ if it.get("type") == "blob" and lib.endswith(dsuf) and fn.endswith(fsuf) and "/" not in fn:
135
+ out.setdefault(fn[:-len(fsuf)], []).append(lib[:-len(dsuf)])
136
+ if len(items) < 100:
137
+ return out
138
+ raise IOError(f"{repo}: tree listing did not end")
139
+
140
+ @staticmethod
141
+ def _pick(refs):
142
+ refs = sorted(dict.fromkeys(refs))
143
+ return refs[0], (f" (also in {', '.join(r.split(':')[0] for r in refs[1:])})" if len(refs) > 1 else "")
144
+
145
+ def find_symbol(self, mpn):
146
+ """MPN -> ('Lib:Name', how) or (None, None)."""
147
+ nm = norm_mpn(mpn)
148
+ if len(nm) < 3:
149
+ return None, None
150
+ if nm in self._norm:
151
+ ref, more = self._pick(self._norm[nm])
152
+ return ref, "exact name" + more
153
+ hits = [r for rx, refs in self._wild if rx.fullmatch(nm) for r in refs]
154
+ if hits:
155
+ ref, more = self._pick(hits)
156
+ return ref, "name with KiCad x wildcards" + more
157
+ raw = [i for i, c in enumerate(str(mpn)) if c.isalnum()] # positions of the normalised characters
158
+ for k in range(len(nm) - 1, max(5, int(len(nm) * 0.6)) - 1, -1):
159
+ sep = raw[k] > raw[k - 1] + 1 # a '-', '/', '(' ... right before the suffix
160
+ if nm[:k] in self._norm and (len(nm) - k <= 2 or (sep and len(nm) - k <= 6)):
161
+ ref, more = self._pick(self._norm[nm[:k]])
162
+ return ref, f"name + ordering suffix '{str(mpn)[raw[k]:]}' (check the variant)" + more
163
+ return None, None
164
+
165
+ def generic_connector(self, value):
166
+ for n in (value, f"{value}_Odd_Even"):
167
+ libs = [l for l in self.symbols.get(n, []) if l == "Connector_Generic"]
168
+ if libs and re.fullmatch(r"Conn_\d\dx\d\d.*", n):
169
+ return f"{libs[0]}:{n}", f"generic connector symbol by value {value}"
170
+ return None, None
171
+
172
+ def footprint(self, ref):
173
+ if not ref:
174
+ return None
175
+ lib, _, name = ref.rpartition(":")
176
+ libs = self.footprints.get(name, [])
177
+ return f"{lib}:{name}" if lib in libs else f"{libs[0]}:{name}" if libs and not lib else None
178
+
179
+ def footprint_by_mpn(self, mpn):
180
+ nm = norm_mpn(re.sub(r"\(.*", "", str(mpn)))
181
+ hits = sorted((len(n), ref) for n, ref in self._fpn if len(nm) >= 6 and nm in n)
182
+ return hits[0][1] if hits else None
183
+
184
+ def footprint_by_prefix(self, name):
185
+ hits = sorted((len(n), f"{libs[0]}:{n}") for n, libs in self.footprints.items() if name and n.startswith(name + "_"))
186
+ return hits[0][1] if hits else None
187
+
188
+
189
+ # ---------------------------------------------------------------- helpers
190
+ def passive_kind(item):
191
+ cat, fp = item["folder"].split("/")[-1], item["footprint"].get("kicad") or item["footprint"]["ref"] or ""
192
+ if cat == "Resistors":
193
+ return "R"
194
+ if cat == "Capacitors":
195
+ return "CP" if fp.startswith("CP_") else "C"
196
+ if cat == "Inductors":
197
+ return "L"
198
+ if cat == "Diodes" and (fp.startswith("LED_") or re.search(r"\bLED\b", item.get("description") or "", re.I)):
199
+ return "LED"
200
+ return None
201
+
202
+
203
+ def _par(item):
204
+ return {p["name"]: p["value"] for p in item.get("parameters", [])}
205
+
206
+
207
+ def _set_par(item, k, v):
208
+ item["parameters"] = [p for p in item["parameters"] if p["name"] != k] + [{"name": k, "value": v}]
209
+ item["cleared"] = [c for c in item.get("cleared", []) if c != k]
210
+
211
+
212
+ def _line_for(item, lines, rows):
213
+ """The BOM line and triage row of a plan item: same MPN, else (no MPN) same value. Of several, a fitted line wins
214
+ over a DNP one (R5 DNP and R1 share the MPN), then the line whose footprint is the item's (10k 0603 and 10k 0402
215
+ are two items with one value)."""
216
+ par = _par(item)
217
+ mpn, val = par.get("Manufacturer Part Number", ""), par.get("Value", "")
218
+ fp = item.get("footprint") or {}
219
+ want = {AL.lib_name(str(x).split(":")[-1]) for x in (fp.get("kicad"), fp.get("bom_ref"), fp.get("ref")) if x}
220
+ best, score = ({}, {}), None
221
+ for ln, row in zip(lines, rows):
222
+ if (mpn and ln.get("mpn") == mpn) or (not mpn and not ln.get("mpn") and ln.get("value", "") == val):
223
+ fps = {AL.lib_name(str(x).split(" ")[0].split(":")[-1]) for x in (ln.get("footprint"), row.get("local_footprint")) if x}
224
+ s = (not ln.get("dnp") and not str(row.get("status") or "").startswith("dnp"), bool(fps & want))
225
+ if score is None or s > score:
226
+ best, score = (ln, row), s
227
+ return best
228
+
229
+
230
+ class Run:
231
+ def __init__(self, out, http, index, libs, sources, api_key=None, kicad_cache=None, refresh_index=False, gen_step=True,
232
+ rules=None, reader=None):
233
+ self.out, self.http, self.index, self.libs, self.sources = Path(out), http, index, libs, sources
234
+ self.rules = rules or RU.DEFAULTS
235
+ self.models = self.out / "models"
236
+ for d in ("PcbLib", "SchLib", "KiCad"):
237
+ (self.models / d).mkdir(parents=True, exist_ok=True)
238
+ self.api_key, self.written, self.gen_step = api_key, {}, gen_step
239
+ self.syms, self.hashes = {}, {} # SchLib path -> symbol dict; kicad_mod -> geometry hash
240
+ self._kicad, self._kicad_args = None, (kicad_cache, refresh_index)
241
+ self.reader, self.waiting = reader, [] # datasheet reader; parts waiting for its batch
242
+
243
+ def kicad(self):
244
+ if self._kicad is None:
245
+ f = Path(self._kicad_args[0] or "")
246
+ held = self._kicad_args[0] and KiCadLibrary._backoff(f.with_name(f.name + ".retry.json"), time.time())
247
+ if self._kicad_args[0] and not self.http.offline and (self._kicad_args[1] or not f.exists()) and \
248
+ (self._kicad_args[1] or not held):
249
+ getattr(self, "log", print)(" building the KiCad library name index from gitlab.com (first run only, "
250
+ "can take a few minutes; cached for 30 days; resumes if it stops) ...")
251
+ self._kicad = KiCadLibrary(self.http, *self._kicad_args)
252
+ return self._kicad
253
+
254
+ def rel(self, p):
255
+ return os.path.relpath(p, self.out).replace(os.sep, "/")
256
+
257
+ # ---- writers: everything goes through kicad_import's converters
258
+ def put_fp(self, st, text, name, source, http=None, height=None, height_src="", spec=None):
259
+ """One file per footprint, shared only by parts whose pads are the same (ecadlib/fp_share.py)."""
260
+ bom_name = name
261
+ name, pcb, same, h = FS.pick(self.written, self.models, name, text, source, st["name"], self.out, self.hashes)
262
+ renamed = {"renamed_from": bom_name} if name != bom_name else {}
263
+ if same is not None:
264
+ st["footprint"] = {**{k: v for k, v in same.items() if k not in ("renamed_from", "chosen")},
265
+ "notes": [f"shared with {same['by']}"], "name": name, "hash": h, **renamed}
266
+ self._relink(st, bom_name, name)
267
+ return
268
+ pcb, stem = Path(pcb), AL.file_stem(name)
269
+ notes = [f"named {name}: footprint {bom_name} is another part's, with other pads"] if renamed else []
270
+ fp, t = K.convert_footprint(text, name, source.split(":", 1)[-1], notes)
271
+ try:
272
+ K.add_body(fp, t, http, height, False, notes, gen_step=self.gen_step, spec=spec, # a part-specific
273
+ height_src=(height_src or "caller") if height else None, # height beats the table
274
+ kind=st.get("kind"), names=st.get("names", ()))
275
+ except ValueError as e:
276
+ notes.append(f"no 3D body: {e}")
277
+ kmod = self.models / "KiCad" / f"{stem}.kicad_mod"
278
+ text = re.sub(r'^(\s*\((?:footprint|module)\s+)("(?:[^"\\]|\\.)*"|[^\s()]+)', # KiCad links a footprint by its file name,
279
+ lambda m: m.group(1) + '"' + stem.replace('"', '\\"') + '"', text, count=1) # Altium by this name: one name
280
+ text = re.sub(r'(\((?:fp_text\s+value|property\s+"Value")\s+)("(?:[^"\\]|\\.)*"|[^\s()]+)', # Value = the name (KLC F9.1)
281
+ lambda m: m.group(1) + '"' + stem.replace('"', '\\"') + '"', text, count=1)
282
+ kmod.write_text(text, encoding="utf-8", newline="\n")
283
+ AL.write_pcblib(pcb, [fp], RU.layers(self.rules))
284
+ st["footprint"] = {"source": source, "file": self.rel(pcb), "kicad_mod": self.rel(kmod), "notes": notes, "by": st["name"],
285
+ "name": name, "hash": h, **renamed}
286
+ self.written[str(pcb)] = st["footprint"]
287
+ self._relink(st, bom_name, name)
288
+
289
+ def _relink(self, st, bom_name, name):
290
+ """A footprint renamed after this part's symbol was written: point the symbol's footprint link at it."""
291
+ rec = st.get("symbol") or {}
292
+ path = str(self.out / rec["file"]) if rec.get("file") else ""
293
+ sym = self.syms.get(path)
294
+ if name == bom_name or rec.get("by") != st["name"] or rec.get("chosen") or not sym or sym.get("footprint") != bom_name:
295
+ return
296
+ self.syms[path] = sym = dict(sym, footprint=name)
297
+ AL.write_schlib(path, [sym])
298
+ rec.setdefault("notes", []).append(f"footprint link {name} (the BOM's {bom_name} is another footprint)")
299
+
300
+ def put_sym(self, st, sym, source, notes=()):
301
+ path = self.models / "SchLib" / f"{AL.file_stem(sym['name'])}.SchLib"
302
+ fr = st.get("footprint") or {}
303
+ if fr.get("renamed_from") and sym.get("footprint") == fr["renamed_from"]: # footprint renamed (fp_share)
304
+ sym = dict(sym, footprint=fr["name"])
305
+ if str(path) in self.written:
306
+ st["symbol"] = dict(self.written[str(path)], notes=[f"shared with {self.written[str(path)]['by']}"])
307
+ return
308
+ AL.write_schlib(path, [sym])
309
+ self.syms[str(path)] = sym
310
+ st["symbol"] = {"source": source, "file": self.rel(path), "notes": list(notes), "by": st["name"]}
311
+ self.written[str(path)] = st["symbol"]
312
+
313
+ def put_kicad_sym(self, st, chain, name, fp_name, source, extra=(), layout=False):
314
+ """layout: re-lay plain IC symbols out by the house pin rule (EasyEDA symbols). KiCad library symbols are curated
315
+ and kept as drawn, unless their pin text would overlap in Altium (symbol_layout.text_problems)."""
316
+ notes = []
317
+ sym = K.convert_symbol(chain, name, fp_name, list(extra), None, notes)
318
+ npins = len({pr[1]["num"] for pr in sym["prims"] if pr[0] == "pin"})
319
+ clash = SL.text_problems(sym)
320
+ if (layout or clash) and SL.wants_layout(npins, self.rules) and SL.is_box_ic(sym):
321
+ sym = SL.relayout(sym, self.rules)
322
+ notes.append(f"{npins} pins grouped by function (ecadlib.symbol_layout)" + (f", {sym['parts']} parts" if sym.get("parts", 1) > 1 else "")
323
+ + (f"; the drawn symbol's pin text overlapped ({clash[0]})" if clash else ""))
324
+ elif clash:
325
+ notes.append(f"pin text overlaps ({'; '.join(clash[:3])}); not a plain IC box, so kept as drawn: check it")
326
+ self.put_sym(st, sym, source, notes)
327
+
328
+ # ---- per part
329
+ def resolve(self, item, ln, row, keep=None):
330
+ """keep: halves a person chose with `ecadlib resolve` ({kind: state record}); they are not re-fetched."""
331
+ par = _par(item)
332
+ mpn = par.get("Manufacturer Part Number", "")
333
+ st = {"name": item["name"], "symbol": None, "footprint": None, "tried": [], "review": [], "verify": [],
334
+ "found": {}, "hint_fp": ""}
335
+ for k, rec in (keep or {}).items():
336
+ st[k] = rec
337
+ st["tried"].append(f"resolve: {k} {rec['source']} chosen by user (kept)")
338
+ if (row.get("status") or "").startswith("dnp"):
339
+ st["tried"].append("DNP line: not fetched")
340
+ return st
341
+ kind, fp_name, sym_name = passive_kind(item), item["footprint"]["ref"], item["symbol"]["ref"]
342
+ st["kind"], st["names"] = kind, [item["footprint"].get("kicad") or "", ln.get("footprint") or "", item.get("description") or ""]
343
+ try:
344
+ self.local(st, row, kind, fp_name, sym_name)
345
+ except Exception as e:
346
+ st["tried"].append(f"local library: failed ({type(e).__name__}: {e})")
347
+ for s in self.sources:
348
+ if st["symbol"] and st["footprint"]:
349
+ break
350
+ try:
351
+ getattr(self, f"src_{s}")(st, item, ln, row, kind, mpn, fp_name, sym_name)
352
+ except Exception as e: # one bad source or part must not stop the run
353
+ st["tried"].append(f"{s}: failed ({type(e).__name__}: {e})")
354
+ lc = (row.get("lcsc") or "").split(" ")[0]
355
+ if row.get("src_description"): # the part search's own text (JLCPCB / LCSC), after a KiCad symbol's
356
+ st["found"].setdefault("description", (row["src_description"], f"LCSC/JLCPCB part data {lc}".strip()))
357
+ return st
358
+
359
+ def local(self, st, row, kind, fp_name, sym_name):
360
+ ls, lf = (row.get("local_symbol") or "").split(" ")[0], (row.get("local_footprint") or "").split(" ")[0]
361
+ if ls and not kind and not st["symbol"]:
362
+ lib, name = ls.split(":", 1)
363
+ f = next((p for r in self.libs for p in Path(r).rglob(f"{lib}.kicad_sym")), None)
364
+ if f:
365
+ chain, _ = K.load_symbol(f"{f}:{name}", None)
366
+ st["hint_fp"] = K._props(chain).get("Footprint", "")
367
+ self.put_kicad_sym(st, chain, sym_name, fp_name, f"local:{ls}")
368
+ if lf and lf in self.index.footprints and not st["footprint"]:
369
+ text = Path(self.index.footprints[lf]["path"]).read_text(encoding="utf-8")
370
+ spec = SPECS / f"{lf.split(':')[-1]}.json"
371
+ hm = json.loads(spec.read_text(encoding="utf-8")).get("height_max") if spec.exists() else None
372
+ self.put_fp(st, text, fp_name, f"local:{lf}", None, F.dim(hm)[2] if hm else None, f"package spec {spec.name}")
373
+ if ls or lf:
374
+ st["tried"].append(f"local library: symbol {'from ' + ls if st['symbol'] else 'not used' if ls else 'none'}, "
375
+ f"footprint {'from ' + lf if st['footprint'] else 'none'}")
376
+
377
+ def src_kicad(self, st, item, ln, row, kind, mpn, fp_name, sym_name):
378
+ lib = self.kicad()
379
+ if not lib.symbols:
380
+ st["tried"].append(f"kicad: library index unavailable ({lib.error})")
381
+ return
382
+ sym_ref = how = fp_ref = fp_how = None
383
+ if kind:
384
+ sym_ref, how = PASSIVE[kind][0], "generic passive symbol"
385
+ pkg = package_of(item["footprint"].get("kicad") or fp_name, item.get("description"))
386
+ if PASSIVE[kind][1] and pkg in IMP2MET:
387
+ fp_ref, fp_how = lib.footprint(PASSIVE[kind][1].format(pkg, IMP2MET[pkg])), f"by package {pkg}"
388
+ elif not st["symbol"]:
389
+ sym_ref, how = lib.find_symbol(mpn)
390
+ if not sym_ref:
391
+ sym_ref, how = lib.generic_connector(ln.get("value", ""))
392
+ chain = None
393
+ if sym_ref and not st["symbol"]:
394
+ chain, _ = K.load_symbol(sym_ref, self.http)
395
+ props = K._props(chain)
396
+ st["hint_fp"] = st["hint_fp"] or props.get("Footprint", "")
397
+ if str(props.get("Datasheet", "")).startswith("http"):
398
+ st["found"].setdefault("datasheet", (props["Datasheet"], f"KiCad symbol {sym_ref}"))
399
+ if props.get("Description"):
400
+ st["found"].setdefault("description", (props["Description"], f"KiCad symbol {sym_ref}"))
401
+ shared = kind in ("R", "C", "CP", "L") # one generic symbol for every value: no MPN, no footprint link
402
+ extra = [] if shared or not mpn else [("MPN", mpn)]
403
+ self.put_kicad_sym(st, chain, sym_name, None if shared else fp_name, f"kicad:{sym_ref}", extra)
404
+ st["tried"].append(f"kicad: symbol {sym_ref} ({how})")
405
+ if "check" in how:
406
+ st["review"].append(f"symbol {sym_ref} matched by {how}")
407
+ elif not st["symbol"]:
408
+ st["tried"].append(f"kicad: no symbol named like {mpn or '(no MPN)'}")
409
+ if st["footprint"]:
410
+ return
411
+ bom_fp = ln.get("footprint", "")
412
+ for cand, why in ((fp_ref, fp_how), (lib.footprint(st["hint_fp"]), "symbol's Footprint property"), (lib.footprint(bom_fp), "BOM footprint name"),
413
+ (None if kind else lib.footprint_by_mpn(mpn), "footprint named after the MPN"),
414
+ (lib.footprint_by_prefix(bom_fp.split(":")[-1]), f"shortest KiCad name starting with '{bom_fp}'")):
415
+ if cand:
416
+ text, _ = K.load_footprint(cand, self.http)
417
+ self.put_fp(st, text, fp_name, f"kicad:{cand}", self.http)
418
+ st["tried"].append(f"kicad: footprint {cand} ({why})")
419
+ if why.startswith(("shortest", "footprint named")):
420
+ st["review"].append(f"footprint {cand} chosen by {why}: compare with the datasheet land pattern")
421
+ return
422
+ st["tried"].append(f"kicad: no footprint for '{bom_fp or fp_name}'")
423
+
424
+ def src_easyeda(self, st, item, ln, row, kind, mpn, fp_name, sym_name):
425
+ m = re.match(r"C\d+", ln.get("lcsc") or row.get("lcsc") or "")
426
+ if not m:
427
+ st["tried"].append("easyeda: no LCSC number")
428
+ return
429
+ lcsc = m.group(0)
430
+ r = self.http.get(P.EasyEDA.URL.format(lcsc), headers=P.EasyEDA.HEADERS)
431
+ if not r.ok:
432
+ st["tried"].append(f"easyeda {lcsc}: {r.error or f'HTTP {r.status}'}")
433
+ return
434
+ data = r.json()
435
+ res = P.parse_easyeda(data, P.SourceResult("easyeda", lcsc=lcsc))
436
+ if not res.found:
437
+ st["tried"].append(f"easyeda {lcsc}: no component")
438
+ return
439
+ if mpn and res.mpn and norm_mpn(res.mpn) != norm_mpn(mpn):
440
+ st["tried"].append(f"easyeda {lcsc}: is {res.mpn}, BOM says {mpn}; not used")
441
+ st["review"].append(f"LCSC {lcsc} is {res.mpn}, not {mpn}: fix the BOM's LCSC number")
442
+ return
443
+ result = data["result"]
444
+ url = (result.get("lcsc") or {}).get("url") or ""
445
+ if url.startswith("http"):
446
+ st["found"].setdefault("datasheet", (url, f"EasyEDA {lcsc} (lcsc.url)"))
447
+ if res.manufacturer:
448
+ st["found"].setdefault("manufacturer", (res.manufacturer, f"EasyEDA {lcsc}"))
449
+ st["found"].setdefault("lcsc", (lcsc, f"EasyEDA {lcsc}" + ("" if ln.get("lcsc") else " (LCSC number found by ecadlib, not in the BOM)")))
450
+ if res.extra.get("description"):
451
+ st["found"].setdefault("description", (res.extra["description"], f"EasyEDA {lcsc}"))
452
+ if res.package:
453
+ st["found"].setdefault("package", (res.package, f"EasyEDA {lcsc}"))
454
+ got = []
455
+ if not st["footprint"]:
456
+ pkg = (result.get("packageDetail") or {})
457
+ notes = []
458
+ text = EC.footprint_to_kicad(pkg.get("dataStr") or {}, pkg.get("title") or fp_name, notes)
459
+ text = FT.tidy(text, notes) # to the rule book: silk reference, pin 1, rounded pads
460
+ self.put_fp(st, text, fp_name, f"easyeda:{lcsc}", None)
461
+ st["footprint"]["notes"] = notes + st["footprint"]["notes"]
462
+ got.append(f"footprint {pkg.get('title', '')}")
463
+ if not st["symbol"]:
464
+ ds = result.get("dataStr") or {}
465
+ if not ds.get("shape") and result.get("subparts"):
466
+ st["tried"].append(f"easyeda {lcsc}: multi-unit symbol (subparts) not converted")
467
+ else:
468
+ notes = []
469
+ text = EC.symbol_to_kicad(ds, sym_name, fp_name, {"MPN": mpn, "LCSC": lcsc, "Manufacturer": res.manufacturer}, notes)
470
+ chain = [K._find_symbol(sexpr.parse(text), sym_name)]
471
+ self.put_kicad_sym(st, chain, sym_name, fp_name, f"easyeda:{lcsc}", [("MPN", mpn)] if mpn else [], layout=True)
472
+ st["symbol"]["notes"] = notes + st["symbol"]["notes"]
473
+ got.append("symbol")
474
+ if got:
475
+ st["found"].setdefault("credit", (EC.CREDIT, f"EasyEDA {lcsc}"))
476
+ st["tried"].append(f"easyeda {lcsc}: " + (", ".join(got) or "nothing needed"))
477
+ st["review"].append(f"EasyEDA {lcsc} conversion: check pads and pins against the datasheet (native converter, format assumed)")
478
+
479
+ def src_standard(self, st, item, ln, row, kind, mpn, fp_name, sym_name):
480
+ """Standard packages and pinouts, built offline (ecadlib.stdparts); fills only what is still missing."""
481
+ from . import stdparts as SP
482
+ desc = " ".join(dict.fromkeys(str(x) for x in (item.get("description"), ln.get("description")) if x))
483
+ k = kind or SP.part_kind(item["folder"], item["footprint"].get("kicad") or fp_name, desc, ln.get("designator"))
484
+ res = SP.resolve([st["hint_fp"], ln.get("footprint"), item["footprint"].get("kicad"), ln.get("value")], k, mpn, desc)
485
+ got = []
486
+ if not st["footprint"] and res["footprint"]:
487
+ sp = SP.spec(res["footprint"])
488
+ text, _ = SP.build(res["footprint"], RU.density_for(sp, self.rules))
489
+ h = F.dim(sp["height_max"])[2] if sp.get("height_max") else None
490
+ self.put_fp(st, text, fp_name, f"standard:{res['kicad_ref']}", None, h, "standard package table", sp)
491
+ got.append(f"footprint {res['kicad_ref']} ({res['fp_how']})")
492
+ fp_src = str((st["footprint"] or {}).get("source") or "")
493
+ same_fp = res["footprint"] and fp_src.endswith(":" + res["footprint"])
494
+ if not st["symbol"] and res["symbol"] and (k in ("R", "C", "CP", "L") or same_fp):
495
+ text, name = SP.symbol_text(res["symbol"], mpn)
496
+ shared = k in ("R", "C", "CP", "L")
497
+ self.put_kicad_sym(st, [K._find_symbol(sexpr.parse(text), name)], sym_name, None if shared else fp_name,
498
+ f"standard:{name}", [] if shared or not mpn else [("MPN", mpn)])
499
+ got.append(f"symbol {name} ({res['sym_how']})")
500
+ if ":" in res["symbol"]:
501
+ st["review"].append(f"symbol pinout {name} from the {mpn} family table (ecadlib.stdparts): check pins 1-3 against the datasheet")
502
+ elif not st["symbol"] and res["symbol"]:
503
+ res["sym_how"] = f"footprint came from {fp_src or 'nowhere'}, not {res['footprint']}: pin numbers not known to match"
504
+ why = [] if st["footprint"] else [f"footprint: {res['fp_how']}"]
505
+ why += [] if st["symbol"] else [f"symbol: {res['sym_how']}"]
506
+ st["tried"].append("standard: " + "; ".join(got + why))
507
+
508
+ def src_datasheet(self, st, item, ln, row, kind, mpn, fp_name, sym_name):
509
+ urls = D.datasheet_urls(item, ln, row, st["found"])
510
+ if not urls:
511
+ st["tried"].append("datasheet: no datasheet URL known")
512
+ return
513
+ if self.reader is None:
514
+ self.reader = D.Reader(self.out, Path(self._kicad_args[0] or self.out / "cache").parent, self.api_key, self.rules, self.http)
515
+ rd = self.reader
516
+ if not rd.key and not rd.estimate_only:
517
+ st["tried"].append(f"needs datasheet reader (no API key): {urls[0][0]} ({urls[0][1]})")
518
+ return
519
+ job = rd.job(mpn or item["name"], urls, fp_name, name=item["name"], fp_needed=not st["footprint"])
520
+ try:
521
+ res = hit = rd.cached(job)
522
+ if res is None and rd.deferred:
523
+ rd.submit(job)
524
+ self.waiting.append((st, job, fp_name, sym_name))
525
+ st["tried"].append(f"datasheet: queued for the batch ({job['url']}, {job['where']})")
526
+ return
527
+ res = res or rd.read_now(job)
528
+ except CC.Held as e:
529
+ self.cross_check_held(st, e)
530
+ return
531
+ self.put_datasheet(st, res, job, fp_name, sym_name, "cached" if hit else "")
532
+
533
+ @staticmethod
534
+ def cross_check_held(st, e):
535
+ """A datasheet read whose cross-check disagreed: nothing is written; both values go to the review."""
536
+ st["cross_check"] = {"status": "disagreed", "summary": CC.summary_line(e.record)}
537
+ st["tried"].append("datasheet reader: held, the cross-check disagrees (see datasheet/*.review.md)")
538
+ st["review"].append(f"cross-check disagrees, nothing written: {CC.summary_line(e.record)}")
539
+
540
+ def flush_datasheets(self, log=print):
541
+ """Send the parts queued for the datasheet batch and build what came back."""
542
+ if not self.waiting:
543
+ return
544
+ try:
545
+ done = self.reader.flush()
546
+ except D.DatasheetError as e:
547
+ done = {job["key"]: e for _, job, _, _ in self.waiting}
548
+ for st, job, fp_name, sym_name in self.waiting:
549
+ r = done.get(job["key"])
550
+ if isinstance(r, CC.Held):
551
+ self.cross_check_held(st, r)
552
+ elif isinstance(r, dict):
553
+ try:
554
+ self.put_datasheet(st, r, job, fp_name, sym_name, r["sidecar"].get("via") or "batch")
555
+ except Exception as e:
556
+ st["tried"].append(f"datasheet: failed ({type(e).__name__}: {e})")
557
+ elif isinstance(r, Exception):
558
+ st["tried"].append(f"datasheet reader: failed ({r})")
559
+ else:
560
+ st["tried"].append(f"datasheet: {r or 'not sent'}" + ("; run again to collect" if str(r).startswith("pending") else ""))
561
+ self.waiting = []
562
+
563
+ def put_datasheet(self, st, res, job, fp_name, sym_name, how=""):
564
+ url, where = job["url"], job["where"]
565
+ got = []
566
+ if not st["footprint"]:
567
+ text, _ = F.build(res["spec"], RU.density_for(res["spec"], self.rules))
568
+ h = F.dim(res["spec"]["height_max"])[2] if res["spec"].get("height_max") else None
569
+ self.put_fp(st, text, fp_name, f"datasheet:{url}", None, h, "datasheet height_max", res["spec"])
570
+ got.append("footprint")
571
+ if not st["symbol"]:
572
+ from . import house_style as HS # active-low pins in your library's notation ([style])
573
+ sym, renamed = HS.adapt_symbol(res["symbol"], HS.active(self.rules, self.out))
574
+ notes = [f"{len(sym['pins'])} pins grouped by function (ecadlib.symbol_layout)"] \
575
+ if SL.wants_layout(sym["pins"], self.rules) else []
576
+ if renamed:
577
+ notes.append("house style pin names: " + ", ".join(renamed[:6]) + (" ..." if len(renamed) > 6 else ""))
578
+ self.put_sym(st, AL.symbol_from_json(dict(sym, name=sym_name, footprint=fp_name), self.rules), f"datasheet:{url}",
579
+ notes)
580
+ got.append("symbol")
581
+ rt = res["sidecar"].get("route") or {}
582
+ n_ans = len(rt.get("attempts") or [])
583
+ st["tried"].append(f"datasheet reader ({where}{', ' + how if how else ''}): {', '.join(got) or 'nothing needed'}; "
584
+ f"{len(res['needs_review'])} review items"
585
+ + (f"; read by {rt['model']}" + (f" after {n_ans - 1} earlier answers (see review.md)" if n_ans > 1 else "")
586
+ if rt.get("model") else ""))
587
+ cc = res.get("cross_check")
588
+ if cc:
589
+ st["cross_check"] = {"status": cc["status"], "summary": CC.summary_line(cc)}
590
+ st["tried"].append(f"cross-check ({cc['mode']}): {cc['status']}: {CC.summary_line(cc)}")
591
+ st["found"].setdefault("datasheet", (url, f"datasheet reader ({where})"))
592
+ st["verify"].append(f"datasheet reader output ({', '.join(got)}): compare with {url}; see datasheet/*.review.md"
593
+ + (f" ({len(res['needs_review'])} items flagged)" if res["needs_review"] else ""))
594
+
595
+
596
+ def _fingerprint(item, ln, row):
597
+ return json.dumps([item["symbol"]["ref"], item["footprint"]["ref"], ln.get("mpn"), ln.get("lcsc"), ln.get("value"),
598
+ ln.get("footprint"), ln.get("datasheet"), row.get("local_symbol"), row.get("local_footprint")])
599
+
600
+
601
+ BUILD_REV = 2 # bump when a source's conversion changes what it writes (1: before the build_rev stamp)
602
+
603
+
604
+ def _built_now(st, rules):
605
+ """True when this ecadlib (version and BUILD_REV) built the part with these rules. A held part built otherwise is
606
+ built again on the next run: it is not in the library yet, and keeping it would make the release check judge the
607
+ old version's models and fields (silk not cut back, no Description). Ready parts are kept as they are (updates
608
+ never change a library's parts behind its back; `--refresh` rebuilds them)."""
609
+ from .rebuild import rules_hash
610
+ bw = st.get("built_with") or {}
611
+ return bw.get("ecadlib") == __version__ and st.get("build_rev") == BUILD_REV and bw.get("rules_sha256") == rules_hash(rules)
612
+
613
+
614
+ def _done(st, out):
615
+ return all(st.get(k) and (Path(out) / st[k]["file"]).exists() for k in ("symbol", "footprint"))
616
+
617
+
618
+ GENERATED_DESCRIPTION = "written by ecadlib from the part's category and package (check it)"
619
+ SINGULAR = {"ICs": "IC", "Resistors": "Resistor", "Capacitors": "Capacitor", "Inductors": "Inductor", "Diodes": "Diode",
620
+ "Transistors": "Transistor", "Connectors": "Connector", "Crystals": "Crystal", "Oscillators": "Oscillator",
621
+ "Switches": "Switch", "Relays": "Relay", "Fuses": "Fuse", "LEDs": "LED", "Sensors": "Sensor"}
622
+
623
+
624
+ def _package_name(text, mpn=""):
625
+ """'SOIC-8_3.9x4.9mm_P1.27mm' / 'SOIC-8_L4.9-W3.9-P1.27-LS6.0-BL' -> 'SOIC-8', 'CAP 0603 (1608 Metric)' -> '0603';
626
+ '' when it names no package ecadlib knows (an MPN, a maker's series)."""
627
+ t = str(text or "").split(":")[-1].strip()
628
+ pkg = package_of(t) if t and norm_mpn(t) != norm_mpn(mpn) else None
629
+ if not pkg:
630
+ return ""
631
+ first = re.split(r"[_ ]", t)[0]
632
+ return first if first.replace("-", "").upper() == pkg.upper() else pkg
633
+
634
+
635
+ def generated_description(item, st, ln):
636
+ """A plain description when no source gave one: '<category>, <package>, <manufacturer> <MPN>' from what the
637
+ plan already knows (nothing is guessed about what the part does). '' when there is not even a part number."""
638
+ par = _par(item)
639
+ mpn = str(par.get("Manufacturer Part Number") or ln.get("mpn") or "").strip()
640
+ if not mpn:
641
+ return ""
642
+ folder = str(item.get("folder") or "").rstrip("/").rsplit("/", 1)[-1]
643
+ cat = SINGULAR.get(folder, folder if folder not in ("", "Misc", "Components") else "")
644
+ pkg = next((p for p in (_package_name((st.get("found", {}).get("package") or ("",))[0], mpn),
645
+ _package_name((st.get("footprint") or {}).get("name"), mpn),
646
+ _package_name(item.get("footprint", {}).get("kicad"), mpn),
647
+ _package_name(ln.get("footprint"), mpn)) if p), "")
648
+ who = " ".join(x for x in (str(par.get("Manufacturer") or "").strip(), mpn) if x)
649
+ return ", ".join(x for x in (cat, pkg, who) if x)
650
+
651
+
652
+ def apply_state(item, st, ln):
653
+ """Copy a part's state into its plan item: model files, sources, filled-in fields, what was tried."""
654
+ for k in ("symbol", "footprint"):
655
+ if st.get(k):
656
+ item[k]["file"] = st[k]["file"]
657
+ if k == "footprint":
658
+ item[k]["kicad_mod"] = st[k].get("kicad_mod")
659
+ was = item[k].get("bom_ref") or item[k].get("ref")
660
+ if st[k].get("name") and st[k].get("renamed_from") == was: # another footprint has the BOM's name
661
+ item[k]["bom_ref"], item[k]["ref"] = was, st[k]["name"]
662
+ item["source"] = {k: (st.get(k) or {}).get("source") for k in ("symbol", "footprint")}
663
+ par, fields = _par(item), {}
664
+ if ln.get("datasheet", "").startswith("http"):
665
+ _set_par(item, "ComponentLink2Description", "Datasheet"); _set_par(item, "ComponentLink2URL", ln["datasheet"]); fields["ComponentLink2URL"] = "BOM"
666
+ elif par.get("ComponentLink2URL"):
667
+ fields["ComponentLink2URL"] = "LCSC datasheet URL pattern (not fetched)"
668
+ elif "datasheet" in st.get("found", {}):
669
+ u, w = st["found"]["datasheet"]
670
+ _set_par(item, "ComponentLink2Description", "Datasheet"); _set_par(item, "ComponentLink2URL", u); fields["ComponentLink2URL"] = w
671
+ if not par.get("Supplier Part Number 1") and "lcsc" in st.get("found", {}): # an LCSC number the BOM did not give
672
+ v, w = st["found"]["lcsc"]
673
+ _set_par(item, "Supplier 1", "LCSC"); _set_par(item, "Supplier Part Number 1", v); fields["Supplier Part Number 1"] = w
674
+ if not par.get("ComponentLink1URL"):
675
+ _set_par(item, "ComponentLink1Description", "LCSC")
676
+ _set_par(item, "ComponentLink1URL", f"https://www.lcsc.com/product-detail/{v}.html"); fields["ComponentLink1URL"] = w
677
+ if not par.get("Manufacturer") and "manufacturer" in st.get("found", {}):
678
+ v, w = st["found"]["manufacturer"]
679
+ _set_par(item, "Manufacturer", v); fields["Manufacturer"] = w
680
+ if "credit" in st.get("found", {}): # EasyEDA's library data asks for this credit on every reuse
681
+ v, w = st["found"]["credit"]
682
+ _set_par(item, "Library Credit", v); fields["Library Credit"] = w
683
+ if not str(item.get("description") or "").strip() and "description" in st.get("found", {}):
684
+ item["description"], fields["Description"] = st["found"]["description"]
685
+ if not str(item.get("description") or "").strip(): # no source gave one: write a plain one to check
686
+ desc = generated_description(item, st, ln)
687
+ if desc:
688
+ item["description"], fields["Description"] = desc, GENERATED_DESCRIPTION
689
+ item["field_sources"] = fields
690
+ item["verify"] = list(st.get("verify", []))
691
+ if st.get("cross_check"): # ecadlib.crosscheck: agreed / disagreed (held) / unchecked / skipped
692
+ item["cross_check"] = dict(st["cross_check"])
693
+ else:
694
+ item.pop("cross_check", None)
695
+ item["librarian"] = {"tried": st.get("tried", []), "review": st.get("review", []),
696
+ "notes": [n for k in ("symbol", "footprint") for n in (st.get(k) or {}).get("notes", [])]}
697
+
698
+
699
+ def review_rows(items):
700
+ rows = []
701
+ for it in items:
702
+ if it.get("a365") and it.get("status") in ("existing", "deleted_in_workspace"):
703
+ rows.append({"part": it["name"], "status": it["status"], "symbol_from": (it.get("source") or {}).get("symbol") or "",
704
+ "footprint_from": (it.get("source") or {}).get("footprint") or "",
705
+ "needs_human": "yes" if it["status"] != "existing" else "",
706
+ "why": "deleted in the Altium 365 workspace" if it["status"] != "existing" else "", "tried": ""})
707
+ continue
708
+ why = [f"no {k} found" for k in ("symbol", "footprint") if not it[k].get("file")]
709
+ if why:
710
+ why += it["librarian"]["tried"]
711
+ why += [h for h in it.get("hold_reasons", []) if not re.match(r"files: no (symbol|footprint) model", h)] + it["librarian"]["review"]
712
+ rows.append({"part": it["name"], "status": it["status"], "symbol_from": it["source"]["symbol"] or "",
713
+ "footprint_from": it["source"]["footprint"] or "", "needs_human": "yes" if why else "",
714
+ "why": "; ".join(dict.fromkeys(why)), "tried": "; ".join(it["librarian"]["tried"]),
715
+ "cross_check": (it.get("cross_check") or {}).get("status", "")})
716
+ return rows
717
+
718
+
719
+ def write_review(rows, out, bom, items=None):
720
+ """review.csv (machine columns, unchanged), review.md and review.html (ecadlib.reports: outcome first, held parts
721
+ with a plain reason and the next step). items: the plan items (default: <out>/plan.json, written just before)."""
722
+ out = Path(out)
723
+ with open(out / "review.csv", "w", newline="", encoding="utf-8-sig") as f:
724
+ w = SS.DictWriter(f, fieldnames=list(rows[0]) if rows else ["part"])
725
+ w.writeheader(); w.writerows(rows)
726
+ if items is None:
727
+ try:
728
+ items = json.loads((out / "plan.json").read_text(encoding="utf-8"))
729
+ except (OSError, ValueError):
730
+ items = [{"name": r["part"], "status": r["status"], "source": {"symbol": r["symbol_from"], "footprint": r["footprint_from"]},
731
+ "librarian": {"tried": r["tried"].split("; ") if r["tried"] else [], "review": []}} for r in rows]
732
+ src = lambda s: M.SOURCES.get((s or "-").split(":")[0], "none")
733
+ counts = {}
734
+ for r in rows:
735
+ k = (src(r["symbol_from"]), src(r["footprint_from"]))
736
+ counts[k] = counts.get(k, 0) + 1
737
+ from . import reports
738
+ details = ["### Where the parts came from", "", "| Symbol from | Footprint from | Parts |", "|---|---|---|"]
739
+ details += [f"| {a} | {b} | {n} |" for (a, b), n in sorted(counts.items())]
740
+ details += ["", "### What was tried, per part", ""] + [f"- **{r['part']}**: " + ("; ".join(reports.tried_line(t) for t in r["tried"].split("; ")) or "-") for r in rows]
741
+ details += CC.review_lines(rows) # which datasheet parts were cross-checked, agreed, or held
742
+ from . import ds_route
743
+ details += ds_route.review_lines(out) # datasheet reads: model, pages, tokens and USD per answer
744
+ reports.write_review_files(items, out, bom, details)
745
+
746
+
747
+ def run(bom, libs=(), out="out/librarian", sources=None, refresh=None, refresh_index=False, allow=(), offline=False,
748
+ cache="out/http_cache", rate=0.5, providers=None, http=None, api_key=None, log=print, gen_step=True,
749
+ rules=None, user=None, datasheet_batch=False, reader=None, progress=None):
750
+ """Whole pipeline; returns the plan items. datasheet_batch: parts that reach the datasheet source are read as one
751
+ Message Batch after every other source ran (reader: a ready datasheet.Reader, e.g. for --no-wait / --estimate). refresh: None = resume, [] = redo all, [names] = redo those.
752
+ rules: library rules (a loaded dict or a path); None uses the ecadlib.toml found from `out` (defaults when none).
753
+ sources: None = the rules' [sources] order. user: who gives the --allow (default: approvals.identity()).
754
+ progress: a term.Progress-like object (start(name) / done(name, status, detail)) for the per-part line."""
755
+ out = Path(out); out.mkdir(parents=True, exist_ok=True)
756
+ from . import migrate as MG, rebuild as LK # versioned files (backed up, migrated) and library.lock.json
757
+ MG.open_folder(out, log=log)
758
+ rules = RU.resolve(rules, out)
759
+ sources = list(sources) if sources is not None else list(rules["sources"]["order"])
760
+ http = http or CachedHTTP(cache, min_interval=rate, offline=offline, timeout=60)
761
+ bad = set(sources) - set(SOURCES)
762
+ if bad:
763
+ raise ValueError(f"unknown sources {sorted(bad)}; use {','.join(SOURCES)}")
764
+ if "standard" in sources and "kicad" in sources and sources.index("standard") < sources.index("kicad"):
765
+ sources.remove("standard") # never ahead of the KiCad library
766
+ sources.insert(sources.index("kicad") + 1, "standard")
767
+ if "standard" in sources and "datasheet" in sources and sources.index("datasheet") < sources.index("standard"):
768
+ from .llm import run as LR # rail prefer_standard: a guided (smaller) model reads datasheets
769
+ if LR.wants(rules, "prefer_standard"): # only for what the offline standard parts can't build
770
+ sources.remove("standard")
771
+ sources.insert(sources.index("datasheet"), "standard")
772
+ lines, mapping = T.read_bom(bom, RU.bom_columns(rules))
773
+ if not lines:
774
+ raise ValueError(f"{bom}: the BOM has a header but no part rows")
775
+ log(f" BOM: {len(lines)} lines; looking them up in your libraries and the part sources (triage) ...")
776
+ index = KiCadIndex(libs)
777
+ from . import plugins as PL # providers: None = [providers] order, else jlcpcb_search,easyeda
778
+ provs = [c(http) for c in PL.select(providers, rules, default="jlcpcb_search,easyeda")]
779
+ rows = T.triage(lines, index, provs, http)
780
+ T.write_reports(rows, mapping, provs, http, out / "triage", bom)
781
+ items = A365.plan(lines, pd.DataFrame(rows, dtype=str).fillna("") if rows else None, rules=rules)
782
+ state_f, plan_f = out / "librarian_state.json", out / "plan.json"
783
+ state = json.loads(state_f.read_text(encoding="utf-8")) if state_f.exists() else {}
784
+ old = {i["name"]: i for i in json.loads(plan_f.read_text(encoding="utf-8"))} if plan_f.exists() else {}
785
+ items, in_ws = A365S.reuse_existing(items, list(old.values()), rules) # parts `ecadlib a365 pull` found in the workspace
786
+ if reader is None and "datasheet" in sources:
787
+ reader = D.Reader(out, cache, api_key, rules, http, batch=datasheet_batch, log=log)
788
+ r = Run(out, http, index, libs, list(sources), api_key, Path(cache) / "kicad_library_index.json", refresh_index, gen_step, rules,
789
+ reader)
790
+ todo = []
791
+ for it in items:
792
+ if it["name"] in in_ws:
793
+ continue
794
+ ln, row = _line_for(it, lines, rows)
795
+ if not it["footprint"]["ref"]:
796
+ it["footprint"]["ref"] = AL.lib_name(it["name"])
797
+ fpr, st = _fingerprint(it, ln, row), state.get(it["name"])
798
+ redo = refresh is not None and (not refresh or it["name"] in refresh)
799
+ stale = bool(st) and (old.get(it["name"]) or {}).get("status") == "held" and not _built_now(st, rules)
800
+ if stale and st.get("key") == fpr and _done(st, out) and not redo:
801
+ bw = st.get("built_with") or {}
802
+ why = (f"built by ecadlib {bw['ecadlib']}" if bw.get("ecadlib") and bw["ecadlib"] != __version__ else
803
+ "built with other rules" if st.get("build_rev") == BUILD_REV else "built by an older ecadlib")
804
+ log(f" {it['name']}: held, {why}: built again, so its models, fields and release check are this version's")
805
+ if st and st.get("key") == fpr and _done(st, out) and not redo and not stale and not FS.unchecked_share(st.get("footprint")):
806
+ st["skipped"] = True
807
+ for k in ("symbol", "footprint"):
808
+ r.written.setdefault(str(out / st[k]["file"]), st[k])
809
+ else:
810
+ todo.append((it, ln, row, fpr))
811
+ keeps = {}
812
+ for it, ln, row, fpr in todo:
813
+ old_st = state.get(it["name"]) or {}
814
+ keeps[it["name"]] = keep = {k: old_st[k] for k in ("symbol", "footprint")
815
+ if (old_st.get(k) or {}).get("chosen") and (out / old_st[k]["file"]).exists()}
816
+ for rec in keep.values(): # a chosen file another part shares is reused, never overwritten by its fetch
817
+ r.written.setdefault(str(out / rec["file"]), rec)
818
+ r.log = log
819
+ rec = LK.Recorder(http) # what each part fetched, for library.lock.json
820
+ if todo:
821
+ log(f" finding symbols and footprints for {len(todo)} part(s) (sources: {', '.join(sources)}) ...")
822
+ if progress is not None:
823
+ progress.total = len(todo)
824
+ for n, (it, ln, row, fpr) in enumerate(todo, 1):
825
+ if progress is not None:
826
+ progress.start(it["name"])
827
+ elif len(todo) > 3:
828
+ log(f" [{n}/{len(todo)}] {it['name']}")
829
+ with rec.part(it["name"]):
830
+ st = r.resolve(it, ln, row, keeps[it["name"]])
831
+ if progress is not None:
832
+ gone = [k for k in ("symbol", "footprint") if not st.get(k)]
833
+ progress.done(it["name"], "held" if gone else "ready",
834
+ f"no {' or '.join(gone)} yet" if gone else "symbol and footprint found")
835
+ st["key"], st["skipped"] = fpr, False
836
+ LK.stamp_state(st, rec.take(it["name"]), rules)
837
+ st["build_rev"] = BUILD_REV
838
+ state[it["name"]] = st
839
+ r.flush_datasheets(log) # --datasheet-batch: one batch for every part that got this far
840
+ for it in items:
841
+ if it["name"] in in_ws:
842
+ log(f" a365 {it['name']:<28} in the Altium 365 workspace: reused, not fetched")
843
+ continue
844
+ apply_state(it, state[it["name"]], _line_for(it, lines, rows)[0])
845
+ for k in ("allow", "approvals", "duplicate_of"): # decisions people made survive re-runs
846
+ if old.get(it["name"], {}).get(k):
847
+ it[k] = old[it["name"]][k]
848
+ prev = old.get(it["name"]) or {}
849
+ for k, was in (prev.get("merged_from") or {}).items(): # `ecadlib dedupe --merge` pointed this half at a keeper
850
+ if k in ("symbol", "footprint") and it[k].get("ref") == was.get("ref") and (prev.get(k) or {}).get("file") \
851
+ and RC._path(out, prev[k]["file"]).exists():
852
+ it[k] = dict(prev[k])
853
+ it.setdefault("merged_from", {})[k] = was
854
+ log(f" {'skip ' if state[it['name']].get('skipped') else 'done ' if _done(state[it['name']], out) else 'open '}"
855
+ f"{it['name']:<28} sym={it['source']['symbol'] or '-'} fp={it['source']['footprint'] or '-'}")
856
+ state = {k: v for k, v in state.items() if k in {i["name"] for i in items}}
857
+ for n in set(allow) - set(state):
858
+ log(f" --allow {n}: not a part in this plan (names are the plan's Part Numbers)")
859
+ write_text_atomic(state_f, json.dumps(state, indent=2))
860
+ from . import house_style as HS # new parts in your house style (house_style.json, [style] apply)
861
+ prof = HS.active(rules, out)
862
+ if prof:
863
+ styled = HS.adapt_items([it for it in items if it["name"] not in in_ws], prof, rules)
864
+ if styled:
865
+ log(f" style {len(styled)} part(s) written in your house style (descriptions, value formats)")
866
+ checks = RC.apply(items, out, allow, rules=rules)
867
+ if allow: # record who gave the --allow, so approvals can refuse self-approval
868
+ from .approvals import identity, post_check, stamp_allow
869
+ stamp_allow(items, allow, identity(user, cwd=out))
870
+ post_check(items, out, checks)
871
+ write_text_atomic(plan_f, json.dumps(items, indent=2))
872
+ rec.close()
873
+ MG.write_stamp(out, ["plan", "librarian_state"])
874
+ LK.write_lock(out, items, state, bom, libs, sources, cache, providers, gen_step, rules)
875
+ RC.write_report(checks, out / "release_check", plan_f)
876
+ write_review(review_rows(items), out, bom, items)
877
+ return items
878
+
879
+
880
+ def main(argv=None):
881
+ ap = argparse.ArgumentParser(description="BOM -> library: triage, fill missing symbols/footprints, Altium models, plan, release check")
882
+ ap.add_argument("bom")
883
+ ap.add_argument("--lib", action="append", default=[], help="local KiCad library dir (repeatable)")
884
+ ap.add_argument("-o", "--out", default="out/librarian")
885
+ ap.add_argument("--sources", help="order of sources to try (default: the rules' [sources] order, else " + ",".join(SOURCES) + ")")
886
+ ap.add_argument("--refresh", nargs="*", metavar="PART", help="redo finished parts: all, or the named ones")
887
+ ap.add_argument("--refresh-index", action="store_true", help="rebuild the KiCad library name index")
888
+ ap.add_argument("--allow", action="append", default=[], metavar="PART", help="release PART despite its release-check failures")
889
+ ap.add_argument("--providers", help="triage providers in order (default: [providers] order, else jlcpcb_search,easyeda)")
890
+ ap.add_argument("--offline", action="store_true", help="use the HTTP cache only")
891
+ ap.add_argument("--cache", default="out/http_cache")
892
+ ap.add_argument("--rate", type=float, default=0.5, help="min seconds between requests per host")
893
+ ap.add_argument("--box", action="store_true", help="extruded 3D box instead of a generated STEP when there is no KiCad model")
894
+ ap.add_argument("--user", help="who gives the --allow (default: $ECADLIB_USER, then git config user.name/email)")
895
+ ap.add_argument("--rules", help="library rules file (default: ecadlib.toml found from -o or here; see `ecadlib init`)")
896
+ ap.add_argument("--datasheet-batch", action="store_true",
897
+ help="read every datasheet still needed as one Message Batch (half price; waits for it, see --datasheet-no-wait)")
898
+ ap.add_argument("--datasheet-no-wait", action="store_true", help="submit the batch and finish; re-run to collect it")
899
+ ap.add_argument("--json", action="store_true", help='print {"plan", "review", "parts", "ready", "held", "blockers"} instead of the log')
900
+ a = ap.parse_args(argv)
901
+ rules = RU.load(a.rules, start=a.out)
902
+ reader = None
903
+ if a.datasheet_batch:
904
+ reader = D.Reader(a.out, a.cache, None, rules, CachedHTTP(a.cache, min_interval=a.rate, offline=a.offline, timeout=60),
905
+ batch=True, wait=not a.datasheet_no_wait)
906
+ from . import messages as M, term as TM
907
+ t = TM.Term()
908
+ loud = (lambda *x, **k: None) if a.json or t.quiet else print
909
+ # on a terminal the per-step log is -v detail (the live progress line and the table below say it); piped, as before
910
+ log = loud if (t.verbose or not t.styled) else (lambda m="", *x, **k: print(m) if str(m).startswith((" BOM:", " finding")) else None)
911
+ prog = TM.Progress(t.err, 0, "parts") if t.styled and not a.json else None # piped: the plain log lines
912
+ try:
913
+ items = run(a.bom, a.lib, a.out, [s for s in a.sources.split(",") if s] if a.sources else None, a.refresh,
914
+ a.refresh_index, a.allow, a.offline, a.cache, a.rate, a.providers, gen_step=not a.box, rules=rules,
915
+ user=a.user, http=reader.http if reader else None, reader=reader,
916
+ log=log, progress=prog)
917
+ except ValueError as e: # unreadable / headerless BOM, unknown --sources
918
+ from .firstrun import bom_hint
919
+ print(f"ecadlib librarian: {e}" + bom_hint(e), file=sys.stderr)
920
+ return 2
921
+ from .firstrun import blockers
922
+ held = sum(i["status"] != "ready" for i in items)
923
+ why = blockers(review_rows(items), rules, a.offline)
924
+ if a.json:
925
+ print(json.dumps({"plan": str(Path(a.out) / "plan.json"), "review": str(Path(a.out) / "review.md"), "parts": len(items),
926
+ "ready": len(items) - held, "held": held, "blockers": [{"message": m, "fix": f} for m, f in why]}))
927
+ return 2 if held else 0
928
+ if prog is not None:
929
+ prog.t.clear_live()
930
+ print_result(items, Path(a.out), why, t)
931
+ return 2 if held else 0
932
+
933
+
934
+ def print_result(items, out, why=(), t=None):
935
+ """The terminal ending of a librarian run: a row per part on a terminal (status, name, the reason in plain
936
+ words), then notes, the counts, what was written and the next commands."""
937
+ from . import messages as M, term as TM
938
+ t = t or TM.Term()
939
+ if t.styled or t.verbose:
940
+ rows = []
941
+ for it in items:
942
+ why_ = M.explain_all(it.get("hold_reasons") or []) if it.get("status") == "held" else []
943
+ src = it.get("source") or {}
944
+ rows.append([t.status(it.get("status") or "", M.status_word(it.get("status"))), it["name"],
945
+ why_[0]["text"] if why_ else t.dim(f"{src.get('symbol') or '-'} / {src.get('footprint') or '-'}")])
946
+ t.table(rows)
947
+ held = [i["name"] for i in items if i.get("status") == "held"]
948
+ plan = out / "plan.json"
949
+ nxt = []
950
+ if held:
951
+ nxt.append((f"ecadlib review {plan}", "go through the held parts one by one"))
952
+ nxt += [(f"ecadlib kicad-export {plan} -o {out.parent.parent / 'kicad' if out.parent.name == 'librarian' else 'kicad'} --name mylib",
953
+ "write the KiCad library"),
954
+ (f"ecadlib altium-pkg {plan} --out altium_pkg", "the Altium hand-off folder (.LibPkg, DbLib)")]
955
+ t.summary(f"{M.plural(len(items), 'part')}: {M.counts(i.get('status') for i in items)}"
956
+ + (f" (held: {', '.join(held[:5])}{' ...' if len(held) > 5 else ''})" if held else ""),
957
+ written=[(str(plan), "library plan"), (str(out / "review.md"), "review: what to check, part by part"),
958
+ (str(out / "models") + "/", "Altium symbol and footprint files")],
959
+ next=nxt, notes=[(m, f) for m, f in why])
960
+
961
+
962
+ if __name__ == "__main__":
963
+ sys.exit(main())