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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """ecadlib - prototype tooling for an AI-assisted "living" ECAD component library."""
2
+
3
+ __version__ = "0.3.0"
ecadlib/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
ecadlib/a365_sync.py ADDED
@@ -0,0 +1,637 @@
1
+ """Two-way Altium 365 sync: bring the components already in a workspace into a plan.json, keep them in step, and
2
+ let `altium365 push` update them instead of releasing duplicates.
3
+
4
+ NEXAR_TOKEN=... ecadlib a365 pull PLAN --workspace URL [--state F] [--folder F ...] [--no-models]
5
+ NEXAR_TOKEN=... ecadlib a365 status PLAN --workspace URL [--state F]
6
+ NEXAR_TOKEN=... ecadlib a365 reconcile PLAN --workspace URL [--state F] [--prefer workspace|local]
7
+ (or: python -m ecadlib.a365_sync ...)
8
+
9
+ pull lists every workspace component (desLibrary.components, paged) with its folder, comment, description,
10
+ revision, lifecycle, parameters and symbol / footprint names and GUIDs. Components the plan does not have
11
+ are added as items with status `existing` and source {"component": "a365", ...}; their .SchLib/.PcbLib are
12
+ downloaded from the symbol/footprint dataDownloadUrl into <plan dir>/a365_models/ and read back with
13
+ altium_lib's readers (skip with --no-models). Items already pulled are refreshed: workspace edits come in,
14
+ local edits stay. A field changed on both sides is a conflict: pull reports it and changes neither side.
15
+ status the same comparison, read-only: in_sync, new_in_workspace, changed_in_workspace, changed_locally, merged
16
+ (both changed, different fields), conflict, deleted_in_workspace, deleted_locally, new_local, linked.
17
+ reconcile applies it and settles conflicts: the workspace value wins (the local value is kept in
18
+ item.a365.overwritten), or with --prefer local the plan value is kept and `altium365 push` sends it.
19
+
20
+ Comparison is three-way, per field, against the snapshot taken at the last pull (item.a365.base, falling back to
21
+ the --state file): parameters one by one, comment, description, folder, symbol and footprint names, model GUIDs.
22
+ Only parameters can be written back through Nexar (desUpdateComponentRevisionParameters); a local change to the
23
+ comment, description, folder or models is reported as `not pushable` (change it in Altium).
24
+ Plan items that were not pulled but have the same name as a workspace component are `linked`: they get the
25
+ workspace snapshot as base, and push updates them (never re-releases them).
26
+
27
+ --state (default <plan dir>/a365_state.json) is the snapshot of every listed component, in the format
28
+ `upstream check-a365 --state` and `dedupe` read ({name: {revision, lifecycle, symbols, footprints, parameters, ...}}).
29
+ Report: <plan dir>/a365_sync.md and .csv (-o to change). Exit codes: 0 nothing to do, 1 differences (status) or
30
+ unresolved conflicts (pull), 2 error. Nexar needs NEXAR_TOKEN (scopes openid, user.access, design.domain)."""
31
+ import argparse, copy, datetime, io, json, os, re, sys, zipfile
32
+ from pathlib import Path
33
+
34
+ try:
35
+ from . import rules as RU
36
+ except ImportError: # rules.py is optional (built separately); defaults below
37
+ RU = None
38
+
39
+ PAGE = 50
40
+ Q_COMPONENTS = """query ($ws: String!, $first: Int!, $after: String) {
41
+ desLibrary(workspaceUrl: $ws) {
42
+ components(first: $first, after: $after) {
43
+ totalCount
44
+ pageInfo { hasNextPage endCursor }
45
+ nodes { ...A365Component }
46
+ }
47
+ }
48
+ }
49
+ fragment A365Component on DesComponent {
50
+ id name comment description isManaged createdAt modifiedAt
51
+ folder { path }
52
+ revision { id details { lifeCycleState { name } createdAt modifiedAt } }
53
+ details {
54
+ parameters { name value }
55
+ symbols { name guid dataDownloadUrl folder { path } }
56
+ footprints { name guid dataDownloadUrl folder { path } }
57
+ }
58
+ }"""
59
+
60
+ PUSHABLE = ("parameters",)
61
+ MISSING = None
62
+ MPN_NAMES = ("Manufacturer Part Number", "MPN", "Mfr Part Number")
63
+
64
+
65
+ # ---------------------------------------------------------------- rules (optional)
66
+ def _param_name(canonical, rules):
67
+ if not rules:
68
+ return canonical
69
+ if RU is not None and hasattr(RU, "param_name"):
70
+ try:
71
+ return RU.param_name(canonical, rules)
72
+ except Exception:
73
+ pass
74
+ return ((rules.get("parameters") or {}).get("names") or {}).get(canonical, canonical)
75
+
76
+
77
+ def mpn_of(item_or_params, rules=None):
78
+ """MPN of a plan item / component dict / {name: value}; falls back to the name."""
79
+ x = item_or_params
80
+ par = x if isinstance(x, dict) and "parameters" not in x and "name" not in x else (x.get("parameters") or {})
81
+ if isinstance(par, list):
82
+ par = {p["name"]: p["value"] for p in par}
83
+ for k in (_param_name("Manufacturer Part Number", rules),) + MPN_NAMES:
84
+ if str(par.get(k) or "").strip():
85
+ return str(par[k]).strip()
86
+ return (x.get("name") if isinstance(x, dict) and "name" in x else "") or ""
87
+
88
+
89
+ def _category(folder, rules):
90
+ """Ref class / category whose rules folder this is (rules [folders] map), or the last folder name."""
91
+ fm = ((rules or {}).get("folders") or {}).get("map") or {}
92
+ f = _norm(folder).lower()
93
+ hit = next((k for k, v in fm.items() if _norm(v).lower() == f), None)
94
+ return hit or (_norm(folder).split("/")[-1] if folder else "")
95
+
96
+
97
+ # ---------------------------------------------------------------- workspace -> snapshots / components / items
98
+ def _norm(p):
99
+ return "/".join(x for x in str(p or "").replace("\\", "/").split("/") if x)
100
+
101
+
102
+ def is_a365(item):
103
+ """True for a plan item that mirrors a workspace component (pulled), not one that was only linked."""
104
+ src = item.get("source")
105
+ return src == "a365" or (isinstance(src, dict) and src.get("component") == "a365")
106
+
107
+
108
+ def fetch_components(client, ws, page=None):
109
+ """Every component of the workspace library, following pageInfo.endCursor."""
110
+ out, after, page = [], None, page or PAGE
111
+ while True:
112
+ lib = client.gql(Q_COMPONENTS, {"ws": ws, "first": page, "after": after})["desLibrary"]
113
+ conn = (lib or {}).get("components") or {}
114
+ out += conn.get("nodes") or []
115
+ pi = conn.get("pageInfo") or {}
116
+ if not pi.get("hasNextPage") or not pi.get("endCursor") or pi.get("endCursor") == after:
117
+ return out
118
+ after = pi["endCursor"]
119
+
120
+
121
+ def snapshot(node):
122
+ """What we remember of a workspace component: the base of the next three-way comparison. Its revision,
123
+ lifecycle, symbols and footprints keys are the `upstream check-a365 --state` format."""
124
+ d = node.get("details") or {}
125
+ rev = node.get("revision") or {}
126
+ rd = rev.get("details") or {}
127
+ syms, fps = d.get("symbols") or [], d.get("footprints") or []
128
+ return {"id": node.get("id"), "revision": rev.get("id"),
129
+ "lifecycle": (rd.get("lifeCycleState") or {}).get("name"),
130
+ "modified_at": node.get("modifiedAt") or rd.get("modifiedAt"), "managed": node.get("isManaged"),
131
+ "folder": _norm((node.get("folder") or {}).get("path")),
132
+ "comment": node.get("comment") or "", "description": node.get("description") or "",
133
+ "parameters": {p["name"]: p["value"] for p in d.get("parameters") or []},
134
+ "symbol": syms[0]["name"] if syms else "", "footprint": fps[0]["name"] if fps else "",
135
+ "symbols": sorted(x.get("guid") or "" for x in syms),
136
+ "footprints": sorted(x.get("guid") or "" for x in fps)}
137
+
138
+
139
+ def component(node, rules=None):
140
+ """A workspace component as the shared component dict (what rules inference consumes)."""
141
+ s, d = snapshot(node), node.get("details") or {}
142
+ model = lambda xs: ({"ref": xs[0]["name"], "guid": xs[0].get("guid"), "folder": _norm((xs[0].get("folder") or {}).get("path")),
143
+ "url": xs[0].get("dataDownloadUrl")} if xs else {"ref": "", "guid": None})
144
+ return {"name": node["name"], "folder": s["folder"], "category": _category(s["folder"], rules),
145
+ "description": s["description"], "comment": s["comment"], "parameters": dict(s["parameters"]),
146
+ "symbol": model(d.get("symbols") or []), "footprint": model(d.get("footprints") or []),
147
+ "revision": s["revision"], "guid": s["id"], "lifecycle": s["lifecycle"], "managed": s["managed"],
148
+ "mpn": mpn_of(s["parameters"], rules) or node["name"]}
149
+
150
+
151
+ def components(items, rules=None):
152
+ """Plan items that came from the workspace, as component dicts (for rules inference without a network call)."""
153
+ out = []
154
+ for it in items:
155
+ a = it.get("a365") or {}
156
+ if not a:
157
+ continue
158
+ par = {p["name"]: p["value"] for p in it.get("parameters") or []}
159
+ out.append({"name": it["name"], "folder": it.get("folder") or "", "category": _category(it.get("folder"), rules),
160
+ "description": it.get("description") or "", "comment": it.get("comment") or "", "parameters": par,
161
+ "symbol": dict(it.get("symbol") or {}), "footprint": dict(it.get("footprint") or {}),
162
+ "revision": a.get("revision"), "guid": a.get("id"), "lifecycle": a.get("lifecycle"),
163
+ "managed": a.get("managed"), "mpn": mpn_of(par, rules) or it["name"], "pulled": is_a365(it)})
164
+ return out
165
+
166
+
167
+ def _meta(snap, ws, now, mirror, old=None):
168
+ m = dict(old or {})
169
+ m.update({k: snap[k] for k in ("id", "revision", "lifecycle", "modified_at", "managed", "symbols", "footprints")})
170
+ m.update({"workspace": ws, "synced_at": now, "mirror": mirror,
171
+ "base": {k: copy.deepcopy(snap[k]) for k in ("parameters", "comment", "description", "folder", "symbol",
172
+ "footprint", "symbols", "footprints")}})
173
+ m.pop("conflicts", None)
174
+ return m
175
+
176
+
177
+ def item_from_node(node, ws, now, rules=None):
178
+ s = snapshot(node)
179
+ return {"name": node["name"], "folder": s["folder"], "description": s["description"], "comment": s["comment"],
180
+ "parameters": [{"name": k, "value": v} for k, v in s["parameters"].items()], "cleared": [],
181
+ "symbol": {"ref": s["symbol"], "file": None, "guid": (s["symbols"] or [None])[0]},
182
+ "footprint": {"ref": s["footprint"], "file": None, "guid": (s["footprints"] or [None])[0]},
183
+ "status": "existing", "category": _category(s["folder"], rules),
184
+ "source": {"component": "a365", "symbol": f"a365:{s['symbol']}" if s["symbol"] else None,
185
+ "footprint": f"a365:{s['footprint']}" if s["footprint"] else None},
186
+ "a365": _meta(s, ws, now, True)}
187
+
188
+
189
+ # ---------------------------------------------------------------- three-way comparison
190
+ def _hname(it, k, rules):
191
+ """The name parameter k of item it has in the workspace: a pulled item already uses the workspace's names, a
192
+ plan item uses ecadlib's (push renames them to the rules' house names)."""
193
+ return k if is_a365(it) else _param_name(k, rules)
194
+
195
+
196
+ def _cname(k, rules):
197
+ """ecadlib's name for workspace parameter k (inverse of the rules' [parameters] names)."""
198
+ inv = {v: c for c, v in (((rules or {}).get("parameters") or {}).get("names") or {}).items()}
199
+ return inv.get(k, k)
200
+
201
+
202
+ def local_fields(it, rules=None):
203
+ """The plan item's side of the comparison, parameter names as push would send them."""
204
+ return {"parameters": {_hname(it, p["name"], rules): p["value"] for p in it.get("parameters") or []},
205
+ "comment": it.get("comment") or "", "description": it.get("description") or "", "folder": _norm(it.get("folder")),
206
+ "symbol": (it.get("symbol") or {}).get("ref") or "", "footprint": (it.get("footprint") or {}).get("ref") or ""}
207
+
208
+
209
+ def _same(field, a, b):
210
+ if field == "folder":
211
+ return _norm(a).lower() == _norm(b).lower()
212
+ return a == b
213
+
214
+
215
+ def three_way(base, ws, local, mirror=True, cleared=()):
216
+ """[(field, key, side, base, workspace, local)] for every field that is not equal on all sides.
217
+ side: 'workspace' (changed only there), 'local' (only in the plan), 'conflict' (both, differently), 'both' (same edit)."""
218
+ out = []
219
+ def one(field, key, b, w, l):
220
+ if _same(field, w, l) and _same(field, b, w):
221
+ return
222
+ side = ("both" if _same(field, w, l) else "workspace" if _same(field, l, b) else
223
+ "local" if _same(field, w, b) else "conflict")
224
+ out.append((field, key, side, b, w, l))
225
+ bp, wp, lp = base.get("parameters") or {}, ws["parameters"], local["parameters"]
226
+ for k in list(dict.fromkeys([*wp, *bp, *lp])):
227
+ l = lp.get(k, MISSING)
228
+ if k not in lp and not mirror and k not in cleared:
229
+ l = wp.get(k, MISSING) # a linked BOM item lists only what it cares about
230
+ one("parameters", k, bp.get(k, MISSING), wp.get(k, MISSING), l)
231
+ for f in ("comment", "description", "folder", "symbol", "footprint") if mirror else ():
232
+ if f in base:
233
+ one(f, "", base[f], ws[f], local[f])
234
+ bm = [base.get("symbols"), base.get("footprints")]
235
+ wm = [ws["symbols"], ws["footprints"]]
236
+ if mirror and bm != [None, None] and bm != wm: # a linked plan item keeps its own model files
237
+ out.append(("models", "", "workspace", bm, wm, bm))
238
+ return out
239
+
240
+
241
+ def _set_param(it, k, v, rules=None):
242
+ """Set workspace parameter k (workspace name) on the item, under the name the item uses for it."""
243
+ ps = it.setdefault("parameters", [])
244
+ cur = next((p for p in ps if _hname(it, p["name"], rules) == k), None)
245
+ if v is MISSING:
246
+ if cur:
247
+ ps.remove(cur)
248
+ if not is_a365(it) and not any(_hname(it, c, rules) == k for c in it.setdefault("cleared", [])):
249
+ it["cleared"].append(_cname(k, rules)) # linked item: make push drop it too
250
+ elif cur:
251
+ cur["value"] = v
252
+ else:
253
+ ps.append({"name": k if is_a365(it) else _cname(k, rules), "value": v})
254
+ it["cleared"] = [c for c in it.get("cleared") or [] if _hname(it, c, rules) != k]
255
+
256
+
257
+ def _take(it, field, key, w, rules=None):
258
+ if field == "parameters":
259
+ _set_param(it, key, w, rules)
260
+ elif field in ("symbol", "footprint"):
261
+ it.setdefault(field, {})["ref"] = w
262
+ it[field]["file"] = None # a different model now: fetch again
263
+ if isinstance(it.get("source"), dict) and is_a365(it):
264
+ it["source"][field] = f"a365:{w}" if w else None
265
+ elif field == "models":
266
+ for k in ("symbol", "footprint"): # new symbol/footprint revision in the workspace: fetch again
267
+ if isinstance(it.get(k), dict):
268
+ it[k]["file"] = None
269
+ else:
270
+ it[field] = w
271
+
272
+
273
+ def _show(v):
274
+ return "(absent)" if v is MISSING else v if isinstance(v, str) else json.dumps(v)
275
+
276
+
277
+ def sync(items, nodes, state=None, prefer=None, apply=False, import_new=True, folders=None, ws="", rules=None, now=None):
278
+ """Compare plan items with workspace nodes. Returns (items, rows, new_state, fetch, {name: status}).
279
+ prefer: None leaves conflicts as they are (pull, status), 'workspace' or 'local' settles them (reconcile).
280
+ apply=False changes nothing (status). fetch: [(item, node)] whose models should be downloaded."""
281
+ now = now or datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0).isoformat()
282
+ items = copy.deepcopy(items) if apply else items
283
+ state = state or {}
284
+ by_name, by_id = {}, {}
285
+ for n in nodes:
286
+ by_name.setdefault(n["name"], n); by_id.setdefault(n.get("id"), n)
287
+ watch = [_norm(f).lower() for f in folders or []]
288
+ inside = lambda f: not watch or any(_norm(f).lower() == w or _norm(f).lower().startswith(w + "/") for w in watch)
289
+ rows, fetch, seen, keep, new_state, status_of = [], [], set(), [], {}, {}
290
+ def row(name, status, field="", plan="", wsv="", base="", detail=""):
291
+ rows.append({"name": name, "status": status, "field": field, "plan": plan, "workspace": wsv, "base": base, "detail": detail})
292
+
293
+ for it in items:
294
+ link = it.get("a365")
295
+ node = by_name.get(it["name"]) or (by_id.get(link.get("id")) if link else None)
296
+ if not node:
297
+ if link and not link.get("mirror", is_a365(it)):
298
+ row(it["name"], "deleted_in_workspace", detail="linked BOM part no longer in the workspace"
299
+ + ("; link dropped, push releases it again" if apply else ""))
300
+ status_of[it["name"]] = "deleted_in_workspace"
301
+ if apply:
302
+ it.pop("a365")
303
+ elif link:
304
+ row(it["name"], "deleted_in_workspace", detail="was revision " + str(link.get("revision")))
305
+ status_of[it["name"]] = "deleted_in_workspace"
306
+ if apply and prefer == "workspace":
307
+ continue # reconcile: workspace wins, the item goes (reported above)
308
+ if apply and it.get("status") != "deleted_in_workspace":
309
+ link["deleted"], link["prev_status"] = now, it.get("status")
310
+ it["status"] = "deleted_in_workspace"
311
+ else:
312
+ row(it["name"], "new_local", detail=f"plan status {it.get('status', '')}")
313
+ status_of[it["name"]] = "new_local"
314
+ keep.append(it)
315
+ continue
316
+ seen.add(node["name"])
317
+ snap = snapshot(node)
318
+ new_state[node["name"]] = {**snap, "in_plan": True}
319
+ if not link:
320
+ diffs = three_way({}, snap, local_fields(it, rules), mirror=False,
321
+ cleared=[_hname(it, c, rules) for c in it.get("cleared") or ()])
322
+ row(it["name"], "linked", detail="in the workspace already: push updates it instead of releasing it"
323
+ + (f"; {sum(d[0] == 'parameters' for d in diffs)} parameter(s) differ" if diffs else ""))
324
+ for f, k, side, b, w, l in diffs:
325
+ if f == "parameters" and w != l:
326
+ row(it["name"], "linked", f"parameter {k}", _show(l), _show(w), "", "push sends the plan value")
327
+ status_of[it["name"]] = "linked"
328
+ if apply:
329
+ it["a365"] = _meta(snap, ws, now, False)
330
+ keep.append(it)
331
+ continue
332
+ base = link.get("base") or {k: v for k, v in (state.get(it["name"]) or {}).items()
333
+ if k in ("parameters", "comment", "description", "folder", "symbols", "footprints")}
334
+ if "parameters" not in base:
335
+ base = {**snap, **base} # no parameter snapshot: take the workspace as the base
336
+ if node["name"] != it["name"]:
337
+ row(it["name"], "renamed_in_workspace", "name", it["name"], node["name"], detail="found by id"
338
+ + ("; renamed in the plan" if apply and is_a365(it) else ""))
339
+ if apply and is_a365(it):
340
+ it["name"] = node["name"]
341
+ mirror = link.get("mirror", is_a365(it))
342
+ diffs = three_way(base, snap, local_fields(it, rules), mirror, [_hname(it, c, rules) for c in it.get("cleared") or ()])
343
+ sides = {d[2] for d in diffs}
344
+ open_conf = []
345
+ for f, k, side, b, w, l in diffs:
346
+ label = f"parameter {k}" if f == "parameters" else f
347
+ if side == "workspace":
348
+ row(it["name"], "changed_in_workspace", label, _show(l), _show(w), _show(b), "taken from the workspace" if apply else "")
349
+ if apply:
350
+ _take(it, f, k, w, rules)
351
+ elif side == "local":
352
+ why = "push sends it" if f in PUSHABLE else "not pushable: Nexar has no mutation for it, change it in Altium"
353
+ row(it["name"], "changed_locally", label, _show(l), _show(w), _show(b), why)
354
+ elif side == "conflict":
355
+ if prefer == "workspace":
356
+ row(it["name"], "conflict", label, _show(l), _show(w), _show(b),
357
+ "workspace wins" + ("; plan value kept in a365.overwritten" if apply else ""))
358
+ if apply:
359
+ link.setdefault("overwritten", []).append({"field": label, "plan": l, "workspace": w, "at": now})
360
+ _take(it, f, k, w, rules)
361
+ elif prefer == "local":
362
+ row(it["name"], "conflict", label, _show(l), _show(w), _show(b),
363
+ "plan wins: push sends it" if f in PUSHABLE else "plan kept; not pushable, change it in Altium")
364
+ else:
365
+ open_conf.append({"field": label, "plan": l, "workspace": w, "base": b})
366
+ row(it["name"], "conflict", label, _show(l), _show(w), _show(b),
367
+ "edited in both; nothing changed: run `a365 reconcile` [--prefer local]")
368
+ eff = sides - {"both"}
369
+ status = ("in_sync" if not eff else "conflict" if "conflict" in eff else "changed_in_workspace" if eff == {"workspace"}
370
+ else "changed_locally" if eff == {"local"} else "merged")
371
+ status_of[it["name"]] = status
372
+ if status == "in_sync":
373
+ row(it["name"], "in_sync")
374
+ elif status == "merged":
375
+ row(it["name"], "merged", detail="workspace and plan changed different fields: both kept")
376
+ if apply:
377
+ if open_conf: # pull: leave base and revision so push refuses until settled
378
+ link["conflicts"] = open_conf
379
+ else:
380
+ it["a365"] = _meta(snap, ws, now, mirror, link)
381
+ if it.get("status") == "deleted_in_workspace":
382
+ it["status"] = it["a365"].pop("prev_status", None) or ("existing" if is_a365(it) else "ready")
383
+ it["a365"].pop("deleted", None)
384
+ elif is_a365(it) and it.get("status") != "duplicate":
385
+ it["status"] = "existing"
386
+ if is_a365(it) and (any(d[0] in ("models", "symbol", "footprint") and d[2] in ("workspace", "conflict") for d in diffs)
387
+ or not (it.get("symbol") or {}).get("file") or not (it.get("footprint") or {}).get("file")):
388
+ fetch.append((it, node))
389
+ keep.append(it)
390
+
391
+ planned = {it["name"] for it in keep}
392
+ for n in nodes:
393
+ if n["name"] in seen or n["name"] in planned:
394
+ continue
395
+ snap = snapshot(n)
396
+ was = (state.get(n["name"]) or {}).get("in_plan")
397
+ new_state[n["name"]] = {**snap, "in_plan": False}
398
+ if not inside(snap["folder"]):
399
+ continue
400
+ status_of[n["name"]] = "deleted_locally" if was else "new_in_workspace"
401
+ if was:
402
+ row(n["name"], "deleted_locally", "folder", "", snap["folder"], detail="removed from the plan; still in the workspace"
403
+ + ("; imported again (workspace wins)" if apply and prefer == "workspace" else ""))
404
+ if not (apply and prefer == "workspace"):
405
+ continue
406
+ else:
407
+ row(n["name"], "new_in_workspace", "folder", "", snap["folder"], detail="imported" if apply and import_new else "")
408
+ if apply and (import_new or was):
409
+ it = item_from_node(n, ws, now, rules)
410
+ keep.append(it); fetch.append((it, n))
411
+ new_state[n["name"]]["in_plan"] = True
412
+ return (keep if apply else items), rows, new_state, fetch, status_of
413
+
414
+
415
+ # ---------------------------------------------------------------- model files
416
+ def _unpack(data, ext):
417
+ """Raw download -> the library file's bytes (an OLE .SchLib/.PcbLib, or the first one inside a zip)."""
418
+ if data[:8] == b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1":
419
+ return data
420
+ if data[:2] == b"PK":
421
+ with zipfile.ZipFile(io.BytesIO(data)) as z:
422
+ name = next((n for n in z.namelist() if n.lower().endswith(ext.lower())), None)
423
+ if name:
424
+ return z.read(name)
425
+ raise ValueError(f"zip without a {ext} ({', '.join(z.namelist()[:5])})")
426
+ raise ValueError(f"not an Altium library file (starts {data[:8]!r})")
427
+
428
+
429
+ def fetch_models(item, node, base, client, log=print):
430
+ """Download the component's first symbol and footprint into <base>/a365_models/{SchLib,PcbLib}/ and read them
431
+ back with altium_lib. Sets item.symbol/footprint.file (relative to base); errors go to item.a365.model_errors."""
432
+ from . import altium_lib as AL
433
+ d = node.get("details") or {}
434
+ errs = []
435
+ for kind, key, ext, reader in (("symbol", "symbols", ".SchLib", lambda p: [s["name"] for s in AL.read_schlib(p)[1]]),
436
+ ("footprint", "footprints", ".PcbLib", lambda p: [f["name"] for f in AL.read_pcblib(p)])):
437
+ ms = d.get(key) or []
438
+ if not ms:
439
+ continue
440
+ m = ms[0]
441
+ have = (item.get(kind) or {}).get("file")
442
+ if have and (Path(base) / have).exists():
443
+ continue
444
+ if not m.get("dataDownloadUrl"):
445
+ errs.append(f"{kind} {m['name']}: no dataDownloadUrl"); continue
446
+ try: # a name cp1252 can't hold (Altium 365 allows it) still gets a file name
447
+ stem = AL.file_stem(m["name"])
448
+ except ValueError:
449
+ stem = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", m["name"]).rstrip(". ") or "_"
450
+ f = Path(base) / "a365_models" / ext[1:] / f"{stem}{ext}"
451
+ try:
452
+ data = _unpack(client.download(m["dataDownloadUrl"]), ext)
453
+ f.parent.mkdir(parents=True, exist_ok=True)
454
+ tmp = f.with_suffix(f.suffix + ".part")
455
+ tmp.write_bytes(data)
456
+ names = reader(tmp)
457
+ if m["name"] not in names and len(names) != 1:
458
+ raise ValueError(f"{ext} holds {', '.join(names[:5]) or 'nothing'}, not {m['name']}")
459
+ os.replace(tmp, f)
460
+ item[kind]["file"] = os.path.relpath(f, Path(base)).replace(os.sep, "/")
461
+ item[kind]["entries"] = names
462
+ except Exception as e:
463
+ errs.append(f"{kind} {m['name']}: {type(e).__name__}: {e}")
464
+ with_tmp = f.with_suffix(f.suffix + ".part")
465
+ if with_tmp.exists():
466
+ with_tmp.unlink()
467
+ item.setdefault("a365", {})["model_errors"] = errs
468
+ if not errs:
469
+ item["a365"].pop("model_errors")
470
+ for e in errs:
471
+ log(f" models {item['name']}: {e}")
472
+ return errs
473
+
474
+
475
+ # ---------------------------------------------------------------- run / write-back / CLI
476
+ def load_plan(path):
477
+ p = Path(path)
478
+ data = json.loads(p.read_text()) if p.exists() else []
479
+ if not isinstance(data, list):
480
+ raise ValueError(f"{p}: expected a list of plan items")
481
+ return data
482
+
483
+
484
+ def default_state(plan):
485
+ return Path(plan).parent / "a365_state.json"
486
+
487
+
488
+ COLS = ["name", "status", "field", "plan", "workspace", "base", "detail"]
489
+
490
+
491
+ def run(mode, plan, client, ws, state=None, prefer="workspace", folders=None, models=True, rules=None, out=None,
492
+ nodes=None, log=print):
493
+ """mode: pull | status | reconcile. Returns {rows, summary, items, components, report, plan, state}."""
494
+ from .upstream import write_report
495
+ if mode not in ("pull", "status", "reconcile"):
496
+ raise ValueError(mode)
497
+ plan = Path(plan)
498
+ state_f = Path(state) if state else default_state(plan)
499
+ items = load_plan(plan)
500
+ st = json.loads(state_f.read_text()) if state_f.exists() else {}
501
+ nodes = fetch_components(client, ws) if nodes is None else nodes
502
+ apply = mode != "status"
503
+ new, rows, new_state, fetch, status_of = sync(items, nodes, st, prefer=prefer if mode == "reconcile" else None, apply=apply,
504
+ folders=folders, ws=ws, rules=rules)
505
+ if apply:
506
+ if models:
507
+ for it, n in fetch:
508
+ fetch_models(it, n, plan.parent, client, log)
509
+ plan.parent.mkdir(parents=True, exist_ok=True)
510
+ if plan.exists() and new != items:
511
+ bak = plan.with_name(plan.name + ".pre-a365")
512
+ bak.write_text(plan.read_text())
513
+ plan.write_text(json.dumps(new, indent=2))
514
+ state_f.parent.mkdir(parents=True, exist_ok=True)
515
+ state_f.write_text(json.dumps(new_state, indent=1))
516
+ summary = {}
517
+ for v in status_of.values():
518
+ summary[v] = summary.get(v, 0) + 1
519
+ stem = Path(out) if out else plan.parent / "a365_sync"
520
+ meta = [f"`{mode}` of plan `{plan}` against `{ws}`, {datetime.datetime.now(datetime.timezone.utc):%Y-%m-%d %H:%M} UTC.", "",
521
+ "Components: " + (", ".join(f"{v} {k}" for k, v in sorted(summary.items())) or "none")]
522
+ shown = [r for r in rows if r["status"] != "in_sync"]
523
+ write_report(shown, stem, "Altium 365 sync", COLS, meta)
524
+ return {"rows": rows, "summary": summary, "items": new if apply else items,
525
+ "components": [component(n, rules) for n in nodes], "report": stem.with_suffix(".md"),
526
+ "plan": plan, "state": state_f, "conflicts": sum(r["status"] == "conflict" for r in rows),
527
+ "unresolved": sum(r["status"] == "conflict" and "nothing changed" in r["detail"] for r in rows)}
528
+
529
+
530
+ def save_meta(plan, items):
531
+ """After a push: write each item's updated `a365` block (new revision / id / base) back into plan.json,
532
+ leaving everything else in the file as it is."""
533
+ plan = Path(plan)
534
+ data = load_plan(plan)
535
+ by = {i["name"]: i for i in items if i.get("a365")}
536
+ changed = False
537
+ for d in data:
538
+ it = by.get(d["name"])
539
+ if it and d.get("a365") != it["a365"]:
540
+ d["a365"] = it["a365"]; changed = True
541
+ if changed:
542
+ plan.write_text(json.dumps(data, indent=2))
543
+ return changed
544
+
545
+
546
+ def reuse_existing(items, old_items, rules=None):
547
+ """Librarian hook: a BOM part that is already a workspace component (same name or MPN as a pulled item in the
548
+ previous plan) is replaced by that item, so nothing is fetched for it. Pulled items the BOM does not use are
549
+ kept at the end of the plan; a BOM part `a365 pull` linked keeps its link. Returns (items, {name of reused item})."""
550
+ from .kicad_index import norm_mpn
551
+ linked = {i["name"]: i["a365"] for i in old_items if i.get("a365") and not is_a365(i)}
552
+ if linked: # keep the link (and its snapshot): push then refuses to overwrite
553
+ items = [dict(it, a365=copy.deepcopy(linked[it["name"]])) # workspace edits made since the last pull
554
+ if it["name"] in linked and not it.get("a365") else it for it in items]
555
+ pulled = [i for i in old_items if is_a365(i)]
556
+ if not pulled:
557
+ return items, set()
558
+ by_name = {i["name"]: i for i in pulled}
559
+ by_mpn = {}
560
+ for i in pulled:
561
+ by_mpn.setdefault(norm_mpn(mpn_of(i, rules)), i)
562
+ out, used = [], set()
563
+ for it in items:
564
+ hit = by_name.get(it["name"]) or by_mpn.get(norm_mpn(mpn_of(it, rules)) or "\0")
565
+ if hit and hit["name"] not in used:
566
+ got = copy.deepcopy(hit)
567
+ got["librarian"] = {"tried": [], "review": [], "notes": [f"in the Altium 365 workspace: reused for BOM part {it['name']}, not fetched"]}
568
+ out.append(got); used.add(hit["name"])
569
+ elif hit:
570
+ continue # second BOM part number for the same workspace component
571
+ else:
572
+ out.append(it)
573
+ for i in pulled:
574
+ if i["name"] not in used:
575
+ got = copy.deepcopy(i)
576
+ got.setdefault("librarian", {"tried": [], "review": [], "notes": ["in the Altium 365 workspace; not in this BOM"]})
577
+ out.append(got)
578
+ return out, used | {i["name"] for i in pulled}
579
+
580
+
581
+ def _load_rules(path, start="."):
582
+ if RU is not None and hasattr(RU, "load"):
583
+ try:
584
+ return RU.load(path, start=start) # found from the plan's folder, as `altium365 push` does
585
+ except Exception as e:
586
+ print(f"a365: rules not loaded ({type(e).__name__}: {e}); using defaults", file=sys.stderr)
587
+ elif path:
588
+ return json.loads(Path(path).read_text())
589
+ return None
590
+
591
+
592
+ def main(argv=None):
593
+ ap = argparse.ArgumentParser(prog="ecadlib a365", description="Two-way sync between a plan.json and an Altium 365 workspace")
594
+ sub = ap.add_subparsers(dest="cmd", required=True)
595
+ for cmd, h in (("pull", "import workspace components into the plan; conflicts are reported, not settled"),
596
+ ("status", "compare plan and workspace; changes nothing"),
597
+ ("reconcile", "apply workspace changes and settle conflicts (--prefer)")):
598
+ p = sub.add_parser(cmd, help=h)
599
+ p.add_argument("plan")
600
+ p.add_argument("--workspace", help="workspace URL (default: rules [altium365] workspace)")
601
+ p.add_argument("--state", help="snapshot file (default <plan dir>/a365_state.json)")
602
+ p.add_argument("--folder", action="append", help="import only components under this workspace folder (repeatable)")
603
+ p.add_argument("--rules", help="ecadlib.toml / rules JSON")
604
+ p.add_argument("-o", "--out", help="report stem (default <plan dir>/a365_sync)")
605
+ if cmd != "status":
606
+ p.add_argument("--no-models", action="store_true", help="do not download .SchLib/.PcbLib files")
607
+ if cmd == "reconcile":
608
+ p.add_argument("--prefer", choices=("workspace", "local"), default="workspace",
609
+ help="who wins a conflict (default: workspace)")
610
+ a = ap.parse_args(argv)
611
+ rules = _load_rules(a.rules, Path(a.plan).parent)
612
+ ws = a.workspace or ((rules or {}).get("altium365") or {}).get("workspace")
613
+ if not ws:
614
+ print("a365: give --workspace URL (see `ecadlib altium365 workspaces`)", file=sys.stderr); return 2
615
+ token = os.environ.get("NEXAR_TOKEN")
616
+ if not token:
617
+ print("a365: set NEXAR_TOKEN (design.domain, user.access, openid)", file=sys.stderr); return 2
618
+ from . import altium365 as A
619
+ try:
620
+ r = run(a.cmd, a.plan, A.Nexar(token), ws, a.state, getattr(a, "prefer", "workspace"), a.folder,
621
+ not getattr(a, "no_models", False), rules, a.out)
622
+ except Exception as e:
623
+ hint = " (token rejected: check it has design.domain, user.access, openid)" if "401" in str(e) else ""
624
+ print(f"a365 {a.cmd}: {type(e).__name__}: {e}{hint}", file=sys.stderr); return 2
625
+ for x in r["rows"]:
626
+ if x["status"] != "in_sync":
627
+ print(f" {x['status']:<21} {x['name']:<28} {x['field']:<26} plan={x['plan']!r} workspace={x['workspace']!r}"
628
+ + (f" ({x['detail']})" if x["detail"] else ""))
629
+ print(", ".join(f"{v} {k}" for k, v in sorted(r["summary"].items())) or "nothing in the workspace or the plan",
630
+ f"-> {r['report']}" + (f"; plan {r['plan']}, state {r['state']}" if a.cmd != "status" else ""))
631
+ if a.cmd == "status":
632
+ return 1 if any(x["status"] != "in_sync" for x in r["rows"]) else 0
633
+ return 1 if r["unresolved"] else 0
634
+
635
+
636
+ if __name__ == "__main__":
637
+ sys.exit(main())