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.
- ecadlib/__init__.py +3 -0
- ecadlib/__main__.py +5 -0
- ecadlib/a365_sync.py +637 -0
- ecadlib/agent.py +1407 -0
- ecadlib/agent_queue.py +780 -0
- ecadlib/agent_watch.py +732 -0
- ecadlib/altium365.py +385 -0
- ecadlib/altium_dblib_sync.py +530 -0
- ecadlib/altium_export.py +209 -0
- ecadlib/altium_forms.py +768 -0
- ecadlib/altium_lib.py +1089 -0
- ecadlib/altium_menu.py +1751 -0
- ecadlib/altium_pkg.py +396 -0
- ecadlib/altium_project.py +1095 -0
- ecadlib/app.py +1458 -0
- ecadlib/approvals.py +395 -0
- ecadlib/assets/demo_bom.csv +8 -0
- ecadlib/assets/kicad_action_plugin.py +607 -0
- ecadlib/assets/kicad_reread.py +60 -0
- ecadlib/assets/pcblib_template.json.gz +0 -0
- ecadlib/bom_inputs.py +339 -0
- ecadlib/bom_triage.py +519 -0
- ecadlib/cad_model.py +528 -0
- ecadlib/cfb.py +171 -0
- ecadlib/cli.py +185 -0
- ecadlib/clihelp.py +149 -0
- ecadlib/cliutil.py +119 -0
- ecadlib/completion.py +173 -0
- ecadlib/costs.py +276 -0
- ecadlib/crosscheck.py +412 -0
- ecadlib/datasheet.py +2168 -0
- ecadlib/dedupe.py +572 -0
- ecadlib/detect.py +170 -0
- ecadlib/doctor.py +450 -0
- ecadlib/ds_route.py +541 -0
- ecadlib/eagle_export.py +1409 -0
- ecadlib/easy_setup.py +338 -0
- ecadlib/easyeda_convert.py +386 -0
- ecadlib/easyeda_pro_export.py +972 -0
- ecadlib/eval/__init__.py +0 -0
- ecadlib/eval/baselines/kicad-v1.json +404 -0
- ecadlib/eval/build_set.py +299 -0
- ecadlib/eval/cli.py +119 -0
- ecadlib/eval/gate.py +358 -0
- ecadlib/eval/golden/demo.txt +379 -0
- ecadlib/eval/golden/specs.txt +3120 -0
- ecadlib/eval/golden/testpack.txt +463 -0
- ecadlib/eval/golden.py +446 -0
- ecadlib/eval/replay/kicad-v1.json.gz +0 -0
- ecadlib/eval/runner.py +456 -0
- ecadlib/eval/score.py +179 -0
- ecadlib/eval/sets/kicad-v1.json +630 -0
- ecadlib/firstrun.py +400 -0
- ecadlib/footprint_adv.py +1006 -0
- ecadlib/footprint_bench.py +218 -0
- ecadlib/footprint_check.py +562 -0
- ecadlib/footprint_gen.py +582 -0
- ecadlib/footprint_rules.py +1092 -0
- ecadlib/forge.py +267 -0
- ecadlib/fp_share.py +95 -0
- ecadlib/fp_tidy.py +389 -0
- ecadlib/fsutil.py +62 -0
- ecadlib/house_style.py +1051 -0
- ecadlib/httpcache.py +124 -0
- ecadlib/hub.py +1490 -0
- ecadlib/hub_copy.py +161 -0
- ecadlib/hub_tools.py +79 -0
- ecadlib/kicad_dblib.py +257 -0
- ecadlib/kicad_export.py +837 -0
- ecadlib/kicad_import.py +913 -0
- ecadlib/kicad_index.py +160 -0
- ecadlib/kicad_menu.py +193 -0
- ecadlib/kicad_pcm.py +475 -0
- ecadlib/launcher.py +156 -0
- ecadlib/librarian.py +963 -0
- ecadlib/lifecycle.py +691 -0
- ecadlib/llm/__init__.py +276 -0
- ecadlib/llm/cli.py +70 -0
- ecadlib/llm/openai_compat.py +214 -0
- ecadlib/llm/profiles.py +245 -0
- ecadlib/llm/rails.py +570 -0
- ecadlib/llm/run.py +126 -0
- ecadlib/llm/sampling.py +49 -0
- ecadlib/mcp_server.py +723 -0
- ecadlib/messages.py +419 -0
- ecadlib/migrate.py +407 -0
- ecadlib/notify.py +329 -0
- ecadlib/package_schema.json +1238 -0
- ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
- ecadlib/package_specs/C_0402_1005Metric.json +22 -0
- ecadlib/package_specs/C_0805_2012Metric.json +22 -0
- ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
- ecadlib/package_specs/D_SMA.json +27 -0
- ecadlib/package_specs/D_SMB.json +27 -0
- ecadlib/package_specs/D_SOD-123.json +43 -0
- ecadlib/package_specs/D_SOD-323.json +43 -0
- ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
- ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
- ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
- ecadlib/package_specs/L_0805_2012Metric.json +22 -0
- ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
- ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
- ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
- ecadlib/package_specs/R_0402_1005Metric.json +22 -0
- ecadlib/package_specs/R_0603_1608Metric.json +22 -0
- ecadlib/package_specs/R_0805_2012Metric.json +22 -0
- ecadlib/package_specs/R_1206_3216Metric.json +22 -0
- ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
- ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
- ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
- ecadlib/package_specs/SOT-23-5.json +57 -0
- ecadlib/package_specs/SOT-23-6.json +62 -0
- ecadlib/package_specs/SOT-23.json +48 -0
- ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
- ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
- ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
- ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
- ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
- ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
- ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
- ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
- ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
- ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
- ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
- ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
- ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
- ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
- ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
- ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
- ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
- ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
- ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
- ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
- ecadlib/package_specs/standard.json.gz +0 -0
- ecadlib/parity.py +1204 -0
- ecadlib/parts_export.py +154 -0
- ecadlib/partview.py +215 -0
- ecadlib/plugins.py +231 -0
- ecadlib/providers.py +226 -0
- ecadlib/rebuild.py +471 -0
- ecadlib/release_check.py +437 -0
- ecadlib/reports.py +274 -0
- ecadlib/resolve.py +556 -0
- ecadlib/review_cli.py +292 -0
- ecadlib/rules.py +979 -0
- ecadlib/rules_infer.py +1205 -0
- ecadlib/sexpr.py +149 -0
- ecadlib/sheetsafe.py +65 -0
- ecadlib/sim_models.py +1087 -0
- ecadlib/sourcing.py +1161 -0
- ecadlib/stdparts.py +755 -0
- ecadlib/step_gen.py +929 -0
- ecadlib/symbol_layout.py +798 -0
- ecadlib/team.py +558 -0
- ecadlib/team_setup.py +288 -0
- ecadlib/term.py +420 -0
- ecadlib/term_views.py +64 -0
- ecadlib/testpack.py +334 -0
- ecadlib/ui/__init__.py +1 -0
- ecadlib/ui/__main__.py +3 -0
- ecadlib/ui/server.py +1029 -0
- ecadlib/ui/static/hairline.js +2 -0
- ecadlib/ui/static/index.html +2098 -0
- ecadlib/ui/static/setup.html +294 -0
- ecadlib/upkeep.py +717 -0
- ecadlib/upstream.py +573 -0
- ecadlib/userconfig.py +257 -0
- ecadlib/visual_check.py +1603 -0
- ecadlib/ws_status.py +203 -0
- ecadlib-0.3.0.dist-info/METADATA +2245 -0
- ecadlib-0.3.0.dist-info/RECORD +199 -0
- ecadlib-0.3.0.dist-info/WHEEL +5 -0
- ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
- ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
- ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
- 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())
|