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
ecadlib/altium365.py ADDED
@@ -0,0 +1,385 @@
1
+ """Altium 365 workspace exporter, through the Nexar Design API.
2
+
3
+ Turns a BOM (plus the triage report) into a *release plan*: one managed workspace component
4
+ per part, with its parameters and the symbol (.SchLib) and footprint (.PcbLib) files to upload.
5
+ Then `push` releases new components and keeps parameters of existing ones up to date.
6
+
7
+ python -m ecadlib.altium365 plan examples/sample_bom.csv --triage out/triage_sample_bom.csv \
8
+ --models out/altium_models --models out/altium_models_kicad -o out/altium365/plan.json
9
+ python -m ecadlib.altium365 workspaces # needs NEXAR_TOKEN
10
+ python -m ecadlib.altium365 push out/altium365/plan.json --workspace URL [--dry-run]
11
+
12
+ Flow per part (mirrors NexarDeveloper/nexar-release-component-demo-wpf, schema as of 2024-07):
13
+ 1. desLibrary.components(where: {name: {eq}}) exists? -> desUpdateComponentRevisionParameters
14
+ 2. POST {files}/File/Upload (header `token`) -> plain-text fileId, valid 24 h
15
+ 3. desReleaseComponent(input) symbol + footprint + component in one call
16
+ Release needs real Altium binaries: one symbol per .SchLib, one footprint per .PcbLib, under
17
+ --models/SchLib/<Library Ref>.SchLib and --models/PcbLib/<Footprint Ref>.PcbLib. Parts without
18
+ them are planned as `needs_altium_models` (altium_lib writes ours; kicad_import converts KiCad library parts).
19
+
20
+ Auth: NEXAR_TOKEN = a user access token with scopes openid, user.access, design.domain
21
+ (the token generated in the Nexar portal has them). Supply-only client-credential tokens
22
+ cannot see workspaces."""
23
+ import argparse, json, os, sys
24
+ from pathlib import Path
25
+
26
+ import pandas as pd
27
+
28
+ from .altium_export import RESERVED, to_rows
29
+ from .altium_lib import file_stem
30
+ from .bom_triage import read_bom
31
+
32
+ API_URL = os.environ.get("NEXAR_API_URL", "https://api.nexar.com/graphql")
33
+ FILES_URL = os.environ.get("NEXAR_FILES_URL", "https://files.nexar.com")
34
+
35
+ Q_WORKSPACES = "query { desWorkspaceInfos { name url } }"
36
+
37
+ # content-type specific defaults, as the demo's ReleaseDefinitions query
38
+ Q_DEFS = """query ($ws: String!) {
39
+ componentScheme: desRevisionNamingSchemeByContentTypeKind(workspaceUrl: $ws, kind: COMPONENT) { id name }
40
+ symbolScheme: desRevisionNamingSchemeByContentTypeKind(workspaceUrl: $ws, kind: SYMBOL) { id name }
41
+ footprintScheme: desRevisionNamingSchemeByContentTypeKind(workspaceUrl: $ws, kind: PCB_COMPONENT) { id name }
42
+ componentLifeCycle: desLifeCycleDefinitionByContentTypeKind(workspaceUrl: $ws, kind: COMPONENT) { id name }
43
+ symbolLifeCycle: desLifeCycleDefinitionByContentTypeKind(workspaceUrl: $ws, kind: SYMBOL) { id name }
44
+ footprintLifeCycle: desLifeCycleDefinitionByContentTypeKind(workspaceUrl: $ws, kind: PCB_COMPONENT) { id name }
45
+ }"""
46
+
47
+ Q_FIND = """query ($ws: String!, $name: String!) {
48
+ desLibrary(workspaceUrl: $ws) { components(first: 1, where: {name: {eq: $name}}) { nodes { id name revision { id } details { parameters { name value } } } } }
49
+ }"""
50
+
51
+ Q_BY_ID = """query ($id: ID!) { desComponentById(id: $id) { id name revision { id } details { parameters { name value } } } }"""
52
+
53
+ M_RELEASE = """mutation ($input: DesReleaseComponentInput!) { desReleaseComponent(input: $input) { errors { message } } }"""
54
+
55
+ M_UPDATE_PARAMS = """mutation ($input: DesUpdateComponentRevisionParametersInput!) {
56
+ desUpdateComponentRevisionParameters(input: $input) { componentId errors { message } }
57
+ }"""
58
+
59
+
60
+ class NexarError(Exception):
61
+ pass
62
+
63
+
64
+ class Nexar:
65
+ def __init__(self, token, session=None):
66
+ import requests
67
+ self.token, self.s = token, session or requests.Session()
68
+
69
+ def gql(self, query, variables=None):
70
+ r = self.s.post(API_URL, json={"query": query, "variables": variables or {}},
71
+ headers={"Authorization": f"Bearer {self.token}"}, timeout=60)
72
+ r.raise_for_status()
73
+ body = r.json()
74
+ if body.get("errors"):
75
+ raise NexarError("; ".join(e.get("message", "?") for e in body["errors"]))
76
+ return body["data"]
77
+
78
+ def upload(self, path):
79
+ with open(path, "rb") as f:
80
+ r = self.s.post(FILES_URL.rstrip("/") + "/File/Upload", files={"file": (Path(path).name, f)},
81
+ headers={"token": self.token}, timeout=120)
82
+ r.raise_for_status()
83
+ return r.text.strip() # opaque id; do not parse
84
+
85
+ def workspaces(self):
86
+ return self.gql(Q_WORKSPACES)["desWorkspaceInfos"]
87
+
88
+ def release_defs(self, ws):
89
+ return self.gql(Q_DEFS, {"ws": ws})
90
+
91
+ def find(self, ws, name):
92
+ conn = self.gql(Q_FIND, {"ws": ws, "name": name})["desLibrary"]["components"] or {}
93
+ nodes = conn.get("nodes") or []
94
+ return nodes[0] if nodes else None
95
+
96
+ def component(self, cid):
97
+ return self.gql(Q_BY_ID, {"id": cid})["desComponentById"]
98
+
99
+ def download(self, url):
100
+ """A symbol/footprint dataDownloadUrl. Tried without credentials first (pre-signed URLs reject extra auth),
101
+ then with the bearer token."""
102
+ r = self.s.get(url, timeout=120)
103
+ if r.status_code in (401, 403):
104
+ r = self.s.get(url, headers={"Authorization": f"Bearer {self.token}"}, timeout=120)
105
+ r.raise_for_status()
106
+ return r.content
107
+
108
+ def find_component(self, ws, name):
109
+ n = self.find(ws, name)
110
+ return n["id"] if n else None
111
+
112
+ def release(self, inp):
113
+ errs = self.gql(M_RELEASE, {"input": inp})["desReleaseComponent"]["errors"]
114
+ if errs:
115
+ raise NexarError("; ".join(e["message"] for e in errs))
116
+
117
+ def update_params(self, component_id, params, note, replace=False):
118
+ inp = {"componentId": component_id, "releaseNote": note, "replaceExisting": replace, "parameters": params}
119
+ res = self.gql(M_UPDATE_PARAMS, {"input": inp})["desUpdateComponentRevisionParameters"]
120
+ if res.get("errors"): # payload errors come back with HTTP 200 and no top-level `errors`
121
+ raise NexarError("; ".join(e["message"] for e in res["errors"]))
122
+ return res["componentId"]
123
+
124
+
125
+ # ------------------------------------------------------------------ plan
126
+ def find_model(models, kind, ref):
127
+ """First <dir>/<kind>/<ref>.<kind> over the --models dirs, in the order given."""
128
+ return next((str(f) for f in (Path(m) / kind / f"{file_stem(ref)}.{kind}" for m in models) if f.exists()), None)
129
+
130
+
131
+ def plan(lines, triage=None, models=None, root=None, rules=None):
132
+ """One release item per unique Part Number, reusing the DbLib row builder for parameters.
133
+ models: a folder or a list of folders, each with SchLib/<Library Ref>.SchLib and PcbLib/<Footprint Ref>.PcbLib;
134
+ symbol and footprint are looked up independently, first folder wins. rules (ecadlib.rules, loaded): part and
135
+ footprint names, folders and default parameters; None keeps ecadlib's own. root overrides the rules' folder root."""
136
+ from . import rules as RU
137
+ models = [models] if isinstance(models, (str, Path)) else list(models or [])
138
+ items, names = [], set()
139
+ defaults = RU.default_params(rules)
140
+ for cat, df in to_rows(lines, triage, rules).items():
141
+ for row in df.to_dict("records"):
142
+ if row["Part Number"] in names: # same MPN under two designator classes: one workspace item
143
+ continue
144
+ names.add(row["Part Number"])
145
+ sym = fp = None
146
+ if models:
147
+ sym, fp = find_model(models, "SchLib", row["Library Ref"]), find_model(models, "PcbLib", row["Footprint Ref"])
148
+ managed = [k for k in row if k not in RESERVED and k not in ("Part Number", "Comment")]
149
+ params = [{"name": k, "value": str(row[k])} for k in managed if str(row[k])]
150
+ have = {p["name"] for p in params}
151
+ params += [{"name": k, "value": v} for k, v in defaults if k not in have and v]
152
+ items.append({
153
+ "name": row["Part Number"],
154
+ "folder": RU.folder_for(cat, rules, root) if rules is not None else f"{root or 'Components'}/{cat}",
155
+ "description": row["Description"], "comment": row["Comment"], "parameters": params,
156
+ "cleared": [k for k in managed if not str(row[k])],
157
+ "symbol": {"ref": row["Library Ref"], "file": sym},
158
+ "footprint": {"ref": row["Footprint Ref"], "file": fp},
159
+ "status": "ready" if sym and fp else "needs_altium_models",
160
+ })
161
+ kfp = df.attrs.get("kicad_fp", {}).get(row["Part Number"])
162
+ if kfp: # renamed by the rules: keep the library name for lookups and checks
163
+ items[-1]["footprint"]["kicad"] = kfp
164
+ return items
165
+
166
+
167
+ def release_input(item, ws, defs, file_ids, models_root="Components/Models"):
168
+ sym_ids, fp_ids = file_ids
169
+ nid = lambda k: (defs.get(k) or {}).get("id")
170
+ return {
171
+ "workspaceUrl": ws,
172
+ "componentReleaseFolder": item["folder"], "useExistingComponentReleaseFolder": True,
173
+ "componentItemName": item["name"], "componentComment": item["comment"],
174
+ "componentDescription": item["description"], "parameters": item["parameters"],
175
+ "componentRevisionNamingSchemeNodeId": nid("componentScheme"),
176
+ "componentLifeCycleDefinitionNodeId": nid("componentLifeCycle"),
177
+ "symbolReleaseFolder": f"{models_root}/Symbols", "symbolItemName": item["symbol"]["ref"] or None,
178
+ "symbolFiles": [{"fileId": i, "relativePath": f"Released\\{n}"} for n, i in sym_ids],
179
+ "symbolRevisionNamingSchemeNodeId": nid("symbolScheme"),
180
+ "symbolLifeCycleDefinitionNodeId": nid("symbolLifeCycle"),
181
+ "footprintReleaseFolder": f"{models_root}/Footprints", "footprintItemName": item["footprint"]["ref"] or None,
182
+ "footprintFiles": [{"fileId": i, "relativePath": f"Released\\{n}"} for n, i in fp_ids],
183
+ "footprintRevisionNamingSchemeNodeId": nid("footprintScheme"),
184
+ "footprintLifeCycleDefinitionNodeId": nid("footprintLifeCycle"),
185
+ }
186
+
187
+
188
+ # ------------------------------------------------------------------ push
189
+ def merged_params(existing, item):
190
+ """Parameter list to send with replaceExisting=True, or None when the workspace already matches.
191
+ Starts from the current revision's parameters so ones added by hand in Altium 365 survive; plan
192
+ values win; fields the plan marks cleared are dropped."""
193
+ cur = {p["name"]: p["value"] for p in ((existing.get("details") or {}).get("parameters") or [])}
194
+ want = dict(cur)
195
+ for k in item.get("cleared", []):
196
+ want.pop(k, None)
197
+ want.update({p["name"]: p["value"] for p in item["parameters"]})
198
+ return None if want == cur else [{"name": k, "value": v} for k, v in want.items()]
199
+
200
+
201
+ def push_existing(it, client, ws, dry_run=False, note="ecadlib sync"):
202
+ """Parameter update for an item that `ecadlib a365 pull` imported or linked (item.a365 = revision, id and the
203
+ snapshot of the last pull). Refuses when the component was edited in the workspace since that snapshot, so a
204
+ person's edit there is never overwritten: run `ecadlib a365 reconcile` first. On success the item's a365 block is
205
+ moved to the new revision (callers write it back with a365_sync.save_meta)."""
206
+ a = it["a365"]
207
+ base = (a.get("base") or {}).get("parameters")
208
+ local = {p["name"] for p in it["parameters"]}
209
+ cleared = list(it.get("cleared") or [])
210
+ if a.get("mirror") and base is not None: # a pulled item lists every parameter: one removed locally is cleared
211
+ cleared += [k for k in base if k not in local and k not in cleared]
212
+ want = {**it, "cleared": cleared}
213
+ if a.get("conflicts"):
214
+ return {"action": "skip", "detail": "conflict with workspace edits: run `ecadlib a365 reconcile` first"}
215
+ if dry_run:
216
+ node = {"details": {"parameters": [{"name": k, "value": v} for k, v in (base or {}).items()]}}
217
+ else:
218
+ node = client.find(ws, it["name"])
219
+ if not node and a.get("id") and hasattr(client, "component"):
220
+ node = client.component(a["id"])
221
+ if not node:
222
+ return {"action": "skip", "detail": "not in the workspace (deleted or renamed there): run `ecadlib a365 reconcile`"}
223
+ rev = (node.get("revision") or {}).get("id")
224
+ cur = {p["name"]: p["value"] for p in ((node.get("details") or {}).get("parameters") or [])}
225
+ if rev and a.get("revision") and rev != a["revision"] and base is not None and cur != base:
226
+ return {"action": "skip", "detail": f"edited in the workspace since the last pull (revision {a['revision']} -> {rev}): "
227
+ "run `ecadlib a365 reconcile` first; nothing sent"}
228
+ new = merged_params(node, want)
229
+ if new is None:
230
+ return {"action": "up_to_date", "detail": ""}
231
+ if dry_run:
232
+ return {"action": "update_parameters", "detail": {"mutation": "desUpdateComponentRevisionParameters",
233
+ "replaceExisting": True, "parameters": new}}
234
+ cid = client.update_params(node["id"], new, note, replace=True)
235
+ after = client.find(ws, it["name"]) or (client.component(cid) if cid and hasattr(client, "component") else None) or {}
236
+ a["id"] = after.get("id") or cid
237
+ a["revision"] = (after.get("revision") or {}).get("id") or a.get("revision")
238
+ a.setdefault("base", {})["parameters"] = {p["name"]: p["value"] for p in new}
239
+ return {"action": "update_parameters", "detail": cid, "a365_updated": True}
240
+
241
+
242
+ def push(items, client, ws, dry_run=False, note="ecadlib sync", policy=None, rules=None):
243
+ """Release new parts, update parameters of parts already in the workspace.
244
+ dry_run: no uploads or mutations; records the requests that would be sent.
245
+ policy: the plan's approval policy (approvals.load_policy); None uses the one release_check stored per item.
246
+ rules: parameter names go to the workspace in the rules' house names ([parameters] names)."""
247
+ from .approvals import push_block
248
+ from .rules import house_item
249
+ from .a365_sync import is_a365
250
+ # a pulled item already carries the workspace's own parameter names: renaming it would rename them there
251
+ items = [i if is_a365(i) else house_item(i, rules) for i in items]
252
+ defs = {} if dry_run else client.release_defs(ws)
253
+ results, uploaded = [], {} # one upload per file per run (shared Resistor.SchLib etc.)
254
+ def upload(p):
255
+ if p not in uploaded:
256
+ uploaded[p] = "<fileId>" if dry_run else client.upload(p)
257
+ return uploaded[p]
258
+ for it in items:
259
+ res = {"name": it["name"], "action": "", "detail": ""}
260
+ if it.get("status") == "held": # release_check held it back: no release, no parameter update
261
+ res["action"], res["detail"] = "skip", "held: " + "; ".join(it.get("hold_reasons") or ["release check"])
262
+ results.append(res)
263
+ continue
264
+ block = push_block(it, policy) # sign-off policy not met, or a dedupe duplicate
265
+ if block:
266
+ res["action"], res["detail"] = "skip", block
267
+ results.append(res)
268
+ continue
269
+ if it.get("a365"): # pulled from / linked to the workspace: update it, never release it again
270
+ try:
271
+ res.update(push_existing(it, client, ws, dry_run, note))
272
+ except Exception as e:
273
+ res["action"], res["detail"] = "error", f"{type(e).__name__}: {e}"
274
+ results.append(res)
275
+ continue
276
+ try:
277
+ existing = None if dry_run else client.find(ws, it["name"])
278
+ if existing:
279
+ new = merged_params(existing, it)
280
+ if new is None:
281
+ res["action"] = "up_to_date"
282
+ else: # full replace: a merge, so parameters added in Altium 365 are kept
283
+ res["action"] = "update_parameters"
284
+ res["detail"] = client.update_params(existing["id"], new, note, replace=True)
285
+ elif it["status"] != "ready":
286
+ res["action"], res["detail"] = "skip", "needs_altium_models (no .SchLib/.PcbLib)"
287
+ else:
288
+ files = [it["symbol"]["file"]], [it["footprint"]["file"]]
289
+ ids = tuple([(Path(p).name, upload(p)) for p in group] for group in files)
290
+ inp = release_input(it, ws, defs, ids)
291
+ res["action"] = "release"
292
+ if dry_run:
293
+ res["detail"] = {"mutation": "desReleaseComponent", "input": inp}
294
+ else:
295
+ client.release(inp)
296
+ except Exception as e: # keep going; report per part
297
+ res["action"], res["detail"] = "error", f"{type(e).__name__}: {e}"
298
+ sim = (it.get("sim_model") or {}).get("attached", {}).get("altium")
299
+ if sim and res["action"] in ("release", "update_parameters", "up_to_date"):
300
+ res["sim_model"] = (f"{sim['type']} link {sim['model_name']} travels inside the .SchLib; the model file "
301
+ f"{sim.get('file') or '(none: built-in)'} is not uploaded (Nexar has no simulation-model input)")
302
+ results.append(res)
303
+ return results
304
+
305
+
306
+ def main(argv=None):
307
+ ap = argparse.ArgumentParser(description="Release BOM parts into an Altium 365 workspace via Nexar")
308
+ sub = ap.add_subparsers(dest="cmd", required=True)
309
+ p = sub.add_parser("plan", help="build a release plan from a BOM")
310
+ p.add_argument("bom")
311
+ p.add_argument("--triage", help="triage CSV from bom_triage")
312
+ p.add_argument("--models", action="append", default=[],
313
+ help="folder with SchLib/<ref>.SchLib and PcbLib/<ref>.PcbLib; repeat to search several, first wins")
314
+ p.add_argument("--root", help="workspace folder to release components under (default: the rules' [folders] root, Components)")
315
+ p.add_argument("-o", "--out", default="out/altium365/plan.json")
316
+ p.add_argument("--rules", help="library rules file (default: ecadlib.toml found from the output folder or here)")
317
+ sub.add_parser("workspaces", help="list workspaces visible to NEXAR_TOKEN")
318
+ u = sub.add_parser("push", help="release / update parts from a plan")
319
+ u.add_argument("plan")
320
+ u.add_argument("--workspace", required=True, help="workspace URL from `workspaces`")
321
+ u.add_argument("--dry-run", action="store_true", help="no network; write the requests that would be sent")
322
+ u.add_argument("-o", "--out", default="out/altium365/push_report.json")
323
+ u.add_argument("--rules", help="library rules file (default: ecadlib.toml found from the plan's folder or here)")
324
+ a = ap.parse_args(argv)
325
+ from . import rules as RU
326
+
327
+ if a.cmd == "plan":
328
+ if Path(a.out).is_dir() or a.out.endswith(("/", "\\")): # -o out/a365: the plan file inside that folder
329
+ a.out = str(Path(a.out) / "plan.json")
330
+ rules = RU.load(a.rules, start=Path(a.out).parent)
331
+ lines, _ = read_bom(a.bom, RU.bom_columns(rules))
332
+ tri = pd.read_csv(a.triage, dtype=str).fillna("") if a.triage else None
333
+ items = plan(lines, tri, a.models, a.root, rules)
334
+ out = Path(a.out); out.parent.mkdir(parents=True, exist_ok=True)
335
+ for i in items: # model paths relative to the plan file, so push works from any cwd
336
+ for k in ("symbol", "footprint"):
337
+ if i[k]["file"]:
338
+ try:
339
+ i[k]["file"] = os.path.relpath(i[k]["file"], out.parent.resolve()).replace(os.sep, "/")
340
+ except ValueError: # another drive on Windows: keep it absolute
341
+ i[k]["file"] = Path(i[k]["file"]).resolve().as_posix()
342
+ out.write_text(json.dumps(items, indent=2))
343
+ ready = sum(i["status"] == "ready" for i in items)
344
+ print(f"{len(items)} components planned, {ready} ready, {len(items) - ready} need Altium models -> {out}")
345
+ for i in items:
346
+ print(f" {i['status']:<20} {i['folder']:<24} {i['name']:<28} sym={i['symbol']['ref']} fp={i['footprint']['ref']}")
347
+ return
348
+
349
+ if a.cmd == "push" and not Path(a.plan).is_file():
350
+ sys.exit(f"{a.plan}: no such plan file (write one with `ecadlib altium365 plan BOM -o {a.plan}`)")
351
+ token = os.environ.get("NEXAR_TOKEN")
352
+ if not token and not (a.cmd == "push" and a.dry_run):
353
+ sys.exit("set NEXAR_TOKEN (Nexar portal token with design.domain, user.access, openid scopes)")
354
+ client = Nexar(token) if token else None
355
+ try:
356
+ if a.cmd == "workspaces":
357
+ for w in client.workspaces():
358
+ print(f"{w['name']:<30} {w['url']}")
359
+ return
360
+ items = json.loads(Path(a.plan).read_text())
361
+ for i in items:
362
+ for k in ("symbol", "footprint"):
363
+ if i[k]["file"]:
364
+ i[k]["file"] = str((Path(a.plan).parent / i[k]["file"]).resolve())
365
+ from .approvals import load_policy
366
+ results = push(items, client, a.workspace, dry_run=a.dry_run, policy=load_policy(Path(a.plan).parent),
367
+ rules=RU.load(a.rules, start=Path(a.plan).parent))
368
+ except Exception as e: # auth / network / GraphQL errors before any per-part work
369
+ hint = " (token rejected: check it has design.domain, user.access, openid)" if "401" in str(e) else ""
370
+ sys.exit(f"Nexar request failed: {type(e).__name__}: {e}{hint}")
371
+ if any(r.get("a365_updated") for r in results):
372
+ from .a365_sync import save_meta # new revisions, so the next push is not mistaken for a conflict
373
+ save_meta(a.plan, items)
374
+ out = Path(a.out); out.parent.mkdir(parents=True, exist_ok=True)
375
+ out.write_text(json.dumps(results, indent=2))
376
+ for r in results:
377
+ print(f" {r['action']:<18} {r['name']}")
378
+ print(f"report -> {out}")
379
+ failed = sum(r["action"] == "error" for r in results)
380
+ if failed:
381
+ sys.exit(f"{failed} of {len(results)} parts failed (see {out})")
382
+
383
+
384
+ if __name__ == "__main__":
385
+ main()