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.
Files changed (199) hide show
  1. ecadlib/__init__.py +3 -0
  2. ecadlib/__main__.py +5 -0
  3. ecadlib/a365_sync.py +637 -0
  4. ecadlib/agent.py +1407 -0
  5. ecadlib/agent_queue.py +780 -0
  6. ecadlib/agent_watch.py +732 -0
  7. ecadlib/altium365.py +385 -0
  8. ecadlib/altium_dblib_sync.py +530 -0
  9. ecadlib/altium_export.py +209 -0
  10. ecadlib/altium_forms.py +768 -0
  11. ecadlib/altium_lib.py +1089 -0
  12. ecadlib/altium_menu.py +1751 -0
  13. ecadlib/altium_pkg.py +396 -0
  14. ecadlib/altium_project.py +1095 -0
  15. ecadlib/app.py +1458 -0
  16. ecadlib/approvals.py +395 -0
  17. ecadlib/assets/demo_bom.csv +8 -0
  18. ecadlib/assets/kicad_action_plugin.py +607 -0
  19. ecadlib/assets/kicad_reread.py +60 -0
  20. ecadlib/assets/pcblib_template.json.gz +0 -0
  21. ecadlib/bom_inputs.py +339 -0
  22. ecadlib/bom_triage.py +519 -0
  23. ecadlib/cad_model.py +528 -0
  24. ecadlib/cfb.py +171 -0
  25. ecadlib/cli.py +185 -0
  26. ecadlib/clihelp.py +149 -0
  27. ecadlib/cliutil.py +119 -0
  28. ecadlib/completion.py +173 -0
  29. ecadlib/costs.py +276 -0
  30. ecadlib/crosscheck.py +412 -0
  31. ecadlib/datasheet.py +2168 -0
  32. ecadlib/dedupe.py +572 -0
  33. ecadlib/detect.py +170 -0
  34. ecadlib/doctor.py +450 -0
  35. ecadlib/ds_route.py +541 -0
  36. ecadlib/eagle_export.py +1409 -0
  37. ecadlib/easy_setup.py +338 -0
  38. ecadlib/easyeda_convert.py +386 -0
  39. ecadlib/easyeda_pro_export.py +972 -0
  40. ecadlib/eval/__init__.py +0 -0
  41. ecadlib/eval/baselines/kicad-v1.json +404 -0
  42. ecadlib/eval/build_set.py +299 -0
  43. ecadlib/eval/cli.py +119 -0
  44. ecadlib/eval/gate.py +358 -0
  45. ecadlib/eval/golden/demo.txt +379 -0
  46. ecadlib/eval/golden/specs.txt +3120 -0
  47. ecadlib/eval/golden/testpack.txt +463 -0
  48. ecadlib/eval/golden.py +446 -0
  49. ecadlib/eval/replay/kicad-v1.json.gz +0 -0
  50. ecadlib/eval/runner.py +456 -0
  51. ecadlib/eval/score.py +179 -0
  52. ecadlib/eval/sets/kicad-v1.json +630 -0
  53. ecadlib/firstrun.py +400 -0
  54. ecadlib/footprint_adv.py +1006 -0
  55. ecadlib/footprint_bench.py +218 -0
  56. ecadlib/footprint_check.py +562 -0
  57. ecadlib/footprint_gen.py +582 -0
  58. ecadlib/footprint_rules.py +1092 -0
  59. ecadlib/forge.py +267 -0
  60. ecadlib/fp_share.py +95 -0
  61. ecadlib/fp_tidy.py +389 -0
  62. ecadlib/fsutil.py +62 -0
  63. ecadlib/house_style.py +1051 -0
  64. ecadlib/httpcache.py +124 -0
  65. ecadlib/hub.py +1490 -0
  66. ecadlib/hub_copy.py +161 -0
  67. ecadlib/hub_tools.py +79 -0
  68. ecadlib/kicad_dblib.py +257 -0
  69. ecadlib/kicad_export.py +837 -0
  70. ecadlib/kicad_import.py +913 -0
  71. ecadlib/kicad_index.py +160 -0
  72. ecadlib/kicad_menu.py +193 -0
  73. ecadlib/kicad_pcm.py +475 -0
  74. ecadlib/launcher.py +156 -0
  75. ecadlib/librarian.py +963 -0
  76. ecadlib/lifecycle.py +691 -0
  77. ecadlib/llm/__init__.py +276 -0
  78. ecadlib/llm/cli.py +70 -0
  79. ecadlib/llm/openai_compat.py +214 -0
  80. ecadlib/llm/profiles.py +245 -0
  81. ecadlib/llm/rails.py +570 -0
  82. ecadlib/llm/run.py +126 -0
  83. ecadlib/llm/sampling.py +49 -0
  84. ecadlib/mcp_server.py +723 -0
  85. ecadlib/messages.py +419 -0
  86. ecadlib/migrate.py +407 -0
  87. ecadlib/notify.py +329 -0
  88. ecadlib/package_schema.json +1238 -0
  89. ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
  90. ecadlib/package_specs/C_0402_1005Metric.json +22 -0
  91. ecadlib/package_specs/C_0805_2012Metric.json +22 -0
  92. ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
  93. ecadlib/package_specs/D_SMA.json +27 -0
  94. ecadlib/package_specs/D_SMB.json +27 -0
  95. ecadlib/package_specs/D_SOD-123.json +43 -0
  96. ecadlib/package_specs/D_SOD-323.json +43 -0
  97. ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
  98. ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
  99. ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
  100. ecadlib/package_specs/L_0805_2012Metric.json +22 -0
  101. ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
  102. ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
  103. ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
  104. ecadlib/package_specs/R_0402_1005Metric.json +22 -0
  105. ecadlib/package_specs/R_0603_1608Metric.json +22 -0
  106. ecadlib/package_specs/R_0805_2012Metric.json +22 -0
  107. ecadlib/package_specs/R_1206_3216Metric.json +22 -0
  108. ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
  109. ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
  110. ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
  111. ecadlib/package_specs/SOT-23-5.json +57 -0
  112. ecadlib/package_specs/SOT-23-6.json +62 -0
  113. ecadlib/package_specs/SOT-23.json +48 -0
  114. ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
  115. ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
  116. ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  117. ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
  118. ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
  119. ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
  120. ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
  121. ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  122. ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  123. ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
  124. ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
  125. ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
  126. ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
  127. ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
  128. ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
  129. ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
  130. ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
  131. ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
  132. ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
  133. ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
  134. ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
  135. ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
  136. ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
  137. ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
  138. ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
  139. ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
  140. ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
  141. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
  142. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
  143. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
  144. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
  145. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
  146. ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
  147. ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
  148. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
  149. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
  150. ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
  151. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
  152. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
  153. ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
  154. ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
  155. ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
  156. ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
  157. ecadlib/package_specs/standard.json.gz +0 -0
  158. ecadlib/parity.py +1204 -0
  159. ecadlib/parts_export.py +154 -0
  160. ecadlib/partview.py +215 -0
  161. ecadlib/plugins.py +231 -0
  162. ecadlib/providers.py +226 -0
  163. ecadlib/rebuild.py +471 -0
  164. ecadlib/release_check.py +437 -0
  165. ecadlib/reports.py +274 -0
  166. ecadlib/resolve.py +556 -0
  167. ecadlib/review_cli.py +292 -0
  168. ecadlib/rules.py +979 -0
  169. ecadlib/rules_infer.py +1205 -0
  170. ecadlib/sexpr.py +149 -0
  171. ecadlib/sheetsafe.py +65 -0
  172. ecadlib/sim_models.py +1087 -0
  173. ecadlib/sourcing.py +1161 -0
  174. ecadlib/stdparts.py +755 -0
  175. ecadlib/step_gen.py +929 -0
  176. ecadlib/symbol_layout.py +798 -0
  177. ecadlib/team.py +558 -0
  178. ecadlib/team_setup.py +288 -0
  179. ecadlib/term.py +420 -0
  180. ecadlib/term_views.py +64 -0
  181. ecadlib/testpack.py +334 -0
  182. ecadlib/ui/__init__.py +1 -0
  183. ecadlib/ui/__main__.py +3 -0
  184. ecadlib/ui/server.py +1029 -0
  185. ecadlib/ui/static/hairline.js +2 -0
  186. ecadlib/ui/static/index.html +2098 -0
  187. ecadlib/ui/static/setup.html +294 -0
  188. ecadlib/upkeep.py +717 -0
  189. ecadlib/upstream.py +573 -0
  190. ecadlib/userconfig.py +257 -0
  191. ecadlib/visual_check.py +1603 -0
  192. ecadlib/ws_status.py +203 -0
  193. ecadlib-0.3.0.dist-info/METADATA +2245 -0
  194. ecadlib-0.3.0.dist-info/RECORD +199 -0
  195. ecadlib-0.3.0.dist-info/WHEEL +5 -0
  196. ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
  197. ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
  198. ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
  199. 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())