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