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/upstream.py
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
"""Upstream change alerts: KiCad library parts we imported, and Altium 365 components that drift from the plan.
|
|
2
|
+
|
|
3
|
+
python3 -m ecadlib.upstream check-kicad out/altium_models_kicad/manifest.json [-o out/upstream_kicad] [--update]
|
|
4
|
+
NEXAR_TOKEN=... python3 -m ecadlib.upstream check-a365 out/altium365/plan.json --workspace URL \
|
|
5
|
+
[--folder Components] [--state out/a365_state.json] [-o out/upstream_a365]
|
|
6
|
+
|
|
7
|
+
Provenance: kicad_import.import_part calls record() after writing a part, which adds an entry to
|
|
8
|
+
<out>/manifest.json per output file: KiCad ref, source URLs (footprint, STEP model, symbol and its `extends`
|
|
9
|
+
parents), sha256 and git blob id of each source, the last GitLab commit touching it, a small summary of the
|
|
10
|
+
source (pads and courtyard, pins and key properties, STEP extent), the output file and its sha256, and the
|
|
11
|
+
converter version. check-kicad asks the GitLab API (HEAD .../repository/files, no download) for each source's
|
|
12
|
+
blob id, fetches only the changed ones and diffs their summaries; a 404 is traced through the commit that removed
|
|
13
|
+
the path to tell `deleted` from `renamed`. --update reconverts parts whose sources changed (not deleted/renamed).
|
|
14
|
+
check-a365 lists the workspace library and compares it with a plan.json: missing components, parameter, comment,
|
|
15
|
+
description, folder, symbol and footprint names, and with --state the component revision and the symbol/footprint
|
|
16
|
+
GUIDs seen last time (Nexar exposes no symbol/footprint revision, so a GUID change is the closest signal).
|
|
17
|
+
Reports: <out>.md and <out>.csv. Exit codes: 0 no changes, 1 changes found, 2 error. GITLAB_TOKEN is optional."""
|
|
18
|
+
import argparse, csv, hashlib, json, os, re, sys
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from urllib.parse import quote
|
|
22
|
+
|
|
23
|
+
from . import __version__, kicad_import as K, sexpr
|
|
24
|
+
from . import sheetsafe as SS
|
|
25
|
+
from .httpcache import CachedHTTP, Response
|
|
26
|
+
|
|
27
|
+
RAW = re.compile(r"https://gitlab\.com/kicad/libraries/([^/]+)/-/raw/([^/]+)/(.+)$")
|
|
28
|
+
API = "https://gitlab.com/api/v4/projects/kicad%2Flibraries%2F{repo}/repository"
|
|
29
|
+
RANK = {"unchanged": 0, "changed": 1, "renamed": 2, "deleted": 3, "error": 4}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def converter_version():
|
|
33
|
+
return f"ecadlib {__version__} kicad_import {hashlib.sha256(Path(K.__file__).read_bytes()).hexdigest()[:12]}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def digests(text):
|
|
37
|
+
b = text.encode("utf-8")
|
|
38
|
+
return hashlib.sha256(b).hexdigest(), hashlib.sha1(b"blob %d\0" % len(b) + b).hexdigest() # blob id = git's
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------- source summaries (what a diff talks about)
|
|
42
|
+
def fp_summary(text):
|
|
43
|
+
t = sexpr.parse(text)
|
|
44
|
+
pads = []
|
|
45
|
+
for p in sexpr.find(t, "pad"):
|
|
46
|
+
at, size = sexpr.first(p, "at"), sexpr.first(p, "size")
|
|
47
|
+
pads.append([p[1], *(round(float(v), 4) for v in (at[1], at[2], size[1], size[2])), p[3],
|
|
48
|
+
round(float(at[3]), 2) if len(at) > 3 else 0.0])
|
|
49
|
+
cy = sexpr.courtyard_bbox(t)
|
|
50
|
+
return {"pads": pads, "courtyard": [round(v, 4) for v in cy] if cy else None}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _find_symbol(text, name):
|
|
54
|
+
return next((s for s in sexpr.find(sexpr.parse(text), "symbol") if s[1] == name), None)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def sym_summary(text, name):
|
|
58
|
+
s = _find_symbol(text, name)
|
|
59
|
+
if not s:
|
|
60
|
+
return {"missing": True}
|
|
61
|
+
pins = {}
|
|
62
|
+
for sub in sexpr.find(s, "symbol"):
|
|
63
|
+
if sub[1].rsplit("_", 1)[-1] not in ("0", "1"): # De Morgan body style
|
|
64
|
+
continue
|
|
65
|
+
for p in sexpr.find(sub, "pin"):
|
|
66
|
+
pins[(sexpr.first(p, "number") or ["", ""])[1]] = [(sexpr.first(p, "name") or ["", ""])[1], p[1]]
|
|
67
|
+
props = {p[1]: p[2] for p in sexpr.find(s, "property") if p[1] in ("Value", "Footprint", "Datasheet", "Description")}
|
|
68
|
+
return {"pins": pins, "props": props, "extends": (sexpr.first(s, "extends") or [None, None])[1]}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def step_summary(text):
|
|
72
|
+
ext = K.step_extent(text, {"scale": [1, 1, 1], "rotate": [0, 0, 0], "offset": [0, 0, 0]})
|
|
73
|
+
return {"extent": [round(v, 3) for v in ext] if ext else None}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def summarize(src, text):
|
|
77
|
+
return {"footprint": lambda: fp_summary(text), "step": lambda: step_summary(text),
|
|
78
|
+
"symbol": lambda: sym_summary(text, src["symbol"])}[src["role"]]()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _few(xs, n=6):
|
|
82
|
+
xs = list(xs)
|
|
83
|
+
return ", ".join(xs[:n]) + (f" +{len(xs) - n} more" if len(xs) > n else "")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def diff_fp(a, b):
|
|
87
|
+
def keyed(pads):
|
|
88
|
+
out, seen = {}, {}
|
|
89
|
+
for p in pads:
|
|
90
|
+
k = seen[p[0]] = seen.get(p[0], -1) + 1
|
|
91
|
+
out[(p[0] or "(npth)") + (f"#{k + 1}" if k else "")] = p
|
|
92
|
+
return out
|
|
93
|
+
ka, kb = keyed(a["pads"]), keyed(b["pads"])
|
|
94
|
+
out = [f"pads {len(ka)} -> {len(kb)}"] if len(ka) != len(kb) else []
|
|
95
|
+
if set(kb) - set(ka):
|
|
96
|
+
out.append("added " + _few(sorted(set(kb) - set(ka))))
|
|
97
|
+
if set(ka) - set(kb):
|
|
98
|
+
out.append("removed " + _few(sorted(set(ka) - set(kb))))
|
|
99
|
+
common = [k for k in ka if k in kb]
|
|
100
|
+
moved = [f"{k} ({ka[k][1]:g},{ka[k][2]:g})->({kb[k][1]:g},{kb[k][2]:g})" for k in common if ka[k][1:3] != kb[k][1:3]]
|
|
101
|
+
sized = [f"{k} {ka[k][3]:g}x{ka[k][4]:g}->{kb[k][3]:g}x{kb[k][4]:g}" for k in common if ka[k][3:5] != kb[k][3:5]]
|
|
102
|
+
shaped = [f"{k} {ka[k][5]}/{ka[k][6]:g}->{kb[k][5]}/{kb[k][6]:g}" for k in common if ka[k][5:] != kb[k][5:]]
|
|
103
|
+
out += [f"{lbl} {_few(x, 4)}" for lbl, x in (("moved", moved), ("resized", sized), ("shape/rotation", shaped)) if x]
|
|
104
|
+
if a["courtyard"] != b["courtyard"]:
|
|
105
|
+
out.append(f"courtyard {a['courtyard']} -> {b['courtyard']}")
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def diff_sym(a, b):
|
|
110
|
+
if b.get("missing"):
|
|
111
|
+
return ["symbol no longer in the file"]
|
|
112
|
+
pa, pb = a.get("pins", {}), b.get("pins", {})
|
|
113
|
+
out = []
|
|
114
|
+
if set(pb) - set(pa):
|
|
115
|
+
out.append("pins added " + _few(f"{n} {pb[n][0]}" for n in sorted(set(pb) - set(pa))))
|
|
116
|
+
if set(pa) - set(pb):
|
|
117
|
+
out.append("pins removed " + _few(f"{n} {pa[n][0]}" for n in sorted(set(pa) - set(pb))))
|
|
118
|
+
common = sorted(set(pa) & set(pb))
|
|
119
|
+
ren = [f"{n} {pa[n][0]}->{pb[n][0]}" for n in common if pa[n][0] != pb[n][0]]
|
|
120
|
+
typ = [f"{n} {pa[n][1]}->{pb[n][1]}" for n in common if pa[n][1] != pb[n][1]]
|
|
121
|
+
out += [f"{lbl} {_few(x, 4)}" for lbl, x in (("pins renamed", ren), ("pin types", typ)) if x]
|
|
122
|
+
props = sorted(k for k in set(a.get("props", {})) | set(b.get("props", {})) if a["props"].get(k) != b["props"].get(k))
|
|
123
|
+
if props:
|
|
124
|
+
out.append("properties changed: " + ", ".join(props))
|
|
125
|
+
if a.get("extends") != b.get("extends"):
|
|
126
|
+
out.append(f"extends {a.get('extends')} -> {b.get('extends')}")
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def diff_summary(role, a, b):
|
|
131
|
+
if not a or not b:
|
|
132
|
+
return []
|
|
133
|
+
if role == "footprint":
|
|
134
|
+
return diff_fp(a, b)
|
|
135
|
+
if role == "symbol":
|
|
136
|
+
return diff_sym(a, b)
|
|
137
|
+
return [f"STEP extent {a['extent']} -> {b['extent']}"] if a.get("extent") != b.get("extent") else []
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------- provenance (called by kicad_import)
|
|
141
|
+
def _remote(repo, path):
|
|
142
|
+
return K.GITLAB.format(repo=repo, path=path)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def fp_sources(ref, http):
|
|
146
|
+
"""[(role, location, text, extra)] the converter read for a footprint: the .kicad_mod and its STEP model."""
|
|
147
|
+
if Path(ref).suffix == ".kicad_mod":
|
|
148
|
+
loc, text = str(ref), Path(ref).read_text(encoding="utf-8")
|
|
149
|
+
else:
|
|
150
|
+
lib, name = ref.split(":", 1)
|
|
151
|
+
path = f"{lib}.pretty/{name}.kicad_mod"
|
|
152
|
+
loc, text = _remote("kicad-footprints", path), K.fetch(http, "kicad-footprints", path)
|
|
153
|
+
out = [("footprint", loc, text, {})]
|
|
154
|
+
mi = K.model_info(sexpr.parse(text))
|
|
155
|
+
mp = K.model_url_path(mi["path"]) if mi else None
|
|
156
|
+
if mp and http is not None: # add_body only fetches the model when it has an http client
|
|
157
|
+
st = K.fetch(http, "kicad-packages3D", mp)
|
|
158
|
+
if st is not None:
|
|
159
|
+
out.append(("step", _remote("kicad-packages3D", mp), st, {}))
|
|
160
|
+
return out
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def sym_sources(ref, http):
|
|
164
|
+
"""The symbol file(s) of a symbol and its `extends` parents, resolved as kicad_import.load_symbol does."""
|
|
165
|
+
m = re.match(r"(.+\.kicad_sym)(?::(.+))?$", ref)
|
|
166
|
+
if m:
|
|
167
|
+
path = Path(m.group(1))
|
|
168
|
+
def get(name):
|
|
169
|
+
for f in (path, path.with_name(f"{name}.kicad_sym")):
|
|
170
|
+
if f.exists() and _find_symbol(f.read_text(encoding="utf-8"), name):
|
|
171
|
+
return str(f), f.read_text(encoding="utf-8")
|
|
172
|
+
raise FileNotFoundError(f"symbol {name} not in {path}")
|
|
173
|
+
name = m.group(2) or [s[1] for s in sexpr.find(sexpr.parse(path.read_text(encoding="utf-8")), "symbol")][0]
|
|
174
|
+
else:
|
|
175
|
+
lib, name = ref.split(":", 1)
|
|
176
|
+
def get(name):
|
|
177
|
+
for p in (f"{lib}.kicad_symdir/{name}.kicad_sym", f"{lib}.kicad_sym"):
|
|
178
|
+
text = K.fetch(http, "kicad-symbols", p)
|
|
179
|
+
if text and _find_symbol(text, name):
|
|
180
|
+
return _remote("kicad-symbols", p), text
|
|
181
|
+
raise FileNotFoundError(f"KiCad symbol {lib}:{name} not found")
|
|
182
|
+
out = []
|
|
183
|
+
while name and len(out) < 8:
|
|
184
|
+
loc, text = get(name)
|
|
185
|
+
out.append(("symbol", loc, text, {"symbol": name}))
|
|
186
|
+
name = (sexpr.first(_find_symbol(text, name), "extends") or [None, None])[1]
|
|
187
|
+
return out
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def last_commit(http, repo, path):
|
|
191
|
+
if http is None:
|
|
192
|
+
return None
|
|
193
|
+
url = API.format(repo=repo) + f"/commits?path={quote(path)}&ref_name=master&per_page=1"
|
|
194
|
+
try:
|
|
195
|
+
r = http.get(url)
|
|
196
|
+
c = r.json()[0] if r.ok else None
|
|
197
|
+
return {"id": c["id"], "date": c["created_at"], "title": c["title"]} if c else None
|
|
198
|
+
except Exception:
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def source_record(role, loc, text, extra, http):
|
|
203
|
+
sha, blob = digests(text)
|
|
204
|
+
src = {"role": role, **extra, "sha256": sha, "blob_id": blob}
|
|
205
|
+
m = RAW.match(loc)
|
|
206
|
+
if m:
|
|
207
|
+
src.update(url=loc, repo=m.group(1), ref=m.group(2), path=m.group(3), last_commit=last_commit(http, m.group(1), m.group(3)))
|
|
208
|
+
else:
|
|
209
|
+
src["file"] = loc
|
|
210
|
+
src["summary"] = summarize(src, text)
|
|
211
|
+
return src
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def load_manifest(path):
|
|
215
|
+
p = Path(path)
|
|
216
|
+
return json.loads(p.read_text(encoding="utf-8")) if p.exists() else {"version": 1, "entries": {}}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def save_manifest(path, man):
|
|
220
|
+
p = Path(path)
|
|
221
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
222
|
+
tmp = p.with_suffix(".tmp")
|
|
223
|
+
tmp.write_text(json.dumps(man, indent=1), encoding="utf-8")
|
|
224
|
+
os.replace(tmp, p)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def record(out, spec, rep, http, step=False, gen_step=True, layers=None):
|
|
228
|
+
"""Add/replace the manifest entries for a part kicad_import just wrote. Never raises: a failure is a note.
|
|
229
|
+
layers: the rules' PcbLib layers the part was written with, so a reconversion keeps them."""
|
|
230
|
+
try:
|
|
231
|
+
mpath, now = Path(out) / "manifest.json", datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
232
|
+
man = load_manifest(mpath)
|
|
233
|
+
for kind, key, ref, srcs in (("footprint", "pcblib", spec.get("footprint"), fp_sources),
|
|
234
|
+
("symbol", "schlib", spec.get("symbol"), sym_sources)):
|
|
235
|
+
if not rep.get(key) or not ref:
|
|
236
|
+
continue
|
|
237
|
+
f = Path(rep[key])
|
|
238
|
+
man["entries"][f"{kind}:{f.stem}"] = {
|
|
239
|
+
"kind": kind, "kicad_ref": ref, "mpn": spec.get("mpn", ""),
|
|
240
|
+
"output": os.path.relpath(f, Path(out)).replace(os.sep, "/"), "output_sha256": hashlib.sha256(f.read_bytes()).hexdigest(),
|
|
241
|
+
"converter": converter_version(), "converted_at": now, "step": bool(step), "gen_step": bool(gen_step),
|
|
242
|
+
"layers": layers,
|
|
243
|
+
"spec": {k: v for k, v in spec.items() if v is not None},
|
|
244
|
+
"sources": [source_record(*s, http) for s in srcs(ref, http)]}
|
|
245
|
+
save_manifest(mpath, man)
|
|
246
|
+
except Exception as e:
|
|
247
|
+
rep.setdefault("notes", []).append(f"provenance not recorded: {type(e).__name__}: {e}")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ---------------------------------------------------------------- GitLab
|
|
251
|
+
class GitLab:
|
|
252
|
+
"""GitLab REST v4 for gitlab.com/kicad/libraries/*: HEAD for blob ids, raw content, last commit, rename tracing."""
|
|
253
|
+
def __init__(self, session=None, token=None, ref="master"):
|
|
254
|
+
if session is None:
|
|
255
|
+
import requests
|
|
256
|
+
session = requests.Session()
|
|
257
|
+
self.s, self.ref = session, ref
|
|
258
|
+
self.h = {"PRIVATE-TOKEN": token} if token else {}
|
|
259
|
+
|
|
260
|
+
def _req(self, method, url, params=None):
|
|
261
|
+
r = self.s.request(method, url, params=params, headers=self.h, timeout=60)
|
|
262
|
+
if r.status_code == 404:
|
|
263
|
+
return None
|
|
264
|
+
if not 200 <= r.status_code < 300:
|
|
265
|
+
raise IOError(f"GitLab {method} {url}: HTTP {r.status_code}")
|
|
266
|
+
return r
|
|
267
|
+
|
|
268
|
+
def _file(self, repo, path):
|
|
269
|
+
return API.format(repo=repo) + "/files/" + quote(path, safe="")
|
|
270
|
+
|
|
271
|
+
def head(self, repo, path):
|
|
272
|
+
r = self._req("HEAD", self._file(repo, path), {"ref": self.ref})
|
|
273
|
+
return {"blob_id": r.headers.get("x-gitlab-blob-id"), "last_commit_id": r.headers.get("x-gitlab-last-commit-id")} if r else None
|
|
274
|
+
|
|
275
|
+
def raw(self, repo, path):
|
|
276
|
+
r = self._req("GET", self._file(repo, path) + "/raw", {"ref": self.ref})
|
|
277
|
+
return r.content.decode("utf-8") if r else None
|
|
278
|
+
|
|
279
|
+
def commit(self, repo, path):
|
|
280
|
+
r = self._req("GET", API.format(repo=repo) + "/commits", {"path": path, "ref_name": self.ref, "per_page": 1})
|
|
281
|
+
c = r.json()[0] if r and r.json() else None
|
|
282
|
+
return {"id": c["id"], "date": c["created_at"], "title": c["title"]} if c else None
|
|
283
|
+
|
|
284
|
+
def moved(self, repo, path):
|
|
285
|
+
"""A path that 404s: ('renamed', new_path, commit) or ('deleted', None, commit) from the commit that last touched it."""
|
|
286
|
+
c = self.commit(repo, path)
|
|
287
|
+
if not c:
|
|
288
|
+
return "deleted", None, None
|
|
289
|
+
for page in range(1, 51):
|
|
290
|
+
r = self._req("GET", API.format(repo=repo) + f"/commits/{c['id']}/diff", {"per_page": 100, "page": page})
|
|
291
|
+
files = r.json() if r else []
|
|
292
|
+
for d in files:
|
|
293
|
+
if d.get("old_path") == path:
|
|
294
|
+
return ("renamed", d["new_path"], c) if d.get("renamed_file") else ("deleted", None, c)
|
|
295
|
+
if len(files) < 100:
|
|
296
|
+
break
|
|
297
|
+
return "deleted", None, c
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ---------------------------------------------------------------- check-kicad
|
|
301
|
+
def check_source(src, gl, memo):
|
|
302
|
+
"""-> dict(status, changes, new_path, commit, text)"""
|
|
303
|
+
key = (src.get("repo"), src.get("path") or src.get("file"))
|
|
304
|
+
if key not in memo:
|
|
305
|
+
res = {"status": "unchanged", "changes": [], "new_path": None, "commit": None, "text": None}
|
|
306
|
+
try:
|
|
307
|
+
if src.get("repo"):
|
|
308
|
+
info = gl.head(src["repo"], src["path"])
|
|
309
|
+
if info is None:
|
|
310
|
+
res["status"], res["new_path"], res["commit"] = gl.moved(src["repo"], src["path"])
|
|
311
|
+
res["changes"] = [f"{res['status']} upstream" + (f" in \"{res['commit']['title']}\"" if res["commit"] else "")]
|
|
312
|
+
elif info["blob_id"] != src["blob_id"]:
|
|
313
|
+
res.update(status="changed", text=gl.raw(src["repo"], src["path"]), commit=gl.commit(src["repo"], src["path"]))
|
|
314
|
+
else:
|
|
315
|
+
f = Path(src["file"])
|
|
316
|
+
if not f.exists():
|
|
317
|
+
res["status"] = "deleted"
|
|
318
|
+
elif digests(f.read_text(encoding="utf-8"))[0] != src["sha256"]:
|
|
319
|
+
res.update(status="changed", text=f.read_text(encoding="utf-8"))
|
|
320
|
+
except Exception as e:
|
|
321
|
+
res.update(status="error", changes=[f"{type(e).__name__}: {e}"])
|
|
322
|
+
memo[key] = res
|
|
323
|
+
res = dict(memo[key])
|
|
324
|
+
if res["status"] == "changed":
|
|
325
|
+
try:
|
|
326
|
+
res["changes"] = diff_summary(src["role"], src.get("summary"), summarize(src, res["text"])) or \
|
|
327
|
+
["content changed (pads, courtyard, pins and properties the same)"]
|
|
328
|
+
except Exception as e:
|
|
329
|
+
res["changes"] = [f"changed; summary failed: {type(e).__name__}: {e}"]
|
|
330
|
+
return res
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def check_kicad(man, gl):
|
|
334
|
+
"""-> (rows, per-entry worst status)"""
|
|
335
|
+
rows, worst, memo = [], {}, {}
|
|
336
|
+
for key, e in man["entries"].items():
|
|
337
|
+
for src in e["sources"]:
|
|
338
|
+
r = check_source(src, gl, memo)
|
|
339
|
+
worst[key] = max(worst.get(key, "unchanged"), r["status"], key=RANK.get)
|
|
340
|
+
rows.append({"entry": key, "kind": e["kind"], "kicad_ref": e["kicad_ref"], "role": src["role"],
|
|
341
|
+
"source": src.get("path") or src.get("file"), "status": r["status"],
|
|
342
|
+
"summary": "; ".join(r["changes"]) + (f"; now {r['new_path']}" if r["new_path"] else ""), "spec": e["spec"],
|
|
343
|
+
"upstream_commit": (r["commit"] or {}).get("id", "")[:12], "upstream_date": (r["commit"] or {}).get("date", ""),
|
|
344
|
+
"_text": r["text"], "_src": src})
|
|
345
|
+
return rows, worst
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class Pinned:
|
|
349
|
+
"""http wrapper for kicad_import: URLs in `pins` answer with that text (what check-kicad just fetched)."""
|
|
350
|
+
def __init__(self, http, pins):
|
|
351
|
+
self.http, self.pins = http, pins
|
|
352
|
+
|
|
353
|
+
def get(self, url, **kw):
|
|
354
|
+
return Response(200, self.pins[url]) if url in self.pins else self.http.get(url, **kw)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _rules_layers(out):
|
|
358
|
+
"""Layers of the rules found from `out` (as `kicad_import -o out` uses), for entries recorded without them."""
|
|
359
|
+
from .rules import layers, load
|
|
360
|
+
try:
|
|
361
|
+
return layers(load(None, start=out))
|
|
362
|
+
except Exception:
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def reconvert(entries, out, http):
|
|
367
|
+
"""Re-run kicad_import for the specs behind `entries` (one conversion per spec); record() refreshes the manifest."""
|
|
368
|
+
done = {}
|
|
369
|
+
for k in ("PcbLib", "SchLib"):
|
|
370
|
+
(Path(out) / k).mkdir(parents=True, exist_ok=True)
|
|
371
|
+
for e in entries:
|
|
372
|
+
spec = e["spec"]
|
|
373
|
+
k = json.dumps(spec, sort_keys=True)
|
|
374
|
+
if k in done:
|
|
375
|
+
continue
|
|
376
|
+
if spec.get("footprint"):
|
|
377
|
+
rep = K.import_part(spec, out, http, e.get("step", False), gen_step=e.get("gen_step", False),
|
|
378
|
+
layers=e["layers"] if "layers" in e else _rules_layers(out))
|
|
379
|
+
else: # symbol-only conversion (kicad_import symbol ...)
|
|
380
|
+
rep = {"notes": [], "ok": False}
|
|
381
|
+
try:
|
|
382
|
+
chain, _ = K.load_symbol(spec["symbol"], http)
|
|
383
|
+
sym = K.convert_symbol(chain, spec.get("symbol_as"), spec.get("symbol_footprint"),
|
|
384
|
+
[tuple(kv.split("=", 1)) for kv in spec.get("params", [])], None, rep["notes"])
|
|
385
|
+
rep["schlib"] = str(K.AL.write_schlib(Path(out) / "SchLib" / f"{K.AL.file_stem(sym['name'])}.SchLib", [sym]))
|
|
386
|
+
rep["ok"] = True
|
|
387
|
+
record(out, spec, rep, http)
|
|
388
|
+
except Exception as ex:
|
|
389
|
+
rep["error"] = f"{type(ex).__name__}: {ex}"
|
|
390
|
+
done[k] = rep
|
|
391
|
+
return done
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
# ---------------------------------------------------------------- check-a365
|
|
395
|
+
Q_COMPONENTS = """query ($ws: String!, $after: String) {
|
|
396
|
+
desLibrary(workspaceUrl: $ws) {
|
|
397
|
+
components(first: 100, after: $after) {
|
|
398
|
+
pageInfo { hasNextPage endCursor }
|
|
399
|
+
nodes { id name comment description isManaged folder { path }
|
|
400
|
+
revision { id details { lifeCycleState { name } createdAt } }
|
|
401
|
+
details { parameters { name value } symbols { name guid } footprints { name guid } } }
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}"""
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def workspace_components(client, ws):
|
|
408
|
+
out, after = [], None
|
|
409
|
+
while True:
|
|
410
|
+
conn = client.gql(Q_COMPONENTS, {"ws": ws, "after": after})["desLibrary"]["components"] or {}
|
|
411
|
+
out += conn.get("nodes") or []
|
|
412
|
+
page = conn.get("pageInfo") or {}
|
|
413
|
+
if not page.get("hasNextPage"):
|
|
414
|
+
return out
|
|
415
|
+
after = page["endCursor"]
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _folder(p):
|
|
419
|
+
return "/".join(x for x in (p or "").replace("\\", "/").split("/") if x).lower()
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def check_a365(items, nodes, folders=None, state=None, rules=None):
|
|
423
|
+
"""plan items vs workspace nodes -> (rows, new state). state: {name: {revision, symbols, footprints}} from last run.
|
|
424
|
+
rules: the plan's parameters are compared under the rules' house names, as push sends them."""
|
|
425
|
+
from .altium365 import merged_params
|
|
426
|
+
from .rules import house_item
|
|
427
|
+
items = [house_item(i, rules) for i in items]
|
|
428
|
+
by = {}
|
|
429
|
+
for n in nodes:
|
|
430
|
+
by.setdefault(n["name"], n)
|
|
431
|
+
watch = {_folder(f) for f in (folders or [i["folder"] for i in items])}
|
|
432
|
+
rows, new_state = [], {}
|
|
433
|
+
row = lambda name, status, field="", plan="", ws="", detail="": rows.append(
|
|
434
|
+
{"name": name, "status": status, "field": field, "plan": plan, "workspace": ws, "detail": detail})
|
|
435
|
+
for it in items:
|
|
436
|
+
n = by.get(it["name"])
|
|
437
|
+
if not n:
|
|
438
|
+
row(it["name"], "missing", detail=f"plan status {it.get('status', '')}"); continue
|
|
439
|
+
d = n.get("details") or {}
|
|
440
|
+
cur = {p["name"]: p["value"] for p in d.get("parameters") or []}
|
|
441
|
+
if merged_params(n, it) is not None:
|
|
442
|
+
for p in it["parameters"]:
|
|
443
|
+
if cur.get(p["name"]) != p["value"]:
|
|
444
|
+
row(it["name"], "parameter", p["name"], p["value"], cur.get(p["name"], "(absent)"))
|
|
445
|
+
for k in it.get("cleared", []):
|
|
446
|
+
if k in cur:
|
|
447
|
+
row(it["name"], "parameter", k, "(cleared)", cur[k])
|
|
448
|
+
for f in ("comment", "description"):
|
|
449
|
+
if f in n and (it.get(f) or "") != (n[f] or ""):
|
|
450
|
+
row(it["name"], f, f, it.get(f) or "", n[f] or "")
|
|
451
|
+
if n.get("folder") and _folder(n["folder"].get("path")) != _folder(it["folder"]):
|
|
452
|
+
row(it["name"], "folder", "folder", it["folder"], n["folder"].get("path"))
|
|
453
|
+
for kind, key in (("symbol", "symbols"), ("footprint", "footprints")):
|
|
454
|
+
names = [x["name"] for x in d.get(key) or []]
|
|
455
|
+
ref = (it.get(kind) or {}).get("ref")
|
|
456
|
+
if ref and names and ref not in names:
|
|
457
|
+
row(it["name"], kind, kind, ref, ", ".join(names))
|
|
458
|
+
rev = n.get("revision") or {}
|
|
459
|
+
snap = {"revision": rev.get("id"), "lifecycle": ((rev.get("details") or {}).get("lifeCycleState") or {}).get("name"),
|
|
460
|
+
"symbols": sorted(x.get("guid") or "" for x in d.get("symbols") or []),
|
|
461
|
+
"footprints": sorted(x.get("guid") or "" for x in d.get("footprints") or [])}
|
|
462
|
+
new_state[it["name"]] = snap
|
|
463
|
+
old = (state or {}).get(it["name"])
|
|
464
|
+
if old:
|
|
465
|
+
for k in ("revision", "symbols", "footprints"):
|
|
466
|
+
if old.get(k) != snap[k]:
|
|
467
|
+
row(it["name"], "revision", k, str(old.get(k)), str(snap[k]), f"changed in the workspace since the last check"
|
|
468
|
+
+ (f" (lifecycle {snap['lifecycle']})" if snap["lifecycle"] else ""))
|
|
469
|
+
planned = {i["name"] for i in items}
|
|
470
|
+
for n in nodes:
|
|
471
|
+
f = _folder((n.get("folder") or {}).get("path"))
|
|
472
|
+
if n["name"] not in planned and any(f == w or f.startswith(w + "/") for w in watch):
|
|
473
|
+
row(n["name"], "not_in_plan", "folder", "", (n.get("folder") or {}).get("path"),
|
|
474
|
+
"managed" if n.get("isManaged") else "unmanaged")
|
|
475
|
+
return rows, new_state
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
# ---------------------------------------------------------------- reports & CLI
|
|
479
|
+
def write_report(rows, out, title, cols, meta=()):
|
|
480
|
+
out = Path(out)
|
|
481
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
482
|
+
with open(out.with_suffix(".csv"), "w", newline="", encoding="utf-8-sig") as f:
|
|
483
|
+
w = SS.DictWriter(f, fieldnames=cols, extrasaction="ignore")
|
|
484
|
+
w.writeheader(); w.writerows(rows)
|
|
485
|
+
esc = lambda v: str(v if v is not None else "").replace("|", "\\|").replace("\n", " ")
|
|
486
|
+
md = [f"# {title}", "", *meta, ""]
|
|
487
|
+
if rows:
|
|
488
|
+
md += ["| " + " | ".join(cols) + " |", "|" + "---|" * len(cols)] + ["| " + " | ".join(esc(r.get(c)) for c in cols) + " |" for r in rows]
|
|
489
|
+
else:
|
|
490
|
+
md.append("No changes.")
|
|
491
|
+
out.with_suffix(".md").write_text("\n".join(md) + "\n", encoding="utf-8")
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
KICAD_COLS = ["entry", "kind", "kicad_ref", "role", "source", "status", "summary", "upstream_commit", "upstream_date", "update"]
|
|
495
|
+
A365_COLS = ["name", "status", "field", "plan", "workspace", "detail"]
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def main(argv=None):
|
|
499
|
+
ap = argparse.ArgumentParser(description="Upstream change alerts for KiCad imports and Altium 365 workspaces")
|
|
500
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
501
|
+
k = sub.add_parser("check-kicad", help="compare imported KiCad parts with gitlab.com/kicad/libraries")
|
|
502
|
+
k.add_argument("manifest", nargs="?", default="out/altium_models_kicad/manifest.json")
|
|
503
|
+
k.add_argument("--update", action="store_true", help="reconvert parts whose sources changed")
|
|
504
|
+
k.add_argument("--all", action="store_true", help="list unchanged sources in the report too")
|
|
505
|
+
k.add_argument("--cache", default="out/http_cache")
|
|
506
|
+
k.add_argument("--offline", action="store_true", help="--update reads unchanged sources from the HTTP cache only")
|
|
507
|
+
k.add_argument("-o", "--out", default="out/upstream_kicad", help="report path stem (.md and .csv)")
|
|
508
|
+
a3 = sub.add_parser("check-a365", help="compare a plan.json with an Altium 365 workspace (NEXAR_TOKEN)")
|
|
509
|
+
a3.add_argument("plan")
|
|
510
|
+
a3.add_argument("--workspace", required=True)
|
|
511
|
+
a3.add_argument("--folder", action="append", help="workspace folder to look for unplanned components (default: the plan's)")
|
|
512
|
+
a3.add_argument("--state", help="JSON snapshot of revisions/GUIDs; compared, then rewritten")
|
|
513
|
+
a3.add_argument("-o", "--out", default="out/upstream_a365", help="report path stem (.md and .csv)")
|
|
514
|
+
a3.add_argument("--rules", help="library rules file: house parameter names (default: ecadlib.toml found from the plan)")
|
|
515
|
+
a = ap.parse_args(argv)
|
|
516
|
+
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
517
|
+
|
|
518
|
+
if a.cmd == "check-kicad":
|
|
519
|
+
if not Path(a.manifest).is_file():
|
|
520
|
+
print(f"check-kicad: no manifest at {a.manifest}: it lists the parts `python -m ecadlib.kicad_import batch ... -o DIR` "
|
|
521
|
+
"converted (DIR/manifest.json); give that file, e.g. `ecadlib upstream check-kicad DIR/manifest.json`",
|
|
522
|
+
file=sys.stderr)
|
|
523
|
+
return 2
|
|
524
|
+
try:
|
|
525
|
+
man = json.loads(Path(a.manifest).read_text(encoding="utf-8"))
|
|
526
|
+
rows, worst = check_kicad(man, GitLab(token=os.environ.get("GITLAB_TOKEN")))
|
|
527
|
+
except Exception as e:
|
|
528
|
+
print(f"check-kicad: {type(e).__name__}: {e}", file=sys.stderr); return 2
|
|
529
|
+
if a.update:
|
|
530
|
+
ch = [r for r in rows if r["status"] == "changed"]
|
|
531
|
+
todo = {r["entry"] for r in ch}
|
|
532
|
+
if todo:
|
|
533
|
+
pins = {r["_src"]["url"]: r["_text"] for r in ch if r["_src"].get("url") and r["_text"] is not None}
|
|
534
|
+
done = reconvert([man["entries"][e] for e in sorted(todo)], Path(a.manifest).parent,
|
|
535
|
+
Pinned(CachedHTTP(a.cache, ttl_s=0, offline=a.offline, timeout=60), pins))
|
|
536
|
+
for r in rows: # every entry of a reconverted spec (footprint and its symbol) was rewritten
|
|
537
|
+
rep = done.get(json.dumps(r["spec"], sort_keys=True))
|
|
538
|
+
if rep:
|
|
539
|
+
r["update"] = "reconverted" if rep.get("ok") else f"failed: {rep.get('error')}"
|
|
540
|
+
shown = rows if a.all else [r for r in rows if r["status"] != "unchanged"]
|
|
541
|
+
counts = {s: sum(v == s for v in worst.values()) for s in RANK}
|
|
542
|
+
write_report(shown, a.out, "KiCad upstream changes", KICAD_COLS,
|
|
543
|
+
[f"Manifest `{a.manifest}`, checked {now}.", "",
|
|
544
|
+
"Parts: " + ", ".join(f"{v} {s}" for s, v in counts.items() if v)])
|
|
545
|
+
for r in shown:
|
|
546
|
+
print(f" {r['status']:<9} {r['entry']:<32} {r['source']:<56} {r['summary']}" + (f" [{r['update']}]" if r.get("update") else ""))
|
|
547
|
+
print(" ".join(f"{v} {s}" for s, v in counts.items() if v) + f" -> {Path(a.out).with_suffix('.md')} and .csv")
|
|
548
|
+
return 2 if counts["error"] else 1 if any(counts[s] for s in ("changed", "renamed", "deleted")) else 0
|
|
549
|
+
|
|
550
|
+
token = os.environ.get("NEXAR_TOKEN")
|
|
551
|
+
if not token:
|
|
552
|
+
print("check-a365: set NEXAR_TOKEN (design.domain, user.access, openid)", file=sys.stderr); return 2
|
|
553
|
+
try:
|
|
554
|
+
from .altium365 import Nexar
|
|
555
|
+
items = json.loads(Path(a.plan).read_text(encoding="utf-8"))
|
|
556
|
+
state = json.loads(Path(a.state).read_text(encoding="utf-8")) if a.state and Path(a.state).exists() else None
|
|
557
|
+
from .rules import load
|
|
558
|
+
rules = load(a.rules, start=Path(a.plan).parent)
|
|
559
|
+
rows, new_state = check_a365(items, workspace_components(Nexar(token), a.workspace), a.folder, state, rules)
|
|
560
|
+
except Exception as e:
|
|
561
|
+
print(f"check-a365: {type(e).__name__}: {e}", file=sys.stderr); return 2
|
|
562
|
+
if a.state:
|
|
563
|
+
Path(a.state).parent.mkdir(parents=True, exist_ok=True)
|
|
564
|
+
Path(a.state).write_text(json.dumps(new_state, indent=1), encoding="utf-8")
|
|
565
|
+
write_report(rows, a.out, "Altium 365 drift", A365_COLS, [f"Plan `{a.plan}` vs `{a.workspace}`, checked {now}."])
|
|
566
|
+
for r in rows:
|
|
567
|
+
print(f" {r['status']:<11} {r['name']:<28} {r['field']:<24} plan={r['plan']!r} workspace={r['workspace']!r}")
|
|
568
|
+
print(f"{len(rows)} differences -> {Path(a.out).with_suffix('.md')} and .csv")
|
|
569
|
+
return 1 if rows else 0
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
if __name__ == "__main__":
|
|
573
|
+
sys.exit(main())
|