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/agent_watch.py
ADDED
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
"""Library agent, WATCH step: find parts that are new, changed or removed in the projects and BOMs you watch.
|
|
2
|
+
|
|
3
|
+
ecadlib agent-watch [--workspace DIR] [--lib DIR ...] [--source TYPE:REF ...] [--dry-run] [--json]
|
|
4
|
+
|
|
5
|
+
Sources (rules [agent.watch] sources, or --source / the `sources` argument):
|
|
6
|
+
{type = "bom_file", ref = "boms/main.csv"} one CSV / TSV / XLSX BOM (bom_triage reads it)
|
|
7
|
+
{type = "folder", ref = "uploads"} every BOM (and .PrjPcb) under a folder; the web UI's
|
|
8
|
+
uploads/<time>-<random>/<name> layout counts the newest
|
|
9
|
+
upload of each file name as that BOM's current version
|
|
10
|
+
{type = "altium_project", ref = "boards/Main/Main.PrjPcb"} BOM straight from the schematics (altium_project.py)
|
|
11
|
+
{type = "a365_project", ref = "Main board", workspace = "https://acme.365.altium.com", variant = ""}
|
|
12
|
+
an Altium 365 project's BOM through Nexar (NEXAR_TOKEN)
|
|
13
|
+
Optional per source: name, include / exclude (globs, folder only), variant (altium_project / a365_project).
|
|
14
|
+
Relative refs are relative to the workspace. include / exclude in [agent.watch] apply to every folder.
|
|
15
|
+
|
|
16
|
+
A scan reads every source, compares its parts (key = "manufacturer|MPN") with what the last scan saw
|
|
17
|
+
(<workspace>/agent/watch_state.json) and returns Change records:
|
|
18
|
+
new a part that appeared and is NOT already in the workspace library (the KiCad index the triage uses, the
|
|
19
|
+
librarian plans' ready parts, Altium 365 components pulled into a plan or an a365_state.json)
|
|
20
|
+
changed a part whose footprint, value, datasheet or LCSC number changed in the BOM
|
|
21
|
+
removed a part no longer in the source
|
|
22
|
+
Scans are idempotent (a second scan with nothing changed returns []), files whose size and time are unchanged are
|
|
23
|
+
not read again (huge BOMs), and a source that fails is recorded in the state with its error while its last good parts
|
|
24
|
+
are kept, so nothing is reported as removed because a file was briefly unreadable or Nexar was down. The same holds
|
|
25
|
+
for a file that changed while it was read, and for a file or Altium 365 project that suddenly reads as no parts at all
|
|
26
|
+
(a BOM caught half-written): its last good parts are kept and the source reports the error. A source whose
|
|
27
|
+
ref changed (file renamed, rules edited) takes over the old state when the content is the same (same file hash, same
|
|
28
|
+
folder files, same Altium 365 project id), so a rename is not reported as every part removed and added.
|
|
29
|
+
|
|
30
|
+
Altium 365 (UNVERIFIED LIVE: api.nexar.com is blocked from the sandbox this was built in): the queries below are
|
|
31
|
+
validated against tests/nexar/schema.graphql (Nexar schema as of 2024-07) and follow the shapes of Nexar's own demos
|
|
32
|
+
(desProjects(workspaceUrl, first, after) paging; design.variants -> bom -> items -> bomItemInstances). Assumptions:
|
|
33
|
+
DesProject.updatedAt (plus latestRevision.revisionId) moves when the design is saved, so an unchanged project is not
|
|
34
|
+
re-read; a project without variants named in the source uses its first variant; DesComponent.details is allowed inside a
|
|
35
|
+
BOM item (the schema notes it is heavier than desComponentById)."""
|
|
36
|
+
import argparse, contextlib, datetime, fnmatch, hashlib, json, os, re, sys
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
TYPES = ("bom_file", "folder", "altium_project", "a365_project")
|
|
40
|
+
WATCH_FIELDS = ("footprint", "value", "datasheet", "lcsc") # a difference here makes a part "changed"
|
|
41
|
+
LINE_FIELDS = ("mpn", "manufacturer", "value", "footprint", "description", "designator", "qty", "datasheet", "lcsc",
|
|
42
|
+
"libref")
|
|
43
|
+
BOM_EXT = (".csv", ".tsv", ".xlsx", ".xlsm", ".xls")
|
|
44
|
+
PROJECT_EXT = (".prjpcb",)
|
|
45
|
+
UPLOAD_DIR = re.compile(r"\d{8}-\d{6}-[0-9a-f]{6}") # app.Workspace.save_upload's folder names
|
|
46
|
+
PLAN_LIBRARY_STATUS = ("ready", "existing") # plan items that are library parts
|
|
47
|
+
STATE_VERSION = 2 # 2: normalised manufacturer keys
|
|
48
|
+
PAGE = 100
|
|
49
|
+
|
|
50
|
+
# ------------------------------------------------------------------ Nexar (Altium 365); UNVERIFIED LIVE, see module doc
|
|
51
|
+
Q_PROJECTS = """query ($ws: String!, $first: Int!, $after: String) {
|
|
52
|
+
desProjects(workspaceUrl: $ws, first: $first, after: $after) {
|
|
53
|
+
pageInfo { hasNextPage endCursor }
|
|
54
|
+
nodes { id projectId name url updatedAt }
|
|
55
|
+
}
|
|
56
|
+
}"""
|
|
57
|
+
|
|
58
|
+
Q_PROJECT = """query ($id: ID!) {
|
|
59
|
+
desProjectById(id: $id) {
|
|
60
|
+
id projectId name url updatedAt
|
|
61
|
+
latestRevision { revisionId }
|
|
62
|
+
design { variants { name } }
|
|
63
|
+
}
|
|
64
|
+
}"""
|
|
65
|
+
|
|
66
|
+
Q_BOM = """query ($id: ID!, $variant: String!, $first: Int!, $after: String) {
|
|
67
|
+
desProjectById(id: $id) {
|
|
68
|
+
design {
|
|
69
|
+
variants(where: {name: {eq: $variant}}) {
|
|
70
|
+
name
|
|
71
|
+
bom {
|
|
72
|
+
items(first: $first, after: $after) {
|
|
73
|
+
pageInfo { hasNextPage endCursor }
|
|
74
|
+
nodes {
|
|
75
|
+
quantity
|
|
76
|
+
bomItemInstances { designator isFitted }
|
|
77
|
+
component {
|
|
78
|
+
id name comment description
|
|
79
|
+
manufacturerParts { companyName partNumber priority }
|
|
80
|
+
details { parameters { name value } footprints { name } }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}"""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def now_iso(now=None):
|
|
92
|
+
t = now or datetime.datetime.now(datetime.timezone.utc)
|
|
93
|
+
if isinstance(t, str):
|
|
94
|
+
return t
|
|
95
|
+
return t.astimezone(datetime.timezone.utc).replace(microsecond=0).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def part_key(manufacturer, mpn):
|
|
99
|
+
from .agent_queue import norm_manufacturer # "ST" and "STMicroelectronics" are one maker
|
|
100
|
+
return f"{norm_manufacturer(manufacturer)}|{str(mpn or '').strip().upper()}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------------ BOM lines -> parts
|
|
104
|
+
def line_key(ln):
|
|
105
|
+
"""(key, mpn) of a BOM line; None for a line with nothing to identify it. A line without an MPN uses the part
|
|
106
|
+
number the triage would use (an IC's Comment, a non-generic LibRef); a bare passive is keyed by value and footprint."""
|
|
107
|
+
from .bom_triage import part_numbers
|
|
108
|
+
pns = part_numbers(ln)
|
|
109
|
+
mpn = (ln.get("mpn") or "").strip() or (pns[0] if pns else "")
|
|
110
|
+
if mpn:
|
|
111
|
+
return part_key(ln.get("manufacturer"), mpn), mpn
|
|
112
|
+
v, fp = (ln.get("value") or "").strip(), (ln.get("footprint") or "").strip()
|
|
113
|
+
if v or fp:
|
|
114
|
+
return part_key(ln.get("manufacturer"), f"{v} {fp}".strip()), ""
|
|
115
|
+
return None, ""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _qty(v):
|
|
119
|
+
m = re.match(r"\s*(\d+)", str(v or ""))
|
|
120
|
+
return int(m.group(1)) if m else 0
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def parts_from_lines(lines, extra=None):
|
|
124
|
+
"""{key: line} with the same part on several lines merged (designators joined, quantities added)."""
|
|
125
|
+
out = {}
|
|
126
|
+
for ln in lines:
|
|
127
|
+
key, mpn = line_key(ln)
|
|
128
|
+
if not key:
|
|
129
|
+
continue
|
|
130
|
+
rec = {f: str(ln.get(f) or "").strip() for f in LINE_FIELDS if str(ln.get(f) or "").strip()}
|
|
131
|
+
rec["mpn"] = mpn
|
|
132
|
+
if ln.get("dnp") is True:
|
|
133
|
+
rec["dnp"] = True
|
|
134
|
+
rec.update(extra or {})
|
|
135
|
+
prev = out.get(key)
|
|
136
|
+
if prev is None:
|
|
137
|
+
out[key] = rec
|
|
138
|
+
continue
|
|
139
|
+
if rec.get("designator"):
|
|
140
|
+
prev["designator"] = ", ".join(x for x in (prev.get("designator"), rec["designator"]) if x)
|
|
141
|
+
if prev.get("qty") or rec.get("qty"):
|
|
142
|
+
prev["qty"] = str(_qty(prev.get("qty")) + _qty(rec.get("qty")))
|
|
143
|
+
for f in LINE_FIELDS:
|
|
144
|
+
if rec.get(f) and not prev.get(f):
|
|
145
|
+
prev[f] = rec[f]
|
|
146
|
+
if prev.get("dnp") and not rec.get("dnp"):
|
|
147
|
+
prev.pop("dnp")
|
|
148
|
+
return out
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def read_bom_parts(path, rules):
|
|
152
|
+
from . import bom_triage as T, rules as RU
|
|
153
|
+
lines, _ = T.read_bom(path, RU.bom_columns(rules), rules)
|
|
154
|
+
return parts_from_lines(lines)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _project_parts(path, rules, variant=None):
|
|
158
|
+
from .altium_project import bom_lines
|
|
159
|
+
with contextlib.redirect_stderr(open(os.devnull, "w")): # project warnings go to the scan's own report
|
|
160
|
+
lines, _ = bom_lines(path, variant=variant or None, rules=rules)
|
|
161
|
+
return parts_from_lines(lines)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ------------------------------------------------------------------ is it already in the workspace library?
|
|
165
|
+
class Library:
|
|
166
|
+
"""What the workspace already has, built on first use: the KiCad index triage uses (--lib folders + approved
|
|
167
|
+
footprints), the librarian plans' ready / existing parts and the Altium 365 components pulled or snapshotted."""
|
|
168
|
+
|
|
169
|
+
def __init__(self, root, libs=(), rules=None):
|
|
170
|
+
self.root, self.libs, self.rules = Path(root), list(libs), rules
|
|
171
|
+
self._index = self._keys = self._idx = None
|
|
172
|
+
|
|
173
|
+
def _plan_keys(self):
|
|
174
|
+
if self._keys is None:
|
|
175
|
+
from .a365_sync import mpn_of
|
|
176
|
+
from .dedupe import mpn_key
|
|
177
|
+
keys = set()
|
|
178
|
+
plans = list(self.root.glob("librarian/*/plan.json")) + list(self.root.glob("altium365/*plan*.json"))
|
|
179
|
+
for f in plans:
|
|
180
|
+
try:
|
|
181
|
+
items = json.loads(f.read_text())
|
|
182
|
+
except (OSError, ValueError):
|
|
183
|
+
continue
|
|
184
|
+
items = items.get("items", []) if isinstance(items, dict) else items
|
|
185
|
+
for it in items if isinstance(items, list) else []:
|
|
186
|
+
if isinstance(it, dict) and it.get("status") in PLAN_LIBRARY_STATUS:
|
|
187
|
+
keys |= {mpn_key(x) for x in (mpn_of(it, self.rules), it.get("name")) if x}
|
|
188
|
+
for f in self.root.glob("**/a365_state.json"):
|
|
189
|
+
try:
|
|
190
|
+
data = json.loads(f.read_text())
|
|
191
|
+
except (OSError, ValueError):
|
|
192
|
+
continue
|
|
193
|
+
for name, snap in (data.items() if isinstance(data, dict) else []):
|
|
194
|
+
par = (snap or {}).get("parameters") if isinstance(snap, dict) else None
|
|
195
|
+
keys |= {mpn_key(x) for x in (name, mpn_of(par or {}, self.rules) if par else "") if x}
|
|
196
|
+
keys.discard("")
|
|
197
|
+
self._keys = keys
|
|
198
|
+
return self._keys
|
|
199
|
+
|
|
200
|
+
def index(self):
|
|
201
|
+
if self._index is None:
|
|
202
|
+
from .kicad_index import KiCadIndex, norm_mpn
|
|
203
|
+
self._index = KiCadIndex(self.libs + [str(self.root / "footprints")])
|
|
204
|
+
mpns = {norm_mpn(x) for s in self._index.symbols for x in (s.name, s.mpn) if x}
|
|
205
|
+
self._idx = (mpns, {s.lcsc.upper() for s in self._index.symbols if s.lcsc})
|
|
206
|
+
return self._index
|
|
207
|
+
|
|
208
|
+
def has(self, line):
|
|
209
|
+
from . import bom_triage as T
|
|
210
|
+
from .dedupe import mpn_key
|
|
211
|
+
from .kicad_index import norm_mpn, parse_value
|
|
212
|
+
pns = T.part_numbers(line)
|
|
213
|
+
keys = self._plan_keys()
|
|
214
|
+
if any(mpn_key(p) in keys for p in pns):
|
|
215
|
+
return True
|
|
216
|
+
index = self.index()
|
|
217
|
+
mpns, lcscs = self._idx
|
|
218
|
+
# index.match finds a symbol by MPN, LCSC or (passives) value + package; only those can be in_library, so the
|
|
219
|
+
# triage (one pass over every symbol) runs only for lines that can match: fast on huge BOMs.
|
|
220
|
+
if not (any(norm_mpn(p) in mpns for p in pns) or (line.get("lcsc") or "").upper() in lcscs - {""}
|
|
221
|
+
or parse_value(line.get("value") or "") is not None):
|
|
222
|
+
return False
|
|
223
|
+
ln = {k: v for k, v in line.items() if k != "dnp"}
|
|
224
|
+
row = T.triage([ln], index, [], None)[0]
|
|
225
|
+
return row["status"].startswith("in_library")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ------------------------------------------------------------------ sources
|
|
229
|
+
def norm_source(src, root):
|
|
230
|
+
"""A source dict (or a bare path) -> {type, ref, name, ...} with the path resolved; raises ValueError."""
|
|
231
|
+
if isinstance(src, str):
|
|
232
|
+
ref = src.strip()
|
|
233
|
+
p = _path(ref, root)
|
|
234
|
+
typ = ("folder" if p.is_dir() else "altium_project" if p.suffix.lower() in PROJECT_EXT + (".schdoc", ".bomdoc")
|
|
235
|
+
else "bom_file")
|
|
236
|
+
src = {"type": typ, "ref": ref}
|
|
237
|
+
if not isinstance(src, dict):
|
|
238
|
+
raise ValueError(f"watch source {src!r}: expected {{type, ref}}")
|
|
239
|
+
typ, ref = src.get("type"), str(src.get("ref") or "").strip()
|
|
240
|
+
if typ not in TYPES:
|
|
241
|
+
raise ValueError(f"watch source {src!r}: type must be one of {', '.join(TYPES)}")
|
|
242
|
+
if not ref:
|
|
243
|
+
raise ValueError(f"watch source {src!r}: ref is empty")
|
|
244
|
+
out = dict(src, type=typ, ref=ref)
|
|
245
|
+
out.setdefault("name", ref if typ == "a365_project" else Path(ref).name or ref)
|
|
246
|
+
return out
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def source_id(src):
|
|
250
|
+
if src["type"] == "a365_project":
|
|
251
|
+
return f"a365_project:{(src.get('workspace') or '').rstrip('/')}#{src['ref']}"
|
|
252
|
+
return f"{src['type']}:{src['ref']}"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _path(ref, root):
|
|
256
|
+
p = Path(ref).expanduser()
|
|
257
|
+
return p if p.is_absolute() else Path(root) / p
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _file_sig(p, old=None):
|
|
261
|
+
"""{size, mtime_ns, sha1}; sha1 is only recomputed when size or time moved."""
|
|
262
|
+
st = p.stat()
|
|
263
|
+
sig = {"size": st.st_size, "mtime_ns": st.st_mtime_ns}
|
|
264
|
+
if old and old.get("size") == sig["size"] and old.get("mtime_ns") == sig["mtime_ns"] and old.get("sha1"):
|
|
265
|
+
return dict(sig, sha1=old["sha1"])
|
|
266
|
+
h = hashlib.sha1()
|
|
267
|
+
with open(p, "rb") as f:
|
|
268
|
+
for b in iter(lambda: f.read(1 << 20), b""):
|
|
269
|
+
h.update(b)
|
|
270
|
+
return dict(sig, sha1=h.hexdigest())
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _same_file(a, b):
|
|
274
|
+
return bool(a and b) and a.get("sha1") == b.get("sha1") and a.get("size") == b.get("size")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _globs(src, cfg):
|
|
278
|
+
inc = list(src.get("include") or []) + list(cfg.get("include") or [])
|
|
279
|
+
exc = list(src.get("exclude") or []) + list(cfg.get("exclude") or [])
|
|
280
|
+
return inc, exc
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _match(rel, pats):
|
|
284
|
+
name = rel.rsplit("/", 1)[-1]
|
|
285
|
+
return any(fnmatch.fnmatch(rel, p) or fnmatch.fnmatch(name, p) for p in pats)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def folder_files(folder, include=(), exclude=()):
|
|
289
|
+
"""{identity: path} of the BOMs / projects under folder. In an uploads/<stamp>-<rand>/<name> layout the identity
|
|
290
|
+
is the file name and only the newest upload of each name counts (a re-upload is a new version of that BOM)."""
|
|
291
|
+
folder = Path(folder)
|
|
292
|
+
found = {}
|
|
293
|
+
for p in sorted(folder.rglob("*")):
|
|
294
|
+
if not p.is_file() or p.is_symlink() or p.suffix.lower() not in BOM_EXT + PROJECT_EXT:
|
|
295
|
+
continue
|
|
296
|
+
rel = p.relative_to(folder).as_posix()
|
|
297
|
+
if any(part.startswith(".") for part in rel.split("/")) or p.name.startswith("~$"): # hidden, Excel lock files
|
|
298
|
+
continue
|
|
299
|
+
if include and not _match(rel, include):
|
|
300
|
+
continue
|
|
301
|
+
if exclude and _match(rel, exclude):
|
|
302
|
+
continue
|
|
303
|
+
up = UPLOAD_DIR.fullmatch(p.parent.name)
|
|
304
|
+
ident = p.name if up else rel
|
|
305
|
+
if ident in found and up and found[ident][0] > p.parent.name:
|
|
306
|
+
continue
|
|
307
|
+
found[ident] = (p.parent.name if up else "", p)
|
|
308
|
+
return {k: v[1] for k, v in sorted(found.items())}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _project_sig(p):
|
|
312
|
+
"""Size and time of the .PrjPcb and every document it names: any saved sheet re-reads the project."""
|
|
313
|
+
from .altium_project import read_prjpcb
|
|
314
|
+
files = [p]
|
|
315
|
+
if p.suffix.lower() in PROJECT_EXT:
|
|
316
|
+
try:
|
|
317
|
+
prj = read_prjpcb(p)
|
|
318
|
+
files += [Path(x) for x in list(prj["schdocs"]) + list(prj["bomdocs"])]
|
|
319
|
+
except (OSError, ValueError):
|
|
320
|
+
pass
|
|
321
|
+
sig = []
|
|
322
|
+
for f in files:
|
|
323
|
+
try:
|
|
324
|
+
st = f.stat()
|
|
325
|
+
sig.append([f.name, st.st_size, st.st_mtime_ns])
|
|
326
|
+
except OSError:
|
|
327
|
+
sig.append([f.name, None, None])
|
|
328
|
+
return {"docs": sig}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def scan_files(src, root, old_files, rules, cfg):
|
|
332
|
+
"""(files {ident: {path, sig, parts}}, file errors [str]) for bom_file / folder / altium_project. A file that can't
|
|
333
|
+
be read keeps its old entry (its parts are not reported as removed) and adds an error."""
|
|
334
|
+
p = _path(src["ref"], root)
|
|
335
|
+
typ = src["type"]
|
|
336
|
+
if typ == "folder":
|
|
337
|
+
if not p.is_dir():
|
|
338
|
+
raise FileNotFoundError(f"{src['ref']}: folder not found")
|
|
339
|
+
inc, exc = _globs(src, cfg)
|
|
340
|
+
todo = folder_files(p, inc, exc)
|
|
341
|
+
else:
|
|
342
|
+
if not p.is_file():
|
|
343
|
+
raise FileNotFoundError(f"{src['ref']}: file not found")
|
|
344
|
+
todo = {"": p}
|
|
345
|
+
files, errors = {}, []
|
|
346
|
+
for ident, f in todo.items():
|
|
347
|
+
old = (old_files or {}).get(ident) or {}
|
|
348
|
+
project = typ == "altium_project" or f.suffix.lower() in PROJECT_EXT
|
|
349
|
+
try:
|
|
350
|
+
sig = _project_sig(f) if project else _file_sig(f, old.get("sig"))
|
|
351
|
+
if old.get("sig") == sig or (not project and _same_file(old.get("sig"), sig)):
|
|
352
|
+
files[ident] = dict(old, path=str(f), sig=sig) # unchanged: not parsed again
|
|
353
|
+
continue
|
|
354
|
+
parts = _project_parts(f, rules, src.get("variant")) if project else read_bom_parts(f, rules)
|
|
355
|
+
if not project:
|
|
356
|
+
st = f.stat()
|
|
357
|
+
if (st.st_size, st.st_mtime_ns) != (sig["size"], sig["mtime_ns"]):
|
|
358
|
+
raise OSError("the file changed while it was read (being saved?): read again next scan")
|
|
359
|
+
if not parts and old.get("parts"):
|
|
360
|
+
# a BOM caught half-written (header only) or a project mid-save reads as "no parts": that is not every
|
|
361
|
+
# part removed. Kept until it has parts again; a source really emptied is removed from the rules.
|
|
362
|
+
raise ValueError(f"no parts read (it had {len(old['parts'])}): kept the last good parts")
|
|
363
|
+
files[ident] = {"path": str(f), "sig": sig, "parts": parts}
|
|
364
|
+
except Exception as e:
|
|
365
|
+
errors.append(f"{ident or f.name}: {type(e).__name__}: {e}")
|
|
366
|
+
if old:
|
|
367
|
+
files[ident] = old
|
|
368
|
+
return files, errors
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
# ------------------------------------------------------------------ Altium 365 through Nexar
|
|
372
|
+
def _paged(client, query, variables, conn_of):
|
|
373
|
+
after, out = None, []
|
|
374
|
+
while True:
|
|
375
|
+
conn = conn_of(client.gql(query, dict(variables, after=after))) or {}
|
|
376
|
+
out += conn.get("nodes") or []
|
|
377
|
+
pi = conn.get("pageInfo") or {}
|
|
378
|
+
if not pi.get("hasNextPage") or not pi.get("endCursor") or pi.get("endCursor") == after:
|
|
379
|
+
return out
|
|
380
|
+
after = pi["endCursor"]
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def a365_projects(client, ws):
|
|
384
|
+
return _paged(client, Q_PROJECTS, {"ws": ws, "first": PAGE}, lambda d: d.get("desProjects"))
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def find_project(client, ws, ref, known_id=None):
|
|
388
|
+
"""The project node for a source: by the id the state remembers (survives a rename), else by node id, projectId,
|
|
389
|
+
name (case-insensitive) or URL."""
|
|
390
|
+
projects = a365_projects(client, ws)
|
|
391
|
+
if known_id:
|
|
392
|
+
hit = next((p for p in projects if p.get("id") == known_id), None)
|
|
393
|
+
if hit:
|
|
394
|
+
return hit
|
|
395
|
+
r = ref.strip()
|
|
396
|
+
for test in (lambda p: r in (p.get("id"), p.get("projectId"), p.get("url")),
|
|
397
|
+
lambda p: (p.get("name") or "").strip().lower() == r.lower()):
|
|
398
|
+
hits = [p for p in projects if test(p)]
|
|
399
|
+
if len(hits) > 1:
|
|
400
|
+
raise ValueError(f"{len(hits)} Altium 365 projects match {ref!r}; use its id or URL as ref")
|
|
401
|
+
if hits:
|
|
402
|
+
return hits[0]
|
|
403
|
+
raise LookupError(f"Altium 365 project {ref!r} not found in {ws}")
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _params(comp):
|
|
407
|
+
return {p.get("name"): p.get("value") or "" for p in ((comp.get("details") or {}).get("parameters") or [])}
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def bom_item_line(item, rules=None):
|
|
411
|
+
"""A Nexar DesBomItem -> a BOM line (the fields read_bom gives for a CSV)."""
|
|
412
|
+
from .a365_sync import MPN_NAMES, mpn_of
|
|
413
|
+
from .altium_project import _datasheet
|
|
414
|
+
comp = item.get("component") or {}
|
|
415
|
+
par = _params(comp)
|
|
416
|
+
mps = sorted((m for m in comp.get("manufacturerParts") or [] if (m.get("partNumber") or "").strip()),
|
|
417
|
+
key=lambda m: m.get("priority") if isinstance(m.get("priority"), int) else 1 << 30)
|
|
418
|
+
mp = mps[0] if mps else {}
|
|
419
|
+
named = next((par[k] for k in MPN_NAMES if (par.get(k) or "").strip()), "") or (mpn_of(par, rules) if par else "")
|
|
420
|
+
mpn = (mp.get("partNumber") or "").strip() or named or comp.get("name") or ""
|
|
421
|
+
inst = item.get("bomItemInstances") or []
|
|
422
|
+
fitted = [i for i in inst if i.get("isFitted", True)]
|
|
423
|
+
fps = (comp.get("details") or {}).get("footprints") or []
|
|
424
|
+
line = {"mpn": mpn, "manufacturer": (mp.get("companyName") or par.get("Manufacturer") or "").strip(),
|
|
425
|
+
"value": par.get("Value") or comp.get("comment") or "", "footprint": fps[0].get("name", "") if fps else "",
|
|
426
|
+
"description": comp.get("description") or "", "datasheet": _datasheet(par),
|
|
427
|
+
"designator": ", ".join(i.get("designator", "") for i in inst if i.get("designator")),
|
|
428
|
+
"qty": str(item.get("quantity") if item.get("quantity") is not None else len(fitted)),
|
|
429
|
+
"lcsc": next((v for k, v in par.items() if k and k.strip().lower() in ("lcsc", "lcsc part", "lcsc part number")), ""),
|
|
430
|
+
"a365_component": comp.get("name") or ""}
|
|
431
|
+
if line["value"].startswith("="): # Altium's "=Value" comment: the parameter, not text
|
|
432
|
+
line["value"] = par.get(line["value"][1:], "")
|
|
433
|
+
if inst and not fitted:
|
|
434
|
+
line["dnp"] = True
|
|
435
|
+
return line
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def scan_a365(src, ws, client, old, rules):
|
|
439
|
+
"""(files, errors, meta) for one Altium 365 project. Its BOM is only fetched when updatedAt / the latest revision
|
|
440
|
+
moved since the last scan."""
|
|
441
|
+
if not ws:
|
|
442
|
+
raise ValueError("no Altium 365 workspace: set workspace on the source or [altium365] workspace in the rules")
|
|
443
|
+
if client is None:
|
|
444
|
+
tok = os.environ.get("NEXAR_TOKEN")
|
|
445
|
+
if not tok:
|
|
446
|
+
raise ValueError("NEXAR_TOKEN is not set (a Nexar token with the design.domain, user.access, openid scopes)")
|
|
447
|
+
from .altium365 import Nexar
|
|
448
|
+
client = Nexar(tok)
|
|
449
|
+
meta_old = (old or {}).get("a365") or {}
|
|
450
|
+
proj = find_project(client, ws, src["ref"], meta_old.get("id"))
|
|
451
|
+
full = client.gql(Q_PROJECT, {"id": proj["id"]})["desProjectById"] or {}
|
|
452
|
+
variants = [v.get("name") for v in ((full.get("design") or {}).get("variants") or []) if v.get("name") is not None]
|
|
453
|
+
variant = src.get("variant") or (variants[0] if variants else "")
|
|
454
|
+
if src.get("variant") and src["variant"] not in variants:
|
|
455
|
+
raise LookupError(f"{proj.get('name')}: no variant {src['variant']!r} (has: {', '.join(map(str, variants)) or 'none'})")
|
|
456
|
+
sig = {"updatedAt": full.get("updatedAt") or proj.get("updatedAt"),
|
|
457
|
+
"revision": (full.get("latestRevision") or {}).get("revisionId"), "variant": variant}
|
|
458
|
+
meta = {"id": proj["id"], "projectId": proj.get("projectId"), "name": proj.get("name") or src["ref"],
|
|
459
|
+
"url": proj.get("url"), "variant": variant}
|
|
460
|
+
old_f = ((old or {}).get("files") or {}).get(proj["id"])
|
|
461
|
+
if old_f and old_f.get("sig") == sig:
|
|
462
|
+
return {proj["id"]: old_f}, [], meta
|
|
463
|
+
|
|
464
|
+
def conn_of(d):
|
|
465
|
+
vs = ((d.get("desProjectById") or {}).get("design") or {}).get("variants") or []
|
|
466
|
+
return ((vs[0].get("bom") or {}).get("items") if vs else None) or {}
|
|
467
|
+
items = _paged(client, Q_BOM, {"id": proj["id"], "variant": variant, "first": PAGE}, conn_of)
|
|
468
|
+
parts = parts_from_lines([bom_item_line(i, rules) for i in items])
|
|
469
|
+
if not parts and old_f and old_f.get("parts"):
|
|
470
|
+
raise ValueError(f"{meta['name']}: Altium 365 returned no BOM items (it had {len(old_f['parts'])}): kept the last good parts")
|
|
471
|
+
return {proj["id"]: {"path": proj.get("url") or "", "sig": sig, "parts": parts}}, [], meta
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
# ------------------------------------------------------------------ state
|
|
475
|
+
def state_path(root):
|
|
476
|
+
return Path(root) / "agent" / "watch_state.json"
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def load_state(root):
|
|
480
|
+
f = state_path(root)
|
|
481
|
+
if not f.exists():
|
|
482
|
+
return {"version": STATE_VERSION, "sources": {}}
|
|
483
|
+
data = json.loads(f.read_text(encoding="utf-8"))
|
|
484
|
+
data.setdefault("sources", {})
|
|
485
|
+
from .agent_queue import aliases_sig
|
|
486
|
+
if int(data.get("version") or 1) < 2 or data.get("aliases") != aliases_sig(): # keys from another name table
|
|
487
|
+
for ent in data["sources"].values():
|
|
488
|
+
for fe in (ent.get("files") or {}).values():
|
|
489
|
+
if isinstance(fe, dict) and isinstance(fe.get("parts"), dict):
|
|
490
|
+
new = {}
|
|
491
|
+
for k, ln in fe["parts"].items():
|
|
492
|
+
m, _, n = str(k).partition("|")
|
|
493
|
+
new.setdefault(part_key((ln or {}).get("manufacturer") or m, n), ln)
|
|
494
|
+
fe["parts"] = new
|
|
495
|
+
data["version"], data["aliases"] = STATE_VERSION, aliases_sig()
|
|
496
|
+
return data
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def save_state(root, state):
|
|
500
|
+
f = state_path(root)
|
|
501
|
+
f.parent.mkdir(parents=True, exist_ok=True)
|
|
502
|
+
tmp = f.with_name(f"{f.name}.{os.getpid()}.tmp")
|
|
503
|
+
tmp.write_text(json.dumps(state, indent=1, sort_keys=True), encoding="utf-8")
|
|
504
|
+
os.replace(tmp, f)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
@contextlib.contextmanager
|
|
508
|
+
def _lock(root, create=True):
|
|
509
|
+
f = Path(root) / "agent" / "watch_state.lock"
|
|
510
|
+
if not create and not f.exists(): # a dry run makes no files; nothing ever wrote the state
|
|
511
|
+
yield
|
|
512
|
+
return
|
|
513
|
+
f.parent.mkdir(parents=True, exist_ok=True)
|
|
514
|
+
with open(f, "a" if create else "r") as h:
|
|
515
|
+
try:
|
|
516
|
+
import fcntl
|
|
517
|
+
except ImportError: # Windows: no advisory lock, single-writer use
|
|
518
|
+
yield
|
|
519
|
+
return
|
|
520
|
+
fcntl.flock(h, fcntl.LOCK_EX)
|
|
521
|
+
try:
|
|
522
|
+
yield
|
|
523
|
+
finally:
|
|
524
|
+
fcntl.flock(h, fcntl.LOCK_UN)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def merged_parts(files):
|
|
528
|
+
"""One {key: line} over a source's files (sorted by identity, first file wins; line.file names it)."""
|
|
529
|
+
out = {}
|
|
530
|
+
for ident in sorted(files or {}):
|
|
531
|
+
for k, ln in ((files[ident] or {}).get("parts") or {}).items():
|
|
532
|
+
if k not in out:
|
|
533
|
+
out[k] = dict(ln, file=ident) if ident else ln
|
|
534
|
+
return out
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _adopt(state, sid, src, new_files, meta, active):
|
|
538
|
+
"""An old state entry for the same content under another ref (renamed file or folder, edited rules, renamed
|
|
539
|
+
Altium 365 project): its id, or None."""
|
|
540
|
+
for osid, ent in state["sources"].items():
|
|
541
|
+
if osid in active or ent.get("type") != src["type"]:
|
|
542
|
+
continue
|
|
543
|
+
of = ent.get("files") or {}
|
|
544
|
+
if src["type"] == "a365_project":
|
|
545
|
+
if meta and (ent.get("a365") or {}).get("id") == meta.get("id"):
|
|
546
|
+
return osid
|
|
547
|
+
elif src["type"] == "folder":
|
|
548
|
+
if of and set(of) == set(new_files) and all(
|
|
549
|
+
_same_file((of[i] or {}).get("sig"), (new_files[i] or {}).get("sig")) for i in of):
|
|
550
|
+
return osid
|
|
551
|
+
elif "" in of and "" in new_files and (
|
|
552
|
+
_same_file(of[""].get("sig"), new_files[""].get("sig")) or of[""].get("sig") == new_files[""].get("sig")):
|
|
553
|
+
return osid
|
|
554
|
+
return None
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _change(kind, key, line, was, src, when):
|
|
558
|
+
ln = line if kind != "removed" else was
|
|
559
|
+
# a line without an MPN (a bare passive) is keyed "|VALUE FOOTPRINT": the Change's mpn says the same, so the key
|
|
560
|
+
# stays manufacturer|MPN (agent_queue refuses an item without an MPN); line.mpn stays empty for the builder
|
|
561
|
+
mpn = ln.get("mpn") or " ".join(x for x in (str(ln.get("value") or "").strip(), str(ln.get("footprint") or "").strip()) if x)
|
|
562
|
+
return {"key": key, "mpn": mpn, "manufacturer": ln.get("manufacturer", ""), "kind": kind,
|
|
563
|
+
"source": {"type": src["type"], "ref": src["ref"], "name": src.get("name") or src["ref"]},
|
|
564
|
+
"line": dict(line or {}), "was": dict(was or {}), "detected": when}
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _merge_blank(old, new):
|
|
568
|
+
"""A line without a manufacturer is the same part as the one known maker of that MPN in this source (before or
|
|
569
|
+
now), so adding or dropping the manufacturer is not "removed" + "new". Returns (old, new) re-keyed."""
|
|
570
|
+
makers = {}
|
|
571
|
+
for k in list(old) + list(new):
|
|
572
|
+
m, _, n = k.partition("|")
|
|
573
|
+
if m:
|
|
574
|
+
makers.setdefault(n, set()).add(m)
|
|
575
|
+
|
|
576
|
+
def rekey(parts):
|
|
577
|
+
out = {}
|
|
578
|
+
for k, ln in parts.items():
|
|
579
|
+
m, _, n = k.partition("|")
|
|
580
|
+
if not m and len(makers.get(n, ())) == 1:
|
|
581
|
+
k = f"{next(iter(makers[n]))}|{n}"
|
|
582
|
+
if k in parts:
|
|
583
|
+
continue # the known-maker line is there too: one part
|
|
584
|
+
out.setdefault(k, ln)
|
|
585
|
+
return out
|
|
586
|
+
return rekey(old), rekey(new)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def diff(old, new, src, when, library=None):
|
|
590
|
+
"""Change records between two {key: line} maps."""
|
|
591
|
+
old, new = _merge_blank(old, new)
|
|
592
|
+
out = []
|
|
593
|
+
for k, ln in new.items():
|
|
594
|
+
prev = old.get(k)
|
|
595
|
+
if prev is None:
|
|
596
|
+
if library is None or not library.has(ln):
|
|
597
|
+
out.append(_change("new", k, ln, {}, src, when))
|
|
598
|
+
elif any((ln.get(f) or "") != (prev.get(f) or "") for f in WATCH_FIELDS):
|
|
599
|
+
out.append(_change("changed", k, ln, prev, src, when))
|
|
600
|
+
out += [_change("removed", k, {}, ln, src, when) for k, ln in old.items() if k not in new]
|
|
601
|
+
return out
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
# ------------------------------------------------------------------ scan
|
|
605
|
+
def watch_rules(rules):
|
|
606
|
+
return dict(((rules or {}).get("agent") or {}).get("watch") or {})
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _workspace_parts(workspace, rules):
|
|
610
|
+
if not isinstance(workspace, (str, os.PathLike)) and hasattr(workspace, "rules"): # a Workspace
|
|
611
|
+
r = rules if rules is not None else workspace.rules()
|
|
612
|
+
return Path(workspace.root), list(getattr(workspace, "libs", []) or []), r
|
|
613
|
+
from . import rules as RU
|
|
614
|
+
root = Path(workspace).resolve()
|
|
615
|
+
return root, [], RU.resolve(rules, root)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def scan_report(workspace, rules=None, sources=None, client=None, now=None, dry_run=False, libs=None):
|
|
619
|
+
"""scan() plus what happened per source: {changes, errors: [{source, error}], sources: [{id, type, ref, name,
|
|
620
|
+
status, parts, error}], state}. dry_run computes the changes without writing the state."""
|
|
621
|
+
root, wlibs, rules = _workspace_parts(workspace, rules)
|
|
622
|
+
from .agent_queue import use_rules
|
|
623
|
+
use_rules(rules) # [agent] manufacturer_aliases for part keys
|
|
624
|
+
cfg = watch_rules(rules)
|
|
625
|
+
when = now_iso(now)
|
|
626
|
+
srcs = [norm_source(s, root) for s in (sources if sources is not None else cfg.get("sources") or [])]
|
|
627
|
+
library = Library(root, list(libs if libs is not None else wlibs), rules)
|
|
628
|
+
changes, errors, summary = [], [], []
|
|
629
|
+
with _lock(root, create=not dry_run):
|
|
630
|
+
state = load_state(root)
|
|
631
|
+
active = {source_id(s) for s in srcs}
|
|
632
|
+
for src in srcs:
|
|
633
|
+
if src["type"] == "a365_project":
|
|
634
|
+
src = dict(src, workspace=src.get("workspace") or (rules.get("altium365") or {}).get("workspace") or "")
|
|
635
|
+
sid = source_id(src)
|
|
636
|
+
ent = state["sources"].get(sid)
|
|
637
|
+
meta, ferrs = None, []
|
|
638
|
+
try:
|
|
639
|
+
if src["type"] == "a365_project":
|
|
640
|
+
files, ferrs, meta = scan_a365(src, src["workspace"], client, ent, rules)
|
|
641
|
+
src = dict(src, name=src.get("name") if src.get("name") != src["ref"] else meta["name"])
|
|
642
|
+
else:
|
|
643
|
+
files, ferrs = scan_files(src, root, (ent or {}).get("files"), rules, cfg)
|
|
644
|
+
except Exception as e:
|
|
645
|
+
msg = f"{type(e).__name__}: {e}"
|
|
646
|
+
errors.append({"source": sid, "error": msg})
|
|
647
|
+
ent = state["sources"].setdefault(sid, {"type": src["type"], "ref": src["ref"], "name": src.get("name"),
|
|
648
|
+
"files": {}, "first_seen": when})
|
|
649
|
+
ent.update(last_scan=when, error=msg, error_since=ent.get("error_since") or when,
|
|
650
|
+
errors=int(ent.get("errors") or 0) + 1)
|
|
651
|
+
summary.append({"id": sid, "type": src["type"], "ref": src["ref"], "name": src.get("name"),
|
|
652
|
+
"status": "error", "error": msg, "parts": len(merged_parts(ent.get("files")))})
|
|
653
|
+
continue
|
|
654
|
+
adopted = None
|
|
655
|
+
if ent is None:
|
|
656
|
+
adopted = _adopt(state, sid, src, files, meta, active)
|
|
657
|
+
if adopted:
|
|
658
|
+
ent = state["sources"].pop(adopted)
|
|
659
|
+
ent["renamed_from"] = ent.get("ref") or adopted
|
|
660
|
+
old_parts = merged_parts((ent or {}).get("files"))
|
|
661
|
+
new_parts = merged_parts(files)
|
|
662
|
+
found = diff(old_parts, new_parts, src, when, library)
|
|
663
|
+
changes += found
|
|
664
|
+
for m in ferrs:
|
|
665
|
+
errors.append({"source": sid, "error": m})
|
|
666
|
+
ent = dict(ent or {"first_seen": when}, type=src["type"], ref=src["ref"], name=src.get("name"), files=files,
|
|
667
|
+
last_scan=when, last_ok=when, error=("; ".join(ferrs) or None), errors=len(ferrs))
|
|
668
|
+
ent.pop("error_since", None)
|
|
669
|
+
if ferrs:
|
|
670
|
+
ent["error_since"] = when
|
|
671
|
+
if meta:
|
|
672
|
+
ent["a365"] = meta
|
|
673
|
+
state["sources"][sid] = ent
|
|
674
|
+
summary.append({"id": sid, "type": src["type"], "ref": src["ref"], "name": src.get("name"),
|
|
675
|
+
"status": "partial" if ferrs else "ok", "error": ent["error"], "parts": len(new_parts),
|
|
676
|
+
"files": len(files), "changes": len(found), **({"renamed_from": adopted} if adopted else {})})
|
|
677
|
+
from .agent_queue import aliases_sig
|
|
678
|
+
state["version"], state["last_scan"], state["aliases"] = STATE_VERSION, when, aliases_sig()
|
|
679
|
+
if not dry_run:
|
|
680
|
+
save_state(root, state)
|
|
681
|
+
return {"changes": changes, "errors": errors, "sources": summary, "dry_run": bool(dry_run),
|
|
682
|
+
"state": str(state_path(root))}
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def scan(workspace, rules=None, sources=None, client=None, now=None, dry_run=False):
|
|
686
|
+
"""Change records for every watched source (see module doc). workspace: a Workspace or its folder."""
|
|
687
|
+
return scan_report(workspace, rules, sources, client, now, dry_run)["changes"]
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
# ------------------------------------------------------------------ CLI
|
|
691
|
+
def _parse_source(s):
|
|
692
|
+
typ, sep, ref = s.partition(":")
|
|
693
|
+
if sep and typ in TYPES:
|
|
694
|
+
return {"type": typ, "ref": ref}
|
|
695
|
+
return s
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def main(argv=None):
|
|
699
|
+
ap = argparse.ArgumentParser(prog="ecadlib agent-watch", description=__doc__.split("\n\n")[0],
|
|
700
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
701
|
+
ap.add_argument("--workspace", default="ecadlib_workspace", help="workspace folder (state in <workspace>/agent/)")
|
|
702
|
+
ap.add_argument("--lib", action="append", default=[], help="KiCad library folder(s) the library check uses")
|
|
703
|
+
ap.add_argument("--source", action="append", default=[],
|
|
704
|
+
help="TYPE:REF (bom_file, folder, altium_project, a365_project) or a path; replaces the rules' sources")
|
|
705
|
+
ap.add_argument("--rules", help="rules file (default: <workspace>/ecadlib.toml, then the usual search)")
|
|
706
|
+
ap.add_argument("--dry-run", action="store_true", help="report changes without saving the watch state")
|
|
707
|
+
ap.add_argument("--json", action="store_true", help="print the full report as JSON")
|
|
708
|
+
a = ap.parse_args(argv)
|
|
709
|
+
from . import rules as RU
|
|
710
|
+
root = Path(a.workspace).resolve()
|
|
711
|
+
rules = RU.load(a.rules) if a.rules else RU.load(None, root)
|
|
712
|
+
rep = scan_report(root, rules, [_parse_source(s) for s in a.source] or None, dry_run=a.dry_run,
|
|
713
|
+
libs=[str(Path(x).resolve()) for x in a.lib])
|
|
714
|
+
if a.json:
|
|
715
|
+
print(json.dumps(rep, indent=1))
|
|
716
|
+
else:
|
|
717
|
+
if not rep["sources"]:
|
|
718
|
+
print("no watch sources: add [agent.watch] sources to ecadlib.toml or pass --source")
|
|
719
|
+
for s in rep["sources"]:
|
|
720
|
+
print(f"{s['status']:8} {s['type']:15} {s['name']}: {s['parts']} parts"
|
|
721
|
+
+ (f", {s.get('changes', 0)} changes" if s["status"] != "error" else f" (kept) - {s['error']}"))
|
|
722
|
+
for c in rep["changes"]:
|
|
723
|
+
diff = ", ".join("%s: %r -> %r" % (f, c["was"].get(f, ""), c["line"].get(f, ""))
|
|
724
|
+
for f in WATCH_FIELDS if c["was"].get(f, "") != c["line"].get(f, ""))
|
|
725
|
+
print(f" {c['kind']:8} {c['mpn'] or c['key']:30} {c['source']['name']}"
|
|
726
|
+
+ (f" ({diff})" if c["kind"] == "changed" else ""))
|
|
727
|
+
print(f"{len(rep['changes'])} change(s)" + (" (dry run: state not saved)" if a.dry_run else f"; state {rep['state']}"))
|
|
728
|
+
return 1 if rep["errors"] else 0
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
if __name__ == "__main__":
|
|
732
|
+
sys.exit(main())
|