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.
- eda_agent/__init__.py +5 -0
- eda_agent/__main__.py +10 -0
- eda_agent/altium365.py +534 -0
- eda_agent/atomicfile.py +93 -0
- eda_agent/bridge/__init__.py +42 -0
- eda_agent/bridge/altium_bridge.py +1929 -0
- eda_agent/bridge/bulk.py +161 -0
- eda_agent/bridge/easyeda_bridge.py +833 -0
- eda_agent/bridge/easyeda_expected.py +144 -0
- eda_agent/bridge/easyeda_verified.py +186 -0
- eda_agent/bridge/exceptions.py +252 -0
- eda_agent/bridge/fault_state.py +55 -0
- eda_agent/bridge/kicad_api.py +1517 -0
- eda_agent/bridge/kicad_bridge.py +464 -0
- eda_agent/bridge/kicad_ipc.py +254 -0
- eda_agent/bridge/kicad_wire.py +214 -0
- eda_agent/bridge/path_spelling.py +72 -0
- eda_agent/bridge/payload.py +89 -0
- eda_agent/bridge/process_manager.py +472 -0
- eda_agent/bridge/recovery.py +100 -0
- eda_agent/bridge/script_crash.py +362 -0
- eda_agent/bridge/script_launcher.py +59 -0
- eda_agent/bridge/websocket.py +247 -0
- eda_agent/checkpoint.py +316 -0
- eda_agent/cli.py +781 -0
- eda_agent/config.py +394 -0
- eda_agent/convert/__init__.py +3 -0
- eda_agent/convert/altium_kicad/__init__.py +23 -0
- eda_agent/convert/altium_kicad/altium_side.py +441 -0
- eda_agent/convert/altium_kicad/board_pass.py +312 -0
- eda_agent/convert/altium_kicad/checks.py +71 -0
- eda_agent/convert/altium_kicad/footprints.py +290 -0
- eda_agent/convert/altium_kicad/hierarchy.py +161 -0
- eda_agent/convert/altium_kicad/importer.py +303 -0
- eda_agent/convert/altium_kicad/pipeline.py +392 -0
- eda_agent/convert/altium_kicad/schematic.py +570 -0
- eda_agent/convert/altium_kicad/tree.py +200 -0
- eda_agent/convert/altium_kicad/variants.py +159 -0
- eda_agent/core/__init__.py +22 -0
- eda_agent/core/backends.py +399 -0
- eda_agent/core/eco_verify.py +134 -0
- eda_agent/core/kicad_drc.py +186 -0
- eda_agent/core/kicad_export.py +83 -0
- eda_agent/core/kicad_footprint.py +453 -0
- eda_agent/core/kicad_libtables.py +143 -0
- eda_agent/core/kicad_netlist.py +156 -0
- eda_agent/core/kicad_pcb.py +160 -0
- eda_agent/core/kicad_sch_doc.py +309 -0
- eda_agent/core/kicad_schematic.py +166 -0
- eda_agent/core/kicad_sexpr_edit.py +181 -0
- eda_agent/core/kicad_symbol.py +160 -0
- eda_agent/core/net_naming.py +66 -0
- eda_agent/core/placement_order.py +54 -0
- eda_agent/core/review_engine.py +261 -0
- eda_agent/core/snapshot.py +167 -0
- eda_agent/design/__init__.py +45 -0
- eda_agent/design/_wiring.py +342 -0
- eda_agent/design/audit.py +689 -0
- eda_agent/design/autonomy.py +692 -0
- eda_agent/design/bom.py +90 -0
- eda_agent/design/calculators.py +278 -0
- eda_agent/design/company_rules.py +302 -0
- eda_agent/design/component_values.py +624 -0
- eda_agent/design/datasheet_fetch.py +242 -0
- eda_agent/design/datasheet_render.py +114 -0
- eda_agent/design/datasheet_search.py +82 -0
- eda_agent/design/designator_sizing.py +113 -0
- eda_agent/design/diffpairs.py +210 -0
- eda_agent/design/discipline.py +996 -0
- eda_agent/design/easyeda_emitter.py +335 -0
- eda_agent/design/fab_check.py +196 -0
- eda_agent/design/fab_data/advancedpcb.json +105 -0
- eda_agent/design/fab_data/aisler.json +89 -0
- eda_agent/design/fab_data/allpcb.json +81 -0
- eda_agent/design/fab_data/bayareacircuits.json +118 -0
- eda_agent/design/fab_data/epec.json +87 -0
- eda_agent/design/fab_data/eurocircuits.json +155 -0
- eda_agent/design/fab_data/expresspcb.json +68 -0
- eda_agent/design/fab_data/jlcpcb.json +100 -0
- eda_agent/design/fab_data/jlcpcb_stackups.json +2513 -0
- eda_agent/design/fab_data/laminates.json +74 -0
- eda_agent/design/fab_data/multicb.json +220 -0
- eda_agent/design/fab_data/nextpcb.json +125 -0
- eda_agent/design/fab_data/oshpark.json +145 -0
- eda_agent/design/fab_data/pcbcart.json +76 -0
- eda_agent/design/fab_data/pcbuniverse.json +81 -0
- eda_agent/design/fab_data/pcbway.json +101 -0
- eda_agent/design/fab_data/sierra.json +91 -0
- eda_agent/design/fab_library.py +461 -0
- eda_agent/design/fab_profile.py +207 -0
- eda_agent/design/footprint_audit.py +579 -0
- eda_agent/design/footprint_gen.py +566 -0
- eda_agent/design/footprint_policy.py +868 -0
- eda_agent/design/footprints.py +180 -0
- eda_agent/design/hierarchy.py +516 -0
- eda_agent/design/hsd_rules.py +147 -0
- eda_agent/design/illustrate.py +90 -0
- eda_agent/design/impedance_sizing.py +198 -0
- eda_agent/design/inventory.py +221 -0
- eda_agent/design/ipc_templates.py +151 -0
- eda_agent/design/jobs.py +182 -0
- eda_agent/design/landpattern/__init__.py +67 -0
- eda_agent/design/landpattern/audit.py +961 -0
- eda_agent/design/landpattern/builder.py +416 -0
- eda_agent/design/landpattern/check.py +820 -0
- eda_agent/design/landpattern/compare.py +339 -0
- eda_agent/design/landpattern/compare_figure.py +299 -0
- eda_agent/design/landpattern/drafting.py +489 -0
- eda_agent/design/landpattern/drawing_dims.py +233 -0
- eda_agent/design/landpattern/emit.py +736 -0
- eda_agent/design/landpattern/ipc7351.py +688 -0
- eda_agent/design/landpattern/land_source.py +65 -0
- eda_agent/design/landpattern/live.py +289 -0
- eda_agent/design/landpattern/model.py +293 -0
- eda_agent/design/landpattern/smd.py +672 -0
- eda_agent/design/landpattern/spec.py +538 -0
- eda_agent/design/landpattern/svg.py +296 -0
- eda_agent/design/landpattern/tht.py +317 -0
- eda_agent/design/layer_stack_plan.py +294 -0
- eda_agent/design/length_matching.py +264 -0
- eda_agent/design/library_conventions.py +277 -0
- eda_agent/design/live.py +612 -0
- eda_agent/design/loops.py +578 -0
- eda_agent/design/mixed_signal.py +110 -0
- eda_agent/design/model_drawing.py +136 -0
- eda_agent/design/models3d.py +899 -0
- eda_agent/design/motif_descriptions.py +134 -0
- eda_agent/design/motifs.py +1162 -0
- eda_agent/design/net_classes.py +70 -0
- eda_agent/design/partition.py +192 -0
- eda_agent/design/pcb_placement.py +3037 -0
- eda_agent/design/phase_gates.py +416 -0
- eda_agent/design/pin_pad_parity.py +74 -0
- eda_agent/design/pin_table.py +161 -0
- eda_agent/design/placement_constraints.py +74 -0
- eda_agent/design/plan.py +433 -0
- eda_agent/design/plan_blocks.py +929 -0
- eda_agent/design/plan_compare.py +58 -0
- eda_agent/design/plan_edit.py +287 -0
- eda_agent/design/plan_erc.py +227 -0
- eda_agent/design/plan_stats.py +82 -0
- eda_agent/design/policy_targets.py +272 -0
- eda_agent/design/port_reference_side.py +187 -0
- eda_agent/design/report.py +378 -0
- eda_agent/design/requirement.py +578 -0
- eda_agent/design/rule_synthesis.py +364 -0
- eda_agent/design/sch_template.py +813 -0
- eda_agent/design/session.py +283 -0
- eda_agent/design/sheet_fit.py +303 -0
- eda_agent/design/sheet_plan.py +467 -0
- eda_agent/design/signal_integrity.py +358 -0
- eda_agent/design/state_machine.py +241 -0
- eda_agent/design/step_worker.py +77 -0
- eda_agent/design/symbol_gen.py +661 -0
- eda_agent/design/thermal_vias.py +204 -0
- eda_agent/design/trace_sizing.py +147 -0
- eda_agent/design/validator.py +271 -0
- eda_agent/design/value_checks.py +89 -0
- eda_agent/design/value_parser.py +104 -0
- eda_agent/design/visual_metrics.py +203 -0
- eda_agent/devproxy.py +331 -0
- eda_agent/diag/__init__.py +13 -0
- eda_agent/diag/checks.py +86 -0
- eda_agent/diag/doctor.py +264 -0
- eda_agent/diag/health.py +417 -0
- eda_agent/diag/sdk_reach.py +179 -0
- eda_agent/easyeda_extension/BUILD_STAMP.json +4 -0
- eda_agent/easyeda_extension/README.md +207 -0
- eda_agent/easyeda_extension/build.py +227 -0
- eda_agent/easyeda_extension/capabilities.json +1075 -0
- eda_agent/easyeda_extension/extension.json +188 -0
- eda_agent/easyeda_extension/iframe_template.html +62 -0
- eda_agent/easyeda_extension/main.js +4753 -0
- eda_agent/easyeda_extension.py +83 -0
- eda_agent/export/__init__.py +9 -0
- eda_agent/export/drc_report.py +78 -0
- eda_agent/export/drc_rules.py +360 -0
- eda_agent/export/kicad_footprint.py +137 -0
- eda_agent/export/library_report.py +96 -0
- eda_agent/export/outjob_outputs.py +29 -0
- eda_agent/export/stackup_csv.py +118 -0
- eda_agent/export/variant_bom.py +89 -0
- eda_agent/export/variant_matrix_csv.py +48 -0
- eda_agent/fileio/__init__.py +39 -0
- eda_agent/fileio/altium_project.py +90 -0
- eda_agent/fileio/altium_sch.py +403 -0
- eda_agent/fileio/altium_schlib.py +69 -0
- eda_agent/fileio/bom.py +131 -0
- eda_agent/fileio/netlist_solver.py +280 -0
- eda_agent/fileio/pcblib_models.py +229 -0
- eda_agent/fileio/review.py +438 -0
- eda_agent/geometry_map.py +752 -0
- eda_agent/housekeeping.py +117 -0
- eda_agent/layout/__init__.py +12 -0
- eda_agent/layout/audit.py +799 -0
- eda_agent/layout/bench.py +515 -0
- eda_agent/layout/bench_run.py +697 -0
- eda_agent/layout/copper_path.py +202 -0
- eda_agent/layout/drc.py +701 -0
- eda_agent/layout/geom.py +469 -0
- eda_agent/layout/loop_audit.py +183 -0
- eda_agent/layout/model.py +346 -0
- eda_agent/layout/pad_entry.py +219 -0
- eda_agent/layout/pad_relief.py +254 -0
- eda_agent/layout/place/__init__.py +7 -0
- eda_agent/layout/place/blocks.py +480 -0
- eda_agent/layout/place/compose.py +504 -0
- eda_agent/layout/place/floorplan.py +978 -0
- eda_agent/layout/place/job.py +132 -0
- eda_agent/layout/place/placer.py +917 -0
- eda_agent/layout/place/transform.py +112 -0
- eda_agent/layout/read_altium.py +636 -0
- eda_agent/layout/rotation_audit.py +161 -0
- eda_agent/layout/route/__init__.py +8 -0
- eda_agent/layout/route/diffpair.py +431 -0
- eda_agent/layout/route/exact.py +186 -0
- eda_agent/layout/route/fanout.py +484 -0
- eda_agent/layout/route/global_route.py +329 -0
- eda_agent/layout/route/gloss.py +501 -0
- eda_agent/layout/route/grid.py +367 -0
- eda_agent/layout/route/job.py +205 -0
- eda_agent/layout/route/lanes.py +829 -0
- eda_agent/layout/route/params.py +243 -0
- eda_agent/layout/route/pour.py +354 -0
- eda_agent/layout/route/router.py +3246 -0
- eda_agent/layout/route/search.py +175 -0
- eda_agent/layout/route/stages.py +615 -0
- eda_agent/layout/rules.py +400 -0
- eda_agent/layout/smart_drc.py +140 -0
- eda_agent/libimport/__init__.py +17 -0
- eda_agent/libimport/_names.py +53 -0
- eda_agent/libimport/cse.py +265 -0
- eda_agent/libimport/easyeda/__init__.py +48 -0
- eda_agent/libimport/easyeda/altium.py +745 -0
- eda_agent/libimport/easyeda/document.py +349 -0
- eda_agent/libimport/easyeda/fetch.py +221 -0
- eda_agent/libimport/easyeda/geometry.py +161 -0
- eda_agent/libimport/easyeda/kicad.py +545 -0
- eda_agent/libimport/easyeda/model3d.py +241 -0
- eda_agent/libimport/easyeda/shapes.py +627 -0
- eda_agent/libimport/kicad/__init__.py +14 -0
- eda_agent/libimport/kicad/reader.py +784 -0
- eda_agent/libimport/kicad/sexpr.py +151 -0
- eda_agent/libimport/providers/__init__.py +241 -0
- eda_agent/libimport/providers/_distributor.py +259 -0
- eda_agent/libimport/providers/_http.py +142 -0
- eda_agent/libimport/providers/altium_local.py +162 -0
- eda_agent/libimport/providers/base.py +145 -0
- eda_agent/libimport/providers/distributors.py +392 -0
- eda_agent/libimport/providers/easyeda.py +86 -0
- eda_agent/libimport/providers/kicad_local.py +460 -0
- eda_agent/libimport/providers/partreel.py +301 -0
- eda_agent/libimport/providers/public_libraries.py +280 -0
- eda_agent/library_db.py +109 -0
- eda_agent/models/__init__.py +51 -0
- eda_agent/models/components.py +76 -0
- eda_agent/models/layers.py +142 -0
- eda_agent/models/primitives.py +233 -0
- eda_agent/models/rules.py +173 -0
- eda_agent/noconsole.py +24 -0
- eda_agent/outjob.py +237 -0
- eda_agent/placement/__init__.py +61 -0
- eda_agent/placement/autoplace.py +838 -0
- eda_agent/preferences.py +205 -0
- eda_agent/prjpcb.py +182 -0
- eda_agent/render/__init__.py +22 -0
- eda_agent/render/bom_html.py +337 -0
- eda_agent/render/pcb_svg.py +970 -0
- eda_agent/render/rasterize.py +202 -0
- eda_agent/render/review_rubric.py +106 -0
- eda_agent/render/sch_svg.py +1110 -0
- eda_agent/route/__init__.py +39 -0
- eda_agent/route/model.py +654 -0
- eda_agent/route/repair.py +442 -0
- eda_agent/route/router.py +968 -0
- eda_agent/safety.py +333 -0
- eda_agent/saving.py +64 -0
- eda_agent/schemas/__init__.py +38 -0
- eda_agent/schemas/commands.py +135 -0
- eda_agent/schemas/envelope.py +38 -0
- eda_agent/schemas/registry.py +140 -0
- eda_agent/schemas/scope.py +19 -0
- eda_agent/scope.py +110 -0
- eda_agent/script_stamps.py +83 -0
- eda_agent/scripts/Altium_API.PrjScr +1273 -0
- eda_agent/scripts/Application.pas +1429 -0
- eda_agent/scripts/Audit.pas +4791 -0
- eda_agent/scripts/Dispatcher.pas +77 -0
- eda_agent/scripts/Generic.pas +19185 -0
- eda_agent/scripts/Library.pas +21956 -0
- eda_agent/scripts/Main.pas +3471 -0
- eda_agent/scripts/PCB.pas +31512 -0
- eda_agent/scripts/PCBGeneric.pas +3360 -0
- eda_agent/scripts/Project.pas +8397 -0
- eda_agent/scripts/SelfTest.pas +754 -0
- eda_agent/scripts/StatusForm.dfm +1970 -0
- eda_agent/scripts/StatusForm.pas +2911 -0
- eda_agent/scripts/Utils.pas +3371 -0
- eda_agent/server.py +1289 -0
- eda_agent/server_log.py +112 -0
- eda_agent/server_registry.py +171 -0
- eda_agent/sim/__init__.py +7 -0
- eda_agent/sim/deck.py +198 -0
- eda_agent/sim/deck_runner.py +321 -0
- eda_agent/sim/dialects.py +102 -0
- eda_agent/sim/model_file.py +130 -0
- eda_agent/sim/ngspice.py +253 -0
- eda_agent/sim/sdf.py +229 -0
- eda_agent/sim/simcfg.py +404 -0
- eda_agent/stdin_handle.py +34 -0
- eda_agent/tools/__init__.py +418 -0
- eda_agent/tools/altium365.py +214 -0
- eda_agent/tools/answering.py +56 -0
- eda_agent/tools/application.py +1672 -0
- eda_agent/tools/audit.py +1222 -0
- eda_agent/tools/bulk.py +238 -0
- eda_agent/tools/bulk_hints.py +148 -0
- eda_agent/tools/calc.py +543 -0
- eda_agent/tools/conventions.py +503 -0
- eda_agent/tools/convert.py +97 -0
- eda_agent/tools/datasheet_hints.py +247 -0
- eda_agent/tools/design.py +2966 -0
- eda_agent/tools/easyeda.py +13396 -0
- eda_agent/tools/eda.py +133 -0
- eda_agent/tools/editor_options.py +54 -0
- eda_agent/tools/folds.py +353 -0
- eda_agent/tools/footprint.py +507 -0
- eda_agent/tools/generic.py +4718 -0
- eda_agent/tools/guidance.py +2213 -0
- eda_agent/tools/ipc_batch.py +254 -0
- eda_agent/tools/kicad.py +1678 -0
- eda_agent/tools/kicad_board.py +1941 -0
- eda_agent/tools/kicad_lib.py +832 -0
- eda_agent/tools/kicad_parity.py +571 -0
- eda_agent/tools/kicad_sch.py +940 -0
- eda_agent/tools/library.py +9295 -0
- eda_agent/tools/live.py +251 -0
- eda_agent/tools/metadata.py +611 -0
- eda_agent/tools/option_hints.py +55 -0
- eda_agent/tools/parts.py +237 -0
- eda_agent/tools/pcb.py +11038 -0
- eda_agent/tools/pin_hints.py +63 -0
- eda_agent/tools/plan.py +125 -0
- eda_agent/tools/project.py +4251 -0
- eda_agent/tools/registry.py +168 -0
- eda_agent/tools/render.py +393 -0
- eda_agent/tools/requests.py +327 -0
- eda_agent/tools/review.py +573 -0
- eda_agent/tools/route.py +572 -0
- eda_agent/tools/sch_template.py +484 -0
- eda_agent/tools/sim.py +704 -0
- eda_agent/tools/uiauto.py +1614 -0
- eda_agent/tools/update.py +43 -0
- eda_agent/ui/__init__.py +27 -0
- eda_agent/ui/altium_locale.py +337 -0
- eda_agent/ui/altium_menus.py +325 -0
- eda_agent/ui/altium_processes.py +84 -0
- eda_agent/ui/captions.py +30 -0
- eda_agent/ui/change_order.py +112 -0
- eda_agent/ui/comments.py +209 -0
- eda_agent/ui/controls.py +988 -0
- eda_agent/ui/dialog_answer.py +256 -0
- eda_agent/ui/dialog_driver.py +483 -0
- eda_agent/ui/dialog_report.py +257 -0
- eda_agent/ui/menu.py +1248 -0
- eda_agent/ui/ocr.py +345 -0
- eda_agent/ui/panel_wheel.py +203 -0
- eda_agent/ui/panels.py +277 -0
- eda_agent/ui/uia.py +417 -0
- eda_agent/ui/windows.py +1331 -0
- eda_agent/units.py +83 -0
- eda_agent/updates.py +562 -0
- eda_agent/web/__init__.py +4 -0
- eda_agent/web/board_actions.py +248 -0
- eda_agent/web/cockpit.py +474 -0
- eda_agent/web/dashboard.py +2253 -0
- eda_agent/web/dashboard_static/i18n/_labels.json +675 -0
- eda_agent/web/dashboard_static/i18n/de.json +672 -0
- eda_agent/web/dashboard_static/i18n/fr.json +672 -0
- eda_agent/web/dashboard_static/i18n/ja.json +672 -0
- eda_agent/web/dashboard_static/i18n/ko.json +672 -0
- eda_agent/web/dashboard_static/i18n/ru.json +672 -0
- eda_agent/web/dashboard_static/i18n/zh-CN.json +672 -0
- eda_agent/web/dashboard_static/i18n/zh-TW.json +672 -0
- eda_agent/web/dashboard_static/index.html +10880 -0
- eda_agent/web/dashboard_static/workbench.html +1677 -0
- eda_agent/web/i18n.py +151 -0
- eda_agent/web/library_view.py +221 -0
- eda_agent/web/workbench.py +615 -0
- eda_agent/web/workbench_strings.py +49 -0
- eda_agent-0.8.1.dist-info/METADATA +695 -0
- eda_agent-0.8.1.dist-info/RECORD +396 -0
- eda_agent-0.8.1.dist-info/WHEEL +4 -0
- eda_agent-0.8.1.dist-info/entry_points.txt +2 -0
- eda_agent-0.8.1.dist-info/licenses/LICENSE +201 -0
- eda_agent-0.8.1.dist-info/licenses/NOTICE +5 -0
eda_agent/__init__.py
ADDED
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
|
eda_agent/atomicfile.py
ADDED
|
@@ -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
|