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/resolve.py ADDED
@@ -0,0 +1,556 @@
1
+ """Fix held parts: rank symbol / footprint candidates for a plan item, convert the chosen one, re-check it.
2
+
3
+ python3 -m ecadlib.resolve out/librarian/plan.json list # held items and their reasons
4
+ python3 -m ecadlib.resolve out/librarian/plan.json --part NAME show # one item: halves, pins vs pads
5
+ python3 -m ecadlib.resolve out/librarian/plan.json --part NAME list [--kind symbol|footprint] [--limit N]
6
+ python3 -m ecadlib.resolve out/librarian/plan.json --part NAME choose symbol|footprint ID [--map PIN=PAD ...]
7
+ [--lib DIR ...] [--cache out/http_cache] [--offline] [--json]
8
+
9
+ Candidates come from the official KiCad library name index (the librarian's cached GitLab index; files fetched
10
+ through httpcache), --lib KiCad library dirs (kicad_index), ecadlib's package specs (spec:<name>, built by
11
+ footprint_gen) and any `alternatives` the plan item records. Ids: kicad:<Lib>:<Name>, local:<Lib>:<Name>,
12
+ spec:<Name>. Names are ranked by the tokens they share with the item (footprint / symbol names, description,
13
+ folder, MPN; rare tokens weigh more), an MPN prefix, package and body size, the designator class's usual
14
+ libraries and, for connectors, the generic Conn_ symbol for the pad count. The best few are opened and their
15
+ pins (or pads) compared with the item's other half the way release_check does; a full match ranks first, and a
16
+ near miss gets a suggested pin map (e.g. a USB-C symbol's shield pin S1 onto the footprint's SH pads).
17
+ `choose` converts the candidate through kicad_import into <plan dir>/models (like the librarian), updates the
18
+ plan item (file, kicad_mod, source, librarian.tried "chosen by user") and librarian_state.json (so a librarian
19
+ re-run keeps the choice), then re-runs release_check on that item only. --map remaps symbol pin numbers to pad
20
+ numbers (choosing a footprint with --map re-converts the item's KiCad symbol with the map).
21
+ API (plain dicts / lists, nothing printed): held(plan), show(plan, part, src), candidates(item, kind, base, src),
22
+ choose(plan, part, kind, id, src=..., pin_map=...)."""
23
+ import argparse, fnmatch, json, math, os, re, sys
24
+ from pathlib import Path
25
+
26
+ from . import altium_lib as AL, footprint_gen as F, kicad_import as K, librarian as L, messages as M, release_check as RC, rules as RU, sexpr
27
+ from .fsutil import write_text_atomic
28
+ from .httpcache import CachedHTTP
29
+ from .kicad_index import KiCadIndex, _body, norm_mpn, package_of
30
+
31
+ KINDS = ("symbol", "footprint")
32
+ REF_CLASS = {"Resistors": "R", "Capacitors": "C", "Inductors": "L", "Diodes": "D", "Transistors": "Q",
33
+ "Crystals": "Y", "Connectors": "J", "ICs": "U"}
34
+ LIB_HINT = {"R": ("Device", "Resistor"), "C": ("Device", "Capacitor"), "L": ("Device", "Inductor"),
35
+ "D": ("Device", "Diode", "LED"), "Q": ("Transistor", "Package_TO"), "Y": ("Device", "Oscillator", "Crystal"),
36
+ "J": ("Connector",)}
37
+
38
+
39
+ class Sources:
40
+ """Where candidates come from. Everything is lazy: nothing is fetched until a candidate list needs it."""
41
+
42
+ def __init__(self, cache="out/http_cache", libs=(), offline=False, http=None, specs=L.SPECS, kicad=None):
43
+ self.cache, self.libs, self.specs = Path(cache), [str(l) for l in libs], Path(specs) if specs else None
44
+ self.http = http or CachedHTTP(cache, min_interval=0.5, offline=offline, timeout=60)
45
+ self._kicad, self._index, self._pools = kicad, None, {}
46
+
47
+ def kicad(self):
48
+ if self._kicad is None:
49
+ self._kicad = L.KiCadLibrary(self.http, self.cache / "kicad_library_index.json")
50
+ return self._kicad
51
+
52
+ def index(self):
53
+ if self._index is None:
54
+ self._index = KiCadIndex(self.libs)
55
+ return self._index
56
+
57
+ def pool(self, kind):
58
+ """[(id, lib, name, tokens)] for every known name of this kind, plus the token document frequencies."""
59
+ if kind not in self._pools:
60
+ k, ix, rows = self.kicad(), self.index(), []
61
+ names = k.symbols if kind == "symbol" else k.footprints
62
+ rows += [(f"kicad:{lib}:{n}", lib, n) for n, libs in names.items() for lib in libs]
63
+ if kind == "symbol":
64
+ rows += [(f"local:{s.lib}:{s.name}", s.lib, s.name) for s in ix.symbols]
65
+ else:
66
+ rows += [(f"local:{r}", r.split(":")[0], r.split(":")[-1]) for r in ix.footprints]
67
+ local = {r.split(":")[-1] for r in ix.footprints}
68
+ if self.specs and self.specs.is_dir():
69
+ rows += [(f"spec:{f.stem}", "spec", f.stem) for f in sorted(self.specs.glob("*.json")) if f.stem not in local]
70
+ rows = [(i, lib, n, tokens(n)) for i, lib, n in rows]
71
+ df = {}
72
+ for r in rows:
73
+ for t in r[3]:
74
+ df[t] = df.get(t, 0) + 1
75
+ self._pools[kind] = (rows, df)
76
+ return self._pools[kind]
77
+
78
+
79
+ # ---------------------------------------------------------------- tokens, pins and pads
80
+ def tokens(*texts):
81
+ """Lower-case name tokens; '16-pin' / '4Pin' -> '16p', '2x5' == '2x05', '1.27mm' -> 'p1.27mm', plural 's' dropped,
82
+ CamelCase also split (PinHeader -> pinheader, pin, header)."""
83
+ out = set()
84
+ for t in texts:
85
+ t = str(t or "")
86
+ t += " " + re.sub(r"(?<=[a-z])(?=[A-Z])", " ", t) if re.search(r"[a-z][A-Z]", t) else "" # PinHeader: also pin, header
87
+ s = re.sub(r"(\d+)[- ]?pins?(?![a-z])", r"\1p", t.lower())
88
+ for w in re.split(r"[^a-z0-9.]+", s):
89
+ w = w.strip(".")
90
+ m = re.fullmatch(r"(\d{1,2})x(\d{1,2})", w)
91
+ if m:
92
+ out.add(f"{int(m[1])}x{int(m[2])}")
93
+ continue
94
+ if re.fullmatch(r"p?\d+(\.\d+)?mm", w):
95
+ w = "p" + w.lstrip("p")
96
+ if len(w) >= 3 or re.fullmatch(r"\d+p", w):
97
+ out.add(w)
98
+ if w.endswith("s") and len(w) > 4 and w[:-1].isalpha():
99
+ out.add(w[:-1])
100
+ return out
101
+
102
+
103
+ def symbol_pins(chain, pin_map=None):
104
+ """KiCad symbol chain -> sorted pin numbers (after pin_map) and its properties."""
105
+ sym = K.convert_symbol(chain, pin_map=pin_map, notes=[])
106
+ return sorted({pr[1]["num"] for pr in sym["prims"] if pr[0] == "pin"}), K._props(chain)
107
+
108
+
109
+ def footprint_pads(text):
110
+ """.kicad_mod text -> [(designator, mechanical)]; mechanical as release_check counts it."""
111
+ return [(str(p[1]), not p[1] or p[2] == "np_thru_hole" or bool(RC.MECH_PAD.fullmatch(str(p[1]))))
112
+ for p in sexpr.find(sexpr.parse(text), "pad") if len(p) > 2]
113
+
114
+
115
+ def compare(pins, pads):
116
+ """Pins vs pads with release_check's rule, plus a suggested pin_map for a near miss."""
117
+ pins, every = set(pins), {d for d, _ in pads}
118
+ numbered = {d for d, m in pads if not m}
119
+ nopad, nopin = sorted(pins - every, key=_nat), sorted(numbered - pins, key=_nat)
120
+ res = {"match": bool(pins) and bool(pads) and not nopad and not nopin, "pins": len(pins), "pads": len(numbered),
121
+ "pins_without_pad": nopad, "pads_without_pin": nopin, "pin_map": None}
122
+ free = sorted((every - pins) - {""}, key=_nat)
123
+ if nopad and len(nopad) == len(nopin) <= 4: # a small near miss, not a renumbering
124
+ res["pin_map"] = dict(zip(nopad, nopin))
125
+ elif len(nopad) == 1 and len(free) == 1 and not nopin:
126
+ res["pin_map"] = {nopad[0]: free[0]}
127
+ return res
128
+
129
+
130
+ def _nat(s):
131
+ return [int(p) if p.isdigit() else p for p in re.split(r"(\d+)", str(s))]
132
+
133
+
134
+ def _par(item):
135
+ return {p["name"]: p["value"] for p in item.get("parameters", [])}
136
+
137
+
138
+ def _path(base, p):
139
+ return RC._path(base, p)
140
+
141
+
142
+ def item_pins(item, base):
143
+ """Pin numbers of the item's current .SchLib, or None."""
144
+ f = _path(base, item["symbol"].get("file"))
145
+ if not f or not f.exists():
146
+ return None
147
+ syms = AL.read_schlib(f)[1]
148
+ return sorted({r["num"] for s in syms for r in s["records"] if r.get("RECORD") == "2"}, key=_nat)
149
+
150
+
151
+ def item_pads(item, base):
152
+ """[(designator, mechanical)] of the item's current footprint (.kicad_mod, else .PcbLib), or None."""
153
+ f = _path(base, item["footprint"].get("file"))
154
+ if not f or not f.exists():
155
+ return None
156
+ km = RC._kicad_mod(item, base, f)
157
+ if km:
158
+ return footprint_pads(km.read_text(encoding="utf-8"))
159
+ pads = [r for r in AL.read_pcblib(f)[0]["records"] if r["type"] == "pad"]
160
+ return [(p["designator"], not p["designator"] or (p["hole"] and not p["plated"]) or bool(RC.MECH_PAD.fullmatch(p["designator"])))
161
+ for p in pads]
162
+
163
+
164
+ # ---------------------------------------------------------------- loading a candidate
165
+ def load(cid, kind, src, rules=None):
166
+ """Candidate id -> (KiCad text or symbol chain, extra) without writing anything.
167
+ symbol: (chain, None); footprint: (kicad_mod text, height_mm or None). rules: the library rules (IPC density
168
+ for spec: candidates); None = ecadlib's own."""
169
+ origin, _, ref = cid.partition(":")
170
+ if kind == "symbol":
171
+ if origin == "kicad":
172
+ return K.load_symbol(ref, src.http)[0], None
173
+ if origin == "local":
174
+ lib, name = ref.split(":", 1)
175
+ f = next((p for r in src.libs for p in Path(r).rglob(f"{lib}.kicad_sym")), None) or \
176
+ next((p for r in src.libs for p in Path(r).rglob(f"{name}.kicad_sym")), None)
177
+ if not f:
178
+ raise FileNotFoundError(f"{cid}: no {lib}.kicad_sym under --lib")
179
+ return K.load_symbol(f"{f}:{name}", None)[0], None
180
+ else:
181
+ if origin == "kicad":
182
+ return K.load_footprint(ref, src.http)[0], None
183
+ if origin == "local":
184
+ hit = src.index().footprints.get(ref)
185
+ if not hit:
186
+ raise FileNotFoundError(f"{cid}: not in the --lib footprints")
187
+ spec = src.specs / f"{ref.split(':')[-1]}.json" if src.specs else None
188
+ hm = json.loads(spec.read_text(encoding="utf-8")).get("height_max") if spec and spec.exists() else None
189
+ return Path(hit["path"]).read_text(encoding="utf-8"), (F.dim(hm)[2] if hm else None)
190
+ if origin == "spec":
191
+ spec = json.loads((src.specs / f"{ref}.json").read_text(encoding="utf-8"))
192
+ return F.build(spec, RU.density_for(spec, rules or RU.DEFAULTS))[0], (F.dim(spec["height_max"])[2] if spec.get("height_max") else None)
193
+ raise ValueError(f"unknown {kind} candidate id {cid!r} (use kicad:Lib:Name, local:Lib:Name or spec:Name)")
194
+
195
+
196
+ # ---------------------------------------------------------------- ranking
197
+ def _query(item, kind):
198
+ """{token: weight}. For a missing footprint the item's own footprint ref is usually just the MPN (KiCad did not
199
+ know it), so its tokens count half; the description and the other half's KiCad name count fully."""
200
+ par, other = _par(item), ("footprint" if kind == "symbol" else "symbol")
201
+ full = [item.get("description"), par.get("Value"), str((item.get("source") or {}).get(other) or "").split(":")[-1],
202
+ item[other].get("ref")]
203
+ if kind == "symbol":
204
+ full.append(item["folder"].split("/")[-1])
205
+ q = {t: 0.5 for t in tokens(item["footprint"].get("ref"))} if kind == "footprint" else {}
206
+ q.update({t: 1.0 for t in tokens(*full)})
207
+ return q
208
+
209
+
210
+ def _alternatives(item, kind):
211
+ alts = list(item.get("alternatives") or []) + list((item.get("librarian") or {}).get("alternatives") or [])
212
+ out = {}
213
+ for a in alts:
214
+ a = {"id": a} if isinstance(a, str) else a
215
+ if a.get("id") and a.get("kind", kind) == kind:
216
+ out[a["id"]] = a.get("why", "")
217
+ return out
218
+
219
+
220
+ def _name_scores(item, kind, src):
221
+ rows, df = src.pool(kind)
222
+ n, q = len(rows) or 1, _query(item, kind)
223
+ idf = {t: w * math.log(n / df.get(t, 1)) for t, w in q.items()}
224
+ mpn = norm_mpn(re.sub(r"\(.*", "", _par(item).get("Manufacturer Part Number", "") or item["name"]))
225
+ cls = REF_CLASS.get(item["folder"].split("/")[-1], "")
226
+ pkg = package_of(item["footprint"].get("ref"), item.get("description"))
227
+ body = _body(item["footprint"].get("ref")) or _body(item.get("description"))
228
+ out = {}
229
+ for cid, lib, name, toks in rows:
230
+ why, s = [], 0.0
231
+ hit = sorted(q.keys() & toks)
232
+ part = [t for t in q.keys() - toks if len(t) >= 5 and any(t in u or (len(u) >= 5 and u in t) for u in toks)]
233
+ s += sum(idf[t] for t in hit) + 0.5 * sum(idf[t] for t in part)
234
+ if hit or part:
235
+ why.append("shares " + ", ".join(hit + [f"~{t}" for t in part]))
236
+ nm = norm_mpn(name)
237
+ k = len(os.path.commonprefix([nm, mpn]))
238
+ if len(mpn) >= 5 and k >= max(5, int(len(mpn) * 0.6)) and (k == len(nm) or kind == "footprint"):
239
+ s += 8 + 4 * (k == len(mpn))
240
+ why.append(f"MPN prefix {mpn[:k]}")
241
+ elif kind == "footprint" and len(mpn) >= 6 and mpn[:6] in nm:
242
+ s += 4
243
+ why.append(f"name contains {mpn[:6]}")
244
+ if cls in LIB_HINT and lib.startswith(LIB_HINT[cls]) and s:
245
+ s += 1
246
+ for label, rx in (("pitch", r"p\d+(\.\d+)?mm"), ("grid", r"\d+x\d+")): # stated pitch / pin grid must agree
247
+ want, have = {t for t in q if re.fullmatch(rx, t)}, {t for t in toks if re.fullmatch(rx, t)}
248
+ if want and have and not want & have:
249
+ s -= 10
250
+ why.append(f"{label} {'/'.join(sorted(have))}, not {'/'.join(sorted(want))}")
251
+ if kind == "footprint":
252
+ p = package_of(name)
253
+ if pkg and p:
254
+ s, _ = (s + 3, why.append(f"package {pkg}")) if p == pkg else (s - 10, why.append(f"package {p}, not {pkg}"))
255
+ b = _body(name)
256
+ if body and b:
257
+ s, _ = (s + 2, why.append("body size agrees")) if b == body else (s - 10, why.append(f"body {b[0]}x{b[1]} mm differs"))
258
+ if s > 0:
259
+ out[cid] = (s, why)
260
+ return out
261
+
262
+
263
+ def _generic_connectors(n):
264
+ if n < 1:
265
+ return []
266
+ names = [f"Conn_01x{n:02d}"] + ([f"Conn_02x{n // 2:02d}_Odd_Even", f"Conn_02x{n // 2:02d}_Counter_Clockwise"] if n % 2 == 0 else [])
267
+ return [f"kicad:Connector_Generic:{x}" for x in names]
268
+
269
+
270
+ def candidates(item, kind, base=".", src=None, limit=10, open_=12):
271
+ """Ranked candidates for one half of a plan item: [{id, kind, name, origin, score, why[], count, match,
272
+ pins_without_pad, pads_without_pin, pin_map, current, error?}]. `open_` = how many top names are opened."""
273
+ if kind not in KINDS:
274
+ raise ValueError(f"kind must be one of {KINDS}")
275
+ src = src or Sources()
276
+ other = item_pads(item, base) if kind == "symbol" else item_pins(item, base)
277
+ scored = _name_scores(item, kind, src)
278
+ pool_ids = {r[0] for r in src.pool(kind)[0]}
279
+ forced = {}
280
+ for cid, why in _alternatives(item, kind).items():
281
+ forced[cid] = (4.0, [f"recorded by the librarian{': ' + why if why else ''}"])
282
+ cur = (item.get("source") or {}).get(kind)
283
+ if cur and cur.startswith(("kicad:", "local:", "spec:")):
284
+ forced.setdefault(cur, (0.0, ["current choice"]))
285
+ if kind == "symbol" and REF_CLASS.get(item["folder"].split("/")[-1]) == "J" and other:
286
+ for cid in _generic_connectors(len({d for d, m in other if not m})):
287
+ if cid in pool_ids:
288
+ forced.setdefault(cid, (3.0, [f"generic connector for {len({d for d, m in other if not m})} pads"]))
289
+ for cid, (s, why) in forced.items():
290
+ s0, w0 = scored.get(cid, (0.0, []))
291
+ scored[cid] = (s0 + s, why + w0)
292
+ top = sorted(scored.items(), key=lambda kv: (-kv[1][0], len(kv[0]), kv[0]))
293
+ top = [kv for kv in top if kv[0] in forced] + [kv for kv in top if kv[0] not in forced][:max(open_ - len(forced), open_ // 2)]
294
+ other_fp = str((item.get("source") or {}).get("footprint") or "").partition(":")[2]
295
+ filters = _fp_filters(item, src) if kind == "footprint" else []
296
+ out = []
297
+ for cid, (s, why) in top:
298
+ c = {"id": cid, "kind": kind, "name": cid.split(":")[-1], "origin": cid.split(":")[0], "why": list(why),
299
+ "count": None, "match": None, "pins_without_pad": [], "pads_without_pin": [], "pin_map": None, "current": cid == cur}
300
+ try:
301
+ got, _ = load(cid, kind, src)
302
+ if kind == "symbol":
303
+ pins, props = symbol_pins(got)
304
+ c["count"] = len(pins)
305
+ if other_fp and props.get("Footprint", "").split(":")[-1] == other_fp.split(":")[-1]:
306
+ s += 3; c["why"].append("its default footprint is the item's footprint")
307
+ if other_fp and _filter_hit(props.get("ki_fp_filters", ""), other_fp):
308
+ s += 2; c["why"].append("its footprint filter accepts the item's footprint")
309
+ cmp = compare(pins, other) if other is not None else None
310
+ else:
311
+ pads = footprint_pads(got)
312
+ c["count"] = len({d for d, m in pads if not m})
313
+ if filters and _filter_hit(" ".join(filters), cid.partition(":")[2]):
314
+ s += 2; c["why"].append("the symbol's footprint filter accepts it")
315
+ cmp = compare(other, pads) if other is not None else None
316
+ if cmp:
317
+ c.update({k: cmp[k] for k in ("match", "pins_without_pad", "pads_without_pin", "pin_map")})
318
+ if cmp["match"]:
319
+ s += 10; c["why"].append(f"pins match the {'footprint' if kind == 'symbol' else 'symbol'}")
320
+ else:
321
+ s += 3 * (cmp["pins"] == cmp["pads"]) + 5 * bool(cmp["pin_map"])
322
+ c["why"].append(f"{cmp['pins']} pins vs {cmp['pads']} pads" + (f": suggest pin_map {cmp['pin_map']}" if cmp["pin_map"] else ""))
323
+ except Exception as e:
324
+ s -= 5
325
+ c["error"] = f"{type(e).__name__}: {e}"
326
+ c["score"] = round(s, 1)
327
+ out.append(c)
328
+ out.sort(key=lambda c: (-c["score"], len(c["id"]), c["id"]))
329
+ return out[:limit]
330
+
331
+
332
+ def _fp_filters(item, src):
333
+ """ki_fp_filters of the item's symbol when it came from a KiCad library."""
334
+ sid = str((item.get("source") or {}).get("symbol") or "")
335
+ if not sid.startswith(("kicad:", "local:")):
336
+ return []
337
+ try:
338
+ return symbol_pins(load(sid, "symbol", src)[0])[1].get("ki_fp_filters", "").split()
339
+ except Exception:
340
+ return []
341
+
342
+
343
+ def _filter_hit(filters, fp_ref):
344
+ lib, _, name = fp_ref.rpartition(":")
345
+ for f in str(filters or "").split():
346
+ if fnmatch.fnmatchcase(f"{lib}:{name}" if ":" in f else name, f):
347
+ return True
348
+ return False
349
+
350
+
351
+ # ---------------------------------------------------------------- plan access and choosing
352
+ def _load_plan(plan):
353
+ return json.loads(Path(plan).read_text(encoding="utf-8"))
354
+
355
+
356
+ def _item(items, part):
357
+ it = next((i for i in items if i["name"] == part), None)
358
+ if not it:
359
+ raise KeyError(f"{part!r} is not in the plan")
360
+ return it
361
+
362
+
363
+ def missing(item):
364
+ return [k for k in KINDS if not item[k].get("file")]
365
+
366
+
367
+ def held(plan):
368
+ """Held items: [{name, folder, missing[], hold_reasons[], source{}}]."""
369
+ return [{"name": i["name"], "folder": i["folder"], "missing": missing(i), "hold_reasons": i.get("hold_reasons", []),
370
+ "source": i.get("source") or {}} for i in _load_plan(plan) if i.get("status") == "held"]
371
+
372
+
373
+ def show(plan, part):
374
+ """One item: status, reasons, each half's ref/file/source and the pins-vs-pads comparison."""
375
+ base, it = Path(plan).parent, _item(_load_plan(plan), part)
376
+ pins, pads = item_pins(it, base), item_pads(it, base)
377
+ return {"name": it["name"], "status": it.get("status"), "hold_reasons": it.get("hold_reasons", []),
378
+ "description": it.get("description"), "folder": it["folder"], "missing": missing(it),
379
+ "symbol": dict(it["symbol"], source=(it.get("source") or {}).get("symbol"), pins=pins),
380
+ "footprint": dict(it["footprint"], source=(it.get("source") or {}).get("footprint"),
381
+ pads=[d for d, _ in pads] if pads is not None else None),
382
+ "pins_vs_pads": compare(pins, pads) if pins is not None and pads is not None else None,
383
+ "tried": (it.get("librarian") or {}).get("tried", []), "review": (it.get("librarian") or {}).get("review", [])}
384
+
385
+
386
+ def _write(item, kind, cid, src, base, pin_map=None):
387
+ """Convert candidate `cid` into <base>/models like the librarian; returns the librarian-style half record."""
388
+ rules = RU.load(None, start=base) # the house rules (layers, density), as the librarian uses them
389
+ r = L.Run(base, src.http, None, src.libs, [], rules=rules)
390
+ st = {"name": item["name"], "symbol": None, "footprint": None}
391
+ got, height = load(cid, kind, src, rules)
392
+ if kind == "footprint":
393
+ r.put_fp(st, got, item["footprint"]["ref"], cid, src.http if cid.startswith("kicad:") else None, height, "package spec")
394
+ else:
395
+ mpn = _par(item).get("Manufacturer Part Number", "")
396
+ shared = L.passive_kind(item) in ("R", "C", "CP", "L")
397
+ notes = []
398
+ sym = K.convert_symbol(got, item["symbol"]["ref"], None if shared else item["footprint"]["ref"],
399
+ [] if shared or not mpn else [("MPN", mpn)], pin_map, notes)
400
+ if pin_map:
401
+ notes.append("pin map " + ", ".join(f"{a}->{b}" for a, b in pin_map.items()))
402
+ r.put_sym(st, sym, cid, notes)
403
+ rec = dict(st[kind], chosen=True)
404
+ if kind == "footprint" and item["footprint"].get("bom_ref"): # a name fp_share gave: re-runs keep it as the ref
405
+ rec["renamed_from"] = item["footprint"]["bom_ref"]
406
+ if pin_map and kind == "symbol":
407
+ rec["pin_map"] = dict(pin_map)
408
+ return rec
409
+
410
+
411
+ def choose(plan, part, kind, candidate_id, base=None, src=None, pin_map=None):
412
+ """Use candidate_id for one half of a plan item, write plan.json (and librarian_state.json when present), re-run
413
+ release_check on that item. Returns {name, status, hold_reasons, warnings, file, source, notes, pins_vs_pads}."""
414
+ if kind not in KINDS:
415
+ raise ValueError(f"kind must be one of {KINDS}")
416
+ plan, src = Path(plan), src or Sources()
417
+ base = Path(base) if base else plan.parent
418
+ items = _load_plan(plan)
419
+ it = _item(items, part)
420
+ done = [(kind, candidate_id, pin_map if kind == "symbol" else None)]
421
+ if pin_map and kind == "footprint": # the map lives in the symbol: re-convert it
422
+ sid = (it.get("source") or {}).get("symbol") or ""
423
+ if not sid.startswith(("kicad:", "local:")):
424
+ raise ValueError(f"--map with a footprint needs a KiCad symbol to re-convert; {part}'s symbol is from {sid or 'nowhere'}")
425
+ done.append(("symbol", sid, pin_map))
426
+ state_f = base / "librarian_state.json"
427
+ state = json.loads(state_f.read_text(encoding="utf-8")) if state_f.exists() else None
428
+ shared_with = lambda k: [i["name"] for i in items if i is not it and i[k].get("file") and
429
+ i[k]["file"] == os.path.relpath(base / "models" / ("SchLib" if k == "symbol" else "PcbLib") /
430
+ f"{AL.file_stem(it[k]['ref'])}.{'SchLib' if k == 'symbol' else 'PcbLib'}", base).replace(os.sep, "/")]
431
+ shared = shared_with(kind)
432
+ rewritten = {n for k, _, _ in done for n in shared_with(k)} # every part whose model file this choice overwrites
433
+ notes = []
434
+ for k, cid, pm in done:
435
+ rec = _write(it, k, cid, src, base, pm)
436
+ it[k]["file"] = rec["file"]
437
+ if k == "footprint":
438
+ it[k]["kicad_mod"] = rec.get("kicad_mod")
439
+ if pm:
440
+ it[k]["pin_map"] = dict(pm)
441
+ else:
442
+ it[k].pop("pin_map", None)
443
+ it.setdefault("source", {"symbol": None, "footprint": None})[k] = cid
444
+ lib = it.setdefault("librarian", {"tried": [], "review": [], "notes": []})
445
+ msg = f"resolve: {k} {cid} chosen by user" + (f" (pin map {pm})" if pm else "")
446
+ lib.setdefault("tried", []).append(msg)
447
+ notes += rec.get("notes", [])
448
+ if state is not None and part in state:
449
+ state[part][k] = rec
450
+ state[part].setdefault("tried", []).append(msg)
451
+ if shared:
452
+ notes.append(f"{kind} file is shared with {', '.join(shared)}: they now use this {kind} too")
453
+ RC.apply([it] + [i for i in items if i["name"] in rewritten], base) # the parts sharing the overwritten file are
454
+ # re-checked too: their old `ready` no longer holds
455
+ write_text_atomic(plan, json.dumps(items, indent=2))
456
+ if state is not None:
457
+ write_text_atomic(state_f, json.dumps(state, indent=2))
458
+ pins, pads = item_pins(it, base), item_pads(it, base)
459
+ return {"name": part, "status": it["status"], "hold_reasons": it["hold_reasons"],
460
+ "warnings": it["release_check"]["warnings"], "file": it[kind]["file"], "source": it["source"][kind],
461
+ "notes": notes, "pins_vs_pads": compare(pins, pads) if pins is not None and pads is not None else None}
462
+
463
+
464
+ # ---------------------------------------------------------------- CLI
465
+ def _pin_map(pairs):
466
+ out = {}
467
+ for p in pairs or []:
468
+ a, sep, b = p.partition("=")
469
+ if not sep or not a or not b:
470
+ raise SystemExit(f"--map wants PIN=PAD, got {p!r}")
471
+ out[a] = b
472
+ return out
473
+
474
+
475
+ def main(argv=None):
476
+ ap = argparse.ArgumentParser(description="Fix held plan items: rank symbol/footprint candidates, choose one, re-check")
477
+ ap.add_argument("plan")
478
+ ap.add_argument("--part", help="plan item (Part Number); without it, `list` shows the held items")
479
+ ap.add_argument("--lib", action="append", default=[], help="local KiCad library dir (repeatable)")
480
+ ap.add_argument("--cache", default="out/http_cache")
481
+ ap.add_argument("--offline", action="store_true", help="use the HTTP cache only")
482
+ ap.add_argument("--json", action="store_true", help="print the API result as JSON")
483
+ sub = ap.add_subparsers(dest="cmd", required=True)
484
+ ls = sub.add_parser("list", help="held items, or with --part the candidates for its missing half")
485
+ ls.add_argument("--kind", choices=KINDS, help="default: the missing half (both when neither is missing)")
486
+ ls.add_argument("--limit", type=int, default=10)
487
+ sub.add_parser("show", help="one item: halves, sources, pins vs pads, hold reasons")
488
+ ch = sub.add_parser("choose", help="use a candidate for one half of --part")
489
+ ch.add_argument("kind", choices=KINDS)
490
+ ch.add_argument("id", help="kicad:Lib:Name, local:Lib:Name or spec:Name")
491
+ ch.add_argument("--map", action="append", metavar="PIN=PAD", help="symbol pin number -> pad number (repeatable)")
492
+ a = ap.parse_args(argv)
493
+ emit = (lambda r: print(json.dumps(r, indent=2))) if a.json else None
494
+ src = lambda: Sources(a.cache, a.lib, a.offline)
495
+ if a.cmd == "list" and not a.part:
496
+ r = held(a.plan)
497
+ if emit:
498
+ return emit(r) or 0
499
+ for h in r:
500
+ print(f"{h['name']} ({h['folder']}){' missing ' + ' + '.join(h['missing']) if h['missing'] else ''}")
501
+ for e in M.explain_all(h["hold_reasons"]):
502
+ print(f" {e['text']}" + (f" {e['note']}" if e["note"] else "") + f"\n next: {e['fix']}")
503
+ print(f"{len(r)} held")
504
+ return 0
505
+ if not a.part:
506
+ ap.error(f"{a.cmd} needs --part")
507
+ if a.cmd == "show":
508
+ r = show(a.plan, a.part)
509
+ if emit:
510
+ return emit(r) or 0
511
+ print(f"{r['name']}: {r['status']} ({r['folder']}) {r['description'] or ''}")
512
+ for k in KINDS:
513
+ h = r[k]
514
+ n = h.get("pins") if k == "symbol" else h.get("pads")
515
+ print(f" {k:<9} {h['ref']} file={h.get('file') or '-'} from={h.get('source') or '-'}"
516
+ + (f" {len(n)} {'pins' if k == 'symbol' else 'pads'}" if n is not None else ""))
517
+ c = r["pins_vs_pads"]
518
+ if c:
519
+ print(f" pins vs pads: {'match' if c['match'] else 'pins without pad ' + str(c['pins_without_pad']) + ', pads without pin ' + str(c['pads_without_pin'])}"
520
+ + (f"; suggest --map {' --map '.join(f'{x}={y}' for x, y in c['pin_map'].items())}" if c and c["pin_map"] else ""))
521
+ for e in M.explain_all(r["hold_reasons"]):
522
+ print(f" held: {e['text']}\n next: {e['fix']}")
523
+ return 0
524
+ if a.cmd == "list":
525
+ plan = _load_plan(a.plan)
526
+ it, s = _item(plan, a.part), src()
527
+ kinds = [a.kind] if a.kind else (missing(it) or list(KINDS))
528
+ r = {k: candidates(it, k, Path(a.plan).parent, s, a.limit) for k in kinds}
529
+ if emit:
530
+ return emit(r) or 0
531
+ for k, cs in r.items():
532
+ print(f"{k} candidates for {a.part}:")
533
+ for c in cs:
534
+ flag = "match" if c["match"] else "-" if c["match"] is None else "differ"
535
+ print(f" {c['score']:>5} {c['id']:<62} {c['count'] if c['count'] is not None else '?':>4} {'pins' if k == 'symbol' else 'pads'} {flag:<6}"
536
+ f"{' (current)' if c['current'] else ''}")
537
+ print(f" {'; '.join(c['why'])}" + (f" ERROR {c['error']}" if c.get("error") else ""))
538
+ if c["pin_map"]:
539
+ print(f" suggest: --map {' --map '.join(f'{x}={y}' for x, y in c['pin_map'].items())}")
540
+ return 0
541
+ from .approvals import cli_plan_lock
542
+ with cli_plan_lock(a.plan): # the library agent's librarian may be rewriting this plan right now
543
+ r = choose(a.plan, a.part, a.kind, a.id, src=src(), pin_map=_pin_map(a.map))
544
+ if emit:
545
+ emit(r)
546
+ else:
547
+ print(f"{a.part}: {a.kind} <- {r['source']} ({r['file']}); now {r['status']}")
548
+ for e in M.explain_all(r["hold_reasons"]):
549
+ print(f" held: {e['text']} next: {e['fix']}")
550
+ for n in r["notes"]:
551
+ print(f" note: {n}")
552
+ return 0 if r["status"] == "ready" else 2
553
+
554
+
555
+ if __name__ == "__main__":
556
+ sys.exit(main())