eda-agent 0.8.1__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 (396) hide show
  1. eda_agent/__init__.py +5 -0
  2. eda_agent/__main__.py +10 -0
  3. eda_agent/altium365.py +534 -0
  4. eda_agent/atomicfile.py +93 -0
  5. eda_agent/bridge/__init__.py +42 -0
  6. eda_agent/bridge/altium_bridge.py +1929 -0
  7. eda_agent/bridge/bulk.py +161 -0
  8. eda_agent/bridge/easyeda_bridge.py +833 -0
  9. eda_agent/bridge/easyeda_expected.py +144 -0
  10. eda_agent/bridge/easyeda_verified.py +186 -0
  11. eda_agent/bridge/exceptions.py +252 -0
  12. eda_agent/bridge/fault_state.py +55 -0
  13. eda_agent/bridge/kicad_api.py +1517 -0
  14. eda_agent/bridge/kicad_bridge.py +464 -0
  15. eda_agent/bridge/kicad_ipc.py +254 -0
  16. eda_agent/bridge/kicad_wire.py +214 -0
  17. eda_agent/bridge/path_spelling.py +72 -0
  18. eda_agent/bridge/payload.py +89 -0
  19. eda_agent/bridge/process_manager.py +472 -0
  20. eda_agent/bridge/recovery.py +100 -0
  21. eda_agent/bridge/script_crash.py +362 -0
  22. eda_agent/bridge/script_launcher.py +59 -0
  23. eda_agent/bridge/websocket.py +247 -0
  24. eda_agent/checkpoint.py +316 -0
  25. eda_agent/cli.py +781 -0
  26. eda_agent/config.py +394 -0
  27. eda_agent/convert/__init__.py +3 -0
  28. eda_agent/convert/altium_kicad/__init__.py +23 -0
  29. eda_agent/convert/altium_kicad/altium_side.py +441 -0
  30. eda_agent/convert/altium_kicad/board_pass.py +312 -0
  31. eda_agent/convert/altium_kicad/checks.py +71 -0
  32. eda_agent/convert/altium_kicad/footprints.py +290 -0
  33. eda_agent/convert/altium_kicad/hierarchy.py +161 -0
  34. eda_agent/convert/altium_kicad/importer.py +303 -0
  35. eda_agent/convert/altium_kicad/pipeline.py +392 -0
  36. eda_agent/convert/altium_kicad/schematic.py +570 -0
  37. eda_agent/convert/altium_kicad/tree.py +200 -0
  38. eda_agent/convert/altium_kicad/variants.py +159 -0
  39. eda_agent/core/__init__.py +22 -0
  40. eda_agent/core/backends.py +399 -0
  41. eda_agent/core/eco_verify.py +134 -0
  42. eda_agent/core/kicad_drc.py +186 -0
  43. eda_agent/core/kicad_export.py +83 -0
  44. eda_agent/core/kicad_footprint.py +453 -0
  45. eda_agent/core/kicad_libtables.py +143 -0
  46. eda_agent/core/kicad_netlist.py +156 -0
  47. eda_agent/core/kicad_pcb.py +160 -0
  48. eda_agent/core/kicad_sch_doc.py +309 -0
  49. eda_agent/core/kicad_schematic.py +166 -0
  50. eda_agent/core/kicad_sexpr_edit.py +181 -0
  51. eda_agent/core/kicad_symbol.py +160 -0
  52. eda_agent/core/net_naming.py +66 -0
  53. eda_agent/core/placement_order.py +54 -0
  54. eda_agent/core/review_engine.py +261 -0
  55. eda_agent/core/snapshot.py +167 -0
  56. eda_agent/design/__init__.py +45 -0
  57. eda_agent/design/_wiring.py +342 -0
  58. eda_agent/design/audit.py +689 -0
  59. eda_agent/design/autonomy.py +692 -0
  60. eda_agent/design/bom.py +90 -0
  61. eda_agent/design/calculators.py +278 -0
  62. eda_agent/design/company_rules.py +302 -0
  63. eda_agent/design/component_values.py +624 -0
  64. eda_agent/design/datasheet_fetch.py +242 -0
  65. eda_agent/design/datasheet_render.py +114 -0
  66. eda_agent/design/datasheet_search.py +82 -0
  67. eda_agent/design/designator_sizing.py +113 -0
  68. eda_agent/design/diffpairs.py +210 -0
  69. eda_agent/design/discipline.py +996 -0
  70. eda_agent/design/easyeda_emitter.py +335 -0
  71. eda_agent/design/fab_check.py +196 -0
  72. eda_agent/design/fab_data/advancedpcb.json +105 -0
  73. eda_agent/design/fab_data/aisler.json +89 -0
  74. eda_agent/design/fab_data/allpcb.json +81 -0
  75. eda_agent/design/fab_data/bayareacircuits.json +118 -0
  76. eda_agent/design/fab_data/epec.json +87 -0
  77. eda_agent/design/fab_data/eurocircuits.json +155 -0
  78. eda_agent/design/fab_data/expresspcb.json +68 -0
  79. eda_agent/design/fab_data/jlcpcb.json +100 -0
  80. eda_agent/design/fab_data/jlcpcb_stackups.json +2513 -0
  81. eda_agent/design/fab_data/laminates.json +74 -0
  82. eda_agent/design/fab_data/multicb.json +220 -0
  83. eda_agent/design/fab_data/nextpcb.json +125 -0
  84. eda_agent/design/fab_data/oshpark.json +145 -0
  85. eda_agent/design/fab_data/pcbcart.json +76 -0
  86. eda_agent/design/fab_data/pcbuniverse.json +81 -0
  87. eda_agent/design/fab_data/pcbway.json +101 -0
  88. eda_agent/design/fab_data/sierra.json +91 -0
  89. eda_agent/design/fab_library.py +461 -0
  90. eda_agent/design/fab_profile.py +207 -0
  91. eda_agent/design/footprint_audit.py +579 -0
  92. eda_agent/design/footprint_gen.py +566 -0
  93. eda_agent/design/footprint_policy.py +868 -0
  94. eda_agent/design/footprints.py +180 -0
  95. eda_agent/design/hierarchy.py +516 -0
  96. eda_agent/design/hsd_rules.py +147 -0
  97. eda_agent/design/illustrate.py +90 -0
  98. eda_agent/design/impedance_sizing.py +198 -0
  99. eda_agent/design/inventory.py +221 -0
  100. eda_agent/design/ipc_templates.py +151 -0
  101. eda_agent/design/jobs.py +182 -0
  102. eda_agent/design/landpattern/__init__.py +67 -0
  103. eda_agent/design/landpattern/audit.py +961 -0
  104. eda_agent/design/landpattern/builder.py +416 -0
  105. eda_agent/design/landpattern/check.py +820 -0
  106. eda_agent/design/landpattern/compare.py +339 -0
  107. eda_agent/design/landpattern/compare_figure.py +299 -0
  108. eda_agent/design/landpattern/drafting.py +489 -0
  109. eda_agent/design/landpattern/drawing_dims.py +233 -0
  110. eda_agent/design/landpattern/emit.py +736 -0
  111. eda_agent/design/landpattern/ipc7351.py +688 -0
  112. eda_agent/design/landpattern/land_source.py +65 -0
  113. eda_agent/design/landpattern/live.py +289 -0
  114. eda_agent/design/landpattern/model.py +293 -0
  115. eda_agent/design/landpattern/smd.py +672 -0
  116. eda_agent/design/landpattern/spec.py +538 -0
  117. eda_agent/design/landpattern/svg.py +296 -0
  118. eda_agent/design/landpattern/tht.py +317 -0
  119. eda_agent/design/layer_stack_plan.py +294 -0
  120. eda_agent/design/length_matching.py +264 -0
  121. eda_agent/design/library_conventions.py +277 -0
  122. eda_agent/design/live.py +612 -0
  123. eda_agent/design/loops.py +578 -0
  124. eda_agent/design/mixed_signal.py +110 -0
  125. eda_agent/design/model_drawing.py +136 -0
  126. eda_agent/design/models3d.py +899 -0
  127. eda_agent/design/motif_descriptions.py +134 -0
  128. eda_agent/design/motifs.py +1162 -0
  129. eda_agent/design/net_classes.py +70 -0
  130. eda_agent/design/partition.py +192 -0
  131. eda_agent/design/pcb_placement.py +3037 -0
  132. eda_agent/design/phase_gates.py +416 -0
  133. eda_agent/design/pin_pad_parity.py +74 -0
  134. eda_agent/design/pin_table.py +161 -0
  135. eda_agent/design/placement_constraints.py +74 -0
  136. eda_agent/design/plan.py +433 -0
  137. eda_agent/design/plan_blocks.py +929 -0
  138. eda_agent/design/plan_compare.py +58 -0
  139. eda_agent/design/plan_edit.py +287 -0
  140. eda_agent/design/plan_erc.py +227 -0
  141. eda_agent/design/plan_stats.py +82 -0
  142. eda_agent/design/policy_targets.py +272 -0
  143. eda_agent/design/port_reference_side.py +187 -0
  144. eda_agent/design/report.py +378 -0
  145. eda_agent/design/requirement.py +578 -0
  146. eda_agent/design/rule_synthesis.py +364 -0
  147. eda_agent/design/sch_template.py +813 -0
  148. eda_agent/design/session.py +283 -0
  149. eda_agent/design/sheet_fit.py +303 -0
  150. eda_agent/design/sheet_plan.py +467 -0
  151. eda_agent/design/signal_integrity.py +358 -0
  152. eda_agent/design/state_machine.py +241 -0
  153. eda_agent/design/step_worker.py +77 -0
  154. eda_agent/design/symbol_gen.py +661 -0
  155. eda_agent/design/thermal_vias.py +204 -0
  156. eda_agent/design/trace_sizing.py +147 -0
  157. eda_agent/design/validator.py +271 -0
  158. eda_agent/design/value_checks.py +89 -0
  159. eda_agent/design/value_parser.py +104 -0
  160. eda_agent/design/visual_metrics.py +203 -0
  161. eda_agent/devproxy.py +331 -0
  162. eda_agent/diag/__init__.py +13 -0
  163. eda_agent/diag/checks.py +86 -0
  164. eda_agent/diag/doctor.py +264 -0
  165. eda_agent/diag/health.py +417 -0
  166. eda_agent/diag/sdk_reach.py +179 -0
  167. eda_agent/easyeda_extension/BUILD_STAMP.json +4 -0
  168. eda_agent/easyeda_extension/README.md +207 -0
  169. eda_agent/easyeda_extension/build.py +227 -0
  170. eda_agent/easyeda_extension/capabilities.json +1075 -0
  171. eda_agent/easyeda_extension/extension.json +188 -0
  172. eda_agent/easyeda_extension/iframe_template.html +62 -0
  173. eda_agent/easyeda_extension/main.js +4753 -0
  174. eda_agent/easyeda_extension.py +83 -0
  175. eda_agent/export/__init__.py +9 -0
  176. eda_agent/export/drc_report.py +78 -0
  177. eda_agent/export/drc_rules.py +360 -0
  178. eda_agent/export/kicad_footprint.py +137 -0
  179. eda_agent/export/library_report.py +96 -0
  180. eda_agent/export/outjob_outputs.py +29 -0
  181. eda_agent/export/stackup_csv.py +118 -0
  182. eda_agent/export/variant_bom.py +89 -0
  183. eda_agent/export/variant_matrix_csv.py +48 -0
  184. eda_agent/fileio/__init__.py +39 -0
  185. eda_agent/fileio/altium_project.py +90 -0
  186. eda_agent/fileio/altium_sch.py +403 -0
  187. eda_agent/fileio/altium_schlib.py +69 -0
  188. eda_agent/fileio/bom.py +131 -0
  189. eda_agent/fileio/netlist_solver.py +280 -0
  190. eda_agent/fileio/pcblib_models.py +229 -0
  191. eda_agent/fileio/review.py +438 -0
  192. eda_agent/geometry_map.py +752 -0
  193. eda_agent/housekeeping.py +117 -0
  194. eda_agent/layout/__init__.py +12 -0
  195. eda_agent/layout/audit.py +799 -0
  196. eda_agent/layout/bench.py +515 -0
  197. eda_agent/layout/bench_run.py +697 -0
  198. eda_agent/layout/copper_path.py +202 -0
  199. eda_agent/layout/drc.py +701 -0
  200. eda_agent/layout/geom.py +469 -0
  201. eda_agent/layout/loop_audit.py +183 -0
  202. eda_agent/layout/model.py +346 -0
  203. eda_agent/layout/pad_entry.py +219 -0
  204. eda_agent/layout/pad_relief.py +254 -0
  205. eda_agent/layout/place/__init__.py +7 -0
  206. eda_agent/layout/place/blocks.py +480 -0
  207. eda_agent/layout/place/compose.py +504 -0
  208. eda_agent/layout/place/floorplan.py +978 -0
  209. eda_agent/layout/place/job.py +132 -0
  210. eda_agent/layout/place/placer.py +917 -0
  211. eda_agent/layout/place/transform.py +112 -0
  212. eda_agent/layout/read_altium.py +636 -0
  213. eda_agent/layout/rotation_audit.py +161 -0
  214. eda_agent/layout/route/__init__.py +8 -0
  215. eda_agent/layout/route/diffpair.py +431 -0
  216. eda_agent/layout/route/exact.py +186 -0
  217. eda_agent/layout/route/fanout.py +484 -0
  218. eda_agent/layout/route/global_route.py +329 -0
  219. eda_agent/layout/route/gloss.py +501 -0
  220. eda_agent/layout/route/grid.py +367 -0
  221. eda_agent/layout/route/job.py +205 -0
  222. eda_agent/layout/route/lanes.py +829 -0
  223. eda_agent/layout/route/params.py +243 -0
  224. eda_agent/layout/route/pour.py +354 -0
  225. eda_agent/layout/route/router.py +3246 -0
  226. eda_agent/layout/route/search.py +175 -0
  227. eda_agent/layout/route/stages.py +615 -0
  228. eda_agent/layout/rules.py +400 -0
  229. eda_agent/layout/smart_drc.py +140 -0
  230. eda_agent/libimport/__init__.py +17 -0
  231. eda_agent/libimport/_names.py +53 -0
  232. eda_agent/libimport/cse.py +265 -0
  233. eda_agent/libimport/easyeda/__init__.py +48 -0
  234. eda_agent/libimport/easyeda/altium.py +745 -0
  235. eda_agent/libimport/easyeda/document.py +349 -0
  236. eda_agent/libimport/easyeda/fetch.py +221 -0
  237. eda_agent/libimport/easyeda/geometry.py +161 -0
  238. eda_agent/libimport/easyeda/kicad.py +545 -0
  239. eda_agent/libimport/easyeda/model3d.py +241 -0
  240. eda_agent/libimport/easyeda/shapes.py +627 -0
  241. eda_agent/libimport/kicad/__init__.py +14 -0
  242. eda_agent/libimport/kicad/reader.py +784 -0
  243. eda_agent/libimport/kicad/sexpr.py +151 -0
  244. eda_agent/libimport/providers/__init__.py +241 -0
  245. eda_agent/libimport/providers/_distributor.py +259 -0
  246. eda_agent/libimport/providers/_http.py +142 -0
  247. eda_agent/libimport/providers/altium_local.py +162 -0
  248. eda_agent/libimport/providers/base.py +145 -0
  249. eda_agent/libimport/providers/distributors.py +392 -0
  250. eda_agent/libimport/providers/easyeda.py +86 -0
  251. eda_agent/libimport/providers/kicad_local.py +460 -0
  252. eda_agent/libimport/providers/partreel.py +301 -0
  253. eda_agent/libimport/providers/public_libraries.py +280 -0
  254. eda_agent/library_db.py +109 -0
  255. eda_agent/models/__init__.py +51 -0
  256. eda_agent/models/components.py +76 -0
  257. eda_agent/models/layers.py +142 -0
  258. eda_agent/models/primitives.py +233 -0
  259. eda_agent/models/rules.py +173 -0
  260. eda_agent/noconsole.py +24 -0
  261. eda_agent/outjob.py +237 -0
  262. eda_agent/placement/__init__.py +61 -0
  263. eda_agent/placement/autoplace.py +838 -0
  264. eda_agent/preferences.py +205 -0
  265. eda_agent/prjpcb.py +182 -0
  266. eda_agent/render/__init__.py +22 -0
  267. eda_agent/render/bom_html.py +337 -0
  268. eda_agent/render/pcb_svg.py +970 -0
  269. eda_agent/render/rasterize.py +202 -0
  270. eda_agent/render/review_rubric.py +106 -0
  271. eda_agent/render/sch_svg.py +1110 -0
  272. eda_agent/route/__init__.py +39 -0
  273. eda_agent/route/model.py +654 -0
  274. eda_agent/route/repair.py +442 -0
  275. eda_agent/route/router.py +968 -0
  276. eda_agent/safety.py +333 -0
  277. eda_agent/saving.py +64 -0
  278. eda_agent/schemas/__init__.py +38 -0
  279. eda_agent/schemas/commands.py +135 -0
  280. eda_agent/schemas/envelope.py +38 -0
  281. eda_agent/schemas/registry.py +140 -0
  282. eda_agent/schemas/scope.py +19 -0
  283. eda_agent/scope.py +110 -0
  284. eda_agent/script_stamps.py +83 -0
  285. eda_agent/scripts/Altium_API.PrjScr +1273 -0
  286. eda_agent/scripts/Application.pas +1429 -0
  287. eda_agent/scripts/Audit.pas +4791 -0
  288. eda_agent/scripts/Dispatcher.pas +77 -0
  289. eda_agent/scripts/Generic.pas +19185 -0
  290. eda_agent/scripts/Library.pas +21956 -0
  291. eda_agent/scripts/Main.pas +3471 -0
  292. eda_agent/scripts/PCB.pas +31512 -0
  293. eda_agent/scripts/PCBGeneric.pas +3360 -0
  294. eda_agent/scripts/Project.pas +8397 -0
  295. eda_agent/scripts/SelfTest.pas +754 -0
  296. eda_agent/scripts/StatusForm.dfm +1970 -0
  297. eda_agent/scripts/StatusForm.pas +2911 -0
  298. eda_agent/scripts/Utils.pas +3371 -0
  299. eda_agent/server.py +1289 -0
  300. eda_agent/server_log.py +112 -0
  301. eda_agent/server_registry.py +171 -0
  302. eda_agent/sim/__init__.py +7 -0
  303. eda_agent/sim/deck.py +198 -0
  304. eda_agent/sim/deck_runner.py +321 -0
  305. eda_agent/sim/dialects.py +102 -0
  306. eda_agent/sim/model_file.py +130 -0
  307. eda_agent/sim/ngspice.py +253 -0
  308. eda_agent/sim/sdf.py +229 -0
  309. eda_agent/sim/simcfg.py +404 -0
  310. eda_agent/stdin_handle.py +34 -0
  311. eda_agent/tools/__init__.py +418 -0
  312. eda_agent/tools/altium365.py +214 -0
  313. eda_agent/tools/answering.py +56 -0
  314. eda_agent/tools/application.py +1672 -0
  315. eda_agent/tools/audit.py +1222 -0
  316. eda_agent/tools/bulk.py +238 -0
  317. eda_agent/tools/bulk_hints.py +148 -0
  318. eda_agent/tools/calc.py +543 -0
  319. eda_agent/tools/conventions.py +503 -0
  320. eda_agent/tools/convert.py +97 -0
  321. eda_agent/tools/datasheet_hints.py +247 -0
  322. eda_agent/tools/design.py +2966 -0
  323. eda_agent/tools/easyeda.py +13396 -0
  324. eda_agent/tools/eda.py +133 -0
  325. eda_agent/tools/editor_options.py +54 -0
  326. eda_agent/tools/folds.py +353 -0
  327. eda_agent/tools/footprint.py +507 -0
  328. eda_agent/tools/generic.py +4718 -0
  329. eda_agent/tools/guidance.py +2213 -0
  330. eda_agent/tools/ipc_batch.py +254 -0
  331. eda_agent/tools/kicad.py +1678 -0
  332. eda_agent/tools/kicad_board.py +1941 -0
  333. eda_agent/tools/kicad_lib.py +832 -0
  334. eda_agent/tools/kicad_parity.py +571 -0
  335. eda_agent/tools/kicad_sch.py +940 -0
  336. eda_agent/tools/library.py +9295 -0
  337. eda_agent/tools/live.py +251 -0
  338. eda_agent/tools/metadata.py +611 -0
  339. eda_agent/tools/option_hints.py +55 -0
  340. eda_agent/tools/parts.py +237 -0
  341. eda_agent/tools/pcb.py +11038 -0
  342. eda_agent/tools/pin_hints.py +63 -0
  343. eda_agent/tools/plan.py +125 -0
  344. eda_agent/tools/project.py +4251 -0
  345. eda_agent/tools/registry.py +168 -0
  346. eda_agent/tools/render.py +393 -0
  347. eda_agent/tools/requests.py +327 -0
  348. eda_agent/tools/review.py +573 -0
  349. eda_agent/tools/route.py +572 -0
  350. eda_agent/tools/sch_template.py +484 -0
  351. eda_agent/tools/sim.py +704 -0
  352. eda_agent/tools/uiauto.py +1614 -0
  353. eda_agent/tools/update.py +43 -0
  354. eda_agent/ui/__init__.py +27 -0
  355. eda_agent/ui/altium_locale.py +337 -0
  356. eda_agent/ui/altium_menus.py +325 -0
  357. eda_agent/ui/altium_processes.py +84 -0
  358. eda_agent/ui/captions.py +30 -0
  359. eda_agent/ui/change_order.py +112 -0
  360. eda_agent/ui/comments.py +209 -0
  361. eda_agent/ui/controls.py +988 -0
  362. eda_agent/ui/dialog_answer.py +256 -0
  363. eda_agent/ui/dialog_driver.py +483 -0
  364. eda_agent/ui/dialog_report.py +257 -0
  365. eda_agent/ui/menu.py +1248 -0
  366. eda_agent/ui/ocr.py +345 -0
  367. eda_agent/ui/panel_wheel.py +203 -0
  368. eda_agent/ui/panels.py +277 -0
  369. eda_agent/ui/uia.py +417 -0
  370. eda_agent/ui/windows.py +1331 -0
  371. eda_agent/units.py +83 -0
  372. eda_agent/updates.py +562 -0
  373. eda_agent/web/__init__.py +4 -0
  374. eda_agent/web/board_actions.py +248 -0
  375. eda_agent/web/cockpit.py +474 -0
  376. eda_agent/web/dashboard.py +2253 -0
  377. eda_agent/web/dashboard_static/i18n/_labels.json +675 -0
  378. eda_agent/web/dashboard_static/i18n/de.json +672 -0
  379. eda_agent/web/dashboard_static/i18n/fr.json +672 -0
  380. eda_agent/web/dashboard_static/i18n/ja.json +672 -0
  381. eda_agent/web/dashboard_static/i18n/ko.json +672 -0
  382. eda_agent/web/dashboard_static/i18n/ru.json +672 -0
  383. eda_agent/web/dashboard_static/i18n/zh-CN.json +672 -0
  384. eda_agent/web/dashboard_static/i18n/zh-TW.json +672 -0
  385. eda_agent/web/dashboard_static/index.html +10880 -0
  386. eda_agent/web/dashboard_static/workbench.html +1677 -0
  387. eda_agent/web/i18n.py +151 -0
  388. eda_agent/web/library_view.py +221 -0
  389. eda_agent/web/workbench.py +615 -0
  390. eda_agent/web/workbench_strings.py +49 -0
  391. eda_agent-0.8.1.dist-info/METADATA +695 -0
  392. eda_agent-0.8.1.dist-info/RECORD +396 -0
  393. eda_agent-0.8.1.dist-info/WHEEL +4 -0
  394. eda_agent-0.8.1.dist-info/entry_points.txt +2 -0
  395. eda_agent-0.8.1.dist-info/licenses/LICENSE +201 -0
  396. eda_agent-0.8.1.dist-info/licenses/NOTICE +5 -0
eda_agent/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026 George Saliba <george.saliba@salitronic.com>
3
+ """EDA Agent - MCP server bridging Altium Designer to MCP clients."""
4
+
5
+ __version__ = "0.8.1"
eda_agent/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026 George Saliba <george.saliba@salitronic.com>
3
+ """Allow running the package as a module: python -m eda_agent"""
4
+
5
+ import sys
6
+
7
+ from .server import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
eda_agent/altium365.py ADDED
@@ -0,0 +1,534 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026 George Saliba <george.saliba@salitronic.com>
3
+ """Altium 365 over GraphQL, through the workspace itself or through Nexar.
4
+
5
+ Workspace: a token created in the workspace under Admin > Developer, sent
6
+ to the workspace's own GraphQL endpoint (EDA_AGENT_ALTIUM365_DOMAIN and
7
+ EDA_AGENT_ALTIUM365_TOKEN). It reaches more than Nexar does. These tokens
8
+ are not refreshed; an expired one is reported for the user to replace.
9
+
10
+ Nexar: api.nexar.com with an app the user registered with Nexar
11
+ (EDA_AGENT_NEXAR_APP_ID). ``eda-agent altium365-login`` signs in once in
12
+ the browser (authorization code with PKCE); the token is kept encrypted
13
+ for the current Windows user and refreshed when it expires.
14
+
15
+ A domain, when set, selects the workspace route. Nothing Altium 365 sends
16
+ back is written to disk: replies stay in memory for ten minutes, well
17
+ inside Nexar's 24-hour limit on keeping data. EDA_AGENT_ALTIUM365_WORKSPACES
18
+ (comma-separated URLs) limits which workspaces the tools may touch.
19
+
20
+ Any reading query may be run; one containing ``mutation`` or
21
+ ``subscription`` is refused. Changes go only through the fixed operations
22
+ built below, each sent only after the live schema shows it exists.
23
+
24
+ Not yet run against a live workspace; that needs an account's token.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import base64
29
+ import hashlib
30
+ import json
31
+ import os
32
+ import re
33
+ import secrets
34
+ import sys
35
+ import threading
36
+ import time
37
+ import urllib.error
38
+ import urllib.parse
39
+ import urllib.request
40
+ from dataclasses import dataclass, field
41
+ from pathlib import Path
42
+ from typing import Any, Callable, Optional
43
+
44
+ NEXAR_GRAPHQL = "https://api.nexar.com/graphql"
45
+ NEXAR_UPLOAD = "https://files.nexar.com/File/Upload"
46
+ NEXAR_AUTH = "https://identity.nexar.com/connect"
47
+ NEXAR_SCOPES = " ".join(["openid", "profile", "email", "offline_access",
48
+ "user.access", "design.domain"])
49
+ MEMORY_SECONDS = 600
50
+ _DEFAULT_REDIRECT = "http://localhost:3000/login"
51
+
52
+
53
+ class Altium365Error(Exception):
54
+ """A failure or refusal, worded for the user to act on."""
55
+
56
+
57
+ def _workspace_key(url: str) -> str:
58
+ return url.strip().rstrip("/") + "/"
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Altium365Config:
63
+ domain: str = ""
64
+ workspace_token: str = ""
65
+ nexar_app_id: str = ""
66
+ redirect_uri: str = _DEFAULT_REDIRECT
67
+ allowed_workspaces: tuple[str, ...] = ()
68
+ saved_login: Path = field(
69
+ default_factory=lambda: Path.home() / ".eda-agent" / "nexar_login.bin")
70
+ timeout_s: float = 60.0
71
+
72
+ @property
73
+ def mode(self) -> str:
74
+ """"workspace", "nexar", or "" when neither is set up."""
75
+ if self.domain:
76
+ return "workspace"
77
+ return "nexar" if self.nexar_app_id else ""
78
+
79
+ @property
80
+ def graphql_url(self) -> str:
81
+ return f"https://{self.domain}/api/graphql/" if self.domain else NEXAR_GRAPHQL
82
+
83
+ @property
84
+ def upload_url(self) -> str:
85
+ return f"https://{self.domain}/api/files" if self.domain else NEXAR_UPLOAD
86
+
87
+
88
+ def read_environment(env: Optional[dict] = None) -> Altium365Config:
89
+ env = os.environ if env is None else env
90
+ domain = re.sub(r"^https?://", "", env.get("EDA_AGENT_ALTIUM365_DOMAIN", "").strip())
91
+ allowed = [_workspace_key(u) for u in
92
+ env.get("EDA_AGENT_ALTIUM365_WORKSPACES", "").split(",") if u.strip()]
93
+ return Altium365Config(
94
+ domain=domain.strip("/"),
95
+ workspace_token=env.get("EDA_AGENT_ALTIUM365_TOKEN", "").strip(),
96
+ nexar_app_id=env.get("EDA_AGENT_NEXAR_APP_ID", "").strip(),
97
+ redirect_uri=env.get("EDA_AGENT_NEXAR_REDIRECT", _DEFAULT_REDIRECT),
98
+ allowed_workspaces=tuple(allowed))
99
+
100
+
101
+ # --- what is sent -----------------------------------------------------------
102
+
103
+ _READS = {
104
+ "workspaces": "query { desWorkspaceInfos { url workspaceId name description } }",
105
+ "projects": ("query($ws: String!, $after: String) { desProjects(workspaceUrl: $ws, "
106
+ "after: $after) { totalCount pageInfo { hasNextPage endCursor } "
107
+ "nodes { id name description } } }"),
108
+ "project": "query($id: ID!) { desProjectById(id: $id) { id name description } }",
109
+ "folders": ("query($ws: String!) { desLibrary(workspaceUrl: $ws) "
110
+ "{ folders { id name path } } }"),
111
+ "components": ("query($ws: String!) { desLibrary(workspaceUrl: $ws) "
112
+ "{ components { nodes { id name%s } } } }"),
113
+ "component": ("query($id: ID!) { desComponentById(id: $id) { id name details "
114
+ "{ parameters { type name value } } } }"),
115
+ "type": ("query($n: String!) { __type(name: $n) { name kind description fields "
116
+ "{ name description args { name type { name kind ofType { name kind } } } "
117
+ "type { name kind ofType { name kind ofType { name kind } } } } } }"),
118
+ }
119
+ _PARAMETERS = " details { parameters { type name value } }"
120
+
121
+ #: Schema fields the reads depend on; check_setup() looks for each one.
122
+ READ_FIELDS = (("Query", "desWorkspaceInfos"), ("Query", "desProjects"),
123
+ ("Query", "desProjectById"), ("Query", "desLibrary"),
124
+ ("Query", "desComponentById"))
125
+ #: Each change and the schema field it needs before it is sent.
126
+ CHANGES = {"publish_symbol": ("Mutation", "desCreateSymbol"),
127
+ "publish_footprint": ("Mutation", "desCreateFootprint"),
128
+ "release_component": ("Mutation", "desReleaseComponent"),
129
+ "set_parameters": ("Mutation", "desUpdateComponentRevisionParameters")}
130
+
131
+
132
+ def refuse_changes(query: str) -> None:
133
+ """Raise when a query could change anything, or is too long to check."""
134
+ if len(query) > 20000:
135
+ raise Altium365Error("the query is longer than 20000 characters")
136
+ code = re.sub(r"#[^\n]*", "", re.sub(r'"(?:\\.|[^"\\])*"', '""', query))
137
+ if re.search(r"\b(?:mutation|subscription)\b", code, re.IGNORECASE):
138
+ raise Altium365Error("only reading queries run here; the altium365 change "
139
+ "tools make changes")
140
+
141
+
142
+ def gql_value(value: Any) -> str:
143
+ """A Python value written as a GraphQL input value."""
144
+ if isinstance(value, bool):
145
+ return str(value).lower()
146
+ if isinstance(value, (str, int, float)):
147
+ return json.dumps(value)
148
+ if isinstance(value, list):
149
+ return "[" + ", ".join(map(gql_value, value)) + "]"
150
+ if isinstance(value, dict):
151
+ bad = [k for k in value if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", k)]
152
+ if bad:
153
+ raise Altium365Error(f"{bad[0]!r} is not a GraphQL field name")
154
+ return "{ " + ", ".join(f"{k}: {gql_value(v)}" for k, v in value.items()) + " }"
155
+ raise Altium365Error(f"a {type(value).__name__} cannot be sent")
156
+
157
+
158
+ def publish_operation(kind: str) -> tuple[str, str]:
159
+ """(schema field, operation) creating a symbol or footprint from a file."""
160
+ name = {"symbol": "desCreateSymbol", "footprint": "desCreateFootprint"}[kind]
161
+ return name, (
162
+ "mutation($fileId: String!, $relativePath: String!, $folderId: ID!, "
163
+ "$ws: String!, $name: String!) { " + name + "(input: { files: { fileId: "
164
+ "$fileId, relativePath: $relativePath }, folderId: $folderId, name: $name, "
165
+ "workspaceUrl: $ws }) { errors { message } id } }")
166
+
167
+
168
+ def release_operation(spec: dict) -> str:
169
+ return ("mutation { desReleaseComponent(input: " + gql_value(spec)
170
+ + ") { errors { message } } }")
171
+
172
+
173
+ def parameters_operation(component_id: str, parameters: list[dict], note: str,
174
+ replace: bool) -> str:
175
+ spec = {"componentId": component_id, "releaseNote": note, "replaceExisting": replace,
176
+ "parameters": [{"name": str(p["name"]), "value": str(p["value"])}
177
+ for p in parameters]}
178
+ return ("mutation { desUpdateComponentRevisionParameters(input: " + gql_value(spec)
179
+ + ") { componentId } }")
180
+
181
+
182
+ def reported_errors(data: dict, name: str) -> list[str]:
183
+ return [e.get("message", "") for e in ((data or {}).get(name) or {}).get("errors") or []]
184
+
185
+
186
+ # --- the Nexar login ----------------------------------------------------------
187
+
188
+ def _windows_crypt(blob: bytes, encrypt: bool) -> bytes: # pragma: no cover - Windows only
189
+ """DPAPI, scoped to the current Windows user."""
190
+ import ctypes
191
+ from ctypes import wintypes
192
+
193
+ class DataBlob(ctypes.Structure):
194
+ _fields_ = [("size", wintypes.DWORD), ("data", ctypes.POINTER(ctypes.c_char))]
195
+
196
+ source = ctypes.create_string_buffer(blob, len(blob))
197
+ given = DataBlob(len(blob), ctypes.cast(source, ctypes.POINTER(ctypes.c_char)))
198
+ result = DataBlob()
199
+ api = ctypes.windll.crypt32
200
+ no_ui = 0x01
201
+ if encrypt:
202
+ done = api.CryptProtectData(ctypes.byref(given), "eda-agent", None, None, None,
203
+ no_ui, ctypes.byref(result))
204
+ else:
205
+ done = api.CryptUnprotectData(ctypes.byref(given), None, None, None, None,
206
+ no_ui, ctypes.byref(result))
207
+ if not done:
208
+ raise Altium365Error("Windows could not protect or read the saved Nexar login")
209
+ try:
210
+ return ctypes.string_at(result.data, result.size)
211
+ finally:
212
+ ctypes.windll.kernel32.LocalFree(result.data)
213
+
214
+
215
+ def store_login(path: Path, token: dict) -> None:
216
+ if sys.platform != "win32":
217
+ raise Altium365Error("the Nexar login is only kept on Windows, encrypted per user")
218
+ path.parent.mkdir(parents=True, exist_ok=True)
219
+ path.write_bytes(_windows_crypt(json.dumps(token).encode(), True))
220
+
221
+
222
+ def fetch_login(path: Path) -> Optional[dict]:
223
+ if sys.platform != "win32" or not path.is_file():
224
+ return None
225
+ return json.loads(_windows_crypt(path.read_bytes(), False))
226
+
227
+
228
+ def forget_login(path: Path) -> bool:
229
+ """Remove the saved Nexar login; False when there was none."""
230
+ try:
231
+ path.unlink()
232
+ except FileNotFoundError:
233
+ return False
234
+ return True
235
+
236
+
237
+ def code_challenge(verifier: str) -> str:
238
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
239
+ return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
240
+
241
+
242
+ def _ask_for_token(form: dict, timeout_s: float) -> dict:
243
+ request = urllib.request.Request(
244
+ f"{NEXAR_AUTH}/token", data=urllib.parse.urlencode(form).encode(), method="POST",
245
+ headers={"Content-Type": "application/x-www-form-urlencoded"})
246
+ try:
247
+ with urllib.request.urlopen(request, timeout=timeout_s) as reply:
248
+ return json.loads(reply.read())
249
+ except urllib.error.HTTPError as exc:
250
+ raise Altium365Error(f"Nexar turned the sign-in down (HTTP {exc.code})") from exc
251
+
252
+
253
+ def browser_login(config: Altium365Config, open_url: Callable[[str], Any],
254
+ wait_s: float = 300) -> dict: # pragma: no cover - needs an account
255
+ """Sign in to Nexar in the browser and keep the login, encrypted."""
256
+ import http.server
257
+
258
+ if not config.nexar_app_id:
259
+ raise Altium365Error("set EDA_AGENT_NEXAR_APP_ID to the client id of your Nexar app")
260
+ back = urllib.parse.urlparse(config.redirect_uri)
261
+ if back.hostname not in ("localhost", "127.0.0.1"):
262
+ raise Altium365Error("the redirect URI has to point at this machine (localhost)")
263
+ verifier = secrets.token_urlsafe(64)
264
+ nonce = secrets.token_hex(16)
265
+ arrived = threading.Event()
266
+ codes: list[str] = []
267
+
268
+ class Catcher(http.server.BaseHTTPRequestHandler):
269
+ def log_message(self, *args: Any) -> None:
270
+ pass
271
+
272
+ def do_GET(self) -> None: # noqa: N802
273
+ asked = urllib.parse.urlparse(self.path)
274
+ fields = dict(urllib.parse.parse_qsl(asked.query))
275
+ if asked.path == back.path and fields.get("state") == nonce and fields.get("code"):
276
+ codes.append(fields["code"])
277
+ arrived.set()
278
+ self.send_response(200)
279
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
280
+ self.end_headers()
281
+ self.wfile.write(b"Signed in for eda-agent. You can close this tab.")
282
+
283
+ listener = http.server.HTTPServer(("127.0.0.1", back.port or 80), Catcher)
284
+ threading.Thread(target=listener.serve_forever, daemon=True).start()
285
+ try:
286
+ open_url(f"{NEXAR_AUTH}/authorize?" + urllib.parse.urlencode({
287
+ "client_id": config.nexar_app_id, "response_type": "code",
288
+ "redirect_uri": config.redirect_uri, "scope": NEXAR_SCOPES, "state": nonce,
289
+ "code_challenge": code_challenge(verifier), "code_challenge_method": "S256"}))
290
+ arrived.wait(wait_s)
291
+ finally:
292
+ listener.shutdown()
293
+ if not codes:
294
+ raise Altium365Error("the browser never came back with a sign-in")
295
+ token = _ask_for_token({"grant_type": "authorization_code", "code": codes[0],
296
+ "redirect_uri": config.redirect_uri,
297
+ "client_id": config.nexar_app_id,
298
+ "code_verifier": verifier}, config.timeout_s)
299
+ token["saved_at"] = time.time()
300
+ store_login(config.saved_login, token)
301
+ return {"signed_in": True, "expires_in_s": token.get("expires_in")}
302
+
303
+
304
+ # --- the session --------------------------------------------------------------
305
+
306
+ class Altium365Session:
307
+ """Reads and changes against one configured route, replies kept briefly."""
308
+
309
+ _BACKOFF_S = (1.0, 2.0, 4.0)
310
+
311
+ def __init__(self, config: Optional[Altium365Config] = None,
312
+ urlopen: Callable = urllib.request.urlopen,
313
+ now: Callable[[], float] = time.time, pause: Callable = time.sleep):
314
+ self.config = config or read_environment()
315
+ self._urlopen, self._now, self._pause = urlopen, now, pause
316
+ self._recent: dict[tuple[str, str], tuple[float, dict]] = {}
317
+ self._guard = threading.Lock()
318
+
319
+ # credentials and transport
320
+
321
+ def _access_token(self) -> str:
322
+ cfg = self.config
323
+ if cfg.mode == "workspace":
324
+ if cfg.workspace_token:
325
+ return cfg.workspace_token
326
+ raise Altium365Error(f"no token for {cfg.domain}: create one in the workspace "
327
+ "(Admin > Developer) and set EDA_AGENT_ALTIUM365_TOKEN")
328
+ if cfg.mode != "nexar":
329
+ raise Altium365Error("Altium 365 is not set up: set EDA_AGENT_ALTIUM365_DOMAIN "
330
+ "and EDA_AGENT_ALTIUM365_TOKEN, or EDA_AGENT_NEXAR_APP_ID "
331
+ "and run eda-agent altium365-login")
332
+ login = fetch_login(cfg.saved_login)
333
+ if not login:
334
+ raise Altium365Error("no Nexar login: run eda-agent altium365-login")
335
+ expires = login.get("saved_at", 0) + int(login.get("expires_in", 0))
336
+ if self._now() < expires - 120:
337
+ return login["access_token"]
338
+ if not login.get("refresh_token"):
339
+ raise Altium365Error("the Nexar login expired: run eda-agent altium365-login")
340
+ renewed = _ask_for_token({"grant_type": "refresh_token",
341
+ "refresh_token": login["refresh_token"],
342
+ "client_id": cfg.nexar_app_id}, cfg.timeout_s)
343
+ renewed.setdefault("refresh_token", login["refresh_token"])
344
+ renewed["saved_at"] = self._now()
345
+ store_login(cfg.saved_login, renewed)
346
+ return renewed["access_token"]
347
+
348
+ def _send(self, operation: str, variables: Optional[dict]) -> dict:
349
+ payload = json.dumps({"query": operation, "variables": variables or {}}).encode()
350
+ waits = list(self._BACKOFF_S)
351
+ while True:
352
+ request = urllib.request.Request(
353
+ self.config.graphql_url, data=payload, method="POST",
354
+ headers={"Authorization": "Bearer " + self._access_token(),
355
+ "Content-Type": "application/json"})
356
+ try:
357
+ with self._urlopen(request, timeout=self.config.timeout_s) as reply:
358
+ answer = json.loads(reply.read())
359
+ break
360
+ except urllib.error.HTTPError as exc:
361
+ if exc.code in (429, 502, 503, 504) and waits:
362
+ self._pause(waits.pop(0))
363
+ continue
364
+ if exc.code in (401, 403):
365
+ raise Altium365Error(f"Altium 365 did not accept the token "
366
+ f"(HTTP {exc.code})") from exc
367
+ text = exc.read()[:300].decode(errors="replace")
368
+ raise Altium365Error(f"Altium 365 answered HTTP {exc.code}: {text}") from exc
369
+ problems = answer.get("errors") or []
370
+ if problems:
371
+ raise Altium365Error("GraphQL errors: " + "; ".join(
372
+ str(p.get("message", p)) for p in problems[:5]))
373
+ return answer.get("data") or {}
374
+
375
+ def read(self, query: str, variables: Optional[dict] = None) -> dict:
376
+ """Run a reading query; the same query within ten minutes is answered
377
+ from memory."""
378
+ refuse_changes(query)
379
+ key = (query, json.dumps(variables or {}, sort_keys=True))
380
+ moment = self._now()
381
+ with self._guard:
382
+ kept = self._recent.get(key)
383
+ if kept and moment - kept[0] < MEMORY_SECONDS:
384
+ return kept[1]
385
+ data = self._send(query, variables)
386
+ with self._guard:
387
+ for old in [k for k, (t, _) in self._recent.items() if moment - t >= MEMORY_SECONDS]:
388
+ del self._recent[old]
389
+ self._recent[key] = (moment, data)
390
+ return data
391
+
392
+ def permitted(self, workspace_url: str) -> str:
393
+ """The workspace URL, normalised, unless the allow-list leaves it out."""
394
+ url = _workspace_key(workspace_url)
395
+ if self.config.allowed_workspaces and url not in self.config.allowed_workspaces:
396
+ raise Altium365Error(f"{url} is not listed in EDA_AGENT_ALTIUM365_WORKSPACES")
397
+ return url
398
+
399
+ # reads
400
+
401
+ def list_workspaces(self) -> list[dict]:
402
+ found = self.read(_READS["workspaces"]).get("desWorkspaceInfos") or []
403
+ allowed = self.config.allowed_workspaces
404
+ return [w for w in found if not allowed
405
+ or _workspace_key(w.get("url") or "") in allowed]
406
+
407
+ def list_projects(self, workspace_url: str, name_contains: str = "",
408
+ limit: int = 200) -> dict:
409
+ ws = self.permitted(workspace_url)
410
+ needle = name_contains.lower()
411
+ kept: list[dict] = []
412
+ cursor, total = None, 0
413
+ for _page in range(100):
414
+ page = self.read(_READS["projects"], {"ws": ws, "after": cursor})["desProjects"]
415
+ total = page.get("totalCount", 0)
416
+ kept.extend(p for p in page.get("nodes") or []
417
+ if needle in (p.get("name") or "").lower())
418
+ more = page["pageInfo"]
419
+ if len(kept) >= limit or not more["hasNextPage"]:
420
+ break
421
+ cursor = more["endCursor"]
422
+ return {"total_in_workspace": total, "projects": kept[:limit],
423
+ "cut_short": len(kept) > limit}
424
+
425
+ def get_project(self, project_id: str) -> dict:
426
+ project = self.read(_READS["project"], {"id": project_id}).get("desProjectById")
427
+ if project is None:
428
+ raise Altium365Error(f"project {project_id} was not found or is not shared "
429
+ "with this token")
430
+ return project
431
+
432
+ def list_library_folders(self, workspace_url: str) -> list[dict]:
433
+ library = self.read(_READS["folders"], {"ws": self.permitted(workspace_url)})
434
+ return (library.get("desLibrary") or {}).get("folders") or []
435
+
436
+ def list_components(self, workspace_url: str, name_contains: str = "",
437
+ with_parameters: bool = False) -> list[dict]:
438
+ query = _READS["components"] % (_PARAMETERS if with_parameters else "")
439
+ library = self.read(query, {"ws": self.permitted(workspace_url)}).get("desLibrary")
440
+ nodes = ((library or {}).get("components") or {}).get("nodes") or []
441
+ needle = name_contains.lower()
442
+ return [n for n in nodes if needle in (n.get("name") or "").lower()]
443
+
444
+ def get_component(self, component_id: str) -> dict:
445
+ part = self.read(_READS["component"], {"id": component_id}).get("desComponentById")
446
+ if part is None:
447
+ raise Altium365Error(f"component {component_id} was not found")
448
+ return part
449
+
450
+ def describe_type(self, type_name: str) -> dict:
451
+ if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,80}", type_name or ""):
452
+ raise Altium365Error("a type name holds letters, digits and _ only")
453
+ described = self.read(_READS["type"], {"n": type_name}).get("__type")
454
+ if described is None:
455
+ raise Altium365Error(f"the schema has no type {type_name}")
456
+ return described
457
+
458
+ @staticmethod
459
+ def _base_type(type_ref: dict) -> dict:
460
+ while type_ref.get("ofType") and type_ref.get("kind") in ("NON_NULL", "LIST"):
461
+ type_ref = type_ref["ofType"]
462
+ return type_ref
463
+
464
+ def read_object(self, query_field: str, object_id: str, most_fields: int = 60) -> dict:
465
+ """Any ``des...ById`` object, asking for the plain fields its schema
466
+ type lists."""
467
+ if not re.fullmatch(r"des[A-Za-z]+ById", query_field or ""):
468
+ raise Altium365Error("query_field is a des...ById field, e.g. desProjectById")
469
+ roots = {f["name"]: f for f in self.describe_type("Query").get("fields") or []}
470
+ if query_field not in roots:
471
+ raise Altium365Error(f"the schema has no Query.{query_field}")
472
+ type_ref = roots[query_field]["type"]
473
+ while type_ref.get("ofType"):
474
+ type_ref = type_ref["ofType"]
475
+ members = self.describe_type(type_ref["name"]).get("fields") or []
476
+ plain = [f["name"] for f in members if not f.get("args")
477
+ and self._base_type(f["type"]).get("kind") in ("SCALAR", "ENUM")]
478
+ nested = [f["name"] for f in members
479
+ if self._base_type(f["type"]).get("kind") not in ("SCALAR", "ENUM")]
480
+ found = self.read(
481
+ "query($id: ID!) { %s(id: $id) { %s } }" % (query_field,
482
+ " ".join(plain[:most_fields])),
483
+ {"id": object_id}).get(query_field)
484
+ if found is None:
485
+ raise Altium365Error(f"{query_field}({object_id}) returned nothing")
486
+ return {"type": type_ref["name"], "fields": found, "nested_fields": nested[:80]}
487
+
488
+ def check_setup(self) -> dict:
489
+ self._access_token() # raises with what is missing
490
+ listed: dict[str, set[str]] = {}
491
+ for type_name in {t for t, _ in READ_FIELDS} | {t for t, _ in CHANGES.values()}:
492
+ try:
493
+ listed[type_name] = {f["name"] for f in
494
+ self.describe_type(type_name).get("fields") or []}
495
+ except Altium365Error:
496
+ listed[type_name] = set()
497
+ absent = [f"{t}.{f}" for t, f in READ_FIELDS if f not in listed[t]]
498
+ return {"ok": not absent, "route": self.config.mode,
499
+ "endpoint": self.config.graphql_url, "missing_fields": absent,
500
+ "changes_offered": {name: f in listed[t] for name, (t, f) in CHANGES.items()}}
501
+
502
+ # changes
503
+
504
+ def require_change(self, name: str) -> None:
505
+ type_name, field_name = CHANGES[name]
506
+ offered = {f["name"] for f in self.describe_type(type_name).get("fields") or []}
507
+ if field_name not in offered:
508
+ raise Altium365Error(f"the schema has no {type_name}.{field_name}, so "
509
+ f"{name} is not sent")
510
+
511
+ def run_change(self, operation: str, variables: Optional[dict] = None) -> dict:
512
+ """One of the fixed operations above, never answered from memory."""
513
+ return self._send(operation, variables)
514
+
515
+ def upload_file(self, path: Path) -> str:
516
+ """Upload a library file for a publish; the id it returns lasts a day."""
517
+ path = Path(path)
518
+ mark = "eda-agent-" + secrets.token_hex(12)
519
+ head = (f"--{mark}\r\nContent-Disposition: form-data; name=\"file\"; "
520
+ f"filename=\"{path.name}\"\r\nContent-Type: application/octet-stream\r\n\r\n")
521
+ body = head.encode() + path.read_bytes() + f"\r\n--{mark}--\r\n".encode()
522
+ token = self._access_token()
523
+ # The workspace's file service takes a bearer token like its GraphQL
524
+ # endpoint; Nexar's takes the token in a header named "token".
525
+ auth = ({"Authorization": f"Bearer {token}"} if self.config.mode == "workspace"
526
+ else {"token": token})
527
+ request = urllib.request.Request(
528
+ self.config.upload_url, data=body, method="POST",
529
+ headers={"Content-Type": f"multipart/form-data; boundary={mark}", **auth})
530
+ with self._urlopen(request, timeout=self.config.timeout_s) as reply:
531
+ file_id = reply.read().decode(errors="replace").strip().strip('"')
532
+ if not file_id:
533
+ raise Altium365Error("the upload returned no file id")
534
+ return file_id
@@ -0,0 +1,93 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026 George Saliba <george.saliba@salitronic.com>
3
+ """Finish an atomic write when Windows will not let go of the target.
4
+
5
+ Every durable write in this codebase stages to a temp file and renames
6
+ over the destination, so a reader never sees a half-written file. On
7
+ Windows that last step is the fragile one: a scanner (Defender, a sync
8
+ client, an indexer) can hold the freshly-written TARGET open for a few
9
+ milliseconds, and ``os.replace`` then fails with ``PermissionError``
10
+ (WinError 5, or WinError 32 for a sharing violation, which Python maps
11
+ to the same class).
12
+
13
+ MEASURED, not theorised: two failures in eight tight renames on this
14
+ machine. That rate is survivable for a write that happens once, and
15
+ fatal for one in a loop. The symbol cache rewrites its library file once
16
+ per symbol, so a 38-symbol extraction was near-certain to hit it, and
17
+ the exception killed the whole schematic run that needed it.
18
+
19
+ WHAT THIS DOES NOT DECIDE. The helper retries and then raises. Whether a
20
+ failed write should be swallowed is the CALLER's judgement and it is not
21
+ the same everywhere:
22
+
23
+ * A cache entry (symbols, HTTP responses) is an optimisation. Skip it.
24
+ * A heartbeat or a fault record is best-effort. Skip it.
25
+ * A bridge REQUEST file is the command itself. Raising is right, because
26
+ skipping it silently leaves the caller waiting out a full timeout with
27
+ nothing to explain the silence.
28
+ * A checkpoint blob or manifest is a backup. Raising is right, because a
29
+ checkpoint that cannot restore is worse than one that never existed.
30
+
31
+ So callers that want to degrade keep their own ``except`` around this,
32
+ and callers that want to fail loudly simply do not.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import logging
38
+ import os
39
+ import time
40
+ from pathlib import Path
41
+ from typing import Union
42
+
43
+ logger = logging.getLogger("eda_agent.atomicfile")
44
+
45
+ #: Sleep before each retry, in seconds. Rising, and starting well above
46
+ #: the few-millisecond window that was measured, so the common case costs
47
+ #: one short sleep. The total is a little over a second, which is the
48
+ #: point: long enough to outlast a scanner, short enough that a caller
49
+ #: stuck behind a genuinely locked file finds out quickly.
50
+ _BACKOFF = (0.01, 0.02, 0.05, 0.1, 0.2, 0.3, 0.5)
51
+
52
+ _PathLike = Union[str, "os.PathLike[str]"]
53
+
54
+
55
+ def replace_with_retry(tmp: _PathLike, target: _PathLike) -> None:
56
+ """``os.replace(tmp, target)``, retried while Windows holds the target.
57
+
58
+ Raises the last ``PermissionError`` if every attempt loses the race,
59
+ so the caller decides what that means.
60
+ """
61
+ last: PermissionError
62
+ for delay in _BACKOFF:
63
+ try:
64
+ os.replace(tmp, target)
65
+ return
66
+ except PermissionError as exc:
67
+ last = exc
68
+ time.sleep(delay)
69
+ # One final attempt after the longest sleep, so the last entry in
70
+ # _BACKOFF is a wait rather than a wasted one.
71
+ try:
72
+ os.replace(tmp, target)
73
+ return
74
+ except PermissionError as exc:
75
+ last = exc
76
+ logger.warning(
77
+ "could not rename %s onto %s after %d attempts: %s",
78
+ tmp, target, len(_BACKOFF) + 1, last,
79
+ )
80
+ raise last
81
+
82
+
83
+ def discard(tmp: _PathLike) -> None:
84
+ """Remove a staged temp file, ignoring any failure.
85
+
86
+ For the caller that decided to give up: without this the abandoned
87
+ ``.tmp`` stays next to the real file, where the next run's glob or a
88
+ directory listing can pick it up.
89
+ """
90
+ try:
91
+ Path(tmp).unlink()
92
+ except OSError:
93
+ pass