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,1095 @@
|
|
|
1
|
+
"""Altium project integration: the BOM straight from a project, and where each library part is used.
|
|
2
|
+
|
|
3
|
+
ecadlib project BOARD.PrjPcb [-o bom.csv] [--variant V] [--json]
|
|
4
|
+
ecadlib where-used --projects DIR [DIR ...] [--part X] [--plan plan.json] [--lib DIR] [--a365 a365_state.json]
|
|
5
|
+
[--lifecycle lifecycle_state.json] [-o out/where_used]
|
|
6
|
+
|
|
7
|
+
`ecadlib librarian BOARD.PrjPcb` and `ecadlib triage BOARD.PrjPcb` work too: bom_triage.read_bom hands .PrjPcb,
|
|
8
|
+
.SchDoc and .BomDoc inputs to bom_lines() here (variant: environment variable ECADLIB_VARIANT, default the base design).
|
|
9
|
+
|
|
10
|
+
What is read
|
|
11
|
+
.PrjPcb UTF-8 INI text. [DocumentN] DocumentPath (Windows-relative; .SchDoc and .BomDoc are used),
|
|
12
|
+
[DeviceSheetN] DocumentPath (device sheets: relative, absolute, or Managed\\Sheets\\<guid>\\X.SchDoc; looked
|
|
13
|
+
for next to the project, as written, and in Altium's device-sheet / data folders and $ECADLIB_DEVICE_SHEETS;
|
|
14
|
+
one that is nowhere is a warning saying its components are not in the BOM),
|
|
15
|
+
[ProjectVariantN] Description + VariationN=Designator=..|UniqueId=\\ABCDEFGH|Kind=0 fitted,1 not fitted,
|
|
16
|
+
2 alternate|AlternatePart=..|AltLibLink_*=.. and ParamVariationN (ParameterName/VariantValue, paired with
|
|
17
|
+
ParamDesignatorN; or ParameterCount + ParameterNameK/VariantValueK), [ParameterN] Name/Value.
|
|
18
|
+
.SchDoc OLE compound file; the "FileHeader" stream is the SchLib record format: a header block then one
|
|
19
|
+
|KEY=VALUE| block per record, OwnerIndex = the owner's position in that list. Read: RECORD=1 components
|
|
20
|
+
(LibReference, DesignItemId, SourceLibraryName, LibraryPath, DatabaseTableName, UniqueID, CurrentPartId,
|
|
21
|
+
PartCount, ComponentDescription, ComponentKind*, VaultGUID/ItemGUID/RevisionGUID and Symbol*GUID for
|
|
22
|
+
Altium 365 components), RECORD=34 designator, RECORD=41 parameters (Comment '=Name' indirection resolved),
|
|
23
|
+
RECORD=44 -> RECORD=45 implementations (ModelType PCBLIB, IsCurrent=T is the footprint in use).
|
|
24
|
+
'%UTF8%Key' fields override their cp1252 twin. Components of kind Graphical, Net Tie (No BOM) and
|
|
25
|
+
Standard (No BOM) are left out of the BOM. Multi-part components (U1A, U1B on any sheet) count once.
|
|
26
|
+
Sheet symbols (RECORD=15, device sheet symbols 215: RECORD=32 name, 33 file name): a sheet used by
|
|
27
|
+
REPEAT(CH,1,4) or by several symbols is multi-channel, and its components are listed once per channel with
|
|
28
|
+
Altium's channel designators ([Design] ChannelDesignatorFormatString, default $Component_$RoomName: R1_CH1).
|
|
29
|
+
A component with no MPN parameter whose Comment reads as a part number (STM32F101T6U6A, not 10k / 100nF)
|
|
30
|
+
gets the Comment as its MPN (mpn_source Comment, a warning, a triage note).
|
|
31
|
+
.BomDoc ActiveBOM document. Its layout is not published and no sample was available: the reader is tolerant
|
|
32
|
+
(INI sections or |KEY=VALUE| lines with designator / item id + manufacturer part number / supplier
|
|
33
|
+
fields; the first solution is the chosen one, the rest become alternates) and says when it found nothing.
|
|
34
|
+
|
|
35
|
+
Where-used: every .PrjPcb under the --projects folders (and loose .SchDoc files in folders without one) is read into an
|
|
36
|
+
index part -> projects -> variants / designators / revisions. The part key is the normalised MPN (dedupe.mpn_key), else
|
|
37
|
+
the Design Item ID, else LibRef+value+footprint. Against a library (--plan librarian plan.json, --lib folders/DbLibs,
|
|
38
|
+
--a365 check-a365 state, all through dedupe.Collector, whose duplicate groups are applied too) it flags parts used in
|
|
39
|
+
projects but missing from the library, library parts used nowhere and projects using a duplicate instead of the keeper.
|
|
40
|
+
Without a library: projects using an older revision of an Altium 365 item than another project does (same ItemGUID,
|
|
41
|
+
lower '-N' revision suffix on the Design Item ID, or simply different RevisionGUIDs), and parts whose lifecycle is
|
|
42
|
+
NRND / last time buy / obsolete (lifecycle_state.json from `ecadlib lifecycle`, a plan item's lifecycle, or the
|
|
43
|
+
component's own 'Lifecycle Status' / 'Part Status' parameter)."""
|
|
44
|
+
import argparse, csv, json, os, re, struct, sys
|
|
45
|
+
from pathlib import Path, PureWindowsPath
|
|
46
|
+
|
|
47
|
+
from . import sheetsafe as SS
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
from . import rules as RU
|
|
51
|
+
except ImportError: # rules.py lands separately; plain defaults until then
|
|
52
|
+
RU = None
|
|
53
|
+
|
|
54
|
+
PROJECT_EXT = (".prjpcb",)
|
|
55
|
+
SCH_EXT = (".schdoc",)
|
|
56
|
+
BOM_EXT = (".bomdoc",)
|
|
57
|
+
INPUT_EXT = PROJECT_EXT + SCH_EXT + BOM_EXT
|
|
58
|
+
NO_BOM_KINDS = {2, 4, 5} # ComponentKind: 0 standard, 1 mechanical, 2 graphical, 3 net tie (BOM), 4 net tie (no BOM), 5 standard (no BOM)
|
|
59
|
+
KIND_LABEL = {0: "Standard", 1: "Mechanical", 2: "Graphical", 3: "Net Tie", 4: "Net Tie (No BOM)", 5: "Standard (No BOM)", 6: "Jumper"}
|
|
60
|
+
MPN_PARAMS = ["Manufacturer Part Number", "Manufacturer Part Number 1", "MPN", "Mfr Part Number", "ManufacturerPartNumber",
|
|
61
|
+
"Manufacturer Part #", "Mfr. Part Number", "Part Number", "PartNumber"]
|
|
62
|
+
MFR_PARAMS = ["Manufacturer", "Manufacturer 1", "Manufacturer Name", "Mfr", "MFR"]
|
|
63
|
+
LCSC_PARAMS = ["LCSC", "LCSC Part", "LCSC Part Number", "LCSC Part #", "JLCPCB Part", "JLCPCB Part #"]
|
|
64
|
+
DATASHEET_PARAMS = ["Datasheet", "Datasheet URL", "DatasheetURL", "Datasheet Link", "HelpURL"]
|
|
65
|
+
LIFECYCLE_PARAMS = ["Lifecycle Status", "Lifecycle", "Part Status", "Life Cycle", "LifeCycle"]
|
|
66
|
+
VALUE_PARAMS = ["Value"]
|
|
67
|
+
REPEAT_RE = re.compile(r"\s*repeat\s*\(\s*([^,()]*?)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)", re.I)
|
|
68
|
+
CHANNEL_FORMAT = "$Component_$RoomName" # Altium's default channel designator format (Project Options > Multi-Channel)
|
|
69
|
+
DEVICE_SHEETS_ENV = "ECADLIB_DEVICE_SHEETS" # extra folders to look for device sheets in (os.pathsep between them)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------- .SchDoc
|
|
73
|
+
def _records(data):
|
|
74
|
+
"""FileHeader stream -> [dict] (upper-case keys). Binary records (flag byte != 0) become {'_binary': flag}."""
|
|
75
|
+
out, i = [], 0
|
|
76
|
+
while i + 4 <= len(data):
|
|
77
|
+
n = struct.unpack_from("<I", data, i)[0]
|
|
78
|
+
flag, n = n >> 24, n & 0xFFFFFF
|
|
79
|
+
b = data[i + 4:i + 4 + n]
|
|
80
|
+
i += 4 + n
|
|
81
|
+
if flag:
|
|
82
|
+
out.append({"_binary": flag})
|
|
83
|
+
continue
|
|
84
|
+
kv, utf = {}, {}
|
|
85
|
+
for part in b.rstrip(b"\0").split(b"|"):
|
|
86
|
+
if b"=" not in part:
|
|
87
|
+
continue
|
|
88
|
+
k, v = part.split(b"=", 1)
|
|
89
|
+
key = k.decode("latin-1").upper()
|
|
90
|
+
if key.startswith("%UTF8%"):
|
|
91
|
+
utf[key[6:]] = v.decode("utf-8", "replace")
|
|
92
|
+
else:
|
|
93
|
+
kv[key] = v.decode("cp1252", "replace")
|
|
94
|
+
kv.update(utf)
|
|
95
|
+
out.append(kv)
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _int(v, default=0):
|
|
100
|
+
try:
|
|
101
|
+
return int(str(v).strip())
|
|
102
|
+
except (TypeError, ValueError):
|
|
103
|
+
return default
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _ci(d, names):
|
|
107
|
+
"""First non-empty value of d for any of names, case-insensitively."""
|
|
108
|
+
low = {k.lower(): v for k, v in d.items()}
|
|
109
|
+
for n in names:
|
|
110
|
+
v = low.get(n.lower())
|
|
111
|
+
if v not in (None, "") and str(v).strip() not in ("", "*"):
|
|
112
|
+
return str(v).strip()
|
|
113
|
+
return ""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _mpn_of(d, names):
|
|
117
|
+
"""First MPN parameter value that is a part number: Altium vault / Altium 365 item ids (CMP-1035-02800-1, which
|
|
118
|
+
Content Vault parts carry as LibRef, Design Item ID and sometimes 'Part Number') are skipped."""
|
|
119
|
+
low = {k.lower(): v for k, v in d.items()}
|
|
120
|
+
for n in names:
|
|
121
|
+
v = str(low.get(n.lower()) or "").strip()
|
|
122
|
+
if v not in ("", "*") and not _is_vault_id(v):
|
|
123
|
+
return v
|
|
124
|
+
return ""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _param_names(field, defaults, rules):
|
|
128
|
+
names = list(defaults)
|
|
129
|
+
if RU and rules is not None:
|
|
130
|
+
try:
|
|
131
|
+
house = RU.param_name(field, rules)
|
|
132
|
+
if house and house not in names:
|
|
133
|
+
names.insert(0, house)
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
elif rules:
|
|
137
|
+
house = ((rules.get("parameters") or {}).get("names") or {}).get(field)
|
|
138
|
+
if house and house not in names:
|
|
139
|
+
names.insert(0, house)
|
|
140
|
+
return names
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _resolve(text, params):
|
|
144
|
+
"""Altium '=Name' indirection (Comment '=Value', '=Part Number')."""
|
|
145
|
+
if text and text.startswith("=") and len(text) > 1:
|
|
146
|
+
return _ci(params, [text[1:]]) or text
|
|
147
|
+
return text
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def read_schdoc(path, rules=None):
|
|
151
|
+
"""-> {path, header, components: [...], warnings: [...]}. One entry per RECORD=1 (multi-part parts repeat)."""
|
|
152
|
+
import olefile
|
|
153
|
+
path = Path(path)
|
|
154
|
+
warnings = []
|
|
155
|
+
with olefile.OleFileIO(str(path)) as o:
|
|
156
|
+
if not o.exists("FileHeader"):
|
|
157
|
+
raise ValueError(f"{path.name}: no FileHeader stream; not an Altium schematic")
|
|
158
|
+
recs = _records(o.openstream("FileHeader").read())
|
|
159
|
+
if not recs or "Schematic" not in recs[0].get("HEADER", "") or "Library" in recs[0].get("HEADER", ""):
|
|
160
|
+
raise ValueError(f"{path.name}: FileHeader is not an Altium schematic header ({recs[0].get('HEADER', '')[:60]!r})"
|
|
161
|
+
if recs else f"{path.name}: empty FileHeader")
|
|
162
|
+
header, recs = recs[0], recs[1:]
|
|
163
|
+
kids = {}
|
|
164
|
+
for j, r in enumerate(recs):
|
|
165
|
+
oi = _int(r.get("OWNERINDEX"), -1)
|
|
166
|
+
if oi >= 0:
|
|
167
|
+
kids.setdefault(oi, []).append(j)
|
|
168
|
+
comps, symbols = [], []
|
|
169
|
+
for idx, r in enumerate(recs):
|
|
170
|
+
rt = r.get("RECORD")
|
|
171
|
+
if rt in ("15", "215"): # sheet symbol / device sheet symbol: RECORD=32 name, 33 file name
|
|
172
|
+
name = next((recs[j].get("TEXT", "") for j in kids.get(idx, []) if recs[j].get("RECORD") == "32"), "").strip()
|
|
173
|
+
fname = next((recs[j].get("TEXT", "") for j in kids.get(idx, []) if recs[j].get("RECORD") == "33"), "").strip()
|
|
174
|
+
if fname:
|
|
175
|
+
m = REPEAT_RE.match(name)
|
|
176
|
+
symbols.append({"name": name, "file": fname, "repeat": (m.group(1), int(m.group(2)), int(m.group(3))) if m else None})
|
|
177
|
+
continue
|
|
178
|
+
if rt != "1":
|
|
179
|
+
continue
|
|
180
|
+
params, des, impls = {}, "", []
|
|
181
|
+
for j in kids.get(idx, []):
|
|
182
|
+
c = recs[j]
|
|
183
|
+
if c.get("RECORD") == "34":
|
|
184
|
+
des = c.get("TEXT", "").strip()
|
|
185
|
+
elif c.get("RECORD") == "41" and c.get("NAME"):
|
|
186
|
+
params[c["NAME"]] = c.get("TEXT", "")
|
|
187
|
+
elif c.get("RECORD") == "44":
|
|
188
|
+
for k in kids.get(j, []):
|
|
189
|
+
m = recs[k]
|
|
190
|
+
if m.get("RECORD") == "45" and m.get("MODELTYPE", "").upper() == "PCBLIB":
|
|
191
|
+
impls.append(m)
|
|
192
|
+
cur = next((m for m in impls if m.get("ISCURRENT", "").upper() == "T"), impls[0] if impls else None)
|
|
193
|
+
kind = next((_int(r[k], 0) for k in ("COMPONENTKINDVERSION3", "COMPONENTKINDVERSION2", "COMPONENTKIND") if k in r), 0)
|
|
194
|
+
comment = _resolve(params.get("Comment", ""), params)
|
|
195
|
+
mpn = _mpn_of(params, _param_names("Manufacturer Part Number", MPN_PARAMS, rules))
|
|
196
|
+
did = r.get("DESIGNITEMID", "").strip()
|
|
197
|
+
comps.append({
|
|
198
|
+
"sheet": path.name, "designator": des, "libref": r.get("LIBREFERENCE", ""), "design_item_id": did,
|
|
199
|
+
"source_library": r.get("SOURCELIBRARYNAME", "").strip("*"), "library_path": r.get("LIBRARYPATH", "").strip("*"),
|
|
200
|
+
"database_table": r.get("DATABASETABLENAME", ""), "unique_id": r.get("UNIQUEID", ""),
|
|
201
|
+
"current_part": _int(r.get("CURRENTPARTID"), 1), "part_count": max(_int(r.get("PARTCOUNT"), 2) - 1, 1),
|
|
202
|
+
"description": r.get("COMPONENTDESCRIPTION", ""), "kind": kind, "in_bom": kind not in NO_BOM_KINDS,
|
|
203
|
+
"comment": comment, "value": _ci(params, VALUE_PARAMS) or comment,
|
|
204
|
+
"footprint": (cur or {}).get("MODELNAME", ""), "footprints": [m.get("MODELNAME", "") for m in impls],
|
|
205
|
+
"mpn": mpn, "manufacturer": _ci(params, _param_names("Manufacturer", MFR_PARAMS, rules)),
|
|
206
|
+
"lcsc": _ci(params, LCSC_PARAMS), "datasheet": _datasheet(params),
|
|
207
|
+
"supplier": _ci(params, ["Supplier 1", "Supplier"]), "supplier_pn": _ci(params, ["Supplier Part Number 1", "Supplier Part Number"]),
|
|
208
|
+
"lifecycle": _ci(params, LIFECYCLE_PARAMS),
|
|
209
|
+
"vault_guid": r.get("VAULTGUID", ""), "item_guid": r.get("ITEMGUID", ""), "revision_guid": r.get("REVISIONGUID", ""),
|
|
210
|
+
"symbol_item_guid": r.get("SYMBOLITEMGUID", ""), "symbol_revision_guid": r.get("SYMBOLREVISIONGUID", ""),
|
|
211
|
+
"params": params,
|
|
212
|
+
})
|
|
213
|
+
if not des:
|
|
214
|
+
warnings.append(f"{path.name}: component {r.get('LIBREFERENCE', '?')} ({r.get('UNIQUEID', '')}) has no designator")
|
|
215
|
+
elif des.endswith("?"):
|
|
216
|
+
warnings.append(f"{path.name}: {des} ({r.get('LIBREFERENCE', '')}) is not annotated")
|
|
217
|
+
return {"path": str(path), "header": header, "components": comps, "warnings": warnings, "sheet_symbols": symbols}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _datasheet(params):
|
|
221
|
+
d = _ci(params, DATASHEET_PARAMS)
|
|
222
|
+
if d:
|
|
223
|
+
return d
|
|
224
|
+
for k, v in params.items(): # ComponentLink<N>Description=Datasheet + ComponentLink<N>URL
|
|
225
|
+
m = re.fullmatch(r"ComponentLink(\d+)Description", k, re.I)
|
|
226
|
+
if m and "datasheet" in v.lower():
|
|
227
|
+
return _ci(params, [f"ComponentLink{m.group(1)}URL"])
|
|
228
|
+
return ""
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ---------------------------------------------------------------- .PrjPcb
|
|
232
|
+
def _read_text(p):
|
|
233
|
+
data = Path(p).read_bytes()
|
|
234
|
+
if data[:2] in (b"\xff\xfe", b"\xfe\xff"):
|
|
235
|
+
return data.decode("utf-16")
|
|
236
|
+
for enc in ("utf-8-sig", "cp1252"):
|
|
237
|
+
try:
|
|
238
|
+
return data.decode(enc)
|
|
239
|
+
except UnicodeDecodeError:
|
|
240
|
+
pass
|
|
241
|
+
return data.decode("latin-1")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def parse_ini(text):
|
|
245
|
+
"""-> [(section, [(key, value)])], order and duplicate keys kept (configparser would refuse those)."""
|
|
246
|
+
out, cur = [], None
|
|
247
|
+
for line in text.splitlines():
|
|
248
|
+
line = line.rstrip("\r")
|
|
249
|
+
if not line.strip():
|
|
250
|
+
continue
|
|
251
|
+
s = line.strip()
|
|
252
|
+
if s.startswith("[") and s.endswith("]"):
|
|
253
|
+
cur = (s[1:-1], [])
|
|
254
|
+
out.append(cur)
|
|
255
|
+
continue
|
|
256
|
+
if cur is None:
|
|
257
|
+
cur = ("", [])
|
|
258
|
+
out.append(cur)
|
|
259
|
+
k, _, v = line.partition("=")
|
|
260
|
+
cur[1].append((k.strip(), v))
|
|
261
|
+
return out
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _fields(raw):
|
|
265
|
+
"""'Designator=R1|UniqueId=\\X|Kind=1' -> dict (split on the first '=': values may start with '=')."""
|
|
266
|
+
out = {}
|
|
267
|
+
for seg in raw.split("|"):
|
|
268
|
+
k, _, v = seg.partition("=")
|
|
269
|
+
if k:
|
|
270
|
+
out[k.strip()] = v
|
|
271
|
+
return out
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _find_file(base, rel):
|
|
275
|
+
p = Path(base) / Path(*PureWindowsPath(rel).parts) if rel else None
|
|
276
|
+
if p is None:
|
|
277
|
+
return None
|
|
278
|
+
if p.exists() and os.name != "nt":
|
|
279
|
+
return p
|
|
280
|
+
cur = Path(base) # Windows paths are case-insensitive: match part by part (on
|
|
281
|
+
for part in PureWindowsPath(rel).parts: # Windows too, for the name as it is spelled on disk)
|
|
282
|
+
if part == "..":
|
|
283
|
+
cur = cur.parent
|
|
284
|
+
continue
|
|
285
|
+
hit = cur.is_dir() and next((c for c in cur.iterdir() if c.name.lower() == part.lower()), None)
|
|
286
|
+
if not hit:
|
|
287
|
+
return p if p.exists() else None
|
|
288
|
+
cur = hit
|
|
289
|
+
return cur
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def read_prjpcb(path):
|
|
293
|
+
"""-> {path, name, documents, schdocs, bomdocs, missing, variants, parameters, design}."""
|
|
294
|
+
path = Path(path)
|
|
295
|
+
secs = parse_ini(_read_text(path))
|
|
296
|
+
docs, devs, variants, params, design = [], [], [], {}, {}
|
|
297
|
+
for name, kv in secs:
|
|
298
|
+
d = dict(kv)
|
|
299
|
+
if name.lower() == "design":
|
|
300
|
+
design = d
|
|
301
|
+
elif re.fullmatch(r"Document\d+", name, re.I) and d.get("DocumentPath"):
|
|
302
|
+
docs.append(d["DocumentPath"])
|
|
303
|
+
elif re.fullmatch(r"DeviceSheet\d+", name, re.I) and d.get("DocumentPath"):
|
|
304
|
+
devs.append(d["DocumentPath"])
|
|
305
|
+
elif re.fullmatch(r"Parameter\d+", name, re.I) and d.get("Name"):
|
|
306
|
+
params[d["Name"]] = d.get("Value", "")
|
|
307
|
+
elif re.fullmatch(r"ProjectVariant\d+", name, re.I):
|
|
308
|
+
vs = [_fields(d[f"Variation{i}"]) for i in range(1, _int(d.get("VariationCount")) + 1) if f"Variation{i}" in d]
|
|
309
|
+
pvs = []
|
|
310
|
+
for i in range(1, _int(d.get("ParamVariationCount")) + 1):
|
|
311
|
+
raw = d.get(f"ParamVariation{i}")
|
|
312
|
+
if raw is None:
|
|
313
|
+
continue
|
|
314
|
+
f = _fields(raw)
|
|
315
|
+
des = d.get(f"ParamDesignator{i}") or f.get("Designator", "")
|
|
316
|
+
n = _int(f.get("ParameterCount"), 0)
|
|
317
|
+
if n:
|
|
318
|
+
for k in range(1, n + 1):
|
|
319
|
+
pvs.append({"designator": des, "unique_id": f.get("UniqueId", ""), "name": f.get(f"ParameterName{k}", ""),
|
|
320
|
+
"value": f.get(f"VariantValue{k}", "")})
|
|
321
|
+
else:
|
|
322
|
+
pvs.append({"designator": des, "unique_id": f.get("UniqueId", ""), "name": f.get("ParameterName", ""),
|
|
323
|
+
"value": f.get("VariantValue", "")})
|
|
324
|
+
variants.append({"name": d.get("Description") or name, "unique_id": d.get("UniqueId", ""),
|
|
325
|
+
"allow_fabrication": d.get("AllowFabrication", "") == "1", "variations": vs, "param_variations": pvs})
|
|
326
|
+
sch, bom, missing, dev_missing = [], [], [], []
|
|
327
|
+
for rel in docs:
|
|
328
|
+
ext = PureWindowsPath(rel).suffix.lower()
|
|
329
|
+
if ext not in SCH_EXT + BOM_EXT:
|
|
330
|
+
continue
|
|
331
|
+
f = _find_file(path.parent, rel)
|
|
332
|
+
if f is None:
|
|
333
|
+
missing.append(rel)
|
|
334
|
+
else:
|
|
335
|
+
(sch if ext in SCH_EXT else bom).append(f)
|
|
336
|
+
for rel in devs:
|
|
337
|
+
if PureWindowsPath(rel).suffix.lower() not in SCH_EXT:
|
|
338
|
+
continue
|
|
339
|
+
f = find_device_sheet(path.parent, rel)
|
|
340
|
+
if f is None:
|
|
341
|
+
dev_missing.append(rel)
|
|
342
|
+
elif not any(Path(f).resolve() == Path(x).resolve() for x in sch):
|
|
343
|
+
sch.append(f)
|
|
344
|
+
return {"path": str(path), "name": path.stem, "documents": docs + devs, "device_sheets": devs, "schdocs": sch, "bomdocs": bom,
|
|
345
|
+
"missing": missing, "device_missing": dev_missing, "variants": variants, "parameters": params, "design": design}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def device_sheet_dirs(environ=None):
|
|
349
|
+
"""Folders device sheets usually live in: $ECADLIB_DEVICE_SHEETS, Altium's 'Device Sheets' library folders under
|
|
350
|
+
%PUBLIC% / your Documents, and Altium's data folders under %APPDATA% / %LOCALAPPDATA% / %PROGRAMDATA% (managed
|
|
351
|
+
device sheets are cached there)."""
|
|
352
|
+
env = os.environ if environ is None else environ
|
|
353
|
+
out = [Path(x) for x in (env.get(DEVICE_SHEETS_ENV) or "").split(os.pathsep) if x.strip()]
|
|
354
|
+
for var, pats in (("PUBLIC", ["Documents/Altium/*/Library/Device Sheets", "Documents/Altium/*/Library"]),
|
|
355
|
+
("USERPROFILE", ["Documents/Altium/*/Library/Device Sheets", "Documents/Altium"]),
|
|
356
|
+
("APPDATA", ["Altium*"]), ("LOCALAPPDATA", ["Altium*"]), ("PROGRAMDATA", ["Altium*"])):
|
|
357
|
+
if env.get(var):
|
|
358
|
+
for pat in pats:
|
|
359
|
+
out += sorted(Path(env[var]).glob(pat))
|
|
360
|
+
return [d for d in dict.fromkeys(out) if d.is_dir()]
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _find_named(root, name, limit=50000):
|
|
364
|
+
"""A file called `name` (any case) under root, depth first, giving up after `limit` entries."""
|
|
365
|
+
n, want = 0, name.lower()
|
|
366
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
367
|
+
n += len(dirnames) + len(filenames)
|
|
368
|
+
hit = next((f for f in filenames if f.lower() == want), None)
|
|
369
|
+
if hit:
|
|
370
|
+
return Path(dirpath) / hit
|
|
371
|
+
if n > limit:
|
|
372
|
+
return None
|
|
373
|
+
return None
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def find_device_sheet(base, rel, dirs=None):
|
|
377
|
+
"""A [DeviceSheetN] DocumentPath on this machine: relative to the project, the absolute path as written, the same
|
|
378
|
+
relative path (Managed\\Sheets\\<guid>\\X.SchDoc ...) under each device-sheet folder, then the file name anywhere
|
|
379
|
+
under those folders and the project's folder. None when it is nowhere."""
|
|
380
|
+
f = _find_file(base, rel)
|
|
381
|
+
if f is not None:
|
|
382
|
+
return f
|
|
383
|
+
w = PureWindowsPath(rel)
|
|
384
|
+
if w.is_absolute() and Path(rel).is_file():
|
|
385
|
+
return Path(rel)
|
|
386
|
+
dirs = device_sheet_dirs() if dirs is None else list(dirs)
|
|
387
|
+
tail = PureWindowsPath(*w.parts[1:]) if w.is_absolute() else w # the path without its drive
|
|
388
|
+
for d in dirs:
|
|
389
|
+
for k in range(len(tail.parts)): # ...\Library\Device Sheets\X.SchDoc: try shorter tails
|
|
390
|
+
f = _find_file(d, str(PureWindowsPath(*tail.parts[k:])))
|
|
391
|
+
if f is not None and f.is_file():
|
|
392
|
+
return f
|
|
393
|
+
for d in [Path(base)] + dirs:
|
|
394
|
+
f = _find_named(d, w.name)
|
|
395
|
+
if f is not None:
|
|
396
|
+
return f
|
|
397
|
+
return None
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# ---------------------------------------------------------------- .BomDoc (ActiveBOM)
|
|
401
|
+
def read_bomdoc(path):
|
|
402
|
+
"""Tolerant ActiveBOM reader -> {items: [{designators, design_item_id, chosen: {mpn, manufacturer, supplier,
|
|
403
|
+
supplier_pn}, alternates: [...]}], warnings}. Unverified against a real .BomDoc (see module docstring)."""
|
|
404
|
+
path = Path(path)
|
|
405
|
+
text = _read_text(path) if b"\0" not in Path(path).read_bytes()[:64] or Path(path).read_bytes()[:2] in (b"\xff\xfe", b"\xfe\xff") else ""
|
|
406
|
+
warnings, items, recs = [], [], []
|
|
407
|
+
if not text:
|
|
408
|
+
return {"items": [], "warnings": [f"{path.name}: binary BomDoc, not read (only text ActiveBOM documents are supported)"]}
|
|
409
|
+
for name, kv in parse_ini(text):
|
|
410
|
+
if any("|" in v and "=" in v for _, v in kv):
|
|
411
|
+
for k, v in kv:
|
|
412
|
+
f = _fields(v) if "|" in v else {}
|
|
413
|
+
if f:
|
|
414
|
+
recs.append(f)
|
|
415
|
+
recs.append({k: v for k, v in kv})
|
|
416
|
+
for line in text.splitlines(): # bare '|KEY=VALUE|...' lines
|
|
417
|
+
if line.startswith("|") and "=" in line:
|
|
418
|
+
recs.append(_fields(line.strip("|")))
|
|
419
|
+
for f in recs:
|
|
420
|
+
low = {k.lower().replace(" ", "").replace("_", ""): v.strip() for k, v in f.items()}
|
|
421
|
+
des = next((low[k] for k in ("designator", "designators", "refdes") if low.get(k)), "")
|
|
422
|
+
did = next((low[k] for k in ("designitemid", "itemid", "libref", "partid") if low.get(k)), "")
|
|
423
|
+
sols = {}
|
|
424
|
+
for k, v in low.items():
|
|
425
|
+
m = re.fullmatch(r"(manufacturerpartnumber|mfrpartnumber|mpn|manufacturer|mfr|suppliername|supplier|"
|
|
426
|
+
r"supplierpartnumber|supplierordercode|supplierpn)(\d*)", k)
|
|
427
|
+
if m and v:
|
|
428
|
+
fld = {"manufacturerpartnumber": "mpn", "mfrpartnumber": "mpn", "mpn": "mpn", "manufacturer": "manufacturer",
|
|
429
|
+
"mfr": "manufacturer", "suppliername": "supplier", "supplier": "supplier"}.get(m.group(1), "supplier_pn")
|
|
430
|
+
sols.setdefault(_int(m.group(2), 1), {}).setdefault(fld, v)
|
|
431
|
+
sols = [s for _, s in sorted(sols.items()) if s.get("mpn")]
|
|
432
|
+
if (des or did) and sols:
|
|
433
|
+
items.append({"designators": _split_refs(des), "design_item_id": did, "chosen": sols[0], "alternates": sols[1:]})
|
|
434
|
+
if not items:
|
|
435
|
+
warnings.append(f"{path.name}: no ActiveBOM line items recognised (format unverified); schematic values used")
|
|
436
|
+
return {"items": items, "warnings": warnings}
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
# ---------------------------------------------------------------- project model + variants
|
|
440
|
+
def _split_refs(s):
|
|
441
|
+
out = []
|
|
442
|
+
for tok in re.split(r"[,;\s]+", s or ""):
|
|
443
|
+
m = re.fullmatch(r"([A-Za-z_]+)(\d+)-(?:\1)?(\d+)", tok)
|
|
444
|
+
if m and int(m.group(3)) >= int(m.group(2)) and int(m.group(3)) - int(m.group(2)) < 1000:
|
|
445
|
+
out += [f"{m.group(1)}{i}" for i in range(int(m.group(2)), int(m.group(3)) + 1)]
|
|
446
|
+
elif tok:
|
|
447
|
+
out.append(tok)
|
|
448
|
+
return out
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _natkey(s):
|
|
452
|
+
return [int(t) if t.isdigit() else t.lower() for t in re.split(r"(\d+)", s)]
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def load_project(path, rules=None):
|
|
456
|
+
""".PrjPcb / .SchDoc / .BomDoc -> {name, path, components (one per physical part), variants, warnings, bomdoc}."""
|
|
457
|
+
p = Path(path)
|
|
458
|
+
ext = p.suffix.lower()
|
|
459
|
+
warnings, bomdocs = [], []
|
|
460
|
+
if ext in BOM_EXT: # a BomDoc names no project file: use the one next to it
|
|
461
|
+
prj = sorted(p.parent.glob("*.[Pp][Rr][Jj][Pp][Cc][Bb]"))
|
|
462
|
+
if not prj:
|
|
463
|
+
raise ValueError(f"{p.name}: no .PrjPcb next to the BomDoc; pass the project instead")
|
|
464
|
+
proj = read_prjpcb(prj[0])
|
|
465
|
+
bomdocs = [p]
|
|
466
|
+
elif ext in PROJECT_EXT:
|
|
467
|
+
proj = read_prjpcb(p)
|
|
468
|
+
bomdocs = proj["bomdocs"]
|
|
469
|
+
elif ext in SCH_EXT:
|
|
470
|
+
proj = {"path": str(p), "name": p.stem, "schdocs": [p], "bomdocs": [], "missing": [], "variants": [], "parameters": {},
|
|
471
|
+
"documents": [p.name], "design": {}}
|
|
472
|
+
else:
|
|
473
|
+
raise ValueError(f"{p.name}: not an Altium project, schematic or BomDoc")
|
|
474
|
+
warnings += [f"{Path(proj['path']).name}: document {m} not found" for m in proj["missing"]]
|
|
475
|
+
warnings += [f"{Path(proj['path']).name}: device sheet {m} not found (looked next to the project and in Altium's device "
|
|
476
|
+
f"sheet folders; its components are NOT in this BOM). Fix: put the sheet there or add its folder to "
|
|
477
|
+
f"{DEVICE_SHEETS_ENV}" for m in proj.get("device_missing", [])]
|
|
478
|
+
if not proj["schdocs"]:
|
|
479
|
+
warnings.append(f"{Path(proj['path']).name}: no schematic documents found")
|
|
480
|
+
raw, read = [], []
|
|
481
|
+
for f in proj["schdocs"]:
|
|
482
|
+
try:
|
|
483
|
+
s = read_schdoc(f, rules)
|
|
484
|
+
except Exception as e: # one unreadable sheet should not hide the rest
|
|
485
|
+
warnings.append(f"{Path(f).name}: {type(e).__name__}: {e}")
|
|
486
|
+
continue
|
|
487
|
+
read.append(s)
|
|
488
|
+
warnings += s["warnings"]
|
|
489
|
+
fmt = (proj.get("design") or {}).get("ChannelDesignatorFormatString") or CHANNEL_FORMAT
|
|
490
|
+
for s in read:
|
|
491
|
+
raw += expand_channels(s, read, fmt, warnings)
|
|
492
|
+
comps, by_des = [], {}
|
|
493
|
+
for c in raw: # multi-part: U1 part A on one sheet, part B on another -> one part
|
|
494
|
+
k = c["designator"] if c["designator"] and not c["designator"].endswith("?") else f"?{c['sheet']}:{c['unique_id']}"
|
|
495
|
+
prev = by_des.get(k)
|
|
496
|
+
if prev:
|
|
497
|
+
same = (prev["design_item_id"], prev["libref"]) == (c["design_item_id"], c["libref"])
|
|
498
|
+
if same and (prev["part_count"] > 1 or c["part_count"] > 1):
|
|
499
|
+
prev["parts_placed"] += 1
|
|
500
|
+
prev["sheets"] = sorted(set(prev["sheets"]) | {c["sheet"]})
|
|
501
|
+
continue
|
|
502
|
+
warnings.append(f"duplicate designator {c['designator']} ({prev['sheet']}: {prev['libref']}, {c['sheet']}: {c['libref']})")
|
|
503
|
+
k = f"{k}@{c['sheet']}:{c['unique_id']}"
|
|
504
|
+
c = dict(c, parts_placed=1, sheets=[c["sheet"]])
|
|
505
|
+
by_des[k] = c
|
|
506
|
+
comps.append(c)
|
|
507
|
+
bom_items = []
|
|
508
|
+
for b in bomdocs:
|
|
509
|
+
r = read_bomdoc(b)
|
|
510
|
+
bom_items += r["items"]
|
|
511
|
+
warnings += r["warnings"]
|
|
512
|
+
if bom_items: # ActiveBOM's chosen solution wins over the schematic parameter
|
|
513
|
+
for c in comps:
|
|
514
|
+
hit = next((it for it in bom_items if c["designator"] in it["designators"]), None) or \
|
|
515
|
+
next((it for it in bom_items if not it["designators"] and it["design_item_id"] and it["design_item_id"] == c["design_item_id"]), None)
|
|
516
|
+
if hit:
|
|
517
|
+
ch = hit["chosen"]
|
|
518
|
+
c.update(mpn=ch["mpn"] if ch.get("mpn") and not _is_vault_id(ch["mpn"]) else c["mpn"], manufacturer=ch.get("manufacturer") or c["manufacturer"],
|
|
519
|
+
supplier=ch.get("supplier") or c["supplier"], supplier_pn=ch.get("supplier_pn") or c["supplier_pn"],
|
|
520
|
+
alternates=hit["alternates"], mpn_source="BomDoc")
|
|
521
|
+
for c in comps:
|
|
522
|
+
c.setdefault("alternates", [])
|
|
523
|
+
if not c.get("mpn_source"):
|
|
524
|
+
c["mpn_source"] = "parameter" if c["mpn"] else ""
|
|
525
|
+
if not c["mpn"] and c["design_item_id"] and not _is_vault_id(c["design_item_id"]) and c["source_library"].lower().endswith((".dblib", ".svndblib")):
|
|
526
|
+
c["mpn"], c["mpn_source"] = c["design_item_id"], "DesignItemId" # a DbLib's key is usually the part number
|
|
527
|
+
if not c["mpn"] and c["in_bom"] and looks_like_mpn(c["comment"]):
|
|
528
|
+
c["mpn"], c["mpn_source"] = c["comment"].strip(), "Comment" # Content Vault parts: no MPN parameter
|
|
529
|
+
by_comment = sorted({c["designator"] for c in comps if c.get("mpn_source") == "Comment"}, key=_natkey)
|
|
530
|
+
if by_comment:
|
|
531
|
+
warnings.append(f"no Manufacturer Part Number parameter on {len(by_comment)} component(s) "
|
|
532
|
+
f"({', '.join(by_comment[:8])}{' ...' if len(by_comment) > 8 else ''}): their Comment is used as the MPN")
|
|
533
|
+
return {"name": proj["name"], "path": proj["path"], "components": comps, "variants": proj["variants"],
|
|
534
|
+
"parameters": proj["parameters"], "warnings": list(dict.fromkeys(warnings)),
|
|
535
|
+
"bomdocs": [str(b) for b in bomdocs], "sheets": [str(s) for s in proj["schdocs"]]}
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
VALUE_RE = re.compile(r"(?i)^[0-9]+([.,][0-9]+)?\s*([pnuµμmkKMGRL]|meg)?[0-9]*\s*(f|h|r|Ω|ohms?|v|a|w|hz|%|ppm)?"
|
|
539
|
+
r"([/_\-±+][0-9a-zµμΩ.%±/_\-+]*)?$")
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def looks_like_mpn(s):
|
|
543
|
+
"""A Comment that reads as a manufacturer part number: one word of letters and digits (- _ . / # + allowed), at least
|
|
544
|
+
5 characters with 2 letters and 2 digits, not '=Param', not a plain value (10k, 100nF, 4k7, 1uF/16V, 0R) and not
|
|
545
|
+
an Altium vault item id (CMP-1035-02800-1: a Content Vault part's LibRef, shown as its Comment)."""
|
|
546
|
+
s = (s or "").strip()
|
|
547
|
+
if not s or s.startswith("=") or _is_vault_id(s) or len(s) < 5 or len(s) > 60 or not re.fullmatch(r"[A-Za-z0-9][-A-Za-z0-9_./#+]*", s):
|
|
548
|
+
return False
|
|
549
|
+
if sum(ch.isalpha() for ch in s) < 2 or sum(ch.isdigit() for ch in s) < 2:
|
|
550
|
+
return False
|
|
551
|
+
return not VALUE_RE.match(s)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _channel_paths(sheet, read, seen=()):
|
|
555
|
+
"""Room-name paths of every instance of a sheet: () for a top sheet; one entry per channel otherwise."""
|
|
556
|
+
name = Path(sheet["path"]).name.lower()
|
|
557
|
+
parents = [(p, sym) for p in read for sym in p.get("sheet_symbols", [])
|
|
558
|
+
if PureWindowsPath(sym["file"]).name.lower() == name and p is not sheet]
|
|
559
|
+
if not parents or name in seen:
|
|
560
|
+
return [()]
|
|
561
|
+
out = []
|
|
562
|
+
for p, sym in parents:
|
|
563
|
+
rooms = ([f"{sym['repeat'][0]}{i}" for i in range(sym["repeat"][1], sym["repeat"][2] + 1)] if sym["repeat"]
|
|
564
|
+
else [sym["name"] or PureWindowsPath(sym["file"]).stem])
|
|
565
|
+
out += [pp + (r,) for pp in _channel_paths(p, read, seen + (name,)) for r in rooms]
|
|
566
|
+
return out
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def channel_designator(fmt, des, room, index):
|
|
570
|
+
"""Altium's channel designator: fmt tokens $Component, $ComponentPrefix, $ComponentIndex, $RoomName, $ChannelIndex,
|
|
571
|
+
$ChannelAlpha (R1 in channel CH2 with '$Component_$RoomName' -> R1_CH2)."""
|
|
572
|
+
m = re.match(r"([^\d]*)(\d*)(.*)", des)
|
|
573
|
+
alpha = ""
|
|
574
|
+
k = index
|
|
575
|
+
while k:
|
|
576
|
+
k, r = divmod(k - 1, 26)
|
|
577
|
+
alpha = chr(65 + r) + alpha
|
|
578
|
+
vals = {"$ComponentPrefix": m.group(1), "$ComponentIndex": m.group(2) + m.group(3), "$Component": des, "$RoomName": room,
|
|
579
|
+
"$ChannelIndex": str(index), "$ChannelAlpha": alpha}
|
|
580
|
+
return re.sub("|".join(re.escape(k) for k in sorted(vals, key=len, reverse=True)), lambda t: vals[t.group(0)], fmt)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def unique_rooms(rooms):
|
|
584
|
+
"""Room names made unique the way Altium numbers channels: sheet symbols that all carry the same name (six
|
|
585
|
+
symbols 'Red_LED' on one sheet) become Red_LED1 ... Red_LED6, so every channel's designators differ. Names that are
|
|
586
|
+
already unique are kept."""
|
|
587
|
+
count = {}
|
|
588
|
+
for r in rooms:
|
|
589
|
+
count[r] = count.get(r, 0) + 1
|
|
590
|
+
taken, seen, out = set(rooms), {}, []
|
|
591
|
+
for r in rooms:
|
|
592
|
+
if count[r] == 1:
|
|
593
|
+
out.append(r)
|
|
594
|
+
continue
|
|
595
|
+
k = seen.get(r, 0)
|
|
596
|
+
while True:
|
|
597
|
+
k += 1
|
|
598
|
+
cand = f"{r}{k}"
|
|
599
|
+
if cand not in taken:
|
|
600
|
+
break
|
|
601
|
+
seen[r] = k
|
|
602
|
+
taken.add(cand)
|
|
603
|
+
out.append(cand)
|
|
604
|
+
return out
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def expand_channels(sheet, read, fmt=CHANNEL_FORMAT, warnings=None):
|
|
608
|
+
"""The sheet's components, once per channel when sheet symbols use it more than once (REPEAT(CH,1,4), or several
|
|
609
|
+
symbols naming the same file): designators per Altium's channel format, e.g. R1_CH1 ... R1_CH4."""
|
|
610
|
+
paths = _channel_paths(sheet, read)
|
|
611
|
+
comps = sheet["components"]
|
|
612
|
+
if len(paths) <= 1:
|
|
613
|
+
return list(comps)
|
|
614
|
+
while all(len(p) > 1 for p in paths) and len({p[0] for p in paths}) == 1:
|
|
615
|
+
paths = [p[1:] for p in paths] # a level every instance shares (one sheet symbol) names nothing
|
|
616
|
+
rooms = unique_rooms(["_".join(p) for p in paths])
|
|
617
|
+
out = []
|
|
618
|
+
for c in comps:
|
|
619
|
+
for i, room in enumerate(rooms, 1):
|
|
620
|
+
des = channel_designator(fmt, c["designator"], room, i) if c["designator"] else c["designator"]
|
|
621
|
+
out.append(dict(c, designator=des, unique_id=f"{c['unique_id']}@{room}", channel=room))
|
|
622
|
+
if warnings is not None:
|
|
623
|
+
name = Path(sheet["path"]).name
|
|
624
|
+
ex = channel_designator(fmt, comps[0]["designator"], rooms[0], 1) if comps and comps[0]["designator"] else ""
|
|
625
|
+
warnings.append(f"{name}: multi-channel sheet, {len(rooms)} channels ({', '.join(rooms[:6])}{' ...' if len(rooms) > 6 else ''}): "
|
|
626
|
+
f"{len(comps)} components counted once per channel" + (f", designators like {ex} ({fmt})" if ex else "")
|
|
627
|
+
+ ("; nested channels joined with _" if any(len(p) > 1 for p in paths) else "")
|
|
628
|
+
+ ("; sheet symbols with the same name numbered 1, 2, ..." if len(set("_".join(p) for p in paths)) < len(paths) else ""))
|
|
629
|
+
return out
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
VAULT_ID_RE = re.compile(r"(?i)[A-Z]{2,5}-\d{3,}(-\d+)+(\.\d+)?")
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def is_vault_id(s):
|
|
636
|
+
"""Altium vault / Altium 365 item(-revision) ids: CMP-1035-02800-1, CMP-003-000000-1, PCC-011-0000-2, SYM-001-0001.
|
|
637
|
+
Never a manufacturer part number."""
|
|
638
|
+
return bool(VAULT_ID_RE.fullmatch(str(s or "").strip()))
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
_is_vault_id = is_vault_id
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def variant_of(project, name):
|
|
645
|
+
if not name:
|
|
646
|
+
return None
|
|
647
|
+
v = next((v for v in project["variants"] if v["name"].lower() == name.lower()), None)
|
|
648
|
+
if v is None:
|
|
649
|
+
raise ValueError(f"no variant {name!r} in {Path(project['path']).name}; variants: "
|
|
650
|
+
f"{', '.join(v['name'] for v in project['variants']) or 'none'}")
|
|
651
|
+
return v
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def apply_variant(comps, variant):
|
|
655
|
+
"""-> copies of comps with 'fitted' and variant overrides (alternate part, parameter values)."""
|
|
656
|
+
out = []
|
|
657
|
+
for c in comps:
|
|
658
|
+
c = dict(c, fitted=c["in_bom"], variant_note="")
|
|
659
|
+
if variant:
|
|
660
|
+
uid = c["unique_id"].split("@")[0] # a channel copy: <UniqueID>@<room>
|
|
661
|
+
by_uid = [v for v in variant["variations"] if v.get("UniqueId") and v["UniqueId"].rsplit("\\", 1)[-1] == uid]
|
|
662
|
+
hit = next((v for v in by_uid if v.get("Designator") == c["designator"]), None) or \
|
|
663
|
+
next(iter(by_uid), None) or \
|
|
664
|
+
next((v for v in variant["variations"] if not v.get("UniqueId") and v.get("Designator") == c["designator"]), None)
|
|
665
|
+
if hit:
|
|
666
|
+
kind = _int(hit.get("Kind"), 0)
|
|
667
|
+
if kind == 1:
|
|
668
|
+
c["fitted"], c["variant_note"] = False, "not fitted"
|
|
669
|
+
elif kind == 2:
|
|
670
|
+
alt = next((v for k, v in hit.items() if re.search(r"designitemid|libreference|libref", k, re.I) and v), "") \
|
|
671
|
+
or hit.get("AlternatePart", "")
|
|
672
|
+
c["variant_note"] = f"alternate part {alt}".strip()
|
|
673
|
+
if alt and not alt.startswith("="):
|
|
674
|
+
c.update(design_item_id=alt, mpn=alt if c["mpn_source"] in ("DesignItemId", "") else c["mpn"],
|
|
675
|
+
libref=next((v for k, v in hit.items() if re.search(r"libreference", k, re.I) and v), c["libref"]))
|
|
676
|
+
params = dict(c["params"])
|
|
677
|
+
for pv in variant["param_variations"]:
|
|
678
|
+
if (pv["unique_id"] and pv["unique_id"].rsplit("\\", 1)[-1] == uid and (not c.get("channel") or pv["designator"] in ("", c["designator"]))) or (not pv["unique_id"] and pv["designator"] == c["designator"]):
|
|
679
|
+
params[pv["name"]] = pv["value"]
|
|
680
|
+
if params != c["params"]:
|
|
681
|
+
changed = {k for k in params if params[k] != c["params"].get(k)}
|
|
682
|
+
c["params"] = params
|
|
683
|
+
mpn = _mpn_of({k: params[k] for k in changed}, MPN_PARAMS)
|
|
684
|
+
c["comment"] = _resolve(params.get("Comment", c["comment"]), params)
|
|
685
|
+
c["value"] = _ci(params, VALUE_PARAMS) or c["comment"]
|
|
686
|
+
if mpn:
|
|
687
|
+
c["mpn"] = mpn
|
|
688
|
+
c["variant_note"] = (c["variant_note"] + "; parameters changed").strip("; ")
|
|
689
|
+
out.append(c)
|
|
690
|
+
return out
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _group_key(c):
|
|
694
|
+
from .dedupe import mpn_key
|
|
695
|
+
if c["mpn"]:
|
|
696
|
+
return "MPN:" + mpn_key(c["mpn"])
|
|
697
|
+
if c["design_item_id"]:
|
|
698
|
+
return "ID:" + c["design_item_id"].upper()
|
|
699
|
+
return "LIB:" + "|".join(x.upper() for x in (c["libref"], c["value"], c["footprint"]))
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def bom_groups(project, variant=None):
|
|
703
|
+
"""Grouped BOM lines (fitted ones, then one DNP line per not-fitted group) with per-variant quantities."""
|
|
704
|
+
comps = [c for c in project["components"] if c["in_bom"]]
|
|
705
|
+
base = apply_variant(comps, variant_of(project, variant))
|
|
706
|
+
per_var = {v["name"]: {c["designator"]: c["fitted"] for c in apply_variant(comps, v)} for v in project["variants"]}
|
|
707
|
+
groups = {}
|
|
708
|
+
for c in base:
|
|
709
|
+
g = groups.setdefault((_group_key(c), c["fitted"]), {"comps": [], "fitted": c["fitted"]})
|
|
710
|
+
g["comps"].append(c)
|
|
711
|
+
out = []
|
|
712
|
+
for (key, fitted), g in sorted(groups.items(), key=lambda kv: (not kv[0][1], _natkey(kv[1]["comps"][0]["designator"]))):
|
|
713
|
+
cs = sorted(g["comps"], key=lambda c: _natkey(c["designator"]))
|
|
714
|
+
first = cs[0]
|
|
715
|
+
refs = [c["designator"] for c in cs]
|
|
716
|
+
pick = lambda f: next((c[f] for c in cs if c.get(f)), "")
|
|
717
|
+
out.append({
|
|
718
|
+
"key": key, "designator": ", ".join(refs), "qty": str(len(refs)) if fitted else "0", "count": len(refs),
|
|
719
|
+
"mpn": pick("mpn"), "manufacturer": pick("manufacturer"), "value": first["value"] or first["comment"],
|
|
720
|
+
"footprint": pick("footprint"), "description": pick("description"), "libref": first["libref"],
|
|
721
|
+
"design_item_id": pick("design_item_id"), "source_library": pick("source_library"), "lcsc": pick("lcsc"),
|
|
722
|
+
"datasheet": pick("datasheet"), "supplier": pick("supplier"), "supplier_pn": pick("supplier_pn"),
|
|
723
|
+
"lifecycle": pick("lifecycle"), "item_guid": pick("item_guid"), "revision_guid": pick("revision_guid"),
|
|
724
|
+
"vault_guid": pick("vault_guid"), "mpn_source": pick("mpn_source"), "dnp": not fitted,
|
|
725
|
+
"alternates": "; ".join(dict.fromkeys(a.get("mpn", "") for c in cs for a in c.get("alternates", []) if a.get("mpn"))),
|
|
726
|
+
"variant_notes": "; ".join(dict.fromkeys(f"{c['designator']}: {c['variant_note']}" for c in cs if c["variant_note"])),
|
|
727
|
+
"sheets": ", ".join(sorted({s for c in cs for s in c["sheets"]})),
|
|
728
|
+
"variants": {vn: sum(1 for r in refs if fit.get(r)) for vn, fit in per_var.items()},
|
|
729
|
+
})
|
|
730
|
+
return out
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
LINE_FIELDS = ("designator", "qty", "mpn", "manufacturer", "value", "footprint", "description", "libref", "lcsc", "datasheet")
|
|
734
|
+
MAPPING = {"designator": "Designator (RECORD=34)", "qty": "count of designators", "mpn": "MPN parameter / ActiveBOM / DbLib DesignItemId",
|
|
735
|
+
"manufacturer": "Manufacturer parameter", "value": "Comment / Value parameter", "footprint": "current PCB model (RECORD=45)",
|
|
736
|
+
"description": "ComponentDescription", "libref": "LibReference", "lcsc": "LCSC parameter", "datasheet": "Datasheet / ComponentLink",
|
|
737
|
+
"dnp": "variant (Kind=1) / component kind"}
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def bom_lines(path, variant=None, rules=None):
|
|
741
|
+
"""bom_triage.read_bom hook: (lines, mapping) in the same shape read_bom returns for a CSV."""
|
|
742
|
+
prj = load_project(path, rules)
|
|
743
|
+
for w in prj["warnings"]:
|
|
744
|
+
print(f" warning: {w}", file=sys.stderr)
|
|
745
|
+
lines = []
|
|
746
|
+
for g in bom_groups(prj, variant):
|
|
747
|
+
ln = {f: g[f] for f in LINE_FIELDS if g.get(f)}
|
|
748
|
+
ln["designator"], ln["qty"] = g["designator"], g["qty"] if not g["dnp"] else str(g["count"])
|
|
749
|
+
ln["dnp"] = g["dnp"]
|
|
750
|
+
for k in ("design_item_id", "item_guid", "revision_guid", "supplier", "supplier_pn", "lifecycle"):
|
|
751
|
+
if g.get(k):
|
|
752
|
+
ln[f"altium_{k}"] = g[k]
|
|
753
|
+
if g.get("mpn_source") == "Comment":
|
|
754
|
+
ln["altium_mpn_source"] = "Comment"
|
|
755
|
+
lines.append(ln)
|
|
756
|
+
if not lines:
|
|
757
|
+
raise ValueError(f"{Path(path).name}: no components found in the project's schematics")
|
|
758
|
+
return lines, dict(MAPPING)
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
CSV_COLS = [("Designator", "designator"), ("Quantity", "qty"), ("Manufacturer Part Number", "mpn"), ("Manufacturer", "manufacturer"),
|
|
762
|
+
("Comment", "value"), ("Footprint", "footprint"), ("Description", "description"), ("LibRef", "libref"),
|
|
763
|
+
("Design Item ID", "design_item_id"), ("Source Library", "source_library"), ("Supplier 1", "supplier"),
|
|
764
|
+
("Supplier Part Number 1", "supplier_pn"), ("LCSC", "lcsc"), ("Datasheet", "datasheet"), ("Lifecycle Status", "lifecycle"),
|
|
765
|
+
("Alternates", "alternates"), ("Item GUID", "item_guid"), ("Revision GUID", "revision_guid"), ("Sheets", "sheets"),
|
|
766
|
+
("Variant Notes", "variant_notes"), ("DNP", "dnp")]
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def write_bom_csv(groups, out, variants=()):
|
|
770
|
+
out = Path(out)
|
|
771
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
772
|
+
with open(out, "w", newline="", encoding="utf-8-sig") as f:
|
|
773
|
+
w = SS.writer(f)
|
|
774
|
+
w.writerow([h for h, _ in CSV_COLS] + [f"Qty [{v}]" for v in variants])
|
|
775
|
+
for g in groups:
|
|
776
|
+
w.writerow([("DNP" if g[k] else "") if k == "dnp" else g.get(k, "") for _, k in CSV_COLS] +
|
|
777
|
+
[g["variants"].get(v, 0) for v in variants])
|
|
778
|
+
return out
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
# ---------------------------------------------------------------- where-used
|
|
782
|
+
def find_projects(dirs):
|
|
783
|
+
"""Every .PrjPcb under dirs; loose .SchDoc files (not listed by a project and with no .PrjPcb in their folder or
|
|
784
|
+
the folders above it, up to the scanned one) count as one-sheet projects."""
|
|
785
|
+
found, loose, claimed = [], [], set()
|
|
786
|
+
for d in dirs:
|
|
787
|
+
d = Path(d)
|
|
788
|
+
if d.is_file():
|
|
789
|
+
found.append(d)
|
|
790
|
+
continue
|
|
791
|
+
if not d.is_dir():
|
|
792
|
+
raise FileNotFoundError(f"{d}: not found")
|
|
793
|
+
files = sorted(p for p in d.rglob("*") if p.is_file())
|
|
794
|
+
prj_dirs = set()
|
|
795
|
+
for p in files:
|
|
796
|
+
if p.suffix.lower() in PROJECT_EXT:
|
|
797
|
+
found.append(p)
|
|
798
|
+
prj_dirs.add(p.parent.resolve())
|
|
799
|
+
try:
|
|
800
|
+
claimed |= {Path(s).resolve() for s in read_prjpcb(p)["schdocs"]}
|
|
801
|
+
except (OSError, ValueError):
|
|
802
|
+
pass
|
|
803
|
+
root = d.resolve()
|
|
804
|
+
for p in files:
|
|
805
|
+
if p.suffix.lower() in SCH_EXT:
|
|
806
|
+
rp = p.resolve()
|
|
807
|
+
above = [rp.parent] + [a for a in rp.parent.parents if a == root or root in a.parents]
|
|
808
|
+
if rp not in claimed and not any(a in prj_dirs for a in above):
|
|
809
|
+
loose.append(p)
|
|
810
|
+
return list(dict.fromkeys(found + loose))
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _rev_suffix(did):
|
|
814
|
+
m = re.search(r"-(\d+)$", did or "")
|
|
815
|
+
return int(m.group(1)) if m and _is_vault_id(did) else None
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def load_library(plan=None, libs=(), a365=None, log=None):
|
|
819
|
+
"""dedupe.Collector over a librarian plan / library folders / Altium 365 state -> (parts, groups, errors)."""
|
|
820
|
+
from . import dedupe as DD
|
|
821
|
+
col = DD.Collector(DD.load_config(None, Path(plan).parent if plan else None), log or (lambda *a, **k: None))
|
|
822
|
+
if plan:
|
|
823
|
+
col.add_plan(json.loads(Path(plan).read_text()), Path(plan).parent)
|
|
824
|
+
a365 = a365 or (Path(plan).parent / "a365_state.json" if (Path(plan).parent / "a365_state.json").exists() else None)
|
|
825
|
+
for lib in libs:
|
|
826
|
+
col.add_lib(lib)
|
|
827
|
+
guids = {}
|
|
828
|
+
if a365:
|
|
829
|
+
col.add_a365(a365)
|
|
830
|
+
data = json.loads(Path(a365).read_text()) # any GUIDs the state carries map back to the component name
|
|
831
|
+
for name, snap in (data.items() if isinstance(data, dict) else ((n.get("name"), n) for n in data)):
|
|
832
|
+
for g in re.findall(r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", json.dumps(snap)):
|
|
833
|
+
guids[g.upper()] = name
|
|
834
|
+
return col.parts, DD.find_groups(col), col.errors, guids
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def load_lifecycle(path):
|
|
838
|
+
if not path or not Path(path).exists():
|
|
839
|
+
return {}
|
|
840
|
+
data = json.loads(Path(path).read_text())
|
|
841
|
+
return {k: v for k, v in (data.get("parts") or {}).items()}
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def where_used(dirs, part=None, plan=None, libs=(), a365=None, lifecycle=None, rules=None, log=None):
|
|
845
|
+
"""-> {parts: {key: entry}, flags: [...], projects: [...], library: bool, warnings: [...]}"""
|
|
846
|
+
from .dedupe import mpn_key
|
|
847
|
+
from .kicad_index import norm_mpn
|
|
848
|
+
from .lifecycle import SEVERITY, normalise
|
|
849
|
+
projects, warnings, parts = [], [], {}
|
|
850
|
+
for f in find_projects(dirs):
|
|
851
|
+
try:
|
|
852
|
+
prj = load_project(f, rules)
|
|
853
|
+
except Exception as e:
|
|
854
|
+
warnings.append(f"{f}: {type(e).__name__}: {e}")
|
|
855
|
+
continue
|
|
856
|
+
projects.append({"name": prj["name"], "path": prj["path"], "variants": [v["name"] for v in prj["variants"]],
|
|
857
|
+
"components": len(prj["components"])})
|
|
858
|
+
warnings += [f"{prj['name']}: {w}" for w in prj["warnings"]]
|
|
859
|
+
comps = [c for c in prj["components"] if c["in_bom"]]
|
|
860
|
+
fits = {v["name"]: {c["designator"]: c["fitted"] for c in apply_variant(comps, v)} for v in prj["variants"]}
|
|
861
|
+
for c in comps:
|
|
862
|
+
key = _group_key(c)
|
|
863
|
+
e = parts.setdefault(key, {"key": key, "mpn": c["mpn"], "manufacturer": c["manufacturer"], "design_item_ids": set(),
|
|
864
|
+
"librefs": set(), "item_guids": set(), "footprints": set(), "lifecycle_param": set(), "uses": {}})
|
|
865
|
+
e["mpn"] = e["mpn"] or c["mpn"]
|
|
866
|
+
e["manufacturer"] = e["manufacturer"] or c["manufacturer"]
|
|
867
|
+
for fld, s in (("design_item_id", "design_item_ids"), ("libref", "librefs"), ("item_guid", "item_guids"),
|
|
868
|
+
("footprint", "footprints"), ("lifecycle", "lifecycle_param")):
|
|
869
|
+
if c[fld]:
|
|
870
|
+
e[s].add(c[fld])
|
|
871
|
+
u = e["uses"].setdefault((prj["path"], c["design_item_id"], c["revision_guid"]), {
|
|
872
|
+
"project": prj["name"], "path": prj["path"], "design_item_id": c["design_item_id"], "revision_guid": c["revision_guid"],
|
|
873
|
+
"item_guid": c["item_guid"], "designators": [], "not_fitted_in": {}})
|
|
874
|
+
u["designators"].append(c["designator"])
|
|
875
|
+
for vn, fit in fits.items():
|
|
876
|
+
if not fit.get(c["designator"], True):
|
|
877
|
+
u["not_fitted_in"].setdefault(vn, []).append(c["designator"])
|
|
878
|
+
for e in parts.values():
|
|
879
|
+
e["uses"] = list(e["uses"].values())
|
|
880
|
+
for u in e["uses"]:
|
|
881
|
+
u["designators"].sort(key=_natkey)
|
|
882
|
+
u["qty"] = len(u["designators"])
|
|
883
|
+
flags = []
|
|
884
|
+
|
|
885
|
+
# library match
|
|
886
|
+
lib_parts, groups, errs, guids = load_library(plan, libs, a365, log) if (plan or libs or a365) else ([], [], [], {})
|
|
887
|
+
warnings += errs
|
|
888
|
+
have_lib = bool(plan or libs or a365)
|
|
889
|
+
by_key, by_name = {}, {}
|
|
890
|
+
for lp in lib_parts:
|
|
891
|
+
if lp.get("key"):
|
|
892
|
+
by_key.setdefault(lp["key"], []).append(lp)
|
|
893
|
+
by_name.setdefault(str(lp["name"]).upper(), []).append(lp)
|
|
894
|
+
keeper = {}
|
|
895
|
+
for g in groups:
|
|
896
|
+
if g["kind"] == "mpn":
|
|
897
|
+
for m in g["members"]:
|
|
898
|
+
if m["name"] != g["keeper"]:
|
|
899
|
+
keeper[m["name"]] = g["keeper"]
|
|
900
|
+
matched = set()
|
|
901
|
+
for e in parts.values():
|
|
902
|
+
hits = []
|
|
903
|
+
if e["mpn"]:
|
|
904
|
+
hits += by_key.get(mpn_key(e["mpn"]), [])
|
|
905
|
+
for did in e["design_item_ids"] | e["librefs"]:
|
|
906
|
+
hits += by_name.get(did.upper(), [])
|
|
907
|
+
names = list(dict.fromkeys(h["name"] for h in hits))
|
|
908
|
+
for g in e["item_guids"]:
|
|
909
|
+
if g.upper() in guids:
|
|
910
|
+
names.append(guids[g.upper()])
|
|
911
|
+
e["library"] = list(dict.fromkeys(names))
|
|
912
|
+
e["library_origin"] = sorted({h["origin"] for h in hits} | ({"a365"} if any(g.upper() in guids for g in e["item_guids"]) else set()))
|
|
913
|
+
matched |= set(e["library"])
|
|
914
|
+
if have_lib and not e["library"]:
|
|
915
|
+
flags.append(_flag("missing_from_library", e, "used in projects but not in the library"))
|
|
916
|
+
for n in e["library"]:
|
|
917
|
+
if n in keeper:
|
|
918
|
+
flags.append(_flag("duplicate_in_use", e, f"library part {n} is a duplicate; keeper is {keeper[n]}"))
|
|
919
|
+
if have_lib:
|
|
920
|
+
seen = set()
|
|
921
|
+
for lp in lib_parts:
|
|
922
|
+
if lp["name"] not in matched and lp["name"] not in seen and lp.get("origin") in ("plan", "dblib", "a365"):
|
|
923
|
+
seen.add(lp["name"])
|
|
924
|
+
flags.append({"flag": "unused_library_part", "key": "", "part": lp["name"], "mpn": lp.get("mpn", ""), "projects": "",
|
|
925
|
+
"detail": f"{lp.get('origin')} part used by none of the {len(projects)} projects scanned"})
|
|
926
|
+
|
|
927
|
+
# revisions: same Altium 365 item placed at different revisions
|
|
928
|
+
by_item = {}
|
|
929
|
+
for e in parts.values():
|
|
930
|
+
for u in e["uses"]:
|
|
931
|
+
if u["item_guid"] and u["revision_guid"]:
|
|
932
|
+
by_item.setdefault(u["item_guid"].upper(), []).append((e, u))
|
|
933
|
+
for ig, uses in by_item.items():
|
|
934
|
+
revs = {u["revision_guid"].upper() for _, u in uses}
|
|
935
|
+
if len(revs) < 2:
|
|
936
|
+
continue
|
|
937
|
+
nums = {u["revision_guid"].upper(): _rev_suffix(u["design_item_id"]) for _, u in uses}
|
|
938
|
+
top = max((n for n in nums.values() if n is not None), default=None)
|
|
939
|
+
for e, u in uses:
|
|
940
|
+
n = nums[u["revision_guid"].upper()]
|
|
941
|
+
if top is not None and n is not None:
|
|
942
|
+
if n < top:
|
|
943
|
+
newest = next(x["design_item_id"] for _, x in uses if nums[x["revision_guid"].upper()] == top)
|
|
944
|
+
flags.append(_flag("old_revision", e, f"{u['design_item_id']} placed; {newest} is used elsewhere", u))
|
|
945
|
+
else:
|
|
946
|
+
flags.append(_flag("mixed_revisions", e, f"item {ig} placed at {len(revs)} revisions; this project has {u['revision_guid']}", u))
|
|
947
|
+
|
|
948
|
+
# lifecycle
|
|
949
|
+
lc = load_lifecycle(lifecycle or (Path(plan).parent / "lifecycle_state.json" if plan else None))
|
|
950
|
+
plan_lc = {}
|
|
951
|
+
if plan:
|
|
952
|
+
for it in json.loads(Path(plan).read_text()):
|
|
953
|
+
st = it.get("lifecycle")
|
|
954
|
+
st = st.get("status") if isinstance(st, dict) else st
|
|
955
|
+
if st:
|
|
956
|
+
plan_lc[it["name"]] = st
|
|
957
|
+
for e in parts.values():
|
|
958
|
+
st, src = "", ""
|
|
959
|
+
if e["mpn"] and norm_mpn(e["mpn"]) in lc:
|
|
960
|
+
st, src = lc[norm_mpn(e["mpn"])].get("status", ""), "lifecycle state"
|
|
961
|
+
if not st:
|
|
962
|
+
st, src = next(((plan_lc[n], "plan") for n in e["library"] if n in plan_lc), ("", ""))
|
|
963
|
+
if not st and e["lifecycle_param"]:
|
|
964
|
+
st, src = normalise(sorted(e["lifecycle_param"])[0]), "schematic parameter"
|
|
965
|
+
e["lifecycle"] = st if st and st != "unknown" else ""
|
|
966
|
+
if SEVERITY.get(e["lifecycle"], 0) > 0:
|
|
967
|
+
flags.append(_flag("lifecycle", e, f"{e['lifecycle']} ({src})"))
|
|
968
|
+
|
|
969
|
+
if part:
|
|
970
|
+
q = part.upper()
|
|
971
|
+
keep = {k for k, e in parts.items() if q in k.upper() or q in (e["mpn"] or "").upper() or
|
|
972
|
+
any(q in x.upper() for x in e["design_item_ids"] | e["librefs"] | e["item_guids"] | set(e["library"])) or
|
|
973
|
+
(e["mpn"] and mpn_key(part) == mpn_key(e["mpn"]))}
|
|
974
|
+
parts = {k: v for k, v in parts.items() if k in keep}
|
|
975
|
+
flags = [f for f in flags if f.get("key") in keep or (f["flag"] == "unused_library_part" and q in f["part"].upper())]
|
|
976
|
+
for e in parts.values():
|
|
977
|
+
for s in ("design_item_ids", "librefs", "item_guids", "footprints", "lifecycle_param"):
|
|
978
|
+
e[s] = sorted(e[s])
|
|
979
|
+
return {"parts": parts, "flags": flags, "projects": projects, "library": have_lib, "warnings": list(dict.fromkeys(warnings))}
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def _flag(kind, e, detail, use=None):
|
|
983
|
+
uses = [use] if use else e["uses"]
|
|
984
|
+
return {"flag": kind, "key": e["key"], "part": e["mpn"] or next(iter(e["design_item_ids"]), "") or next(iter(e["librefs"]), ""),
|
|
985
|
+
"mpn": e["mpn"], "projects": ", ".join(dict.fromkeys(u["project"] for u in uses)), "detail": detail}
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
WU_COLS = ["key", "mpn", "manufacturer", "design_item_id", "libref", "library", "lifecycle", "project", "qty", "designators",
|
|
989
|
+
"not_fitted", "item_guid", "revision_guid", "path"]
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def where_used_rows(res):
|
|
993
|
+
rows = []
|
|
994
|
+
for k, e in sorted(res["parts"].items()):
|
|
995
|
+
for u in e["uses"]:
|
|
996
|
+
rows.append({"key": k, "mpn": e["mpn"], "manufacturer": e["manufacturer"], "design_item_id": u["design_item_id"],
|
|
997
|
+
"libref": ", ".join(e["librefs"]), "library": ", ".join(e.get("library", [])), "lifecycle": e.get("lifecycle", ""),
|
|
998
|
+
"project": u["project"], "qty": u["qty"], "designators": ", ".join(u["designators"]),
|
|
999
|
+
"not_fitted": "; ".join(f"{v}: {', '.join(d)}" for v, d in u["not_fitted_in"].items()),
|
|
1000
|
+
"item_guid": u["item_guid"], "revision_guid": u["revision_guid"], "path": u["path"]})
|
|
1001
|
+
return rows
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def write_where_used(res, stem):
|
|
1005
|
+
stem = Path(stem)
|
|
1006
|
+
stem.parent.mkdir(parents=True, exist_ok=True)
|
|
1007
|
+
rows = where_used_rows(res)
|
|
1008
|
+
with open(stem.with_suffix(".csv"), "w", newline="", encoding="utf-8-sig") as f:
|
|
1009
|
+
w = SS.DictWriter(f, fieldnames=WU_COLS)
|
|
1010
|
+
w.writeheader()
|
|
1011
|
+
w.writerows(rows)
|
|
1012
|
+
with open(stem.with_name(stem.name + "_flags.csv"), "w", newline="", encoding="utf-8-sig") as f:
|
|
1013
|
+
w = SS.DictWriter(f, fieldnames=["flag", "part", "mpn", "projects", "detail"], extrasaction="ignore")
|
|
1014
|
+
w.writeheader()
|
|
1015
|
+
w.writerows(res["flags"])
|
|
1016
|
+
counts = {}
|
|
1017
|
+
for fl in res["flags"]:
|
|
1018
|
+
counts[fl["flag"]] = counts.get(fl["flag"], 0) + 1
|
|
1019
|
+
md = ["# Where used", "", f"- projects scanned: {len(res['projects'])}", f"- parts: {len(res['parts'])}",
|
|
1020
|
+
f"- library compared: {'yes' if res['library'] else 'no (pass --plan / --lib / --a365)'}"]
|
|
1021
|
+
md += [f"- **{k}**: {v}" for k, v in sorted(counts.items())]
|
|
1022
|
+
md += ["", "## Projects", "", "| project | components | variants | path |", "|---|---|---|---|"]
|
|
1023
|
+
md += [f"| {p['name']} | {p['components']} | {', '.join(p['variants']) or '-'} | `{p['path']}` |" for p in res["projects"]]
|
|
1024
|
+
if res["flags"]:
|
|
1025
|
+
md += ["", "## Flags", "", "| flag | part | projects | detail |", "|---|---|---|---|"]
|
|
1026
|
+
md += [f"| {f['flag']} | {f['part']} | {f['projects']} | {f['detail']} |" for f in res["flags"]]
|
|
1027
|
+
md += ["", "## Parts", "", "| part | MPN | library | lifecycle | project | qty | designators | not fitted |", "|---|---|---|---|---|---|---|---|"]
|
|
1028
|
+
for r in rows:
|
|
1029
|
+
md.append(f"| {r['design_item_id'] or r['libref']} | {r['mpn']} | {r['library'] or '-'} | {r['lifecycle'] or '-'} | {r['project']} | "
|
|
1030
|
+
f"{r['qty']} | {r['designators'][:60]} | {r['not_fitted'] or '-'} |")
|
|
1031
|
+
if res["warnings"]:
|
|
1032
|
+
md += ["", "## Warnings", ""] + [f"- {w}" for w in res["warnings"]]
|
|
1033
|
+
stem.with_suffix(".md").write_text("\n".join(md) + "\n")
|
|
1034
|
+
js = {"projects": res["projects"], "flags": res["flags"], "warnings": res["warnings"], "parts": res["parts"]}
|
|
1035
|
+
stem.with_suffix(".json").write_text(json.dumps(js, indent=1, default=list))
|
|
1036
|
+
return {"md": stem.with_suffix(".md"), "csv": stem.with_suffix(".csv"), "flags": stem.with_name(stem.name + "_flags.csv"),
|
|
1037
|
+
"json": stem.with_suffix(".json")}
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
# ---------------------------------------------------------------- CLI
|
|
1041
|
+
def main(argv=None):
|
|
1042
|
+
ap = argparse.ArgumentParser(prog="ecadlib project", description="BOM straight from an Altium project (.PrjPcb / .SchDoc / .BomDoc)")
|
|
1043
|
+
ap.add_argument("project")
|
|
1044
|
+
ap.add_argument("-o", "--out", help="BOM CSV to write (default: <project>_bom.csv next to the project)")
|
|
1045
|
+
ap.add_argument("--variant", help="apply this project variant (default: the base design)")
|
|
1046
|
+
ap.add_argument("--json", action="store_true", help="print the grouped BOM as JSON")
|
|
1047
|
+
a = ap.parse_args(argv)
|
|
1048
|
+
try:
|
|
1049
|
+
prj = load_project(a.project)
|
|
1050
|
+
groups = bom_groups(prj, a.variant)
|
|
1051
|
+
except (ValueError, FileNotFoundError) as e:
|
|
1052
|
+
print(f"ecadlib project: {e}", file=sys.stderr)
|
|
1053
|
+
return 2
|
|
1054
|
+
variants = [v["name"] for v in prj["variants"]]
|
|
1055
|
+
out = write_bom_csv(groups, a.out or Path(a.project).with_name(f"{Path(a.project).stem}_bom.csv"), variants)
|
|
1056
|
+
if a.json:
|
|
1057
|
+
print(json.dumps(groups, indent=1))
|
|
1058
|
+
else:
|
|
1059
|
+
fitted = [g for g in groups if not g["dnp"]]
|
|
1060
|
+
print(f"{prj['name']}: {len(prj['sheets'])} sheets, {len(prj['components'])} components, {len(fitted)} BOM lines"
|
|
1061
|
+
f"{' (variant ' + a.variant + ')' if a.variant else ''}; variants: {', '.join(variants) or 'none'}")
|
|
1062
|
+
for g in groups:
|
|
1063
|
+
print(f" {g['qty']:>3} {(g['mpn'] or g['design_item_id'] or g['libref'])[:28]:<28} {g['footprint'][:24]:<24} "
|
|
1064
|
+
f"{g['designator'][:40]}{' DNP' if g['dnp'] else ''}")
|
|
1065
|
+
for w in prj["warnings"]:
|
|
1066
|
+
print(f"warning: {w}", file=sys.stderr)
|
|
1067
|
+
print(f"wrote {out}")
|
|
1068
|
+
return 0
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
def where_used_main(argv=None):
|
|
1072
|
+
ap = argparse.ArgumentParser(prog="ecadlib where-used", description="Which projects use which library part")
|
|
1073
|
+
ap.add_argument("--projects", nargs="+", required=True, help="folders (searched recursively) or project files")
|
|
1074
|
+
ap.add_argument("--part", help="only this part (MPN, Design Item ID, LibRef or GUID; substring match)")
|
|
1075
|
+
ap.add_argument("--plan", help="librarian plan.json to compare against")
|
|
1076
|
+
ap.add_argument("--lib", action="append", default=[], help="library folder / .DbLib / .SchLib (repeatable)")
|
|
1077
|
+
ap.add_argument("--a365", help="check-a365 state JSON (Altium 365 components)")
|
|
1078
|
+
ap.add_argument("--lifecycle", help="lifecycle_state.json from `ecadlib lifecycle`")
|
|
1079
|
+
ap.add_argument("-o", "--out", default="out/where_used", help="report stem (.md, .csv, _flags.csv, .json)")
|
|
1080
|
+
a = ap.parse_args(argv)
|
|
1081
|
+
try:
|
|
1082
|
+
res = where_used(a.projects, a.part, a.plan, a.lib, a.a365, a.lifecycle)
|
|
1083
|
+
except (ValueError, FileNotFoundError) as e:
|
|
1084
|
+
print(f"ecadlib where-used: {e}", file=sys.stderr)
|
|
1085
|
+
return 2
|
|
1086
|
+
files = write_where_used(res, a.out)
|
|
1087
|
+
print(f"{len(res['projects'])} projects, {len(res['parts'])} parts, {len(res['flags'])} flags")
|
|
1088
|
+
for fl in res["flags"][:50]:
|
|
1089
|
+
print(f" {fl['flag']:<22} {fl['part'][:28]:<28} {fl['detail'][:80]}")
|
|
1090
|
+
print(f"wrote {files['md']} and .csv")
|
|
1091
|
+
return 1 if res["flags"] else 0
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
if __name__ == "__main__":
|
|
1095
|
+
sys.exit(main())
|