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