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
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
"""Altium without 365: sync a librarian / altium365 plan.json into a Database Library (.DbLib) or an
|
|
2
|
+
SVN Database Library (.SVNDbLib) folder.
|
|
3
|
+
|
|
4
|
+
python -m ecadlib.altium_dblib_sync out/librarian/plan.json --to D:/libs/ecadlib [--svn] [--prune]
|
|
5
|
+
[--name ecadlib] [--win-dir C:\\libs\\ecadlib] [--svn-url URL] [--commit [-m MSG]] [--dry-run] [-o report.json]
|
|
6
|
+
ecadlib dblib-sync ... (same options)
|
|
7
|
+
|
|
8
|
+
Folder layout (one entry per model file, the altium_lib / altium365 convention):
|
|
9
|
+
<to>/<name>.DbLib | <name>.SVNDbLib INI file Altium opens (Excel connection, field maps, tables)
|
|
10
|
+
<to>/<name>.xlsx parts table: one sheet per category (Capacitors, ICs, ...), all text
|
|
11
|
+
<to>/<name>.csv the same rows flat (Table column first), for diffs and review only
|
|
12
|
+
<to>/SchLib/<Library Ref>.SchLib symbol per file; [Library Path] = SchLib\\<Library Ref>.SchLib
|
|
13
|
+
<to>/PcbLib/<Footprint Ref>.PcbLib footprint per file; [Footprint Path] = PcbLib\\<Footprint Ref>.PcbLib
|
|
14
|
+
<to>/ecadlib_sync.json which rows and files this tool manages (only those are --prune-able)
|
|
15
|
+
|
|
16
|
+
Per plan item, like `altium365 push`: `held` items (release_check) are left exactly as they are; items without
|
|
17
|
+
both models are `skipped`; otherwise the row is `added`, `updated` (row fields or a model file changed) or
|
|
18
|
+
`up_to_date`. Row updates merge: columns added by hand in the sheet survive, plan values win, the plan's
|
|
19
|
+
`cleared` fields are blanked. Rows and model files no longer in the plan are reported (`not_in_plan`) and only
|
|
20
|
+
deleted with --prune, and only if this tool wrote them. Unchanged files are not rewritten. Model files written
|
|
21
|
+
are read back with altium_lib's readers and must contain the Library Ref / Footprint Ref the row names.
|
|
22
|
+
|
|
23
|
+
An existing .DbLib/.SVNDbLib is updated, not replaced: settings made in Altium's DbLib editor are kept, missing
|
|
24
|
+
tables and field maps are appended, and ConnectionString / SVNRepositoryURL are only rewritten when --win-dir /
|
|
25
|
+
--svn-url are given (or the URL is read from `svn info`). Altium opens the workbook through LibraryDatabasePath +
|
|
26
|
+
DatabasePathRelative=1 (the .xlsx next to the DbLib), so the library folder can be moved or copied to another PC. The
|
|
27
|
+
connection string's Data Source is only what Altium's DbLib editor shows (and uses if the relative option is switched
|
|
28
|
+
off): the --win-dir folder, else the fixed C:\\ecadlib\\library placeholder, never this machine's absolute path.
|
|
29
|
+
|
|
30
|
+
SVN (--svn): <to> is meant to be an SVN working copy; the .SVNDbLib gets SVNRepositoryURL and a LibrarySearchPath
|
|
31
|
+
of <url>/SchLib;<url>/PcbLib. With --commit, and `svn` on PATH and <to> a working copy, it runs svn add / delete /
|
|
32
|
+
commit for exactly the files it touched; otherwise it prints those commands."""
|
|
33
|
+
import argparse, csv, io, json, os, re, shutil, subprocess, sys
|
|
34
|
+
from pathlib import Path, PureWindowsPath
|
|
35
|
+
|
|
36
|
+
from . import altium_lib as AL
|
|
37
|
+
from . import sheetsafe as SS
|
|
38
|
+
from .altium_export import (COLUMNS, dblib_links, excel_connection, fieldmap_section, table_section)
|
|
39
|
+
|
|
40
|
+
STATE = "ecadlib_sync.json"
|
|
41
|
+
EXT = {"dblib": ".DbLib", "svndblib": ".SVNDbLib"}
|
|
42
|
+
KINDS = (("symbol", "SchLib", "Library"), ("footprint", "PcbLib", "Footprint"))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ------------------------------------------------------------------ plan -> rows
|
|
46
|
+
def load_plan(path):
|
|
47
|
+
"""plan.json with model paths resolved against the plan's folder (as altium365 push does)."""
|
|
48
|
+
items = json.loads(Path(path).read_text())
|
|
49
|
+
for i in items:
|
|
50
|
+
for k, _, _ in KINDS:
|
|
51
|
+
if (i.get(k) or {}).get("file"):
|
|
52
|
+
i[k]["file"] = str((Path(path).parent / i[k]["file"]).resolve())
|
|
53
|
+
return items
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def table_of(item):
|
|
57
|
+
"""Sheet name from the workspace folder (Components/Capacitors -> Capacitors); Excel's sheet name rules."""
|
|
58
|
+
t = re.sub(r"[\[\]:*?/\\]", "_", (item.get("folder") or "Misc").rstrip("/").rsplit("/", 1)[-1]).strip("'")
|
|
59
|
+
return (t or "Misc")[:31]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def plan_row(item, rules=None):
|
|
63
|
+
"""The DbLib row for one plan item, columns in altium_export order, then any extra plan parameters; parameter
|
|
64
|
+
columns in the rules' house names ([parameters] names)."""
|
|
65
|
+
row = {c: "" for c in COLUMNS}
|
|
66
|
+
row.update({"Part Number": item["name"], "Description": item.get("description", ""), "Comment": item.get("comment", "")})
|
|
67
|
+
for k, d, w in KINDS:
|
|
68
|
+
ref = (item.get(k) or {}).get("ref") or ""
|
|
69
|
+
row[f"{w} Ref"] = ref
|
|
70
|
+
row[f"{w} Path"] = f"{d}\\{AL.file_stem(ref)}.{d}" if ref else ""
|
|
71
|
+
row.update({p["name"]: str(p["value"]) for p in item.get("parameters", [])})
|
|
72
|
+
from .rules import house_row
|
|
73
|
+
return house_row(row, rules)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------------ parts table (xlsx + csv)
|
|
77
|
+
def read_tables(xlsx):
|
|
78
|
+
"""{sheet: (header, [row dicts])} from an existing parts workbook; {} if there is none."""
|
|
79
|
+
if not Path(xlsx).exists():
|
|
80
|
+
return {}
|
|
81
|
+
import openpyxl
|
|
82
|
+
wb = openpyxl.load_workbook(xlsx, read_only=True, data_only=True)
|
|
83
|
+
out = {}
|
|
84
|
+
try:
|
|
85
|
+
for ws in wb.worksheets:
|
|
86
|
+
rows = [["" if v is None else str(v) for v in r] for r in ws.iter_rows(values_only=True)]
|
|
87
|
+
if not rows:
|
|
88
|
+
continue
|
|
89
|
+
cols = [(i, h) for i, h in enumerate(rows[0]) if h] # by position: a blank header column must not
|
|
90
|
+
hdr = [h for _, h in cols] # shift the hand columns right of it
|
|
91
|
+
out[ws.title] = (hdr, [{h: r[i] if i < len(r) else "" for i, h in cols} for r in rows[1:] if any(r)])
|
|
92
|
+
finally:
|
|
93
|
+
wb.close()
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def write_xlsx(path, tables):
|
|
98
|
+
import openpyxl
|
|
99
|
+
from openpyxl.cell.cell import TYPE_STRING
|
|
100
|
+
wb = openpyxl.Workbook()
|
|
101
|
+
wb.remove(wb.active)
|
|
102
|
+
for name, (hdr, rows) in tables.items():
|
|
103
|
+
ws = wb.create_sheet(name)
|
|
104
|
+
ws.append(hdr)
|
|
105
|
+
for r in rows:
|
|
106
|
+
ws.append([r.get(c, "") for c in hdr])
|
|
107
|
+
for row in ws.iter_rows(min_row=2): # all text: "=Value" is a parameter, not a formula; '0402' stays '0402'
|
|
108
|
+
for c in row:
|
|
109
|
+
if isinstance(c.value, str):
|
|
110
|
+
c.data_type = TYPE_STRING
|
|
111
|
+
ws.freeze_panes = "B2"
|
|
112
|
+
tmp = Path(str(path) + ".tmp")
|
|
113
|
+
wb.save(tmp)
|
|
114
|
+
os.replace(tmp, path)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def csv_text(tables):
|
|
118
|
+
cols = ["Table"]
|
|
119
|
+
for hdr, _ in tables.values():
|
|
120
|
+
cols += [c for c in hdr if c not in cols]
|
|
121
|
+
buf = io.StringIO()
|
|
122
|
+
w = SS.DictWriter(buf, cols, lineterminator="\n", param_refs=True) # =Value stays; =HYPERLINK(...) does not
|
|
123
|
+
w.writeheader()
|
|
124
|
+
for name, (_, rows) in tables.items():
|
|
125
|
+
for r in rows:
|
|
126
|
+
w.writerow({"Table": name, **r})
|
|
127
|
+
return buf.getvalue()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ------------------------------------------------------------------ .DbLib / .SVNDbLib INI
|
|
131
|
+
def _sections(text):
|
|
132
|
+
secs = []
|
|
133
|
+
for ln in text.replace("\r\n", "\n").split("\n"):
|
|
134
|
+
if re.fullmatch(r"\[[^\]=]+\]", ln.strip()):
|
|
135
|
+
secs.append([ln.strip()[1:-1], []])
|
|
136
|
+
elif secs and ln.strip():
|
|
137
|
+
secs[-1][1].append(ln)
|
|
138
|
+
return secs
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
PLACEHOLDER_DIR = r"C:\ecadlib\library" # the Data Source folder unless --win-dir names one
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def win_folder(win_dir=None):
|
|
145
|
+
"""The Windows folder the connection string names: --win-dir, else PLACEHOLDER_DIR on every OS. Never the library's
|
|
146
|
+
own absolute path: Altium reads the workbook through LibraryDatabasePath + DatabasePathRelative=1 (next to the
|
|
147
|
+
DbLib), and a machine path would stop the folder from being portable."""
|
|
148
|
+
return PureWindowsPath(win_dir or PLACEHOLDER_DIR)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _kv(lines):
|
|
152
|
+
return dict(ln.split("=", 1) for ln in lines if "=" in ln)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def dblib_update(existing, tables, links, override=()):
|
|
156
|
+
"""New or merged INI text. tables: {sheet: columns} (sheet names without '$'); links: kwargs for
|
|
157
|
+
altium_export.dblib_links; override: [DatabaseLinks] keys whose template value replaces the existing one."""
|
|
158
|
+
names = {t + "$": cols for t, cols in tables.items()}
|
|
159
|
+
tmpl = dblib_links(last_table=next(iter(names), ""), **links)
|
|
160
|
+
if not existing:
|
|
161
|
+
L = list(tmpl)
|
|
162
|
+
for i, tn in enumerate(names, 1):
|
|
163
|
+
L += table_section(i, tn)
|
|
164
|
+
fm = 0
|
|
165
|
+
for tn, cols in names.items():
|
|
166
|
+
for c in cols:
|
|
167
|
+
fm += 1
|
|
168
|
+
L += fieldmap_section(fm, tn, c)
|
|
169
|
+
return "\r\n".join(L) + "\r\n"
|
|
170
|
+
secs = _sections(existing)
|
|
171
|
+
link_tmpl = _kv(tmpl[3:])
|
|
172
|
+
db = next((s for s in secs if s[0] == "DatabaseLinks"), None)
|
|
173
|
+
if db is None:
|
|
174
|
+
db = ["DatabaseLinks", []]
|
|
175
|
+
secs.insert(1 if secs and secs[0][0] == "OutputDatabaseLinkFile" else 0, db)
|
|
176
|
+
have = _kv(db[1])
|
|
177
|
+
for k, v in link_tmpl.items():
|
|
178
|
+
if k not in have:
|
|
179
|
+
db[1].append(f"{k}={v}")
|
|
180
|
+
elif k in override and have[k] != v:
|
|
181
|
+
db[1] = [f"{k}={v}" if ln.split("=", 1)[0] == k else ln for ln in db[1]]
|
|
182
|
+
num = lambda s, p: int(s[0][len(p):]) if s[0].startswith(p) and s[0][len(p):].isdigit() else None
|
|
183
|
+
tabs = {_kv(s[1]).get("TableName") for s in secs if num(s, "Table") is not None}
|
|
184
|
+
fmaps = {_kv(s[1]).get("Options", "").split("|", 1)[0] for s in secs if num(s, "FieldMap") is not None}
|
|
185
|
+
ti = max([num(s, "Table") or 0 for s in secs] + [0])
|
|
186
|
+
fi = max([num(s, "FieldMap") or 0 for s in secs] + [0])
|
|
187
|
+
last_t = max([k for k, s in enumerate(secs) if num(s, "Table") is not None] + [secs.index(db)])
|
|
188
|
+
new_t, new_f = [], []
|
|
189
|
+
for tn, cols in names.items():
|
|
190
|
+
if tn not in tabs:
|
|
191
|
+
ti += 1
|
|
192
|
+
sec = table_section(ti, tn)
|
|
193
|
+
new_t.append([sec[0][1:-1], sec[1:]])
|
|
194
|
+
for c in cols:
|
|
195
|
+
if f"FieldName={tn}.{c}" not in fmaps:
|
|
196
|
+
fi += 1
|
|
197
|
+
sec = fieldmap_section(fi, tn, c)
|
|
198
|
+
new_f.append([sec[0][1:-1], sec[1:]])
|
|
199
|
+
secs = secs[:last_t + 1] + new_t + secs[last_t + 1:] + new_f
|
|
200
|
+
return "".join(f"[{n}]\r\n" + "".join(ln + "\r\n" for ln in body) for n, body in secs)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ------------------------------------------------------------------ svn
|
|
204
|
+
def run_cmd(cmd, cwd=None):
|
|
205
|
+
return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def svn_info(target, runner=run_cmd, which=shutil.which):
|
|
209
|
+
"""Working-copy URL of target, or None (no svn on PATH, or not a working copy)."""
|
|
210
|
+
if not which("svn"):
|
|
211
|
+
return None
|
|
212
|
+
r = runner(["svn", "info", "--show-item", "url", str(target)])
|
|
213
|
+
return r.stdout.strip() if r.returncode == 0 and r.stdout.strip() else None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def svn_commands(written, removed, message):
|
|
217
|
+
"""svn add/delete/commit for exactly the paths (relative to the working copy root) this run touched."""
|
|
218
|
+
written, removed = sorted(set(written)), sorted(set(removed))
|
|
219
|
+
dirs = sorted({p.split("/")[0] for p in written + removed if "/" in p})
|
|
220
|
+
cmds = []
|
|
221
|
+
if written:
|
|
222
|
+
cmds.append(["svn", "add", "--force", "--parents", *written])
|
|
223
|
+
if removed:
|
|
224
|
+
cmds.append(["svn", "delete", "--force", *removed])
|
|
225
|
+
if written or removed:
|
|
226
|
+
cmds.append(["svn", "commit", "--depth", "empty", "-m", message, *dirs, *written, *removed])
|
|
227
|
+
return cmds
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def svn_step(target, written, removed, commit=False, message="ecadlib dblib-sync", runner=run_cmd, which=shutil.which):
|
|
231
|
+
"""Run (commit=True, svn available, target is a working copy) or just return the svn commands."""
|
|
232
|
+
cmds = svn_commands(written, removed, message)
|
|
233
|
+
res = {"commands": [" ".join(_q(c) for c in cmd) for cmd in cmds], "ran": False, "output": []}
|
|
234
|
+
if not cmds:
|
|
235
|
+
res["note"] = "nothing to commit"
|
|
236
|
+
elif not commit:
|
|
237
|
+
res["note"] = "run these in the working copy (or pass --commit)"
|
|
238
|
+
elif not which("svn"):
|
|
239
|
+
res["note"] = "svn is not on PATH: run these by hand"
|
|
240
|
+
elif runner(["svn", "info", str(target)]).returncode != 0:
|
|
241
|
+
res["note"] = f"{target} is not an svn working copy: check it out first, then run these"
|
|
242
|
+
else:
|
|
243
|
+
for cmd in cmds:
|
|
244
|
+
r = runner(cmd, cwd=str(target))
|
|
245
|
+
res["output"].append((r.stdout or "") + (r.stderr or ""))
|
|
246
|
+
if r.returncode != 0:
|
|
247
|
+
res["error"] = f"{cmd[1]} failed (exit {r.returncode})"
|
|
248
|
+
break
|
|
249
|
+
else:
|
|
250
|
+
res["ran"] = True
|
|
251
|
+
return res
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _q(s):
|
|
255
|
+
return f'"{s}"' if re.search(r"[\s()&|;<>]", s) else s
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# ------------------------------------------------------------------ sync
|
|
259
|
+
def _readback(path, kind, ref):
|
|
260
|
+
"""None if the written model reads back with altium_lib and holds `ref`, else the problem."""
|
|
261
|
+
try:
|
|
262
|
+
names = ([c["name"] for c in AL.read_schlib(path)[1]] if kind == "SchLib"
|
|
263
|
+
else [f["name"] for f in AL.read_pcblib(path)])
|
|
264
|
+
except Exception as e:
|
|
265
|
+
return f"{Path(path).name} does not read back: {type(e).__name__}: {e}"
|
|
266
|
+
return None if ref in names else f"{Path(path).name} holds {names}, not {ref!r}"
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def sync(plan, target_dir, mode="dblib", name="ecadlib", prune=False, win_dir=None, svn_url=None,
|
|
270
|
+
dry_run=False, base=None, runner=run_cmd, which=shutil.which, rules=None):
|
|
271
|
+
"""Bring <target_dir> in line with the plan. plan: a plan.json path, or its items (model paths absolute or
|
|
272
|
+
relative to `base`). Returns {"results": [...], "written": [...], "removed": [...], "dblib": path, ...};
|
|
273
|
+
results are {"name", "action", "detail", "table"} with action added/updated/up_to_date/held/skipped/error,
|
|
274
|
+
and not_in_plan/removed for rows (kind "row") and model files (kind "file") the plan no longer has."""
|
|
275
|
+
if mode not in EXT:
|
|
276
|
+
raise ValueError(f"mode must be one of {sorted(EXT)}")
|
|
277
|
+
items = load_plan(plan) if isinstance(plan, (str, Path)) else plan
|
|
278
|
+
base = Path(base or (Path(plan).parent if isinstance(plan, (str, Path)) else "."))
|
|
279
|
+
to = Path(target_dir)
|
|
280
|
+
from .rules import param_name, resolve
|
|
281
|
+
rules = resolve(rules, base) # house parameter names: ecadlib.toml found from the plan
|
|
282
|
+
xlsx, csvf, lib, statef = to / f"{name}.xlsx", to / f"{name}.csv", to / f"{name}{EXT[mode]}", to / STATE
|
|
283
|
+
state = json.loads(statef.read_text()) if statef.exists() else {}
|
|
284
|
+
managed_rows, managed_files = set(state.get("rows", [])), set(state.get("files", []))
|
|
285
|
+
old = read_tables(xlsx)
|
|
286
|
+
tables = {t: (list(h), [dict(r) for r in rows]) for t, (h, rows) in old.items()}
|
|
287
|
+
where = {r.get("Part Number"): t for t, (_, rows) in tables.items() for r in rows}
|
|
288
|
+
results, written, removed, copied, failed = [], [], [], {}, {}
|
|
289
|
+
|
|
290
|
+
def find(pn, t):
|
|
291
|
+
rows = tables.get(t, ([], []))[1]
|
|
292
|
+
return next((r for r in rows if r.get("Part Number") == pn), None)
|
|
293
|
+
|
|
294
|
+
for it in items:
|
|
295
|
+
res = {"name": it["name"], "action": "", "detail": "", "table": table_of(it)}
|
|
296
|
+
results.append(res)
|
|
297
|
+
if it.get("status") == "held":
|
|
298
|
+
res["action"], res["detail"] = "held", "; ".join(it.get("hold_reasons") or ["release check"])
|
|
299
|
+
if it["name"] in where:
|
|
300
|
+
res["table"] = where[it["name"]]
|
|
301
|
+
continue
|
|
302
|
+
if it.get("status") != "ready":
|
|
303
|
+
res["action"], res["detail"] = "skipped", it.get("status") or "not ready"
|
|
304
|
+
continue
|
|
305
|
+
notes, problems = [], []
|
|
306
|
+
for k, d, w in KINDS: # model files first: a bad file must not reach the table
|
|
307
|
+
src, ref = it[k]["file"], it[k]["ref"]
|
|
308
|
+
src = Path(src) if Path(src).is_absolute() else base / src
|
|
309
|
+
rel = f"{d}/{AL.file_stem(ref)}.{d}"
|
|
310
|
+
dst = to / rel
|
|
311
|
+
if not src.exists():
|
|
312
|
+
problems.append(f"{k} file missing: {src}")
|
|
313
|
+
continue
|
|
314
|
+
data = src.read_bytes()
|
|
315
|
+
if rel in copied:
|
|
316
|
+
if copied[rel] != data:
|
|
317
|
+
problems.append(f"{rel}: another plan item already wrote different content this run")
|
|
318
|
+
elif rel in failed: # same bad file as an earlier item: same error
|
|
319
|
+
problems.append(failed[rel])
|
|
320
|
+
elif rel in written:
|
|
321
|
+
notes.append(f"{rel} updated")
|
|
322
|
+
continue
|
|
323
|
+
copied[rel] = data
|
|
324
|
+
if dst.exists() and dst.read_bytes() == data:
|
|
325
|
+
continue
|
|
326
|
+
notes.append(f"{rel} {'updated' if dst.exists() else 'added'}")
|
|
327
|
+
if not dry_run:
|
|
328
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
329
|
+
tmp = dst.with_name(dst.name + ".tmp")
|
|
330
|
+
tmp.write_bytes(data)
|
|
331
|
+
err = _readback(tmp, d, ref)
|
|
332
|
+
if err:
|
|
333
|
+
tmp.unlink()
|
|
334
|
+
problems.append(err)
|
|
335
|
+
failed[rel] = err
|
|
336
|
+
notes.pop()
|
|
337
|
+
continue
|
|
338
|
+
os.replace(tmp, dst)
|
|
339
|
+
else:
|
|
340
|
+
err = _readback(src, d, ref)
|
|
341
|
+
if err:
|
|
342
|
+
problems.append(err)
|
|
343
|
+
failed[rel] = err
|
|
344
|
+
continue
|
|
345
|
+
written.append(rel)
|
|
346
|
+
if problems:
|
|
347
|
+
res["action"], res["detail"] = "error", "; ".join(problems + notes)
|
|
348
|
+
continue
|
|
349
|
+
want, t = plan_row(it, rules), res["table"]
|
|
350
|
+
old_t = where.get(it["name"])
|
|
351
|
+
cur = find(it["name"], old_t) if old_t else None
|
|
352
|
+
if cur is None:
|
|
353
|
+
hdr, rows = tables.setdefault(t, (list(want), []))
|
|
354
|
+
hdr += [c for c in want if c not in hdr]
|
|
355
|
+
rows.append(want)
|
|
356
|
+
where[it["name"]] = t
|
|
357
|
+
res["action"], res["detail"] = "added", "; ".join(notes)
|
|
358
|
+
managed_rows.add(it["name"])
|
|
359
|
+
continue
|
|
360
|
+
new = dict(cur)
|
|
361
|
+
for c in (param_name(c, rules) for c in it.get("cleared", [])):
|
|
362
|
+
if c in new:
|
|
363
|
+
new[c] = ""
|
|
364
|
+
new.update(want)
|
|
365
|
+
changed = [c for c in new if str(new.get(c, "")) != str(cur.get(c, ""))]
|
|
366
|
+
if old_t != t: # category changed: move the row
|
|
367
|
+
tables[old_t][1].remove(cur)
|
|
368
|
+
hdr, rows = tables.setdefault(t, (list(want), []))
|
|
369
|
+
hdr += [c for c in new if c not in hdr]
|
|
370
|
+
rows.append(new)
|
|
371
|
+
where[it["name"]] = t
|
|
372
|
+
notes.insert(0, f"moved from {old_t}")
|
|
373
|
+
elif changed:
|
|
374
|
+
hdr, rows = tables[t]
|
|
375
|
+
hdr += [c for c in new if c not in hdr]
|
|
376
|
+
rows[rows.index(cur)] = new
|
|
377
|
+
if changed:
|
|
378
|
+
notes.insert(0, "row: " + ", ".join(changed))
|
|
379
|
+
res["action"] = "updated" if notes else "up_to_date"
|
|
380
|
+
res["detail"] = "; ".join(notes)
|
|
381
|
+
managed_rows.add(it["name"])
|
|
382
|
+
|
|
383
|
+
# rows and model files the plan no longer has
|
|
384
|
+
plan_names = {it["name"] for it in items}
|
|
385
|
+
for t, (_, rows) in tables.items():
|
|
386
|
+
for r in list(rows):
|
|
387
|
+
pn = r.get("Part Number", "")
|
|
388
|
+
if pn in plan_names:
|
|
389
|
+
continue
|
|
390
|
+
res = {"name": pn, "action": "not_in_plan", "detail": "", "table": t, "kind": "row"}
|
|
391
|
+
if pn not in managed_rows:
|
|
392
|
+
res["detail"] = "not written by ecadlib: kept"
|
|
393
|
+
elif prune:
|
|
394
|
+
rows.remove(r)
|
|
395
|
+
managed_rows.discard(pn)
|
|
396
|
+
res["action"] = "removed"
|
|
397
|
+
else:
|
|
398
|
+
res["detail"] = "kept (--prune deletes it)"
|
|
399
|
+
results.append(res)
|
|
400
|
+
used = {f"{d}/{AL.file_stem(r.get(f'{w} Ref', ''))}.{d}" for _, (_, rows) in tables.items() for r in rows
|
|
401
|
+
for _, d, w in KINDS if r.get(f"{w} Ref")}
|
|
402
|
+
for _, d, _ in KINDS:
|
|
403
|
+
for f in sorted((to / d).glob(f"*.{d}")) if (to / d).is_dir() else []:
|
|
404
|
+
rel = f"{d}/{f.name}"
|
|
405
|
+
if rel in used or rel in copied:
|
|
406
|
+
continue
|
|
407
|
+
res = {"name": rel, "action": "not_in_plan", "detail": "", "table": "", "kind": "file"}
|
|
408
|
+
if rel not in managed_files:
|
|
409
|
+
res["detail"] = "not written by ecadlib: kept"
|
|
410
|
+
elif prune:
|
|
411
|
+
res["action"] = "removed"
|
|
412
|
+
removed.append(rel)
|
|
413
|
+
managed_files.discard(rel)
|
|
414
|
+
if not dry_run:
|
|
415
|
+
f.unlink()
|
|
416
|
+
else:
|
|
417
|
+
res["detail"] = "no row uses it; kept (--prune deletes it)"
|
|
418
|
+
results.append(res)
|
|
419
|
+
managed_files |= set(written)
|
|
420
|
+
tables = {t: v for t, v in tables.items() if v[1] or t in old} # never create an empty sheet
|
|
421
|
+
|
|
422
|
+
# parts table, DbLib file, state: only rewritten when their content changes
|
|
423
|
+
out = {"results": results, "written": written, "removed": removed, "dblib": str(lib), "mode": mode,
|
|
424
|
+
"dry_run": dry_run}
|
|
425
|
+
def put(rel, text=None, writer=None):
|
|
426
|
+
if not dry_run:
|
|
427
|
+
to.mkdir(parents=True, exist_ok=True)
|
|
428
|
+
if writer:
|
|
429
|
+
writer()
|
|
430
|
+
else:
|
|
431
|
+
(to / rel).write_bytes(text.encode("utf-8"))
|
|
432
|
+
written.append(rel)
|
|
433
|
+
if tables and tables != old:
|
|
434
|
+
put(xlsx.name, writer=lambda: write_xlsx(xlsx, tables))
|
|
435
|
+
if tables:
|
|
436
|
+
ct = csv_text(tables)
|
|
437
|
+
if not csvf.exists() or csvf.read_bytes().decode("utf-8", "replace") != ct:
|
|
438
|
+
put(csvf.name, ct)
|
|
439
|
+
|
|
440
|
+
url = svn_url or (svn_info(to, runner, which) if mode == "svndblib" else None)
|
|
441
|
+
win = win_folder(win_dir)
|
|
442
|
+
extra = (("SVNRepositoryURL", url or ""), ("FileRepositoryIsRelative", "0")) if mode == "svndblib" else ()
|
|
443
|
+
links = dict(conn_str=excel_connection(win / xlsx.name), db_type="Microsoft Excel", db_path=xlsx.name,
|
|
444
|
+
relative=True, search_path=f"{url}/SchLib;{url}/PcbLib" if url else "", extra=extra)
|
|
445
|
+
override = {"LibraryDatabaseType", "LibraryDatabasePath", "DatabasePathRelative"}
|
|
446
|
+
if win_dir: # else keep a connection set in Altium's DbLib editor
|
|
447
|
+
override.add("ConnectionString")
|
|
448
|
+
if url:
|
|
449
|
+
override |= {"SVNRepositoryURL", "LibrarySearchPath"}
|
|
450
|
+
cur_text = lib.read_bytes().decode("utf-8") if lib.exists() else "" # keep the \r\n
|
|
451
|
+
if tables:
|
|
452
|
+
text = dblib_update(cur_text, {t: h for t, (h, _) in tables.items()}, links, override)
|
|
453
|
+
if text != cur_text:
|
|
454
|
+
put(lib.name, text)
|
|
455
|
+
cur_text = text
|
|
456
|
+
db = next((s for s in _sections(cur_text) if s[0] == "DatabaseLinks"), ["", []])
|
|
457
|
+
url = url or _kv(db[1]).get("SVNRepositoryURL") or None # the one already in the file
|
|
458
|
+
if mode == "svndblib" and not url:
|
|
459
|
+
out["warning"] = "no SVN repository URL (pass --svn-url, or sync into an svn working copy)"
|
|
460
|
+
st = {"rows": sorted(managed_rows), "files": sorted(managed_files)}
|
|
461
|
+
if st != {"rows": sorted(state.get("rows", [])), "files": sorted(state.get("files", []))}:
|
|
462
|
+
put(STATE, json.dumps(st, indent=1) + "\n")
|
|
463
|
+
out["svn_url"] = url
|
|
464
|
+
return out
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def summary(out):
|
|
468
|
+
n = {}
|
|
469
|
+
for r in out["results"]:
|
|
470
|
+
n[r["action"]] = n.get(r["action"], 0) + 1
|
|
471
|
+
return ", ".join(f"{v} {k}" for k, v in sorted(n.items()))
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def main(argv=None):
|
|
475
|
+
ap = argparse.ArgumentParser(description="Sync a plan.json into an Altium .DbLib / .SVNDbLib library folder")
|
|
476
|
+
ap.add_argument("plan", help="plan.json from `ecadlib librarian` or `ecadlib altium365 plan`")
|
|
477
|
+
ap.add_argument("--to", required=True, help="library folder (for --svn: an svn working copy)")
|
|
478
|
+
ap.add_argument("--svn", action="store_true", help="write an .SVNDbLib (SVN Database Library) instead of a .DbLib")
|
|
479
|
+
ap.add_argument("--name", default="ecadlib", help="base name of the .DbLib/.SVNDbLib, .xlsx and .csv")
|
|
480
|
+
ap.add_argument("--prune", action="store_true", help="delete rows and model files (written by ecadlib) no longer in the plan")
|
|
481
|
+
ap.add_argument("--win-dir", help=r"Windows folder the library lives in, for the Excel connection string (default C:\ecadlib\library; Altium finds the workbook relative to the DbLib either way)")
|
|
482
|
+
ap.add_argument("--svn-url", help="repository URL of --to (default: from `svn info`)")
|
|
483
|
+
ap.add_argument("--commit", action="store_true", help="svn add/commit the changed files (needs svn on PATH and a working copy)")
|
|
484
|
+
ap.add_argument("-m", "--message", default="ecadlib dblib-sync", help="svn commit message")
|
|
485
|
+
ap.add_argument("--dry-run", action="store_true", help="report only; write nothing")
|
|
486
|
+
ap.add_argument("-o", "--out", help="write the JSON report here")
|
|
487
|
+
ap.add_argument("--rules", help="library rules file: house parameter names (default: ecadlib.toml found from the plan)")
|
|
488
|
+
a = ap.parse_args(argv)
|
|
489
|
+
|
|
490
|
+
out = sync(a.plan, a.to, "svndblib" if a.svn else "dblib", a.name, a.prune, a.win_dir, a.svn_url, a.dry_run, rules=a.rules)
|
|
491
|
+
from . import messages as M, term as TM
|
|
492
|
+
t = TM.Term()
|
|
493
|
+
rows = []
|
|
494
|
+
for r in out["results"]:
|
|
495
|
+
st = {"added": "ready", "updated": "ready", "unchanged": "skipped", "held": "held", "error": "failed",
|
|
496
|
+
"removed": "info"}.get(r["action"], "info")
|
|
497
|
+
detail = M.explain(r["detail"].removeprefix("held: "))["text"] if r["action"] == "held" else M.clean(r["detail"])
|
|
498
|
+
if r["action"] in ("added", "updated", "unchanged") and not t.verbose:
|
|
499
|
+
detail = ""
|
|
500
|
+
rows.append([t.status(st, r["action"]), r["table"], r["name"], detail])
|
|
501
|
+
t.table(rows)
|
|
502
|
+
if out.get("warning"):
|
|
503
|
+
t.note(M.clean(out["warning"]), None)
|
|
504
|
+
if a.dry_run and not t.quiet:
|
|
505
|
+
t.wrap("would write " + (", ".join(out["written"]) or "nothing"), 0)
|
|
506
|
+
if a.svn and not a.dry_run:
|
|
507
|
+
s = svn_step(a.to, out["written"], out["removed"], a.commit, a.message)
|
|
508
|
+
out["svn"] = s
|
|
509
|
+
t.write(f"svn: {s.get('note') or ('committed' if s['ran'] else s.get('error', ''))}")
|
|
510
|
+
if not s["ran"]:
|
|
511
|
+
for c in s["commands"]:
|
|
512
|
+
t.write(f" {c}")
|
|
513
|
+
written = [] if a.dry_run else [(out["dblib"], "open this in Altium (it lists the parts from the .xlsx next to it)")]
|
|
514
|
+
if not a.dry_run and t.verbose:
|
|
515
|
+
written += [(w, "") for w in out["written"]]
|
|
516
|
+
t.summary(f"{summary(out)}" + (" (dry run: nothing written; " + M.plural(len(out["written"]), "file") + " would be)" if a.dry_run else "")
|
|
517
|
+
+ (f"; removed {', '.join(out['removed'])}" if out["removed"] else ""),
|
|
518
|
+
written=written,
|
|
519
|
+
next=[(f"ecadlib dblib-sync {a.plan} --to {a.to}", "write it for real")] if a.dry_run else
|
|
520
|
+
([(f"ecadlib review {a.plan}", "the held parts")] if any(r["action"] == "held" for r in out["results"]) else []))
|
|
521
|
+
if a.out:
|
|
522
|
+
Path(a.out).parent.mkdir(parents=True, exist_ok=True)
|
|
523
|
+
Path(a.out).write_text(json.dumps(out, indent=2))
|
|
524
|
+
errors = sum(r["action"] == "error" for r in out["results"])
|
|
525
|
+
if errors or (out.get("svn") or {}).get("error"):
|
|
526
|
+
sys.exit(f"{errors} part(s) failed" + (f"; svn: {out['svn']['error']}" if (out.get("svn") or {}).get("error") else ""))
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
if __name__ == "__main__":
|
|
530
|
+
main()
|