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/review_cli.py ADDED
@@ -0,0 +1,292 @@
1
+ """`ecadlib review`: go through the held parts one at a time, in the terminal.
2
+
3
+ ecadlib review [PLAN] [--workspace W] [--part P] [--yes] [--offline] [--lib DIR ...] [--limit N]
4
+
5
+ For each held part: the reason in plain words and its fix (ecadlib.messages), the datasheet link, and - when a
6
+ symbol or footprint is missing or the pins and pads disagree - the best candidates (ecadlib.resolve). Then one
7
+ question:
8
+
9
+ 1-N use that candidate (converted, written into the plan, the part re-checked)
10
+ a release anyway (like `ecadlib release-check --allow PART`; recorded with your name)
11
+ d open the datasheet in the browser
12
+ s skip to the next part q stop here
13
+
14
+ Only when stdin is a terminal. With --yes, or piped, nothing is asked and nothing changes: each held part is printed
15
+ with its reason and the commands that fix it (so it also works in a log or a script). Without PLAN, every library
16
+ plan in the workspace with held parts is reviewed (--workspace, else $ECADLIB_WORKSPACE, else ./ecadlib_workspace).
17
+ Exit code: 0 when nothing is held at the end, 2 otherwise."""
18
+ import argparse, json, os, re, sys, webbrowser
19
+ from pathlib import Path
20
+
21
+ from . import messages as M, term as TM
22
+
23
+ CHOICES = "1-{n} use a candidate a release anyway d open the datasheet s skip q quit"
24
+
25
+
26
+ def datasheet_url(item):
27
+ """The part's datasheet link from its parameters (ComponentLinkNURL next to a "Datasheet" description, or a
28
+ Datasheet / Datasheet URL parameter), or None."""
29
+ pars = {p.get("name"): p.get("value") for p in item.get("parameters") or [] if isinstance(p, dict)}
30
+ for k, v in pars.items():
31
+ m = re.fullmatch(r"ComponentLink(\d+)Description", k or "")
32
+ if m and str(v).strip().lower() == "datasheet" and pars.get(f"ComponentLink{m.group(1)}URL"):
33
+ return pars[f"ComponentLink{m.group(1)}URL"]
34
+ for k, v in sorted(pars.items()): # a link without a description that is plainly a PDF
35
+ if re.fullmatch(r"ComponentLink\d+URL", k or "") and str(v).lower().split("?")[0].endswith(".pdf") \
36
+ and not pars.get(k.replace("URL", "Description")):
37
+ return v
38
+ for k in ("Datasheet", "Datasheet URL", "datasheet"):
39
+ if str(pars.get(k) or "").startswith(("http://", "https://")):
40
+ return pars[k]
41
+ return None
42
+
43
+
44
+ def datasheet_page(reasons):
45
+ """A page number named in the reasons ("page 34"), for #page=N."""
46
+ for r in reasons:
47
+ m = re.search(r"\bpage (\d+)", str(r))
48
+ if m:
49
+ return int(m.group(1))
50
+ return None
51
+
52
+
53
+ def find_plans(workspace):
54
+ ws = Path(workspace)
55
+ return sorted(ws.glob("librarian/*/plan.json"), key=lambda p: p.stat().st_mtime, reverse=True)
56
+
57
+
58
+ def _cache_for(plan, given):
59
+ if given:
60
+ return given
61
+ for c in (Path(plan).parent.parent.parent / "http_cache", Path("out/http_cache")):
62
+ if c.is_dir():
63
+ return str(c)
64
+ return "out/http_cache"
65
+
66
+
67
+ def wants_candidates(item, why):
68
+ from . import resolve as R
69
+ return bool(R.missing(item)) or any(e["code"].startswith(("pins.", "files.")) for e in why)
70
+
71
+
72
+ def candidates(item, plan, src, limit, t):
73
+ """{kind: [candidate]} for the missing half (or both when pins and pads disagree); errors become a note."""
74
+ from . import resolve as R
75
+ kinds = R.missing(item) or list(R.KINDS)
76
+ out = {}
77
+ for k in kinds:
78
+ try:
79
+ with TM.waiting(f"{k} candidates for {item['name']} (KiCad library index)", t.err):
80
+ out[k] = R.candidates(item, k, Path(plan).parent, src, limit)
81
+ except Exception as e: # network down, no cache: say so, keep going
82
+ text, fix = M.friendly_error(e)
83
+ t.note(f"No {k} candidates right now: {text}", fix + " (--offline uses what is cached)")
84
+ out[k] = []
85
+ return out
86
+
87
+
88
+ def show_part(t, item, plan, cands, src=None):
89
+ """The card for one held part; returns the numbered candidate list [(kind, candidate)]."""
90
+ why = M.explain_all(item.get("hold_reasons") or [])
91
+ desc = item.get("description") or ""
92
+ t.write(f"{t.status('held')} {t.bold(item['name'])}" + (f" {t.dim(desc)}" if desc else ""))
93
+ for e in why:
94
+ t.wrap(e["text"] + (f" {e['note']}" if e.get("note") else ""), 4)
95
+ t.wrap(f"fix: {e['fix']}", 4)
96
+ if t.verbose:
97
+ for e in why:
98
+ t.write(t.dim(f" ({e['code']}: {e['raw']})"))
99
+ url = datasheet_url(item)
100
+ if url:
101
+ t.write(f" datasheet: {url}")
102
+ numbered = []
103
+ for kind, cs in cands.items():
104
+ if not cs:
105
+ t.write(f" No {kind} candidates found." + " Add your KiCad libraries with --lib" + (", or drop --offline." if a_offline(src) else "."))
106
+ continue
107
+ t.write(f" {kind} candidates:")
108
+ rows = []
109
+ for c in cs:
110
+ numbered.append((kind, c))
111
+ unit = "pins" if kind == "symbol" else "pads"
112
+ fit = (t.status("ready", "fits") if c["match"] else t.status("skipped", "not compared") if c["match"] is None
113
+ else t.status("held", "pins and pads differ"))
114
+ rows.append([f"{len(numbered)}", c["id"], f"{c['count'] if c['count'] is not None else '?'} {unit}", fit
115
+ + (" (current)" if c.get("current") else "")])
116
+ t.table(rows, indent=6, right=(0,))
117
+ return numbered
118
+
119
+
120
+ def a_offline(src):
121
+ return bool(getattr(getattr(src, "http", None), "offline", False))
122
+
123
+
124
+ def release_anyway(plan, name, user=None):
125
+ """release-check --allow for one part; returns the item after the re-check."""
126
+ from . import release_check as RC
127
+ from .approvals import cli_plan_lock, identity, post_check, stamp_allow
128
+ from .fsutil import write_text_atomic
129
+ plan = Path(plan)
130
+ with cli_plan_lock(plan):
131
+ items = json.loads(plan.read_text(encoding="utf-8"))
132
+ it = next(i for i in items if i["name"] == name)
133
+ rows = RC.apply([it], plan.parent, [name])
134
+ stamp_allow([it], [name], identity(user, cwd=plan.parent))
135
+ post_check([it], plan.parent, rows)
136
+ write_text_atomic(plan, json.dumps(items, indent=2))
137
+ return it
138
+
139
+
140
+ def fix_commands(plan, item, numbered):
141
+ cmds = []
142
+ if numbered:
143
+ kind, c = numbered[0]
144
+ cmds.append(f"ecadlib resolve {plan} --part {item['name']} choose {kind} {c['id']}")
145
+ codes = [e["code"] for e in M.explain_all(item.get("hold_reasons") or [])]
146
+ if any(not c.startswith("files.") for c in codes):
147
+ cmds.append(f"ecadlib release-check {plan} --allow {item['name']}")
148
+ return cmds
149
+
150
+
151
+ def review_plan(plan, a, t, inp, interactive):
152
+ """Review one plan's held parts. Returns (held at the start, still held, stopped?)."""
153
+ from . import resolve as R
154
+ held = [h["name"] for h in R.held(plan)]
155
+ if a.part:
156
+ held = [n for n in held if n == a.part]
157
+ if not held:
158
+ return 0, 0, False
159
+ t.write(t.bold(f"{plan}: {M.plural(len(held), 'held part')}"))
160
+ src = None
161
+ still, stopped = 0, False
162
+ for k, name in enumerate(held, 1):
163
+ items = json.loads(Path(plan).read_text(encoding="utf-8"))
164
+ item = next((i for i in items if i["name"] == name), None)
165
+ if not item or item.get("status") != "held":
166
+ continue
167
+ t.write("")
168
+ t.write(t.dim(f"[{k}/{len(held)}]"))
169
+ why = M.explain_all(item.get("hold_reasons") or [])
170
+ cands = {}
171
+ if wants_candidates(item, why) and a.limit > 0:
172
+ src = src or R.Sources(_cache_for(plan, a.cache), a.lib, a.offline)
173
+ cands = candidates(item, plan, src, a.limit, t)
174
+ numbered = show_part(t, item, plan, cands, src)
175
+ if not interactive:
176
+ for c in fix_commands(plan, item, numbered):
177
+ t.write(f" to fix: {c}")
178
+ still += 1
179
+ continue
180
+ status = "held"
181
+ while True:
182
+ fixed_only = all(e["code"].startswith("files.") for e in M.explain_all(item.get("hold_reasons") or []))
183
+ ask = CHOICES.format(n=len(numbered)) if numbered else CHOICES.split(" ", 1)[1]
184
+ if fixed_only: # a missing / broken file cannot be released anyway: do not offer it
185
+ ask = ask.replace(" a release anyway", "").replace("a release anyway ", "")
186
+ try:
187
+ ans = inp(f" {ask}\n > ").strip().lower()
188
+ except EOFError:
189
+ ans = "q"
190
+ if ans in ("", "s", "skip", "n"):
191
+ break
192
+ if ans in ("q", "quit", "exit"):
193
+ stopped = True
194
+ break
195
+ if ans in ("d", "datasheet"):
196
+ url = datasheet_url(item)
197
+ if not url:
198
+ t.note("This part has no datasheet link.", "Add one to the BOM (a Datasheet column) and fill the library again.")
199
+ continue
200
+ page = datasheet_page(item.get("hold_reasons") or [])
201
+ full = url + (f"#page={page}" if page and "#" not in url else "")
202
+ opened = False
203
+ try:
204
+ opened = webbrowser.open(full)
205
+ except Exception:
206
+ pass
207
+ t.write(f" {'opened' if opened else 'open this in your browser:'} {full}")
208
+ continue
209
+ if ans in ("a", "release", "allow"):
210
+ it = release_anyway(plan, name, a.user)
211
+ status = it.get("status")
212
+ if status == "held":
213
+ left = M.explain_all(it.get("hold_reasons") or [])
214
+ t.write(f" {t.status('held')}: {left[0]['text']} {left[0].get('note') or ''}".rstrip())
215
+ t.write(f" fix: {left[0]['fix']}")
216
+ continue
217
+ t.write(f" {t.status(status, M.status_word(status))}: released anyway, recorded in the plan")
218
+ break
219
+ if ans.isdigit() and 1 <= int(ans) <= len(numbered):
220
+ kind, c = numbered[int(ans) - 1]
221
+ from .approvals import cli_plan_lock
222
+ try:
223
+ with cli_plan_lock(plan), TM.waiting(f"{c['id']} (fetch and convert)", t.err):
224
+ r = R.choose(plan, name, kind, c["id"], src=src, pin_map=c.get("pin_map") or None)
225
+ except Exception as e:
226
+ text, fix = M.friendly_error(e)
227
+ t.note(text, fix)
228
+ continue
229
+ status = r["status"]
230
+ t.write(f" {kind} is now {c['id']}: {t.status(status, M.status_word(status))}")
231
+ for e in M.explain_all(r["hold_reasons"]):
232
+ t.write(f" {e['text']} fix: {e['fix']}")
233
+ for n in r["notes"]:
234
+ t.detail(f" note: {M.clean(n)}")
235
+ if status != "held":
236
+ break
237
+ items = json.loads(Path(plan).read_text(encoding="utf-8"))
238
+ item = next(i for i in items if i["name"] == name)
239
+ continue
240
+ t.write(f" Not one of the choices: {ans!r}.")
241
+ if status == "held":
242
+ still += 1
243
+ if stopped:
244
+ still += sum(1 for _ in held[k:])
245
+ break
246
+ return len(held), still, stopped
247
+
248
+
249
+ def main(argv=None, inp=input):
250
+ ap = argparse.ArgumentParser(prog="ecadlib review", description="go through held parts one at a time: why it is "
251
+ "held in plain words, pick a candidate, release anyway, open the datasheet")
252
+ ap.add_argument("plan", nargs="?", help="a library plan (plan.json); default: every plan in --workspace with held parts")
253
+ ap.add_argument("--workspace", default=os.environ.get("ECADLIB_WORKSPACE") or "ecadlib_workspace")
254
+ ap.add_argument("--part", help="only this part")
255
+ ap.add_argument("--yes", action="store_true", help="never ask: print each held part, its reason and the fix commands")
256
+ ap.add_argument("--lib", action="append", default=[], help="local KiCad library dir for candidates (repeatable)")
257
+ ap.add_argument("--cache", help="HTTP cache (default: the workspace's http_cache)")
258
+ ap.add_argument("--offline", action="store_true", help="candidates from the cache only")
259
+ ap.add_argument("--limit", type=int, default=5, help="candidates shown per missing half (0: none)")
260
+ ap.add_argument("--user", help="who releases anyway (default: $ECADLIB_USER, then git config)")
261
+ a = ap.parse_args(argv)
262
+ t = TM.Term()
263
+ plans = [Path(a.plan)] if a.plan else find_plans(a.workspace)
264
+ if not plans:
265
+ t.err.error("review", f"There is no library plan in {a.workspace} yet.",
266
+ "Fill a library from a BOM first: ecadlib librarian BOM.csv -o <workspace>/librarian/board")
267
+ return 2
268
+ tty = inp is not input or (sys.stdin is not None and sys.stdin.isatty()) # tests pass their own inp
269
+ interactive = tty and not a.yes
270
+ total = still = 0
271
+ for plan in plans:
272
+ n, s, stopped = review_plan(plan, a, t, inp, interactive)
273
+ total, still = total + n, still + s
274
+ if stopped:
275
+ break
276
+ if not total:
277
+ t.summary(f"Nothing is held{' for ' + a.part if a.part else ''}: every part is ready or waiting for sign-off.",
278
+ next=[(f"ecadlib status --workspace {a.workspace}" if not a.plan else f"ecadlib release-check {plans[0]}", "")])
279
+ return 0
280
+ done = total - still
281
+ head = (f"{M.plural(total, 'held part')} reviewed: {done} fixed, {still} still held" if interactive
282
+ else f"{M.plural(still, 'part')} held")
283
+ nxt = [] if interactive else [("ecadlib review" + (f" {a.plan}" if a.plan else f" --workspace {a.workspace}"),
284
+ "in a terminal: pick a fix for each part")]
285
+ if done:
286
+ nxt.append((f"ecadlib release-check {plans[0]}", "check the whole library again"))
287
+ t.summary(head, next=nxt)
288
+ return 2 if still else 0
289
+
290
+
291
+ if __name__ == "__main__":
292
+ sys.exit(main())