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.
Files changed (199) hide show
  1. ecadlib/__init__.py +3 -0
  2. ecadlib/__main__.py +5 -0
  3. ecadlib/a365_sync.py +637 -0
  4. ecadlib/agent.py +1407 -0
  5. ecadlib/agent_queue.py +780 -0
  6. ecadlib/agent_watch.py +732 -0
  7. ecadlib/altium365.py +385 -0
  8. ecadlib/altium_dblib_sync.py +530 -0
  9. ecadlib/altium_export.py +209 -0
  10. ecadlib/altium_forms.py +768 -0
  11. ecadlib/altium_lib.py +1089 -0
  12. ecadlib/altium_menu.py +1751 -0
  13. ecadlib/altium_pkg.py +396 -0
  14. ecadlib/altium_project.py +1095 -0
  15. ecadlib/app.py +1458 -0
  16. ecadlib/approvals.py +395 -0
  17. ecadlib/assets/demo_bom.csv +8 -0
  18. ecadlib/assets/kicad_action_plugin.py +607 -0
  19. ecadlib/assets/kicad_reread.py +60 -0
  20. ecadlib/assets/pcblib_template.json.gz +0 -0
  21. ecadlib/bom_inputs.py +339 -0
  22. ecadlib/bom_triage.py +519 -0
  23. ecadlib/cad_model.py +528 -0
  24. ecadlib/cfb.py +171 -0
  25. ecadlib/cli.py +185 -0
  26. ecadlib/clihelp.py +149 -0
  27. ecadlib/cliutil.py +119 -0
  28. ecadlib/completion.py +173 -0
  29. ecadlib/costs.py +276 -0
  30. ecadlib/crosscheck.py +412 -0
  31. ecadlib/datasheet.py +2168 -0
  32. ecadlib/dedupe.py +572 -0
  33. ecadlib/detect.py +170 -0
  34. ecadlib/doctor.py +450 -0
  35. ecadlib/ds_route.py +541 -0
  36. ecadlib/eagle_export.py +1409 -0
  37. ecadlib/easy_setup.py +338 -0
  38. ecadlib/easyeda_convert.py +386 -0
  39. ecadlib/easyeda_pro_export.py +972 -0
  40. ecadlib/eval/__init__.py +0 -0
  41. ecadlib/eval/baselines/kicad-v1.json +404 -0
  42. ecadlib/eval/build_set.py +299 -0
  43. ecadlib/eval/cli.py +119 -0
  44. ecadlib/eval/gate.py +358 -0
  45. ecadlib/eval/golden/demo.txt +379 -0
  46. ecadlib/eval/golden/specs.txt +3120 -0
  47. ecadlib/eval/golden/testpack.txt +463 -0
  48. ecadlib/eval/golden.py +446 -0
  49. ecadlib/eval/replay/kicad-v1.json.gz +0 -0
  50. ecadlib/eval/runner.py +456 -0
  51. ecadlib/eval/score.py +179 -0
  52. ecadlib/eval/sets/kicad-v1.json +630 -0
  53. ecadlib/firstrun.py +400 -0
  54. ecadlib/footprint_adv.py +1006 -0
  55. ecadlib/footprint_bench.py +218 -0
  56. ecadlib/footprint_check.py +562 -0
  57. ecadlib/footprint_gen.py +582 -0
  58. ecadlib/footprint_rules.py +1092 -0
  59. ecadlib/forge.py +267 -0
  60. ecadlib/fp_share.py +95 -0
  61. ecadlib/fp_tidy.py +389 -0
  62. ecadlib/fsutil.py +62 -0
  63. ecadlib/house_style.py +1051 -0
  64. ecadlib/httpcache.py +124 -0
  65. ecadlib/hub.py +1490 -0
  66. ecadlib/hub_copy.py +161 -0
  67. ecadlib/hub_tools.py +79 -0
  68. ecadlib/kicad_dblib.py +257 -0
  69. ecadlib/kicad_export.py +837 -0
  70. ecadlib/kicad_import.py +913 -0
  71. ecadlib/kicad_index.py +160 -0
  72. ecadlib/kicad_menu.py +193 -0
  73. ecadlib/kicad_pcm.py +475 -0
  74. ecadlib/launcher.py +156 -0
  75. ecadlib/librarian.py +963 -0
  76. ecadlib/lifecycle.py +691 -0
  77. ecadlib/llm/__init__.py +276 -0
  78. ecadlib/llm/cli.py +70 -0
  79. ecadlib/llm/openai_compat.py +214 -0
  80. ecadlib/llm/profiles.py +245 -0
  81. ecadlib/llm/rails.py +570 -0
  82. ecadlib/llm/run.py +126 -0
  83. ecadlib/llm/sampling.py +49 -0
  84. ecadlib/mcp_server.py +723 -0
  85. ecadlib/messages.py +419 -0
  86. ecadlib/migrate.py +407 -0
  87. ecadlib/notify.py +329 -0
  88. ecadlib/package_schema.json +1238 -0
  89. ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
  90. ecadlib/package_specs/C_0402_1005Metric.json +22 -0
  91. ecadlib/package_specs/C_0805_2012Metric.json +22 -0
  92. ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
  93. ecadlib/package_specs/D_SMA.json +27 -0
  94. ecadlib/package_specs/D_SMB.json +27 -0
  95. ecadlib/package_specs/D_SOD-123.json +43 -0
  96. ecadlib/package_specs/D_SOD-323.json +43 -0
  97. ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
  98. ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
  99. ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
  100. ecadlib/package_specs/L_0805_2012Metric.json +22 -0
  101. ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
  102. ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
  103. ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
  104. ecadlib/package_specs/R_0402_1005Metric.json +22 -0
  105. ecadlib/package_specs/R_0603_1608Metric.json +22 -0
  106. ecadlib/package_specs/R_0805_2012Metric.json +22 -0
  107. ecadlib/package_specs/R_1206_3216Metric.json +22 -0
  108. ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
  109. ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
  110. ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
  111. ecadlib/package_specs/SOT-23-5.json +57 -0
  112. ecadlib/package_specs/SOT-23-6.json +62 -0
  113. ecadlib/package_specs/SOT-23.json +48 -0
  114. ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
  115. ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
  116. ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  117. ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
  118. ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
  119. ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
  120. ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
  121. ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  122. ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  123. ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
  124. ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
  125. ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
  126. ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
  127. ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
  128. ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
  129. ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
  130. ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
  131. ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
  132. ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
  133. ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
  134. ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
  135. ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
  136. ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
  137. ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
  138. ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
  139. ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
  140. ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
  141. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
  142. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
  143. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
  144. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
  145. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
  146. ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
  147. ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
  148. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
  149. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
  150. ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
  151. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
  152. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
  153. ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
  154. ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
  155. ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
  156. ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
  157. ecadlib/package_specs/standard.json.gz +0 -0
  158. ecadlib/parity.py +1204 -0
  159. ecadlib/parts_export.py +154 -0
  160. ecadlib/partview.py +215 -0
  161. ecadlib/plugins.py +231 -0
  162. ecadlib/providers.py +226 -0
  163. ecadlib/rebuild.py +471 -0
  164. ecadlib/release_check.py +437 -0
  165. ecadlib/reports.py +274 -0
  166. ecadlib/resolve.py +556 -0
  167. ecadlib/review_cli.py +292 -0
  168. ecadlib/rules.py +979 -0
  169. ecadlib/rules_infer.py +1205 -0
  170. ecadlib/sexpr.py +149 -0
  171. ecadlib/sheetsafe.py +65 -0
  172. ecadlib/sim_models.py +1087 -0
  173. ecadlib/sourcing.py +1161 -0
  174. ecadlib/stdparts.py +755 -0
  175. ecadlib/step_gen.py +929 -0
  176. ecadlib/symbol_layout.py +798 -0
  177. ecadlib/team.py +558 -0
  178. ecadlib/team_setup.py +288 -0
  179. ecadlib/term.py +420 -0
  180. ecadlib/term_views.py +64 -0
  181. ecadlib/testpack.py +334 -0
  182. ecadlib/ui/__init__.py +1 -0
  183. ecadlib/ui/__main__.py +3 -0
  184. ecadlib/ui/server.py +1029 -0
  185. ecadlib/ui/static/hairline.js +2 -0
  186. ecadlib/ui/static/index.html +2098 -0
  187. ecadlib/ui/static/setup.html +294 -0
  188. ecadlib/upkeep.py +717 -0
  189. ecadlib/upstream.py +573 -0
  190. ecadlib/userconfig.py +257 -0
  191. ecadlib/visual_check.py +1603 -0
  192. ecadlib/ws_status.py +203 -0
  193. ecadlib-0.3.0.dist-info/METADATA +2245 -0
  194. ecadlib-0.3.0.dist-info/RECORD +199 -0
  195. ecadlib-0.3.0.dist-info/WHEEL +5 -0
  196. ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
  197. ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
  198. ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
  199. 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())