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/release_check.py
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"""Check every part of an altium365 plan before it goes to Altium 365; hold back what fails.
|
|
2
|
+
|
|
3
|
+
python3 -m ecadlib.release_check out/librarian/plan.json [--allow PART ...] [--unallow PART ...] [-o REPORT_STEM]
|
|
4
|
+
|
|
5
|
+
Rules, per plan item:
|
|
6
|
+
files the .SchLib and .PcbLib exist and read back with altium_lib's reader (cannot be overridden)
|
|
7
|
+
pins every symbol pin has a pad with the same number and every pad a pin; unnumbered, NPTH and
|
|
8
|
+
mounting pads (MP, MH, SH, MTG, ...) are allowed without a pin
|
|
9
|
+
footprint footprint_check and the rule book (footprint_rules, docs/footprint-rulebook.md) on the item's .kicad_mod:
|
|
10
|
+
every footprint_check error fails, and rule-book errors in FR.RELEASE_HOLDS (name, layers, type, annular
|
|
11
|
+
ring, thermal vias, silkscreen text size / over pads, also on the PcbLib); a reversed diode / LED / polarized-capacitor pin map fails for every part. Parts taken from the official KiCad
|
|
12
|
+
library are held only for overlapping pads, a missing courtyard and a missing pin 1 (their other
|
|
13
|
+
findings are reported as warnings). Without a .kicad_mod the same three are checked on the PcbLib.
|
|
14
|
+
Overlapping pads whose symbol pins share a name (USB-C A1/B12 = GND) are stacked on purpose: a warning.
|
|
15
|
+
height a 3D height is present and inside the plausible range for the package family (HEIGHTS)
|
|
16
|
+
fields Manufacturer, Manufacturer Part Number, a datasheet link and Description are filled in
|
|
17
|
+
parity the .kicad_mod and the .PcbLib / .SchLib read back the same (ecadlib.parity: pads, drills, paste, courtyard,
|
|
18
|
+
3D, pin -> pad); `[release] parity = "warn"` reports instead of holding, "off" skips it
|
|
19
|
+
verify the item carries `verify` notes (e.g. a datasheet-reader footprint): a person has to look first
|
|
20
|
+
Failing items get status `held` and `hold_reasons`; others `ready`. `--allow PART` records an override in the
|
|
21
|
+
item (`allow.failures` = the failures it covers); an allowed item is `ready` while its failures stay within
|
|
22
|
+
that list. altium365 push releases only `ready` items. Items `ecadlib a365 pull` brought in from the workspace
|
|
23
|
+
(status `existing`) are already released there and are not checked. Exit code 0 when nothing is held, 2 otherwise."""
|
|
24
|
+
import argparse, csv, datetime, re, sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from . import altium_lib as AL, footprint_check as FC, footprint_rules as FR
|
|
28
|
+
from . import sheetsafe as SS
|
|
29
|
+
from .fsutil import write_text_atomic
|
|
30
|
+
|
|
31
|
+
# plausible overall height (mm) per package family: first regex (on footprint name) that matches wins
|
|
32
|
+
HEIGHTS = [
|
|
33
|
+
(r"^LED_(0201|0402|0603|0805|1206)", 0.2, 1.4), (r"(^|[_-])01005(?!\d)", 0.1, 0.3), (r"(^|[_-])0201(?!\d)", 0.15, 0.45), (r"(^|[_-])0402(?!\d)", 0.2, 0.8),
|
|
34
|
+
(r"(^|[_-])0603(?!\d)", 0.3, 1.1), (r"(^|[_-])0805(?!\d)", 0.35, 1.6), (r"(^|[_-])1206(?!\d)", 0.4, 2.0),
|
|
35
|
+
(r"(^|[_-])(1210|1812|2010|2512)(?!\d)", 0.4, 3.0), (r"^CP_EIA", 0.8, 3.5),
|
|
36
|
+
(r"^SOT-223", 1.4, 1.95), (r"^SOT-23", 0.8, 1.45), (r"^SOT-89", 1.3, 1.7), (r"^SOT-?(323|363|353|563|666)", 0.45, 1.1),
|
|
37
|
+
(r"^(D_)?SOD-?(123|323|523|923)", 0.4, 1.4), (r"^(D_)?(SMA|SMB|SMC)\b", 1.9, 2.7),
|
|
38
|
+
(r"^(TSSOP|MSOP|VSSOP|HTSSOP|TSOP)", 0.75, 1.25), (r"^SSOP", 1.2, 2.05), (r"^(SOIC|SOP|SO-)", 1.2, 1.85),
|
|
39
|
+
(r"(QFN|DFN|[^S]SON|^SON|WSON|USON)", 0.3, 1.05), (r"^LQFP", 1.35, 1.7), (r"^TQFP", 1.0, 1.25), (r"QFP", 1.0, 4.1),
|
|
40
|
+
(r"BGA", 0.4, 2.6), (r"^Crystal_SMD_(1612|2016|2520|3225|5032)", 0.35, 1.4), (r"^Crystal_SMD_HC49", 3.0, 5.0),
|
|
41
|
+
(r"^USB_C_Receptacle", 2.0, 3.6), (r"^JST_SH", 1.8, 3.5), (r"^PinHeader_.*P1\.27mm", 2.0, 9.0),
|
|
42
|
+
(r"^PinHeader_.*P2\.54mm", 2.5, 12.0), (r"^(ESP32|RF_Module)", 1.5, 4.0), (r"^LED_", 0.2, 6.0),
|
|
43
|
+
]
|
|
44
|
+
GENERIC_HEIGHT = (0.05, 30.0)
|
|
45
|
+
MECH_PAD = re.compile(r"(MP|MH|SH|MTG|MOUNT|MECH|NPTH|H)\d*", re.I)
|
|
46
|
+
REQUIRED = ["Manufacturer", "Manufacturer Part Number"]
|
|
47
|
+
UNFIXABLE = ("files",) # rules --allow cannot override
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def height_range(fp_name):
|
|
51
|
+
for rx, lo, hi in HEIGHTS:
|
|
52
|
+
if re.search(rx, fp_name or "", re.I):
|
|
53
|
+
return lo, hi, rx
|
|
54
|
+
return (*GENERIC_HEIGHT, None)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _package_height(item, fp_name):
|
|
58
|
+
"""(height, label) from kicad_import.package_default_height for a standard chip size / small package, or None."""
|
|
59
|
+
from .kicad_import import package_default_height
|
|
60
|
+
cat = str(item.get("folder") or "").split("/")[-1]
|
|
61
|
+
kind = {"Resistors": "R", "Capacitors": "C"}.get(cat)
|
|
62
|
+
names = [item["footprint"].get("kicad"), item["footprint"].get("ref"), fp_name, item.get("description"), item.get("name")]
|
|
63
|
+
return package_default_height(names, kind)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _mil(s):
|
|
67
|
+
m = re.match(r"\s*(-?[\d.]+)\s*mil", str(s or ""))
|
|
68
|
+
return float(m.group(1)) * 0.0254 if m else None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _path(base, p):
|
|
72
|
+
return (Path(base) / p) if p and not Path(p).is_absolute() else (Path(p) if p else None)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _kicad_mod(item, base, fp_file):
|
|
76
|
+
p = _path(base, item["footprint"].get("kicad_mod"))
|
|
77
|
+
if p and p.exists():
|
|
78
|
+
return p
|
|
79
|
+
if fp_file: # <models>/KiCad/<name>.kicad_mod next to <models>/PcbLib
|
|
80
|
+
q = fp_file.parent.parent / "KiCad" / f"{AL.file_stem(item['footprint']['ref'])}.kicad_mod"
|
|
81
|
+
return q if q.exists() else None
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _pad_rects(pads):
|
|
86
|
+
out = []
|
|
87
|
+
for r in pads:
|
|
88
|
+
w, h = AL.to_mm(r["w"]), AL.to_mm(r["h"])
|
|
89
|
+
if round(r["rot"]) % 180 == 90:
|
|
90
|
+
w, h = h, w
|
|
91
|
+
out.append((r["designator"], AL.to_mm(r["x"]), AL.to_mm(r["y"]), w, h, r["rot"] % 90 != 0))
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
OVERLAP = re.compile(r"pads (\S+) and (\S+) overlap")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _same_net(msg, pin_names):
|
|
99
|
+
"""True when an overlap message names two pads whose symbol pins share a name (e.g. USB-C A1/B12 = GND):
|
|
100
|
+
such pads are stacked on purpose and are connected anyway."""
|
|
101
|
+
m = OVERLAP.search(msg)
|
|
102
|
+
if not m:
|
|
103
|
+
return False
|
|
104
|
+
a, b = (pin_names.get(n, "").strip() for n in m.groups())
|
|
105
|
+
return bool(a) and a == b and a not in ("~", m.group(1), m.group(2))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _pcblib_minimal(fp, pin_names=None, courtyard=AL.MECH15, pin1=None):
|
|
109
|
+
"""Overlap, courtyard and pin 1 from the PcbLib records (used when there is no .kicad_mod)."""
|
|
110
|
+
fails, warns, pads = [], [], [r for r in fp["records"] if r["type"] == "pad"]
|
|
111
|
+
rects = _pad_rects([p for p in pads if p["hole"] == 0 or p["plated"]])
|
|
112
|
+
for i, a in enumerate(rects):
|
|
113
|
+
for b in rects[i + 1:]:
|
|
114
|
+
if a[0] and a[0] == b[0] or a[5] or b[5]:
|
|
115
|
+
continue
|
|
116
|
+
if abs(a[1] - b[1]) < (a[3] + b[3]) / 2 - 0.005 and abs(a[2] - b[2]) < (a[4] + b[4]) / 2 - 0.005:
|
|
117
|
+
msg = f"pads {a[0]} and {b[0]} overlap"
|
|
118
|
+
(warns if _same_net(msg, pin_names or {}) else fails).append(msg)
|
|
119
|
+
if not any(r["layer"] == courtyard for r in fp["records"] if r["type"] in ("track", "arc", "region")):
|
|
120
|
+
fails.append(f"no courtyard (Mechanical {courtyard - 56})")
|
|
121
|
+
if not _has_pin1({p["designator"] for p in pads}, pin1):
|
|
122
|
+
fails.append("no pin 1 pad")
|
|
123
|
+
return fails, warns
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _has_pin1(nums, pin1=None):
|
|
127
|
+
return bool(set(pin1 or ("1", "A1")) & set(nums))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def check_item(item, base=".", rules=None):
|
|
131
|
+
"""-> (failures [(rule, message)], warnings [str], info {height, ...}). rules (ecadlib.rules): required fields,
|
|
132
|
+
accepted pin-1 names and the courtyard layer; None = ecadlib's own."""
|
|
133
|
+
from . import rules as RU
|
|
134
|
+
rules = rules or RU.DEFAULTS
|
|
135
|
+
pin1 = rules["footprint"]["pin1"]
|
|
136
|
+
fails, warns, info = [], [], {}
|
|
137
|
+
add = lambda rule, msg: fails.append((rule, msg))
|
|
138
|
+
sym_f, fp_f = _path(base, item["symbol"].get("file")), _path(base, item["footprint"].get("file"))
|
|
139
|
+
syms = fps = None
|
|
140
|
+
pin_names = {}
|
|
141
|
+
for kind, f, reader in (("symbol", sym_f, AL.read_schlib), ("footprint", fp_f, AL.read_pcblib)):
|
|
142
|
+
if not f:
|
|
143
|
+
add("files", f"no {kind} model (.{'SchLib' if kind == 'symbol' else 'PcbLib'})")
|
|
144
|
+
elif not f.exists():
|
|
145
|
+
add("files", f"{kind} file missing: {f}")
|
|
146
|
+
else:
|
|
147
|
+
try:
|
|
148
|
+
r = reader(f)
|
|
149
|
+
r = r[1] if kind == "symbol" else r
|
|
150
|
+
if not r:
|
|
151
|
+
raise ValueError("library is empty")
|
|
152
|
+
if kind == "symbol":
|
|
153
|
+
syms = r
|
|
154
|
+
else:
|
|
155
|
+
fps = r
|
|
156
|
+
except Exception as e: # unreadable: altium_lib's reader is the gate
|
|
157
|
+
add("files", f"{kind} file does not read back ({type(e).__name__}: {e})")
|
|
158
|
+
# ---- pins vs pads
|
|
159
|
+
if syms and fps:
|
|
160
|
+
pins = {r["num"] for s in syms for r in s["records"] if r.get("RECORD") == "2"}
|
|
161
|
+
pin_names = {r["num"]: r.get("name", "") for s in syms for r in s["records"] if r.get("RECORD") == "2"}
|
|
162
|
+
pads = [r for r in fps[0]["records"] if r["type"] == "pad"]
|
|
163
|
+
if not pins:
|
|
164
|
+
add("pins", "symbol has no pins")
|
|
165
|
+
if not pads:
|
|
166
|
+
add("pins", "footprint has no pads")
|
|
167
|
+
grid = FC.mount_rx([p["designator"] for p in pads]) is FC.MOUNT_GRID # BGA: H1, H2 .. are balls, not holes
|
|
168
|
+
mech = {p["designator"] for p in pads if not p["designator"] or (p["hole"] and not p["plated"])
|
|
169
|
+
or MECH_PAD.fullmatch(p["designator"]) and not (grid and re.fullmatch(r"H\d+", p["designator"]))}
|
|
170
|
+
numbered = {p["designator"] for p in pads} - mech
|
|
171
|
+
if pins - {p["designator"] for p in pads}:
|
|
172
|
+
add("pins", f"symbol pins without a pad: {FC._ranges(pins - {p['designator'] for p in pads})}")
|
|
173
|
+
if numbered - pins:
|
|
174
|
+
add("pins", f"pads without a symbol pin: {FC._ranges(numbered - pins)}")
|
|
175
|
+
if mech - {""}:
|
|
176
|
+
warns.append(f"mechanical pads without a pin (allowed): {FC._ranges(mech - {''})}")
|
|
177
|
+
# ---- footprint rules
|
|
178
|
+
src = str((item.get("source") or {}).get("footprint") or "")
|
|
179
|
+
kicad_lib = src.startswith("kicad:")
|
|
180
|
+
kmod = _kicad_mod(item, base, fp_f) if fp_f else None
|
|
181
|
+
if kmod:
|
|
182
|
+
info["kicad_mod"] = str(kmod)
|
|
183
|
+
text = kmod.read_text(encoding="utf-8")
|
|
184
|
+
try:
|
|
185
|
+
issues, nums = FC.check(text), {p["num"] for p in FC._pad_rects(FC.sexpr.parse(text))}
|
|
186
|
+
except Exception as e: # checker crash: fall back to the PcbLib minimum below
|
|
187
|
+
issues, nums, kmod = [], set(), None
|
|
188
|
+
warns.append(f"footprint_check failed on {info.pop('kicad_mod')} ({type(e).__name__}: {e})")
|
|
189
|
+
for lvl, rule, msg in issues:
|
|
190
|
+
if rule == "pin1" and msg == "no pad numbered 1" and _has_pin1(nums, pin1):
|
|
191
|
+
continue # A1 counts as pin 1 (connectors, BGAs)
|
|
192
|
+
if rule == "copper" and "overlap" in msg and _same_net(msg, pin_names):
|
|
193
|
+
warns.append(f"{lvl} {rule}: {msg} (same pin name '{pin_names[OVERLAP.search(msg).group(1)]}': stacked on purpose)")
|
|
194
|
+
continue
|
|
195
|
+
hard = lvl == "error" and (not kicad_lib or (rule == "copper" and "overlap" in msg) or
|
|
196
|
+
(rule == "courtyard" and msg.startswith("no courtyard")) or (rule == "pin1" and "no pad" in msg))
|
|
197
|
+
(add("footprint", f"{rule}: {msg}") if hard else warns.append(f"{lvl} {rule}: {msg}"))
|
|
198
|
+
if kmod: # the rule book (footprint_rules): naming, layers, type, pin map ...
|
|
199
|
+
try:
|
|
200
|
+
book = FR.check(text, filename=kmod.name, symbol_pins=pin_names if syms else None, geometry=False)
|
|
201
|
+
except Exception as e: # a rule-book crash must not hide the other rules
|
|
202
|
+
book = [("warning", "rulebook", f"footprint_rules failed ({type(e).__name__}: {e})")]
|
|
203
|
+
for lvl, rule, msg in book:
|
|
204
|
+
if rule == "connect":
|
|
205
|
+
continue # the pins rule above already compares the Altium symbol and footprint
|
|
206
|
+
if fps and msg.startswith("in the Altium export"):
|
|
207
|
+
continue # the PcbLib itself is checked below
|
|
208
|
+
hard = lvl == "error" and (rule == "pinmap" or (not kicad_lib and rule in FR.RELEASE_HOLDS))
|
|
209
|
+
(add("footprint", f"{rule}: {msg}") if hard else warns.append(f"{lvl} {rule}: {msg}"))
|
|
210
|
+
if not kmod and fps:
|
|
211
|
+
mf, mw = _pcblib_minimal(fps[0], pin_names, RU.layer_id(rules["layers"]["courtyard"]), pin1)
|
|
212
|
+
for m in mf:
|
|
213
|
+
add("footprint", m)
|
|
214
|
+
warns += [f"{m} (same pin name: stacked on purpose)" for m in mw]
|
|
215
|
+
warns.append("no .kicad_mod: only overlap, courtyard and pin 1 checked (on the PcbLib)")
|
|
216
|
+
if fps: # silkscreen text as Altium shows it: size, over pads, over the body
|
|
217
|
+
for lvl, msg in FR.pcblib_text_issues(fps[0]):
|
|
218
|
+
hard = lvl == "error" and not kicad_lib
|
|
219
|
+
(add("footprint", f"silktext: {msg}") if hard else warns.append(f"{lvl} silktext: {msg}"))
|
|
220
|
+
# ---- KiCad and Altium read back the same (ecadlib.parity)
|
|
221
|
+
mode = (rules.get("release") or {}).get("parity", "hold")
|
|
222
|
+
if mode != "off" and kmod and fps:
|
|
223
|
+
from . import parity as PY
|
|
224
|
+
try:
|
|
225
|
+
diffs = PY.release_diffs(text, fps[0], syms, rules)
|
|
226
|
+
except Exception as e: # a parity crash must not hide the other rules
|
|
227
|
+
diffs = [{"level": "warn", "msg": f"parity check failed ({type(e).__name__}: {e})"}]
|
|
228
|
+
for d in diffs:
|
|
229
|
+
(add("parity", d["msg"]) if d["level"] == "error" and mode == "hold" else warns.append(f"parity: {d['msg']}"))
|
|
230
|
+
info["parity"] = "mismatch" if any(d["level"] == "error" for d in diffs) else "match"
|
|
231
|
+
# ---- the footprint written for Fusion / EAGLE and EasyEDA Pro reads back the same (eagle_export, easyeda_pro_export)
|
|
232
|
+
for tool, module, label in (("eagle", "eagle_export", "EAGLE / Fusion"), ("easyeda", "easyeda_pro_export", "EasyEDA Pro")):
|
|
233
|
+
tmode = (rules.get("release") or {}).get(tool, "warn")
|
|
234
|
+
if tmode == "off" or not kmod:
|
|
235
|
+
continue
|
|
236
|
+
import importlib
|
|
237
|
+
try:
|
|
238
|
+
diffs = importlib.import_module(f"ecadlib.{module}").check_footprint(text, kmod.stem)
|
|
239
|
+
except Exception as e: # a writer crash must not hide the other rules
|
|
240
|
+
diffs = [{"level": "error", "msg": f"{label} export failed ({type(e).__name__}: {e})"}]
|
|
241
|
+
for d in diffs:
|
|
242
|
+
if d["level"] == "error":
|
|
243
|
+
(add(tool, f"{label}: {d['msg']}") if tmode == "hold" else warns.append(f"{tool}: {d['msg']}"))
|
|
244
|
+
info[tool] = "mismatch" if any(d["level"] == "error" for d in diffs) else "match"
|
|
245
|
+
# ---- 3D height
|
|
246
|
+
if fps:
|
|
247
|
+
bodies = [AL.parse_params(r["params"]) for r in fps[0]["records"] if r["type"] == "body"]
|
|
248
|
+
hs = [h for h in (_mil(b.get("OVERALLHEIGHT")) for b in bodies) if h]
|
|
249
|
+
h = max(hs) if hs else _mil(fps[0]["params"].get("HEIGHT"))
|
|
250
|
+
if not bodies and h:
|
|
251
|
+
warns.append("height only as the footprint HEIGHT parameter; no 3D body")
|
|
252
|
+
lo, hi, fam = height_range(item["footprint"].get("kicad") or item["footprint"].get("ref") or fps[0]["name"])
|
|
253
|
+
info["height"] = h
|
|
254
|
+
default = any("package default" in n for n in (item.get("librarian") or {}).get("notes", []))
|
|
255
|
+
pd = None if h else _package_height(item, fps[0]["name"])
|
|
256
|
+
if not h and pd:
|
|
257
|
+
warns.append(f"HEIGHT from package default {pd[0]} mm ({pd[1]}), verify: the PcbLib has no 3D height (no API key "
|
|
258
|
+
"for the datasheet, no KiCad model)")
|
|
259
|
+
info["height"] = pd[0]
|
|
260
|
+
elif not h:
|
|
261
|
+
add("height", "no 3D height (no 3D body and no HEIGHT)")
|
|
262
|
+
elif default:
|
|
263
|
+
warns.append(f"3D height {h:.2f} mm is the package default, verify against the datasheet")
|
|
264
|
+
if not lo - 0.01 <= h <= hi + 0.01:
|
|
265
|
+
warns.append(f"3D height {h:.2f} mm outside {lo}-{hi} mm for {fam or 'any package'}")
|
|
266
|
+
elif not lo - 0.01 <= h <= hi + 0.01:
|
|
267
|
+
add("height", f"3D height {h:.2f} mm outside {lo}-{hi} mm for {fam or 'any package'}")
|
|
268
|
+
elif not fam:
|
|
269
|
+
warns.append(f"no height range for package '{item['footprint'].get('ref')}'; only checked {lo}-{hi} mm")
|
|
270
|
+
# ---- fields
|
|
271
|
+
par = {p["name"]: p["value"] for p in item.get("parameters", [])}
|
|
272
|
+
for k in rules["parameters"]["required"]: # default: REQUIRED, then the datasheet link and Description
|
|
273
|
+
if k == "Datasheet":
|
|
274
|
+
if not any(str(par.get(x, "")).strip() for x in ("ComponentLink2URL", "Datasheet", RU.canonical_param("Datasheet", rules))):
|
|
275
|
+
add("fields", "no datasheet link")
|
|
276
|
+
elif k == "Description":
|
|
277
|
+
if not str(item.get("description") or "").strip():
|
|
278
|
+
add("fields", "Description is empty")
|
|
279
|
+
elif str((item.get("field_sources") or {}).get("Description", "")).startswith("written by ecadlib"):
|
|
280
|
+
warns.append(f"Description \"{item['description']}\" was written by ecadlib from the category and "
|
|
281
|
+
"package (no source had one): check it")
|
|
282
|
+
elif not str(par.get(RU.canonical_param(k, rules), par.get(k, ""))).strip():
|
|
283
|
+
add("fields", f"{k} is empty")
|
|
284
|
+
for v in item.get("verify") or []:
|
|
285
|
+
add("verify", v)
|
|
286
|
+
return fails, warns, info
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def apply(items, base=".", allow=(), unallow=(), who="--allow", rules=None):
|
|
290
|
+
"""Run check_item on every item, set status ready / held, record --allow overrides. Returns report rows.
|
|
291
|
+
rules: a loaded rules dict, a path, or None to use the ecadlib.toml found from `base` (defaults when none)."""
|
|
292
|
+
from . import rules as RU
|
|
293
|
+
rules = RU.resolve(rules, base)
|
|
294
|
+
rows, today = [], datetime.date.today().isoformat()
|
|
295
|
+
for it in items:
|
|
296
|
+
if it.get("a365") and it.get("status") in ("existing", "deleted_in_workspace"):
|
|
297
|
+
# pulled from Altium 365 (ecadlib a365 pull): already released there, nothing to release or hold back
|
|
298
|
+
it["hold_reasons"], it["release_check"] = [], {"result": it["status"], "failures": [], "warnings": []}
|
|
299
|
+
rows.append({"name": it["name"], "status": it["status"], "result": it["status"], "symbol_source": "a365",
|
|
300
|
+
"footprint_source": "a365", "height_mm": "", "failures": "", "warnings": "", "allowed": ""})
|
|
301
|
+
continue
|
|
302
|
+
fails, warns, info = check_item(it, base, rules)
|
|
303
|
+
msgs = [f"{r}: {m}" for r, m in fails]
|
|
304
|
+
if it["name"] in unallow:
|
|
305
|
+
it.pop("allow", None)
|
|
306
|
+
if it["name"] in allow:
|
|
307
|
+
it["allow"] = {"by": who, "date": today, "failures": [m for m in msgs if not m.startswith(UNFIXABLE)]}
|
|
308
|
+
for k in ("hold_reasons", "release_check"): # the key order a librarian re-run writes: no plan.json churn
|
|
309
|
+
if k in it:
|
|
310
|
+
it[k] = it.pop(k)
|
|
311
|
+
covered = set((it.get("allow") or {}).get("failures", []))
|
|
312
|
+
hard = [m for m in msgs if m.startswith(UNFIXABLE)]
|
|
313
|
+
open_ = [m for m in msgs if m not in covered]
|
|
314
|
+
if not msgs:
|
|
315
|
+
it["status"], result = "ready", "pass"
|
|
316
|
+
elif it.get("allow") and not open_:
|
|
317
|
+
it["status"], result = "ready", "allowed"
|
|
318
|
+
else:
|
|
319
|
+
it["status"], result = "held", "held"
|
|
320
|
+
it["hold_reasons"] = open_ if result == "held" else []
|
|
321
|
+
if result == "held" and it.get("allow"):
|
|
322
|
+
it["hold_reasons"] = [m + (" (cannot be overridden)" if m in hard else " (not covered by --allow)") for m in open_]
|
|
323
|
+
it["release_check"] = {"result": result, "failures": msgs, "warnings": warns, **{k: v for k, v in info.items() if k in ("height", "parity")}}
|
|
324
|
+
rows.append({"name": it["name"], "status": it["status"], "result": result,
|
|
325
|
+
"symbol_source": (it.get("source") or {}).get("symbol") or "", "footprint_source": (it.get("source") or {}).get("footprint") or "",
|
|
326
|
+
"height_mm": f"{info['height']:.2f}" if info.get("height") else "", "failures": "; ".join(msgs),
|
|
327
|
+
"warnings": "; ".join(warns), "allowed": "yes" if it.get("allow") else ""})
|
|
328
|
+
from .approvals import post_check # sign-off policy (approvals.json next to the plan) and dedupe status
|
|
329
|
+
post_check(items, base, rows)
|
|
330
|
+
return rows
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _split(failures):
|
|
334
|
+
"""A report row's "rule: msg; rule: msg" back into reasons (messages may contain "; " themselves)."""
|
|
335
|
+
return [x for x in re.split(r"; (?=(?:%s): )" % "|".join(("files", "pins", "footprint", "height", "fields", "parity", "verify")),
|
|
336
|
+
failures or "") if x]
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def write_report(rows, stem, plan=""):
|
|
340
|
+
stem = Path(stem)
|
|
341
|
+
stem.parent.mkdir(parents=True, exist_ok=True)
|
|
342
|
+
with open(stem.with_suffix(".csv"), "w", newline="", encoding="utf-8-sig") as f:
|
|
343
|
+
w = SS.DictWriter(f, fieldnames=list(rows[0]) if rows else ["name", "status"])
|
|
344
|
+
w.writeheader(); w.writerows(rows)
|
|
345
|
+
from . import messages as M
|
|
346
|
+
held = [r for r in rows if r["status"] == "held"]
|
|
347
|
+
allowed = sum(r["result"] == "allowed" for r in rows)
|
|
348
|
+
where = Path(str(plan)).parent.name if plan else ""
|
|
349
|
+
md = [f"# Release check{': ' + where if where else ''}", "",
|
|
350
|
+
f"**{M.plural(len(rows), 'part')}: {M.counts(r['status'] for r in rows)}.**"
|
|
351
|
+
+ (f" {allowed} of the ready ones {'was' if allowed == 1 else 'were'} released anyway." if allowed else ""), "",
|
|
352
|
+
("Held parts stay out of the library until they are fixed or released anyway." if held
|
|
353
|
+
else "Nothing is held."), ""]
|
|
354
|
+
if held:
|
|
355
|
+
md += [f"## Held ({len(held)})", ""]
|
|
356
|
+
for r in held:
|
|
357
|
+
md += [f"### {r['name']}", ""]
|
|
358
|
+
for e in M.explain_all(_split(r["failures"])):
|
|
359
|
+
md.append(f"- {e['text']}" + (f" {e['note']}" if e["note"] else "") + f" \n **Next:** {e['fix']}")
|
|
360
|
+
md.append("")
|
|
361
|
+
rest = [r for r in rows if r["status"] != "held"]
|
|
362
|
+
if rest:
|
|
363
|
+
md += [f"## Not held ({len(rest)})", "", "| Part | Status | Symbol from | Footprint from | Height |", "|---|---|---|---|---|"]
|
|
364
|
+
md += [f"| {r['name']} | {M.status_word('allowed' if r['result'] == 'allowed' else r['status'])} | {M.source_word(r['symbol_source'])} | "
|
|
365
|
+
f"{M.source_word(r['footprint_source'])} | {r['height_mm'] + ' mm' if r['height_mm'] else '-'} |" for r in rest]
|
|
366
|
+
md.append("")
|
|
367
|
+
warned = [r for r in rows if r["warnings"]]
|
|
368
|
+
if warned:
|
|
369
|
+
md += ["## Warnings (not held)", ""] + [f"- **{r['name']}**: {M.clean(r['warnings'])}" for r in warned]
|
|
370
|
+
stem.with_suffix(".md").write_text("\n".join(md) + "\n", encoding="utf-8")
|
|
371
|
+
return stem
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def print_result(rows, plan, stem, t=None):
|
|
375
|
+
"""The terminal view: one row per part (status, name, the reason in plain words), then the summary."""
|
|
376
|
+
from . import messages as M, term as TM
|
|
377
|
+
t = t or TM.Term()
|
|
378
|
+
table = []
|
|
379
|
+
for r in rows:
|
|
380
|
+
st = {"pass": "ready", "allowed": "ready"}.get(r["result"], r["status"])
|
|
381
|
+
word = "released anyway" if r["result"] == "allowed" else None
|
|
382
|
+
reasons = [x for x in (r["failures"] or "").split("; ") if x] if r["status"] == "held" else []
|
|
383
|
+
why = M.explain_all(reasons)
|
|
384
|
+
first = why[0]["text"] + (f" (+{len(why) - 1} more)" if len(why) > 1 else "") if why else ""
|
|
385
|
+
table.append([t.status(st, word), r["name"], first])
|
|
386
|
+
if t.verbose: # -v: every reason, each with its next step
|
|
387
|
+
for k, e in enumerate(why):
|
|
388
|
+
table += ([["", "", e["text"]]] if k else []) + [["", "", f"fix: {e['fix']}"]]
|
|
389
|
+
t.table(table)
|
|
390
|
+
held = [r["name"] for r in rows if r["status"] == "held"]
|
|
391
|
+
nxt = [(f"ecadlib review {plan}", "go through the held parts one by one")] if held else []
|
|
392
|
+
if any(r["status"] == "awaiting_approval" for r in rows):
|
|
393
|
+
nxt.append((f"ecadlib approve {plan} status", "who still has to sign off"))
|
|
394
|
+
if not held:
|
|
395
|
+
nxt.append((f"ecadlib kicad-export {plan} -o kicad --name mylib", "write the KiCad library"))
|
|
396
|
+
t.summary(M.counts(r["status"] for r in rows) + (f" (held: {', '.join(held[:5])}{' ...' if len(held) > 5 else ''})" if held else ""),
|
|
397
|
+
written=[(str(stem.with_suffix(".md")), "release check report"), (str(plan), "library plan, statuses updated")],
|
|
398
|
+
next=nxt)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def main(argv=None):
|
|
402
|
+
import json
|
|
403
|
+
ap = argparse.ArgumentParser(description="Check every plan item before it goes to Altium 365; hold back what fails")
|
|
404
|
+
ap.add_argument("plan")
|
|
405
|
+
ap.add_argument("--allow", action="append", default=[], help="part name to release despite its current failures (repeatable)")
|
|
406
|
+
ap.add_argument("--unallow", action="append", default=[], help="remove an earlier --allow")
|
|
407
|
+
ap.add_argument("-o", "--out", help="report stem (.md and .csv); default <plan dir>/release_check")
|
|
408
|
+
ap.add_argument("--user", help="who gives the --allow (default: git config user.name/email); see approvals")
|
|
409
|
+
ap.add_argument("--rules", help="library rules file (default: ecadlib.toml found from the plan's folder or here)")
|
|
410
|
+
ap.add_argument("--json", action="store_true", help="print the result as JSON (plan, report, counts, rows)")
|
|
411
|
+
a = ap.parse_args(argv)
|
|
412
|
+
plan = Path(a.plan)
|
|
413
|
+
from .approvals import cli_plan_lock
|
|
414
|
+
with cli_plan_lock(plan): # the library agent's librarian may be rewriting this plan right now
|
|
415
|
+
items = json.loads(plan.read_text(encoding="utf-8"))
|
|
416
|
+
unknown = set(a.allow + a.unallow) - {i["name"] for i in items}
|
|
417
|
+
if unknown:
|
|
418
|
+
sys.exit(f"not in the plan: {', '.join(sorted(unknown))}")
|
|
419
|
+
rows = apply(items, plan.parent, a.allow, a.unallow, rules=a.rules)
|
|
420
|
+
if a.allow: # record the person, so approvals can refuse self-approval
|
|
421
|
+
from .approvals import identity, post_check, stamp_allow
|
|
422
|
+
stamp_allow(items, a.allow, identity(a.user, cwd=plan.parent))
|
|
423
|
+
post_check(items, plan.parent, rows)
|
|
424
|
+
write_text_atomic(plan, json.dumps(items, indent=2))
|
|
425
|
+
stem = write_report(rows, a.out or plan.parent / "release_check", plan)
|
|
426
|
+
held = sum(r["status"] == "held" for r in rows)
|
|
427
|
+
if a.json: # stable shape: README "Machine-readable output (--json)"
|
|
428
|
+
st = ("ready", "held", "awaiting_approval", "duplicate")
|
|
429
|
+
print(json.dumps({"plan": str(plan), "report": str(stem.with_suffix(".md")),
|
|
430
|
+
"counts": {s: sum(r["status"] == s for r in rows) for s in st}, "rows": rows}, indent=2, default=str))
|
|
431
|
+
return 2 if held else 0
|
|
432
|
+
print_result(rows, plan, stem)
|
|
433
|
+
return 2 if held else 0
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
if __name__ == "__main__":
|
|
437
|
+
sys.exit(main())
|